Richard_Yi

Richard_Yi 查看完整档案

杭州编辑浙江工业大学  |  非计算机 编辑某某有限公司  |  Java开发 编辑 ricstudio.top 编辑
编辑

个人主页:https://ricstudio.top

座右铭:吾辈采石之人,当心怀大教堂之愿景

佛系求关注。

技术兴趣:热衷微服务、分布式技术;

关键词:重度猫瘾、健身、读书、生活;

Please let me know if my answer helps you out.
如果我的答案对你有用,请让我知道。

个人动态

Richard_Yi 发布了文章 · 3月21日

面向实际的单测完整解决方案分享

前言

本文整理自前不久在组内组织的一次单元测试分享。

背景主要是后续我们的持续集成流程中会增加单测覆盖率这个一个卡点,大家之后需要慢慢将手头上的服务的单测补充起来。然后就发现组里的人对单测这个事情的理解有很大的偏差,并且有些人不知道怎么去写单测。所以就有了这么一次分享。

本文大纲如下:

  • 为什么要进行单元测试
  • 怎么做单元测试(术与道)
    • 单测之道
    • 单测之术
    • Spock - 一站式的单元测试利器

      • TestableMock - 一款特立独行的轻量Mock工具
  • 一些思想误区
  • 结语

为什么要写单元测试

首先我们要搞清楚写单元测试的目的是什么?

单测的目的:尽早尽量小的范围内暴露错误

作为开发人员,我们都应该知道,越早发现的缺陷,其修复成本是越低的。

另外注意尽量小的范围这个描述,这个意味着一个单测方法的关注点应该尽量最小粒度的,即理想情况下,一个单测方法应该对应功能类的一个方法的一个逻辑分支(logic branch)。

另外,我们再谈谈单元测试的好处,包括但不限于:

  • 提升软件质量

    优质的单元测试可以保障开发质量和程序的鲁棒性。越早发现的缺陷,其修复的成本越低。

  • 促进代码优化

    单元测试的编写者和维护者都是开发工程师,在这个过程当中开发人员会不断去审视自己的代码,从而(潜意识)去优化自己的代码。

  • 提升研发效率

    编写单元测试,表面上是占用了项目研发时间,但是在后续的联调、集成、回归测试阶段,单测覆盖率高的代码缺陷少、问题已修复,有助于提升整体的研发效率。

  • 增加重构自信

    代码的重构一般会涉及较为底层的改动,比如修改底层的数据结构等,上层服务经常会受到影响;在有单元测试的保障下,我们对重构出来的代码会多一份底气。

像我们这次的背景就是强调要提升整个研发团队的研发效能,单测覆盖率作为必不可少的一环,也就加到了我们的持续集成流程中。

单测之道

基本原则和基本要求

这部分的讲述主要是为了强调写单测时的一些注意点,纠正一些同学对单测的错误写法和认知。

首先就是 AIR 原则。在包括阿里开发手册等很多文章和规约中关于单元测试的要求里面都提到了这个原则。

单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试质量的保障上,却是非常关键的。

A:Automatic(自动化)

单元测试应该是全自动执行的,并且非交互式的。测试框架通常是定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。

单测要能报错,单元测试中不准使用System.out来进行人肉验证,必须使用assert来验证。

有些同学不喜欢用Assert,而喜欢在test case中写个System.out.println,人肉观察一下结果,确定结果是否正确。这种写法根本不是单测,原因是即使当时被测试代码是正确的,后续这些代码还有可能被修改,而一旦这些代码被改错了。println根本不会报错,测试正常通过只会带来虚假的自信心,这种所谓的"单测"连暴露错误的作用都起不到,根本就不应该存在。

I:Independent(独立性)

保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。

反例:method2需要依赖method1的执行,将执行结果做为method2的输入。。

R:Repeatable(可重复)

单元测试是可以重复执行的,不能受到外界环境的影响。单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。

除了这上面的 AIR 原则,关于单测还有几点需求:

要有强度

单测的结果校验靠的是最后的assert断言,断言需要反映对应功能分支的明确需求才能有强度。

举个简单的例子,假设有以下的待测方法:

public class FileInfoService {

    public FileInfoDTO singleQuery(Long id) {
        // 实现
    }
}

以下的断言强度就显得较弱,只对文件信息是否为Null做了判断。

(我使用的是spock测试框架,后面会具体说)

    @Issue("单笔查询 - 对应id存在时")
    def "test SingleQuery when_id_exists"() {
        given: "一个存在的文件id"
        Long existId = 1

        when:
        FileInfoDTO fileInfoDTO = fileInfoService.singleQuery(existId)

        then: "判断文件信息是否查询正确"
        fileInfoDTO != null
    }

正确的断言应该到判断具体字段是否正确的强度:

    // 假设测试库中已经插入了 id=1 的文件信息
    @Issue("单笔查询 - 对应id存在时")
  def "test SingleQuery_id_exists"() {
        given: "一个存在的文件id"
        Long existId = 1

        when:
        FileInfoDTO fileInfoDTO = fileInfoService.singleQuery(existId)

        then: "判断文件信息是否查询正确"
            fileInfoDTO != null
        Objects.equals(fileInfoDTO.fileName, "***.png")
    }

覆盖率保证

强度是指单元测试中对结果的验证要全面,覆盖度则是指测试用例本身的设计要覆盖被测试程序(SUT, Sysem Under Test)尽可能多的逻辑。只有覆盖度和强度都比较高才能较好的实现单测的目的。

按照测试理论,SUT的覆盖度分为方法覆盖度,行覆盖度,分支覆盖度和组合分支覆盖度几类。不同的系统对单测覆盖度的要求不尽相同,但这是有底线的。一般来说,程序配套的 单测至少要达到>80%的方法覆盖以及>60%的行覆盖 ,才能起到"看门狗"的作用,也才是有维护价值的单测。

粒度要小

和集成测试不同,单元测试的粒度一定要小,只有粒度小才能在出错时尽快定位到出错的地点。单测的粒度最大是类,一般是方法。单测不负责检查跨类或者跨系统的交互逻辑 , 那都是集成测试的范围。

通俗的说,程序员写单测的目的是"擦好自己的屁股",把自己的代码从实现中隔离出来,在集成测试前先保证自己的代码没有逻辑问题。至于集成测试乃至其它测试中暴露出来的接口理解不一致或者性能问题,那都不在单元测试的范围内。

速度要快

作为"看门狗",最好是在每次代码有修改时都运行单元测试,这样才能尽快的发现问题。这就要求单元测试的运行一定要快。一般要求 单个测试的运行时间不超过3秒 , 而整个项目的单测时间控制在3分钟之内,这样才能在持续集成中尽快暴露问题。

上面只是一般性要求,具体情况当然需要具体分析对待

单测不仅仅是给持续集成跑的,跑测试有时更多的是程序员本身, 单测速度和程序员跑单测的意愿成反比 ,如果单测只要5秒,程序员会经常跑单测,去享受一下全绿灯的满足感,可如果单测要跑5分钟,能在提交前跑一下单测就不错了。

比如上面这种全绿灯的“快感”。

单测之术

前面讲了这么多的理论,可能各位看官们都看厌了,下面我们以 Spring boot 开发为例,就讲一些具体的技术方案。

测试框架 - Spock

首先是单测的技术框架选型。我这里选用的是spock,而不是大家常用的Junit。

我这边就拿 Junit5 和 Spock 对比两个例子。其他的可以看附录中的扩展资料。

可读性和维护性方面

spock 的单测结构是基于一种given-when-then的句式结构,这种概念来源于BDD。简而言之,它统一了测试的创建,提高了测试的可读性,并使编写起来更容易,尤其是对于经验不足的人。

比如这是一段用spock写的单测代码:

class SimpleCalculatorSpec extends Specification {
    def "should add two numbers"() {
        given: "create a calculater instance"
            Calculator calculator = new Calculator()
        when: "get calculating result via the calculater"
            int result = calculator.add(1, 2)
        then: "assert the result is right"
            result == 3
    }
}

用 junit 写是什么样的:

class SimpleCalculatorTest {
    @Test
    void shouldAddTwoNumbers() {
        //given
        Calculator calculator = new Calculator();
        //when
        int result = calculator.add(1, 2);
        //then
        assertEquals(3, result);
    }
}

在junit中,你只能用注释表达你的意图。

写的快

其实对于大多数人而言(包括我),写单测都是一件相对痛苦的事情,因为单测的代码量绝对不会比你对应功能类的代码量要少。写单测已经如此痛苦了,为什么不能让这件事情稍稍变得舒服一点?

比如异常断言,我们为了断言的强度,我们有时不止要判断是否抛出对应异常还要判断异常的属性。这是junit5的写法:

@Test
void shouldThrowBusinessExceptionOnCommunicationProblem() {
    //when
    Executable e = () -> client.sendPing(TEST_REQUEST_ID)
    //then
    CommunicationException thrown = assertThrows(CommunicationException.class, e);
    assertEquals("Communication problem when sending request with id: " + TEST_REQUEST_ID, thrown.getMessage());
    assertEquals(TEST_REQUEST_ID, thrown.getRequestId());
}

这是spock的写法:

def "should capture exception"() {
    when:
        client.sendPing(TEST_REQUEST_ID)
    then:
        def e = thrown(CommunicationException)
        e.message == "Communication problem when sending request with id: $TEST_REQUEST_ID"
        e.requestId == TEST_REQUEST_ID
}

还有就是我们最常用的mock,Junit5 中内置了mockito 这个mock工具。

@Test
public void should_not_call_remote_service_if_found_in_cache() {
    //given
    given(cacheMock.getCachedOperator(CACHED_MOBILE_NUMBER)).willReturn(Optional.of(PLUS));
    //when
    service.checkOperator(CACHED_MOBILE_NUMBER);
    //then
    then(webserviceMock).should(never()).checkOperator(CACHED_MOBILE_NUMBER);
//   verify(webserviceMock, never()).checkOperator(CACHED_MOBILE_NUMBER);   //alternative syntax
}

spock框架内置了一个mock子系统,提供mock相关的功能:

def "should not hit remote service if found in cache"() {
    given:
        cacheMock.getCachedOperator(CACHED_MOBILE_NUMBER) >> Optional.of(PLUS)
    when:
        service.checkOperator(CACHED_MOBILE_NUMBER)
    then:
        0 * webserviceMock.checkOperator(CACHED_MOBILE_NUMBER)
}

可以看到,虽然 mockito的语法非常可读(given/thenReturn/thenAnswer/...),但是你不会觉得,我只是想mock一下这个方法的调用,为什么要写如此多的代码?

关于spock 和 junit5 的进一步对比,大家可以阅读这篇文章spock-vs-junit-5,上述部分的对比代码也节选于此;

另外关于spock的使用,具体可以参照我写的一篇文章:Spock in Java 慢慢爱上写单元测试;当然官方文档是更好的选择:

mock 工具 - TestableMock

到这里可能会有人说了,spock不是内置mock工具吗,为啥还要单独再说mock工具的事情。主要spock mock和 mockito 一样,并不全面。

比如我想mock静态方法,spock就做不到了。在mock全能方面,第一个想到的应该是 powermock,但是呢,powermock 用在 spock 上面有兼容性的问题,我google了很多资料但是都没有解决。并且引入的依赖项都十分复杂,所以我就放弃了powermock。转向了 TestableMock 这款阿里开源的mock工具。

官方并没有说明这个框架可以和spock结合使用,但是按照其mock的原理和实际测试下来,是可以在spock中使用的。

我的场景是我的待测方法中引入了 javax.imageio.ImageIO 这个类,我并不想实际调用的它的read(InputStream input) 方法,因为我没有真实的图片输入流。所以我要把它mock掉。

step1 引入依赖

<dependency>
    <groupId>com.alibaba.testable</groupId>
    <artifactId>testable-all</artifactId>
    <version>${testable.version}</version>
    <scope>test</scope>
</dependency>

step 2 声明mock容器

mock 容器算是 TestableMock 里面的概念。在对应测试类中再申明一个静态类


    static class Mock {

        // 放置Mock方法的地方
        @MockMethod(targetClass = ImageIO.class)
        private static BufferedImage read(InputStream input) throws IOException {
          // 借用MOCK_CONTEXT来判断不同的测试case
            switch ((String) MOCK_CONTEXT.get("case")) {
                case "normal":
                    return new BufferedImage(100, 200, 1)
                case "error":
                    throw new IOException()
                default:
                    return null
            }
        }

    }

MOCK_CONTEXT 主要是为了不同的输出场景。

step 3 编写对应测试case的代码

    def "test createFileInfo pic normal"() {

        when:
        //... logic
          
        // 确定mock的case
        MOCK_CONTEXT.put("case", "normal");

        FileInfo fileInfo = fileService.createFileInfo(********)
      
        then:
        fileInfo.width == 100
        fileInfo.height == 200
    }

数据层(DAO)层测试

DAO层测试,是我在分享的时候唯一建议同学们启动 spring 容器测试的情况。

我们集团内部使用的是 MyBatis Plus,在实际场景中,DAO对数据库的操作依赖于mybatis的sql mapper 文件或者基于MyBatis Plus的动态sql,在单测中验证所有sql 逻辑的正确性非常重要,在DAO层有足够的覆盖度和强度后,Service层的单测才能仅仅关注自身的业务逻辑。

为了验证,我们需要一个能实际运行的数据库。为了提高速度和减少依赖,可以使用内存数据库。下面的方案中就使用 H2 作为单测数据库。

step1 引入依赖

        <!-- h2database -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>1.4.197</version>
            <scope>test</scope>
        </dependency>

                
                <!-- spock相关 -->
        <!-- https://mvnrepository.com/artifact/org.spockframework/spock-core -->
        <dependency>
            <groupId>org.spockframework</groupId>
            <artifactId>spock-core</artifactId>
            <version>1.1-groovy-2.4</version>
            <scope>test</scope>
        </dependency>

                <!-- 在spock中集成spring容器测试 -->
        <dependency>
            <groupId>org.spockframework</groupId>
            <artifactId>spock-spring</artifactId>
            <version>1.1-groovy-2.4</version>
            <scope>test</scope>
        </dependency>

        <!-- enables mocking of classes without default constructor -->
        <dependency>
            <groupId>org.objenesis</groupId>
            <artifactId>objenesis</artifactId>
            <version>2.6</version>
            <scope>test</scope>
        </dependency>

step2 准备初始化sql

在测试资源目录 src/test/resource 下新建 db/{your_module}.sql ,其中的内容是需要初始化的建表语句,也可以包括部分初始记录的dml语句,至于怎么导,很多数据库工具可以实现。如果表结构发生了更改,需要人工重新导出。

step3 准备测试类

@Title("Dao测试")
@ContextConfiguration(classes = [DaoTestConfiguration.class])
class FileInfoDaoTest extends Specification {
      @Autowired
    private FileInfoService fileInfoService
}

@ContextConfiguration(classes = [DaoTestConfiguration.class])这个注解很关键,他是spring-test模块的注解,通过这个注解可以配置spring容器在启动时的上下文。如果你不指定,那一般来说就默认是你Spring Boot 的 Application 类决定的上下文。

再来看看DaoTestConfiguration.class里面做了什么配置:

@Configuration
@ComponentScan(basePackages = {"com.***.***.dao"})
@MapperScan({"com.****.***.mapper"})
@SpringBootApplication(exclude = {FeignAutoConfiguration.class, ApolloAutoConfiguration.class,***.class,***.class,
        Swagger2AutoConfiguration.class, FeignRibbonClientAutoConfiguration.class,
        ManagementContextAutoConfiguration.class, JmxAutoConfiguration.class,
        BeansEndpointAutoConfiguration.class, TaskExecutionAutoConfiguration.class,
        TaskExecutionAutoConfiguration.class, AuditAutoConfiguration.class,
        LoadBalancerAutoConfiguration.class, RefreshEndpointAutoConfiguration.class, HystrixAutoConfiguration.class,
        RibbonAutoConfiguration.class, ConsulDiscoveryClientConfiguration.class})
public class DaoTestConfiguration {

    @Bean
    public DataSource dataSource() {
        EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder();
        return builder.setType(EmbeddedDatabaseType.H2).addScript("classpath:db/***.sql").build();
    }

}

这个类里面关键点主要有几个:

  • @SpringBootApplication(exclude = {})

    通过这个注解,把一些自动装配的类给排除掉了。比如我的项目集成了Apollo,swagger等组件,还有我们的微服务框架是在 spring cloud 基础上自研的,项目里面也做了很多自动装配类的处理,这些在单元测试的环节都可以说是无用的,增加单测的启动时间不说,甚至会让你的单测启都起不起来。

    还记得我们上面的原则吗,单测要尽可能得对外部环境没有依赖。所以,去掉这些自动装配类是很必要的。当然,这一块需要具体项目具体分析。因为不同项目引入的组件不同,另外还要小心别把spring boot自身启动需要的自动装配类给排了,也会导致起不来。

  • @ComponentScan(basePackages = {"com.***.***.dao"})

    因为我们只测Dao层,所以只需要扫描Dao层的类即可。

  • 配置 h2 数据源,指定初始化sql脚本

剩下的工作就是编写具体的测试代码即可。

    def "UpdateFileSize"() {
        given:
        FileInfo fileInfo = fileInfoDao.getById(1)
        def size = fileInfo.fileSize + 1

        when:
        fileInfoDao.updateFileSize(fileInfo.id, size)

        then: "size更新成功"
        fileInfoDao.getById(1).fileSize == size
    }

其他类的测试

对于除数据库以外的依赖,包括消息中间件、缓存等中间件以及外部的微服务、第三方API,在单测中全部采用Mock进行解耦。

我想了想,还是在这演示下一个基于多个服务依赖的功能类应该怎么测试。

假设有这个一个类,他的功能实现依赖于3个外部服务(你可以想象成一个feign client、dubbo provider、thrid api服务)

/**
 * 根据黑名单和白名单确认用户是否能访问
 */
@Service
public class AccessService {

    @Autowired
    private UserService userService;

    @Autowired
    private BlackListProvider blackListProvider;

    @Autowired
    private WhiteListProvider whiteListProvider;


    /**
     * 返回指定用户是否能够访问
     * @param userId 用户ID
     *
     * @return
     */
    public boolean canAccess(Long userId) {

        List<Long> whiteListProviderList = whiteListProvider.provideUserIdWhiteList();
        if (whiteListProviderList.contains(userId)) {
            return true;
        } else {
            String name = userService.findNameById(userId);
            return !blackListProvider.provideUserNameBlackList().contains(name);
        }
    }
}

编写对应测试类如下:


@Subject(AccessService)
class AccessServiceTest extends Specification {

    def "test canAccess"() {

        given: "准备数据和mock"
        // mock 外部服务
        UserService userService = Mock()
        WhiteListProvider whiteListProvider = Mock()
        BlackListProvider blackListProvider = Mock()

        // 初始化
        AccessService accessService = new AccessService()
        // 赋值
        accessService.userService = userService;
        accessService.whiteListProvider = whiteListProvider;
        accessService.blackListProvider = blackListProvider;
        // 有人可能会奇怪,这三个服务不是都是私有变量吗,可以这么赋值吗?
        // 对,groovy中就是可以这么方便!

        and: "打桩"
        // 打桩是mock中的另一个名词,意思就是指定对应输入时返回对应的输出
        // user服务传入1L/2L/3L/4L,返回tom/jerry/anna/lucky
        userService.findNameById(1L) >> "tom"
        userService.findNameById(2L) >> "jerry"
        userService.findNameById(3L) >> "anna"
        userService.findNameById(4L) >> "lucky"

        // 白名单包含jerry,anna和lucky
        whiteListProvider.provideUserIdWhiteList() >> [2L, 3L, 4L]

        // 黑名单包含 "tom","jerry","peter"
        blackListProvider.provideUserNameBlackList() >> ["tom", "jerry", "peter"]


        expect:
        result == accessService.canAccess(userId)

        where:
        userId | result
        // 在黑名单里,不在白名单里,不能访问
        1L | false
        // 在黑名单里,也在白名单里,可以访问
        2L | true
        // 不在黑名单里,在白名单里,可以访问
        3L | true
        // 不在黑名单里,也不在白名单里,可以访问
        4L | true
    }

}

这里不展开讲spock的语法。

一些思想误区

补单测

补单测是很有责任心的表现,但还是要说 单测应该随着代码同时产生,而不应该是补出来的

当一段代码(一个类或者一个方法)刚被写出来的时候,开发对整个上下文非常清楚,要测试什么逻辑也很明确(单测是白盒测试),这时候写单测速度最快,也最容易设计出高强度的单元测试。如果等一次产出N个类,上千行代码再去写单测,很多当时的上下文都已经遗忘了,而且惰性会使人面对大量工作时产生畏难情绪,这时写的单测质量就比较差了。至于为几个月甚至几年前的代码写单测,基本上除了大规模重构,是没人愿意去写的。

在测试前置这方面最激进的尝试是TDD (Test Driven Development),其次是TFD (Test First Development),它们都要求单测在代码前完成。尽管这两个实践目前不是很流行,但还是推荐有兴趣的同学去尝试一下TDD,经过TDD熏陶的代码会自然的觉得单元测试是程序的一部分,对于这点理解也会更深。

项目紧,没时间写单测

这也是没有写单测习惯的开发经常会说的话。

再紧的项目都要有设计、编码、测试和发布这些环节,如果说项目紧不写单测,看起来编码阶段省了一些时间,但如果存在问题,必然会在测试和线上花掉成倍甚至更多的成本来修复。

也就是说,可能从开发的角度,你的工作是因为不写单测按时完成了,但是从整体项目/功能的交付时间来看,却不是这样。所以如果你的团队是个懂得单测重要性的团队,就应该在评估开发时间的把单测的时间考虑进去。

错误率是恒定的,需要的调试量也是固定的,用测试甚至线上环境调试并不能降低调试的量,只会降低调试效率。

单测是QA的工作

这是混淆了单元测试和集成测试的边界。

单元测试是白盒测试,应该随着代码一起产出,一起修改。单元测试的目的是让程序员"擦干净自己的屁股",保证相对小的模块确实在按照设计目标工作。单元测试需要代码和程序同时变动,不要说QA,就是换个开发写单测都赶不上这个节奏(除非结对编程)。所以单元测试一定是开发的工作。

集成测试是黑盒测试,一般是端到端的测试,很大的工作量在维护上下游环境的兼容上。集成测试运行的频率也比单元测试低,这部分工作由QA来作还是可以接受的。

结语

本文讲述了单测的术和道,介绍了单测的一些道理和具体的技术方案。文中提到的spock并非是强硬性要求,只是我个人偏好而已。其实用junit写也是能写的,只是代码量会比较多。看你哪个用的熟。

我个人觉得关键的是在于树立起对单测的正确认知,在实际操作中做到文中提到的几个原则和要求。越是重要的项目,我越推荐你写单元测试。单元测试就是我们程序员的救生圈,在代码的海洋中为程序员提供安全感。有了单元测试的保障,程序员才有信心在约定时间内完成联调和发布,才敢对已有的程序作修改和重构而不担心引入新问题。

参考文章

查看原文

赞 2 收藏 1 评论 3

Richard_Yi 发布了文章 · 2020-10-30

Elasticsearch 如何做到快速检索 - 倒排索引的秘密

"All problems in computer science can be solved by another level of indirection.”

– David J. Wheeler

“计算机世界就是 trade-off 的艺术”

一、前言

最近接触的几个项目都使用到了 Elasticsearch (以下简称 ES ) 来存储数据和对数据进行搜索分析,就对 ES 进行了一些学习。本文整理自我自己的一次技术分享。

本文不会关注 ES 里面的分布式技术、相关 API 的使用,而是专注分享下 ”ES 如何快速检索“ 这个主题上面。这个也是我在学习之前对 ES 最感兴趣的部分。


本文大致包括以下内容:

  • 关于搜索

    • 传统关系型数据库和 ES 的差别
    • 搜索引擎原理
  • 细究倒排索引

    • 倒排索引具体是个什么样子的(posting list -> term dic -> term index)
    • 关于 postings list 的一些巧技 (FOR、Roaring Bitmaps)
    • 如何快速做联合查询?

二、关于搜索

先设想一个关于搜索的场景,假设我们要搜索一首诗句内容中带“前”字的古诗,

用 传统关系型数据库和 ES 实现会有什么差别?

如果用像 MySQL 这样的 RDBMS 来存储古诗的话,我们应该会去使用这样的 SQL 去查询

select name from poems where content like "%前%";

这种我们称为顺序扫描法,需要遍历所有的记录进行匹配。

不但效率低,而且不符合我们搜索时的期望,比如我们在搜索“ABCD"这样的关键词时,通常还希望看到"A","AB","CD",“ABC”的搜索结果。

于是乎就有了专业的搜索引擎,比如我们今天的主角 -- ES。

搜索引擎原理

搜索引擎的搜索原理简单概括的话可以分为这么几步,

  • 内容爬取,停顿词过滤

    比如一些无用的像"的",“了”之类的语气词/连接词

  • 内容分词,提取关键词
  • 根据关键词建立倒排索引
  • 用户输入关键词进行搜索

这里我们就引出了一个概念,也是我们今天的要剖析的重点 - 倒排索引。也是 ES 的核心知识点。

如果你了解 ES 应该知道,ES 可以说是对 Lucene 的一个封装,里面关于倒排索引的实现就是通过 lucene 这个 jar 包提供的 API 实现的,所以下面讲的关于倒排索引的内容实际上都是 lucene 里面的内容。

三、倒排索引

首先我们还不能忘了我们之前提的搜索需求,先看下建立倒排索引之后,我们上述的查询需求会变成什么样子,

这样我们一输入“前”,借助倒排索引就可以直接定位到符合查询条件的古诗。

当然这只是一个很大白话的形式来描述倒排索引的简要工作原理。在 ES 中,这个倒排索引是具体是个什么样的,怎么存储的等等,这些才是倒排索引的精华内容。

1. 几个概念

在进入下文之前,先描述几个前置概念。

term

关键词这个东西是我自己的讲法,在 ES 中,关键词被称为 term

postings list

还是用上面的例子,{静夜思, 望庐山瀑布}是 "前" 这个 term 所对应列表。在 ES 中,这些被描述为所有包含特定 term 文档的 id 的集合。由于整型数字 integer 可以被高效压缩的特质,integer 是最适合放在 postings list 作为文档的唯一标识的,ES 会对这些存入的文档进行处理,转化成一个唯一的整型 id。

再说下这个 id 的范围,在存储数据的时候,在每一个 shard 里面,ES 会将数据存入不同的 segment,这是一个比 shard 更小的分片单位,这些 segment 会定期合并。在每一个 segment 里面都会保存最多 2^31 个文档,每个文档被分配一个唯一的 id,从0(2^31)-1

相关的名词都是 ES 官方文档给的描述,后面参考材料中都可以找到出处。

2. 索引内部结构

上面所描述的倒排索引,仅仅是一个很粗糙的模型。真的要在实际生产中使用,当然还差的很远。

在实际生产场景中,比如 ES 最常用的日志分析,日志内容进行分词之后,可以得到多少的 term?

那么如何快速的在海量 term 中查询到对应的 term 呢?遍历一遍显然是不现实的。

term dictionary

于是乎就有了 term dictionary,ES 为了能快速查找到 term,将所有的 term 排了一个序,二分法查找。是不是感觉有点眼熟,这不就是 MySQL 的索引方式的,直接用 B+树建立索引词典指向被索引的数据。

term index

但是问题又来了,你觉得 Term Dictionary 应该放在哪里?肯定是放在内存里面吧?磁盘 io 那么慢。就像 MySQL 索引就是存在内存里面了。

但是如果把整个 term dictionary 放在内存里面会有什么后果呢?

内存爆了...

别忘了,ES 默认可是会对全部 text 字段进行索引,必然会消耗巨大的内存,为此 ES 针对索引进行了深度的优化。在保证执行效率的同时,尽量缩减内存空间的占用。

于是乎就有了 term index

Term index 从数据结构上分类算是一个“Trie 树”,也就是我们常说的字典树。这是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。

这棵树不会包含所有的 term,它包含的是 term 的一些前缀(这也是字典树的使用场景,公共前缀)。通过 term index 可以快速地定位到 term dictionary 的某个 offset,然后从这个位置再往后顺序查找。就想右边这个图所表示的。(怎么样,像不像我们查英文字典,我们定位 S 开头的第一个单词,或者定位到 Sh 开头的第一个单词,然后再往后顺序查询)

lucene 在这里还做了两点优化,一是 term dictionary 在磁盘上面是分 block 保存的,一个 block 内部利用公共前缀压缩,比如都是 Ab 开头的单词就可以把 Ab 省去。二是 term index 在内存中是以 FST(finite state transducers)的数据结构保存的。

FST 有两个优点:

  • 空间占用小。通过对词典中单词前缀和后缀的重复利用,压缩了存储空间
  • 查询速度快。O(len(str)) 的查询时间复杂度。
FST 的理论比较复杂,本文不细讲

延伸阅读:https://www.shenyanchao.cn/bl...

OK,现在我们能得到 lucene 倒排索引大致是个什么样子的了。

四、关于 postings list 的一些巧技

在实际使用中,postings list 还需要解决几个痛点,

  • postings list 如果不进行压缩,会非常占用磁盘空间,
  • 联合查询下,如何快速求交并集(intersections and unions)

对于如何压缩,可能会有人觉得没有必要,”posting list 不是已经只存储文档 id 了吗?还需要压缩?”,但是如果在 posting list 有百万个 doc id 的情况,压缩就显得很有必要了。(比如按照朝代查询古诗?),至于为啥需要求交并集,ES 是专门用来搜索的,肯定会有很多联合查询的需求吧 (AND、OR)。

按照上面的思路,我们先将如何压缩。

1. 压缩

Frame of Reference

在 lucene 中,要求 postings lists 都要是有序的整形数组。这样就带来了一个很好的好处,可以通过 增量编码(delta-encode)这种方式进行压缩。

比如现在有 id 列表 [73, 300, 302, 332, 343, 372],转化成每一个 id 相对于前一个 id 的增量值(第一个 id 的前一个 id 默认是 0,增量就是它自己)列表是[73, 227, 2, 30, 11, 29]在这个新的列表里面,所有的 id 都是小于 255 的,所以每个 id 只需要一个字节存储

实际上 ES 会做的更加精细,

它会把所有的文档分成很多个 block,每个 block 正好包含 256 个文档,然后单独对每个文档进行增量编码,计算出存储这个 block 里面所有文档最多需要多少位来保存每个 id,并且把这个位数作为头信息(header)放在每个 block 的前面。这个技术叫 Frame of Reference

上图也是来自于 ES 官方博客中的一个示例(假设每个 block 只有 3 个文件而不是 256)。

FOR 的步骤可以总结为:

进过最后的位压缩之后,整型数组的类型从固定大小 (8,16,32,64 位)4 种类型,扩展到了[1-64] 位共 64 种类型。

通过以上的方式可以极大的节省 posting list 的空间消耗,提高查询性能。不过 ES 为了提高 filter 过滤器查询的性能,还做了更多的工作,那就是缓存

Roaring Bitmaps (for filter cache)

在 ES 中,可以使用 filters 来优化查询,filter 查询只处理文档是否匹配与否,不涉及文档评分操作,查询的结果可以被缓存。

对于 filter 查询,es 提供了 filter cache 这种特殊的缓存,filter cache 用来存储 filters 得到的结果集。缓存 filters 不需要太多的内存,它只保留一种信息,即哪些文档与 filter 相匹配。同时它可以由其它的查询复用,极大地提升了查询的性能。

我们上面提到的 Frame Of Reference 压缩算法对于 postings list 来说效果很好,但对于需要存储在内存中的 filter cache 等不太合适。

filter cache 会存储那些经常使用的数据,针对 filter 的缓存就是为了加速处理效率,对压缩算法要求更高。

对于这类 postings list,ES 采用不一样的压缩方式。那么让我们一步步来。

首先我们知道 postings list 是 Integer 数组,具有压缩空间。

假设有这么一个数组,我们第一个压缩的思路是什么?用位的方式来表示,每个文档对应其中的一位,也就是我们常说的位图,bitmap。

它经常被作为索引用在数据库、查询引擎和搜索引擎中,并且位操作(如 and 求交集、or 求并集)之间可以并行,效率更好。

但是,位图有个很明显的缺点,不管业务中实际的元素基数有多少,它占用的内存空间都恒定不变。也就是说不适用于稀疏存储。业内对于稀疏位图也有很多成熟的压缩方案,lucene 采用的就是roaring bitmaps

我这里用简单的方式描述一下这个压缩过程是怎么样,

将 doc id 拆成高 16 位,低 16 位。对高位进行聚合 (以高位做 key,value 为有相同高位的所有低位数组),根据低位的数据量 (不同高位聚合出的低位数组长度不相同),使用不同的 container(数据结构) 存储。

  • len<4096 ArrayContainer 直接存值
  • len>=4096 BitmapContainer 使用 bitmap 存储

分界线的来源:value 的最大总数是为2^16=65536. 假设以 bitmap 方式存储需要 65536bit=8kb,而直接存值的方式,一个值 2 byte,4K 个总共需要2byte*4K=8kb。所以当 value 总量 <4k 时,使用直接存值的方式更节省空间。

空间压缩主要体现在:

  • 高位聚合 (假设数据中有 100w 个高位相同的值,原先需要 100w*2byte,现在只要 1*2byte)
  • 低位压缩

缺点就在于位操作的速度相对于原生的 bitmap 会有影响。

这就是 trade-off 呀。平衡的艺术。

2. 联合查询

讲完了压缩,我们再来讲讲联合查询。

先讲简单的,如果查询有 filter cache,那就是直接拿 filter cache 来做计算,也就是说位图来做 AND 或者 OR 的计算。

如果查询的 filter 没有缓存,那么就用 skip list 的方式去遍历磁盘上的 postings list。

以上是三个 posting list。我们现在需要把它们用 AND 的关系合并,得出 posting list 的交集。首先选择最短的 posting list,逐个在另外两个 posting list 中查找看是否存在,最后得到交集的结果。遍历的过程可以跳过一些元素,比如我们遍历到绿色的 13 的时候,就可以跳过蓝色的 3 了,因为 3 比 13 要小。

用 skip list 还会带来一个好处,还记得前面说的吗,postings list 在磁盘里面是采用 FOR 的编码方式存储的

会把所有的文档分成很多个 block,每个 block 正好包含 256 个文档,然后单独对每个文档进行增量编码,计算出存储这个 block 里面所有文档最多需要多少位来保存每个 id,并且把这个位数作为头信息(header)放在每个 block 的前面。

因为这个 FOR 的编码是有解压缩成本的。利用 skip list,除了跳过了遍历的成本,也跳过了解压缩这些压缩过的 block 的过程,从而节省了 cpu

五、总结

下面我们来做一个技术总结(感觉有点王刚老师的味道😂)

  • 为了能够快速定位到目标文档,ES 使用倒排索引技术来优化搜索速度,虽然空间消耗比较大,但是搜索性能提高十分显著。
  • 为了能够在数量巨大的 terms 中快速定位到某一个 term,同时节约对内存的使用和减少磁盘 io 的读取,lucene 使用 "term index -> term dictionary -> postings list" 的倒排索引结构,通过 FST 压缩放入内存,进一步提高搜索效率。
  • 为了减少 postings list 的磁盘消耗,lucene 使用了 FOR(Frame of Reference)技术压缩,带来的压缩效果十分明显。
  • ES 的 filter 语句采用了 Roaring Bitmap 技术来缓存搜索结果,保证高频 filter 查询速度的同时降低存储空间消耗。
  • 在联合查询时,在有 filter cache 的情况下,会直接利用位图的原生特性快速求交并集得到联合查询结果,否则使用 skip list 对多个 postings list 求交并集,跳过遍历成本并且节省部分数据的解压缩 cpu 成本

Elasticsearch 的索引思路

将磁盘里的东西尽量搬进内存,减少磁盘随机读取次数 (同时也利用磁盘顺序读特性),结合各种压缩算法,用及其苛刻的态度使用内存。

所以,对于使用 Elasticsearch 进行索引时需要注意:

  • 不需要索引的字段,一定要明确定义出来,因为默认是自动建索引的
  • 同样的道理,对于 String 类型的字段,不需要 analysis 的也需要明确定义出来,因为默认也是会 analysis 的
  • 选择有规律的 ID 很重要,随机性太大的 ID(比如 Java 的 UUID) 不利于查询

最后说一下,技术选型永远伴随着业务场景的考量,每种数据库都有自己要解决的问题(或者说擅长的领域),对应的就有自己的数据结构,而不同的使用场景和数据结构,需要用不同的索引,才能起到最大化加快查询的目的。

这篇文章讲的虽是 Lucene 如何实现倒排索引,如何精打细算每一块内存、磁盘空间、如何用诡谲的位运算加快处理速度,但往高处思考,再类比一下 MySQL,你就会发现,虽然都是索引,但是实现起来,截然不同。笼统的来说,b-tree 索引是为写入优化的索引结构。当我们不需要支持快速的更新的时候,可以用预先排序等方式换取更小的存储空间,更快的检索速度等好处,其代价就是更新慢,就像 ES。

希望本篇文章能给你带来一些收获~

参考文档

查看原文

赞 8 收藏 5 评论 0

Richard_Yi 赞了文章 · 2020-05-12

Apache Dubbo 云原生服务自省架构设计

原文链接:Apache Dubbo 云原生服务自省架构设计,来自于微信公众号:次灵均阁

背景

随着微服务架构的推广和普及,服务之间的耦合度在逐步降低。在演化的过程中,伴随着应用组织架构的变化以及基础设施的衍进,服务和应用之间的边界变得更为模糊。Java 作为一门面向对象的编程语言,Java 接口(interface)作为服务之间通讯的一等公民,配合文档(JavaDoc)便于开发人员理解和维护。基于相同的编程哲学,Apache Dubbo 作为传统的 RPC 服务治理框架,通过接口实现分布式服务。然而对于微服务治理而言,应用(或“服务”)才是基础设施的核心要素。面对云原生(Cloud Native)技术的兴起,传统的 Dubbo 架构不断地面临着新的的挑战。下面内容将以 Apache Dubbo 2.7.5 为基础,介绍全新架构 - Apache Dubbo 服务自省(后文简称“服务自省”),了解 Dubbo 传统架构所面临的现实挑战,以及服务自省架构的设计和解决之道。

术语约定

  • Service:SOA 或微服务中的“服务”,或称之为“应用”,具有全局唯一的名称
  • Service Name: 服务名称,或应用名称
  • Servce Instance:服务实例,或称为应用实例(Application Instance),表示单个 Dubbo 应用进程
  • Registry:注册中心
  • Dubbo 服务:又称之为“Dubbo 业务服务”,包含 Java 接口、通讯协议,版本(version)和分组(group)等元信息
  • Dubbo 服务 ID:唯一鉴定 Dubbo 服务的元数据,用于 Dubbo 服务暴露(发布)和订阅
  • Provider:Dubbo 服务提供方
  • Consumer:Dubbo 服务消费方
  • Dubbo 服务暴露:也称之为 Dubbo 服务发布,或英文中的“export”、"exported"
  • Dubbo 应用服务:也称之为 Dubbo 业务服务,或业务 Dubbo 服务

使用场景

服务自省是 Dubbo 应用在运行时处理和分析 Dubbo 服务元信息(Metadata)的过程,如当前应用暴露 的Dubbo 服务以及各自的通讯协议等。期间会伴随着事件的广播和处理,如服务暴露事件。Dubbo 服务自省架构是其传统架的一种补充,更是未来 Dubbo 架构,它更适合以下使用场景:

  • 超大规模 Dubbo 服务治理场景
  • 微服务架构和元原生应用
  • Dubbo 元数据架构的基石

超大规模 Dubbo 服务治理场景

如果 Dubbo 集群规模超过一千以上,或者集群扩缩容已无法自如地执行,如 Zookeeper 管理数万 Dubbo 服务,服务自省可极大化减轻注册中心的压力,尤其在内存足迹、网络传输以及变更通知上体现。

微服务架构和元原生应用

如果想要 Dubbo 应用更好地微服务化,或者更接近于云原生应用,那么服务自省是一种不错的选择,它能够提供已应用为粒度的服务注册与发现模型,全面地支持最流行的 Spring Cloud 和 Kubernetes 注册中心,并且能与 Spring Cloud 或 Spring Boot 应用交互。

Dubbo 元数据架构的基石

Dubbo 元数据架构是围绕 Dubbo DevOps 而引入,包括 Dubbo 配置元数据(如:属性配置、路由规则等)和结构元数据(如:Java 注解、接口和文档等)。服务自省作为 Dubbo 元数据的基础设施,不仅支持所有元数据的存储和平滑升级,而且不会对注册中心、配置中心和元数据中心产生额外的负担。

传统架构

Apache Dubbo 是一款面向接口代理的高性能 RPC 框架,提供服务注册与发现的特性,其基础架构如下图所示:

image.png

(图 1)

  • Provider 为服务提供方,提供 Java 服务接口的实现,并将其元信息注册到 Dubbo 注册中心(过程 1.register 所示)
  • Consumer 为服务消费端,从 Dubbo 注册中心检索订阅的 Java 服务接口的元信息(过程 2.subscribe 所示),通过框架处理后,生成代理程序执行远程方法调用(过程 4.invoke 所示)
  • Registry 为注册中心,属于注册元信息中心化基础设施(如 Apache Zookeeper 或 Alibaba Nacos),为 Provider 提供注册通道,为 Cosumer 提供订阅渠道。同时,注册中心支持注册元信息变更通知,通知 Consumer 上游 Provider 节点的变化(如扩容或缩容)。而注册元信息均以 Dubbo URL 的形式存储
  • Monitor 为服务治理平台,提供开发和运维人员服务查询、路由规则、服务 Mock 和测试等治理能力

综上所述,Dubbo 注册与发现的对象是 Dubbo 服务(Java 接口),而其载体为注册元信息,即Dubbo URL,如:dubbo://192.168.1.2:20880/com.foo.BarService?version=1.0.0&group=default,通常包含必须信息,如服务提供方 IP 和端口、 Java 接口,可选包含版本(version)和分组(group)等。服务 URL 所包含的信息能够唯一界别服务提供方的进程。

现实挑战

为了更好地符合 Java 开发人员的编程习惯,Dubbo 以 Java 服务接口作为注册对象,所面临的现实挑战主要有:

  • 如何解决或缓解注册中心压力过载
  • 如何支持以应用为粒度的服务注册与发现
  • 如何精简 Dubbo URL 元数据

如何解决或缓解注册中心压力过载

注册中心内存压力

Dubbo 注册中心是中心化的基础设施,大多数注册中心的实现为内存型存储,比如 Zookeeper、Nacos 或 Consul、Eureka。注册中心的内存消耗与 Dubbo 服务注册的数量成正比,任一 Dubbo Provider 允许注册 N 个 Dubbo 服务接口,当 N 越大,注册中心的负载越重。根据不完全统计,Dubbo 核心 Provider 用通常会暴露 20 ~ 50 个服务接口。注册中心是中心化的基础设施,其稳定性面临严峻考验。尽管微服务架构不断地深化,然而现实情况是,更多开发者仍旧愿意在单一 Provider 上不断地增加 Dubbo 服务接口,而非更细粒度的 Dubbo Provider 组织。

注册中心网络压力

为了避免单点故障,主流的注册中心均提供高可用方案。为解决集群环境数据同步的难题,内建一致性协议,如 Zookeeper 使用的 Zab 协议,Consul 采用的 Raft 协议。无论哪种方式,当 Dubbo URL 数量变化频繁时,网络和 CPU 压力也会面临考验。如果注册中心与客户端之间维持长连接状态的话,如 Zookeeper,注册中心的网络负担会更大。

注册中心通知压力

假设某个 Dubbo Provider 注册了 N 个 Dubbo 服务接口,当它扩容或缩容 M 个实例(节点)时,N 数量越大,注册中心至少有 M * N 个 Dubbo URL 注册或移除。同时,大多数注册中心实现支持注册变化通知,如 Zookeeper 节点变化通知。当 Dubbo Consumer 订阅该 Provider 的 Dubbo 服务接口数为 X 时,X 数值越大,通知的次数也就越多。实际上,对于来自同一 Provider 的服务接口集合而言,X-1 次通知是重复和无价值的。

如果 Dubbo 注册实体不再是服务 URL,而是 Dubbo Provider 节点的话,那么上述情况所描述的注册中心压力将得到很大程度的缓解。(负载只有过去的 1/N 甚至更少),然而 Dubbo 如何以应用为粒度来注册又是一个新的挑战。

如何支持以应用为粒度的服务注册与发现

尽管 Dubbo 也存在应用(Application)的概念,不过传统的使用场景并非核心要素,仅在 Dubbo Monitor 或 Dubbo Admin 场景下做辨识之用。随着微服务架构和云原生技术的兴起,以应用为粒度的注册模型已是大势所趋,如 Spring Cloud 和 Kubernetes 服务注册与发现模型。注册中心所管理的对象通常与业务无关,甚至不具备 RPC 的语义。在术语上,微服务架构中的“服务”(Services)与云原生中“应用”(Applications)是相同的概念,属于逻辑名称,而它们的成员则以服务实例(Service Instances)体现,服务和服务实例的数量关系为 1:N。

单个服务实例代表一个服务进程,而多个 Dubbo 服务 URL 可隶属一个 Dubbo Provider 进程,因此,Dubbo URL 与服务实例的数量关系是 N : 1。假设一个 Dubbo Provider 进程仅提供一个 Dubbo 服务(接口)的话,即 N = 1 的情况,虽然以应用为粒度的服务注册与发现能够基于 Dubbo 传统的 Registry SPI 实现,不过对于现有 Dubbo 应用而言,将存在巨大的应用微服务化工作。

支持 Spring Cloud 服务注册与发现模型

Spring Cloud 是 VMware 公司(前为 Pivotal)推出的,一套以 Spring 为技术栈的云原生(Cloud-Native)解决方案,在 Java 微服务领域具备得天独厚的优势,拥有超大规模的全球用户。Spring Cloud 官方支持三种注册中心实现,包括:Eureka、Zookeeper 和 Consul,Spring Cloud Alibaba 扩展了 Nacos 注册中心实现。 尽管 Zookeeper、Consul 和 Nacos 也被 Apache Dubbo 官方支持,然而两者的服务注册与发现的机制不尽相同。

若要 Dubbo 支持 Spring Cloud 服务注册与发现模型,Dubbo 则需基于 Dubbo Registry SPI 实现,否则底层的变化和兼容性存在风险。

支持 Kubernetes 服务注册与发现模型

Kubernetes 源自 Google 15 年生产环境的运维经验,是一个可移植的、可扩展的开源平台,用于管理容器化的工作负载和服务。Kubernetes 原生服务发现手段主要包括:DNS 和 API Server。DNS 服务发现是一种服务地址的通用方案,不过对于相对复杂 Dubbo 元数据而言,这种服务发现机制或许无法直接被 Dubbo Registry SPI 适配。相反,API Server 所支持相对更便利,毕竟 Spring Cloud Kubernetes 同样基于此机制实现,并已在生产环境得到验证。换言之,只要 Dubbo 支持 Spring Cloud 服务注册与发现模型,那么基于 Kubernetes API Server 的支持也能实现。

兼容 Dubbo 传统服务注册与发现模型

所谓兼容 Dubbo 传统服务注册与发现模型,包含两层含义:

  • 基于 Dubbo Registry SPI 同时支持 Spring Cloud 和 Kubernetes 服务注册与发现模型
  • 传统和新的 Dubbo 服务注册与发现模型之间能够相互发现

如何精简 Dubbo URL 元数据

Dubbo 从 2.7.0 开始增加了简化 URL 元数据的特性,被“简化”的数据存放至元数据中心。由于 Dubbo 传统服务注册与发现模型并未减少 Dubbo 服务 URL 注册数量。因此,精简后的 URL 并未明显地减少注册中心所承受的压力。同时,Dubbo URL 元数据精简模式存在一定的限制,即所有的 Dubbo Provider 节点必须是无状态的,每个节点中的 URL 元信息均是一致的,现实中,这个要求非常难以保证,尤其在同一 Provider 节点存在不同的版本或配置的情况下。综上所述,Dubbo URL 元数据需要进一步精简,至少压力应该避免聚集在注册中心之上。

架构设计

架构上,Dubbo 服务自省不仅要解决上述挑战,而且实际场景则更为复杂,因此,架构细节也将循序渐进地展开讨论,整体架构可由以下子架构组成:

  • 服务注册与发现架构
  • 元数据服务架构
  • 事件驱动架构

服务注册与发现架构

Dubbo 服务自省首要需求是减轻注册中心的承载的压力,同时,以应用为粒度的服务注册与发现模型不但能够最大化的减少 Dubbo 服务元信息注册数量,而且还能支持 Spring Cloud 和 Kubernetes 环境,可谓是一举两得,架构图如下所示:

image.png

(图 2)

注册实体

图中所示,从 Provider 和 Consumer 向注册中心注册的实体不再是 Dubbo URL,而是服务实例(Service Instance),一个服务实例代表一个 Provider 或 Consumer Dubbo 应用进程。服务实例属性包括:

  • 服务名(Service Name):该名称必须在注册中心全局唯一
注:名称规则架构上不做约束,不过不同注册中心的规则存在差异
  • 主机地址(Host/IP):能够被解析的主机名或者 TCP IP 地址
  • 服务端口(Port):应用进程所暴露的 Dubbo 协议端口,如 Dubbo 默认端口 20880
注:如果应用进程暴露多个 Dubbo 协议端口,如 dubbo 和 rest,那么,服务端口随机挑选其一,架构上不强制检验端口是否可用
  • 元数据(Metadata):服务实例的附加信息,用于存储 Dubbo 元信息,类似于通讯协议头或附件
  • 激活状态(Enabled):用于标记当前实例是否对外提供服务

上述服务实例模型的支持依赖于注册中心的实现。换言之,并非所有注册中心实现满足服务自省架构的要求。

注册中心

除了满足服务实例模型的要求之外,注册中心还得具备以下能力:

  • 服务实例变化通知(Notification):如上图步骤 4 所示,当 Consumer 订阅的 Provider 的服务实例发生变化时,注册中心能够实时地通知 Consumer
  • 心跳检测(Heartbeats):注册中心能够检测失效的服务实例,并且合理地移除它们

业界主流的注册中心中满足上述要求的有:

总之,Spring Cloud 与Kubernetes注册中心均符合服务自省对注册中心的要求。不过,在 Dubbo 传统 RPC 使用场景中,Provider 和 Consumer 关注的是 Dubbo 服务接口,而非 Service 或服务实例。假设需要将现有的 Dubbo 应用迁移至服务自省架构,Provider 和 Consumer 做大量的代码调整是不现实的。理想的情况下,两端实现代码均无变化,仅修改少量配置,就能达到迁移的效果。那么,Dubbo 服务接口是如何与 Service 进行映射的呢?

Dubbo 服务与 Service 映射

前文曾讨论,单个 Dubbo Service 能够发布多个 Dubbo 服务,所以,Dubbo 服务与 Service 的数量关系是 N 对 1。不过,Dubbo 服务与 Dubbo Service 之间并不存在强绑定关系,换言之,某个 Dubbo 服务也能部署在多个 Dubbo Services 中,因此,Dubbo 服务与 Service 数量关系是 N 对 M(N, M >= 1),如下图所示:

image.png

(图 3)

上图中 P1 Service 到 P3 Service 为 Dubbo Service,com.acme.Interface1 到 com.acme.InterfaceN 则为 Dubbo 服务接口全称限定名(QFN)。值得注意的是,Dubbo 服务的 Java 接口(interface)允许不同的版本(version)或分组(group),所以仅凭 Java 接口无法唯一标识某个 Dubbo 服务,还需要增加通讯协议(protocol)方可,映射关系更新如下:

image.png(图 4)

Dubbo 服务 ID 字符表达模式为: ${protocol}:${interface}:${version}:${group} , 其中,版本(version)或分组(group)是可选的。当 Dubbo Consumer 订阅 Dubbo 服务时,构建对应 ID,通过这个 ID 来查询 Dubbo Provider 的 Service 名称列表。

由于 Dubbo 服务与 Service 的映射关系取决于业务场景,架构层面无从预判。因此,这种映射关系只能在 Dubbo 服务暴露时(运行时)才能确定,否则,Dubbo 服务能被多个 Consumer 应用订阅时,Consumer 无法定位 Provider Service 名称,进而无法完成服务发现。同时,映射关系的数据通常采用配置的方式来存储,服务自省提供两种配置实现,即 “中心化映射配置” 和 “本地化映射配置”。

中心化映射配置

明显地,注册中心来扮演动态映射配置的角色并不适合,不然,Dubbo Service 与映射关系在注册中心是平级的,无论在理解上,还是设计上是混乱的。结合 Dubbo 现有基础设施分析,这个存储设施可由 Dubbo 配置中心承担。

其中 Dubbo 2.7.5 动态配置 API(DynamicConfiguration )支持二级结构,即:group 和 key,其中,group 存储 Dubbo 服务 ID,而 key 则关联对应的 Dubbo Service 名称,对应的 "图 4” 的数据结构则是:

image.png

(图 5)

如此设计的原因如下:

  1. 获取 Dubbo 服务对应 Services

利用 DynamicConfiguration#getConfigKeys(String group) 方法,能够轻松地通过 Dubbo 服务 ID 获取其发布的所有 Dubbo Services,结合服务发现接口获取服务所部署的 Service 实例集合,最终转化为 Dubbo URL 列表。

  1. 避免 Dubbo Services 配置相互覆盖

以 Dubbo 服务 ID dubbo:com.acme.Interface1:default 为例,它的提供者 Dubbo Services 分别:P1 Service 和 P2 Service。假设配置 Group 为 "default"(任意名字均可), Key 为 "dubbo:com.acme.Interface1:default",而内容则是 Dubbo Service 名称的话。当 P1 Service 和 P2 Service 同时启动时,无论哪个 Services 最后完成 Dubbo 服务暴露,那么,该配置内容必然是二选其一,无论配置中心是否支持原子操作。即使配置中心支持内容追加的特性,由于两个 Service 服务实例过程不确定,配置内容可能会出现重复,如:“P1 Service,P2 Service,P1 Service”。

  1. 获取 Dubbo 服务发布的 timestamp
配置中心潜在的压力

假设当 P1 Service 存在 5 个服务实例,当 Dubbo 服务 dubbo:com.acme.Interface1:default(ID)发布时,配置所关联的 key 就是当前 Dubbo Service 名称,即(P1 Service),而内容则是最后发布该 Dubbo 服务的时间戳(timestamp)。当服务实例越多时,配置中心和网络传输所承受的写入压力也就越大。当然架构设计上,服务自省也希望避免重复推送配置,比如在 DynamicConfiguration API 增加类似于 publishConfigIfAbsent 这样的方法,不过目前大多数配置中心产品(如:Nacos、Consul)不支持这样的操作,所以未来服务自省架构会有针对性的提供支持(如:Zookeeper)。

注册中心作为配置中心

由于服务自省架构必须依赖注册中心,同时动态映射配置又依赖配置中心的话,应用的架构复杂度和维护成本均有所提升,不过 Apache Dubbo 所支持的部分注册中心也可作为配置中心使用,情况如下所示:

基础软件注册中心配置中心
Apache Zookeeper
HashiCorp Consul
Alibaba Nacos
Netflix Eureka
Kubernetes API Server

其中,ZookeeperConsulNacos 是目前业界流行的注册中心,这对于大多数选择开源产品的应用无疑是一个福音。

本地化映射配置

如果开发人员认为配置中心的引入增加了架构的复杂性,那么,静态映射配置或许是一种解决方案。

该特性并未在最新 Dubbo 2.7.6 全面发布,部分特性已在 Dubbo Spring Cloud 中发布
接口映射配置

在 Dubbo 传统的编程模型中, 常以 Java 注解 @Reference 或 XML 元素 ` 订阅目标 Dubbo 服务。服务自省架构在此基础上增加 service` 属性的映射一个或多个 Dubbo Service 名称,如:

<reference services="P1 Service,P2 Service" interface="com.acme.Interface1" />

@Reference(services="P1 Service,P2 Service") 
private com.acme.Interface1 interface1;

如此配置后,Dubbo 服务 com.acme.Interface1 将向 p1-servicep2-service 订阅服务。如果开发人员认为这种方式会侵入到代码,服务自省还提供外部化配置方式配置映射。

外部化映射配置

服务自省架构支持外部化配置的方式声明“Dubbo 服务与 Service 映射”,配置格式为 Properties ,以图 4 为例,内容如下:

dubbo\:com.acme.Interface1\:default = P1 Service,P2 Service
thirft\:com.acme.InterfaceX = P1 Service,P3 Service
rest\:com.acme.interfaceN = P1 Service
应用级别映射配置

除此之外,Dubbo Spring Cloud 提供应用级别的 Dubbo 服务映射配置,即 dubbo.cloud.subscribed-services ,例如:

dubbo:
    cloud:
    subscribed-services: P1 Service,P3 Service

总之,无论是映射配置的方式是中心化还是本地化,服务 Consumer 依赖这些数据来定位 Dubbo Provider Services,再通过服务发现 API 结合 Service 名称(列表)获取服务实例集合,为合成 Dubbo URL 做准备:

image.png

(图 6)

不过,映射关系并非是一种强约束,Dubbo Provider 的服务是否可用的检验方法是探测目标 Dubbo Service 是否存在,并需确认订阅的 Dubbo 服务在目标 Services 是否真实暴露,因此,服务自省引入了 Dubbo 元数据服务架构,来完成 Dubbo 服务 URL 的存储。

元数据服务架构

Dubbo 元数据服务是一个常规的 Dubbo 服务,为服务订阅端提供 Dubbo 元数据的服务目录,类似于 WebServices 中的 WDSL 或 REST 中的 HATEOAS,帮助 Dubbo Consumer 获取订阅的 Dubbo 服务的 URL 列表。元数据服务架构无法独立于服务注册与发现架构而存在,下面通过“整体架构”的讨论,了解两者之间的关系。

整体架构

架构上,无论 Dubbo Service 属于 Provider 还是 Consumer,甚至是两者的混合,每个 Dubbo (Service)服务实例有且仅有一个 Dubbo 元数据服务。换言之,Dubbo Service 不存在纯粹的 Consumer,即使它不暴露任何业务服务,那么它也可能是 Dubbo 运维平台(如 Dubbo Admin)的 Provider。不过出于行文的习惯,Consumer 仍旧被定义为 Dubbo 服务消费者(应用)。由于每个 Dubbo Service 均发布自身的 Dubbo 元数据服务,那么,架构不会为不同的 Dubbo Service 设计独立的元数据服务接口(Java)。换言之,所有的 Dubbo Service 元数据服务接口是统一的,命名为 MetadataService

微观架构

从 Dubbo 服务(URL)注册与发现的视角, MetadataService 扮演着传统 Dubbo 注册中心的角色。综合服务注册与发现架构(Dubbo Service 级别),微观架构如下图所示:

image.png

(图 7)

**
**

对于 Provider(服务提供者)而言,Dubbo 应用服务暴露与传统方式无异,而 MetadataService 的暴露时机必须在它们完成后,同时, MetadataService 需要收集这些 Dubbo 服务的 URL(存储细节将在“元数据服务存储模式“ 小节讨论)。假设某个 Provider 的 Dubbo 应用服务暴露数量为 N,那么,它所有的 Dubbo 服务暴露数量为 N + 1。

对于 Consumer(服务消费者)而言,获 Dubbo 应用服务订阅 URL 列表后,Dubbo 服务调用的方式与传统方式是相同的。不过在此之前,Consumer 需要通过 MetadataService 合成订阅 Dubbo 服务的 URL。该过程之所以称之为“合成”,而非“获取,是因为一次 MetadataService 服务调用仅在其 Provider 中的一台服务实例上执行,而该 Provider 可能部署了 N 个服务实例。具体“合成”的细节需要结合“宏观架构”来说明。

宏观架构

元数据服务的宏观架构依赖于服务注册与发现架构,如下图所示:

image.png

(图 8)

图 8 中 p 和 c 分别代表 Provider 和 Consumer 的执行动作,后面紧跟的数字表示动作的次序,从 0 开始计数。执行动作是串行的,并属于 Fast-Fail 设计,如果前阶段执行失败,后续动作将不会发生。之所以如此安排是为了确保 MetadataService 能够暴露和消费。首先从 Provider 执行流程开始说明。

Provider 执行流程
  • p0:发布所有的 Dubbo 应用服务,声明和定义方式与传统方式完全相同。
  • p1:暴露 MetadataService ,该步骤完全由框架自行处理,无论是否 p0 是否暴露 Dubbo 服务
  • p2:在服务实例注册之前, 框架将触发并处理事件(Event),将 MetadataService 的元数据先同步到服务实例(Service Instance)的元数据。随后,执行服务实例注册
  • p3:建立所有的 Dubbo 应用服务与当前 Dubbo Service 名称的映射,并同步到配置源(抽象)
Consumer 执行流程
  • c0:注册当前 Dubbo Service 的服务实例,可选步骤,架构允许 Consumer 不进行服务注册
  • c1:通过订阅 Dubbo 服务元信息查找配置源,获取对应 Dubbo Services 名称(列表)
  • c2:利用已有 Dubbo Service 名称(可能存在多个),通过服务发现 API 获取 Provider 服务实例集合。假设 Service 名称 P,服务实例数量为 N
  • c3:
    1. 随机选择 Provider 一台服务实例 Px,从中获取 MetadataService 的元数据
    2. 将元数据组装 MetadataService Dubbo 调用客户端(代理)
    3. 发起 MetadataService Dubbo 调用,获取该服务实例 Px 所暴露的 Dubbo 应用服务 URL 列表
    4. 从 Dubbo 应用服务 URL 列表过滤出当前订阅 Dubbo 应用服务的 URL
    5. 理论上,步骤 c 和 d 还需要执行 N-1 次才能获取 P 所有服务实例的 Dubbo URL 列表。为了减少调用次数,步骤 d 的结果作为模板,克隆其他 N-1 台服务实例 URL 列表
    6. 将所有订阅 Dubbo 应用服务的 URL 同步到 Dubbo 客户端(与传统方式是相同的)
  • c4:发起 Dubbo 应用服务调用(与传统方式是相同的)

不难看出,上述架构以及流程结合了“服务注册与发现”与“元数据服务”双架构,步骤之间会触发相关 Dubbo 事件,如“服务实例注册前事件”等。换言之,三种架构综合体也就是服务自省架构。

至此,关于 Dubbo 服务自省架构设计方面,还存在一些细节亟待说明,比如:

  1. 不同的 Dubbo Service 的 MetadataService 怎样体现差异呢?
  2. MetadataService 作为一个常规的 Dubbo 服务,它的注册元信息存放在何处?
  3. MetadataService 作为服务目录,它管理的 Dubbo 应用服务 URL 是如何存储的?
  4. 在 Consumer 执行流程的 c3.e 中,克隆 N - 1 条 URL 的前提是该 Provider 的所有服务实例均部署了相同 Dubbo 应用服务。如果 Provider 处于升级的部署过程,同一 Dubbo 应用服务接口在不同的服务实例上存在差异,那么该服务的 URL 应该如何获取?
  5. 除了 Dubbo 服务 URL 发现之外,元数据服务还支持哪些元数据类型呢?

元数据服务 Metadata

元数据服务 Metadata,称之为“元数据服务的元数据”,主要包括:

  • inteface:Dubbo 元数据服务所暴露的接口,即 MetadataService
  • serviceName : 当前 MetadataService 所部署的 Dubbo Service 名称,作为 MetadataService 分组信息
  • group:当前 MetadataService 分组,数据使用 serviceName
  • version:当前 MetadataService 的版本,版本号通常在接口层面声明,不同的 Dubbo 发行版本 version 可能相同,比如 Dubbo 2.7.5 和 2.7.6 中的 version 均为 1.0.0。理论上,version 版本越高,支持元信息类型更丰富
  • protocol: MetadataService 所暴露协议,为了确保 Provider 和 Consumer 通讯兼容性,默认协议为:“dubbo”,也可以支持其他协议。
  • port:协议所使用的网络端口
  • host:当前 MetadataService 所在的服务实例主机或 IP
  • params:当前 MetadataService 暴露后 URL 中的参数信息

不难得出,凭借以上元数据服务的 Metadata,可将元数据服务的 Dubbo 服务 ID 确定,辅助 Provider 服务暴露和 Consumer 服务订阅 MetadataService 。不过对于 Provider,这些元信息都是已知的,而对 Consumer 而言,它们直接能获取的元信息仅有:

  • serviceName:通过“Dubbo 接口与 Service 映射”关系,可得到 Provider Service 名称
  • interface:即 MetadataService ,因为 Provider 和 Consumer 公用 MetadataService 接口
  • group:即 serviceName

不过 Consumer 合成 MetadataService Dubbo URL 还需获取 version、host、port、protocol 以及 params:

  • version:尽管 MetadataService 接口是统一接口,然而 Provider 和 Consumer 可能引入的 Dubbo 版本不同,从而它们使用的 MetadataService version 也会不同,所以这个信息需要 Provider 在暴露MetadataService 时,同步到服务实例的 Metadata 中,方便 Consumer 从 Metadata 中获取
  • host:由于 Consumer 已得到 serviceName,可通过服务发现 API 获取服务实例对象,该对象包含 host 属性,直接被 Consumer 获取即可。
  • port:与 version 类似,从 Provider 服务实例中的 Metadata 中获取
  • params:同上

通过元数据服务 Metadata 的描述,解释了不同 Dubbo Services 是怎样体现差异性的,并且说明了 MetadataService 元信息的存储介质,这也就是服务自省架构为什么强依赖支持 Metadata 的注册中心的原因。下个小节将讨论 MetadataService 所存储 Dubbo 应用服务 URL 存放在何处。

元数据服务存储模式

Dubbo 2.7.5 在引入 MetadataService 的同时,也为其设计了两种存储方式,适用于不同的场景,即“本地存储模式”和“远程存储模式”。其中,本地存储模式是默认选项。

元数据服务本地存储模式

本地存储模式又称之为内存存储模式(In-Memory),如 Dubbo 应用服务发现和注册场景中,暴露和订阅的 URL 直接存储在内存中。架构上,本地存储模式的 MetadataService 相当于去中心化的 Dubbo 应用服务的注册中心。

元数据服务远程存储模式

远程存储模式,与去中心化的本地存储模式相反,采用 Dubbo 元数据中心来管理 Dubbo 元信息,又称之为元中心化存储模式(Metadata Center)。

选择存储模式

为了减少负载压力和维护成本,服务自省中的元数据服务推荐使用地存储模式”

回顾前文“Consumer 执行流程”中的步骤 c3.e,为了减少 MetadataService 调用次数,服务自省将第一次的调用结果作为模板,再结合其他 N-1 服务实例的元信息,合成完整的 N 台服务实例的 Dubbo 元信息。假设,Dubbo Service 服务实例中部署的 Dubbo 服务数量和内容不同,那么,c3.e 的执行步骤是存在问题的。因此,服务自省引入“Dubbo 服务修订版本”的机制来解决不对等部署的问题。

尽管“Dubbo 服务修订版本”机制能够介绍 MetadataService 整体消费次数,然而当新修订版本的服务实例过少,并且 Consumer 过多时,如新的版本 Provider 应用分批部署,每批的服务实例为 1 台,而其 Consumer 服务实例成千上万。为了确保这类场景的稳定性,Provider 和 Consumer 的 MetadataService 可选择“远程存储模式”,避免消费热点的发生。

Dubbo 服务修订版本

当业务出现变化时,Dubbo Service 的 Dubbo 服务也会随之升级。通常,Provider 先行升级,Consumer 随后跟进。

考虑以下场景,Provider “P1” 线上已发布 interface 为 com.acme.Interface1,group 为 group , version 为 v1 ,即 Dubbo 服务 ID 为:dubbo:com.acme.Interface1:v1:default 。P1 可能出现升级的情况有:

  1. Dubbo 服务 interface 升级

由于 Dubbo 基于 Java 接口来暴露服务,同时 Java 接口通常在 Dubbo 微服务中又是唯一的。如果 interface 的全类名调整的话,那么,相当于 com.acme.Interface1 做下线处理,Consumer 将无法消费到该 Dubbo 服务,这种情况不予考虑。如果是 Provider 新增服务接口的话,那么 com.acme.Interface1 则并没有变化,也无需考虑。所以,有且仅有一种情况考虑,即“Dubbo interface 方法声明升级”,包括:

  • 增加服务方法
  • 删除服务方法
  • 修改方法签名
  1. Dubbo 服务 group、version 和 protocol 升级

假设 P1 在升级过程中,新的服务实例部署仅存在调整 group 后的 Dubbo 服务,如 dubbo:com.acme.Interface1:v1:test ,那么这种升级就是不兼容升级,在新老交替过程中,Consumer 仅能消费到老版本的 Dubbo 服务。当新版本完全部署完成后,Consumer 将无法正常服务调用。如果,新版本中 P1 同时部署了 dubbo:com.acme.Interface1:v1:default

dubbo:com.acme.Interface1:v1:test 的话,相当于 group 并无变化。同理,version 和 protocol 变化,相当于 Dubbo 服务 ID 变化,这类情况无需处理

  1. Dubbo 服务元数据升级

这是一种比较特殊的升级方法,即 Provider 所有服务实例 Dubbo 服务 ID 相同,然而 Dubbo 服务的参数在不同版本服务实例存在差异,假设 Dubbo Service P1 部署 5 台服务,其中 3 台服务实例设置 timeout 为 1000 ms,其余 2 台 timeout 为 3000 ms。换言之,P1 拥有两个版本(状态)的 MetadataService

综上所述,无论是 Dubbo interface 方法声明升级,还是 Dubbo 服务元数据升级,均可认为是 Dubbo 服务升级的因子,这些因子所计算出来的数值称之为“Dubbo 服务修订版本”,服务自省架构将其命名为“revision”。架构设设计上,当 Dubbo Service 增加或删除服务方法、修改方法签名以及调整 Dubbo 服务元数据,revision 也会随之变化,revision 数据将存放在其 Dubbo 服务实例的 metadata 中。当 Consumer 订阅 Provider Dubbo 服务元信息时,MetadataService 远程调用的次数取决于服务实例列表中出现 revision 的个数,整体执行流程如下图所示:

image.png

(图 9)

  1. Consumer 通过服务发现 API 向注册中心获取 Provider 服务实例列表
  2. 注册中心返回 6 台服务实例,其中 revision 为 1 的服务实例为 Instance 1 到 3, revision 为 2 的服务实例是 Instance 4 和 Instance 5,revision 为 3 的服务实例仅有 Instance 6
  3. Consumer 在这 6 台服务实例中随机选择一台,如图中 Instance 3
  4. Consumer 向 Instance 3 发起 MetadataService 的远程调用,获得 Dubbo URL 列表,并建立 revision 为 1 的 URL 列表缓存,用 cache = { 1:urls(r1) } 表示
  5. (重复步骤 4)Consumer 再从剩余的 5 台服务实例中随机选择一台,如图中的 Instance 5,由于 Instance 5 与 Instance 3 的 revision 分为为 2 和 1,此时缓存 cache = { 1:urls(r1) } 未命中,所以 Consumer 将再次发起远程调用,获取新的 Dubbo URL 列表,并更新缓存,即 cache = { 1:urls(r1) , 2:urls(r2) }
  6. (重复步骤 4)Consumer 再从剩余的 4 台服务实例中随机选择一台,假设服务实例是 Instance 6,由于此时 revision 为3,所以缓存 cache = { 1:urls(r1) , 2:urls(r2) } 再次未命中,再次发起远程调用,并更新缓存 cache = { 1:urls(r1) , 2:urls(r2) , 3:urls(r3) }
  7. (重复步骤 4)由于缓存 cache = { 1:urls(r1) , 2:urls(r2) , 3:urls(r3) } 已覆盖三个 revision 场景,如果该步骤选择服务实例落在 revision 为 1 的子集中,只需克隆 urls(r1),并根据具体服务实例替换部分 host 和 port 等少量元信息即可,组成成新的 Dubbo URL 列表,依次类推,计算直到剩余服务实例为 0。

大多数情况,revision 的数量不会超过 2,换言之,Consumer 发起 MetadataService 的远程调用不会超过 2次。无论 revision 数量的大小,架构能够保证获取 Dubbo 元信息的正确性。

当然 MetadataService 并非仅支持 Dubbo URL 元数据,还有其他类型的支持。

元数据类型

架构上,元数据服务(MetadataService)未来将逐步替代 Dubbo 2.7.0 元数据中心,并随着 Dubbo 版本的更迭,所支持的元数据类型也将有所变化,比如 Dubbo 2.7.5 元数据服务支持的类型包括:

  • Dubbo 暴露的服务 URL 列表
  • Dubbo 订阅的服务 URL 列表
  • Dubbo 服务定义

Dubbo 暴露的服务 URL 列表

当前 Dubbo Service 暴露或发布 Dubbo 服务 URL 集合,如:[ dubbo://192.168.1.2:20880/com.acme.Interface1?group=default&version=v1 , thirft://192.168.1.2:20881/com.acme.InterfaceX , rest://192.168.1.2:20882/com.acme.interfaceN ]

Dubbo 订阅的服务 URL 列表

当前 Dubbo Service 所有订阅的 Dubbo 服务 URL 集合,该元数据主要被 Dubbo 运维平台来收集。

Dubbo 服务定义

Dubbo 服务提供方(Provider)在服务暴露的过程中,将元信息以 JSON 的格式同步到注册中心,包括服务配置的全部参数,以及服务的方法信息(方法名,入参出参的格式)。在服务自省引入之前,该元数据被 Dubbo 2.7.0 元数据中心 存储,如:

{
 "parameters": {
  "side": "provider",
  "methods": "sayHello",
  "dubbo": "2.0.2",
  "threads": "100",
  "interface": "org.apache.dubbo.samples.metadatareport.configcenter.api.AnnotationService",
  "threadpool": "fixed",
  "version": "1.1.1",
  "generic": "false",
  "revision": "1.1.1",
  "valid": "true",
  "application": "metadatareport-configcenter-provider",
  "default.timeout": "5000",
  "group": "d-test",
  "anyhost": "true"
 },
 "canonicalName": "org.apache.dubbo.samples.metadatareport.configcenter.api.AnnotationService",
 "codeSource": "file:/../dubbo-samples/dubbo-samples-metadata-report/dubbo-samples-metadata-report-configcenter/target/classes/",
 "methods": [{
  "name": "sayHello",
  "parameterTypes": ["java.lang.String"],
  "returnType": "java.lang.String"
 }],
 "types": [{
  "type": "java.lang.String",
  "properties": {
   "value": {
    "type": "char[]"
   },
   "hash": {
    "type": "int"
   }
  }
 }, {
  "type": "int"
 }, {
  "type": "char"
 }]
}

更多元数据类型支持

在架构上,元数据服务(MetadataService)所支持元数据类型是不限制的,如下图所示:

image.png

(图 10)

除上文曾讨论的三种元数据类型,还包括“Dubbo 服务 REST 元信息” 和 “其他元信息”。其中,Dubbo 服务 REST 元信息包含 Dubbo 服务 与 REST 映射信息,可用于 Dubbo 服务网关,而其他元信息可能包括 Dubbo 服务 JavaDoc 元信息,可用于 Dubbo API 文档。

元数据服务升级

考虑到 Dubbo Provider 和 Consumer 可能依赖不同发行版本的 MetadataService ,因此,Provider 提供的和 Consumer 所需要的元数据类型并不对等,如 Provider 使用 Dubbo 版本为 2.7.5,该发行版本仅支持“Dubbo 暴露的服务 URL 列表”,“Dubbo 订阅的服务 URL 列表”和“Dubbo 服务定义”,这三种元数据分别来源于接口的三个方法。当 Consumer 使用了更高的 Dubbo 版本,并需要获取“Dubbo 服务 REST 元信息”时,自然无法从 Provider 端获取。假设 MetadataService 为其新增一个方法,那么,当 Consumer 发起调用时,那么这个调用自然会失败。即使两端使用的版本相同,那么 Provider 仍有可能选择性支持特定的元数据类型。为了确保元数据接口的兼容性,MetadataService 应具备元数据类型支持的判断。如此设计,MetadataService 在元数据类型上支持更具有弹性。

事件驱动架构

相较于传统的 Dubbo 架构,服务自省架构的执行流程更为复杂,执行动作之间的关联非常紧密,如 Dubbo Service 服务实例注册前需要完成 Dubbo 服务 revision 的计算,并将其添加至服务实例的 metadata 中。又如当 Dubbo Service 服务实例出现变化时,Consumer 元数据需要重新计算。这些动作被 “事件”(Event)驱动,驱动者被定义为“事件分发器”( EventDispatcher ),而动作的处理则由“事件监听器”(EventListener)执行,三者均为 “Dubbo 事件"的核心组件,同样由 Dubbo 2.7.5 引入。不过,Dubbo 事件是相对独立的架构,不过被服务自省中的“服务注册与发现架构”和“元数据服务架构”依赖。

Dubbo 内建事件

Dubbo 内建事件可归纳为以下类型:

  • Dubbo 服务类型事件
  • Dubbo Service 类型事件
  • Dubbo 服务实例类型事件
  • Dubbo 服务注册和发现类型事件

Dubbo 服务类型事件

事件类型事件触发时机
ServiceConfigExportedEvent当 Dubbo 服务暴露完成时
ServiceConfigUnexportedEvent当 Dubbo 服务下线后
ReferenceConfigInitializedEvent当 Dubbo 服务引用初始化后
ReferenceConfigDestroyedEvent当 Dubbo 服务引用销毁后

Dubbo Service 类型事件

事件类型事件触发时机
DubboShutdownHookRegisteredEvent当 Dubbo ShutdownHook 注册后
DubboShutdownHookUnregisteredEvent当 Dubbo ShutdownHook 注销后
DubboServiceDestroyedEvent当 Dubbo 进程销毁后

Dubbo 服务实例类型事件

事件类型事件触发时机
ServiceInstancePreRegisteredEvent当 Dubbo 服务实例注册前
ServiceInstanceRegisteredEvent当 Dubbo 服务实例注册后
ServiceInstancePreUnregisteredEvent当 Dubbo 服务实例注销前
ServiceInstanceUnregisteredEvent当 Dubbo 服务实例注销后
ServiceInstancesChangedEvent当 某个 Dubbo Service 下的服务实例列表变更时

Dubbo 服务注册和发现类型事件

事件类型事件触发时机
ServiceDiscoveryInitializingEvent当 Dubbo 服务注册与发现组件初始化中
ServiceDiscoveryInitializedEvent当 Dubbo 服务注册与发现组件初始化后
ServiceDiscoveryExceptionEvent当 Dubbo 服务注册与发现组件异常发生时
ServiceDiscoveryDestroyingEvent当 Dubbo 服务注册与发现组件销毁中
ServiceDiscoveryDestroyedEvent当 Dubbo 服务注册与发现组件销毁后

课程推荐

查看原文

赞 10 收藏 4 评论 1

Richard_Yi 发布了文章 · 2020-04-30

Java 应用线上问题排查思路、工具小结

原文地址:Java 应用线上问题排查思路、工具小结

原创不易,转载请注明出处。

前言

本文总结了一些常见的线上应急现象和对应排查步骤和工具。分享的主要目的是想让对线上问题接触少的同学有个预先认知,免得在遇到实际问题时手忙脚乱。毕竟作者自己也是从手忙脚乱时走过来的。

只不过这里先提示一下。在线上应急过程中要记住,只有一个总体目标:尽快恢复服务,消除影响。 不管处于应急的哪个阶段,我们首先必须想到的是恢复问题,恢复问题不一定能够定位问题,也不一定有完美的解决方案,也许是通过经验判断,也许是预设开关等,但都可能让我们达到快速恢复的目的,然后保留部分现场,再去定位问题、解决问题和复盘

在大多数情况下,我们都是先优先恢复服务,保留下当时的异常信息(内存dump、线程dump、gc log等等,在紧急情况下甚至可以不用保留,等到事后去复现),等到服务正常,再去复盘问题。

好,现在让我们进入正题吧。

常见现象:CPU 利用率高/飙升

场景预设:

监控系统突然告警,提示服务器负载异常。

预先说明:

CPU飙升只是一种现象,其中具体的问题可能有很多种,这里只是借这个现象切入。

注:CPU使用率是衡量系统繁忙程度的重要指标。但是CPU使用率的安全阈值是相对的,取决于你的系统的IO密集型还是计算密集型。一般计算密集型应用CPU使用率偏高load偏低,IO密集型相反。

常见原因:

  • 频繁 gc
  • 死循环、线程阻塞、io wait...etc

模拟

这里为了演示,用一个最简单的死循环来模拟CPU飙升的场景,下面是模拟代码,

在一个最简单的SpringBoot Web 项目中增加CpuReaper这个类,

/**
 * 模拟 cpu 飙升场景
 * @author Richard_yyf
 */
@Component
public class CpuReaper {

    @PostConstruct
    public void cpuReaper() {
        int num = 0;
        long start = System.currentTimeMillis() / 1000;
        while (true) {
            num = num + 1;
            if (num == Integer.MAX_VALUE) {
                System.out.println("reset");
                num = 0;
            }
            if ((System.currentTimeMillis() / 1000) - start > 1000) {
                return;
            }
        }
    }
}

打包成jar之后,在服务器上运行。java -jar cpu-reaper.jar &

第一步:定位出问题的线程

方法 a: 传统的方法

  1. top 定位CPU 最高的进程

    执行top命令,查看所有进程占系统CPU的排序,定位是哪个进程搞的鬼。在本例中就是咱们的java进程。PID那一列就是进程号。(对指示符含义不清楚的见【附录】)

  2. top -Hp pid 定位使用 CPU 最高的线程

  3. printf '0x%x' tid 线程 id 转化 16 进制

    > printf '0x%x' 12817
    > 0x3211
  4. jstack pid | grep tid 找到线程堆栈

    > jstack 12816 | grep 0x3211 -A 30

方法 b: show-busy-java-threads

这个脚本来自于github上一个开源项目,项目提供了很多有用的脚本,show-busy-java-threads就是其中的一个。使用这个脚本,可以直接简化方法A中的繁琐步骤。如下,

> wget --no-check-certificate https://raw.github.com/oldratlee/useful-scripts/release-2.x/bin/show-busy-java-threads
> chmod +x show-busy-java-threads

> ./show-busy-java-threads

show-busy-java-threads
# 从所有运行的Java进程中找出最消耗CPU的线程(缺省5个),打印出其线程栈

# 缺省会自动从所有的Java进程中找出最消耗CPU的线程,这样用更方便
# 当然你可以手动指定要分析的Java进程Id,以保证只会显示你关心的那个Java进程的信息
show-busy-java-threads -p <指定的Java进程Id>

show-busy-java-threads -c <要显示的线程栈数>

方法 c: arthas thread

阿里开源的arthas现在已经几乎包揽了我们线上排查问题的工作,提供了一个很完整的工具集。在这个场景中,也只需要一个thread -n 命令即可。

> curl -O https://arthas.gitee.io/arthas-boot.jar # 下载

要注意的是,arthas的cpu占比,和前面两种cpu占比统计方式不同。前面两种针对的是Java进程启动开始到现在的cpu占比情况,arthas这种是一段采样间隔内,当前JVM里各个线程所占用的cpu时间占总cpu时间的百分比。

具体见官网:https://alibaba.github.io/art...

后续

通过第一步,找出有问题的代码之后,观察到线程栈之后。我们就要根据具体问题来具体分析。这里举几个例子。

情况一:发现使用CPU最高的都是GC 线程。

GC task thread#0 (ParallelGC)" os_prio=0 tid=0x00007fd99001f800 nid=0x779 runnable
GC task thread#1 (ParallelGC)" os_prio=0 tid=0x00007fd990021800 nid=0x77a runnable 
GC task thread#2 (ParallelGC)" os_prio=0 tid=0x00007fd990023000 nid=0x77b runnable 
GC task thread#3 (ParallelGC)" os_prio=0 tid=0x00007fd990025000 nid=0x77c runnabl

gc 排查的内容较多,所以我决定在后面单独列一节讲述。

情况二:发现使用CPU最高的是业务线程

  • io wait

    • 比如此例中,就是因为磁盘空间不够导致的io阻塞
  • 等待内核态锁,如 synchronized

    • jstack -l pid | grep BLOCKED 查看阻塞态线程堆栈
    • dump 线程栈,分析线程持锁情况。
    • arthas提供了thread -b,可以找出当前阻塞其他线程的线程。针对 synchronized 情况

常见现象:频繁 GC

1. 回顾GC流程

在了解下面内容之前,请先花点时间回顾一下GC的整个流程。


接前面的内容,这个情况下,我们自然而然想到去查看gc 的具体情况。

  • 方法a : 查看gc 日志
  • 方法b : jstat -gcutil 进程号 统计间隔毫秒 统计次数(缺省代表一致统计
  • 方法c : 如果所在公司有对应用进行监控的组件当然更方便(比如Prometheus + Grafana)

这里对开启 gc log 进行补充说明。一个常常被讨论的问题(惯性思维)是在生产环境中GC日志是否应该开启。因为它所产生的开销通常都非常有限,因此我的答案是需要开启。但并不一定在启动JVM时就必须指定GC日志参数。

HotSpot JVM有一类特别的参数叫做可管理的参数。对于这些参数,可以在运行时修改他们的值。我们这里所讨论的所有参数以及以“PrintGC”开头的参数都是可管理的参数。这样在任何时候我们都可以开启或是关闭GC日志。比如我们可以使用JDK自带的jinfo工具来设置这些参数,或者是通过JMX客户端调用HotSpotDiagnostic MXBean的setVMOption方法来设置这些参数。

这里再次大赞arthas❤️,它提供的vmoption命令可以直接查看,更新VM诊断相关的参数。

获取到gc日志之后,可以上传到GC easy帮助分析,得到可视化的图表分析结果。

2. GC 原因及定位

prommotion failed

从S区晋升的对象在老年代也放不下导致 FullGC(fgc 回收无效则抛 OOM)。

可能原因:

  • survivor 区太小,对象过早进入老年代

    查看 SurvivorRatio 参数

  • 大对象分配,没有足够的内存

    dump 堆,profiler/MAT 分析对象占用情况

  • old 区存在大量对象

    dump 堆,profiler/MAT 分析对象占用情况

你也可以从full GC 的效果来推断问题,正常情况下,一次full GC应该会回收大量内存,所以 正常的堆内存曲线应该是呈锯齿形。如果你发现full gc 之后堆内存几乎没有下降,那么可以推断: 堆中有大量不能回收的对象且在不停膨胀,使堆的使用占比超过full GC的触发阈值,但又回收不掉,导致full GC一直执行。换句话来说,可能是内存泄露了。

一般来说,GC相关的异常推断都需要涉及到内存分析,使用jmap之类的工具dump出内存快照(或者 Arthas的heapdump)命令,然后使用MAT、JProfiler、JVisualVM等可视化内存分析工具。

至于内存分析之后的步骤,就需要小伙伴们根据具体问题具体分析啦。

常见现象:线程池异常

场景预设:

业务监控突然告警,或者外部反馈提示大量请求执行失败。

异常说明:

Java 线程池以有界队列的线程池为例,当新任务提交时,如果运行的线程少于 corePoolSize,则创建新线程来处理请求。如果正在运行的线程数等于 corePoolSize 时,则新任务被添加到队列中,直到队列满。当队列满了后,会继续开辟新线程来处理任务,但不超过 maximumPoolSize。当任务队列满了并且已开辟了最大线程数,此时又来了新任务,ThreadPoolExecutor 会拒绝服务。

常见问题和原因

这种线程池异常,一般可以通过开发查看日志查出原因,有以下几种原因:

  1. 下游服务 响应时间(RT)过长

    这种情况有可能是因为下游服务异常导致的,作为消费者我们要设置合适的超时时间和熔断降级机制。

    另外针对这种情况,一般都要有对应的监控机制:比如日志监控、metrics监控告警等,不要等到目标用户感觉到异常,从外部反映进来问题才去看日志查。

  2. 数据库慢 sql 或者数据库死锁

    查看日志中相关的关键词。

  3. Java 代码死锁

    jstack –l pid | grep -i –E 'BLOCKED | deadlock'

四、常见问题恢复

这一部分内容参考自此篇文章

对于上文提到的一些问题,这里总结了一些恢复的方法。

五、Arthas

这里还是想单独用一节安利一下Arthas这个工具。

Arthas 是阿里巴巴开源的Java 诊断工具,基于 Java Agent 方式,使用 Instrumentation 方式修改字节码方式进行 Java 应用诊断。

  • dashboard :系统实时数据面板, 可查看线程,内存,gc 等信息
  • thread :查看当前线程信息,查看线程的堆栈,如查看最繁忙的前 n 线程
  • getstatic:获取静态属性值,如 getstatic className attrName 可用于查看线上开关真实值
  • sc:查看 jvm 已加载类信息,可用于排查 jar 包冲突
  • sm:查看 jvm 已加载类的方法信息
  • jad:反编译 jvm 加载类信息,排查代码逻辑没执行原因
  • logger:查看logger信息,更新logger level
  • watch:观测方法执行数据,包含出参、入参、异常等
  • trace:方法内部调用时长,并输出每个节点的耗时,用于性能分析
  • tt:用于记录方法,并做回放
以上内容节选自Arthas官方文档

另外,Arthas里的 还集成了 ognl 这个轻量级的表达式引擎,通过ognl,你可以用arthas 实现很多的“骚”操作。

其他的这里就不多说了,感兴趣的可以去看看arthas的官方文档、github issue。

六、涉及工具

再说下一些工具。

结语

我知道我这篇文章对于线上异常的归纳并不全面,还有网络(超时、TCP队列溢出...)、堆外内存等很多的异常场景没有涉及。主要是因为自己接触很少,没有深刻体会研究过,强行写出来免不得会差点意思,更怕的是误了别人😅。

还有想说的就是,Java 应用线上排查实际非常考究一个人基础是否扎实、解决问题能力是否过关。比如线程池运行机制、gc分析、Java 内存分析等等,如果基础不扎实,看了更多的是一头雾水。另外就是,多看看网上一些有实际场景的关于异常排查的经验文章,学习他们解决排查问题的思路和工具。这样即使自己暂时遇不到,但是会在脑海里面慢慢总结出一套解决类似问题的结构框架,到时候真的遇到了,也就是触类旁通的事情罢了。

如果本文有帮助到你,希望能点个赞,这是对我的最大动力🤝🤝🤗🤗。

参考

附录

top 命令显示的指示符的含义

指示符含义
PID进程id
USER进程所有者
PR进程优先级
NInice值。负值表示高优先级,正值表示低优先级
VIRT进程使用的虚拟内存总量,单位kb。VIRT=SWAP+RES
RES进程使用的、未被换出的物理内存大小,单位kb。RES=CODE+DATA
SHR共享内存大小,单位kb
S进程状态。D=不可中断的睡眠状态 R=运行 S=睡眠 T=跟踪/停止 Z=僵尸进程
%CPU上次更新到现在的CPU时间占用百分比
%MEM进程使用的物理内存百分比
TIME+进程使用的CPU时间总计,单位1/100秒
COMMAND进程名称(命令名/命令行)
查看原文

赞 10 收藏 7 评论 0

Richard_Yi 发布了文章 · 2020-04-27

Java 并发编程 ④ - Java 内存模型

原文地址:Java 并发编程 ④ - Java 内存模型

转载请注明出处!

往期文章:

前言

Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范

JMM与Java内存区域是两个容易混淆的概念,这两者既有差别又有联系:

  • 区别

两者是不同的概念层次Java 内存模型是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。而Java运行时内存的划分是具体的,是JVM运行Java程序时,必要的内存划分。

  • 联系

都存在私有数据区域和共享数据区域。一般来说,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。

在学习Java 内存模型时,我们经常会提到3个特性:

  • 可见性 - Visibility
  • 原子性 - Atomicity
  • 有序性 - Ordering

Java内存模型就是围绕着在并发过程中如何处理这3个特性来建立的。本文也会按照这三个特性讲述。

一、Java 共享变量的内存可见性问题

在讨论之前,需要先重温一下,JVM运行时内存区域:

线程私有变量不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,这一块的数据也称为共享变量,内存可见性问题针对的就是共享变量。


好了,弄清楚问题的主体之后,我们再来思考一个问题。

为什么堆上的变量会存在内存可见性的问题呢?

JMM对硬件层面缓存访问的抽象

其实,这就要涉及到计算机硬件的缓存访问操作了。

现代计算机中,处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。

Java的内存访问操作与上述的硬件缓存具有很高的可比性:

Java内存模型中,规定了:

  • 所有的变量都存储在主内存中。
  • 每个线程还有自己的工作内存,存储了该线程以读、写共享变量的副本。
  • 本地内存(或者叫工作内存)是Java内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。
  • 线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。

从抽象的角度来说,JMM定义了线程和主内存之间的抽象关系

按照上述对于JMM的描述,当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。

Cache(工作内存)的存在就会带来共享变量的内存不可见的问题(也可以叫做缓存一致性问题),具体可以看下面的例子:

  • 假设现在主内存中有共享变量X=0;
  • 线程A首先获取共享变量X的值,由于Cache中没有命中,所以去加载主内存中变量X的值,把X=0的值缓存到工作内存中,线程A执行了修改操作X++,然后将其写入工作内存中,并且刷新到主内存中。
  Thread-A工作内存中 X=1
  主内存中           X=1
  • 线程B开始获取共享变量,由于Cache没有命中,所以去加载主内存中变量X的值,把X=1的值缓存到工作内存中。然后线程B执行了修改操作X++,然后将其写入工作内存中,并且刷新到主内存中。
  Thread-B工作内存中 X=2
  Thread-A工作内存中 X=1
  主内存中           X=2

明明线程B已经把X的值修改为了2,为何线程A获取的还是1呢?这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。

如何保证内存的可见性

那么如何保证内存的可见性,主要有三种实现方式:

  • volatile 关键字

    该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存

  • sychronized 关键字

    一个线程在获取到监视器锁以后才能进入 synchronized 控制的代码块,一旦进入代码块,首先,该线程对于共享变量的缓存就会失效,因此 synchronized 代码块中对于共享变量的读取需要从主内存中重新获取,也就能获取到最新的值

    退出代码块的时候,会将该线程写缓冲区中的数据刷到主内存中,所以在 synchronized 代码块之前或 synchronized 代码块中对于共享变量的操作随着该线程退出 synchronized 块,会立即对其他线程可见(当然前提是线程会去主内存读取最新值)。

  • final 关键字

    在对象的构造方法中设置 final 属性,同时在对象初始化完成前,不要将此对象的引用写入到其他线程可以访问到的地方(不要让引用在构造函数中逸出)。如果这个条件满足,当其他线程看到这个对象的时候,那个线程始终可以看到正确初始化后的对象的 final 属性。(final 字段所引用的对象里的字段或数组元素可能在后续还会变化,若没有正确同步,其它线程也许不能看到最新改变的值,但一定可以看到完全初始化的对象或数组被 final 字段引用的那个时刻的对象字段值或数组元素。)

    final 的场景比较偏,一般就是前面两种方式

    延伸链接:JSR-133:JavaTM 内存模型与线程规范

volatile 和 sychronized 是我认为比较重要的内容,会有单独的章节来讲。

二、原子性

JMM 内存交互操作

Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作

  • read:把一个变量的值从主内存传输到线程的工作内存中
  • load:在 read 之后执行,把 read 得到的值放入线程的工作内存的变量副本中
  • use:把线程的工作内存中一个变量的值传递给执行引擎
  • assign:把一个从执行引擎接收到的值赋给工作内存的变量
  • store:把工作内存的一个变量的值传送到主内存中
  • write:在 store 之后执行,把 store 得到的值放入主内存的变量中
  • lock:作用于主内存的变量,把一个变量标识成一条线程独占的状态
  • unlock: 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
JMM关于内存交互的定义规则非常的严谨和繁琐,为了方便理解,Java设计团队将Java内存模型的操作简化为read、write、lock和unlock四种,但这只是语言描述上的等价化简,Java内存模型的基础设计并未改变。

JMM 对于原子性的规定

所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。

Java 内存模型保证了 readloaduseassignstorewritelockunlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(longdouble)的读写操作划分为两次 32 位的操作来进行,也就是说基本数据类型的访问读写是原子性的,除了longdouble是非原子性的,loadstoreread write 操作可以不具备原子性。 在《深入理解Java 虚拟机》书中提醒我们只需要知道有这么一回事,真的要用到这个知识点的场景十分罕见。

共享变量的原子性问题

这里放一个很经典的例子,并发条件下的计数器自增。

/**
 * 内存模型三大特性 - 原子性验证对比
 *
 * @author Richard_yyf
 */
public class AtomicExample {

    private static AtomicInteger atomicCount = new AtomicInteger();

    private static int count = 0;

    private static void add() {
        atomicCount.incrementAndGet();
        count++;
    }

    public static void main(String[] args) {
        final int threadSize = 1000;
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < threadSize; i++) {
            executor.execute(() -> {
                add();
                countDownLatch.countDown();
            });
        }
        System.out.println("atomicCount: " + atomicCount);
        System.out.println("count: " + count);

        ThreadPoolUtil.tryReleasePool(executor);
    }
}

输出结果:

atomicCount: 1000
count: 997

可以看到,虽然有1000个线程执行了count++操作,最终得到的结果却不是预期的1000。

至于原因呢,就是因为count++这行代码,并不是一个原子性操作。可以借助下图帮助理解。

count++这个简单的操作根据上面的原理分析,可以知道内存操作实际分为读写存三步;因为读写存这个整体的操作,不具备原子性,count被两个或多个线程读入了同样的旧值,读到线程内存当中,再进行写操作,再存回去,那么就可能出现主内存被重复set同一个值的情况,如上图所示,两个线程进行了count++,实际上只进行了一次有效操作。

如何保证原子性

想要保证原子性,可以尝试以下几种方式:

  • CAS:使用基于CAS实现的原子操作类(例如AtomicInteger)
  • synchronized 关键字:可以使用synchronized 来保证限定临界区内操作的原子性。它对应的内存间交互操作为:lock 和 unlock,在虚拟机实现上对应的字节码指令为 monitorenter 和 monitorexit
前者是乐观锁(读多写少场景),后者是悲观锁(读少写多场景)

三、有序性

重排序

计算机在执行程序时,为了提高性能,编译器和处理器会对指令做重排。

重排序由以下几种机制引起:

  • 编译器优化重排

    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 指令并行重排

    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

  • 内存系统重排

    由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

如何保证有序性

Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。意思就是说,在Java内存模型的规定下,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。 在单线程下,可以保证重排序优化之后最终执行的结果与程序顺序执行的结果一致(我们常说的as-if-serial语义),但是在多线程下就会存在问题。

重排序在多线程下会导致非预期的程序执行结果,想要保证可见性,可以考虑以下实现方式:

  • volatile

    volatile产生内存屏障,禁止指令重排序

  • synchronized

    保证每个时刻只有一个线程进入同步代码块,相当于是让线程顺序执行同步代码。

小结

Java内存模型的一系列运行规则看起来有点繁琐,但总结起来,是围绕原子性、可见性、有序性特征建立。归根究底,是为实现共享变量的在多个线程的工作内存的数据一致性,是的在多线程并发、指令重排序优化的环境中程序能如预期运行。

本文介绍了Java内存模型,以及其围绕的有序性、内存可见性以及原子性相关的知识。不得不说,关于Java内存模型,真的要深究估计可以写出一本小书,有兴趣的读者可以参阅其他资料做更深的了解。

上文中提到的valotilesynchronized,是比较重要的内容,会有单独的章节。

参考

  • 《Java 并发编程之美》
  • 《深入理解Java虚拟机》
  • JSR133中文
查看原文

赞 5 收藏 4 评论 0

Richard_Yi 发布了文章 · 2020-04-24

读书日,谈谈读书

undefined

4月23号是国际读书日,借着这个时机就想谈谈自己对读书的感悟。

其实回顾前大半个学生生涯,我都不是一个喜欢读书的人。

因为什么?我后来仔细想了想,因为我觉得“无用”。

原来从很久很久以前,我都是站在一种很纯粹的实用主义立场,去看待读书这件事情,这和每个人家庭的成长环境有关,从小到大,周围的人都给我灌输着一种成功主义的价值观。“你要成功,你要出名,你要吃得苦中苦,吃得苦中苦的目的是赚大钱,成为人上人”。“你说你看这些闲书有什么用”。

然而,站在纯粹的实用主义立场,用处其实不大,甚至有反作用,你的眼界、审美水平、认知能力会被局限在一个小小的圈子中,精神世界会变得匮乏,甚至有可能越来越“愚蠢”。

所幸在一些良心UP主,良心公众号的推荐之下,我慢慢开始阅读。到现在差不多有两年了,读的书越多就越后悔。后悔什么呢?后悔没有早点阅读。

《财富自由之路》、《富爸爸与穷爸爸》刷新了我对金钱、财富、工作的理解,让我有意识地去学习理财相关的知识,培养理财思维。让我懂得不要为短期的金钱工作,更看重个人能力的增值。

《社会性动物》、《非暴力沟通》、《关键沟通》让我用一种全新的视角去看待人与人之间的相互影响、沟通与合作,学会了很多与人沟通的艺术。

《被讨厌的勇气》、《幸福的方法》让我对幸福有了新的理解,明白了幸福是一个需要长期追求、永不间断的过程,是在自己觉得有意义的生活方式中享受其中点滴。

是的,这是我现阶段我体会到读书的最大的意义,也是我想说的第一点:

读书能让你用最低的成本去培养你的眼界、思维和认知。

读书,尤其是读经典的书,实际上就是和各种各样的伟人、学者进行跨时空的交流和学习。


其次,读书让我们学会谦卑

很多时候读书具有一种悖论性:

我们因为无知才去阅读,而我们越阅读,我们越承认自己的无知。

牛顿说:一直以来,我就像一个在海边玩耍的小孩,时不时被某个特别光滑的鹅卵石或美丽的贝壳所吸引,然而却对面前那无边无际的真理的海洋浑然无知。苏格拉底说:我唯一知道的就是自己一无所知。

学习的真谛就在于此,真正的学习不是为了炫耀已有的知识,而是承认自己是如此的无知,发自内心地感恩自己能够获得真理的惊鸿一瞥。在广袤的真理海洋中不断地学会谦卑,对未知的领域保持足够的敬畏。

读书让我明白了,自认为万事皆知的人只是最大的愚昧,也让我明白了,有些人的傲慢不过是不学无术的另一种表达。


最后,读书可以提高我们的审美

我们经常会把书成为精神食粮,那些好的书籍就像我们吃过的饭菜,我们很难记住自己好几天前吃过什么饭菜,但是这些食物为我们的身体提供了能量养分,组成了我们的身体。我们很难记住我们读过的书中的具体的文字,但是这些书籍、以及我们在阅读过程中的思考,都内化到了我们的精神世界,形成了我们独有的品位。随着你的审美水平越来越高,你会为了一些更高水平的快乐,去抛弃一些低下的乐趣。

想一想,当你乘坐宇宙飞船前往火星,所有的乘客都不住地欣赏着窗外的璀璨星空,而你却埋头只顾看肥皂剧,你不觉得这是一种浪费吗?当你如孩童一般在泥巴潭旁嬉戏游玩,觉得人生快乐,莫过于此。也许有一天,你会在海边漫步,拥有更大的快乐。

实际上我现在这个阶段,还不敢妄谈“美”,因为我自认为我的审美水平还停留在一个很初级的阶段。但是我非常渴望能够通过读书来提高自己的审美水平。

写文在此,期待与您共勉。

查看原文

赞 0 收藏 0 评论 0

Richard_Yi 赞了文章 · 2020-04-20

Apache毕业贺礼—Apache ShardingSphere跌宕起伏的开源之路

作者介绍

潘娟,京东数科高级DBA,Apache ShardingSphere PMC

张亮,京东数科数据研发负责人,Apache ShardingSphere VP,Apache Dubbo PMC,人气开源项目Elastic-Job作者

前序

从Sharding-JDBC到Apache ShardingSphere;
从轻量级的分库分表中间件到完整闭环的分布式数据库中间件平台;
从2016年1月的第一行代码到现今的300K+行代码;
从寥寥无几的关注到GitHub 10K+的star;
从无人问津的社区到100+位贡献者;
从公司内部的应用类库到100+的采用公司列表;
从寻找mentor到顺利成为Apache顶级项目。
……

Apache ShardingSphere团队核心初创人员将讲述这其中的跌宕起伏,并以时间轴为线索为你呈现它开源之路背后的故事。

项目介绍

Apache ShardingSphere是一套开源的分布式数据库中间件解决方案组成的生态圈,它由3款相互独立,却又能够混合部署配合使用的产品组成。它们均提供标准化的数据分片、分布式事务和数据库治理功能,可适用于如Java同构、异构语言、云原生等各种多样化的应用场景,核心功能如1-1所示。

image.png
1-1 ShardingSphere核心功能架构图

Apache ShardingSphere由三个子项目组成,形成一个完整的数据库解决方案,合称 J.P.S. 生态系统。

ShardingSphere-JDBC:定位为轻量级Java框架,在Java的JDBC层提供额外服务。 它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。

ShardingSphere-Proxy:定位为透明化的数据库代理端,提供封装了数据库二进制协议的服务端版本,用于完成对异构语言的支持。 目前提供MySQL/PostgreSQL版本,它可以使用任何兼容MySQL/PostgreSQL协议的访问客户端操作数据,对DBA更加友好。

ShardingSphere-Sidecar(TODO):定位为Kubernetes的云原生数据库代理,以Sidecar的形式代理所有对数据库的访问。 通过无中心、零侵入的方案提供与数据库交互的的啮合层,即Database Mesh,又可称数据网格。

Apache ShardingSphere的亮点主要包括:

  1. 完整的分布式数据库解决方案:提供数据分片、分布式事务、数据弹性迁移、数据库和数据治理等核心能力。
  2. 独立的SQL解析引擎:支持多SQL方言的完全独立化SQL解析引擎,能够脱离ShardingSphere独立使用。
  3. 可插拔微内核:所有的SQL方言、数据库协议和功能都能够通过SPI的可插拔方式加载或卸载,微内核甚至在未来可以运行于无任何功能的空白环境中。

为Apache做准备

找寻mentor是进入Apache基金的最初且最重要的一步。在了解了Apache基金会的运作方式后,我们便踏上了找寻mentor之旅。参加各种与开源相关的分享会或meetup,借此来认识Apache的member。但是,事情却并不顺利。多次的尝试,多次的接触换来的只是口头的认可。这段时间我们确实倍感压力和焦虑,甚至打算以后再说,一切随缘。

后来一个契机,我们认识了吴晟和华为的姜宁。吴晟是Apache SkyWalking项目的VP,在开源领域有丰富的经验。他和ShardingSphere的前身Sharding-JDBC很有渊源,Sharding-JDBC项目原型也有他参与设计,因此,他最终作为ShardingSphere的PPMC一同建设社区。在参与ShardingSphere社区建设的这一年多的时间里,他又陆续担任了多个Apache孵化项目的Mentor,并在今年被选举为Apache Member;而姜宁同样是一位热心又有经验的老手,是国内最资深的Apache Member之一,在与他交流的过程中,终于让我们看到一些希望,他也最终成为了我们的mentor。再后来,团队VP张亮又前去上海参加HDC大会,认识了我们的另一位mentor—Craig L Russell,Craig当时是Apache的秘书长,所有的SGA、ICLA等法务文件均由他负责签署。在ShardingSphere孵化的过程中,Craig当选了Apache软件基金会的主席。他友善而和气,给予了我们很多有关社区规范的实用建议,也愿意助我们一臂之力;第三位mentor则是由Apache RocketMQ的核心成员冯嘉担任;最后由Roman Shaposhnik担任项目的Champion,为项目寻找导师之旅画上完美句号。

至今还记得我们当时的欣喜和激动。之前的无助、徘徊、失落在这一瞬间柳暗花明。每个进入Apache基金会的项目,一定都有自己的故事。尤其对于中国的项目来说,语言与地域的障碍让我们雪上加霜。好在有越来越多的来自于中国的项目进入了Apache基金会,也能看到越来越多的华人活跃在Apache的邮件列表里,还有ALC Beijing的建立让参与门槛不断降低,这对想要参与的国内朋友来说,确实是个good news!

进入Apache孵化器

为了正式进入Apache孵化器,项目代码、社区、文档等都需要进行一系列的规范和整理。这确实是个琐碎但很重要的事情。

代码层面,合规操作是首要原则。我们梳理第三方依赖的许可协议, 确保满足Apache软件许可协议(ASL)合规的要求;社区方面,我们开始由中文转变成英文;文档方面则需要我们准备英文文档,并准备相关的proposal。由于项目最开始的目标就是进入Apache基金会,所以在项目初期,依赖就尽可能地简单,社区相对规范,文档在不断翻译。不打无准备之仗,这些提前的准备让这部分工作进展顺利,而项目获得Apache域名的那一刻,大家才真切感受到所有付出得到了最有价值的回报。

除了学习写规范代码,团队成员也开始学习Apache的规范、运作方式、英文沟通渠道等细节。我们开始了解到如何关注社区,什么是consensus decision,如何用异步方式进行邮件沟通。特别是邮件列表的学习非常重要,你可以在其中找到历史问题记录、合规的解决方案、优秀的案例等。

Apache way的探索

很多人认为只要代码开放,就叫做开源。但其实,这仅仅只是开源旅程的第一步。如何构建一个活跃的社区,如何理解Apache way,是一个更为重要的话题。ShardingSphere在进入Apache孵化器初期并未能完全理解Apache way,并且由于过度注重代码风格,以至于参与门槛较高、社区活跃度平平。起初,我们并不知道问题出在哪里,迷茫了很长一段时间,直到在跟Apache的member不断交流的过程中才渐渐意识到问题所在,因此社区发起了有关committer bar的讨论,见图1-2。这是社区建设之路的转折点,因为从此community over code的理念开始逐渐渗入人心,并指导我们的行动。

image.png
1-2 Committer bar讨论邮件

仔细阅读Apache way的关注点:Earned Authority, Community of Peers, Open Communications, Consensus Decision Making, Responsible Oversight。你会发现它一直在强调合规、开放、平等、协作,为的就是建立合规且活跃的项目社区,尽可能地做到让更多的人参与,平等沟通,推动项目发展,促进个人成长。

秉持这个理念,ShardingSphere开始在多维度进行调整,

  • 代码:规整代码结构,划分模块功能,提供项目可插拔能力,从而允许用户局部参与某一模块的同时,尽量不破坏整体代码结构。
  • 心态:开放的心态,编制社区任务,鼓励社区朋友参与,相关PPMC或Committer积极提供指导和帮助。
  • 规范:梳理文档和代码规范,并提供详细的订阅、参与指南,大范围促进用户自主进行社区贡献。
  • 交流:鼓励社区尽可能使用邮件和Issue进行讨论从而公开讨论内容,同时针对较为细节的讨论则放在微信群里进行。此外,官方公众号还会介绍社区的进展、Release、刊登技术文章等。
  • 合作:与其他Apache社区建立联系、增加沟通,从合作交流中进行学习和发展。

在孵化期间,Apache ShardingSphere先后与Apache SkyWalking、Apache ServiceComb进行项目的合作与集成,不仅彼此的产品功能更加完善,还增加了社区成员之间的交流。此外,还与Apache DolphinScheduler(Incubating)和Apache IoTDB(Incubating)举办了co-meetup,详见图1-3。还与Apach pulsar和Apache APISIX(Incubating)的核心成员们进行了多次交流和探讨。

image.png
1-3 co-meetup

经过时间的积累,社区已有了质的变化。从社区的邮件讨论、GitHub的数据展示中,你会发现ShardingSphere的社区开始真正变得活跃与多元化。图1-4展示了ShardingSphere在Apache孵化器一年多的社区数据变化。

image.png
1-4 社区数据变化

社区与贡献者之间的依赖和互赢也在整个过程中体现的淋漓尽致。对于贡献者来说,他们会在这个开源社区中与其他人交流、协作。而这个持续的过程,将带来以下成果,

  • 扩大人际交友圈
  • 不断学习与成长
  • 提高自己的技术影响力
  • 拓宽职业渠道
  • 结合兴趣,享受过程

而对于社区来说,这个相互帮助和沟通的过程则会,

  • 拓展项目的功能
  • 收获活跃多元化的生态圈
  • 增加项目知名度
  • 获得社区的可持续发展

从这个角度来看,不断探索Apache way不也是希望出现这样一种共赢而互助的局面吗?Please remember community over code。

从孵化器毕业

所有孵化器的项目最终都希望能走向TLP(Top Level Project)。在mentor的指导、PPMC的探索、committer和contributor的支持与付出下,ShardingSphere开始筹备Apache孵化器毕业。依据Apache的成熟度评估模型图1-5,在以下几个方面评估社区和项目是否成熟。其实在Apache项目社区的初建阶段,我们建议大家就在这几个方面发力,因为这是官方给予的毕业标准及指导方针。以此为方向,探索属于各自项目的独特社区运作方式,也可谓是百花齐放。

image.png
1-5 Apache项目成熟度评估模型

经历Release、社区建设、Apache member的指导、meetup举办等一系列事件,ShardingSphere终于在社区发起了毕业讨论,开始接受Apache member及所有Apache成员的指导和评估。虽然最终以10 +1 binding votes,6 +1 non-binding votes和 no -1 or +/-0 votes通过毕业投票,但过程也是一波三折。

即便是经过1年多的社区建设,项目基本成熟,但面对毕业还是有很多工作要合乎毕业规范。例如确认商标是否可使用、完成项目官网有关Apache brand和trademark的陈述、网站符合Apache way等。在这个投票期间,由于官网存在fork me on github的slogan,而这一问题一直频繁出现并且没有结论,所以其他Apache成员借此单独开辟了thread来讨论这一问题,查看Email List了解详情。虽说这一举让ShardingSphere被成功推到前台,间接提高了项目的曝光,却也能看出Apache对于第三方独立、禁止参与商业行为的重视和严苛。可喜可贺的是,2020年4月16日,Apache ShardingSphere最终通过基金会董事会决议,加入了TLP行业!

未来的路

从Apache孵化器毕业成为TLP,对ShardingSphere来说,并不是一个结束,而是另一个开始。在产品功能上,ShardingSphere将继续在分布式数据库中间件平台上深耕,打磨出以“分布式”为核心的数据库中间件生态圈,从而提供完整的解决方案,如图1-6所示。从社区角度讲,ShardingSphere仍将继续活跃社区,鼓励更多朋友成为社区的committer和contributor。所以,我们欢迎大家关注ShardingSphere,并加入到社区来,与更多知己结伴前行。

image.png
1-6 Apache ShardingSphere生态圈

未来之路不可预测,但立足当下,眺望未来,初心未改,即便亦步亦趋,也愿一苇以航!

Apache ShardingSphere committer列表

Mentor

Craig L Russell
冯嘉,阿里巴巴
姜宁,华为

PMC

张亮,京东数科
潘娟,京东数科
赵俊,京东数科
张永伦,京东数科
陈清阳,翼支付
曹昊,海南新软
马晓光
杜红军,领创智信
杨翊,京东数科
吴晟,tetrate.io
高洪涛,tetrate.io

Committer

李亚,九个小海豹
颜志一,DaoCloud
董宗磊,京东零售
孙海生,瓜子
王奇,京东零售
欧阳文,一卡易
蒋晓峰,阿里巴巴
王光远
秦金卫,京东数科
岳令
赵亚楠

官网:https://shardingsphere.apache.org/
查看原文

赞 18 收藏 4 评论 1

Richard_Yi 收藏了文章 · 2020-04-13

一键导出微信读书的书籍和笔记

简介


全民阅读的时代已经来临,目前使用读书软件的用户数2.1亿,日活跃用户超过500万,其中19-35岁年轻用户占比超过60%,本科及以上学历用户占比高达80%,北上广深及其他省会城市/直辖市用户占比超过80%。本人习惯使用微信读书,为了方便整理书籍和导出笔记,便开发了这个小工具。




部分截图










代码思路

1. 目录结构

首先,我们先看一下整体目录结构

Code
├─ excel_func.py                   读写excel文件
├─ pyqt_gui.py                     PyQt GUI界面
└─ wereader.py                     微信读书相关api
  • excel_func.py

使用xlrd和xlwt库对excel文件进行读写操作

  • pyqt_gui.py

使用PyQt绘制GUI界面

  • wereader.py

通过抓包解析获得相关api


2. excel_func.py

def write_excel_xls(path, sheet_name_list, value):
    # 新建一个工作簿
    workbook = xlwt.Workbook()

    # 获取需要写入数据的行数
    index = len(value)

    for sheet_name in sheet_name_list:

        # 在工作簿中新建一个表格
        sheet = workbook.add_sheet(sheet_name)

        # 往这个工作簿的表格中写入数据
        for i in range(0, index):
            for j in range(0, len(value[i])):
                sheet.write(i, j, value[i][j])

    # 保存工作簿
    workbook.save(path)

该函数的代码流程为:

  1. 创建excel文件
  2. 创建表格
  3. 往表格写入数据




3. pyqt_gui.py

class MainWindow(QMainWindow):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.DomainCookies = {}

        self.setWindowTitle('微信读书助手') # 设置窗口标题
        self.resize(900, 600) # 设置窗口大小
        self.setWindowFlags(Qt.WindowMinimizeButtonHint) # 禁止最大化按钮
        self.setFixedSize(self.width(), self.height()) # 禁止调整窗口大小

        url = 'https://weread.qq.com/#login' # 目标地址
        self.browser = QWebEngineView() # 实例化浏览器对象

        QWebEngineProfile.defaultProfile().cookieStore().deleteAllCookies() # 初次运行软件时删除所有cookies

        QWebEngineProfile.defaultProfile().cookieStore().cookieAdded.connect(self.onCookieAdd) # cookies增加时触发self.onCookieAdd()函数
        self.browser.loadFinished.connect(self.onLoadFinished) # 网页加载完毕时触发self.onLoadFinished()函数

        self.browser.load(QUrl(url)) # 加载网页
        self.setCentralWidget(self.browser) # 设置中心窗口

该函数的代码流程为:

  1. 新建QT窗口
  2. 实例化QWebEngineView对象
  3. 绑定self.onCookieAdd事件
  4. 绑定self.onLoadFinished事件
  5. 加载网页




    # 网页加载完毕事件
    def onLoadFinished(self):

        global USER_VID
        global HEADERS

        # 获取cookies
        cookies = ['{}={};'.format(key, value) for key,value in self.DomainCookies.items()]
        cookies = ' '.join(cookies)
        # 添加Cookie到header
        HEADERS.update(Cookie=cookies)

        # 判断是否成功登录微信读书
        if login_success(HEADERS):
            print('登录微信读书成功!')

            # 获取用户user_vid
            if 'wr_vid' in self.DomainCookies.keys():
                USER_VID = self.DomainCookies['wr_vid']
                print('用户id:{}'.format(USER_VID))

                # 关闭整个qt窗口
                self.close()

        else:
            print('请扫描二维码登录微信读书...')

该函数的代码流程为:

  1. 当网页加载完毕时,检测是否成功登录微信读书
  2. 如果成功登录微信读书,则关闭QT窗口,开始进行数据导出
  3. 如果失败登录微信读书,则继续等待用户扫描二维码




    # 添加cookies事件
    def onCookieAdd(self, cookie):
        if 'weread.qq.com' in cookie.domain():
            name = cookie.name().data().decode('utf-8')
            value = cookie.value().data().decode('utf-8')
            if name not in self.DomainCookies:
                self.DomainCookies.update({name: value})

该函数的代码流程为:

  1. 保存微信读书网址的cookies,以便后续操作




    books = get_bookshelf(USER_VID, HEADERS) # 获取书架上的书籍
    books_finish_read = books['finishReadBooks']
    books_recent_read = books['recentBooks']
    books_all = books['allBooks']
    write_excel_xls_append(data_dir + '我的书架.xls', '已读完的书籍', books_finish_read) # 追加写入excel文件
    write_excel_xls_append(data_dir + '我的书架.xls', '最近阅读的书籍', books_recent_read)  # 追加写入excel文件
    write_excel_xls_append(data_dir + '我的书架.xls', '所有的书籍', books_all)  # 追加写入excel文件

    # 获取书架上的每本书籍的笔记
    for index, book in enumerate(books_finish_read):
        book_id = book[0]
        book_name = book[1]
        notes = get_bookmarklist(book[0], HEADERS)

        with open(note_dir + book_name + '.txt', 'w') as f:
            f.write(notes)
        print('导出笔记 {} ({}/{})'.format(note_dir + book_name + '.txt', index+1, len(books_finish_read)))

该函数的代码流程为:

  1. 调用write_excel_xls_append函数,保存书籍,并且导出笔记




4. wereader.py

def get_bookshelf(userVid, headers):
    """获取书架上所有书"""
    url = "https://i.weread.qq.com/shelf/friendCommon"
    params = dict(userVid=userVid)
    r = requests.get(url, params=params, headers=headers, verify=False)
    if r.ok:
        data = r.json()
    else:
        raise Exception(r.text)

    books_finish_read = set() # 已读完的书籍
    books_recent_read = set() # 最近阅读的书籍
    books_all = set() # 书架上的所有书籍


    for book in data['recentBooks']:
        if not book['bookId'].isdigit(): # 过滤公众号
            continue
        b = Book(book['bookId'], book['title'], book['author'], book['cover'], book['intro'], book['category'])
        books_recent_read.add(b)

    books_all = books_finish_read + books_recent_read

    return dict(finishReadBooks=books_finish_read, recentBooks=books_recent_read, allBooks=books_all)

该函数的代码流程为:

  1. 获取最近阅读的书籍、已经读完的书籍、所有书籍
  2. 过滤公众号部分
  3. 将书籍数据保存为字典格式




def get_bookmarklist(bookId, headers):
    """获取某本书的笔记返回md文本"""
    url = "https://i.weread.qq.com/book/bookmarklist"
    params = dict(bookId=bookId)
    r = requests.get(url, params=params, headers=headers, verify=False)

    if r.ok:
        data = r.json()
        # clipboard.copy(json.dumps(data, indent=4, sort_keys=True))
    else:
        raise Exception(r.text)
    chapters = {c['chapterUid']: c['title'] for c in data['chapters']}
    contents = defaultdict(list)

    for item in sorted(data['updated'], key=lambda x: x['chapterUid']):
        # for item in data['updated']:
        chapter = item['chapterUid']
        text = item['markText']
        create_time = item["createTime"]
        start = int(item['range'].split('-')[0])
        contents[chapter].append((start, text))

    chapters_map = {title: level for level, title in get_chapters(int(bookId), headers)}
    res = ''
    for c in sorted(chapters.keys()):
        title = chapters[c]
        res += '#' * chapters_map[title] + ' ' + title + '\n'
        for start, text in sorted(contents[c], key=lambda e: e[0]):
            res += '> ' + text.strip() + '\n\n'
        res += '\n'

    return res

该函数的代码流程为:

  1. 获取某一本书籍的笔记
  2. 将返回的字符串改写成markdown格式并输出






如何运行

# 跳转到当前目录
cd 目录名
# 先卸载依赖库
pip uninstall -y -r requirement.txt
# 再重新安装依赖库
pip install -r requirement.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 开始运行
python pyqt_gui.py




补充

完整版源代码存放在github上,有需要的请点击这里下载

项目持续更新,欢迎您star本项目




License

The MIT License (MIT)

查看原文

Richard_Yi 关注了专栏 · 2020-04-08

技术也能说人话

很多技术看起来复杂,其实可以说的很简单。

关注 6

Richard_Yi 赞了文章 · 2020-04-08

为什么要用Go语言?

本文章创作于2020年4月,大约6000字,预计阅读时间15分钟,请坐和放宽。

logo.png

前言

Go 是一个开源的编程语言,它能让构造简单、可靠且高效的软件变得容易[1]。

Go 语言被设计成一门应用于搭载 Web 服务器,存储集群或类似用途的巨型中央服务器的系统编程语言。对于高性能分布式系统领域而言,Go语言无疑比大多数其它语言有着更高的开发效率。它提供了海量并行的支持,这对于游戏服务端的开发而言是再好不过了[1]。

其实早在2018年前,我就已经有在国内的程序员环境中断断续续地听到Go语言的消息,Go语言提供的方便的并发编程方式,十分适合我当时选择的毕业设计选题,但是受限于导师的语言选择、项目的进度追赶、考研的时间压榨,一直没有机会来好好地学习这门语言。

在进入研究生阶段后,尽管研究的方向和算法相关,但未来的职业方向还是选择了以后端为主,主要是因为想做更多和业务相关的工作。为了能在有限的时间里给予自己足够深的知识底蕴,选择了一些让自己去深入了解的方向,Go语言自然也在其中,今天终于有机会来开始研究这门语言。

为什么要用Go语言?

撰写此文的初衷,是本文的标题,也是我作为初学者一直以来的疑问:

“我为什么要用Go语言?”

为了回答这个问题,我翻阅了很多Go语言相关的文档、书籍和教程,我发现我很难在它们之中找到非常明显直接的答案,书上和教程只会说,“是的,Go语言好用”

对于部分人来说,这个问题的答案或许很“明显”,比如选择Go语言是因为Google设计的语言、Go开发赚的钱多、XX公司使用Go语言等等,如果想要了解这门语言更加本质的东西,仅仅这些答案我认为是还不够的。

部分Go的教徒可能会说,他们选择的理由是和语言本身相关的,比如:

  • Go编译快
  • Go执行快
  • Go并发编程方便
  • Go有垃圾回收(Garbage Collection, GC)

的确,Go是有这些特点,但这并非都是Go独有的

  • 运行时解释的脚本语言(比如Python)几乎不需要时间编译
  • C、C++甚至是汇编,基本上能够榨干一台机器的大部分性能
  • 大部分语言都有并发编程的支持库
  • 大部分语言都不需要程序员主动关注内存情况

一些Go的忠实粉丝把这种All in One的特性作为评价语言的标准,他们认为至少在这些方面,Go是可以完美的代替其他语言的。

那么,Go真的能优秀到完全替代另一个语言么?

其实未必,我始终认为银弹是不存在的[2],无论是在这次调查前,还是在这次调查后。

本文从Go语言被设计的初衷出发,深入互联网各种角落,调查Go所具有的那些特性是否足够优秀,同时和其他语言进行适当的比较,你可以选择性的阅读、接受或者反对我的内容,毕竟有交流才能传播知识。

我的最终目的是让更多的初学者看到Go没有轻易暴露出的缺点,同时也能看到Go真正优秀的地方

设计Go的初衷

Go语言的主要目标是将静态语言的安全性和高效性与动态语言的易开发性进行有机结合,达到完美平衡,从而使编程变得更加有乐趣,而不是在艰难抉择中痛苦前行[3]。

Google公司不可能无缘无故地设计一个新语言(一些特性相比于其他语言也没有新到哪里去),这一切肯定是有原因的。

设计Go语言是为了解决当时Google开发遇到的一些问题[4]:

  • C++编译慢、没有现代化(入门级友好的)的内存管理
  • 数以万计行的代码,难以维护
  • 部署的平台各式各样,交叉编译困难
  • ......

joke.png

找不到什么合适的语言,想着反正都是弄来自己用,Google选择造个轮子试试。

Go 语言起源 2007 年,并于 2009 年正式对外发布。它从 2009 年 9 月 21 日开始作为谷歌公司 20%兼职项目,即相关员工利用 20% 的空余时间来参与 Go 语言的研发工作。该项目的三位领导者均是著名的 IT 工程师:Robert Griesemer,参与开发 Java HotSpot 虚拟机;Rob Pike,Go 语言项目总负责人,贝尔实验室 Unix 团队成员,参与的项目包括 Plan 9,Inferno 操作系统和 Limbo 编程语言;Ken Thompson,贝尔实验室 Unix 团队成员,C 语言、Unix 和 Plan 9 的创始人之一,与 Rob Pike 共同开发了 UTF-8 字符集规范。自 2008 年 1 月起,Ken Thompson 就开始研发一款以 C 语言为目标结果的编译器来拓展 Go 语言的设计思想[3]。

go-designers.png

Go 语言设计者:Griesemer、Thompson 和 Pike [3]

当时Google的很多工程师是用的都是C/C++,所以语法的设计上接近于C,Go的设计师们想要解决其他语言使用中的缺点,但是仍保留他们的优点[5]:

  • 静态类型和运行时效率
  • 可读性和易用性
  • 高性能的网络和多进程
  • ...

emmm,这些听起来还是比较玄乎,毕竟设计归设计,实现归实现,我们回顾一下现在Go的几个主要特点,编译速度、执行速度、内存管理以及并发编程。

Go的编译为什么快

当然,设计Go语言也不是完全从零开始,最初Go的团队尝试设计实现一个Go语言的编译前端,由基于C的gcc编译器来编译成机器代码,这个面向gcc的前端编译器也就是目前的Go编译器之一的gccgo。

与其说Go的编译为什么快,不如先说说C++的编译为什么慢,C++也可以用gcc编译,编译速度的大部分差异很有可能来源于语言设计本身。

在讨论问题之前,其中需要先说明的一点是:这里比较的编译速度都是在静态编译下的

静态编译和动态编译的区别:

  • 静态编译:编译器在编译可执行文件时,要把使用到的链接库提取出来,链接打包进可执行文件中,编译结果只有一个可执行文件。
  • 动态编译:可执行文件需要附带独立的库文件,不打包库到可执行文件中,减少可执行文件体积,在执行的时候再调用库即可。

两种方式有各自的优点和缺点,前者不需要去管理不同版本库的兼容性问题,后者可以减少内存和存储的占用(因为可以让不同程序共享同一个库),两种方式孰优孰弱,要对应到具体的工程问题上,Go默认的编译方式是静态编译

回到我们要讨论的问题:C++的编译为什么慢?

C++编译慢的主要两个大头原因[6]

  • 头文件的include方式
  • 模板的编译

C++使用include方式引用头文件,会让需要编译的代码有乘数级的增加,例如当同一个头文件被同一个项目下的N个文件include时,编译器会将头文件引入到每一份代码中,所以同一个头文件会被编译N次(这在大多数时候都是不必要的);C++使用的模板是为了支持泛型编程,在编写对不同类型的泛型函数时,可以提供很大的便利,但是这对于编译器来说,会增加非常多不必要的编译负担。

当然C++对这两个问题有很多后续的优化方法,但是这对于很多开发者来说,他们不想在这上面有过多时间和精力开销。

大部分后来的编程语言在引入文件的方式上,使用了import module来代替include 头文件的方式,import解决了重复编译的问题,当然Go也是使用的import方式;在模板的编译问题上,由于Go在设计理念上遵循从简入手,所以没有将泛函编程纳入到设计框架中,所以天生的没有模版编译带来的时间开销(没有泛型支持也是很多人不满Go语言的理由)。

在Go 的1.5 版本中,Go团队使用Go语言来编写Go语言的编译器(也叫自举),相比于gccgo来说:

  • 提高了编译速度,但执行速度略有下降(性能细节优化还不如gcc)
  • 增加了可编译的平台类型(以往受限于gcc)

在此之外,Go语言语法中的关键字也是非常少的(Go1.11版本里只有25个)[7],这也可以减少编译器花费在语法解析上的时间开销。

keywords.png

所以在我看来,Go编译速度快,主要出于四个原因

  • 使用了import的引用管理方式;
  • 没有模板的编译负担;
  • 1.5版本后的自举编译器优化;
  • 更少的关键字。

所以为了加快编译速度、放弃C++而转入Go的同时,也要考虑一下是否要放弃泛型编程的优点。

注:泛型可能在Go 2版本获得支持。

Go的实际性能如何

Go的执行速度,可以参考一个语言性能测试数据网站 —— The Computer Language Benchmarks Game[8]。

这个网站在不同的算法上对每个语言进行测试,然后给出时间和内存上的开销数据比对。

比较的语言有C++、Java、Python。

首先是时间开销:

time-cost.png

注意时间开销的单位是s,并且Y轴为了方便进行不同跨度上的比较,所以选取的是对数轴(即非线性轴,为1-10-100-1000的比较跨度)。

然后是内存开销:

mem-cost.png

注意Y轴为了方便进行不同跨度上的比较,所以选取的是对数轴(即非线性轴,为1000-10000-100000-1000000的比较跨度)。

需要注意的是,语言本身的性能只决定了一个程序的最高理论性能,程序具体的性能还要取决于这个程序的实现方法,所以当各个语言的性能并没有太大的差异时,性能往往只取决于程序实现的方式。

通过两个图的数据可以分析:

  • Go虽然还无法达到C++那样的极致性能,但是在大部分情况下已经很接近了
  • Go和Java在算法的时间开销上难分伯仲,但在内存的开销上Java就要高得多了;
  • Go在上述的绝大部分情况下,至少时间和内存开销都比Python要优秀得多;

Go的并发编程

Go的并发之所以比较受欢迎,网络上的很多内容集中在几个方面:

  • 天生并发的设计
  • 轻量化的并发编程方式
  • 较高的并发性能
  • 轻量级线程Goroutines、并发通信Channels以及其他便捷的并发同步控制工具

由于Go在设计的时候就考虑到了并发的支持,或者说很多特性都是为了并发而设计,这和一些后期库支持并发和第三方库支持并发的语言不同。

所以Go的并发到底有多方便?在Go中使用并发,只需要在普通的函数执行前加上一个go关键字,就可以新建一个线程让函数在其中执行:

func main() {
    go loop() // 启动一个goroutine
    loop()
}

这样带来的好处不仅仅是让并发编程更方便了,在一些特定情况下,比如Go引用一些使用了并发的库时,这些库所使用的并发也是基于Go本身的并发设计,不会存在库使用另一套并发实现的情况,这样Go调度器在处理程序中的各种并发线程时,可以有更加统一化的管理方式。

不过Go的并发对于程序的实现要求还是比较高的,在使用一些通信Channel的场合,稍有疏忽就可能出现死锁的问题,比如:

fatal error: all goroutines are asleep - deadlock!

Go的并发量可以比大部分语言里普通的线程实现要高,这受益于轻量级的Goroutine,轻量化主要是它所占用的空间要小得多,例如64位环境下的JVM,它会默认固定为每个线程分配1MB的线程栈空间,而Goroutines大概只有4-8KB,之后再按需分配。足够轻量化的线程在相同的内存下也就可以有更高并发量(服务器CPU还没有饱和的情况下),同时也可以减少很多上下文切换的时间开销[9]。但是如果你的每个线程占用空间都非常大时(比如10MB,当然这是非常规需求的情况下),Go的轻量化优势就没有那么明显了。

Go在并发上的优点很明显,也是Go的功能目标,从语言设计上支持了并发,提供了统一便捷的工具,复杂的并发业务也需要在Go的一整套并发规范体系下进行编程,当然这肯定会牺牲部分实现自由度,但可以获得性能的提高和维护成本的下降。

PS:关于Go调度器的内容在这里并没有被提及,因为很难用简单的文字向读者说明该调度方式和其他调度方式的优劣,将在未来的某一篇中会细致地介绍Go调度器的内容。

Go的垃圾回收

垃圾回收(英语:Garbage Collection,缩写为GC),在计算机科学中是一种自动的存储器管理机制。当一个计算机上的动态存储器不再需要时,就应该予以释放,以让出存储器,这种存储器资源管理,称为垃圾回收。垃圾回收器可以让程序员减轻许多负担,也减少程序员犯错的机会[10]。

在使用Go或者其他支持GC的语言时,不用再像C++一样,手动地去释放不需要的变量占用的内容空间(free/delete)

的确,这很方便(对于懒人和容易忘记主动释放的人),但是也多了一些限制(暗箱操作的不透明性以及在GC处理上的性能开销)。GC也不是万能的,当遇到一些对性能要求较高的场景,还是需要记得进行一些主动释放或优化操作(比如说自定义内存池)。

PS:将在未来的某一篇中会细致地介绍Go垃圾回收的细节(如果你们也觉得有必要的话)。

什么时候可以选择Go?

Go有很多优点,编译快、性能好、天生并发以及垃圾回收,很多比较有特色的内容也还没有说到(比如gofmt)。

Go语言也有很多缺点,比如第三方库支持还不够多(相比于Python来说就少的太多了)、支持编译的平台还不够广、还有被称为噩梦的依赖版本管理(已经在改善了,但是还没有达到完全可靠的程度)。

所以到底Go适合做什么,不适合做什么?

分析了这么多后,这个问题其实很难回答,但我们可以选择先从不适合的领域把Go剔除掉,看看我们会剩下什么。

Go不适合做什么

  • 极致高性能优化的场景,你可能需要使用C/C++,甚至是汇编;
  • 简单流程的脚本工具、数值分析、深度学习,可能Python更适合(至少目前是);
  • 搭一个博客或网站,PHP何尝不是天下第一的语言呢;
  • 如果你想比较方便找到一份的后端工作,绝大部分公司的Java岗一直缺人(在实际生产过程中,目前Go仍没有比Java表现得好太多,至少没有好到让一个部门/公司将核心业务重新转向Go来进行重构);
  • ...

你可以找到类似上面那样的很多场景,你可能会发现Go并不能那么完美地替代掉谁。

Go适合做什么

最后,到了我们的终极问题,Go到底适合做什么?

读到这里你可能会觉得,好像是我把Go的特性吹了一遍,然后突然告诉你可能Go不适合你。

Go天生并发,面向并发,所以Go的定位一直很清楚,从最浅显的视角来看,至少Go作为一个有较高性能的并发后端来说,是具有非常大的诱惑力的。

尤其对于后端相关的程序员而言,在某些业务功能的初步实现上,简洁的语法、内置的并发、快速的编译,都可以让你更加高效快速地完成任务(前提是Go的内容足以完成你的任务),不用再去担忧编译优化和内存回收、不用担心过多的时间和内存开销、不用担心不同版本库之间的冲突(静态编译)以及不用担心交叉编译平台适配问题。

大部分情况下,编写一个服务,你只需要:实现、编译、部署、运行

高效快速,足够敏捷,这在企业的绝大部分项目的初期都是适用的,这也是大部分项目对开发初期的要求。当一个项目或者服务真的可以发展下去,需求的确触碰到Go的天花板时,再考虑使用更加好的语言或方法去优化也为时不晚。

简而言之,尽管Go的过于简洁带来了很多问题(有些人说的难听点叫过于简单),Go所具有的优点,可以让大部分人用编程语言这种工具,来解决对他们而言更加重要的问题。

Go语言不是银弹,但它的确能有效地解决这些问题。

参考文章

扩展阅读

在调查Go的过程中,发现了一些比较有意思、或者比较实用的文章,一并附在这里。

  • 我为什么选择使用 Go 语言?,该文写于2016年,在我的文章基本构思完成的时候,偶然看到了这篇文章,作者有很多早期Go版本的开发经验,里面有更多的细节都是出自于工程师的经验之谈,我发现其中的部分想法和我不谋而合,你可以把这篇文章当作本文的后续扩展阅读,不过要注意文章的时效,可能提及到的一些Go的缺点现在已经被改进了。
  • C/C++编译器的工作过程,主要是供不熟悉C系的朋友了解一下编译器的工作过程。
  • The Computer Language Benchmarks Game,一个对各个语言进行性能测试的网站,里面的算法具有一定的代表性,但是不能代表所有工程可能遇到的情况,仅供参考。
  • 为什么 Go 语言在某些方面的性能还不如 Java?,这是知乎上一个2017年开始有的问题,你可以看到很多人对于这个问题的分析,从多个角度来理解语言之间的性能差异。
  • go-wiki WhyGo,Go的Github仓库上维护的Wiki中,有一篇关于WhyGo的文章整理,不过大部分是英文,里面主要是很多关于“为什么我要选择Go”的软硬稿。
  • 为什么要使用Go语言,Go语言的优势在哪里,这个知乎的提问更早,是来自2013年的Yvonne YU用户,在Go的早期其实是具有很大的争议的,你可以看到大家在各个问题上的博弈。
  • 哪些公司在使用Go,Go的Github仓库上维护的Wiki中,有一篇关于全球都有哪些公司在使用Go,不过提供的信息大部分只有一个公司名,比如国内有阿里巴巴(而人家大部分都招Java),可以看看但参考性不大。
  • Go 语言的优点,缺点和令人厌恶的设计,这是Go语言中文网上一篇2018年的文章,如果你对语言本身的一些特性的设计感兴趣,你可以选择看看,作者从很多语法层面上介绍了Go的优点和缺点。
  • Ruby China - 瞎扯淡 真的没必要浪费心思在 Go 语言上,这是我无意中找到的一篇有名的帖子,这个问题始于2013年,在Ruby China上,其中也是大佬们(可能)从各个角度来辩论Go是否值得学习,可以当作武侠小说观看。
  • The way to Go - 3.8 Go性能说明,《The way to Go》这本书上为数不多关于Go性能问题的说明。
  • C++开发转向go开发是否是一个好的发展方向?,2014年知乎上关于C++和Go的一个讨论,其实我觉得“如果选择一个并不意味着就要放弃另一个”,程序员不是研究语言的,也不应该是只靠某一门语言吃饭。
  • 我为什么放弃Go语言 Liigo,嗯,2014年,仍旧是Go争议很大的时候,CSDN上一篇阅读数很高的文章,作者从自己的角度对Go进行批判(Go早期的确是有不少问题),你可以看到早期Go的很多问题,也可以斟酌这些问题对你是否重要以及到底在2020年的Go中有没有被解决。
  • Golang 本身是用什么语言写的?,一个关于编译的有趣的问题,可以适当了解。
  • 搞懂Go垃圾回收,一篇还算比较新的分析Go垃圾回收问题的文章。
  • 有趣的编程语言:Go 语言的启动时间是 C 语言的 300 多倍,C# 的关键字最多,这篇InfoQ文章其实算是一个典型的标题党,主要使用的是一个Github上关于各个语言HelloWorld程序启动时间的测试数据(https://github.com/bdrung/sta...,使用gccgo编译的Go程序的启动时间非常地长,的确是C的300多倍,但使用GC编译的Go程序启动时间只是C的2倍。
  • Go 语言的历史回顾,我一直在寻找一个整理Go的版本变动细节的文章,在Go的官方文档和各种书籍上寻找无果时,在InfoQ上找到了一篇还算跟踪地比较新的(Go 1.0 - Go 1.13)文章,对于初学者而言,知道语言的变化也是很重要的(比如方便的知道哪些问题解决了,哪些还没有被解决),可能之后会拓展性的写一篇关于这个的文章。
查看原文

赞 20 收藏 9 评论 3

Richard_Yi 发布了文章 · 2020-04-04

《clean code》 阅读笔记

编者寄语:

这是一本真正的好书,不过如果读者没有一定的经验,以及缺乏对编程境界的追求的话,可能认为这本书很一般。当然,对于有心人来说,这本书里面的部分东西可能都已经习以为常了。

那么,你是怎样的呢?

另外我为什么写的是《clean code》而不是《代码整洁之道》,因为这本书很多地方你需要看原版的文字才能get到作者真正想表达的意思。如果有能力还是看原版吧。

看原版书,你能学到很多术语表达,在你看外文技术文章的时候更容易帮助你理解全文。如increase cohesion - 增加内聚性,decrease coupling - 减少耦合,separate concerns - 关注点分离,modularize system concerns - 模块化系统关注点,这些都是很经典的表达。

I sincerely and strongly recommend u to read 《clean code》 rather than 《代码整洁之道》

原文地址:《clean code》 阅读笔记

转载请注明出处!

一、整洁代码 ⭐

关键词:优雅
  1. 代码逻辑直接了当,让缺陷难以隐藏
  2. 尽量减少依赖关系,使之便于维护
  3. 依据某种分层策略完善错误处理代码
  4. 性能调至最优,省得引诱别人做没规矩的优化
  5. 整洁的代码只做一件事
  6. 简单直接,具有可读性
  7. 有单元测试和验收测试
  8. 有意义的命名
  9. 代码应在字面上表达其含义
  10. 尽量少的实体:类、方法、函数
  11. 没有重复代码
整洁的代码读起来令人愉悦

二、有意义的命名 ⭐⭐

  • 使用带有语义的命名,能 够让维护代码的人更容易理解和修改代码
  • 编程本来就是一种社会活动(大部分的编程活动都是人与人协作的过程)
  • 避免思维映射,明确才是王道
  • 尽可能要做到“顾名思义”,看到名称就能知道这个变量、函数、类、包的意义、用途。

具体规则

  1. 名副其实:名称不需要注释补充就可见其含义、用途
  2. 不要写多余的废话或者容易让人混淆的命名。

    比如"customerObject"和"customer", "ProductInfo"和"ProductData";这种就是意义混杂的废话。如果真的有区别,就用特定的可以区分的命名来描述它。

  3. 使用读得出来的名称。
  4. 使用可搜索的名称。

    MAX_CLASSES_PER_STUDENT很容易,但想找数字7就麻烦了。

  5. 类名和对象名应该是名词或名词短语。
  6. 方法名应当是动词或动词短语。

    如postPayment、deletePage或save。属性访问器、修改器和断言应该根据其值命名,并依Javabean标准加上get、set和is前缀。

  7. 每个抽象概念选一个词,并且一以贯之

    我的理解中,在同个领域模型中,就应该只有一个命名,比如订单号,同个系统中不应该出现TradeNo、OrderNo等多个命名。

  8. 尽量用术语(CS术语,算法,数学术语)命名

    尽管用那些计算机科学(Computer Science,CS)术语、算法名、模式名、数学术语。

  9. 上一条无法做到的情况下,尽量使用源自所涉问题领域的名称。

    如果不能用程序员熟悉的术语来给手头的工作命名,就采用从所涉问题领域而来的名称。

  10. 添加富有意义的语境,例如利用UserInfo类封装各种个人信息

三、函数 ⭐⭐

编程就像讲故事,要用准确、清晰、富有表达力的语句(代码)
  • 好的函数应该做到自顶向下阅读代码时,像是在阅读报刊文章。
  • 写代码很像是写文章。先想怎么写就怎么写,然后再打磨:分解函数、修改名称、消除重复
  • 编程其实是一门语言设计艺术,大师级程序员把程序系统当做故事来讲。使用准确、清晰、富有表达力的代码来帮助你讲故事。

具体规则

  1. 短小!短小!短小

    重要的事情说3遍。

  2. 函数应该做一件事。做好这件事。只做这一件事
  3. 每个函数一个抽象层级!!!

    这个是编者认为非常重要的一点,也是本人在开发过程当中看到最多的问题。应该处于不同抽象层级的代码混乱在一起时,阅读和理解起来会很痛苦。

    引原文描述:

    函数中混杂不同抽象层级,往往让人迷惑。读者可能无法判断某个表达式是基础概念还是细节。更恶劣的是,就像破损的窗户,一旦细节与基础概念混杂,更多的细节就会在函数中纠结起来。

    但是,就像作者说的,这条规则很难

  4. 使用描述性的名称

    长而具有描述性的名称,要比短而令人费解的名称好。长而具有描述性的名称,要比描述性的长注释好。

    为只做一件事的小函数取个好名字。函数越短小、功能越集中,就越便于取个好名字。

  5. 拒绝boolean型标识参数。

    例: CopyUtil.copyToDB(isWorkDB) --> CopyUtil.copyToWorkDB(), CopyUtil.copyToLiveDB()

    (但是编者阅读很多源码里面也没有遵守,手动狗头...)

  6. 如果一定需要多个参数,那么可能需要对参数进行封装
  7. 使用异常代替返回错误码,错误处理代码就能从主路径代码中分离出来得到简化。
  8. Don't Repeat Yourself(经典的DRY原则)
  9. 先把函数写出来,再规范化

四、注释

这节实际上内容不多,尽量避免注释
  • 别给糟糕的代码加注释(专家建议不如重写)
  • 把力气花在写清楚明白的代码上,直接保证无需编写注释。
  • 好的注释:

    • 法律信息
    • 提供信息
    • 解释意图
    • 警示
    • TODO注释

五、格式 ⭐

  • 代码格式很重要。代码格式关乎沟通,而沟通是专业开发者的头等大事。
  • 向报纸格式学习代码编写。

具体规则

  1. 垂直距离

    1. 变量声明应该尽可能靠近使用位置,本地变量应该在函数顶部出现
    2. 实体变量应该放在类的顶部声明
    3. 相关的函数应该放在一起
    4. 函数的排列顺序保持其相互调用的顺序
  2. 水平位置

    1. 一行代码尽量短,不超过100 - 120 个字符。

      这个在常见的IDE中可以设置提示线。下图是IDEA的配置位置。

      效果:

    2. 用空格将相关性弱的分开
    3. 声明和赋值不需要水平对齐
    4. 注意缩进
  3. 团队之间形成一致的代码格式规范(Checkstyle 插件了解一下?)

    不要使用不同的风格来编写源代码,会增加其复杂度。

六、对象与数据结构

这块有两个我比较在意的概念
  • 要弄清楚数据结构和对象的差异:对象把数据隐藏于抽象之后,曝露操作数据的函数。数据结构曝露其数据,没有提供有意义的函数
  • The Law of Demeter:模块不应了解它所操作对象的内部情形。

    更准确更白话地说:方法不应调用由任何函数返回的对象的方法。只跟朋友谈话,不与陌生人谈话。

    反例:

    final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

七、错误处理 ⭐⭐

  • 一个原则:错误处理很重要,但是如果它搞乱了代码逻辑,就是错误的做法
  • 整洁代码是可读的,但也要强固。可读与强固并不冲突。如果将错误处理隔离看待,独立于主要逻辑之外,就能写出强固而整洁的代码。做到这一步,我们就能单独处理它,也极大地提升了代码的可维护性。

具体规则

  1. 使用异常而非返回码
  2. 使用不可控异常(这点深有体会,checked Exception的代价很大)

    这里作者想说明的是,在使用受检异常时,你首先要考虑这样是否能值回票价。因为受检异常违反了开闭原则,当你在一个方法内抛出了受检异常时,你就得在catch语句和抛出异常之间的方法调用链中的每个方法签名中声明这个异常。

    这意味着,你对软件较低层级的修改,会涉及到较高层级的签名。封装被打破了,因为在抛出路径中的每个函数都要去了解下一层级的异常细节。既然异常旨在让你能在较远处处理错误,可控异常以这种方式破坏封装简直就是一种耻辱

    如果你在编写一套关键代码库,则可控异常有时也会有用:你必须捕获异常。但对于一般的应用开发,其依赖成本要高于收益。
  3. 给出异常发生的环境说明(这个也很重要)

    创建信息充分的错误消息,并和异常一起传递出去。在消息中,包括失败的操作和失败类型。如果你的应用程序有日志系统,传递足够的信息给catch块,并记录下来。

    良好的日志和异常机制,是不应该出现调试的。打日志和抛异常,一定要把上下文给出来,否则,等于在毁灭命案现场,把后边处理问题的人,往歪路上带。

    需要调试来查找错误时,往往是一种对异常处理机制的侮辱

  4. 使用通用异常类打包第三方API包的异常(如调用一些第三方支付SDK等)
  5. 尝试使用特例模式(SPECIAL CASE PATTERN),将异常行为封装到特例对象中。

    很巧妙高级的一种设计模式。

    // 修改前
    try {
        MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
        m_total += expenses.getTotal();
    } catch(MealExpensesNotFound e) {
        m_total += getMealPerDiem();
    }
    
    
    // 优化之后,当没有餐食消耗(即上述代码抛出MealExpensesNotFound的情况),返回特例对象
    MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
    m_total += expenses.getTotal();
    
    // 特例对象
    public class PerDiemMealExpenses implements MealExpenses {
        public int getTotal() {
        // return the per diem default
        }
    }
  6. 不要返回null,不要传递null

    相信不少程序员都深受null 的困扰。返回null值,基本上是在给自己增加工作量,也是在给调用者添乱。只要有一处没检查null值,应用程序就会失控。

    在大多数编程语言中,没有良好的方法能对付由调用者意外传入的null值。事已如此,恰当的做法就是禁止传入null值。

八、边界

边界这一章个人读起来比较难懂。感觉像是翻译的问题。

原书这一章节的名字叫做"Boundaries"。

这一章篇幅较短,意义有点难懂,这里简单总结:作者的意思是让我们自己的代码和第三方库的代码不要耦合太紧密,需要有清晰的Boundaries。

同时也给出了第三方类库的学习建议:探索性地学习测试,以此熟悉类库,写出良好的代码。

九、单元测试

  • 测试代码和生产代码一样重要。它可不是二等公民。

    它需要被思考、被设计和被照料。它该像生产代码一般保持整洁。

    测试代码需要随着生产代码的演进而修改,如果测试不能保持整洁,只会越来越难修改。

  • 整洁的测试有什么要素?有三个要素:可读性,可读性和可读性
  • 每个测试一个断言,每个测试一个概念。

单测本身也应该成为Code Review的一部分,单测写的好,bug一定少。

TDD 三定律

  • 定律一 在编写不能通过的单元测试前,不可编写生产代码。
  • 定律二 只可编写刚好无法通过的单元测试,不能编译也算不通过。
  • 定律三 只可编写刚好足以通过当前失败测试的生产代码。
任何一种迭代和增量的交付方式,都会遇到一个严肃的灵魂拷问:频繁对软件做修改,如何保障软件不被改坏?这个问题,用人肉测试解决不了。交付越频繁,人肉测试就越不可能跟上节奏。自动化的、快速且可靠的、覆盖完善的测试必不可少。这种要求,后补式的、黑盒的测试方法不可能达到,必须在开发软件的过程中内建。

当团队被迫采用迭代和增量的需求管理和项目管理方式,对应的配置管理和质量保障手段就必须跟上。TDD不是锦上添花,而是迭代和增量交付不可或缺的基石

F.I.R.S.T.

整洁的测试应该遵循以下5条规则:

  • 快速(Fast)

    测试应该够快。测试应该能快速运行。测试运行缓慢,你就不会想要频繁地运行它。如果你不频繁运行测试,就不能尽早发现问题,也无法轻易修正,从而也不能轻而易举地清理代码。最终,代码就会腐坏。

  • 独立(Independent)

    测试应该相互独立。某个测试不应为下一个测试设定条件。你应该可以单独运行每个测试,及以任何顺序运行测试。当测试互相依赖时,头一个没通过就会导致一连串的测试失败,使问题诊断变得困难,隐藏了下级错误。

  • 可重复(Repeatable)

    测试应当可在任何环境中重复通过。你应该能够在生产环境、质检环境中运行测试,也能够在无网络的列车上用笔记本电脑运行测试。如果测试不能在任意环境中重复,你就总会有个解释其失败的接口。当环境条件不具备时,你也会无法运行测试。

  • 自足验证(Self-Validating)

    测试应该有布尔值输出。无论是通过或失败,你不应该查看日志文件来确认测试是否通过。你不应该手工对比两个不同文本文件来确认测试是否通过。如果测试不能自足验证,对失败的判断就会变得依赖主观,而运行测试也需要更长的手工操作时间

  • 及时(Timely)

    测试应及时编写。单元测试应该恰好在使其通过的生产代码之前编写。如果在编写生产代码之后编写测试,你会发现生产代码难以测试。你可能会认为某些生产代码本身难以测试。你可能不会去设计可测试的代码

十、类 ⭐⭐

类应该尽量短小

对于衡量类的大小,这里书中提出了一个不同的衡量方法:计算权责。我理解的意思就是,一个类承担了太多的权责之后,这个类就算大了。

所以书中随即提出了SRP - 单一权责原则(也叫单一职责原则)

单一权责原则

单一权责原则(SRP)认为,类或模块应有且只有一条加以修改的理由。该原则既给出了权责的定义,又是关于类的长度的指导方针。类只应有一个权责——只有一条修改的理由。

作者还提到了,系统应该由许多短小的类而不是少量巨大的类组成。每个小类封装一个权责,只有一个修改的原因,并与少数其他类一起协同达成期望的系统行为。

内聚

同时,作者提出了保持内聚性就会得到许多短小的类。

类的高内聚的含义是:类的实体变量应尽可能少,类中方法尽可能多地使用到这些变量。(如果一个类中的每个变量都被每个方法所使用,则该类具有最大的内聚性)

组织类时考虑代码的修改

在整洁的系统中,我们对类加以组织,以降低修改的风险。

  • 开放-闭合原则(OCP)

    类应当对扩展开放,对修改封闭。通过子类化手段,类对添加新功能是开放的,而且可以同时不触及其他类。

  • 依赖倒置原则(Dependency Inversion Principle,DIP)

    DIP认为类应当依赖于抽象而不是依赖于具体细节。通过这种抽象隔离了系统之间的元素,使得系统每个元素的理解变得更加容易,使用起来更加灵活、更加可复用。

十一、系统

系统构造与使用分开。

这里我理解就是将一些对象实例的初始化和使用分离解耦,将构建实例的逻辑交给一个公共的模块/类/框架来做。这里作者也介绍了开发中常见的两种方式,体现了这种思想:

  • 工厂:使用工厂方法自行决定何时创建实例,但是构造细节却在其他地方
  • 依赖注入:当A对B有依赖时,A中不负责B的实例化(这就是类的权责单一原则

后半章主要讲的是AOP的思想和具体的框架实现。就是说将一些重复性、功能性的代码(如:性能监视、日志记录、事务管理、安全检查、缓存等)进行关注面切分,模块化,成就了分散化管理和决策。最终的效果也显而易见,减少了重复代码,关注面的分离也使得设计、决策更加清晰容易。

十二、Emergence (迭进)

这一节主要是讲了四个简单的设计规则(design rules),通过遵循这四个规则,你可以编写出很好的代码,深入了解代码的结构和设计,继而以一种更简单的方式来学习掌握SRP和DIP之类的设计原则。

Four rules of Simple Design are of significant help increating well-designed software
  • 运行所有的测试

    全面测试并持续通过所有测试。遵循SRP的类,测试起来较为简单。测试编写得越多,就越能持续走向编写较易测试的代码。所以,确保系统完全可测试能帮助我们创建更好的设计。

    有了全面的测试保驾护航之后,我们就有条件一步一步地去重构完善我们的代码,目的是为了得到“高内聚,低耦合”的系统。书中也提出了下面三条简单的规则。

  • 不要重复(DRY)
  • 写出能清晰表达编码者意图的代码(Expressive)
  • 尽量减少类和方法(Mininal Classes and Methods)

    当你在重构时,按照SRP、代码可读性等规则遵守,是有可能创建出比原来更多的细小的类。但这不在本条的针对范围之内。

    这里的尽量减少,作者举例了一种情况,就是毫无意义的教条主义会导致编码人员无意识的创建很多的类和方法。不知道你有没有类似的经历,我拿我亲身体会举个例子,我很难理解在某个项目中,对一个领域对象(如User),在构建对应的Service层和Dao层的时候,一定要为每个类创建接口,即使这些接口根本不可能有其他的实现类。

十三、并发

“Objects are abstractions of processing. Threads are abstractions of schedule.”

                                                        —James O. Coplien

这一节作者讨论了并发编程的需求和难点,并且给出了一些解决这些困难和编写整洁并发代码的建议。因为关于并发编程有更好的资料可以学习,所以这里我就简单总结一下。

并发防御原则

  • 单一权责原则(SRP):方法/类/组件应当只有一个修改的理由
  • 限制数据作用域:严格限制对可能被共享的数据的访问
  • 使用数据复本:这点很好理解,避免数据的共享。(Java 中的ThreadLocal)
  • 线程应尽可能独立:不与其他线程共享数据。每个线程处理一个客户端请求,从不共享的源头接纳所有请求数据,存储为本地变量

小结

其他未提到的章节,是我觉得相较来说非重点的章节。还有可能会有一些内容的遗漏,因为这本书中的精华,我觉得我还需要学习领会。

好书常读常新,这本书就在我的工位上,我希望在经历一段时间的工作实践之后,再次打开这本书,我能有更多更新的一些感悟。

如果本文有帮助到你,希望能点个赞,这是对我的最大动力🤝🤝🤗🤗。
查看原文

赞 5 收藏 4 评论 0

Richard_Yi 赞了文章 · 2020-04-03

Java线程池实现原理及其在美团业务中的实践

随着计算机行业的飞速发展,摩尔定律逐渐失效,多核CPU成为主流。使用多线程并行计算逐渐成为开发人员提升服务器性能的基本武器。J.U.C提供的线程池:ThreadPoolExecutor类,帮助开发人员管理线程并方便地执行并行任务。了解并合理使用线程池,是一个开发人员必修的基本功。

本文开篇简述线程池概念和用途,接着结合线程池的源码,帮助读者领略线程池的设计思路,最后回归实践,通过案例讲述使用线程池遇到的问题,并给出了一种动态化线程池解决方案。

一、写在前面

1.1 线程池是什么

线程池(Thread Pool)是一种基于池化思想管理线程的工具,经常出现在多线程服务器中,如MySQL。

线程过多会带来额外的开销,其中包括创建销毁线程的开销、调度线程的开销等等,同时也降低了计算机的整体性能。线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。

而本文描述线程池是JDK中提供的ThreadPoolExecutor类。

当然,使用线程池可以带来一系列好处:

  • 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。
  • 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
  • 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行。

1.2 线程池解决的问题是什么

线程池解决的核心问题就是资源管理问题。在并发环境下,系统不能够确定在任意时刻中,有多少任务需要执行,有多少资源需要投入。这种不确定性将带来以下若干问题:

  1. 频繁申请/销毁资源和调度资源,将带来额外的消耗,可能会非常巨大。
  2. 对资源无限申请缺少抑制手段,易引发系统资源耗尽的风险。
  3. 系统无法合理管理内部的资源分布,会降低系统的稳定性。

为解决资源分配这个问题,线程池采用了“池化”(Pooling)思想。池化,顾名思义,是为了最大化收益并最小化风险,而将资源统一在一起管理的一种思想。

Pooling is the grouping together of resources (assets, equipment, personnel, effort, etc.) for the purposes of maximizing advantage or minimizing risk to the users. The term is used in finance, computing and equipment management.——wikipedia

“池化”思想不仅仅能应用在计算机领域,在金融、设备、人员管理、工作管理等领域也有相关的应用。

在计算机领域中的表现为:统一管理IT资源,包括服务器、存储、和网络资源等等。通过共享资源,使用户在低投入中获益。除去线程池,还有其他比较典型的几种使用策略包括:

  1. 内存池(Memory Pooling):预先申请内存,提升申请内存速度,减少内存碎片。
  2. 连接池(Connection Pooling):预先申请数据库连接,提升申请连接的速度,降低系统的开销。
  3. 实例池(Object Pooling):循环使用对象,减少资源在初始化和释放时的昂贵损耗。

在了解完“是什么”和“为什么”之后,下面我们来一起深入一下线程池的内部实现原理。

二、线程池核心设计与实现

在前文中,我们了解到:线程池是一种通过“池化”思想,帮助我们管理线程而获取并发性的工具,在Java中的体现是ThreadPoolExecutor类。那么它的的详细设计与实现是什么样的呢?我们会在本章进行详细介绍。

2.1 总体设计

Java中的线程池核心实现类是ThreadPoolExecutor,本章基于JDK 1.8的源码来分析Java线程池的核心设计与实现。我们首先来看一下ThreadPoolExecutor的UML类图,了解下ThreadPoolExecutor的继承关系。

图1 ThreadPoolExecutor UML类图

ThreadPoolExecutor实现的顶层接口是Executor,顶层接口Executor提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供Runnable对象,将任务的运行逻辑提交到执行器(Executor)中,由Executor框架完成线程的调配和任务的执行部分。ExecutorService接口增加了一些能力:(1)扩充执行任务的能力,补充可以为一个或一批异步任务生成Future的方法;(2)提供了管控线程池的方法,比如停止线程池的运行。AbstractExecutorService则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可。最下层的实现类ThreadPoolExecutor实现最复杂的运行部分,ThreadPoolExecutor将会一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务。

ThreadPoolExecutor是如何运行,如何同时维护线程和执行任务的呢?其运行机制如下图所示:

图2 ThreadPoolExecutor运行流程

线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。线程池的运行主要分成两部分:任务管理、线程管理。任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:(1)直接申请线程执行该任务;(2)缓冲到队列中等待线程执行;(3)拒绝该任务。线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。

接下来,我们会按照以下三个部分去详细讲解线程池运行机制:

  1. 线程池如何维护自身状态。
  2. 线程池如何管理任务。
  3. 线程池如何管理线程。

2.2 生命周期管理

线程池运行的状态,并不是用户显式设置的,而是伴随着线程池的运行,由内部来维护。线程池内部使用一个变量维护两个值:运行状态(runState)和线程数量 (workerCount)。在具体实现中,线程池将运行状态(runState)、线程数量 (workerCount)两个关键参数的维护放在了一起,如下代码所示:

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

ctl这个AtomicInteger类型,是对线程池的运行状态和线程池中有效线程的数量进行控制的一个字段, 它同时包含两部分的信息:线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount),高3位保存runState,低29位保存workerCount,两个变量之间互不干扰。用一个变量去存储两个值,可避免在做相关决策时,出现不一致的情况,不必为了维护两者的一致,而占用锁资源。通过阅读线程池源代码也可以发现,经常出现要同时判断线程池运行状态和线程数量的情况。线程池也提供了若干方法去供用户获得线程池当前的运行状态、线程个数。这里都使用的是位运算的方式,相比于基本运算,速度也会快很多。

关于内部封装的获取生命周期状态、获取线程池线程数量的计算方法如以下代码所示:

private static int runStateOf(int c)     { return c & ~CAPACITY; } //计算当前运行状态
private static int workerCountOf(int c)  { return c & CAPACITY; }  //计算当前线程数量
private static int ctlOf(int rs, int wc) { return rs | wc; }   //通过状态和线程数生成ctl

ThreadPoolExecutor的运行状态有5种,分别为:

其生命周期转换如下入所示:

图3 线程池生命周期

2.3 任务执行机制

2.3.1 任务调度

任务调度是线程池的主要入口,当用户提交了一个任务,接下来这个任务将如何执行都是由这个阶段决定的。了解这部分就相当于了解了线程池的核心运行机制。

首先,所有任务的调度都是由execute方法完成的,这部分完成的工作是:检查现在线程池的运行状态、运行线程数、运行策略,决定接下来执行的流程,是直接申请线程执行,或是缓冲到队列中执行,亦或是直接拒绝该任务。其执行过程如下:

  1. 首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。
  2. 如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。
  3. 如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。
  4. 如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。
  5. 如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。

其执行流程如下图所示:

图4 任务调度流程

2.3.2 任务缓冲

任务缓冲模块是线程池能够管理任务的核心部分。线程池的本质是对任务和线程的管理,而做到这一点最关键的思想就是将任务和线程两者解耦,不让两者直接关联,才可以做后续的分配工作。线程池中是以生产者消费者模式,通过一个阻塞队列来实现的。阻塞队列缓存任务,工作线程从阻塞队列中获取任务。

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

下图中展示了线程1往阻塞队列中添加元素,而线程2从阻塞队列中移除元素:

图5 阻塞队列

使用不同的队列可以实现不一样的任务存取策略。在这里,我们可以再介绍下阻塞队列的成员:

2.3.3 任务申请

由上文的任务分配部分可知,任务的执行有两种可能:一种是任务直接由新创建的线程执行。另一种是线程从任务队列中获取任务然后执行,执行完任务的空闲线程会再次去从队列中申请任务再去执行。第一种情况仅出现在线程初始创建的时候,第二种是线程获取任务绝大多数的情况。

线程需要从任务缓存模块中不断地取任务执行,帮助线程从阻塞队列中获取任务,实现线程管理模块和任务管理模块之间的通信。这部分策略由getTask方法实现,其执行流程如下图所示:

图6 获取任务流程图

getTask这部分进行了多次判断,为的是控制线程的数量,使其符合线程池的状态。如果线程池现在不应该持有那么多线程,则会返回null值。工作线程Worker会不断接收新任务去执行,而当工作线程Worker接收不到任务的时候,就会开始被回收。

2.3.4 任务拒绝

任务拒绝模块是线程池的保护部分,线程池有一个最大的容量,当线程池的任务缓存队列已满,并且线程池中的线程数目达到maximumPoolSize时,就需要拒绝掉该任务,采取任务拒绝策略,保护线程池。

拒绝策略是一个接口,其设计如下:

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

用户可以通过实现这个接口去定制拒绝策略,也可以选择JDK提供的四种已有拒绝策略,其特点如下:

2.4 Worker线程管理

2.4.1 Worker线程

线程池为了掌握线程的状态并维护线程的生命周期,设计了线程池内的工作线程Worker。我们来看一下它的部分代码:

private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
    final Thread thread;//Worker持有的线程
    Runnable firstTask;//初始化的任务,可以为null
}

Worker这个工作线程,实现了Runnable接口,并持有一个线程thread,一个初始化的任务firstTask。thread是在调用构造方法时通过ThreadFactory来创建的线程,可以用来执行任务;firstTask用它来保存传入的第一个任务,这个任务可以有也可以为null。如果这个值是非空的,那么线程就会在启动初期立即执行这个任务,也就对应核心线程创建时的情况;如果这个值是null,那么就需要创建一个线程去执行任务列表(workQueue)中的任务,也就是非核心线程的创建。

Worker执行任务的模型如下图所示:

图7 Worker执行任务

线程池需要管理线程的生命周期,需要在线程长时间不运行的时候进行回收。线程池使用一张Hash表去持有线程的引用,这样可以通过添加引用、移除引用这样的操作来控制线程的生命周期。这个时候重要的就是如何判断线程是否在运行。

​Worker是通过继承AQS,使用AQS来实现独占锁这个功能。没有使用可重入锁ReentrantLock,而是使用AQS,为的就是实现不可重入的特性去反应线程现在的执行状态。

1.lock方法一旦获取了独占锁,表示当前线程正在执行任务中。
2.如果正在执行任务,则不应该中断线程。
3.如果该线程现在不是独占锁的状态,也就是空闲的状态,说明它没有在处理任务,这时可以对该线程进行中断。
4.线程池在执行shutdown方法或tryTerminate方法时会调用interruptIdleWorkers方法来中断空闲的线程,interruptIdleWorkers方法会使用tryLock方法来判断线程池中的线程是否是空闲状态;如果线程是空闲状态则可以安全回收。

在线程回收过程中就使用到了这种特性,回收过程如下图所示:

图8 线程池回收过程

2.4.2 Worker线程增加

增加线程是通过线程池中的addWorker方法,该方法的功能就是增加一个线程,该方法不考虑线程池是在哪个阶段增加的该线程,这个分配线程的策略是在上个步骤完成的,该步骤仅仅完成增加线程,并使它运行,最后返回是否成功这个结果。addWorker方法有两个参数:firstTask、core。firstTask参数用于指定新增的线程执行的第一个任务,该参数可以为空;core参数为true表示在新增线程时会判断当前活动线程数是否少于corePoolSize,false表示新增线程前需要判断当前活动线程数是否少于maximumPoolSize,其执行流程如下图所示:

图9 申请线程执行流程图

2.4.3 Worker线程回收

线程池中线程的销毁依赖JVM自动的回收,线程池做的工作是根据当前线程池的状态维护一定数量的线程引用,防止这部分线程被JVM回收,当线程池决定哪些线程需要回收时,只需要将其引用消除即可。Worker被创建出来后,就会不断地进行轮询,然后获取任务去执行,核心线程可以无限等待获取任务,非核心线程要限时获取任务。当Worker无法获取到任务,也就是获取的任务为空时,循环会结束,Worker会主动消除自身在线程池内的引用。

try {
  while (task != null || (task = getTask()) != null) {
    //执行任务
  }
} finally {
  processWorkerExit(w, completedAbruptly);//获取不到任务时,主动回收自己
}

线程回收的工作是在processWorkerExit方法完成的。

图10 线程销毁流程

事实上,在这个方法中,将线程引用移出线程池就已经结束了线程销毁的部分。但由于引起线程销毁的可能性有很多,线程池还要判断是什么引发了这次销毁,是否要改变线程池的现阶段状态,是否要根据新状态,重新分配线程。

2.4.4 Worker线程执行任务

在Worker类中的run方法调用了runWorker方法来执行任务,runWorker方法的执行过程如下:

1.while循环不断地通过getTask()方法获取任务。
2.getTask()方法从阻塞队列中取任务。
3.如果线程池正在停止,那么要保证当前线程是中断状态,否则要保证当前线程不是中断状态。
4.执行任务。
5.如果getTask结果为null则跳出循环,执行processWorkerExit()方法,销毁线程。

执行流程如下图所示:

图11 执行任务流程

三、线程池在业务中的实践

3.1 业务背景

在当今的互联网业界,为了最大程度利用CPU的多核性能,并行运算的能力是不可或缺的。通过线程池管理线程获取并发性是一个非常基础的操作,让我们来看两个典型的使用线程池获取并发性的场景。

场景1:快速响应用户请求

描述:用户发起的实时请求,服务追求响应时间。比如说用户要查看一个商品的信息,那么我们需要将商品维度的一系列信息如商品的价格、优惠、库存、图片等等聚合起来,展示给用户。

分析:从用户体验角度看,这个结果响应的越快越好,如果一个页面半天都刷不出,用户可能就放弃查看这个商品了。而面向用户的功能聚合通常非常复杂,伴随着调用与调用之间的级联、多级级联等情况,业务开发同学往往会选择使用线程池这种简单的方式,将调用封装成任务并行的执行,缩短总体响应时间。另外,使用线程池也是有考量的,这种场景最重要的就是获取最大的响应速度去满足用户,所以应该不设置队列去缓冲并发任务,调高corePoolSize和maxPoolSize去尽可能创造多的线程快速执行任务。

图12 并行执行任务提升任务响应速度

场景2:快速处理批量任务

描述:离线的大量计算任务,需要快速执行。比如说,统计某个报表,需要计算出全国各个门店中有哪些商品有某种属性,用于后续营销策略的分析,那么我们需要查询全国所有门店中的所有商品,并且记录具有某属性的商品,然后快速生成报表。

分析:这种场景需要执行大量的任务,我们也会希望任务执行的越快越好。这种情况下,也应该使用多线程策略,并行计算。但与响应速度优先的场景区别在于,这类场景任务量巨大,并不需要瞬时的完成,而是关注如何使用有限的资源,尽可能在单位时间内处理更多的任务,也就是吞吐量优先的问题。所以应该设置队列去缓冲并发任务,调整合适的corePoolSize去设置处理任务的线程数。在这里,设置的线程数过多可能还会引发线程上下文切换频繁的问题,也会降低处理任务的速度,降低吞吐量。

图13 并行执行任务提升批量任务执行速度

3.2 实际问题及方案思考

线程池使用面临的核心的问题在于:线程池的参数并不好配置。一方面线程池的运行机制不是很好理解,配置合理需要强依赖开发人员的个人经验和知识;另一方面,线程池执行的情况和任务类型相关性较大,IO密集型和CPU密集型的任务运行起来的情况差异非常大,这导致业界并没有一些成熟的经验策略帮助开发人员参考。

关于线程池配置不合理引发的故障,公司内部有较多记录,下面举一些例子:

Case1:2018年XX页面展示接口大量调用降级:

事故描述:XX页面展示接口产生大量调用降级,数量级在几十到上百。

事故原因:该服务展示接口内部逻辑使用线程池做并行计算,由于没有预估好调用的流量,导致最大核心数设置偏小,大量抛出RejectedExecutionException,触发接口降级条件,示意图如下:

图14 线程数核心设置过小引发RejectExecutionException

Case2:2018年XX业务服务不可用S2级故障

事故描述:XX业务提供的服务执行时间过长,作为上游服务整体超时,大量下游服务调用失败。

事故原因:该服务处理请求内部逻辑使用线程池做资源隔离,由于队列设置过长,最大线程数设置失效,导致请求数量增加时,大量任务堆积在队列中,任务执行时间过长,最终导致下游服务的大量调用超时失败。示意图如下:

图15 线程池队列长度设置过长、corePoolSize设置过小导致任务执行速度低

业务中要使用线程池,而使用不当又会导致故障,那么我们怎样才能更好地使用线程池呢?针对这个问题,我们下面延展几个方向:

1. 能否不用线程池?

回到最初的问题,业务使用线程池是为了获取并发性,对于获取并发性,是否可以有什么其他的方案呢替代?我们尝试进行了一些其他方案的调研:

综合考虑,这些新的方案都能在某种情况下提升并行任务的性能,然而本次重点解决的问题是如何更简易、更安全地获得的并发性。另外,Actor模型的应用实际上甚少,只在Scala中使用广泛,协程框架在Java中维护的也不成熟。这三者现阶段都不是足够的易用,也并不能解决业务上现阶段的问题。

2. 追求参数设置合理性?

有没有一种计算公式,能够让开发同学很简易地计算出某种场景中的线程池应该是什么参数呢?

带着这样的疑问,我们调研了业界的一些线程池参数配置方案:

调研了以上业界方案后,我们并没有得出通用的线程池计算方式。并发任务的执行情况和任务类型相关,IO密集型和CPU密集型的任务运行起来的情况差异非常大,但这种占比是较难合理预估的,这导致很难有一个简单有效的通用公式帮我们直接计算出结果。

3. 线程池参数动态化?

尽管经过谨慎的评估,仍然不能够保证一次计算出来合适的参数,那么我们是否可以将修改线程池参数的成本降下来,这样至少可以发生故障的时候可以快速调整从而缩短故障恢复的时间呢?基于这个思考,我们是否可以将线程池的参数从代码中迁移到分布式配置中心上,实现线程池参数可动态配置和即时生效,线程池参数动态化前后的参数修改流程对比如下:

图16 动态修改线程池参数新旧流程对比

基于以上三个方向对比,我们可以看出参数动态化方向简单有效。

3.3 动态化线程池

3.3.1 整体设计

动态化线程池的核心设计包括以下三个方面:

  1. 简化线程池配置:线程池构造参数有8个,但是最核心的是3个:corePoolSize、maximumPoolSize,workQueue,它们最大程度地决定了线程池的任务分配和线程分配策略。考虑到在实际应用中我们获取并发性的场景主要是两种:(1)并行执行子任务,提高响应速度。这种情况下,应该使用同步队列,没有什么任务应该被缓存下来,而是应该立即执行。(2)并行执行大批次任务,提升吞吐量。这种情况下,应该使用有界队列,使用队列去缓冲大批量的任务,队列容量必须声明,防止任务无限制堆积。所以线程池只需要提供这三个关键参数的配置,并且提供两种队列的选择,就可以满足绝大多数的业务需求,Less is More。
  2. 参数可动态修改:为了解决参数不好配,修改参数成本高等问题。在Java线程池留有高扩展性的基础上,封装线程池,允许线程池监听同步外部的消息,根据消息进行修改配置。将线程池的配置放置在平台侧,允许开发同学简单的查看、修改线程池配置。
  3. 增加线程池监控:对某事物缺乏状态的观测,就对其改进无从下手。在线程池执行任务的生命周期添加监控能力,帮助开发同学了解线程池状态。

图17 动态化线程池整体设计

3.3.2 功能架构

动态化线程池提供如下功能:

动态调参:支持线程池参数动态调整、界面化操作;包括修改线程池核心大小、最大核心大小、队列长度等;参数修改后及时生效。
任务监控:支持应用粒度、线程池粒度、任务粒度的Transaction监控;可以看到线程池的任务执行情况、最大任务执行时间、平均任务执行时间、95/99线等。
负载告警:线程池队列任务积压到一定值的时候会通过大象(美团内部通讯工具)告知应用开发负责人;当线程池负载数达到一定阈值的时候会通过大象告知应用开发负责人。
操作监控:创建/修改和删除线程池都会通知到应用的开发负责人。
操作日志:可以查看线程池参数的修改记录,谁在什么时候修改了线程池参数、修改前的参数值是什么。
权限校验:只有应用开发负责人才能够修改应用的线程池参数。

图18 动态化线程池功能架构

参数动态化

JDK原生线程池ThreadPoolExecutor提供了如下几个public的setter方法,如下图所示:

图19 JDK 线程池参数设置接口

JDK允许线程池使用方通过ThreadPoolExecutor的实例来动态设置线程池的核心策略,以setCorePoolSize为方法例,在运行期线程池使用方调用此方法设置corePoolSize之后,线程池会直接覆盖原来的corePoolSize值,并且基于当前值和原始值的比较结果采取不同的处理策略。对于当前值小于当前工作线程数的情况,说明有多余的worker线程,此时会向当前idle的worker线程发起中断请求以实现回收,多余的worker在下次idel的时候也会被回收;对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的worker线程来执行队列任务,setCorePoolSize具体流程如下:

图20 setCorePoolSize方法执行流程

线程池内部会处理好当前状态做到平滑修改,其他几个方法限于篇幅,这里不一一介绍。重点是基于这几个public方法,我们只需要维护ThreadPoolExecutor的实例,并且在需要修改的时候拿到实例修改其参数即可。基于以上的思路,我们实现了线程池参数的动态化、线程池参数在管理平台可配置可修改,其效果图如下图所示:

图21 可动态修改线程池参数

用户可以在管理平台上通过线程池的名字找到指定的线程池,然后对其参数进行修改,保存后会实时生效。目前支持的动态参数包括核心数、最大值、队列长度等。除此之外,在界面中,我们还能看到用户可以配置是否开启告警、队列等待任务告警阈值、活跃度告警等等。关于监控和告警,我们下面一节会对齐进行介绍。

线程池监控

除了参数动态化之外,为了更好地使用线程池,我们需要对线程池的运行状况有感知,比如当前线程池的负载是怎么样的?分配的资源够不够用?任务的执行情况是怎么样的?是长任务还是短任务?基于对这些问题的思考,动态化线程池提供了多个维度的监控和告警能力,包括:线程池活跃度、任务的执行Transaction(频率、耗时)、Reject异常、线程池内部统计信息等等,既能帮助用户从多个维度分析线程池的使用情况,又能在出现问题第一时间通知到用户,从而避免故障或加速故障恢复。

1. 负载监控和告警

线程池负载关注的核心问题是:基于当前线程池参数分配的资源够不够。对于这个问题,我们可以从事前和事中两个角度来看。事前,线程池定义了“活跃度”这个概念,来让用户在发生Reject异常之前能够感知线程池负载问题,线程池活跃度计算公式为:线程池活跃度 = activeCount/maximumPoolSize。这个公式代表当活跃线程数趋向于maximumPoolSize的时候,代表线程负载趋高。事中,也可以从两方面来看线程池的过载判定条件,一个是发生了Reject异常,一个是队列中有等待任务(支持定制阈值)。以上两种情况发生了都会触发告警,告警信息会通过大象推送给服务所关联的负责人。

图22 大象告警通知

2. 任务级精细化监控

在传统的线程池应用场景中,线程池中的任务执行情况对于用户来说是透明的。比如在一个具体的业务场景中,业务开发申请了一个线程池同时用于执行两种任务,一个是发消息任务、一个是发短信任务,这两类任务实际执行的频率和时长对于用户来说没有一个直观的感受,很可能这两类任务不适合共享一个线程池,但是由于用户无法感知,因此也无从优化。动态化线程池内部实现了任务级别的埋点,且允许为不同的业务任务指定具有业务含义的名称,线程池内部基于这个名称做Transaction打点,基于这个功能,用户可以看到线程池内部任务级别的执行情况,且区分业务,任务监控示意图如下图所示:

图23 线程池任务执行监控

3. 运行时状态实时查看

用户基于JDK原生线程池ThreadPoolExecutor提供的几个public的getter方法,可以读取到当前线程池的运行状态以及参数,如下图所示:

图24 线程池实时运行情况

动态化线程池基于这几个接口封装了运行时状态实时查看的功能,用户基于这个功能可以了解线程池的实时状态,比如当前有多少个工作线程,执行了多少个任务,队列中等待的任务数等等。效果如下图所示:

图25 线程池实时运行情况

3.4 实践总结

面对业务中使用线程池遇到的实际问题,我们曾回到支持并发性问题本身来思考有没有取代线程池的方案,也曾尝试着去追求线程池参数设置的合理性,但面对业界方案具体落地的复杂性、可维护性以及真实运行环境的不确定性,我们在前两个方向上可谓“举步维艰”。最终,我们回到线程池参数动态化方向上探索,得出一个且可以解决业务问题的方案,虽然本质上还是没有逃离使用线程池的范畴,但是在成本和收益之间,算是取得了一个很好的平衡。成本在于实现动态化以及监控成本不高,收益在于:在不颠覆原有线程池使用方式的基础之上,从降低线程池参数修改的成本以及多维度监控这两个方面降低了故障发生的概率。希望本文提供的动态化线程池思路能对大家有帮助。

四、参考资料

作者简介

  • 致远,2018年加入美团点评,美团到店综合研发中心后台开发工程师。
  • 陆晨,2015年加入美团点评,美团到店综合研发中心后台技术专家。

招聘信息

美团到店综合研发中心长期招聘前端、后端、数据仓库、机器学习/数据挖掘算法工程师,欢迎感兴趣的同学发送简历到:tech@meituan.com(邮件标题注明:美团到店综合研发中心-上海)

阅读更多技术文章,请扫码关注微信公众号-美团技术团队!

查看原文

赞 27 收藏 23 评论 0

Richard_Yi 发布了文章 · 2020-03-30

有个定时任务突然不执行了,别急,原因可能在这

原文地址:有个定时任务突然不执行了,别急,原因可能在这

转载请注明出处!

小伙伴们,我们一起来避坑😅😅

问题描述

程序发版之后一个定时任务突然挂了!

undefined

“幸亏是用灰度跑的,不然完蛋了。😭”

之前因为在线程池踩过坑,阅读过ThreadPoolExecutor的源码,自以为不会再踩坑,没想到又一不小心踩坑了,只不过这次的坑踩在了ScheduledThreadPoolExecutor上面。写代码真的是要注意细节上的东西。

ScheduledThreadPoolExecutorThreadPoolExecutor功能的延伸(继承关系),按照以前的经验,很快就知道的问题所在,特此记录一下。希望小伙伴们别重蹈覆辙。

问题重现

代码模拟:

public class ScheduledExecutorTest {

    private static LongAdder longAdder = new LongAdder();

    public static void main(String[] args) {

        ScheduledExecutorService scheduledExecutor = Executors.newSingleThreadScheduledExecutor();
        
        scheduledExecutor.scheduleAtFixedRate(ThreadExecutorExample::doTask,
                1, 1, TimeUnit.SECONDS);
    }
    
    private static void doTask() {

        int count = longAdder.intValue();
        longAdder.increment();
        
        System.out.println("定时任务开始执行 === " + count);
        
        // ① 下面这一段注释前和注释后的区别
        if (count == 3) {
            throw new RuntimeException("some runtime exception");
        }
    }
}

代码块①注释的情况下,执行结果:

定时任务开始执行 === 0
定时任务开始执行 === 1
定时任务开始执行 === 2
定时任务开始执行 === 3
定时任务开始执行 === 4
定时任务开始执行 === 5
定时任务开始执行 === 6
定时任务开始执行 === 7
定时任务开始执行 === 8
.... 会一直执行下去

代码块①不注释的情况下,执行结果:

定时任务开始执行 === 0
定时任务开始执行 === 1
定时任务开始执行 === 2
定时任务开始执行 === 3
// 停止输出,任务不再被执行

初步结论

因为任务最外面没有用try-catch 捕捉,或者说任务执行时,遇到了 Uncaught Exception,所以导致这个定时任务停止执行了。

走进源码看问题

有了初步的结论,我们需要知道的就是,ScheduledExecutorService这个定时线程调度器(定时任务线程池)在碰到 Uncaught Exception 的时候,是怎么处理的,是在哪一块导致任务停止的?

之前是看过ThreadPoolExecutor的源码,当线程池的线程工作时抛出 Uncaught Exception 时,会这个线程抛弃掉,然后再新启一个worker,来执行任务。在这里显然不一样,因为这个问题的主体是定时任务,定时任务的后续执行停止了,而不是worker线程。

带着问题,我们走进源码去看更深层次的答案。

这里说一句,本文不会成为ScheduledThreadPoolExecutor的完整源码解析,只是在具体问题场景下,讨论源码的运行。
ScheduledExecutorService scheduledExecutor = Executors.newSingleThreadScheduledExecutor();

先看生成的ScheduledExecutorService实例,

    public static ScheduledExecutorService newSingleThreadScheduledExecutor() {
        return new DelegatedScheduledExecutorService
            (new ScheduledThreadPoolExecutor(1));
    }

返回了一个DelegatedScheduledExecutorService对象,

    static class DelegatedScheduledExecutorService
            extends DelegatedExecutorService
            implements ScheduledExecutorService {
        private final ScheduledExecutorService e;
        DelegatedScheduledExecutorService(ScheduledExecutorService executor) {
            super(executor);
            e = executor;
        }
        public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
            return e.schedule(command, delay, unit);
        }
        public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) {
            return e.schedule(callable, delay, unit);
        }
        public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) {
            return e.scheduleAtFixedRate(command, initialDelay, period, unit);
        }
        public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit) {
            return e.scheduleWithFixedDelay(command, initialDelay, delay, unit);
        }
    }

发现这个类实际上就是把ScheduledExecutorService 包装了一层,实际上的动作是由ScheduledThreadPoolExecutor类执行的。

所以我们再进去看,这里我们关注的scheduleAtFixedRate(...)方法,也就是计划执行定时任务的方法。

我们先不急着看方法的实现,先看下它的接口层ScheduledExecutorService,这个方法的 JavaDoc 上面写了这么一段话:

If any execution of the task encounters an exception, subsequent executions are suppressed.
Otherwise, the task will only terminate via cancellation or termination of the executor. If any execution of this task takes longer than its period, then subsequent executions may start late, but will not concurrently execute.

<font color = 'red'>如果任务的任何一次执行遇到异常,则将禁止后续执行</font>。其他情况下,任务将仅通过取消操作或终止线程池来停止。

如果某一次的执行时间超过了任务的间隔时间,后续任务会等当前这次执行结束才执行。

这个方法的注释,已经告诉我们了在使用这个方法的时候,要注意的事项了。

  1. 要注意发生异常时,任务终止的情况。
  2. 要注意定时任务调度会等待正在执行的任务结束,才会发起下一轮调度,即使超过了间隔时间。
这里说一句,线程池的使用中,注释真的十分关键,把坑说的很清楚。(mdzz,说了那么多你自己还不是没看😓😓)

这个注释已经解释了一大半,但是我们这个是源码解析,当然看看里面是怎么做的,

    public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                                  long initialDelay,
                                                  long period,
                                                  TimeUnit unit) {
        if (command == null || unit == null)
            throw new NullPointerException();
        if (period <= 0)
            throw new IllegalArgumentException();
        // ①
        ScheduledFutureTask<Void> sft =
            new ScheduledFutureTask<Void>(command,
                                          null,
                                          triggerTime(initialDelay, unit),
                                          unit.toNanos(period));
        RunnableScheduledFuture<Void> t = decorateTask(command, sft);
        sft.outerTask = t;
        delayedExecute(t);
        return t;
    }

    protected <V> RunnableScheduledFuture<V> decorateTask(
        Runnable runnable, RunnableScheduledFuture<V> task) {
        return task;
    }

    private void delayedExecute(RunnableScheduledFuture<?> task) {
        if (isShutdown())
            reject(task);
        else {
            super.getQueue().add(task);
            if (isShutdown() &&
                !canRunInCurrentRunState(task.isPeriodic()) &&
                remove(task))
                task.cancel(false);
            else
                ensurePrestart();
        }
    }

这里的核心逻辑就是将 Runnable 包装成了一个ScheduledFutureTask对象,这个包装是在FutureTask基础上增加了定时调度需要的一些数据。(FutureTask是线程池的核心类之一)

decorateTask是一个钩子方法,用来给扩展用的,在这里的默认实现就是返回ScheduledFutureTask本身。

然后主逻辑就是通过delayedExecute放入队列中。(这里省略对源码中线程池shutdown情况处理的解释)


这里我们放一张图,简单描述一下ScheduledThreadPoolExecutor工作的过程:

我们很容易都推断出来,我们想要找的对于 Uncaught Exception 逻辑的处理肯定是在任务执行的时候,从哪里可以看出来呢,就是ScheduledFutureTaskrun方法。

        public void run() {
            // 是否是周期性任务
            boolean periodic = isPeriodic();
            // 如果不可以在当前状态下运行,就取消任务(将这个任务的状态设置为CANCELLED)。
            if (!canRunInCurrentRunState(periodic))
                cancel(false);
            else if (!periodic)
                // 如果不是周期性的任务,调用 FutureTask # run 方法
                ScheduledFutureTask.super.run();
            else if (ScheduledFutureTask.super.runAndReset()) {
                // 如果是周期性的。
                // 执行任务,但不设置返回值,成功后返回 true。
                
                // 设置下次执行时间
                setNextRunTime();
                // 再次将任务添加到队列中
                reExecutePeriodic(outerTask);
            }
        }

这里我们关注的是ScheduledFutureTask.super.runAndReset(),实际上调用的是其父类FutureTask

runAndReset()方法,这个方法会在执行成功之后重置线程状态,reset就是这个语义。

可以看到,当上述方法执行返回false的时候,就不会再次将任务添加的队列中,这和我们最开始看到的异常情况是一致的,看来答案就在这个方法里面。那我们接下去看看。

    protected boolean runAndReset() {
        if (state != NEW ||
            !UNSAFE.compareAndSwapObject(this, runnerOffset,
                                         null, Thread.currentThread()))
            return false;
        boolean ran = false;
        int s = state;
        try {
            Callable<V> c = callable;
            if (c != null && s == NEW) {
                try {
                    // ① 任务执行
                    c.call(); // don't set result
                    ran = true;
                } catch (Throwable ex) {
                    setException(ex);
                }
            }
        } finally {
            // runner must be non-null until state is settled to
            // prevent concurrent calls to run()
            runner = null;
            // state must be re-read after nulling runner to prevent
            // leaked interrupts
            s = state;
            if (s >= INTERRUPTING)
                handlePossibleCancellationInterrupt(s);
        }
        // 
        return ran && s == NEW;
    }

代码块①是执行任务的地方,这里有一个默认为false的ran变量,当任务执行成功时,ran会被设成 true,即任务已执行。可以看到当代码块①抛出异常的时候,ran 等于false,runAndReset()返回给调用方的最终结果是false,也就应验了我们上面说的逻辑走向。

总结

整篇文章到这里结束啦,本篇主要介绍了当ScheduledThreadPoolExecutor碰到 Uncaught Exception 时的源码处理逻辑。我们自己在使用这个线程池时,需要注意对任务运行时异常的处理(最简单的方式就是在最外层加个try-catch ,然后捕捉打印日志)。

如果本文有帮助到你,希望能点个赞,这是对我的最大动力🤝🤝🤗🤗。
查看原文

赞 2 收藏 2 评论 0

Richard_Yi 回答了问题 · 2020-03-26

zookeeper如何让所有节点更新最新值的?

你前面理解的没错。这种情况是存在的。zk 写入是必须通过 leader 串行的写入,而且只要一半以上的节点写入成功即可。而任何节点都可提供读取服务。例如:zk,有 1~5 个节点,写入了一个最新的数据,最新数据写入到节点 1~3,会返回成功。然后读取请求过来要读取最新的节点数据,请求可能被分配到节点 4~5 。而此时最新数据还没有同步到节点4~5。会读取不到最近的数据。如果想要读取到最新的数据,可以在读取前使用 sync 命令。

至于说一致性,一致性也有很多种类型。ZooKeeper 文档中明确写明它的一致性是 Sequential consistency (顺序一致性)

我目测你理解的这种是分布式事务中的2PC(二阶段提交协议)的“全量通过”

zk 使用的是ZAB协议,ZAB协议中多次用到“过半”设计策略 ,该策略是zk在A(可用性)与C(一致性)间做的取舍,也是zk具有高容错特性的本质。相较分布式事务中的2PC(二阶段提交协议)的“全量通过”,ZAB协议可用性更高(牺牲了部分一致性),能在集群半数以下服务宕机时正常对外提供服务。

具体更多的内容,如果想了解,最好去看看《从paxos到Zookeeper分布式一致性原理与实践》

关注 2 回答 1

Richard_Yi 赞了文章 · 2020-03-26

一杯茶的时间,上手 Docker

努力工作,然后进入梦乡,“工作”和“做梦”之间好像没有任何关联;编写代码,然后部署应用,这两者似乎也是天各一边。然而果真如此吗?这篇文章将通过《盗梦空间》的方式打开 Docker,让你实现从“做梦”到“筑梦”的实质性转变。在原先的“做梦”阶段(手动配置和部署),一切都充满了随机性和不可控性,你有时甚至都无法回忆起具体做的每一步;而在“筑梦”阶段(借助 Docker),你将通过自动化、高度可重复且可追踪的方式轻松实现任何配置和部署任务。希望读完这篇文章的你,也能成为一个优秀的“筑梦师”!

如果您觉得我们写得还不错,记得 点赞 + 关注 + 评论 三连,鼓励我们写出更好的教程?

准备工作

写在前面的话

很多朋友跟我们反馈说,“一杯茶”纯粹就是忽悠人,写那么长,怎么可能在一杯茶的时间内看完?实际上,“饮茶”的方式因人而异,不同的读者自有不同的节奏。你完全可以选择一目十行、甚至只浏览一下插图,几分钟的时间便能看完;也可以选择跟着我们一步一步动手实践,甚至在有些地方停下来思考一番,虽然需要花更多的时间,但是我们相信这份投入的时间一定是值得的。

其次,我们想确认你是否是这篇文章的受众:

  1. 如果你已经是每天操纵数以千计容器的 DevOps 大佬,那么很抱歉打扰了,这篇文章对你来说可能过于简单;
  2. 如果你已经比较熟悉 Docker 了,想要更多的实战操作经验,这篇文章能够较好地帮助你复习和巩固关键的知识点;
  3. 如果你只听说过 Docker,但是基本上不会用,那么这篇文章就是为你准备的!只不过友情提醒:Docker 上手略有难度,想要真正掌握需要投入足够的时间,认真读完这篇文章一定能让你有相当大的进步

最后,每个小节的结构都是实战演练 + 回忆与升华。回忆与升华部分是笔者花了不少时间对优质资源进行搜集和整合而成,并结合了自身使用容器的经验,相信能够进一步加深你的理解,如果你赶时间的话,也可以略过哦。

PS:这篇文章并没有像常规的 Docker 教程一样上来就郑重其事地讲 Docker 的背景、概念、优势(很有可能你已经听到耳朵生茧了hhh),而是完全通过实践的方式直观地理解 Docker。在最后,我们还是会贴出经典的 Docker 架构图,结合之前的操作体验,相信你会有了然于胸的感觉。

前提条件

在正式阅读这篇文章之前,我们希望你已经具备以下条件:

  • 最基本的命令行操作经验
  • 对计算机网络有一定的了解,特别是应用层中的端口这一概念
  • 最好经历过配环境、部署项目的痛苦挣扎?

我们将实现什么

现在假定你手头已经有了一个 React 编写的“梦想清单”项目,如下面这个动图所示:

我们将在这篇文章中教你一步步用 Docker 将这个应用容器化,用 Nginx 服务器提供构建好的静态页面。

你将学会

这篇文章不会涉及 ...

当然咯,这篇文章作为一篇入门性质的教程,以下进阶内容不会涉及:

  • Docker 网络机制
  • 数据卷和 Bind Mount 实现数据分享
  • Docker Compose
  • 多阶段构建(Multi-stage Build)
  • Docker Machine 工具
  • 容器编排技术,例如 Kubernetes 以及 Docker Swarm

以上进阶知识我们会马上推出相关教程,敬请期待。

安装 Docker

我们推荐各个平台用以下方式安装 Docker(经过我们反复测试哦)。

Windows

菜鸟教程中详细介绍了 Win7/8 以及 Win10 的不同推荐安装方法。注意 Win10 建议开启 Hyper-V 虚拟化技术。

macOS

可通过点击官方下载链接下载并安装 DMG 文件(如果速度慢的话可以把链接复制进迅雷哦)。安装完毕之后,点击 Docker 应用图标即可打开。

Linux

对于各大 Linux 发行版(Ubuntu、CentOS 等等),我们推荐用官方脚本进行安装,方便快捷:

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

然后推荐将 docker 的权限移交给非 root 用户,这样使用 docker 就不需要每次都 sudo 了:

sudo usermod -aG docker $USER

注销用户或者重启之后就会生效。然后通过 systemd 服务配置 Docker 开机启动:

sudo systemctl enable docker

配置镜像仓库

默认的镜像仓库 Docker Hub 在国外,国内拉取速度比较感人。建议参考这篇文章配置镜像加速。

镜像与容器:筑梦师的图纸和梦境

镜像(Image)和容器(Container)是 Docker 中最为基础也是最为关键的两个概念,前者就是筑梦师的图纸,根据这张图纸的内容,就能够生成完全可预测的梦境(也就是后者)。

提示

如果你觉得这个比喻难以理解,那么可以通过面向对象编程中“类”(class)和“实例”(instance)这两个概念进行类比,“类”就相当于“镜像”,“实例”就相当于“容器”。

小试牛刀:梦开始的地方

在略微接触了镜像与容器这两个基础概念之后,我们打算暂停理论的讲解,而先来一波小实验让你快速感受一下。

实验一:Hello World!

按照历史惯例,我们运行一下来自 Docker 的 Hello World,命令如下:

docker run hello-world

输出如下:

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
1b930d010525: Pull complete
Digest: sha256:fb158b7ad66f4d58aa66c4455858230cd2eab4cdf29b13e5c3628a6bfc2e9f05
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
...

不就打印了一个字符串然后退出吗,有这么神奇?其实 Docker 为我们默默做了以下事情:

  1. 检查本地是否有指定的 hello-world:latest 镜像(latest 是镜像标签,后面会细讲),如果没有,执行第 2 步,否则直接执行第 3 步
  2. 本地没有指定镜像(Unable to find xxx locally),从 Docker Hub 下载到本地
  3. 根据本地的 hello-world:latest 镜像创建一个新的容器并运行其中的程序
  4. 运行完毕后,容器退出,控制权返回给用户

实验二:运行一个 Nginx 服务器

感觉太简单?我们来尝试一个高级一点的:运行一个 Nginx 服务器。运行以下命令

docker run -p 8080:80 nginx

运行之后,你会发现一直卡住,也没有任何输出,但放心你的电脑并没有死机。让我们打开浏览器访问 localhost:8080

这时候熟悉 Nginx 的朋友可能就坐不住了:就一个简简单单的 docker run 命令,就搞定了 Nginx 服务器的安装和部署??没错,你可以继续访问一些不存在的路由,比如 localhost:8080/what,同样会提示 404。这时候我们再看 Docker 容器的输出,就有内容(服务器日志)了:

总结一下刚才 Docker 做的事情:

  1. 检查本地是否有指定的 nginx:latest 镜像(关于 latest 标签,后面会细讲),如果没有,执行第 2 步,否则直接执行第 3 步
  2. 本地没有指定镜像(Unable to find xxx locally),从 Docker Hub 下载到本地
  3. 根据本地的 nginx:latest 镜像创建一个新的容器,并通过 -p--publish)参数建立本机的 8080 端口与容器的 80 端口之间的映射,然后运行其中的程序
  4. Nginx 服务器程序保持运行,容器也不会退出
提示

端口映射规则的格式为 <本机端口>:<容器端口>。Nginx 容器默认开放了 80 端口,我们通过设置 8080:80 的端口映射规则,就可以在本机(容器之外)通过访问 localhost:8080 访问,甚至可以在同一局域网内通过内网 IP 访问,这篇文章的最后会演示哦。

实验三:后台运行 Nginx

看上去很酷,不过像 Nginx 服务器这样的进程我们更希望把它抛到后台一直运行。按 Ctrl + C 退出当前的容器,然后再次运行以下命令:

docker run -p 8080:80 --name my-nginx -d nginx

注意到与之前不同的是,我们:

  • 加了一个参数 --name,用于指定容器名称为 my-nginx
  • 加了一个选项 -d--detach),表示“后台运行”
警告

容器的名称必须是唯一的,如果已经存在同一名称的容器(即使已经不再运行)就会创建失败。如果遇到这种情况,可以删除之前不需要的容器(后面会讲解怎么删除)。

Docker 会输出一串长长的 64 位容器 ID,然后把终端的控制权返回给了我们。我们试着访问 localhost:8080,还能看到那一串熟悉的 Welcome to nginx!,说明服务器真的在后台运行起来了。

那我们怎么管理这个服务器呢?就像熟悉的 UNIX ps 命令一样,docker ps 命令可以让我们查看当前容器的状态:

docker ps

输出结果是这样的:

提示

由于 docker ps 的输出比较宽,如果你觉得结果不直观的话可以把终端(命令行)拉长,如下图所示:

从这张表中,就可以清晰地看到了我们在后台运行的 Nginx 服务器容器的一些信息:

  • 容器 ID(Container ID)为 0bddac16b8d8(你机器上的可能不一样)
  • 所用镜像(Image)为 nginx
  • 运行命令/程序(Command)为 nginx -g 'daemon of...,这个是 Nginx 镜像自带的运行命令,暂时不用关心
  • 创建时间(Created)为 45 seconds ago(45 秒钟之前)
  • 当前状态(Status)为 Up 44 seconds(已运行 44 秒钟)
  • 端口(Ports)为 0.0.0.0:8080->80/tcp,意思是访问本机的 0.0.0.0:8080 的所有请求会被转发到该容器的 TCP 80 端口
  • 名称(Names)为刚才指定的 my-nginx

如果我们要让容器停下来,通过 docker stop 命令指定容器名称或 ID 进行操作即可,命令如下:

docker stop my-nginx
# docker stop 0bddac16b8d8
注意

如果指定容器 ID 的话,记得要换成自己机器上真实的 ID 哦。此外,在没有冲突的情况下,ID 可以只写前几位字符,例如写 0bd 也是可以的。

实验四:交互式运行

在过了一把 Nginx 服务器的瘾之后,我们再来体验一下 Docker 容器的另一种打开方式:交互式运行。运行以下命令,让我们进入到一个 Ubuntu 镜像中:

docker run -it --name dreamland ubuntu

可以看到我们加了 -it 选项,等于是同时指定 -i--interactive,交互式模式)和 -t--tty,分配一个模拟终端) 两个选项。以上命令的输出如下:

Unable to find image 'ubuntu:latest' locally
latest: Pulling from library/ubuntu
2746a4a261c9: Pull complete
4c1d20cdee96: Pull complete
0d3160e1d0de: Pull complete
c8e37668deea: Pull complete
Digest: sha256:9207fc49baba2e62841d610598cb2d3107ada610acd4f47252faf73ed4026480
Status: Downloaded newer image for ubuntu:latest
root@94279dbf5d93:/#

等下,我们怎么被抛在了一个新的命令行里面?没错,你现在已经在这个 Ubuntu 镜像构筑的“梦境”之中,你可以随意地“游走”,运行一些命令:

root@94279dbf5d93:/# whoami
root
root@94279dbf5d93:/# ls
bin   dev  home  lib64  mnt  proc  run   srv  tmp  var
boot  etc  lib   media  opt  root  sbin  sys  usr

例如我们在上面运行了 whoamils 命令,你基本上可以确定现在已经在“梦境”(容器)之中了。这时候打开一个新的终端(命令行),运行 docker ps 命令,就可以看到正在运行中的 Ubuntu 镜像:

回到之前的容器中,按 Ctrl + D (或者输入 exit 命令)即可退出。你可以在之前查看 docker ps 的终端再次检查容器是否已经被关闭了。

销毁容器:听梦碎的声音

筑梦师难免会有失败的作品,而我们刚才创建的 Docker 容器也只是用于初步探索,后续不会再用到。由于 Docker 容器是直接存储在我们本地硬盘上的,及时清理容器也能够让我们的硬盘压力小一些。我们可以通过以下命令查看所有容器(包括已经停止的):

docker ps -a

-a--all)用于显示所有容器,如果不加的话只会显示运行中的容器。可以看到输出如下(这里我把终端拉宽了,方便你看):

提示

你也许观察到,之前的实验一和实验二中我们没有指定容器名称,Docker 为我们取了颇为有趣的默认容器名称(比如 hardcore_nash),格式是一个随机的形容词加上一位著名科学家/程序员的姓氏(运气好的话,你可能会看到 Linux 之父 torvalds 哦)。

类似 Shell 中的 rm 命令,我们可以通过 docker rm 命令销毁容器,例如删除我们之前创建的 dreamland 容器:

docker rm dreamland
# 或者指定容器 ID,记得替换成自己机器上的
# docker rm 94279dbf5d93

但如果我们想要销毁所有容器怎么办?一次次输入 docker rm 删除显然不方便,可以通过以下命令轻松删除所有容器

docker rm $(docker ps -aq)

docker ps -aq 会输出所有容器的 ID,然后作为参数传给 docker rm 命令,就可以根据 ID 删除所有容器啦。

危险!

执行之前一定要仔细检查是否还有有价值的容器(特别是业务数据),因为容器一旦删除无法再找回(这里不讨论硬盘恢复这种黑科技)!

回忆与升华

关于端口映射

可能有些同学还是没有完全理解“端口映射”的概念,以 8080:80 这一条映射规则为例,我们可以用“传送门”的比喻来理解(下面的图是《传送门2》游戏的封面):

还是把容器比作“梦境”,把本机环境比作“现实”,通过建立端口映射,访问本机的 8080 端口的请求就会被“传送”到容器的 80 端口,是不是很神奇呢。

容器生命周期:梦境地图

跟着做完上面四个小实验之后,你或许已经对 Docker 容器有了非常直观的感受和理解了。是时候祭出这张十(sang)分(xin)经(bing)典(kuang)的 Docker 容器生命周期图了(来源:https://docker-saigon.github....):

这张图乍一看颇具视觉冲击力,甚至会让你感觉不知所措。没事,我们大致地解读这张图里面的四类元素:

  1. 容器状态(带颜色的圆圈):包括已创建(Created)、运行中(Running)、已暂停(Paused)、已停止(Stopped)以及被删除(Deleted)
  2. Docker 命令(箭头上以 docker 开头的文字):包括 docker rundocker createdocker stop 等等
  3. 事件(矩形框):包括 createstartdiestop 还有 OOM(内存耗尽)等等
  4. 还有一个条件判断,根据重启策略(Restart Policy)判断是否需要重新启动容器

OK,这张图还是很难一下子理解,不过还记得刚才我们做的四个小实验吗?我们实际上走了一共两条路径(也是日常使用中走的最多的路),接下来将一一进行分析。

第一条路径(自然结束)

如上图所示:

  • 我们先通过 docker run 命令,直接创建(create)并启动(start)一个容器,进入到运行状态(Running)
  • 然后程序运行结束(例如输出 Hello World 之后,或者通过 Ctrl + C 使得程序终止),容器死亡(die)
  • 由于我们没有设置重启策略,所以直接进入到停止状态(Stopped)
  • 最后通过 docker rm 命令销毁容器,进入到被删除状态(Deleted)

第二条路径(强制结束)

  • 我们还是通过 docker run 命令,直接创建(create)并启动(start)一个容器,进入到运行状态(Running)
  • 然后通过 docker stop 命令杀死容器中的程序(die)并停止(stop)容器,最终进入到停止状态(Stopped)
  • 最后通过 docker rm 命令销毁容器,进入到被删除状态(Deleted)
提示

有些眼尖的读者可能发现 docker killdocker stop 的功能非常相似,它们之前存在细微的区别: kill 命令向容器内运行的程序直接发出 SIGKILL 信号(或其他指定信号),而 stop 则是先发出 SIGTERM 再发出 SIGKILL 信号,属于优雅关闭(Graceful Shutdown)。

一条捷径:删除运行中的容器

生命周期图其实有一条捷径没有画出来:直接从运行中(或暂停中)到被删除,通过给 docker rm 命令加上选项 -f--force,强制执行)就可以实现:

# 假设 dreamland 还在运行中
docker rm -f dreamland

同样地,我们可以删除所有容器,无论处于什么状态:

docker rm -f $(docker ps -aq)

自由探索

你尽可以自由探索其他我们没走过的路线,例如尝试再次启动之前已经停止的容器(docker start),或者暂停正在运行的容器(docker pause)。幸运的是,docker help 命令可以为我们提供探索的指南针,例如我们想了解 start 命令的使用方法:

$ docker help start

Usage:    docker start [OPTIONS] CONTAINER [CONTAINER...]

Start one or more stopped containers

Options:
  -a, --attach                  Attach STDOUT/STDERR and forward signals
      --checkpoint string       Restore from this checkpoint
      --checkpoint-dir string   Use a custom checkpoint storage directory
      --detach-keys string      Override the key sequence for
                                detaching a container
  -i, --interactive             Attach container's STDIN

读到这里,相信你已经了解了如何利用现有的镜像创造容器,并进行管理。在接下来,我们将带你创建自己的 Docker 镜像,开始成为一名标准的“筑梦师”!

容器化第一个应用:开启筑梦之旅

在之前的步骤中,我们体验了别人为我们提前准备好的镜像(例如 hello-worldnginxubuntu),这些镜像都可以在 Docker Hub 镜像仓库中找到。在这一步,我们将开始筑梦之旅:学习如何容器化(Containerization)你的应用。

正如开头所说,我们将容器化一个全栈的”梦想清单“应用,运行以下命令来获取代码,然后进入项目:

git clone -b start-point https://github.com/tuture-dev/docker-dream.git
cd docker-dream

在这一步中,我们将容器化这个用 React 编写的前端应用,用 Nginx 来提供前端页面的访问。

什么是容器化

容器化包括三个阶段:

  • 编写代码:我们已经提供了写好的代码
  • 构建镜像:也就是这一节的核心内容,下面会详细展开
  • 创建和运行容器:通过容器的方式运行我们的应用

构建镜像

构建 Docker 镜像主要包括两种方式:

  1. 手动:根据现有的镜像创建并运行一个容器,进入其中进行修改,然后运行 docker commit 命令根据修改后的容器创建新的镜像
  2. 自动:创建 Dockerfile 文件,指定构建镜像的命令,然后通过 docker build 命令直接创建镜像

由于篇幅有限,这篇文章只会讲解使用最为广泛的第二种创建镜像的方式。

一些准备工作

我们先把前端项目 client 构建成一个静态页面。确保你的机器上已经安装 Node 和 npm(点击这里下载,或使用 nvm),然后进入到 client 目录下,安装所有依赖,并构建项目:

cd client
npm install
npm run build

等待一阵子后,你应该可以看到 client/build 目录,存放了我们要展示的前端静态页面。

创建 Nginx 配置文件 client/config/nginx.conf,代码如下:

server {
    listen 80;
    root /www;
    index index.html;
    sendfile on;
    sendfile_max_chunk 1M;
    tcp_nopush on;
    gzip_static on;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

不熟悉 Nginx 配置的同学不用担心哦,直接复制粘贴就可以了。上面的配置大致意思是:监听 80 端口,网页根目录在 /www,首页文件是 index.html,如果访问 / 则提供文件 index.html

创建 Dockerfile

然后就是这一步骤中最重要的代码:Dockerfile!创建 client/Dockerfile 文件,代码如下:

FROM nginx:1.13

# 删除 Nginx 的默认配置
RUN rm /etc/nginx/conf.d/default.conf

# 添加自定义 Nginx 配置
COPY config/nginx.conf /etc/nginx/conf.d/

# 将前端静态文件拷贝到容器的 /www 目录下
COPY build /www

可以看到我们用了 Dockerfile 中的三个指令:

  • FROM 用于指定基础镜像,这里我们基于 nginx:1.13 镜像作为构建的起点
  • RUN 命令用于在容器内运行任何命令(当然前提是命令必须存在)
  • COPY 命令用于从 Dockerfile 所在的目录拷贝文件到容器指定的路径

是时候来构建我们的镜像了,运行以下命令:

# 如果你已经在 client 目录中
#(注意最后面有个点,代表当前目录)
docker build -t dream-client .

# 如果你回到了项目根目录
docker build -t dream-client client

可以看到我们指定了 -t--tag,容器标签)为 dream-client,最后指定了构建容器的上下文目录(也就是 当前目录 .client)。

运行以上的命令之后,你会发现:

Sending build context to Docker daemon:66.6MB

而且这个数字还在不断变大,就像黑客科幻电影中的场景一样,最后应该停在了 290MB 左右。接着运行了一系列的 Step(4 个),然后提示镜像构建成功。

为啥这个构建上下文(Build Context)这么大?因为我们把比“黑洞”还“重”的 node_modules 也加进去了!(忍不住想起了下面这张图)

使用 .dockerignore 忽略不需要的文件

Docker 提供了类似 .gitignore 的机制,让我们可以在构建镜像时忽略特定的文件或目录。创建 client/.dockerignore 文件(注意 dockerignore 前面有一个点):

node_modules

很简单,我们只想忽略掉可怕的 node_modules。再次运行构建命令:

docker build -t dream-client .

太好了!这次只有 1.386MB,而且速度也明显快了很多!

运行容器

终于到了容器化的最后一步——创建并运行我们的容器!通过以下命令运行刚才创建的 dream-client 镜像:

docker run -p 8080:80 --name client -d dream-client

与之前类似,我们还是设定端口映射规则为 8080:80,容器名称为 client,并且通过 -d 设置为后台运行。然后访问 localhost:8080

成功了!一开始定下的三个梦想也都完成了!

提示

甚至,我们已经可以通过内网来访问“梦想清单”了。Linux 或 macOS 的同学可以在终端输入 ifconfig 命令查询本机内网 IP,Windows 的同学则是在 CMD 输入 ipconfig 查询本机内网 IP,一般是以 10172.16~172.31192.168 开头。例如我的内网 IP 是 192.168.0.2,那么在同一局域网下(一般是 WiFi),可以用其他设备(比如说你的手机)访问 192.168.0.2:8080

回忆与升华

关于镜像标签

在刚才的实战中,你也许已经注意到在拉取和构建镜像时,Docker 总是会为我们加上一个 :latest 标签,这个 :latest 的含义便是“最新”的意思。和软件的版本机制一样,镜像也可以通过标签实现“版本化”。

注意

latest 字面上的意思的确是“最新的”,但也只是一个普通的标签,并不能确保真的是“最新的”,更不会自动更新。更多讨论请参考这篇文章

实际上,我们完全可以在拉取或构建镜像时指定标签(通常被认为是一种好的做法):

docker pull nginx:1.13
docker build -t dream-client:1.0.0

还可以给现有的镜像打上标签:

# 把默认的 latest 镜像打上一个 newest 标签
docker tag dream-client dream-client:newest
# 甚至可以同时修改镜像的名称和标签
docker tag dream-client:1.0.0 dream-client2:latest

可以看到,标签未必一定是版本,还可以是任何字符串(当然最好要有意义,否则过了一阵子你也不记得这个打了这个标签的容器有什么作用了)。

关于 Dockerfile

Dockerfile 实际上是默认名称,我们当然可以取一个别的名字,例如 myDockerfile,然后在构建镜像时指定 -f--file)参数即可:

docker build -f myDockerfile -t dream-client .

这里举两个经典的使用场景:

  1. 例如在 Web 开发时,分别创建 Dockerfile.dev 用于构建开发镜像,创建 Dockerfile.prod 构建生产环境下的镜像;
  2. 在训练 AI 模型时,创建 Dockerfile.cpu 用于构建用 CPU 训练的镜像,创建 Dockerfile.gpu 构建用 GPU 训练的镜像。

再次思考镜像和容器的关系

经过刚才的容器化实战,相信你对镜像和容器的关系又有了新的理解。请看下面这张图:

在之前的“小试牛刀”环节中(用绿色箭头标出),我们:

  1. 通过 docker pull 从 Docker 镜像仓库拉取镜像到本地
  2. 通过 docker run 命令,根据镜像创建并运行容器
  3. 通过 docker stop 等命令操作容器,使其发生各种状态转变

而在这一节的容器化实战中(用红色箭头标出),我们:

  1. 通过 docker build 命令,根据一个 Dockerfile 文件构建镜像
  2. 通过 docker tag 命令,给镜像打上标签,得到一个新镜像
  3. (由于篇幅有限没有讲)通过 docker commit 命令,将一个现有的容器转化为镜像

俯瞰全景:Docker 架构图

是时候拿出经典的 Docker 架构图了:

可以看到,Docker 遵循经典的客户端-服务器架构(client-server),核心组成部分包括:

  • 服务器(也就是 Docker 守护进程),在 Linux 系统中也就是 dockerd 命令
  • 服务器暴露出的 REST API,提供了与守护进程通信和操作的接口
  • 客户端(也就是我们一直在用的命令行程序 docker

至此,这篇 Docker 快速入门实战教程也就结束啦,希望你已经对 Docker 的概念和使用有了初步的理解。后续我们还会发表 Docker 进阶的内容(例如 Network 网络、Volume 数据卷、Docker Compose 等等),手把手带大家部署一个全栈应用(前后端和数据库)到云主机(或任何你能够登录的机器),敬请期待~

想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。

查看原文

赞 22 收藏 13 评论 1

Richard_Yi 发布了文章 · 2020-03-25

Java 并发编程 ③ - ThreadLocal 和 InheritableThreadLocal 详解

原文地址:Java 并发编程 ③ - ThreadLocal 和 InheritableThreadLocal 详解

转载请注明出处!

前言

往期文章:

继上一篇结尾讲的,这一篇文章主要是讲ThreadLocal 和 InheritableThreadLocal。主要内容有:

  • ThreadLocal 使用 和 实现原理
  • ThreadLocal 副作用

    • 脏数据
    • 内存泄漏的分析
  • InheritableThreadLocal 使用 和 实现原理

一、ThreadLocal

ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,即变量在线程间隔离而在方法或类间共享的场景。 确切的来说,ThreadLocal 并不是专门为了解决多线程共享变量产生的并发问题而出来的,而是给提供了一个新的思路,曲线救国。

通过实例代码来简单演示下ThreadLocal的使用。

public class ThreadLocalExample {

    private static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {

        ExecutorService service = Executors.newCachedThreadPool();

        service.execute(() -> {
            System.out.println(Thread.currentThread().getName() + " set 1");
            threadLocal.set(1);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 不会收到线程2的影响,因为ThreadLocal 线程本地存储
            System.out.println(Thread.currentThread().getName() + " get " + threadLocal.get());
            threadLocal.remove();
        });

        service.execute(() -> {
            System.out.println(Thread.currentThread().getName() + " set 2");
            threadLocal.set(2);
            threadLocal.remove();
        });

        ThreadPoolUtil.tryReleasePool(service);
    }
}

可以看到,线程1不会受到线程2的影响,因为ThreadLocal 创建的是线程私有的变量。

二、ThreadLocal 实现原理 ⭐

2.1 理清 ThreadLocal 中几个关键类之间的关系

我们先看下ThreadLocal 与 Thread 的类图,了解他们的主要方法和相互之间的关系。

图中几个类我们标注一下:

  • Thread
  • ThreadLocal
  • ThreadLocalMap
  • ThreadLocalMap.Entry

接下去,我们首先先开始了解这几个类的相互关系:

  1. Thread 类中有一个 threadLocals 成员变量(实际上还有一个inheritableThreadLocals,后面讲),它的类型是ThreadLocal 的内部静态类ThreadLocalMap

    public class Thread implements Runnable {
      
          // ...... 省略
          
        /* ThreadLocal values pertaining to this thread. This map is maintained
     ThreadLocal.ThreadLocalMap threadLocals = null;
 
 ```
 
  1. ThreadLocalMap 是一个定制化的Hashmap,为什么是个HashMap?很好理解,每个线程可以关联多个ThreadLocal变量。

        /**
         * ThreadLocalMap is a customized hash map suitable only for
         * maintaining thread local values. No operations are exported
         * outside of the ThreadLocal class. The class is package private to
         * allow declaration of fields in class Thread.  To help deal with
         * very large and long-lived usages, the hash table entries use
         * WeakReferences for keys. However, since reference queues are not
         * used, stale entries are guaranteed to be removed only when
      */
     static class ThreadLocalMap {
         // ...
     }
 ```
  1. ThreadLocalMap 初始化时会创建一个大小为16的Entry 数组,Entry 对象也是用来保存 key- value 键值对(这个Key固定是ThreadLocal 类型)。值得注意的是,这个Entry 继承了 WeakReference(这个设计是为了防止内存泄漏,后面会讲)

            static class Entry extends WeakReference<ThreadLocal<?>> {
           /** The value associated with this ThreadLocal. */
                Object value;
    
                Entry(ThreadLocal<?> k, Object v) {
                    super(k);
                    value = v;
                }
            }

2.2 ThreadLocal的set、get及remove方法的源码

a. void set(T value)

    public void set(T value) {
        // ① 获取当前线程
        Thread t = Thread.currentThread();
        // ② 去查找对应线程的ThreadLocalMap变量
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            // ③ 第一次调用就创建当前线程的对应的ThreadLocalMap
            // 并且会将值保存进去,key是当前的threadLocal,value就是传进来的值
            createMap(t, value);
    }

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

b. T get()

    public T get() {
        // ① 获取当前线程
        Thread t = Thread.currentThread();
        // ② 去查找对应线程的ThreadLocalMap变量
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            // ③ 不为null,返回当前threadLocal 对应的value值
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        // ④ 当前线程的threadLocalMap为空,初始化
        return setInitialValue();
    }

    private T setInitialValue() {
        // ⑤ 初始化的值为null
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            // 初始化当前线程的threadLocalMap
            createMap(t, value);
        return value;
    }

    protected T initialValue() {
        return null;
    }

c. void remove()

如果当前线程的threadLocals变量不为空,则删除当前线程中指定ThreadLocal实例对应的本地变量。

     public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
     }

从源码中可以看出来,自始至终,这些本地变量不是存放在ThreadLocal实例里面,而是存放在调用线程的threadLocals变量,那个线程私有的threadLocalMap 里面

ThreadLocal就是一个工具壳和一个key,它通过set方法把value值放入调用线程的threadLocals里面并存放起来,当调用线程调用它的get方法时,再从当前线程的threadLocals变量里面将其拿出来使用。

讲到这里,实现原理就算讲完了,实际上ThreadLocal 的源码算是非常简单易懂。关于ThreadLocal 真正的重点和难点,是我们后面的内容。

三、ThreadLocal 副作用

ThreadLocal 是为了线程能够安全的共享/传递某个变量设计的,但是有一定的副作用。

ThreadLocal 的主要问题是会产生脏数据内存泄露

先说一个结论,这两个问题通常是在线程池的线程中使用 ThreadLocal 引发的,因为线程池有线程复用内存常驻两个特点。

3.1 脏数据

脏数据应该是大家比较好理解的,所以这里呢,先拿出来讲。线程复用会产生脏数据。由于线程池会重用 Thread 对象 ,那么与 Thread 绑定的类的静态属性 ThreadLocal 变量也会被重用。如果在实现的线程 run() 方法体中不显式地调用 remove() 清理与线程相关的 ThreadLocal 信息,那么倘若下一个线程不调用 set() 设置初始值,就可能 get() 到重用的线程信息,包括 ThreadLocal 所关联的线程对象的 value 值。

为了便于理解,这里提供一个demo:

public class ThreadLocalDirtyDataDemo {

    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {

        ExecutorService pool = Executors.newFixedThreadPool(1);

        for (int i = 0; i < 2; i++) {
            MyThread thread = new MyThread();
            pool.execute(thread);
        }
        ThreadPoolUtil.tryReleasePool(pool);
    }

    private static class MyThread extends Thread {
        private static boolean flag = true;

        @Override
        public void run() {
            if (flag) {
                // 第一个线程set之后,并没有进行remove
                // 而第二个线程由于某种原因(这里是flag=false) 没有进行set操作
                String sessionInfo = this.getName();
                threadLocal.set(sessionInfo);
                flag = false;
            }
            System.out.println(this.getName() + " 线程 是 " + threadLocal.get());
            // 线程使用完threadLocal,要及时remove,这里是为了演示错误情况
        }
    }
}

执行结果:

Thread-0 线程 是 Thread-0
Thread-1 线程 是 Thread-0

3.2 内存泄露 ⭐

在讲这个之前,有必要看一张图,从栈与堆的角度看看ThreadLocal 使用过程当中几个类的引用关系。

看到红色的虚线箭头没?这个就是理解ThreadLocal的一个重点和难点。

我们再看一遍Entry的源码:

          static class Entry extends WeakReference<ThreadLocal<?>> {
              /** The value associated with this ThreadLocal. */
              Object value;
  
              Entry(ThreadLocal<?> k, Object v) {
                  super(k);
                  value = v;
              }
          }

ThreadLocalMap 的每个 Entry 都是一个对的弱引用 - WeakReference<ThreadLocal<?>>,这一点从super(k)可看出。另外,每个 Entry都包含了一个对 的强引用。

在前面的叙述中,我有提到Entry extends WeakReference<ThreadLocal<?>> 是为了防止内存泄露。实际上,这里说的防止内存泄露是针对ThreadLocal 对象的

怎么说呢?继续往下看。

如果你有学习过Java 中的引用的话,这个WeakReference应该不会陌生,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收<font color='#ff6600' >只被弱引用关联</font>的对象。

更详细的相关内容可以阅读笔者的这篇文章 【万字精美图文带你掌握JVM垃圾回收#Java 中的引用】 )

通过这种设计,即使线程正在执行中, 只要 ThreadLocal 对象引用被置成 null,Entry 的 Key 就会自动在下一次 YGC 时被垃圾回收(因为只剩下ThreadLocalMap 对其的弱引用,没有强引用了)

如果这里Entry 的key 值是对 ThreadLocal 对象的强引用的话,那么即使ThreadLocal的对象引用被声明成null 时,这些 ThreadLocal 不能被回收,因为还有来自 ThreadLocalMap 的强引用,这样子就会造成内存泄漏

这类key被回收( key == null)的Entry 在 ThreadLocalMap 源码中被称为 stale entry (翻译过来就是 “过时的条目”),会在下一次执行 ThreadLocalMap 的 getEntry 和 set 方法中,将 这些 stale entry 的value 置为 null,使得原来value 指向的变量可以被垃圾回收

“会在下一次执行 ThreadLocalMap 的 getEntry 和 set 方法中,将 这些 stale entry 的value 置为 null,使得 原来value 指向的变量可以被垃圾回收”这一部分描述,可以查阅 ThreadLocalMap#expungeStaleEntry()方法源码及调用这个方法的地方。

这样子来看,ThreadLocalMap 是通过这种设计,解决了 ThreadLocal 对象可能会存在的内存泄漏的问题并且对应的value 也会因为上述的 stale entry 机制被垃圾回收


但是我们为什么还会说使用ThreadLocal 可能存在内存泄露问题呢,在这里呢,指的是还存在那个Value(图中的紫色块)实例无法被回收的情况

请注意哦,上述机制的前提是ThreadLocal 的引用被置为null,才会触发弱引用机制,继而回收Entry 的 Value对象实例。我们来看下ThreadLocal 源码中的注释

instances are typically private static fields in classes

ThreadLocal 对象通常作为私有静态变量使用

-- 如果说一个 ThreadLocal 是非静态的,属于某个线程实例类,那就失去了线程内共享的本质属性。

作为静态变量使用的话, 那么其生命周期至少不会随着线程结束而结束。也就是说,绝大多数的静态threadLocal对象都不会被置为null。这样子的话,通过 stale entry 这种机制来清除Value 对象实例这条路是走不通的。必须要手动remove() 才能保证。

这里还是用上面的例子来做示例。

public class ThreadLocalDirtyDataDemo {

    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {

        ExecutorService pool = Executors.newFixedThreadPool(1);

        for (int i = 0; i < 2; i++) {
            MyThread thread = new MyThread();
            pool.execute(thread);
        }
        ThreadPoolUtil.tryReleasePool(pool);
    }

    private static class MyThread extends Thread {
        private static boolean flag = true;

        @Override
        public void run() {
            if (flag) {
                // 第一个线程set之后,并没有进行remove
                // 而第二个线程由于某种原因(这里是flag=false) 没有进行set操作
                String sessionInfo = this.getName();
                threadLocal.set(sessionInfo);
                flag = false;
            }
            System.out.println(this.getName() + " 线程 是 " + threadLocal.get());
            // 线程使用完threadLocal,要及时remove,这里是为了演示错误情况
        }
    }
}

在这个例子当中,如果不进行 remove() 操作, 那么这个线程执行完成后,通过 ThreadLocal 对象持有的 String 对象是不会被释放的。

为什么说只有线程复用的时候,会出现这个问题呢?当然啦,因为这些本地变量都是存储在线程的内部变量中的,当线程销毁时,threadLocalMap的对象引用会被置为null,value实例对象 随着线程的销毁,在内存中成为了不可达对象,然后被垃圾回收。

    // Thread#exit()
    private void exit() {
        if (group != null) {
            group.threadTerminated(this);
            group = null;
        }
        /* Aggressively null out all reference fields: see bug 4006245 */
        target = null;
        /* Speed the release of some of these resources */
        threadLocals = null;
        inheritableThreadLocals = null;
        inheritedAccessControlContext = null;
        blocker = null;
        uncaughtExceptionHandler = null;
    }

总结

总结一下

  • WeakReference 的引入,是为了将ThreadLocal 对象与ThreadLocalMap 设计成一种弱引用的关系,来避免ThreadLocal 实例对象不能被回收而存在的内存泄露问题,当threadLocal 对象被回收时,会有清理 stale entry 机制,回收其对应的Value实例对象。
  • 我们常说的内存泄露问题,针对的是threadLocal对应的Value对象实例。在线程对象被重用且threadLocal为静态变量时,如果没有手动remove(),就可能会造成内存泄露的情况。
  • 上述两种内存泄露的情况只有在线程复用的情况下才会出现,因为在线程销毁时threadLocalMap的对象引用会被置为null。
  • 解决副作用的方法很简单,就是每次用完ThreadLocal,都要及时调用 remove() 方法去清理。

四、InheritableThreadLocal

在一些场景中,子线程需要可以获取父线程的本地变量,比如用一个统一的ID来追踪记录调用链路。但是ThreadLocal 是不支持继承性的,同一个ThreadLocal变量在父线程中被设置值后,在子线程中是获取不到对应的对象的。

为了解决这个问题,InheritableThreadLocal 也就应运而生。

4.1 使用

public class InheritableThreadLocalDemo {

    private static ThreadLocal<String> threadLocal = new InheritableThreadLocal<>();

    public static void main(String[] args) {
        // 主线程
        threadLocal.set("hello world");
        // 启动子线程
        Thread thread = new Thread(() -> {
            // 子线程输出父线程的threadLocal 变量值
            System.out.println("子线程: " + threadLocal.get());
        });

        thread.start();

        System.out.println("main: " +threadLocal.get());

    }
}

输出:

main: hello world
子线程: hello world

4.2 原理

要了解原理,我们先来看一下 InheritableThreadLocal 的源码。

public class InheritableThreadLocal<T> extends ThreadLocal<T> {

    // ①
    protected T childValue(T parentValue) {
        return parentValue;
    }

    // ②
    ThreadLocalMap getMap(Thread t) {
       return t.inheritableThreadLocals;
    }

    // ③
    void createMap(Thread t, T firstValue) {
        t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
    }
}
public class Thread implements Runnable {

    /*
     * InheritableThreadLocal values pertaining to this thread. This map is
     * maintained by the InheritableThreadLocal class.
     */
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

可以看到,InheritableThreadLocal 继承了ThreadLocal,并且重写了三个方法,看来实现的门道就在这三个方法里面。

先看代码③,InheritableThreadLocal 重写了createMap方法,那么现在当第一次调用set方法时,创建的是当前线程的inheritableThreadLocals 变量的实例而不再是threadLocals。由代码②可知,当调用get方法获取当前线程内部的map变量时,获取的是inheritableThreadLocals而不再是threadLocals。

可以这么说,在InheritableThreadLocal的世界里,变量inheritableThreadLocals替代了threadLocals。

代码②③都讲了,再来看看代码①,以及如何让子线程可以访问父线程的本地变量。

这要从创建Thread的代码说起,打开Thread类的默认构造函数,代码如下。

    public Thread(Runnable target) {
        init(null, target, "Thread-" + nextThreadNum(), 0);
    }

    private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
        
        // ... 省略无关部分
        // 获取父线程 - 当前线程
        Thread parent = currentThread();
        
        // ... 省略无关部分
        // 如果父线程的inheritThreadLocals不为null 且 inheritThreadLocals=true
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            // 设置子线程中的inheritableThreadLocals变量
            this.inheritableThreadLocals =
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        // ... 省略无关部分
    }

    static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
        return new ThreadLocalMap(parentMap);
    }

再来看看里面是如何执行createInheritedMap 的。

        private ThreadLocalMap(ThreadLocalMap parentMap) {
            Entry[] parentTable = parentMap.table;
            int len = parentTable.length;
            setThreshold(len);
            table = new Entry[len];

            for (int j = 0; j < len; j++) {
                Entry e = parentTable[j];
                if (e != null) {
                    @SuppressWarnings("unchecked")
                    ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
                    if (key != null) {
                        // 这里调用了重写的代码① childValue
                        Object value = key.childValue(e.value);
                        Entry c = new Entry(key, value);
                        int h = key.threadLocalHashCode & (len - 1);
                        while (table[h] != null)
                            h = nextIndex(h, len);
                        table[h] = c;
                        size++;
                    }
                }
            }
        }

在该构造函数内部把父线程的inheritableThreadLocals成员变量的值复制到新的ThreadLocalMap 对象中。

## 小结

本章讲了ThreadLocal 和 InheritableThreadLocal 的相关知识点。

ThreadLocal 实现线程内部变量共享,InheritableThreadLocal 实现了父线程与子线程的变量继承。但是还有一种场景,InheritableThreadLocal 无法解决,也就是在使用线程池等会池化复用线程的执行组件情况下,异步执行执行任务,需要传递上下文的情况

针对上述情况,阿里开源了一个TTL,即Transmittable ThreadLocal来解决这个问题,有兴趣的朋友们可以去看看。

之后有时间的话我会单独写一篇文章介绍一下。

如果本文有帮助到你,希望能点个赞,这是对我的最大动力????。

参考

  • 《Java 并发编程之美》
  • 《码出高效》
查看原文

赞 3 收藏 3 评论 0

Richard_Yi 发布了文章 · 2020-03-23

Dubbo 服务性能压测(with JMeter)

原文地址:Dubbo 服务性能压测(with JMeter)

转载请注明出处!

前言

最近在做Dubbo服务与Prometheus的监控集成,为了测试监控组件对Dubbo RPC 调用的性能影响,就需要对添加前后做性能测试。虽然之前给组内搭建了统一的Dubbo 服务测试平台,但是无法用于性能测试。

说起性能测试,大家可能会有很多选择,wrk、JMeter等等。但是相信大家一般都是用于测试HTTP接口,对于这种Dubbo框架的这种私有协议dubbo://,这些工具没有提供原生的支持。第一个想法就是通过Dubbo 的泛化调用来自己写一个客户端,然后统计测试结果,但是这样一是不优雅,二是有可能重复造轮子,浪费时间。经过一番google之后,果然得到了想要的答案。

今天要介绍的,就是一款来自于Dubbo 社区的JMeter 插件jmeter-plugins-for-apache-dubbo,使用这款插件,就可以让JMeter 对Dubbo 服务的测试。

本文假定读者使用JMeter 进行过简单的性能测试,并且安装了JMeter

正文

Step 1:安装Dubbo 插件

  1. 克隆项目:git clone https://github.com/thubbo/jmeter-plugins-for-apache-dubbo.git
  2. 打包项目,构建 JMeter 插件:mvn clean install

    或者你可以直接跳过上面两步,下载 jmeter-plugins-dubbo-2.7.3-jar-with-dependencies.jar

  3. 将插件添加到 ${JMETER_HOME}\lib\ext(安装完之后重启jmeter)

image.png

Step 2:编写JMeter 脚本

1、创建Dubbo Sample

【测试计划】 区域右键单击 【线程组】,并选择 【添加】 > 【取样器】 > Dubbo Sample

image.png

Dubbo Sample 对话框中配置注册中心地址、服务接口名(Java interface 类名)、方法名、参数类型和参数值等信息。

配置步骤如下:

  • 配置注册中心,通常使用 ZooKeeper。(配置完成之后,点击上面Get Provider List 按钮获取注册的服务列表)

    • Protocol 选择为 zookeeper,则 Address 填写 ZooKeeper 地址。
    • 若生产环境通常包含多个 ZooKeeper 节点,可填写多个 ZooKeeper 地址并用英文逗号(,)隔开。
    • 若针对单台服务器进行测试,则将 Protocol 选择 noneAddress 填写 Dubbo 服务地址。
  • 按需调整服务调用配置,如分组 Group、版本 Version、调用超时时间 Timeout(默认为 1 秒)等。
  • 配置 Dubbo 服务的完整 Java 接口类名和方法名。
  • 配置每个参数的参数类型和参数值。

    • 参数类型:基本类型(如 boolean, int 等)直接写类型名,其他类型写完整 Java 类名(注意哦,是完整类名)。
    • 参数值:基本类型和字符串直接写参数值,复杂类型用 JSON 表示填写

image.png

为了方便本地调试测试脚本,可以添加结构监听器,右键单击 【线程组】,选择 【添加】 > 【监听器】> 【察看结果树】,添加 察看结果树 监听器。

Step 3:测试执行

【线程组】上右击,点击【验证】,执行单次请求,来测试工具与服务的联通性。

【察看结果树】选项卡中可以看到【响应数据】返回如预期,说明可以正常执行 Dubbo 调用了。

image.png

Step 4:添加断言

有时候你会看到执行结果显示成功,但是实际上Dubbo 服务调用失败了,或者业务处理失败,返回结果中包含了错误码。比如下面两张图。

RPC 调用失败。

image.png

业务处理失败。

image.png

解决方法:

针对此类问题,可以添加断言来检查服务是否成功。泛化调用的结果以 JSON 形式返回,可以添加断言检查返回的 JSON 数据,以更准确的校验服务执行是否成功。

具体步骤就是,在 jmeter的 【测试计划】 区域右键单击 Dubbo Sample,并选择 【添加】 > 【断言】 > 【JSR233 Assertion】

image.png

这里我给出我的groovy 测试脚本代码:

String respStr = null;
Map<String, Object> resp = null;
try {
    respStr = SampleResult.getResponseDataAsString();
    resp = (Map<String, Object>) com.alibaba.fastjson.JSON.parse(respStr);
} catch (Throwable ex) {
    // pass
    log.error("error", ex);
}
if (resp == null) {
    AssertionResult.setFailure(true);
    AssertionResult.setFailureMessage("RESPONSE IS NOT JSON: " + respStr);
} else {
    // 简单检查: dubbo 泛化调用失败时, 返回 JSON 包含 code 和 detailMessage 字段.
    if (resp.get("code") != null && resp.get("detailMessage")) {
        AssertionResult.setFailure(true);
        AssertionResult.setFailureMessage("rpc exception, code=" + resp.get("code") + " detailMessage=" + resp.get("detailMessage"));
    } else if(!"SUCCESS".equals((String)resp.get("code"))) {
        // TODO 根据你自己的实际业务,校验请求是否成功.
        AssertionResult.setFailure(true);
        AssertionResult.setFailureMessage("请求失败, code=" + resp.get("code"));
    } else {
        AssertionResult.setFailure(false);
    }
}

image.png

让我们看看,添加了断言之后,再验证的结果。

image.png

可以看到,断言起到了业务校验的作用,并且提示了报错信息。

至于如何编写脚本的其他部分,就要考虑你要模拟的场景来设置了,属于如何使用JMeter的部分,所以这里就不过多叙述。

更多常见问题

关于该插件的更多常见问题,请参考该插件的github wiki中的FAQ

参考

查看原文

赞 1 收藏 1 评论 0

Richard_Yi 发布了文章 · 2020-03-20

Java 并发编程 ② - 线程生命周期与状态流转

原文地址:Java 并发编程 ② - 线程生命周期与状态流转

转载请注明出处!

前言

往期文章:

继上一篇结尾讲的,这一篇文章主要是讲线程的生命周期以及状态流转。主要内容有:

  • Java 中对线程状态的定义,与操作系统线程状态的对比
  • 线程状态的流转图
  • 如何自己验证状态的流转

一、Java 线程的状态

先来谈一谈Java 中线程的状态。在 java.lang.Thread.State 类是 Thread的内部枚举类,在里面定义了Java 线程的六个状态,-注释信息也非常的详细。

    public enum State {
        
        /**
         * Thread state for a thread which has not yet started.
         * 初始态,代表线程刚创建出来,但是还没有 start 的状态
         */
        NEW,

        /**
         * Thread state for a runnable thread.  A thread in the runnable
         * state is executing in the Java virtual machine but it may
         * be waiting for other resources from the operating system
         * such as processor.
         * 
         * 运行态,代表线程正在运行或者等待操作系统资源,如CPU资源
         */
        RUNNABLE,

        /**
         * Thread state for a thread blocked waiting for a monitor lock.
         * A thread in the blocked state is waiting for a monitor lock
         * to enter a synchronized block/method or
         * reenter a synchronized block/method after calling
         * {@link Object#wait() Object.wait}.
         * 
         * 阻塞态,代表线程正在等待一个监视器锁(即我们常说的synchronized)
         * 或者是在调用了Object.wait之后被notify()重新进入synchronized代码块
         */
        BLOCKED,

        /**
         * Thread state for a waiting thread.
         * A thread is in the waiting state due to calling one of the
         * following methods:
         * <ul>
         *   <li>{@link Object#wait() Object.wait} with no timeout</li>
         *   <li>{@link #join() Thread.join} with no timeout</li>
         *   <li>{@link LockSupport#park() LockSupport.park}</li>
         * </ul>
         *
         * <p>A thread in the waiting state is waiting for another thread to
         * perform a particular action.
         *
         * For example, a thread that has called <tt>Object.wait()</tt>
         * on an object is waiting for another thread to call
         * <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
         * that object. A thread that has called <tt>Thread.join()</tt>
         * is waiting for a specified thread to terminate.
         * 
         * 等待态,调用以下方法会进入等待状态:
         * 1. 调用不会超时的Object.wait()方法
         * 2. 调用不会超时的Thread.join()方法
         * 3. 调用不会超时的LockSupport.park()方法
         */
        WAITING,

        /**
         * Thread state for a waiting thread with a specified waiting time.
         * A thread is in the timed waiting state due to calling one of
         * the following methods with a specified positive waiting time:
         * <ul>
         *   <li>{@link #sleep Thread.sleep}</li>
         *   <li>{@link Object#wait(long) Object.wait} with timeout</li>
         *   <li>{@link #join(long) Thread.join} with timeout</li>
         *   <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
         *   <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
         * </ul>
         * 
         * 超时等待态,在调用了以下方法后会进入超时等待状态
         * 1. Thread.sleep()方法后
         * 2. Object.wait(timeout)方法
         * 3. Thread.join(timeout)方法
         * 4. LockSupport.parkNanos(nanos)方法
         * 5. LockSupport.parkUntil(deadline)方法
         */
        TIMED_WAITING,

        /**
         * Thread state for a terminated thread.
         * The thread has completed execution.
         * 
         * 终止态,代表线程已经执行完毕
         */
        TERMINATED;
    }

关于上面JDK源码中对于BLOCKED状态的注释,这里有一点需要补充的,就是如果是线程调用了Object.wait(timeout)方法进入TIMED_WAITING状态之后,如果是因为超过指定时间,脱离TIMED_WAITING状态,如果接下去线程是要重新进入synchronize 代码块的话,也是会先进入等待队列,变成BLOCKED状态,然后请求监视器锁资源。

1.1 操作系统中的线程状态

再来看,操作系统层面,线程存在五类状态,状态的流转关系可以参考下面的这张图。

可以看到,Java 中所说的线程状态和操作系统层面的线程状态是不太一样的。

  • Java 中的 RUNNABLE 其实包含了OS中的RUNNINGREADY
  • Java 中的WAITINGTIMED_WAITINGBLOCKED其实是对OS中WAITING状态的一个更细致的划分

Thread.State源码中也写了这么一句话:

These states are virtual machine states which do not reflect any operating system thread states.

这些状态只是线程在虚拟机中的状态,并不反映操作系统的线程状态

对于这两个层面对比,你需要知道的是,Java的线程状态是服务于虚拟机的。从这个角度来考虑的话,把底层OS中的RUNNINGREADY状态映射上来也没多大意义,因此,统一成为RUNNABLE 状态是不错的选择,而对WAITING状态更细致的划分,也是出于这么一个考虑。

二、状态流转图

图很详细,结合前面的内容一起食用。

关于阻塞状态,这里还要多说几句话,我们上面说的,都是在JVM 代码层面的实际线程状态。但是在一些书比如《码出高效》中,会把Java 线程的阻塞状态分为:

  • 同步阻塞:即锁被其他线程占用
  • 主动阻塞:指调用了Thread 的某些方法,主动让出CPU执行权,比如sleep()、join()等
  • 等待阻塞:执行了wait()系列方法

三、测试

这里演示一下,如何在IDEA 上面来验证上述的状态流转。有疑问或者有兴趣的读者可以按照同样的方法来验证。

我这里想要用代码验证下面的情况,

就是如果是线程1调用了Object.wait(timeout)方法进入TIMED_WAITING状态之后,如果是因为超过指定时间,脱离TIMED_WAITING状态,如果接下去线程是要重新进入synchronize 代码块的话,也是会先进入等待队列,变成BLOCKED状态,然后请求监视器锁资源。
public class ThreadLifeTempTest {

    public static void main(String[] args) {
        Object object = new Object();

        new Thread(()->{
            synchronized (object) {
                try {
                    System.out.println("thread1 waiting");
                    // 等待10s,进入Timed_Waiting
                    // 10s 后会进入Blocked,获取object的监视器锁
                    object.wait(10000);
                    System.out.println("thread1 after waiting");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Thread1").start();

        new Thread(()->{
            synchronized (object) {
                try {
                    // sleep也不会释放锁,所以thread1 不会获取到锁
                    Thread.sleep(10000000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Thread2").start();
    }
}

使用IDEA的RUN模式运行代码,然后点击左边的一个摄像头按钮(dump thread),查看各线程的状态。

在Thread 1 等待 10s中时,dump的结果:Thread 1和Thread 2都处于 TIMED_WAITING状态,

"Thread2" #13 prio=5 os_prio=0 tid=0x0000000020196800 nid=0x65b8 waiting on condition [0x0000000020afe000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
    at java.lang.Thread.sleep(Native Method)
    at main.java.concurrent.thread.ThreadLifeTempTest.lambda$main$1(ThreadLifeTempTest.java:33)
    - locked <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest$$Lambda$2/1096979270.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:748)

"Thread1" #12 prio=5 os_prio=0 tid=0x0000000020190800 nid=0x25fc in Object.wait() [0x00000000209ff000]
   java.lang.Thread.State: TIMED_WAITING (on object monitor)
    at java.lang.Object.wait(Native Method)
    - waiting on <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest.lambda$main$0(ThreadLifeTempTest.java:21)
    - locked <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest$$Lambda$1/1324119927.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:748)

在Thread 1 等待 10s之后,Thread 1重新进入synchronize 代码块,进入等待队列,变成BLOCKED状态

"Thread2" #13 prio=5 os_prio=0 tid=0x0000000020196800 nid=0x65b8 waiting on condition [0x0000000020afe000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
    at java.lang.Thread.sleep(Native Method)
    at main.java.concurrent.thread.ThreadLifeTempTest.lambda$main$1(ThreadLifeTempTest.java:33)
    - locked <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest$$Lambda$2/1096979270.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:748)

"Thread1" #12 prio=5 os_prio=0 tid=0x0000000020190800 nid=0x25fc waiting for monitor entry [0x00000000209ff000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at java.lang.Object.wait(Native Method)
    - waiting on <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest.lambda$main$0(ThreadLifeTempTest.java:21)
    - locked <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest$$Lambda$1/1324119927.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:748)

小结

在本篇文章中,主要讲解了线程的生命周期,各个状态以及状态流转。如果对线程状态的变化还有不了解的,可以借助最后一部分的测试方法来实际验证,帮助理解。

下一章,内容是介绍ThreadLocal 和 InheritableThreadLocal 的用法和原理,感兴趣请持续关注。

如果本文有帮助到你,希望能点个赞,这是对我的最大动力????。

参考

查看原文

赞 10 收藏 9 评论 3

Richard_Yi 回答了问题 · 2020-03-18

解决linux怎么查看jar包里的文件的内容?

不用解压吧,vim ***.jar命令可以直接做。
1、 vim ***.jar
image.png
2、/MANIFEST.MF 查找文件,光标定位到 MANIFEST.MF 文件上
image.png
3、再按下回车键,就可以看到里面的内容,jar中的其他文件同理
image.png

关注 3 回答 2

Richard_Yi 发布了文章 · 2020-03-18

Java 并发编程基础 ① - 线程

原文地址:Java 并发编程基础 ① - 线程

转载请注明出处!

一、什么是线程

进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,线程则是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源

操作系统在分配资源时是把资源分配给进程的,但是CPU资源比较特殊,它是被分配到线程的,因为真正要占用CPU运行的是线程,所以也说线程是CPU分配的基本单位

以Java 为例,我们启动一个main函数时,实际上就是启动了一个JVM 的进程main函数所在的线程就是这个进程的一个线程,也称为主线程。一个JVM进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域

二、线程创建和运行

Java 线程创建有3种方式:

  1. 继承 Thread 类并且重写 run 方法
  2. 实现 Runnable接口的 run 方法
  3. 使用FutureTask方式
具体代码示例不详述,太基础,会感觉在水。

说下 FutureTask 的方式,这种方式的本事也是实现了Runnable 接口的 run 方法,看它的继承结构就可以知道。

前两种方式都没办法拿到任务的返回结果,但是 Futuretask 方式可以。

三、线程通知与等待

3.1 wait() 方法

wait() 方法的效果就是该调用线程被阻塞挂起,直到发生以下几种情况才会调起:

  • 其他线程调用了该共享对象的 notify() 方法或者 notifyAll() 方法(继续往下走)
  • 其他线程调用了该线程的 interrupt() 方法,该线程会 InterruptedException 异常返回

如果调用 wait() 方法的线程没有事先获取该对象的监视器锁,调用线程会抛出IllegalMonitorStateException 异常。当线程调用wait() 之后,就已经释放了该对象的监视器锁

那么,一个线程如何才能获取一个共享变量的监视器锁?

  1. 执行synchronized 同步代码块,使用该共享变量作为参数。

    synchronized(共享变量) {
        // TODO
    }
  2. 调用该共享变量的同步方法(synchronized 修饰)

    synchronized void sum(int a, int b) {
        // TODO
    }

3.2 notify() / notifyAll()

一个线程调用共享对象的 notify() 方法后,会唤醒一个在该共享变量上调用 wait(...) 系列方法后被挂起的线程。

值得注意的是:

  • 一个共享变量上可能会有多个线程在等待,notify() 具体唤醒哪个等待的线程是随机的
  • 被唤醒的线程不能马上从wait方法返回并继续执行,它必须在获取了共享对象的监视器锁后才可以返回,也就是唤醒它的线程释放了共享变量上的监视器锁后,被唤醒的线程也不一定会获取到共享对象的监视器锁,这是因为该线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行

notifyAll() 方法则会唤醒所有在该共享变量上由于调用wait系列方法而被挂起的线程。

3.3 实例

比较经典的就是生产者和消费者的例子

public class NotifyWaitDemo {

    public static final int MAX_SIZE = 1024;
    // 共享变量
    public static Queue queue = new Queue();

    public static void main(String[] args) {
        // 生产者
        Thread producer = new Thread(() -> {
            synchronized (queue) {
                while (true) {
                    // 挂起当前线程(生产者线程)
                    // 并且,释放通过queue的监视器锁,让消费者对象获取到锁,执行消费逻辑
                    if (queue.size() == MAX_SIZE) {
                        try {
                            queue.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    // 空闲则生成元素,并且通知消费线程
                    queue.add();
                    queue.notifyAll();
                }
            }
        });
        // 消费者
        Thread consumer = new Thread(() -> {
            synchronized (queue) {
                while (true) {
                    // 挂起当前线程(消费者线程)
                    // 并且,释放通过queue的监视器锁,让生产者对象获取到锁,执行生产逻辑
                    if (queue.size() == 0) {
                        try {
                            queue.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    // 空闲则消费元素,并且通知生产线程
                    queue.take();
                    queue.notifyAll();
                }
            }
        });
        producer.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        consumer.start();
    }

    static class Queue {

        private int size = 0;

        public int size() {
            return this.size;
        }

        public void add() {
            // TODO
            size++;
            System.out.println("执行add 操作,current size: " +  size);
        }

        public void take() {
            // TODO
            size--;
            System.out.println("执行take 操作,current size: " +  size);
        }
    }
}

3.4 wait()/notify()/notifyAll() 为什么定义在 Object 类中?

由于Thread类继承了Object类,所以Thread也可以调用者三个方法,等待和唤醒必须是同一个锁。而锁可以是任意对象,所以可以被任意对象调用的方法是定义在object类中。

四、join() - 等待线程执行终止

适用场景:需要等待某几件事情完成后才能继续往下执行,比如多个线程加载资源,需要等待多个线程全部加载完毕再汇总处理。

public static void main(String[] args){
    ...
    thread1.join();
    thread2.join();
    System.out.println("all child thread over!");
}

主线程首先会在调用thread1.join() 后被阻塞,等待thread1执行完毕后,调用thread2.join(),等待thread2 执行完毕(有可能),以此类推,最终会等所有子线程都结束后main函数才会返回。如果其他线程调用了被阻塞线程的 interrupt() 方法,被阻塞线程会抛出 InterruptedException 异常而返回。

4.1 实例

给出一个实例帮助理解。

public class JoinExample {

    private static final int TIMES = 100;

    private class JoinThread extends Thread {

        JoinThread(String name){
           super(name);
        }

        @Override
        public void run() {
            for (int i = 0; i < TIMES; i++) {
                System.out.println(getName() + " " + i);
            }
        }
    }

    public static void main(String[] args) {
        JoinExample example = new JoinExample();
        example.test();
    }

    private void test() {
       for (int i = 0; i < TIMES; i++) {
           if (i == 20) {
               Thread jt1 = new JoinThread("子线程1");
               Thread jt2 = new JoinThread("子线程2");
               jt1.start();
               jt2.start();
               // main 线程调用了jt线程的join()方法
               // main 线程必须等到 jt 执行完之后才会向下执行
               try {
                   jt1.join();
                   jt2.join();
                   // join(long mills) - 等待时间内 被join的线程还没执行,不再等待
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
           System.out.println(Thread.currentThread().getName() + "  " + i);
        }
    }
}

五、线程睡眠

sleep()会使线程暂时让出指定时间的执行权,也就是在这期间不参与CPU的调度但不会释放锁

指定的睡眠时间到了后该函数会正常返回,线程就处于就绪状态,然后参与CPU的调度,获取到CPU资源后就可以继续运行了。

如果在睡眠期间其他线程调用了该线程的 interrupt() 方法中断了该线程,则该线程会在调用sleep方法的地方抛出 InterruptedException 异常而返回。

/**
 * 帮助理解 sleep 不会让出监视器锁资源
 *
 * 在线程A睡眠的这10s内obj的监视器锁还是线程A自己持有
 * 线程B会一直阻塞直到线程A醒来后退出synchronize代码块 释放锁
 *
 * @author Richard_yyf
 * @version 1.0 2020/3/12
 */
public class ThreadSleepDemo {

    
    private static final Object obj = new Object();

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            // 获取obj monitor 锁
            synchronized (obj) {
                try {
                    System.out.println("child thread A is in sleep");
                    Thread.sleep(10000);
                    System.out.println("child thread A is awake");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread threadB = new Thread(() -> {
            // 获取obj monitor 锁
            synchronized (obj) {
                try {
                    System.out.println("child thread B is in sleep");
                    Thread.sleep(10000);
                    System.out.println("child thread B is awake");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        threadA.start();
        threadB.start();
    }
}

六、让出CPU执行权 - yield()

线程调用yield 方法时,实际上是暗示线程调度器当前线程请求让出自己的CPU使用(告诉线程调度器可以进行下一轮的线程调度),但线程调度器可以无条件忽略这个暗示

我们知道操作系统是为每个线程分配一个时间片来占有CPU的,正常情况下当一个线程把分配给自己的时间片使用完后,线程调度器才会进行下一轮的线程调度,而当一个线程调用了Thread类的静态方法 yield 时,是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,这暗示线程调度器现在就可以进行下一轮的线程调度。

一般很少使用这个方法,在调试或者测试时这个方法或许可以帮助复现由于并发竞争条件导致的问题,其在设计并发控制时或许会有用途,在java.util.concurrent.locks包里面的锁时会看到该方法的使用

sleep与yield方法的区别在于:当线程调用sleep方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会去调度该线程。而调用yield 方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度时就有可能调度到当前线程执行。

七、线程中断

很多人看到 interrupt() 方法,认为“中断”线程不就是让线程停止嘛。实际上, interrupt() 方法实现的根本就不是这个效果, interrupt()方法更像是发出一个信号,这个信号会改变线程的一个标识位属性(中断标识),对于这个信号如何进行响应则是无法确定的(可以有不同的处理逻辑)。很多时候调用 interrupt() 方法非但不是为了停止线程,反而是为了让线程继续运行下去

官方一点的表述:

Java中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理

7.1 void interrupt()

设置线程的中断标志为true并立即返回,但线程实际上并没有被中断而会继续向下执行;如果线程因为调用了wait系列函数、join方法或者sleep方法而被阻塞挂起,其他线程调用该线程的interrupt()方法会使该线程抛出InterruptedException异常而返回。

7.2 boolean isInterrupted()

检测线程是否被中断,是则返回true,否则返回false。

    public boolean isInterrupted() {
        // 传递 false 说明不清除中断标志
        return isInterrupted(false);
    }

7.3 boolean interrupted()

检测当前线程是否被中断,返回值同上 isInterrupted() ,不同的是,如果发现当前线程被中断,会清除中断标志;该方法是static方法,内部是获取当前调用线程的中断标志而不是调用interrupted()方法的实例对象的中断标志

    public static boolean interrupted() {
        // static 方法
        // true - 清除终端标志
        // currentThread
        return currentThread().isInterrupted(true);
    }

八、线程上下文切换

我们都知道,在多线程编程中,线程个数一般都大于CPU 个数,而每个CPU同一时刻只能被一个线程使用。

为了让用户感觉多个线程是在同时执行的,CPU资源的分配采用了时间片轮转的策略,也就是每个线程分配一个时间片,线程在时间片内占用CPU执行任务。当前线程使用完时间片后,就会处于就绪状态并且让出CPU让其他线程占用,这就是线程的上下文切换

九、线程死锁

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。

就如上图,线程A持有资源2,同时想申请资源1,线程B持有资源1,同时想申请资源2,两个线程相互等待就形成了死锁状态。

死锁的产生有四个条件:

  • 互斥条件:指线程对已经获取到的资源进行排他性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。
  • 请求并持有条件:指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源。
  • 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源。
  • 环路等待条件:指在发生死锁时,必然存在一个线程—资源的环形链,即线程集合{T0, T1, T2, …, Tn}中的T0正在等待一个T1占用的资源,T1正在等待T2占用的资源,……Tn正在等待已被T0占用的资源。

十、守护线程与用户线程

Java 线程分为两类,

  • daemon 线程(即守护线程)
  • user 线程 (用户线程)

在JVM 启动时会调用 main 函数,main 函数所在的线程就是一个用户线程,同时在JVM 内部也启动了很多守护线程,比如 GC 线程。

守护线程和用户线程的区别在于,守护线程不会影响JVM 的退出,当最后一个用户线程结束时,JVM 会正常退出。

所以,如果你希望在主线程结束后JVM进程马上结束,那么在创建线程时可以将其设置为守护线程,如果你希望在主线程结束后子线程继续工作,等子线程结束后再让JVM进程结束,那么就将子线程设置为用户线程

举例:比如在Tomcat 的NIO 实现NioEndpoint 类中,会开启一组接受线程来接受用户的连接请求,以及一组处理线程负责具体处理用户请求。

    /**
     * Start the NIO endpoint, creating acceptor, poller threads.
     */
    @Override
    public void startInternal() throws Exception {

        if (!running) {
            // ... 省略

            // Start poller threads 处理线程
            pollers = new Poller[getPollerThreadCount()];
            for (int i=0; i<pollers.length; i++) {
                pollers[i] = new Poller();
                Thread pollerThread = new Thread(pollers[i], getName() + "-ClientPoller-"+i);
                pollerThread.setPriority(threadPriority);
                pollerThread.setDaemon(true); // 
                pollerThread.start();
            }
            // 启动接收线程
            startAcceptorThreads();
        }
    }

    protected final void startAcceptorThreads() {
        int count = getAcceptorThreadCount();
        acceptors = new Acceptor[count];

        for (int i = 0; i < count; i++) {
            acceptors[i] = createAcceptor();
            String threadName = getName() + "-Acceptor-" + i;
            acceptors[i].setThreadName(threadName);
            Thread t = new Thread(acceptors[i], threadName);
            t.setPriority(getAcceptorThreadPriority());
            t.setDaemon(getDaemon()); // 默认值是true 
            t.start();
        }
    }

在如上代码中,在默认情况下,接受线程和处理线程都是守护线程,这意味着当tomcat收到shutdown命令后并且没有其他用户线程存在的情况下tomcat进程会马上消亡,而不会等待处理线程处理完当前的请求。

小结

本篇讲了有关Java 线程的一些基础知识。下一篇,我计划是写一下 Java 线程的生命周期、线程的各个状态和以及各个状态的流转。因为关于这部分我感觉国内网站上大部分的文章都没有讲清楚,我会尝试用图片、文字和具体的代码结合的方式写一篇。有兴趣的小伙伴可以关注一下。

如果本文有帮助到你,希望能点个赞,这是对我的最大动力????。

参考

  • 《Java 并发编程之美》
查看原文

赞 9 收藏 8 评论 3