24-Java并发编程实战

开篇词 | 你为什么需要学习并发编程？

你好，我是王宝令，资深架构师，目前从事电商架构的设计工作。从毕业到现在，我前前后后写了 15 年的程序，刚毕业的时候从事证券业务的开发，开发语言是 C/C++，之后从事 ERP 产品的研发，开发语言主要是 C# 和 Java，最近几年主要是从事 Java 开发平台和基础中间件的设计开发工作。

还记得毕业后我接触的第一个项目是证券相关的，国外的同事用 C 语言写了一个内存数据库，代码写得极为简练优美，我当时怀着无比崇敬的心情把代码看了又看，看完感觉受益匪浅。不过兴奋之余，我也有些焦虑，因为其中一块并发相关的代码，我看得是云里雾里，总感觉自己没有悟透。

我下意识地告诉自己说这块的知识积累还不够，所以要勤学苦练。你可知道，15 年前相关的学习资料并不多，我的师傅向我推荐了《操作系统原理》这本教材，他说：“并发编程最早的应用领域就是操作系统的实现，你把这本书看懂了，并发的问题自然就解决了。”但是理论和实践之间总是有鸿沟的，之后好多年，最让我感到无助的还是处理并发相关的问题。

并发编程的掌握过程并不容易。我相信为了解决这个问题，你也听别人总结过并发编程的第一原则，那就是不要写并发程序。这个原则在我刚毕业的那几年曾经是行得通的，那个时候多核服务器还是一种奢侈品，系统的并发量也很低，借助数据库和类似 Tomcat 这种中间件，我们基本上不用写并发程序。或者说，并发问题基本上都被中间件和数据库解决了。

但是最近几年，并发编程已经慢慢成为一项必备技能。

这主要是硬件的驱动以及国内互联网行业的飞速发展决定的，现在 64 核的服务器已经飞入寻常百姓家，大型互联网厂商的系统并发量轻松过百万，传统的中间件和数据库已经不能为我们遮风挡雨，反而成了瓶颈所在。

于是，并发编程最近几年成为非常热门的领域，人才稀缺。但与此同时，关于并发编程的书籍也渐渐丰富起来了。所以当极客时间团队和我聊这个专栏的时候，我的第一个疑问就是目前市面上已经有很多这方面的图书了，而且很多都非常优秀，是否还有必要搞一个这样的专栏。

但是深入想过之后，我坚定了写作的信心。这些年接触的大部分同学，都是工作几年后很多技术突飞猛进，却只有并发编程成为瓶颈，虽然并发相关的类库他们也熟悉，却总是写不出正确、高效的并发程序，原因在哪里？我发现很多人是因为某个地方有了盲点，忽略了一些细节，但恰恰是这些细节决定了程序的正确性和效率。

而这个盲点有时候涉及对操作系统的理解，有时候又涉及一点硬件知识，非常复杂，如果要推荐相关图书，可能要推荐好几本，这就有点“大炮打蚊子”的感觉了，效率很差。同时图书更追求严谨性，却也因此失掉了形象性，所以阅读的过程也确实有点艰辛。

我想，如果能够把这些问题解决，那么做这个事情应该是有意义的。

例如，Java 里 synchronized、wait()/notify() 相关的知识很琐碎，看懂难，会用更难。但实际上 synchronized、wait()、notify() 不过是操作系统领域里管程模型的一种实现而已，Java SDK 并发包里的条件变量 Condition 也是管程里的概念，synchronized、wait()/notify()、条件变量这些知识如果单独理解，自然是管中窥豹。但是如果站在管程这个理论模型的高度，你就会发现这些知识原来这么简单，同时用起来也就得心应手了。

管程作为一种解决并发问题的模型，是继信号量模型之后的一项重大创新，它与信号量在逻辑上是等价的（可以用管程实现信号量，也可以用信号量实现管程），但是相比之下管程更易用。而且，很多编程语言都支持管程，搞懂管程，对学习其他很多语言的并发编程有很大帮助。然而，很多人急于学习 Java 并发编程技术，却忽略了技术背后的理论和模型，而理论和模型却往往比具体的技术更为重要。

此外，Java 经过这些年的发展，Java SDK 并发包提供了非常丰富的功能，对于初学者来说可谓是眼花缭乱，好多人觉得无从下手。但是，Java SDK 并发包乃是并发大师 Doug Lea 出品，堪称经典，它内部一定是有章可循的。那它的章法在哪里呢？

其实并发编程可以总结为三个核心问题：分工、同步、互斥。

所谓分工指的是如何高效地拆解任务并分配给线程，而同步指的是线程之间如何协作，互斥则是保证同一时刻只允许一个线程访问共享资源。Java SDK 并发包很大部分内容都是按照这三个维度组织的，例如 Fork/Join 框架就是一种分工模式，CountDownLatch 就是一种典型的同步方式，而可重入锁则是一种互斥手段。

当把并发编程核心的问题搞清楚，再回过头来看 Java SDK 并发包，你会感觉豁然开朗，它不过是针对并发问题开发出来的工具而已，此时的 SDK 并发包可以任你“盘”了。

而且，这三个核心问题是跨语言的，你如果要学习其他语言的并发编程类库，完全可以顺着这三个问题按图索骥。Java SDK 并发包其余的一部分则是并发容器和原子类，这些比较容易理解，属于辅助工具，其他语言里基本都能找到对应的。

所以，你说并发编程难学吗？

首先，难是肯定的。因为这其中涉及操作系统、CPU、内存等等多方面的知识，如果你缺少某一块，那理解起来自然困难。其次，难不难学也可能因人而异，就我的经验来看，很多人在学习并发编程的时候，总是喜欢从点出发，希望能从点里找到规律或者本质，最后却把自己绕晕了。

我前面说过，并发编程并不是 Java 特有的语言特性，它是一个通用且早已成熟的领域。Java 只是根据自身情况做了实现罢了，当你理解或学习并发编程的时候，如果能够站在较高层面，系统且有体系地思考问题，那就会容易很多。

所以，我希望这个专栏更多地谈及问题背后的本质、问题的起源，同时站在理论、模型的角度讲解 Java 并发，让你的知识更成体系，融会贯通。最终让你能够得心应手地解决各种并发难题，同时将这些知识用于其他编程语言，让你的一分辛劳三分收获。

下面就是这个专栏的目录，你可以快速了解下整个专栏的知识结构体系。

当然，我们要坚持下去，不能三天打鱼两天晒网，因为滴水穿石非一日之功。

很多人都说学习是反人性的，开始容易，但是长久的坚持却很难。这个我也认同，我面试的时候，就经常问候选人一个问题：“工作中，有没有一件事你自己坚持了很久，并且从中获益？”如果候选人能够回答出来，那会是整个面试的加分项，因为我觉得，坚持真是一个可贵的品质，一件事情，有的人三分热度，而有的人，一做就能做一年，或者更久。你放长到时间的维度里看，这两种人，最后的成就绝对是指数级的差距。

我希望你能和我坚持下来，我们一起学习，一起交流，遇到问题不是简单地抱怨和逃避，而是努力探寻答案与解决方法。这一次，就让我们一起来坚持探索并发编程的奥秘，体会探索知识的乐趣。今天的文章是开篇词，我们的主菜很快就来，如果可以的话，还请在留言区中做个自我介绍，和我聊聊你目前的工作、学习情况，以及你在并发编程方面的学习痛点，方便我在后面针对性地给你讲解，这样，我们可以彼此了解。

最后，感谢你对我的信任，我定会努力实现完美交付。

学习攻略 | 如何才能学好并发编程？

并发编程并不是一门相对独立的学科，而是一个综合学科。并发编程相关的概念和技术看上非常零散，相关度也很低，总给你一种这样的感觉：我已经学习很多相关技术了，可还是搞不定并发编程。那如何才能学习好并发编程呢？

其实很简单，只要你能从两个方面突破一下就可以了。一个是“跳出来，看全景”，另一个是“钻进去，看本质”。

跳出来，看全景

我们先说“跳出来”。你应该也知道，学习最忌讳的就是“盲人摸象”，只看到局部，而没有看到全局。所以，你需要从一个个单一的知识和技术中“跳出来”，高屋建瓴地看并发编程。当然，这首要之事就是你建立起一张全景图。

不过，并发编程相关的知识和技术还真是错综复杂，时至今日也还没有一张普遍认可的全景图，也许这正是很多人在并发编程方面难以突破的原因吧。好在经过多年摸爬滚打，我自己已经“勾勒”出了一张全景图，不一定科学，但是在某种程度上我想它还是可以指导你学好并发编程的。

在我看来，并发编程领域可以抽象成三个核心问题：分工、同步和互斥。

1. 分工

所谓分工，类似于现实中一个组织完成一个项目，项目经理要拆分任务，安排合适的成员去完成。

在并发编程领域，你就是项目经理，线程就是项目组成员。任务分解和分工对于项目成败非常关键，不过在并发领域里，分工更重要，它直接决定了并发程序的性能。在现实世界里，分工是很复杂的，著名数学家华罗庚曾用“烧水泡茶”的例子通俗地讲解了统筹方法（一种安排工作进程的数学方法），“烧水泡茶”这么简单的事情都这么多说道，更何况是并发编程里的工程问题呢。

既然分工很重要又很复杂，那一定有前辈努力尝试解决过，并且也一定有成果。的确，在并发编程领域这方面的成果还是很丰硕的。Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是一种分工方法。除此之外，并发编程领域还总结了一些设计模式，基本上都是和分工方法相关的，例如生产者 - 消费者、Thread-Per-Message、Worker Thread 模式等都是用来指导你如何分工的。

学习这部分内容，最佳的方式就是和现实世界做对比。例如生产者 - 消费者模式，可以类比一下餐馆里的大厨和服务员，大厨就是生产者，负责做菜，做完放到出菜口，而服务员就是消费者，把做好的菜给你端过来。不过，我们经常会发现，出菜口有时候一下子出了好几个菜，服务员是可以把这一批菜同时端给你的。其实这就是生产者 - 消费者模式的一个优点，生产者一个一个地生产数据，而消费者可以批处理，这样就提高了性能。

2. 同步

分好工之后，就是具体执行了。在项目执行过程中，任务之间是有依赖的，一个任务结束后，依赖它的后续任务就可以开工了，后续工作怎么知道可以开工了呢？这个就是靠沟通协作了，这是一项很重要的工作。

在并发编程领域里的同步，主要指的就是线程间的协作，本质上和现实生活中的协作没区别，不过是一个线程执行完了一个任务，如何通知执行后续任务的线程开工而已。

协作一般是和分工相关的。Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是分工方法，但同时也能解决线程协作的问题。例如，用 Future 可以发起一个异步调用，当主线程通过 get() 方法取结果时，主线程就会等待，当异步执行的结果返回时，get() 方法就自动返回了。主线程和异步线程之间的协作，Future 工具类已经帮我们解决了。除此之外，Java SDK 里提供的 CountDownLatch、CyclicBarrier、Phaser、Exchanger 也都是用来解决线程协作问题的。

不过还有很多场景，是需要你自己来处理线程之间的协作的。

工作中遇到的线程协作问题，基本上都可以描述为这样的一个问题：当某个条件不满足时，线程需要等待，当某个条件满足时，线程需要被唤醒执行。例如，在生产者 - 消费者模型里，也有类似的描述，“当队列满时，生产者线程等待，当队列不满时，生产者线程需要被唤醒执行；当队列空时，消费者线程等待，当队列不空时，消费者线程需要被唤醒执行。”

在 Java 并发编程领域，解决协作问题的核心技术是管程，上面提到的所有线程协作技术底层都是利用管程解决的。管程是一种解决并发问题的通用模型，除了能解决线程协作问题，还能解决下面我们将要介绍的互斥问题。可以这么说，管程是解决并发问题的万能钥匙。

所以说，这部分内容的学习，关键是理解管程模型，学好它就可以解决所有问题。其次是了解 Java SDK 并发包提供的几个线程协作的工具类的应用场景，用好它们可以妥妥地提高你的工作效率。

3. 互斥

分工、同步主要强调的是性能，但并发程序里还有一部分是关于正确性的，用专业术语叫“线程安全”。并发程序里，当多个线程同时访问同一个共享变量的时候，结果是不确定的。不确定，则意味着可能正确，也可能错误，事先是不知道的。而导致不确定的主要源头是可见性问题、有序性问题和原子性问题，为了解决这三个问题，Java 语言引入了内存模型，内存模型提供了一系列的规则，利用这些规则，我们可以避免可见性问题、有序性问题，但是还不足以完全解决线程安全问题。解决线程安全问题的核心方案还是互斥。

所谓互斥，指的是同一时刻，只允许一个线程访问共享变量。

实现互斥的核心技术就是锁，Java 语言里 synchronized、SDK 里的各种 Lock 都能解决互斥问题。虽说锁解决了安全性问题，但同时也带来了性能问题，那如何保证安全性的同时又尽量提高性能呢？可以分场景优化，Java SDK 里提供的 ReadWriteLock、StampedLock 就可以优化读多写少场景下锁的性能。还可以使用无锁的数据结构，例如 Java SDK 里提供的原子类都是基于无锁技术实现的。

除此之外，还有一些其他的方案，原理是不共享变量或者变量只允许读。这方面，Java 提供了 Thread Local 和 final 关键字，还有一种 Copy-on-write 的模式。

使用锁除了要注意性能问题外，还需要注意死锁问题。

这部分内容比较复杂，往往还是跨领域的，例如要理解可见性，就需要了解一些 CPU 和缓存的知识；要理解原子性，就需要理解一些操作系统的知识；很多无锁算法的实现往往也需要理解 CPU 缓存。这部分内容的学习，需要博览群书，在大脑里建立起 CPU、内存、I/O 执行的模拟器。这样遇到问题就能得心应手了。

跳出来，看全景，可以让你的知识成体系，所学知识也融汇贯通起来，由点成线，由线及面，画出自己的知识全景图。

并发编程全景图之思维导图

钻进去，看本质

但是光跳出来还不够，还需要下一步，就是在某个问题上钻进去，深入理解，找到本质。

就拿我个人来说，我已经烦透了去讲述或被讲述一堆概念和结论，而不分析这些概念和结论是怎么来的，以及它们是用来解决什么问题的。在大学里，这样的教材很流行，直接导致了芸芸学子成绩很高，但解决问题的能力很差。其实，知其然知其所以然，才算真的学明白了。

我属于理论派，我认为工程上的解决方案，一定要有理论做基础。所以在学习并发编程的过程中，我都会探索它背后的理论是什么。比如，当看到 Java SDK 里面的条件变量 Condition 的时候，我会下意识地问，“它是从哪儿来的？是 Java 的特有概念，还是一个通用的编程概念？”当我知道它来自管程的时候，我又会问，“管程被提出的背景和解决的问题是什么？”这样一路探索下来，我发现 Java 语言里的并发技术基本都是有理论基础的，并且这些理论在其他编程语言里也有类似的实现。所以我认为，技术的本质是背后的理论模型。

总结

当初我学习 Java 并发编程的时候，试图上来就看 Java SDK 的并发包，但是很快就放弃了。原因是我觉得东西太多，眼花缭乱的，虽然借助网络上的技术文章，感觉都看懂了，但是很快就又忘了。实际应用的时候大脑也一片空白，根本不知道从哪里下手，有时候好不容易解决了个问题，也不知道这个方案是不是合适的。

我知道根本原因是，我的并发知识还没有成体系。

我想，要让自己的知识成体系，一定要挖掘 Java SDK 并发包背后的设计理念。Java SDK 并发包是并发大师 Doug Lea 设计的，他一定不是随意设计的，一定是深思熟虑的，其背后是 Doug Lea 对并发问题的深刻认识。可惜这个设计的思想目前并没有相关的论文，所以只能自己琢磨了。

分工、同步和互斥的全景图，是我对并发问题的个人总结，不一定正确，但是可以帮助我快速建立解决并发问题的思路，梳理并发编程的知识，加深认识。我将其分享给你，希望对你也有用。

对于某个具体的技术，我建议你探索它背后的理论本质，理论的应用面更宽，一项优秀的理论往往在多个语言中都有体现，在多个不同领域都有应用。所以探求理论本质，既能加深对技术本身的理解，也能拓展知识深度和广度，这是个一举多得的方法。这方面，希望我们一起探讨，共同进步。

欢迎在留言区跟我分享你的经历与想法。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

01 | 可见性、原子性和有序性问题：并发编程Bug的源头

如果你细心观察的话，你会发现，不管是哪一门编程语言，并发类的知识都是在高级篇里。换句话说，这块知识点其实对于程序员来说，是比较进阶的知识。我自己这么多年学习过来，也确实觉得并发是比较难的，因为它会涉及到很多的底层知识，比如若你对操作系统相关的知识一无所知的话，那去理解一些原理就会费些力气。这是我们整个专栏的第一篇文章，我说这些话的意思是如果你在中间遇到自己没想通的问题，可以去查阅资料，也可以在评论区找我，以保证你能够跟上学习进度。

你我都知道，编写正确的并发程序是一件极困难的事情，并发程序的 Bug 往往会诡异地出现，然后又诡异地消失，很难重现，也很难追踪，很多时候都让人很抓狂。但要快速而又精准地解决“并发”类的疑难杂症，你就要理解这件事情的本质，追本溯源，深入分析这些 Bug 的源头在哪里。

那为什么并发编程容易出问题呢？它是怎么出问题的？今天我们就重点聊聊这些 Bug 的源头。

并发程序幕后的故事

这些年，我们的 CPU、内存、I/O 设备都在不断迭代，不断朝着更快的方向努力。但是，在这个快速发展的过程中，有一个核心矛盾一直存在，就是这三者的速度差异。CPU 和内存的速度差异可以形象地描述为：CPU 是天上一天，内存是地上一年（假设 CPU 执行一条普通指令需要一天，那么 CPU 读写内存得等待一年的时间）。内存和 I/O 设备的速度差异就更大了，内存是天上一天，I/O 设备是地上十年。

程序里大部分语句都要访问内存，有些还要访问 I/O，根据木桶理论（一只水桶能装多少水取决于它最短的那块木板），程序整体的性能取决于最慢的操作——读写 I/O 设备，也就是说单方面提高 CPU 性能是无效的。

为了合理利用 CPU 的高性能，平衡这三者的速度差异，计算机体系机构、操作系统、编译程序都做出了贡献，主要体现为：

CPU 增加了缓存，以均衡与内存的速度差异；

操作系统增加了进程、线程，以分时复用 CPU，进而均衡 CPU 与 I/O 设备的速度差异；

编译程序优化指令执行次序，使得缓存能够得到更加合理地利用。

现在我们几乎所有的程序都默默地享受着这些成果，但是天下没有免费的午餐，并发程序很多诡异问题的根源也在这里。

源头之一：缓存导致的可见性问题

在单核时代，所有的线程都是在一颗 CPU 上执行，CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存，一个线程对缓存的写，对另外一个线程来说一定是可见的。例如在下面的图中，线程 A 和线程 B 都是操作同一个 CPU 里面的缓存，所以线程 A 更新了变量 V 的值，那么线程 B 之后再访问变量 V，得到的一定是 V 的最新值（线程 A 写过的值）。

CPU 缓存与内存的关系图

一个线程对共享变量的修改，另外一个线程能够立刻看到，我们称为可见性。

多核时代，每颗 CPU 都有自己的缓存，这时 CPU 缓存与内存的数据一致性就没那么容易解决了，当多个线程在不同的 CPU 上执行时，这些线程操作的是不同的 CPU 缓存。比如下图中，线程 A 操作的是 CPU-1 上的缓存，而线程 B 操作的是 CPU-2 上的缓存，很明显，这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。

多核 CPU 的缓存与内存关系图

下面我们再用一段代码来验证一下多核场景下的可见性问题。下面的代码，每执行一次 add10K() 方法，都会循环 10000 次 count+=1 操作。在 calc() 方法中我们创建了两个线程，每个线程调用一次 add10K() 方法，我们来想一想执行 calc() 方法得到的结果应该是多少呢？

public class Test {

  private long count = 0;

  private void add10K() {

    int idx = 0;

    while(idx++ < 10000) {

      count += 1;

    }

  }

  public static long calc() {

    final Test test = new Test();

    // 创建两个线程，执行 add() 操作

    Thread th1 = new Thread(()->{

      test.add10K();

    });

    Thread th2 = new Thread(()->{

      test.add10K();

    });

    // 启动两个线程

    th1.start();

    th2.start();

    // 等待两个线程执行结束

    th1.join();

    th2.join();

    return count;

  }

}

直觉告诉我们应该是 20000，因为在单线程里调用两次 add10K() 方法，count 的值就是 20000，但实际上 calc() 的执行结果是个 10000 到 20000 之间的随机数。为什么呢？

我们假设线程 A 和线程 B 同时开始执行，那么第一次都会将 count=0 读到各自的 CPU 缓存里，执行完 count+=1 之后，各自 CPU 缓存里的值都是 1，同时写入内存后，我们会发现内存中是 1，而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值，两个线程都是基于 CPU 缓存里的 count 值来计算，所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。

循环 10000 次 count+=1 操作如果改为循环 1 亿次，你会发现效果更明显，最终 count 的值接近 1 亿，而不是 2 亿。如果循环 10000 次，count 的值接近 20000，原因是两个线程不是同时启动的，有一个时差。

变量 count 在 CPU 缓存和内存的分布图

源头之二：线程切换带来的原子性问题

由于 IO 太慢，早期的操作系统就发明了多进程，即便在单核的 CPU 上我们也可以一边听着歌，一边写 Bug，这个就是多进程的功劳。

操作系统允许某个进程执行一小段时间，例如 50 毫秒，过了 50 毫秒操作系统就会重新选择一个进程来执行（我们称为“任务切换”），这个 50 毫秒称为“时间片”。

线程切换示意图

在一个时间片内，如果一个进程进行一个 IO 操作，例如读个文件，这个时候该进程可以把自己标记为“休眠状态”并出让 CPU 的使用权，待文件读进内存，操作系统会把这个休眠的进程唤醒，唤醒后的进程就有机会重新获得 CPU 的使用权了。

这里的进程在等待 IO 时之所以会释放 CPU 使用权，是为了让 CPU 在这段等待时间里可以做别的事情，这样一来 CPU 的使用率就上来了；此外，如果这时有另外一个进程也读文件，读文件的操作就会排队，磁盘驱动在完成一个进程的读操作后，发现有排队的任务，就会立即启动下一个读操作，这样 IO 的使用率也上来了。

是不是很简单的逻辑？但是，虽然看似简单，支持多进程分时复用在操作系统的发展史上却具有里程碑意义，Unix 就是因为解决了这个问题而名噪天下的。

早期的操作系统基于进程来调度 CPU，不同进程间是不共享内存空间的，所以进程要做任务切换就要切换内存映射地址，而一个进程创建的所有线程，都是共享一个内存空间的，所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度，现在我们提到的“任务切换”都是指“线程切换”。

Java 并发程序都是基于多线程的，自然也会涉及到任务切换，也许你想不到，任务切换竟然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候，我们现在基本都使用高级语言编程，高级语言里一条语句往往需要多条 CPU 指令完成，例如上面代码中的count += 1，至少需要三条 CPU 指令。

指令 1：首先，需要把变量 count 从内存加载到 CPU 的寄存器；

指令 2：之后，在寄存器中执行 +1 操作；

指令 3：最后，将结果写入内存（缓存机制导致可能写入的是 CPU 缓存而不是内存）。

操作系统做任务切换，可以发生在任何一条CPU 指令执行完，是的，是 CPU 指令，而不是高级语言里的一条语句。对于上面的三条指令来说，我们假设 count=0，如果线程 A 在指令 1 执行完后做线程切换，线程 A 和线程 B 按照下图的序列执行，那么我们会发现两个线程都执行了 count+=1 的操作，但是得到的结果不是我们期望的 2，而是 1。

非原子操作的执行路径示意图

我们潜意识里面觉得 count+=1 这个操作是一个不可分割的整体，就像一个原子一样，线程的切换可以发生在 count+=1 之前，也可以发生在 count+=1 之后，但就是不会发生在中间。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的，而不是高级语言的操作符，这是违背我们直觉的地方。因此，很多时候我们需要在高级语言层面保证操作的原子性。

源头之三：编译优化带来的有序性问题

那并发编程里还有没有其他有违直觉容易导致诡异 Bug 的技术呢？有的，就是有序性。顾名思义，有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能，有时候会改变程序中语句的先后顺序，例如程序中：“a=6；b=7；”编译器优化后可能变成“b=7；a=6；”，在这个例子中，编译器调整了语句的顺序，但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。

在 Java 领域一个经典的案例就是利用双重检查创建单例对象，例如下面的代码：在获取实例 getInstance() 的方法中，我们首先判断 instance 是否为空，如果为空，则锁定 Singleton.class 并再次检查 instance 是否为空，如果还为空则创建 Singleton 的一个实例。

public class Singleton {

  static Singleton instance;

  static Singleton getInstance(){

    if (instance == null) {

      synchronized(Singleton.class) {

        if (instance == null)

          instance = new Singleton();

        }

    }

    return instance;

  }

}

假设有两个线程 A、B 同时调用 getInstance() 方法，他们会同时发现 instance == null ，于是同时对 Singleton.class 加锁，此时 JVM 保证只有一个线程能够加锁成功（假设是线程 A），另外一个线程则会处于等待状态（假设是线程 B）；线程 A 会创建一个 Singleton 实例，之后释放锁，锁释放后，线程 B 被唤醒，线程 B 再次尝试加锁，此时是可以加锁成功的，加锁成功后，线程 B 检查 instance == null 时会发现，已经创建过 Singleton 实例了，所以线程 B 不会再创建一个 Singleton 实例。

这看上去一切都很完美，无懈可击，但实际上这个 getInstance() 方法并不完美。问题出在哪里呢？出在 new 操作上，我们以为的 new 操作应该是：

分配一块内存 M；

在内存 M 上初始化 Singleton 对象；

然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的：

分配一块内存 M；

将 M 的地址赋值给 instance 变量；

最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢？我们假设线程 A 先执行 getInstance() 方法，当执行完指令 2 时恰好发生了线程切换，切换到了线程 B 上；如果此时线程 B 也执行 getInstance() 方法，那么线程 B 在执行第一个判断时会发现 instance != null ，所以直接返回 instance，而此时的 instance 是没有初始化过的，如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

双重检查创建单例的异常执行路径

总结

要写好并发程序，首先要知道并发程序的问题在哪里，只有确定了“靶子”，才有可能把问题解决，毕竟所有的解决方案都是针对问题的。并发程序经常出现的诡异问题看上去非常无厘头，但是深究的话，无外乎就是直觉欺骗了我们，只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理，很多并发 Bug 都是可以理解、可以诊断的。

在介绍可见性、原子性、有序性的时候，特意提到缓存导致的可见性问题，线程切换带来的原子性问题，编译优化带来的有序性问题，其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的，都是提高程序性能。但是技术在解决一个问题的同时，必然会带来另外一个问题，所以在采用一项技术的同时，一定要清楚它带来的问题是什么，以及如何规避。

我们这个专栏在讲解每项技术的时候，都会尽量将每项技术解决的问题以及产生的问题讲清楚，也希望你能够在这方面多思考、多总结。

课后思考

常听人说，在 32 位的机器上对 long 型变量进行加减操作存在并发隐患，到底是不是这样呢？现在相信你一定能分析出来。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

02 | Java内存模型：看Java如何解决可见性和有序性问题

上一期我们讲到在并发场景中，因可见性、原子性、有序性导致的问题常常会违背我们的直觉，从而成为并发编程的 Bug 之源。这三者在编程领域属于共性问题，所有的编程语言都会遇到，Java 在诞生之初就支持多线程，自然也有针对这三者的技术方案，而且在编程语言领域处于领先地位。理解 Java 解决并发问题的解决方案，对于理解其他语言的解决方案有触类旁通的效果。

那我们就先来聊聊如何解决其中的可见性和有序性导致的问题，这也就引出来了今天的主角——Java 内存模型。

Java 内存模型这个概念，在职场的很多面试中都会考核到，是一个热门的考点，也是一个人并发水平的具体体现。原因是当并发程序出问题时，需要一行一行地检查代码，这个时候，只有掌握 Java 内存模型，才能慧眼如炬地发现问题。

什么是 Java 内存模型？

你已经知道，导致可见性的原因是缓存，导致有序性的原因是编译优化，那解决可见性、有序性最直接的办法就是禁用缓存和编译优化，但是这样问题虽然解决了，我们程序的性能可就堪忧了。

合理的方案应该是按需禁用缓存以及编译优化。那么，如何做到“按需禁用”呢？对于并发程序，何时禁用缓存以及编译优化只有程序员知道，那所谓“按需禁用”其实就是指按照程序员的要求来禁用。所以，为了解决可见性和有序性问题，只需要提供给程序员按需禁用缓存和编译优化的方法即可。

Java 内存模型是个很复杂的规范，可以从不同的视角来解读，站在我们这些程序员的视角，本质上可以理解为，Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说，这些方法包括 volatile、synchronized 和 final 三个关键字，以及六项 Happens-Before 规则，这也正是本期的重点内容。

使用 volatile 的困惑

volatile 关键字并不是 Java 语言的特产，古老的 C 语言里也有，它最原始的意义就是禁用 CPU 缓存。

例如，我们声明一个 volatile 变量 volatile int x = 0，它表达的是：告诉编译器，对这个变量的读写，不能使用 CPU 缓存，必须从内存中读取或者写入。这个语义看上去相当明确，但是在实际使用的时候却会带来困惑。

例如下面的示例代码，假设线程 A 执行 writer() 方法，按照 volatile 语义，会把变量 “v=true” 写入内存；假设线程 B 执行 reader() 方法，同样按照 volatile 语义，线程 B 会从内存中读取变量 v，如果线程 B 看到 “v == true” 时，那么线程 B 看到的变量 x 是多少呢？

直觉上看，应该是 42，那实际应该是多少呢？这个要看 Java 的版本，如果在低于 1.5 版本上运行，x 可能是 42，也有可能是 0；如果在 1.5 以上的版本上运行，x 就是等于 42。

// 以下代码来源于【参考 1】

class VolatileExample {

  int x = 0;

  volatile boolean v = false;

  public void writer() {

    x = 42;

    v = true;

  }

  public void reader() {

    if (v == true) {

      // 这里 x 会是多少呢？

    }

  }

}

分析一下，为什么 1.5 以前的版本会出现 x = 0 的情况呢？我相信你一定想到了，变量 x 可能被 CPU 缓存而导致可见性问题。这个问题在 1.5 版本已经被圆满解决了。Java 内存模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢？答案是一项 Happens-Before 规则。

Happens-Before 规则

如何理解 Happens-Before 呢？如果望文生义（很多网文也都爱按字面意思翻译成“先行发生”），那就南辕北辙了，Happens-Before 并不是说前面一个操作发生在后续操作的前面，它真正要表达的是：前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人，虽然远隔千里，一个人心之所想，另一个人都看得到。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。所以比较正式的说法是：Happens-Before 约束了编译器的优化行为，虽允许编译器优化，但是要求编译器优化后一定遵守 Happens-Before 规则。

Happens-Before 规则应该是 Java 内存模型里面最晦涩的内容了，和程序员相关的规则一共有如下六项，都是关于可见性的。

恰好前面示例代码涉及到这六项规则中的前三项，为便于你理解，我也会分析上面的示例代码，来看看规则 1、2 和 3 到底该如何理解。至于其他三项，我也会结合其他例子作以说明。

1. 程序的顺序性规则

这条规则是指在一个线程中，按照程序顺序，前面的操作 Happens-Before 于后续的任意操作。这还是比较容易理解的，比如刚才那段示例代码，按照程序的顺序，第 6 行代码 “x = 42;” Happens-Before 于第 7 行代码 “v = true;”，这就是规则 1 的内容，也比较符合单线程里面的思维：程序前面对某个变量的修改一定是对后续操作可见的。

（为方便你查看，我将那段示例代码在这儿再呈现一遍）

// 以下代码来源于【参考 1】

class VolatileExample {

  int x = 0;

  volatile boolean v = false;

  public void writer() {

    x = 42;

    v = true;

  }

  public void reader() {

    if (v == true) {

      // 这里 x 会是多少呢？

    }

  }

}

2. volatile 变量规则

这条规则是指对一个 volatile 变量的写操作， Happens-Before 于后续对这个 volatile 变量的读操作。

这个就有点费解了，对一个 volatile 变量的写操作相对于后续对这个 volatile 变量的读操作可见，这怎么看都是禁用缓存的意思啊，貌似和 1.5 版本以前的语义没有变化啊？如果单看这个规则，的确是这样，但是如果我们关联一下规则 3，就有点不一样的感觉了。

3. 传递性

这条规则是指如果 A Happens-Before B，且 B Happens-Before C，那么 A Happens-Before C。

我们将规则 3 的传递性应用到我们的例子中，会发生什么呢？可以看下面这幅图：

示例代码中的传递性规则

从图中，我们可以看到：

“x=42” Happens-Before 写变量 “v=true” ，这是规则 1 的内容；

写变量“v=true” Happens-Before 读变量 “v=true”，这是规则 2 的内容 。

再根据这个传递性规则，我们得到结果：“x=42” Happens-Before 读变量“v=true”。这意味着什么呢？

如果线程 B 读到了“v=true”，那么线程 A 设置的“x=42”对线程 B 是可见的。也就是说，线程 B 能看到 “x == 42” ，有没有一种恍然大悟的感觉？这就是 1.5 版本对 volatile 语义的增强，这个增强意义重大，1.5 版本的并发工具包（java.util.concurrent）就是靠 volatile 语义来搞定可见性的，这个在后面的内容中会详细介绍。

4. 管程中锁的规则

这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

要理解这个规则，就首先要了解“管程指的是什么”。管程是一种通用的同步原语，在 Java 中指的就是 synchronized，synchronized 是 Java 里对管程的实现。

管程中的锁在 Java 里是隐式实现的，例如下面的代码，在进入同步块之前，会自动加锁，而在代码块执行完会自动释放锁，加锁以及释放锁都是编译器帮我们实现的。

synchronized (this) { // 此处自动加锁

  // x 是共享变量, 初始值 =10

  if (this.x < 12) {

    this.x = 12;

  }

} // 此处自动解锁

所以结合规则 4——管程中锁的规则，可以这样理解：假设 x 的初始值是 10，线程 A 执行完代码块后 x 的值会变成 12（执行完自动释放锁），线程 B 进入代码块时，能够看到线程 A 对 x 的写操作，也就是线程 B 能够看到 x==12。这个也是符合我们直觉的，应该不难理解。

5. 线程 start() 规则

这条是关于线程启动的。它是指主线程 A 启动子线程 B 后，子线程 B 能够看到主线程在启动子线程 B 前的操作。

换句话说就是，如果线程 A 调用线程 B 的 start() 方法（即在线程 A 中启动线程 B），那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。具体可参考下面示例代码。

Thread B = new Thread(()->{

  // 主线程调用 B.start() 之前

  // 所有对共享变量的修改，此处皆可见

  // 此例中，var==77

});

// 此处对共享变量 var 修改

var = 77;

// 主线程启动子线程

B.start();

6. 线程 join() 规则

这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成（主线程 A 通过调用子线程 B 的 join() 方法实现），当子线程 B 完成后（主线程 A 中 join() 方法返回），主线程能够看到子线程的操作。当然所谓的“看到”，指的是对共享变量的操作。

换句话说就是，如果在线程 A 中，调用线程 B 的 join() 并成功返回，那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。具体可参考下面示例代码。

Thread B = new Thread(()->{

  // 此处对共享变量 var 修改

  var = 66;

});

// 例如此处对共享变量修改，

// 则这个修改结果对线程 B 可见

// 主线程启动子线程

B.start();

B.join()

// 子线程所有对共享变量的修改

// 在主线程调用 B.join() 之后皆可见

// 此例中，var==66

被我们忽视的 final

前面我们讲 volatile 为的是禁用缓存以及编译优化，我们再从另外一个方面来看，有没有办法告诉编译器优化得更好一点呢？这个可以有，就是final 关键字。

final 修饰变量时，初衷是告诉编译器：这个变量生而不变，可以可劲儿优化。Java 编译器在 1.5 以前的版本的确优化得很努力，以至于都优化错了。

问题类似于上一期提到的利用双重检查方法创建单例，构造函数的错误重排导致线程可能看到 final 变量的值会变化。详细的案例可以参考这个文档。

当然了，在 1.5 以后 Java 内存模型对 final 类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”，就不会出问题了。

“逸出”有点抽象，我们还是举个例子吧，在下面例子中，在构造函数里面将 this 赋值给了全局变量 global.obj，这就是“逸出”，线程通过 global.obj 读取 x 是有可能读到 0 的。因此我们一定要避免“逸出”。

// 以下代码来源于【参考 1】

final int x;

// 错误的构造函数

public FinalFieldExample() {

  x = 3;

  y = 4;

  // 此处就是讲 this 逸出，

  global.obj = this;

}

总结

Java 的内存模型是并发编程领域的一次重要创新，之后 C++、C#、Golang 等高级语言都开始支持内存模型。Java 内存模型里面，最晦涩的部分就是 Happens-Before 规则了，Happens-Before 规则最初是在一篇叫做Time, Clocks, and the Ordering of Events in a Distributed System的论文中提出来的，在这篇论文中，Happens-Before 的语义是一种因果关系。在现实世界里，如果 A 事件是导致 B 事件的起因，那么 A 事件一定是先于（Happens-Before）B 事件发生的，这个就是 Happens-Before 语义的现实理解。

在 Java 语言里面，Happens-Before 的语义本质上是一种可见性，A Happens-Before B 意味着 A 事件对 B 事件来说是可见的，无论 A 事件和 B 事件是否发生在同一个线程里。例如 A 事件发生在线程 1 上，B 事件发生在线程 2 上，Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。

Java 内存模型主要分为两部分，一部分面向你我这种编写并发程序的应用开发人员，另一部分是面向 JVM 的实现人员的，我们可以重点关注前者，也就是和编写并发程序相关的部分，这部分内容的核心就是 Happens-Before 规则。相信经过本章的介绍，你应该对这部分内容已经有了深入的认识。

课后思考

有一个共享变量 abc，在一个线程里设置了 abc 的值 abc=3，你思考一下，有哪些办法可以让其他线程能够看到abc==3？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

参考

JSR 133 (Java Memory Model) FAQ

Java 内存模型 FAQ

JSR-133: JavaTM Memory Model and Thread Specification

03 | 互斥锁（上）：解决原子性问题

在第一篇文章中我们提到，一个或者多个操作在 CPU 执行的过程中不被中断的特性，称为“原子性”。理解这个特性有助于你分析并发编程 Bug 出现的原因，例如利用它可以分析出 long 型变量在 32 位机器上读写可能出现的诡异 Bug，明明已经把变量成功写入内存，重新读出来却不是自己写入的。

那原子性问题到底该如何解决呢？

你已经知道，原子性问题的源头是线程切换，如果能够禁用线程切换那不就能解决这个问题了吗？而操作系统做线程切换是依赖 CPU 中断的，所以禁止 CPU 发生中断就能够禁止线程切换。

在早期单核 CPU 时代，这个方案的确是可行的，而且也有很多应用案例，但是并不适合多核场景。这里我们以 32 位 CPU 上执行 long 型变量的写操作为例来说明这个问题，long 型变量是 64 位，在 32 位 CPU 上执行写操作会被拆分成两次写操作（写高 32 位和写低 32 位，如下图所示）。

在单核 CPU 场景下，同一时刻只有一个线程执行，禁止 CPU 中断，意味着操作系统不会重新调度线程，也就是禁止了线程切换，获得 CPU 使用权的线程就可以不间断地执行，所以两次写操作一定是：要么都被执行，要么都没有被执行，具有原子性。

但是在多核场景下，同一时刻，有可能有两个线程同时在执行，一个线程执行在 CPU-1 上，一个线程执行在 CPU-2 上，此时禁止 CPU 中断，只能保证 CPU 上的线程连续执行，并不能保证同一时刻只有一个线程执行，如果这两个线程同时写 long 型变量高 32 位的话，那就有可能出现我们开头提及的诡异 Bug 了。

“同一时刻只有一个线程执行”这个条件非常重要，我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的，那么，无论是单核 CPU 还是多核 CPU，就都能保证原子性了。

简易锁模型

当谈到互斥，相信聪明的你一定想到了那个杀手级解决方案：锁。同时大脑中还会出现以下模型：

简易锁模型

我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前，首先尝试加锁 lock()，如果成功，则进入临界区，此时我们称这个线程持有锁；否则呢就等待，直到持有锁的线程解锁；持有锁的线程执行完临界区的代码后，执行解锁 unlock()。

这个过程非常像办公室里高峰期抢占坑位，每个人都是进坑锁门（加锁），出坑开门（解锁），如厕这个事就是临界区。很长时间里，我也是这么理解的。这样理解本身没有问题，但却很容易让我们忽视两个非常非常重要的点：我们锁的是什么？我们保护的又是什么？

改进后的锁模型

我们知道在现实世界里，锁和锁要保护的资源是有对应关系的，比如你用你家的锁保护你家的东西，我用我家的锁保护我家的东西。在并发编程世界里，锁和资源也应该有这个关系，但这个关系在我们上面的模型中是没有体现的，所以我们需要完善一下我们的模型。

改进后的锁模型

首先，我们要把临界区要保护的资源标注出来，如图中临界区里增加了一个元素：受保护的资源 R；其次，我们要保护资源 R 就得为它创建一把锁 LR；最后，针对这把锁 LR，我们还需在进出临界区时添上加锁操作和解锁操作。另外，在锁 LR 和受保护资源之间，我特地用一条线做了关联，这个关联关系非常重要。很多并发 Bug 的出现都是因为把它忽略了，然后就出现了类似锁自家门来保护他家资产的事情，这样的 Bug 非常不好诊断，因为潜意识里我们认为已经正确加锁了。

Java 语言提供的锁技术：synchronized

锁是一种通用的技术方案，Java 语言提供的 synchronized 关键字，就是锁的一种实现。synchronized 关键字可以用来修饰方法，也可以用来修饰代码块，它的使用示例基本上都是下面这个样子：

class X {

  // 修饰非静态方法

  synchronized void foo() {

    // 临界区

  }

  // 修饰静态方法

  synchronized static void bar() {

    // 临界区

  }

  // 修饰代码块

  Object obj = new Object()；

  void baz() {

    synchronized(obj) {

      // 临界区

    }

  }

}

看完之后你可能会觉得有点奇怪，这个和我们上面提到的模型有点对不上号啊，加锁 lock() 和解锁 unlock() 在哪里呢？其实这两个操作都是有的，只是这两个操作是被 Java 默默加上的，Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock()，这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的，毕竟忘记解锁 unlock() 可是个致命的 Bug（意味着其他线程只能死等下去了）。

那 synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象在哪里呢？上面的代码我们看到只有修饰代码块的时候，锁定了一个 obj 对象，那修饰方法的时候锁定的是什么呢？这个也是 Java 的一条隐式规则：

当修饰静态方法的时候，锁定的是当前类的 Class 对象，在上面的例子中就是 Class X；
当修饰非静态方法的时候，锁定的是当前实例对象 this。

对于上面的例子，synchronized 修饰静态方法相当于:

class X {

  // 修饰静态方法

  synchronized(X.class) static void bar() {

    // 临界区

  }

}

修饰非静态方法，相当于：

class X {

  // 修饰非静态方法

  synchronized(this) void foo() {

    // 临界区

  }

}

用 synchronized 解决 count+=1 问题

相信你一定记得我们前面文章中提到过的 count+=1 存在的并发问题，现在我们可以尝试用 synchronized 来小试牛刀一把，代码如下所示。SafeCalc 这个类有两个方法：一个是 get() 方法，用来获得 value 的值；另一个是 addOne() 方法，用来给 value 加 1，并且 addOne() 方法我们用 synchronized 修饰。那么我们使用的这两个方法有没有并发问题呢？

class SafeCalc {

  long value = 0L;

  long get() {

    return value;

  }

  synchronized void addOne() {

    value += 1;

  }

}

我们先来看看 addOne() 方法，首先可以肯定，被 synchronized 修饰后，无论是单核 CPU 还是多核 CPU，只有一个线程能够执行 addOne() 方法，所以一定能保证原子操作，那是否有可见性问题呢？要回答这问题，就要重温一下上一篇文章中提到的管程中锁的规则。

管程中锁的规则：对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

管程，就是我们这里的 synchronized（至于为什么叫管程，我们后面介绍），我们知道 synchronized 修饰的临界区是互斥的，也就是说同一时刻只有一个线程执行临界区的代码；而所谓“对一个锁解锁 Happens-Before 后续对这个锁的加锁”，指的是前一个线程的解锁操作对后一个线程的加锁操作可见，综合 Happens-Before 的传递性原则，我们就能得出前一个线程在临界区修改的共享变量（该操作在解锁之前），对后续进入临界区（该操作在加锁之后）的线程是可见的。

按照这个规则，如果多个线程同时执行 addOne() 方法，可见性是可以保证的，也就说如果有 1000 个线程执行 addOne() 方法，最终结果一定是 value 的值增加了 1000。看到这个结果，我们长出一口气，问题终于解决了。

但也许，你一不小心就忽视了 get() 方法。执行 addOne() 方法后，value 的值对 get() 方法是可见的吗？这个可见性是没法保证的。管程中锁的规则，是只保证后续对这个锁的加锁的可见性，而 get() 方法并没有加锁操作，所以可见性没法保证。那如何解决呢？很简单，就是 get() 方法也 synchronized 一下，完整的代码如下所示。

class SafeCalc {

  long value = 0L;

  synchronized long get() {

    return value;

  }

  synchronized void addOne() {

    value += 1;

  }

}

上面的代码转换为我们提到的锁模型，就是下面图示这个样子。get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源，这个资源用 this 这把锁来保护。线程要进入临界区 get() 和 addOne()，必须先获得 this 这把锁，这样 get() 和 addOne() 也是互斥的。

保护临界区 get() 和 addOne() 的示意图

这个模型更像现实世界里面球赛门票的管理，一个座位只允许一个人使用，这个座位就是“受保护资源”，球场的入口就是 Java 类里的方法，而门票就是用来保护资源的“锁”，Java 里的检票工作是由 synchronized 解决的。

锁和受保护资源的关系

我们前面提到，受保护资源和锁之间的关联关系非常重要，他们的关系是怎样的呢？一个合理的关系是：受保护资源和锁之间的关联关系是 N:1 的关系。还拿前面球赛门票的管理来类比，就是一个座位，我们只能用一张票来保护，如果多发了重复的票，那就要打架了。现实世界里，我们可以用多把锁来保护同一个资源，但在并发领域是不行的，并发领域的锁和现实世界的锁不是完全匹配的。不过倒是可以用同一把锁来保护多个资源，这个对应到现实世界就是我们所谓的“包场”了。

上面那个例子我稍作改动，把 value 改成静态变量，把 addOne() 方法改成静态方法，此时 get() 方法和 addOne() 方法是否存在并发问题呢？

class SafeCalc {

  static long value = 0L;

  synchronized long get() {

    return value;

  }

  synchronized static void addOne() {

    value += 1;

  }

}

如果你仔细观察，就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 value，两个锁分别是 this 和 SafeCalc.class。我们可以用下面这幅图来形象描述这个关系。由于临界区 get() 和 addOne() 是用两个锁保护的，因此这两个临界区没有互斥关系，临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证，这就导致并发问题了。

两把锁保护一个资源的示意图

总结

互斥锁，在并发领域的知名度极高，只要有了并发问题，大家首先容易想到的就是加锁，因为大家都知道，加锁能够保证执行临界区代码的互斥性。这样理解虽然正确，但是却不能够指导你真正用好互斥锁。临界区的代码是操作受保护资源的路径，类似于球场的入口，入口一定要检票，也就是要加锁，但不是随便一把锁都能有效。所以必须深入分析锁定的对象和受保护资源的关系，综合考虑受保护资源的访问路径，多方面考量才能用好互斥锁。

synchronized 是 Java 在语言层面提供的互斥原语，其实 Java 里面还有很多其他类型的锁，但作为互斥锁，原理都是相通的：锁，一定有一个要锁定的对象，至于这个锁定的对象要保护的资源以及在哪里加锁 / 解锁，就属于设计层面的事情了。

课后思考

下面的代码用 synchronized 修饰代码块来尝试解决并发问题，你觉得这个使用方式正确吗？有哪些问题呢？能解决可见性和原子性问题吗？

class SafeCalc {

  long value = 0L;

  long get() {

    synchronized (new Object()) {

      return value;

    }

  }

  void addOne() {

    synchronized (new Object()) {

      value += 1;

    }

  }

}

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

04 | 互斥锁（下）：如何用一把锁保护多个资源？

在上一篇文章中，我们提到受保护资源和锁之间合理的关联关系应该是 N:1 的关系，也就是说可以用一把锁来保护多个资源，但是不能用多把锁来保护一个资源，并且结合文中示例，我们也重点强调了“不能用多把锁来保护一个资源”这个问题。而至于如何保护多个资源，我们今天就来聊聊。

当我们要保护多个资源时，首先要区分这些资源是否存在关联关系。

保护没有关联关系的多个资源

在现实世界里，球场的座位和电影院的座位就是没有关联关系的，这种场景非常容易解决，那就是球赛有球赛的门票，电影院有电影院的门票，各自管理各自的。

同样这对应到编程领域，也很容易解决。例如，银行业务中有针对账户余额（余额是一种资源）的取款操作，也有针对账户密码（密码也是一种资源）的更改操作，我们可以为账户余额和账户密码分配不同的锁来解决并发问题，这个还是很简单的。

相关的示例代码如下，账户类 Account 有两个成员变量，分别是账户余额 balance 和账户密码 password。取款 withdraw() 和查看余额 getBalance() 操作会访问账户余额 balance，我们创建一个 final 对象 balLock 作为锁（类比球赛门票）；而更改密码 updatePassword() 和查看密码 getPassword() 操作会修改账户密码 password，我们创建一个 final 对象 pwLock 作为锁（类比电影票）。不同的资源用不同的锁保护，各自管各自的，很简单。

class Account {

  // 锁：保护账户余额

  private final Object balLock

    = new Object();

  // 账户余额

  private Integer balance;

  // 锁：保护账户密码

  private final Object pwLock

    = new Object();

  // 账户密码

  private String password;

  // 取款

  void withdraw(Integer amt) {

    synchronized(balLock) {

      if (this.balance > amt){

        this.balance -= amt;

      }

    }

  }

  // 查看余额

  Integer getBalance() {

    synchronized(balLock) {

      return balance;

    }

  }

  // 更改密码

  void updatePassword(String pw){

    synchronized(pwLock) {

      this.password = pw;

    }

  }

  // 查看密码

  String getPassword() {

    synchronized(pwLock) {

      return password;

    }

  }

}

当然，我们也可以用一把互斥锁来保护多个资源，例如我们可以用 this 这一把锁来管理账户类里所有的资源：账户余额和用户密码。具体实现很简单，示例程序中所有的方法都增加同步关键字 synchronized 就可以了，这里我就不一一展示了。

但是用一把锁有个问题，就是性能太差，会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁，取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理，能够提升性能。这种锁还有个名字，叫细粒度锁。

保护有关联关系的多个资源

如果多个资源是有关联关系的，那这个问题就有点复杂了。例如银行业务里面的转账操作，账户 A 减少 100 元，账户 B 增加 100 元。这两个账户就是有关联关系的。那对于像转账这种有关联关系的操作，我们应该怎么去解决呢？先把这个问题代码化。我们声明了个账户类：Account，该类有一个成员变量余额：balance，还有一个用于转账的方法：transfer()，然后怎么保证转账操作 transfer() 没有并发问题呢？

class Account {

  private int balance;

  // 转账

  void transfer(

      Account target, int amt){

    if (this.balance > amt) {

      this.balance -= amt;

      target.balance += amt;

    }

  }

}

相信你的直觉会告诉你这样的解决方案：用户 synchronized 关键字修饰一下 transfer() 方法就可以了，于是你很快就完成了相关的代码，如下所示。

class Account {

  private int balance;

  // 转账

  synchronized void transfer(

      Account target, int amt){

    if (this.balance > amt) {

      this.balance -= amt;

      target.balance += amt;

    }

  }

}

在这段代码中，临界区内有两个资源，分别是转出账户的余额 this.balance 和转入账户的余额 target.balance，并且用的是一把锁 this，符合我们前面提到的，多个资源可以用一把锁来保护，这看上去完全正确呀。真的是这样吗？可惜，这个方案仅仅是看似正确，为什么呢？

问题就出在 this 这把锁上，this 这把锁可以保护自己的余额 this.balance，却保护不了别人的余额 target.balance，就像你不能用自家的锁来保护别人家的资产，也不能用自己的票来保护别人的座位一样。

用锁 this 保护 this.balance 和 target.balance 的示意图

下面我们具体分析一下，假设有 A、B、C 三个账户，余额都是 200 元，我们用两个线程分别执行两个转账操作：账户 A 转给账户 B 100 元，账户 B 转给账户 C 100 元，最后我们期望的结果应该是账户 A 的余额是 100 元，账户 B 的余额是 200 元， 账户 C 的余额是 300 元。

我们假设线程 1 执行账户 A 转账户 B 的操作，线程 2 执行账户 B 转账户 C 的操作。这两个线程分别在两颗 CPU 上同时执行，那它们是互斥的吗？我们期望是，但实际上并不是。因为线程 1 锁定的是账户 A 的实例（A.this），而线程 2 锁定的是账户 B 的实例（B.this），所以这两个线程可以同时进入临界区 transfer()。同时进入临界区的结果是什么呢？线程 1 和线程 2 都会读到账户 B 的余额为 200，导致最终账户 B 的余额可能是 300（线程 1 后于线程 2 写 B.balance，线程 2 写的 B.balance 值被线程 1 覆盖），可能是 100（线程 1 先于线程 2 写 B.balance，线程 1 写的 B.balance 值被线程 2 覆盖），就是不可能是 200。

并发转账示意图

使用锁的正确姿势

在上一篇文章中，我们提到用同一把锁来保护多个资源，也就是现实世界的“包场”，那在编程领域应该怎么“包场”呢？很简单，只要我们的锁能覆盖所有受保护资源就可以了。在上面的例子中，this 是对象级别的锁，所以 A 对象和 B 对象都有自己的锁，如何让 A 对象和 B 对象共享一把锁呢？

稍微开动脑筋，你会发现其实方案还挺多的，比如可以让所有对象都持有一个唯一性的对象，这个对象在创建 Account 时传入。方案有了，完成代码就简单了。示例代码如下，我们把 Account 默认构造函数变为 private，同时增加一个带 Object lock 参数的构造函数，创建 Account 对象时，传入相同的 lock，这样所有的 Account 对象都会共享这个 lock 了。

class Account {

  private Object lock；

  private int balance;

  private Account();

  // 创建 Account 时传入同一个 lock 对象

  public Account(Object lock) {

    this.lock = lock;

  }

  // 转账

  void transfer(Account target, int amt){

    // 此处检查所有对象共享的锁

    synchronized(lock) {

      if (this.balance > amt) {

        this.balance -= amt;

        target.balance += amt;

      }

    }

  }

}

这个办法确实能解决问题，但是有点小瑕疵，它要求在创建 Account 对象的时候必须传入同一个对象，如果创建 Account 对象时，传入的 lock 不是同一个对象，那可就惨了，会出现锁自家门来保护他家资产的荒唐事。在真实的项目场景中，创建 Account 对象的代码很可能分散在多个工程中，传入共享的 lock 真的很难。

所以，上面的方案缺乏实践的可行性，我们需要更好的方案。还真有，就是用 Account.class 作为共享的锁。Account.class 是所有 Account 对象共享的，而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的，所以我们不用担心它的唯一性。使用 Account.class 作为共享的锁，我们就无需在创建 Account 对象时传入了，代码更简单。

class Account {

  private int balance;

  // 转账

  void transfer(Account target, int amt){

    synchronized(Account.class) {

      if (this.balance > amt) {

        this.balance -= amt;

        target.balance += amt;

      }

    }

  }

}

下面这幅图很直观地展示了我们是如何使用共享的锁 Account.class 来保护不同对象的临界区的。

总结

相信你看完这篇文章后，对如何保护多个资源已经很有心得了，关键是要分析多个资源之间的关系。如果资源之间没有关系，很好处理，每个资源一把锁就可以了。如果资源之间有关联关系，就要选择一个粒度更大的锁，这个锁应该能够覆盖所有相关的资源。除此之外，还要梳理出有哪些访问路径，所有的访问路径都要设置合适的锁，这个过程可以类比一下门票管理。

我们再引申一下上面提到的关联关系，关联关系如果用更具体、更专业的语言来描述的话，其实是一种“原子性”特征，在前面的文章中，我们提到的原子性，主要是面向 CPU 指令的，转账操作的原子性则是属于是面向高级语言的，不过它们本质上是一样的。

“原子性”的本质是什么？其实不是不可分割，不可分割只是外在表现，其本质是多个资源间有一致性的要求，操作的中间状态对外不可见。例如，在 32 位的机器上写 long 型变量有中间状态（只写了 64 位中的 32 位），在银行转账的操作中也有中间状态（账户 A 减少了 100，账户 B 还没来得及发生变化）。所以解决原子性问题，是要保证中间状态对外不可见。

课后思考

在第一个示例程序里，我们用了两把不同的锁来分别保护账户余额、账户密码，创建锁的时候，我们用的是：private final Object xxxLock = new Object();，如果账户余额用 this.balance 作为互斥锁，账户密码用 this.password 作为互斥锁，你觉得是否可以呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

05 | 一不小心就死锁了，怎么办？

在上一篇文章中，我们用 Account.class 作为互斥锁，来解决银行业务里面的转账问题，虽然这个方案不存在并发问题，但是所有账户的转账操作都是串行的，例如账户 A 转账户 B、账户 C 转账户 D 这两个转账操作现实世界里是可以并行的，但是在这个方案里却被串行化了，这样的话，性能太差。

试想互联网支付盛行的当下，8 亿网民每人每天一笔交易，每天就是 8 亿笔交易；每笔交易都对应着一次转账操作，8 亿笔交易就是 8 亿次转账操作，也就是说平均到每秒就是近 1 万次转账操作，若所有的转账操作都串行，性能完全不能接受。

那下面我们就尝试着把性能提升一下。

向现实世界要答案

现实世界里，账户转账操作是支持并发的，而且绝对是真正的并行，银行所有的窗口都可以做转账操作。只要我们能仿照现实世界做转账操作，串行的问题就解决了。

我们试想在古代，没有信息化，账户的存在形式真的就是一个账本，而且每个账户都有一个账本，这些账本都统一存放在文件架上。银行柜员在给我们做转账时，要去文件架上把转出账本和转入账本都拿到手，然后做转账。这个柜员在拿账本的时候可能遇到以下三种情况：

文件架上恰好有转出账本和转入账本，那就同时拿走；

如果文件架上只有转出账本和转入账本之一，那这个柜员就先把文件架上有的账本拿到手，同时等着其他柜员把另外一个账本送回来；

转出账本和转入账本都没有，那这个柜员就等着两个账本都被送回来。

上面这个过程在编程的世界里怎么实现呢？其实用两把锁就实现了，转出账本一把，转入账本另一把。在 transfer() 方法内部，我们首先尝试锁定转出账户 this（先把转出账本拿到手），然后尝试锁定转入账户 target（再把转入账本拿到手），只有当两者都成功时，才执行转账操作。这个逻辑可以图形化为下图这个样子。

两个转账操作并行示意图

而至于详细的代码实现，如下所示。经过这样的优化后，账户 A 转账户 B 和账户 C 转账户 D 这两个转账操作就可以并行了。

class Account {

  private int balance;

  // 转账

  void transfer(Account target, int amt){

    // 锁定转出账户

    synchronized(this) {

      // 锁定转入账户

      synchronized(target) {

        if (this.balance > amt) {

          this.balance -= amt;

          target.balance += amt;

        }

      }

    }

  }

}

没有免费的午餐

上面的实现看上去很完美，并且也算是将锁用得出神入化了。相对于用 Account.class 作为互斥锁，锁定的范围太大，而我们锁定两个账户范围就小多了，这样的锁，上一章我们介绍过，叫细粒度锁。使用细粒度锁可以提高并行度，是性能优化的一个重要手段。

这个时候可能你已经开始警觉了，使用细粒度锁这么简单，有这样的好事，是不是也要付出点什么代价啊？编写并发程序就需要这样时时刻刻保持谨慎。

的确，使用细粒度锁是有代价的，这个代价就是可能会导致死锁。

在详细介绍死锁之前，我们先看看现实世界里的一种特殊场景。如果有客户找柜员张三做个转账业务：账户 A 转账户 B 100 元，此时另一个客户找柜员李四也做个转账业务：账户 B 转账户 A 100 元，于是张三和李四同时都去文件架上拿账本，这时候有可能凑巧张三拿到了账本 A，李四拿到了账本 B。张三拿到账本 A 后就等着账本 B（账本 B 已经被李四拿走），而李四拿到账本 B 后就等着账本 A（账本 A 已经被张三拿走），他们要等多久呢？他们会永远等待下去…因为张三不会把账本 A 送回去，李四也不会把账本 B 送回去。我们姑且称为死等吧。

转账业务中的“死等”

现实世界里的死等，就是编程领域的死锁了。死锁的一个比较专业的定义是：一组互相竞争资源的线程因互相等待，导致“永久”阻塞的现象。

上面转账的代码是怎么发生死锁的呢？我们假设线程 T1 执行账户 A 转账户 B 的操作，账户 A.transfer(账户 B)；同时线程 T2 执行账户 B 转账户 A 的操作，账户 B.transfer(账户 A)。当 T1 和 T2 同时执行完①处的代码时，T1 获得了账户 A 的锁（对于 T1，this 是账户 A），而 T2 获得了账户 B 的锁（对于 T2，this 是账户 B）。之后 T1 和 T2 在执行②处的代码时，T1 试图获取账户 B 的锁时，发现账户 B 已经被锁定（被 T2 锁定），所以 T1 开始等待；T2 则试图获取账户 A 的锁时，发现账户 A 已经被锁定（被 T1 锁定），所以 T2 也开始等待。于是 T1 和 T2 会无期限地等待下去，也就是我们所说的死锁了。

class Account {

  private int balance;

  // 转账

  void transfer(Account target, int amt){

    // 锁定转出账户

    synchronized(this){     ①

      // 锁定转入账户

      synchronized(target){ ②

        if (this.balance > amt) {

          this.balance -= amt;

          target.balance += amt;

        }

      }

    }

  }

}

关于这种现象，我们还可以借助资源分配图来可视化锁的占用情况（资源分配图是个有向图，它可以描述资源和线程的状态）。其中，资源用方形节点表示，线程用圆形节点表示；资源中的点指向线程的边表示线程已经获得该资源，线程指向资源的边则表示线程请求资源，但尚未得到。转账发生死锁时的资源分配图就如下图所示，一个“各据山头死等”的尴尬局面。

转账发生死锁时的资源分配图

如何预防死锁

并发程序一旦死锁，一般没有特别好的方法，很多时候我们只能重启应用。因此，解决死锁问题最好的办法还是规避死锁。

那如何避免死锁呢？要避免死锁就需要分析死锁发生的条件，有个叫 Coffman 的牛人早就总结过了，只有以下这四个条件都发生时才会出现死锁：

互斥，共享资源 X 和 Y 只能被一个线程占用；

占有且等待，线程 T1 已经取得共享资源 X，在等待共享资源 Y 的时候，不释放共享资源 X；

不可抢占，其他线程不能强行抢占线程 T1 占有的资源；

循环等待，线程 T1 等待线程 T2 占有的资源，线程 T2 等待线程 T1 占有的资源，就是循环等待。

反过来分析，也就是说只要我们破坏其中一个，就可以成功避免死锁的发生。

其中，互斥这个条件我们没有办法破坏，因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的，到底如何做呢？

对于“占用且等待”这个条件，我们可以一次性申请所有的资源，这样就不存在等待了。

对于“不可抢占”这个条件，占用部分资源的线程进一步申请其他资源时，如果申请不到，可以主动释放它占有的资源，这样不可抢占这个条件就破坏掉了。

对于“循环等待”这个条件，可以靠按序申请资源来预防。所谓按序申请，是指资源是有线性顺序的，申请的时候可以先申请资源序号小的，再申请资源序号大的，这样线性化后自然就不存在循环了。

我们已经从理论上解决了如何预防死锁，那具体如何体现在代码上呢？下面我们就来尝试用代码实践一下这些理论。

1. 破坏占用且等待条件

从理论上讲，要破坏这个条件，可以一次性申请所有资源。在现实世界里，就拿前面我们提到的转账操作来讲，它需要的资源有两个，一个是转出账户，另一个是转入账户，当这两个账户同时被申请时，我们该怎么解决这个问题呢？

可以增加一个账本管理员，然后只允许账本管理员从文件架上拿账本，也就是说柜员不能直接在文件架上拿账本，必须通过账本管理员才能拿到想要的账本。例如，张三同时申请账本 A 和 B，账本管理员如果发现文件架上只有账本 A，这个时候账本管理员是不会把账本 A 拿下来给张三的，只有账本 A 和 B 都在的时候才会给张三。这样就保证了“一次性申请所有资源”。

通过账本管理员拿账本

对应到编程领域，“同时申请”这个操作是一个临界区，我们也需要一个角色（Java 里面的类）来管理这个临界区，我们就把这个角色定为 Allocator。它有两个重要功能，分别是：同时申请资源 apply() 和同时释放资源 free()。账户 Account 类里面持有一个 Allocator 的单例（必须是单例，只能由一个人来分配资源）。当账户 Account 在执行转账操作的时候，首先向 Allocator 同时申请转出账户和转入账户这两个资源，成功后再锁定这两个资源；当转账操作执行完，释放锁之后，我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。具体的代码实现如下。

class Allocator {

  private List<Object> als =

    new ArrayList<>();

  // 一次性申请所有资源

  synchronized boolean apply(

    Object from, Object to){

    if(als.contains(from) ||

         als.contains(to)){

      return false;

    } else {

      als.add(from);

      als.add(to);

    }

    return true;

  }

  // 归还资源

  synchronized void free(

    Object from, Object to){

    als.remove(from);

    als.remove(to);

  }

}

class Account {

  // actr 应该为单例

  private Allocator actr;

  private int balance;

  // 转账

  void transfer(Account target, int amt){

    // 一次性申请转出账户和转入账户，直到成功

    while(!actr.apply(this, target))

      ；

    try{

      // 锁定转出账户

      synchronized(this){

        // 锁定转入账户

        synchronized(target){

          if (this.balance > amt){

            this.balance -= amt;

            target.balance += amt;

          }

        }

      }

    } finally {

      actr.free(this, target)

    }

  }

}

2. 破坏不可抢占条件

破坏不可抢占条件看上去很简单，核心是要能够主动释放它占有的资源，这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候，如果申请不到，线程直接进入阻塞状态了，而线程进入阻塞状态，啥都干不了，也释放不了线程已经占有的资源。

你可能会质疑，“Java 作为排行榜第一的语言，这都解决不了？”你的怀疑很有道理，Java 在语言层次确实没有解决这个问题，不过在 SDK 层面还是解决了的，java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的。关于这个话题，咱们后面会详细讲。

3. 破坏循环等待条件

破坏这个条件，需要对资源进行排序，然后按序申请资源。这个实现非常简单，我们假设每个账户都有不同的属性 id，这个 id 可以作为排序字段，申请的时候，我们可以按照从小到大的顺序来申请。比如下面代码中，①~⑥处的代码对转出账户（this）和转入账户（target）排序，然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。

class Account {

  private int id;

  private int balance;

  // 转账

  void transfer(Account target, int amt){

    Account left = this        ①

    Account right = target;    ②

    if (this.id > target.id) { ③

      left = target;           ④

      right = this;            ⑤

    }                          ⑥

    // 锁定序号小的账户

    synchronized(left){

      // 锁定序号大的账户

      synchronized(right){

        if (this.balance > amt){

          this.balance -= amt;

          target.balance += amt;

        }

      }

    }

  }

}

总结

当我们在编程世界里遇到问题时，应不局限于当下，可以换个思路，向现实世界要答案，利用现实世界的模型来构思解决方案，这样往往能够让我们的方案更容易理解，也更能够看清楚问题的本质。

但是现实世界的模型有些细节往往会被我们忽视。因为在现实世界里，人太智能了，以致有些细节实在是显得太不重要了。在转账的模型中，我们为什么会忽视死锁问题呢？原因主要是在现实世界，我们会交流，并且会很智能地交流。而编程世界里，两个线程是不会智能地交流的。所以在利用现实模型建模的时候，我们还要仔细对比现实世界和编程世界里的各角色之间的差异。

我们今天这一篇文章主要讲了用细粒度锁来锁定多个资源时，要注意死锁的问题。这个就需要你能把它强化为一个思维定势，遇到这种场景，马上想到可能存在死锁问题。当你知道风险之后，才有机会谈如何预防和避免，因此，识别出风险很重要。

预防死锁主要是破坏三个条件中的一个，有了这个思路后，实现就简单了。但仍需注意的是，有时候预防死锁成本也是很高的。例如上面转账那个例子，我们破坏占用且等待条件的成本就比破坏循环等待条件的成本高，破坏占用且等待条件，我们也是锁了所有的账户，而且还是用了死循环 while(!actr.apply(this, target));方法，不过好在 apply() 这个方法基本不耗时。 在转账这个例子中，破坏循环等待条件就是成本最低的一个方案。

所以我们在选择具体方案的时候，还需要评估一下操作成本，从中选择一个成本最低的方案。

课后思考

我们上面提到：破坏占用且等待条件，我们也是锁了所有的账户，而且还是用了死循环 while(!actr.apply(this, target));这个方法，那它比 synchronized(Account.class) 有没有性能优势呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

06 | 用“等待-通知”机制优化循环等待

由上一篇文章你应该已经知道，在破坏占用且等待条件的时候，如果转出账本和转入账本不满足同时在文件架上这个条件，就用死循环的方式来循环等待，核心代码如下：

// 一次性申请转出账户和转入账户，直到成功

while(!actr.apply(this, target))

  ；

如果 apply() 操作耗时非常短，而且并发冲突量也不大时，这个方案还挺不错的，因为这种场景下，循环上几次或者几十次就能一次性获取转出账户和转入账户了。但是如果 apply() 操作耗时长，或者并发冲突量大的时候，循环等待这种方案就不适用了，因为在这种场景下，可能要循环上万次才能获取到锁，太消耗 CPU 了。

其实在这种场景下，最好的方案应该是：如果线程要求的条件（转出账本和转入账本同在文件架上）不满足，则线程阻塞自己，进入等待状态；当线程要求的条件（转出账本和转入账本同在文件架上）满足后，通知等待的线程重新执行。其中，使用线程阻塞的方式就能避免循环等待消耗 CPU 的问题。

那 Java 语言是否支持这种等待 - 通知机制呢？答案是：一定支持（毕竟占据排行榜第一那么久）。下面我们就来看看 Java 语言是如何支持等待 - 通知机制的。

完美的就医流程

在介绍 Java 语言如何支持等待 - 通知机制之前，我们先看一个现实世界里面的就医流程，因为它有着完善的等待 - 通知机制，所以对比就医流程，我们就能更好地理解和应用并发编程中的等待 - 通知机制。

就医流程基本上是这样：

患者先去挂号，然后到就诊门口分诊，等待叫号；

当叫到自己的号时，患者就可以找大夫就诊了；

就诊过程中，大夫可能会让患者去做检查，同时叫下一位患者；

当患者做完检查后，拿检测报告重新分诊，等待叫号；

当大夫再次叫到自己的号时，患者再去找大夫就诊。

或许你已经发现了，这个有着完美等待 - 通知机制的就医流程，不仅能够保证同一时刻大夫只为一个患者服务，而且还能够保证大夫和患者的效率。与此同时你可能也会有疑问，“这个就医流程很复杂呀，我们前面描述的等待 - 通知机制相较而言是不是太简单了？”那这个复杂度是否是必须的呢？这个是必须的，我们不能忽视等待 - 通知机制中的一些细节。

下面我们来对比看一下前面都忽视了哪些细节。

患者到就诊门口分诊，类似于线程要去获取互斥锁；当患者被叫到时，类似线程已经获取到锁了。

大夫让患者去做检查（缺乏检测报告不能诊断病因），类似于线程要求的条件没有满足。

患者去做检查，类似于线程进入等待状态；然后大夫叫下一个患者，这个步骤我们在前面的等待 - 通知机制中忽视了，这个步骤对应到程序里，本质是线程释放持有的互斥锁。

患者做完检查，类似于线程要求的条件已经满足；患者拿检测报告重新分诊，类似于线程需要重新获取互斥锁，这个步骤我们在前面的等待 - 通知机制中也忽视了。

所以加上这些至关重要的细节，综合一下，就可以得出一个完整的等待 - 通知机制：线程首先获取互斥锁，当线程要求的条件不满足时，释放互斥锁，进入等待状态；当要求的条件满足时，通知等待的线程，重新获取互斥锁。

用 synchronized 实现等待 - 通知机制

在 Java 语言里，等待 - 通知机制可以有多种实现方式，比如 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现。

如何用 synchronized 实现互斥锁，你应该已经很熟悉了。在下面这个图里，左边有一个等待队列，同一时刻，只允许一个线程进入 synchronized 保护的临界区（这个临界区可以看作大夫的诊室），当有一个线程进入临界区后，其他线程就只能进入图中左边的等待队列里等待（相当于患者分诊等待）。这个等待队列和互斥锁是一对一的关系，每个互斥锁都有自己独立的等待队列。

wait() 操作工作原理图

在并发程序中，当一个线程进入临界区后，由于某些条件不满足，需要进入等待状态，Java 对象的 wait() 方法就能够满足这种需求。如上图所示，当调用 wait() 方法后，当前线程就会被阻塞，并且进入到右边的等待队列中，这个等待队列也是互斥锁的等待队列。 线程在进入等待队列的同时，会释放持有的互斥锁，线程释放锁后，其他线程就有机会获得锁，并进入临界区了。

那线程要求的条件满足时，该怎么通知这个等待的线程呢？很简单，就是 Java 对象的 notify() 和 notifyAll() 方法。我在下面这个图里为你大致描述了这个过程，当条件满足时调用 notify()，会通知等待队列（互斥锁的等待队列）中的线程，告诉它条件曾经满足过。

notify() 操作工作原理图

为什么说是曾经满足过呢？因为notify() 只能保证在通知时间点，条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合，所以当线程执行的时候，很可能条件已经不满足了（保不齐有其他线程插队）。这一点你需要格外注意。

除此之外，还有一个需要注意的点，被通知的线程要想重新执行，仍然需要获取到互斥锁（因为曾经获取的锁在调用 wait() 时已经释放了）。

上面我们一直强调 wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列，所以如果 synchronized 锁定的是 this，那么对应的一定是 this.wait()、this.notify()、this.notifyAll()；如果 synchronized 锁定的是 target，那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。而且 wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁，所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。如果在 synchronized{}外部调用，或者锁定的 this，而用 target.wait() 调用的话，JVM 会抛出一个运行时异常：java.lang.IllegalMonitorStateException。

小试牛刀：一个更好地资源分配器

等待 - 通知机制的基本原理搞清楚后，我们就来看看它如何解决一次性申请转出账户和转入账户的问题吧。在这个等待 - 通知机制中，我们需要考虑以下四个要素。

互斥锁：上一篇文章我们提到 Allocator 需要是单例的，所以我们可以用 this 作为互斥锁。

线程要求的条件：转出账户和转入账户都没有被分配过。

何时等待：线程要求的条件不满足就等待。

何时通知：当有线程释放账户时就通知。

将上面几个问题考虑清楚，可以快速完成下面的代码。需要注意的是我们使用了：

  while(条件不满足) {

    wait();

  }

利用这种范式可以解决上面提到的条件曾经满足过这个问题。因为当 wait() 返回时，有可能条件已经发生变化了，曾经条件满足，但是现在已经不满足了，所以要重新检验条件是否满足。范式，意味着是经典做法，所以没有特殊理由不要尝试换个写法。后面在介绍“管程”的时候，我会详细介绍这个经典做法的前世今生。

class Allocator {

  private List<Object> als;

  // 一次性申请所有资源

  synchronized void apply(

    Object from, Object to){

    // 经典写法

    while(als.contains(from) ||

         als.contains(to)){

      try{

        wait();

      }catch(Exception e){

      }

    }

    als.add(from);

    als.add(to);

  }

  // 归还资源

  synchronized void free(

    Object from, Object to){

    als.remove(from);

    als.remove(to);

    notifyAll();

  }

}

尽量使用 notifyAll()

在上面的代码中，我用的是 notifyAll() 来实现通知机制，为什么不使用 notify() 呢？这二者是有区别的，notify() 是会随机地通知等待队列中的一个线程，而 notifyAll() 会通知等待队列中的所有线程。从感觉上来讲，应该是 notify() 更好一些，因为即便通知所有线程，也只有一个线程能够进入临界区。但那所谓的感觉往往都蕴藏着风险，实际上使用 notify() 也很有风险，它的风险在于可能导致某些线程永远不会被通知到。

假设我们有资源 A、B、C、D，线程 1 申请到了 AB，线程 2 申请到了 CD，此时线程 3 申请 AB，会进入等待队列（AB 分配给线程 1，线程 3 要求的条件不满足），线程 4 申请 CD 也会进入等待队列。我们再假设之后线程 1 归还了资源 AB，如果使用 notify() 来通知等待队列中的线程，有可能被通知的是线程 4，但线程 4 申请的是 CD，所以此时线程 4 还是会继续等待，而真正该唤醒的线程 3 就再也没有机会被唤醒了。

所以除非经过深思熟虑，否则尽量使用 notifyAll()。

总结

等待 - 通知机制是一种非常普遍的线程间协作的方式。工作中经常看到有同学使用轮询的方式来等待某个状态，其实很多情况下都可以用今天我们介绍的等待 - 通知机制来优化。Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法可以快速实现这种机制，但是它们的使用看上去还是有点复杂，所以你需要认真理解等待队列和 wait()、notify()、notifyAll() 的关系。最好用现实世界做个类比，这样有助于你的理解。

Java 语言的这种实现，背后的理论模型其实是管程，这个很重要，不过你不用担心，后面会有专门的一章来介绍管程。现在你只需要能够熟练使用就可以了。

课后思考

很多面试都会问到，wait() 方法和 sleep() 方法都能让当前线程挂起一段时间，那它们的区别是什么？现在你也试着回答一下吧。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

07 | 安全性、活跃性以及性能问题

通过前面六篇文章，我们开启了一个简单的并发旅程，相信现在你对并发编程需要注意的问题已经有了更深入的理解，这是一个很大的进步，正所谓只有发现问题，才能解决问题。但是前面六篇文章的知识点可能还是有点分散，所以是时候将其总结一下了。

并发编程中我们需要注意的问题有很多，很庆幸前人已经帮我们总结过了，主要有三个方面，分别是：安全性问题、活跃性问题和性能问题。下面我就来一一介绍这些问题。

安全性问题

相信你一定听说过类似这样的描述：这个方法不是线程安全的，这个类不是线程安全的，等等。

那什么是线程安全呢？其实本质上就是正确性，而正确性的含义就是程序按照我们期望的执行，不要让我们感到意外。在第一篇《可见性、原子性和有序性问题：并发编程 Bug 的源头》中，我们已经见识过很多诡异的 Bug，都是出乎我们预料的，它们都没有按照我们期望的执行。

那如何才能写出线程安全的程序呢？第一篇文章中已经介绍了并发 Bug 的三个主要源头：原子性问题、可见性问题和有序性问题。也就是说，理论上线程安全的程序，就要避免出现原子性问题、可见性问题和有序性问题。

那是不是所有的代码都需要认真分析一遍是否存在这三个问题呢？当然不是，其实只有一种情况需要：存在共享数据并且该数据会发生变化，通俗地讲就是有多个线程会同时读写同一数据。那如果能够做到不共享数据或者数据状态不发生变化，不就能够保证线程的安全性了嘛。有不少技术方案都是基于这个理论的，例如线程本地存储（Thread Local Storage，TLS）、不变模式等等，后面我会详细介绍相关的技术方案是如何在 Java 语言中实现的。

但是，现实生活中，必须共享会发生变化的数据，这样的应用场景还是很多的。

当多个线程同时访问同一数据，并且至少有一个线程会写这个数据的时候，如果我们不采取防护措施，那么就会导致并发 Bug，对此还有一个专业的术语，叫做数据竞争（Data Race）。比如，前面第一篇文章里有个 add10K() 的方法，当多个线程调用时候就会发生数据竞争，如下所示。

public class Test {

  private long count = 0;

  void add10K() {

    int idx = 0;

    while(idx++ < 10000) {

      count += 1;

    }

  }

}

那是不是在访问数据的地方，我们加个锁保护一下就能解决所有的并发问题了呢？显然没有这么简单。例如，对于上面示例，我们稍作修改，增加两个被 synchronized 修饰的 get() 和 set() 方法， add10K() 方法里面通过 get() 和 set() 方法来访问 value 变量，修改后的代码如下所示。对于修改后的代码，所有访问共享变量 value 的地方，我们都增加了互斥锁，此时是不存在数据竞争的。但很显然修改后的 add10K() 方法并不是线程安全的。

public class Test {

  private long count = 0;

  synchronized long get(){

    return count；

  }

  synchronized void set(long v){

    count = v;

  }

  void add10K() {

    int idx = 0;

    while(idx++ < 10000) {

      set(get()+1)

    }

  }

}

假设 count=0，当两个线程同时执行 get() 方法时，get() 方法会返回相同的值 0，两个线程执行 get()+1 操作，结果都是 1，之后两个线程再将结果 1 写入了内存。你本来期望的是 2，而结果却是 1。

这种问题，有个官方的称呼，叫竞态条件（Race Condition）。所谓竞态条件，指的是程序的执行结果依赖线程执行的顺序。例如上面的例子，如果两个线程完全同时执行，那么结果是 1；如果两个线程是前后执行，那么结果就是 2。在并发环境里，线程的执行顺序是不确定的，如果程序存在竞态条件问题，那就意味着程序执行的结果是不确定的，而执行结果不确定这可是个大 Bug。

下面再结合一个例子来说明下竞态条件，就是前面文章中提到的转账操作。转账操作里面有个判断条件——转出金额不能大于账户余额，但在并发环境里面，如果不加控制，当多个线程同时对一个账号执行转出操作时，就有可能出现超额转出问题。假设账户 A 有余额 200，线程 1 和线程 2 都要从账户 A 转出 150，在下面的代码里，有可能线程 1 和线程 2 同时执行到第 6 行，这样线程 1 和线程 2 都会发现转出金额 150 小于账户余额 200，于是就会发生超额转出的情况。

class Account {

  private int balance;

  // 转账

  void transfer(

      Account target, int amt){

    if (this.balance > amt) {

      this.balance -= amt;

      target.balance += amt;

    }

  }

}

所以你也可以按照下面这样来理解竞态条件。在并发场景中，程序的执行依赖于某个状态变量，也就是类似于下面这样：

if (状态变量 满足 执行条件) {

  执行操作

}

当某个线程发现状态变量满足执行条件后，开始执行操作；可是就在这个线程执行操作的时候，其他线程同时修改了状态变量，导致状态变量不满足执行条件了。当然很多场景下，这个条件不是显式的，例如前面 addOne 的例子中，set(get()+1) 这个复合操作，其实就隐式依赖 get() 的结果。

那面对数据竞争和竞态条件问题，又该如何保证线程的安全性呢？其实这两类问题，都可以用互斥这个技术方案，而实现互斥的方案有很多，CPU 提供了相关的互斥指令，操作系统、编程语言也会提供相关的 API。从逻辑上来看，我们可以统一归为：锁。前面几章我们也粗略地介绍了如何使用锁，相信你已经胸中有丘壑了，这里就不再赘述了，你可以结合前面的文章温故知新。

活跃性问题

所谓活跃性问题，指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题，当然除了死锁外，还有两种情况，分别是“活锁”和“饥饿”。

通过前面的学习你已经知道，发生“死锁”后线程会互相等待，而且会一直等待下去，在技术上的表现形式是线程永久地“阻塞”了。

但有时线程虽然没有发生阻塞，但仍然会存在执行不下去的情况，这就是所谓的“活锁”。可以类比现实世界里的例子，路人甲从左手边出门，路人乙从右手边进门，两人为了不相撞，互相谦让，路人甲让路走右手边，路人乙也让路走左手边，结果是两人又相撞了。这种情况，基本上谦让几次就解决了，因为人会交流啊。可是如果这种情况发生在编程世界了，就有可能会一直没完没了地“谦让”下去，成为没有发生阻塞但依然执行不下去的“活锁”。

解决“活锁”的方案很简单，谦让时，尝试等待一个随机的时间就可以了。例如上面的那个例子，路人甲走左手边发现前面有人，并不是立刻换到右手边，而是等待一个随机的时间后，再换到右手边；同样，路人乙也不是立刻切换路线，也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的，所以同时相撞后再次相撞的概率就很低了。“等待一个随机时间”的方案虽然很简单，却非常有效，Raft 这样知名的分布式一致性算法中也用到了它。

那“饥饿”该怎么去理解呢？所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。“不患寡，而患不均”，如果线程优先级“不均”，在 CPU 繁忙的情况下，优先级低的线程得到执行的机会很小，就可能发生线程“饥饿”；持有锁的线程，如果执行的时间过长，也可能导致“饥饿”问题。

解决“饥饿”问题的方案很简单，有三种方案：一是保证资源充足，二是公平地分配资源，三就是避免持有锁的线程长时间执行。这三个方案中，方案一和方案三的适用场景比较有限，因为很多场景下，资源的稀缺性是没办法解决的，持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。

那如何公平地分配资源呢？在并发编程里，主要是使用公平锁。所谓公平锁，是一种先来后到的方案，线程的等待是有顺序的，排在等待队列前面的线程会优先获得资源。

性能问题

使用“锁”要非常小心，但是如果小心过度，也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大，这样就不能够发挥多线程的优势了，而我们之所以使用多线程搞并发程序，为的就是提升性能。

所以我们要尽量减少串行，那串行对性能的影响是怎么样的呢？假设串行百分比是 5%，我们用多核多线程相比单核单线程能提速多少呢？

有个阿姆达尔（Amdahl）定律，代表了处理器并行运算之后效率提升的能力，它正好可以解决这个问题，具体公式如下：

S=1(1−p)+pnS=1(1−p)+pn

公式里的 n 可以理解为 CPU 的核数，p 可以理解为并行百分比，那（1-p）就是串行百分比了，也就是我们假设的 5%。我们再假设 CPU 的核数（也就是 n）无穷大，那加速比 S 的极限就是 20。也就是说，如果我们的串行率是 5%，那么我们无论采用什么技术，最高也就只能提高 20 倍的性能。

所以使用锁的时候一定要关注对性能的影响。 那怎么才能避免锁带来的性能问题呢？这个问题很复杂，Java SDK 并发包里之所以有那么多东西，有很大一部分原因就是要提升在某个特定领域的性能。

不过从方案层面，我们可以这样来解决这个问题。

第一，既然使用锁会带来性能问题，那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术，例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等；Java 并发包里面的原子类也是一种无锁的数据结构；Disruptor 则是一个无锁的内存队列，性能都非常好……

第二，减少锁持有的时间。互斥锁本质上是将并行的程序串行化，所以要增加并行度，一定要减少持有锁的时间。这个方案具体的实现技术也有很多，例如使用细粒度的锁，一个典型的例子就是 Java 并发包里的 ConcurrentHashMap，它使用了所谓分段锁的技术（这个技术后面我们会详细介绍）；还可以使用读写锁，也就是读是无锁的，只有写的时候才会互斥。

性能方面的度量指标有很多，我觉得有三个指标非常重要，就是：吞吐量、延迟和并发量。

吞吐量：指的是单位时间内能处理的请求数量。吞吐量越高，说明性能越好。

延迟：指的是从发出请求到收到响应的时间。延迟越小，说明性能越好。

并发量：指的是能同时处理的请求数量，一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标，一般都会是基于并发量来说的。例如并发量是 1000 的时候，延迟是 50 毫秒。

总结

并发编程是一个复杂的技术领域，微观上涉及到原子性问题、可见性问题和有序性问题，宏观则表现为安全性、活跃性以及性能问题。

我们在设计并发程序的时候，主要是从宏观出发，也就是要重点关注它的安全性、活跃性以及性能。安全性方面要注意数据竞争和竞态条件，活跃性方面需要注意死锁、活锁、饥饿等问题，性能方面我们虽然介绍了两个方案，但是遇到具体问题，你还是要具体分析，根据特定的场景选择合适的数据结构和算法。

要解决问题，首先要把问题分析清楚。同样，要写好并发程序，首先要了解并发程序相关的问题，经过这 7 章的内容，相信你一定对并发程序相关的问题有了深入的理解，同时对并发程序也一定心存敬畏，因为一不小心就出问题了。不过这恰恰也是一个很好的开始，因为你已经学会了分析并发问题，然后解决并发问题也就不远了。

课后思考

Java 语言提供的 Vector 是一个线程安全的容器，有同学写了下面的代码，你看看是否存在并发问题呢？

void addIfNotExist(Vector v,

    Object o){

  if(!v.contains(o)) {

    v.add(o);

  }

}

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

08 | 管程：并发编程的万能钥匙

并发编程这个技术领域已经发展了半个世纪了，相关的理论和技术纷繁复杂。那有没有一种核心技术可以很方便地解决我们的并发问题呢？这个问题如果让我选择，我一定会选择管程技术。Java 语言在 1.5 之前，提供的唯一的并发原语就是管程，而且 1.5 之后提供的 SDK 并发包，也是以管程技术为基础的。除此之外，C/C++、C# 等高级语言也都支持管程。

可以这么说，管程就是一把解决并发问题的万能钥匙。

什么是管程

不知道你是否曾思考过这个问题：为什么 Java 在 1.5 之前仅仅提供了 synchronized 关键字及 wait()、notify()、notifyAll() 这三个看似从天而降的方法？在刚接触 Java 的时候，我以为它会提供信号量这种编程原语，因为操作系统原理课程告诉我，用信号量能解决所有并发问题，结果我发现不是。后来我找到了原因：Java 采用的是管程技术，synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。而管程和信号量是等价的，所谓等价指的是用管程能够实现信号量，也能用信号量实现管程。但是管程更容易使用，所以 Java 选择了管程。

管程，对应的英文是 Monitor，很多 Java 领域的同学都喜欢将其翻译成“监视器”，这是直译。操作系统领域一般都翻译成“管程”，这个是意译，而我自己也更倾向于使用“管程”。

所谓管程，指的是管理共享变量以及对共享变量的操作过程，让他们支持并发。翻译为 Java 领域的语言，就是管理类的成员变量和成员方法，让这个类是线程安全的。那管程是怎么管的呢？

MESA 模型

在管程的发展史上，先后出现过三种不同的管程模型，分别是：Hasen 模型、Hoare 模型和 MESA 模型。其中，现在广泛应用的是 MESA 模型，并且 Java 管程的实现参考的也是 MESA 模型。所以今天我们重点介绍一下 MESA 模型。

在并发编程领域，有两大核心问题：一个是互斥，即同一时刻只允许一个线程访问共享资源；另一个是同步，即线程之间如何通信、协作。这两大问题，管程都是能够解决的。

我们先来看看管程是如何解决互斥问题的。

管程解决互斥问题的思路很简单，就是将共享变量及其对共享变量的操作统一封装起来。在下图中，管程 X 将共享变量 queue 这个队列和相关的操作入队 enq()、出队 deq() 都封装起来了；线程 A 和线程 B 如果想访问共享变量 queue，只能通过调用管程提供的 enq()、deq() 方法来实现；enq()、deq() 保证互斥性，只允许一个线程进入管程。不知你有没有发现，管程模型和面向对象高度契合的。估计这也是 Java 选择管程的原因吧。而我在前面章节介绍的互斥锁用法，其背后的模型其实就是它。

管程模型的代码化语义

那管程如何解决线程间的同步问题呢？

这个就比较复杂了，不过你可以借鉴一下我们曾经提到过的就医流程，它可以帮助你快速地理解这个问题。为进一步便于你理解，在下面，我展示了一幅 MESA 管程模型示意图，它详细描述了 MESA 模型的主要组成部分。

在管程模型里，共享变量和对共享变量的操作是被封装起来的，图中最外层的框就代表封装的意思。框的上面只有一个入口，并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时，只允许一个线程进入，其他线程则在入口等待队列中等待。这个过程类似就医流程的分诊，只允许一个患者就诊，其他患者都在门口等待。

管程里还引入了条件变量的概念，而且每个条件变量都对应有一个等待队列，如下图，条件变量 A 和条件变量 B 分别都有自己的等待队列。

MESA 管程模型

那条件变量和等待队列的作用是什么呢？其实就是解决线程同步问题。你也可以结合上面提到的入队出队例子加深一下理解。

假设有个线程 T1 执行出队操作，不过需要注意的是执行出队操作，有个前提条件，就是队列不能是空的，而队列不空这个前提条件就是管程里的条件变量。 如果线程 T1 进入管程后恰好发现队列是空的，那怎么办呢？等待啊，去哪里等呢？就去条件变量对应的等待队列里面等。此时线程 T1 就去“队列不空”这个条件变量的等待队列中等待。这个过程类似于大夫发现你要去验个血，于是给你开了个验血的单子，你呢就去验血的队伍里排队。线程 T1 进入条件变量的等待队列后，是允许其他线程进入管程的。这和你去验血的时候，医生可以给其他患者诊治，道理都是一样的。

再假设之后另外一个线程 T2 执行入队操作，入队操作执行成功之后，“队列不空”这个条件对于线程 T1 来说已经满足了，此时线程 T2 要通知 T1，告诉它需要的条件已经满足了。当线程 T1 得到通知后，会从等待队列里面出来，但是出来之后不是马上执行，而是重新进入到入口等待队列里面。这个过程类似你验血完，回来找大夫，需要重新分诊。

条件变量及其等待队列我们讲清楚了，下面再说说 wait()、notify()、notifyAll() 这三个操作。前面提到线程 T1 发现“队列不空”这个条件不满足，需要进到对应的等待队列里等待。这个过程就是通过调用 wait() 来实现的。如果我们用对象 A 代表“队列不空”这个条件，那么线程 T1 需要调用 A.wait()。同理当“队列不空”这个条件满足时，线程 T2 需要调用 A.notify() 来通知 A 等待队列中的一个线程，此时这个队列里面只有线程 T1。至于 notifyAll() 这个方法，它可以通知等待队列中的所有线程。

这里我还是来一段代码再次说明一下吧。下面的代码实现的是一个阻塞队列，阻塞队列有两个操作分别是入队和出队，这两个方法都是先获取互斥锁，类比管程模型中的入口。

对于入队操作，如果队列已满，就需要等待直到队列不满，所以这里用了notFull.await();。

对于出队操作，如果队列为空，就需要等待直到队列不空，所以就用了notEmpty.await();。

如果入队成功，那么队列就不空了，就需要通知条件变量：队列不空notEmpty对应的等待队列。

如果出队成功，那就队列就不满了，就需要通知条件变量：队列不满notFull对应的等待队列。

public class BlockedQueue<T>{

  final Lock lock =

    new ReentrantLock();

  // 条件变量：队列不满

  final Condition notFull =

    lock.newCondition();

  // 条件变量：队列不空

  final Condition notEmpty =

    lock.newCondition();

  // 入队

  void enq(T x) {

    lock.lock();

    try {

      while (队列已满){

        // 等待队列不满

        notFull.await();

      }

      // 省略入队操作...

      // 入队后, 通知可出队

      notEmpty.signal();

    }finally {

      lock.unlock();

    }

  }

  // 出队

  void deq(){

    lock.lock();

    try {

      while (队列已空){

        // 等待队列不空

        notEmpty.await();

      }

      // 省略出队操作...

      // 出队后，通知可入队

      notFull.signal();

    }finally {

      lock.unlock();

    }

  }

}

在这段示例代码中，我们用了 Java 并发包里面的 Lock 和 Condition，如果你看着吃力，也没关系，后面我们还会详细介绍，这个例子只是先让你明白条件变量及其等待队列是怎么回事。需要注意的是：await() 和前面我们提到的 wait() 语义是一样的；signal() 和前面我们提到的 notify() 语义是一样的。

wait() 的正确姿势

但是有一点，需要再次提醒，对于 MESA 管程来说，有一个编程范式，就是需要在一个 while 循环里面调用 wait()。这个是 MESA 管程特有的。

while(条件不满足) {

  wait();

}

Hasen 模型、Hoare 模型和 MESA 模型的一个核心区别就是当条件满足后，如何通知相关线程。管程要求同一时刻只允许一个线程执行，那当线程 T2 的操作使线程 T1 等待的条件满足时，T1 和 T2 究竟谁可以执行呢？

Hasen 模型里面，要求 notify() 放在代码的最后，这样 T2 通知完 T1 后，T2 就结束了，然后 T1 再执行，这样就能保证同一时刻只有一个线程执行。

Hoare 模型里面，T2 通知完 T1 后，T2 阻塞，T1 马上执行；等 T1 执行完，再唤醒 T2，也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型，T2 多了一次阻塞唤醒操作。

MESA 管程里面，T2 通知完 T1 后，T2 还是会接着执行，T1 并不立即执行，仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后，T2 也没有多余的阻塞唤醒操作。但是也有个副作用，就是当 T1 再次执行的时候，可能曾经满足的条件，现在已经不满足了，所以需要以循环方式检验条件变量。

notify() 何时可以使用

还有一个需要注意的地方，就是 notify() 和 notifyAll() 的使用，前面章节，我曾经介绍过，除非经过深思熟虑，否则尽量使用 notifyAll()。那什么时候可以使用 notify() 呢？需要满足以下三个条件：

所有等待线程拥有相同的等待条件；

所有等待线程被唤醒后，执行相同的操作；

只需要唤醒一个线程。

比如上面阻塞队列的例子中，对于“队列不满”这个条件变量，其阻塞队列里的线程都是在等待“队列不满”这个条件，反映在代码里就是下面这 3 行代码。对所有等待线程来说，都是执行这 3 行代码，重点是 while 里面的等待条件是完全相同的。

while (队列已满){

  // 等待队列不满

  notFull.await();

}

所有等待线程被唤醒后执行的操作也是相同的，都是下面这几行：

// 省略入队操作...

// 入队后, 通知可出队

notEmpty.signal();

同时也满足第 3 条，只需要唤醒一个线程。所以上面阻塞队列的代码，使用 signal() 是可以的。

总结

Java 参考了 MESA 模型，语言内置的管程（synchronized）对 MESA 模型进行了精简。MESA 模型中，条件变量可以有多个，Java 语言内置的管程里只有一个条件变量。具体如下图所示。

Java 中的管程示意图

Java 内置的管程方案（synchronized）使用简单，synchronized 关键字修饰的代码块，在编译期会自动生成相关加锁和解锁的代码，但是仅支持一个条件变量；而 Java SDK 并发包实现的管程支持多个条件变量，不过并发包里的锁，需要开发人员自己进行加锁和解锁操作。

并发编程里两大核心问题——互斥和同步，都可以由管程来帮你解决。学好管程，理论上所有的并发问题你都可以解决，并且很多并发工具类底层都是管程实现的，所以学好管程，就是相当于掌握了一把并发编程的万能钥匙。

课后思考

wait() 方法，在 Hasen 模型和 Hoare 模型里面，都是没有参数的，而在 MESA 模型里面，增加了超时参数，你觉得这个参数有必要吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

09 | Java线程（上）：Java线程的生命周期

在 Java 领域，实现并发程序的主要手段就是多线程。线程是操作系统里的一个概念，虽然各种不同的开发语言如 Java、C# 等都对其进行了封装，但是万变不离操作系统。Java 语言里的线程本质上就是操作系统的线程，它们是一一对应的。

在操作系统层面，线程也有“生老病死”，专业的说法叫有生命周期。对于有生命周期的事物，要学好它，思路非常简单，只要能搞懂生命周期中各个节点的状态转换机制就可以了。

虽然不同的开发语言对于操作系统线程进行了不同的封装，但是对于线程的生命周期这部分，基本上是雷同的。所以，我们可以先来了解一下通用的线程生命周期模型，这部分内容也适用于很多其他编程语言；然后再详细有针对性地学习一下 Java 中线程的生命周期。

通用的线程生命周期

通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是：初始状态、可运行状态、运行状态、休眠状态和终止状态。

通用线程状态转换图——五态模型

这“五态模型”的详细情况如下所示。

初始状态，指的是线程已经被创建，但是还不允许分配 CPU 执行。这个状态属于编程语言特有的，不过这里所谓的被创建，仅仅是在编程语言层面被创建，而在操作系统层面，真正的线程还没有创建。

可运行状态，指的是线程可以分配 CPU 执行。在这种状态下，真正的操作系统线程已经被成功创建了，所以可以分配 CPU 执行。

当有空闲的 CPU 时，操作系统会将其分配给一个处于可运行状态的线程，被分配到 CPU 的线程的状态就转换成了运行状态。

运行状态的线程如果调用一个阻塞的 API（例如以阻塞方式读文件）或者等待某个事件（例如条件变量），那么线程的状态就会转换到休眠状态，同时释放 CPU 使用权，休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了，线程就会从休眠状态转换到可运行状态。

线程执行完或者出现异常就会进入终止状态，终止状态的线程不会切换到其他任何状态，进入终止状态也就意味着线程的生命周期结束了。

这五种状态在不同编程语言里会有简化合并。例如，C 语言的 POSIX Threads 规范，就把初始状态和可运行状态合并了；Java 语言里则把可运行状态和运行状态合并了，这两个状态在操作系统调度层面有用，而 JVM 层面不关心这两个状态，因为 JVM 把线程调度交给操作系统处理了。

除了简化合并，这五种状态也有可能被细化，比如，Java 语言里就细化了休眠状态（这个下面我们会详细讲解）。

Java 中线程的生命周期

介绍完通用的线程生命周期模型，想必你已经对线程的“生老病死”有了一个大致的了解。那接下来我们就来详细看看 Java 语言里的线程生命周期是什么样的。

Java 语言中线程共有六种状态，分别是：

NEW（初始化状态）

RUNNABLE（可运行 / 运行状态）

BLOCKED（阻塞状态）

WAITING（无时限等待）

TIMED_WAITING（有时限等待）

TERMINATED（终止状态）

这看上去挺复杂的，状态类型也比较多。但其实在操作系统层面，Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态，即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一，那么这个线程就永远没有 CPU 的使用权。

所以 Java 线程的生命周期可以简化为下图：

Java 中的线程状态转换图

其中，BLOCKED、WAITING、TIMED_WAITING 可以理解为线程导致休眠状态的三种原因。那具体是哪些情形会导致线程从 RUNNABLE 状态转换到这三种状态呢？而这三种状态又是何时转换回 RUNNABLE 的呢？以及 NEW、TERMINATED 和 RUNNABLE 状态是如何转换的？

1. RUNNABLE 与 BLOCKED 的状态转换

只有一种场景会触发这种转换，就是线程等待 synchronized 的隐式锁。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行，其他线程只能等待，这种情况下，等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时，就又会从 BLOCKED 转换到 RUNNABLE 状态。

如果你熟悉操作系统线程的生命周期的话，可能会有个疑问：线程调用阻塞式 API 时，是否会转换到 BLOCKED 状态呢？在操作系统层面，线程是会转换到休眠状态的，但是在 JVM 层面，Java 线程的状态不会发生变化，也就是说 Java 线程的状态会依然保持 RUNNABLE 状态。JVM 层面并不关心操作系统调度相关的状态，因为在 JVM 看来，等待 CPU 使用权（操作系统层面此时处于可执行状态）与等待 I/O（操作系统层面此时处于休眠状态）没有区别，都是在等待某个资源，所以都归入了 RUNNABLE 状态。

而我们平时所谓的 Java 在调用阻塞式 API 时，线程会阻塞，指的是操作系统线程的状态，并不是 Java 线程的状态。

2. RUNNABLE 与 WAITING 的状态转换

总体来说，有三种场景会触发这种转换。

第一种场景，获得 synchronized 隐式锁的线程，调用无参数的 Object.wait() 方法。其中，wait() 方法我们在上一篇讲解管程的时候已经深入介绍过了，这里就不再赘述。

第二种场景，调用无参数的 Thread.join() 方法。其中的 join() 是一种线程同步方法，例如有一个线程对象 thread A，当调用 A.join() 的时候，执行这条语句的线程会等待 thread A 执行完，而等待中的这个线程，其状态会从 RUNNABLE 转换到 WAITING。当线程 thread A 执行完，原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。

第三种场景，调用 LockSupport.park() 方法。其中的 LockSupport 对象，也许你有点陌生，其实 Java 并发包中的锁，都是基于它实现的。调用 LockSupport.park() 方法，当前线程会阻塞，线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程，目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。

3. RUNNABLE 与 TIMED_WAITING 的状态转换

有五种场景会触发这种转换：

调用带超时参数的 Thread.sleep(long millis) 方法；

获得 synchronized 隐式锁的线程，调用带超时参数的 Object.wait(long timeout) 方法；

调用带超时参数的 Thread.join(long millis) 方法；

调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法；

调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。

这里你会发现 TIMED_WAITING 和 WAITING 状态的区别，仅仅是触发条件多了超时参数。

4. 从 NEW 到 RUNNABLE 状态

Java 刚创建出来的 Thread 对象就是 NEW 状态，而创建 Thread 对象主要有两种方法。一种是继承 Thread 对象，重写 run() 方法。示例代码如下：

// 自定义线程对象

class MyThread extends Thread {

  public void run() {

    // 线程需要执行的代码

    ......

  }

}

// 创建线程对象

MyThread myThread = new MyThread();

另一种是实现 Runnable 接口，重写 run() 方法，并将该实现类作为创建 Thread 对象的参数。示例代码如下：

// 实现 Runnable 接口

class Runner implements Runnable {

  @Override

  public void run() {

    // 线程需要执行的代码

    ......

  }

}

// 创建线程对象

Thread thread = new Thread(new Runner());

NEW 状态的线程，不会被操作系统调度，因此不会执行。Java 线程要执行，就必须转换到 RUNNABLE 状态。从 NEW 状态转换到 RUNNABLE 状态很简单，只要调用线程对象的 start() 方法就可以了，示例代码如下：

MyThread myThread = new MyThread();

// 从 NEW 状态转换到 RUNNABLE 状态

myThread.start()；

5. 从 RUNNABLE 到 TERMINATED 状态

线程执行完 run() 方法后，会自动转换到 TERMINATED 状态，当然如果执行 run() 方法的时候异常抛出，也会导致线程终止。有时候我们需要强制中断 run() 方法的执行，例如 run() 方法访问一个很慢的网络，我们等不下去了，想终止怎么办呢？Java 的 Thread 类里面倒是有个 stop() 方法，不过已经标记为 @Deprecated，所以不建议使用了。正确的姿势其实是调用 interrupt() 方法。

那 stop() 和 interrupt() 方法的主要区别是什么呢？

stop() 方法会真的杀死线程，不给线程喘息的机会，如果线程持有 ReentrantLock 锁，被 stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁，那其他线程就再也没机会获得 ReentrantLock 锁，这实在是太危险了。所以该方法就不建议使用了，类似的方法还有 suspend() 和 resume() 方法，这两个方法同样也都不建议使用了，所以这里也就不多介绍了。

而 interrupt() 方法就温柔多了，interrupt() 方法仅仅是通知线程，线程有机会执行一些后续操作，同时也可以无视这个通知。被 interrupt 的线程，是怎么收到通知的呢？一种是异常，另一种是主动检测。

当线程 A 处于 WAITING、TIMED_WAITING 状态时，如果其他线程调用线程 A 的 interrupt() 方法，会使线程 A 返回到 RUNNABLE 状态，同时线程 A 的代码会触发 InterruptedException 异常。上面我们提到转换到 WAITING、TIMED_WAITING 状态的触发条件，都是调用了类似 wait()、join()、sleep() 这样的方法，我们看这些方法的签名，发现都会 throws InterruptedException 这个异常。这个异常的触发条件就是：其他线程调用了该线程的 interrupt() 方法。

当线程 A 处于 RUNNABLE 状态时，并且阻塞在 java.nio.channels.InterruptibleChannel 上时，如果其他线程调用线程 A 的 interrupt() 方法，线程 A 会触发 java.nio.channels.ClosedByInterruptException 这个异常；而阻塞在 java.nio.channels.Selector 上时，如果其他线程调用线程 A 的 interrupt() 方法，线程 A 的 java.nio.channels.Selector 会立即返回。

上面这两种情况属于被中断的线程通过异常的方式获得了通知。还有一种是主动检测，如果线程处于 RUNNABLE 状态，并且没有阻塞在某个 I/O 操作上，例如中断计算圆周率的线程 A，这时就得依赖线程 A 主动检测中断状态了。如果其他线程调用线程 A 的 interrupt() 方法，那么线程 A 可以通过 isInterrupted() 方法，检测是不是自己被中断了。

总结

理解 Java 线程的各种状态以及生命周期对于诊断多线程 Bug 非常有帮助，多线程程序很难调试，出了 Bug 基本上都是靠日志，靠线程 dump 来跟踪问题，分析线程 dump 的一个基本功就是分析线程状态，大部分的死锁、饥饿、活锁问题都需要跟踪分析线程的状态。同时，本文介绍的线程生命周期具备很强的通用性，对于学习其他语言的多线程编程也有很大的帮助。

你可以通过 jstack 命令或者Java VisualVM这个可视化工具将 JVM 所有的线程栈信息导出来，完整的线程栈信息不仅包括线程的当前状态、调用栈，还包括了锁的信息。例如，我曾经写过一个死锁的程序，导出的线程栈明确告诉我发生了死锁，并且将死锁线程的调用栈信息清晰地显示出来了（如下图）。导出线程栈，分析线程状态是诊断并发问题的一个重要工具。

发生死锁的线程栈

课后思考

下面代码的本意是当前线程被中断之后，退出while(true)，你觉得这段代码是否正确呢？

Thread th = Thread.currentThread();

while(true) {

  if(th.isInterrupted()) {

    break;

  }

  // 省略业务代码无数

  try {

    Thread.sleep(100);

  }catch (InterruptedException e){

    e.printStackTrace();

  }

}

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

10 | Java线程（中）：创建多少线程才是合适的？

在 Java 领域，实现并发程序的主要手段就是多线程，使用多线程还是比较简单的，但是使用多少个线程却是个困难的问题。工作中，经常有人问，“各种线程池的线程数量调整成多少是合适的？”或者“Tomcat 的线程数、Jdbc 连接池的连接数是多少？”等等。那我们应该如何设置合适的线程数呢？

要解决这个问题，首先要分析以下两个问题：

为什么要使用多线程？

多线程的应用场景有哪些？

为什么要使用多线程？

使用多线程，本质上就是提升程序性能。不过此刻谈到的性能，可能在你脑海里还是比较笼统的，基本上就是快、快、快，这种无法度量的感性认识很不科学，所以在提升性能之前，首要问题是：如何度量性能。

度量性能的指标有很多，但是有两个指标是最核心的，它们就是延迟和吞吐量。延迟指的是发出请求到收到响应这个过程的时间；延迟越短，意味着程序执行得越快，性能也就越好。 吞吐量指的是在单位时间内能处理请求的数量；吞吐量越大，意味着程序能处理的请求越多，性能也就越好。这两个指标内部有一定的联系（同等条件下，延迟越短，吞吐量越大），但是由于它们隶属不同的维度（一个是时间维度，一个是空间维度），并不能互相转换。

我们所谓提升性能，从度量的角度，主要是降低延迟，提高吞吐量。这也是我们使用多线程的主要目的。那我们该怎么降低延迟，提高吞吐量呢？这个就要从多线程的应用场景说起了。

多线程的应用场景

要想“降低延迟，提高吞吐量”，对应的方法呢，基本上有两个方向，一个方向是优化算法，另一个方向是将硬件的性能发挥到极致。前者属于算法范畴，后者则是和并发编程息息相关了。那计算机主要有哪些硬件呢？主要是两类：一个是 I/O，一个是 CPU。简言之，在并发编程领域，提升性能本质上就是提升硬件的利用率，再具体点来说，就是提升 I/O 的利用率和 CPU 的利用率。

估计这个时候你会有个疑问，操作系统不是已经解决了硬件的利用率问题了吗？的确是这样，例如操作系统已经解决了磁盘和网卡的利用率问题，利用中断机制还能避免 CPU 轮询 I/O 状态，也提升了 CPU 的利用率。但是操作系统解决硬件利用率问题的对象往往是单一的硬件设备，而我们的并发程序，往往需要 CPU 和 I/O 设备相互配合工作，也就是说，我们需要解决 CPU 和 I/O 设备综合利用率的问题。关于这个综合利用率的问题，操作系统虽然没有办法完美解决，但是却给我们提供了方案，那就是：多线程。

下面我们用一个简单的示例来说明：如何利用多线程来提升 CPU 和 I/O 设备的利用率？假设程序按照 CPU 计算和 I/O 操作交叉执行的方式运行，而且 CPU 计算和 I/O 操作的耗时是 1:1。

如下图所示，如果只有一个线程，执行 CPU 计算的时候，I/O 设备空闲；执行 I/O 操作的时候，CPU 空闲，所以 CPU 的利用率和 I/O 设备的利用率都是 50%。

单线程执行示意图

如果有两个线程，如下图所示，当线程 A 执行 CPU 计算的时候，线程 B 执行 I/O 操作；当线程 A 执行 I/O 操作的时候，线程 B 执行 CPU 计算，这样 CPU 的利用率和 I/O 设备的利用率就都达到了 100%。

二线程执行示意图

我们将 CPU 的利用率和 I/O 设备的利用率都提升到了 100%，会对性能产生了哪些影响呢？通过上面的图示，很容易看出：单位时间处理的请求数量翻了一番，也就是说吞吐量提高了 1 倍。此时可以逆向思维一下，如果 CPU 和 I/O 设备的利用率都很低，那么可以尝试通过增加线程来提高吞吐量。

在单核时代，多线程主要就是用来平衡 CPU 和 I/O 设备的。如果程序只有 CPU 计算，而没有 I/O 操作的话，多线程不但不会提升性能，还会使性能变得更差，原因是增加了线程切换的成本。但是在多核时代，这种纯计算型的程序也可以利用多线程来提升性能。为什么呢？因为利用多核可以降低响应时间。

为便于你理解，这里我举个简单的例子说明一下：计算 1+2+… … +100 亿的值，如果在 4 核的 CPU 上利用 4 个线程执行，线程 A 计算 [1，25 亿)，线程 B 计算 [25 亿，50 亿)，线程 C 计算 [50，75 亿)，线程 D 计算 [75 亿，100 亿]，之后汇总，那么理论上应该比一个线程计算 [1，100 亿] 快将近 4 倍，响应时间能够降到 25%。一个线程，对于 4 核的 CPU，CPU 的利用率只有 25%，而 4 个线程，则能够将 CPU 的利用率提高到 100%。

多核执行多线程示意图

创建多少线程合适？

创建多少线程合适，要看多线程具体的应用场景。我们的程序一般都是 CPU 计算和 I/O 操作交叉执行的，由于 I/O 设备的速度相对于 CPU 来说都很慢，所以大部分情况下，I/O 操作执行的时间相对于 CPU 计算来说都非常长，这种场景我们一般都称为 I/O 密集型计算；和 I/O 密集型计算相对的就是 CPU 密集型计算了，CPU 密集型计算大部分场景下都是纯 CPU 计算。I/O 密集型程序和 CPU 密集型程序，计算最佳线程数的方法是不同的。

下面我们对这两个场景分别说明。

对于 CPU 密集型计算，多线程本质上是提升多核 CPU 的利用率，所以对于一个 4 核的 CPU，每个核一个线程，理论上创建 4 个线程就可以了，再多创建线程也只是增加线程切换的成本。所以，对于 CPU 密集型的计算场景，理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上，线程的数量一般会设置为“CPU 核数 +1”，这样的话，当线程因为偶尔的内存页失效或其他原因导致阻塞时，这个额外的线程可以顶上，从而保证 CPU 的利用率。

对于 I/O 密集型的计算场景，比如前面我们的例子中，如果 CPU 计算和 I/O 操作的耗时是 1:1，那么 2 个线程是最合适的。如果 CPU 计算和 I/O 操作的耗时是 1:2，那多少个线程合适呢？是 3 个线程，如下图所示：CPU 在 A、B、C 三个线程之间切换，对于线程 A，当 CPU 从 B、C 切换回来时，线程 A 正好执行完 I/O 操作。这样 CPU 和 I/O 设备的利用率都达到了 100%。

三线程执行示意图

通过上面这个例子，我们会发现，对于 I/O 密集型计算场景，最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的，我们可以总结出这样一个公式：

最佳线程数 =1 +（I/O 耗时 / CPU 耗时）

我们令 R=I/O 耗时 / CPU 耗时，综合上图，可以这样理解：当线程 A 执行 IO 操作时，另外 R 个线程正好执行完各自的 CPU 计算。这样 CPU 的利用率就达到了 100%。

不过上面这个公式是针对单核 CPU 的，至于多核 CPU，也很简单，只需要等比扩大就可以了，计算公式如下：

最佳线程数 =CPU 核数 * [ 1 +（I/O 耗时 / CPU 耗时）]

总结

很多人都知道线程数不是越多越好，但是设置多少是合适的，却又拿不定主意。其实只要把握住一条原则就可以了，这条原则就是将硬件的性能发挥到极致。上面我们针对 CPU 密集型和 I/O 密集型计算场景都给出了理论上的最佳公式，这些公式背后的目标其实就是将硬件的性能发挥到极致。

对于 I/O 密集型计算场景，I/O 耗时和 CPU 耗时的比值是一个关键参数，不幸的是这个参数是未知的，而且是动态变化的，所以工程上，我们要估算这个参数，然后做各种不同场景下的压测来验证我们的估计。不过工程上，原则还是将硬件的性能发挥到极致，所以压测时，我们需要重点关注 CPU、I/O 设备的利用率和性能指标（响应时间、吞吐量）之间的关系。

课后思考

有些同学对于最佳线程数的设置积累了一些经验值，认为对于 I/O 密集型应用，最佳线程数应该为：2 * CPU 的核数 + 1，你觉得这个经验值合理吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

11 | Java线程（下）：为什么局部变量是线程安全的？

我们一遍一遍重复再重复地讲到，多个线程同时访问共享变量的时候，会导致并发问题。那在 Java 语言里，是不是所有变量都是共享变量呢？工作中我发现不少同学会给方法里面的局部变量设置同步，显然这些同学并没有把共享变量搞清楚。那 Java 方法里面的局部变量是否存在并发问题呢？下面我们就先结合一个例子剖析下这个问题。

比如，下面代码里的 fibonacci() 这个方法，会根据传入的参数 n ，返回 1 到 n 的斐波那契数列，斐波那契数列类似这样： 1、1、2、3、5、8、13、21、34……第 1 项和第 2 项是 1，从第 3 项开始，每一项都等于前两项之和。在这个方法里面，有个局部变量：数组 r 用来保存数列的结果，每次计算完一项，都会更新数组 r 对应位置中的值。你可以思考这样一个问题，当多个线程调用 fibonacci() 这个方法的时候，数组 r 是否存在数据竞争（Data Race）呢？

// 返回斐波那契数列

int[] fibonacci(int n) {

  // 创建结果数组

  int[] r = new int[n];

  // 初始化第一、第二个数

  r[0] = r[1] = 1;  // ①

  // 计算 2..n

  for(int i = 2; i < n; i++) {

      r[i] = r[i-2] + r[i-1];

  }

  return r;

}

你自己可以在大脑里模拟一下多个线程调用 fibonacci() 方法的情景，假设多个线程执行到 ① 处，多个线程都要对数组 r 的第 1 项和第 2 项赋值，这里看上去感觉是存在数据竞争的，不过感觉再次欺骗了你。

其实很多人也是知道局部变量不存在数据竞争的，但是至于原因嘛，就说不清楚了。

那它背后的原因到底是怎样的呢？要弄清楚这个，你需要一点编译原理的知识。你知道在 CPU 层面，是没有方法概念的，CPU 的眼里，只有一条条的指令。编译程序，负责把高级语言里的方法转换成一条条的指令。所以你可以站在编译器实现者的角度来思考“怎么完成方法到指令的转换”。

方法是如何被执行的

高级语言里的普通语句，例如上面的r[i] = r[i-2] + r[i-1];翻译成 CPU 的指令相对简单，可方法的调用就比较复杂了。例如下面这三行代码：第 1 行，声明一个 int 变量 a；第 2 行，调用方法 fibonacci(a)；第 3 行，将 b 赋值给 c。

int a = 7；

int[] b = fibonacci(a);

int[] c = b;

当你调用 fibonacci(a) 的时候，CPU 要先找到方法 fibonacci() 的地址，然后跳转到这个地址去执行代码，最后 CPU 执行完方法 fibonacci() 之后，要能够返回。首先找到调用方法的下一条语句的地址：也就是int[] c=b;的地址，再跳转到这个地址去执行。 你可以参考下面这个图再加深一下理解。

方法的调用过程

到这里，方法调用的过程想必你已经清楚了，但是还有一个很重要的问题，“CPU 去哪里找到调用方法的参数和返回地址？”如果你熟悉 CPU 的工作原理，你应该会立刻想到：通过 CPU 的堆栈寄存器。CPU 支持一种栈结构，栈你一定很熟悉了，就像手枪的弹夹，先入后出。因为这个栈是和方法调用相关的，因此经常被称为调用栈。

例如，有三个方法 A、B、C，他们的调用关系是 A->B->C（A 调用 B，B 调用 C），在运行时，会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间，称为栈帧，每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时，会创建新的栈帧，并压入调用栈；当方法返回时，对应的栈帧就会被自动弹出。也就是说，栈帧和方法是同生共死的。

调用栈结构

利用栈结构来支持方法调用这个方案非常普遍，以至于 CPU 里内置了栈寄存器。虽然各家编程语言定义的方法千奇百怪，但是方法的内部执行原理却是出奇的一致：都是靠栈结构解决的。Java 语言虽然是靠虚拟机解释执行的，但是方法的调用也是利用栈结构解决的。

局部变量存哪里？

我们已经知道了方法间的调用在 CPU 眼里是怎么执行的，但还有一个关键问题：方法内的局部变量存哪里？

局部变量的作用域是方法内部，也就是说当方法执行完，局部变量就没用了，局部变量应该和方法同生共死。此时你应该会想到调用栈的栈帧，调用栈的栈帧就是和方法同生共死的，所以局部变量放到调用栈里那儿是相当的合理。事实上，的确是这样的，局部变量就是放到了调用栈里。于是调用栈的结构就变成了下图这样。

保护局部变量的调用栈结构

这个结论相信很多人都知道，因为学 Java 语言的时候，基本所有的教材都会告诉你 new 出来的对象是在堆里，局部变量是在栈里，只不过很多人并不清楚堆和栈的区别，以及为什么要区分堆和栈。现在你应该很清楚了，局部变量是和方法同生共死的，一个变量如果想跨越方法的边界，就必须创建在堆里。

调用栈与线程

两个线程可以同时用不同的参数调用相同的方法，那调用栈和线程之间是什么关系呢？答案是：每个线程都有自己独立的调用栈。因为如果不是这样，那两个线程就互相干扰了。如下面这幅图所示，线程 A、B、C 每个线程都有自己独立的调用栈。

线程与调用栈的关系图

现在，让我们回过头来再看篇首的问题：Java 方法里面的局部变量是否存在并发问题？现在你应该很清楚了，一点问题都没有。因为每个线程都有自己的调用栈，局部变量保存在线程各自的调用栈里面，不会共享，所以自然也就没有并发问题。再次重申一遍：没有共享，就没有伤害。

线程封闭

方法里的局部变量，因为不会和其他线程共享，所以没有并发问题，这个思路很好，已经成为解决并发问题的一个重要技术，同时还有个响当当的名字叫做线程封闭，比较官方的解释是：仅在单线程内访问数据。由于不存在共享，所以即便不同步也不会有并发问题，性能杠杠的。

采用线程封闭技术的案例非常多，例如从数据库连接池里获取的连接 Connection，在 JDBC 规范里并没有要求这个 Connection 必须是线程安全的。数据库连接池通过线程封闭技术，保证一个 Connection 一旦被一个线程获取之后，在这个线程关闭 Connection 之前的这段时间里，不会再分配给其他线程，从而保证了 Connection 不会有并发问题。

总结

调用栈是一个通用的计算机概念，所有的编程语言都会涉及到，Java 调用栈相关的知识，我并没有花费很大的力气去深究，但是靠着那点 C 语言的知识，稍微思考一下，基本上也就推断出来了。工作了十几年，我发现最近几年和前些年最大的区别是：很多技术的实现原理我都是靠推断，然后看源码验证，而不是像以前一样纯粹靠看源码来总结了。

建议你也多研究原理性的东西、通用的东西，有这些东西之后再学具体的技术就快多了。

课后思考

常听人说，递归调用太深，可能导致栈溢出。你思考一下原因是什么？有哪些解决方案呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

12 | 如何用面向对象思想写好并发程序？

在工作中，我发现很多同学在设计之初都是直接按照单线程的思路来写程序的，而忽略了本应该重视的并发问题；等上线后的某天，突然发现诡异的 Bug，再历经千辛万苦终于定位到问题所在，却发现对于如何解决已经没有了思路。

关于这个问题，我觉得咱们今天很有必要好好聊聊“如何用面向对象思想写好并发程序”这个话题。

面向对象思想与并发编程有关系吗？本来是没关系的，它们分属两个不同的领域，但是在 Java 语言里，这两个领域被无情地融合在一起了，好在融合的效果还是不错的：在 Java 语言里，面向对象思想能够让并发编程变得更简单。

那如何才能用面向对象思想写好并发程序呢？结合我自己的工作经验来看，我觉得你可以从封装共享变量、识别共享变量间的约束条件和制定并发访问策略这三个方面下手。

一、封装共享变量

并发程序，我们关注的一个核心问题，不过是解决多线程同时访问共享变量的问题。在《03 | 互斥锁（上）：解决原子性问题》中，我们类比过球场门票的管理，现实世界里门票管理的一个核心问题是：所有观众只能通过规定的入口进入，否则检票就形同虚设。在编程世界这个问题也很重要，编程领域里面对于共享变量的访问路径就类似于球场的入口，必须严格控制。好在有了面向对象思想，对共享变量的访问路径可以轻松把控。

面向对象思想里面有一个很重要的特性是封装，封装的通俗解释就是将属性和实现细节封装在对象内部，外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性，这和门票管理模型匹配度相当的高，球场里的座位就是对象属性，球场入口就是对象的公共方法。我们把共享变量作为对象的属性，那对于共享变量的访问路径就是对象的公共方法，所有入口都要安排检票程序就相当于我们前面提到的并发访问策略。

利用面向对象思想写并发程序的思路，其实就这么简单：将共享变量作为对象属性封装在内部，对所有公共方法制定并发访问策略。就拿很多统计程序都要用到计数器来说，下面的计数器程序共享变量只有一个，就是 value，我们把它作为 Counter 类的属性，并且将两个公共方法 get() 和 addOne() 声明为同步方法，这样 Counter 类就成为一个线程安全的类了。

public class Counter {

  private long value;

  synchronized long get(){

    return value;

  }

  synchronized long addOne(){

    return ++value;

  }

}

当然，实际工作中，很多的场景都不会像计数器这么简单，经常要面临的情况往往是有很多的共享变量，例如，信用卡账户有卡号、姓名、身份证、信用额度、已出账单、未出账单等很多共享变量。这么多的共享变量，如果每一个都考虑它的并发安全问题，那我们就累死了。但其实仔细观察，你会发现，很多共享变量的值是不会变的，例如信用卡账户的卡号、姓名、身份证。对于这些不会发生变化的共享变量，建议你用 final 关键字来修饰。这样既能避免并发问题，也能很明了地表明你的设计意图，让后面接手你程序的兄弟知道，你已经考虑过这些共享变量的并发安全问题了。

二、识别共享变量间的约束条件

识别共享变量间的约束条件非常重要。因为这些约束条件，决定了并发访问策略。例如，库存管理里面有个合理库存的概念，库存量不能太高，也不能太低，它有一个上限和一个下限。关于这些约束条件，我们可以用下面的程序来模拟一下。在类 SafeWM 中，声明了两个成员变量 upper 和 lower，分别代表库存上限和库存下限，这两个变量用了 AtomicLong 这个原子类，原子类是线程安全的，所以这两个成员变量的 set 方法就不需要同步了。

public class SafeWM {

  // 库存上限

  private final AtomicLong upper =

        new AtomicLong(0);

  // 库存下限

  private final AtomicLong lower =

        new AtomicLong(0);

  // 设置库存上限

  void setUpper(long v){

    upper.set(v);

  }

  // 设置库存下限

  void setLower(long v){

    lower.set(v);

  }

  // 省略其他业务代码

}

虽说上面的代码是没有问题的，但是忽视了一个约束条件，就是库存下限要小于库存上限，这个约束条件能够直接加到上面的 set 方法上吗？我们先直接加一下看看效果（如下面代码所示）。我们在 setUpper() 和 setLower() 中增加了参数校验，这乍看上去好像是对的，但其实存在并发问题，问题在于存在竞态条件。这里我顺便插一句，其实当你看到代码里出现 if 语句的时候，就应该立刻意识到可能存在竞态条件。

我们假设库存的下限和上限分别是 (2,10)，线程 A 调用 setUpper(5) 将上限设置为 5，线程 B 调用 setLower(7) 将下限设置为 7，如果线程 A 和线程 B 完全同时执行，你会发现线程 A 能够通过参数校验，因为这个时候，下限还没有被线程 B 设置，还是 2，而 5>2；线程 B 也能够通过参数校验，因为这个时候，上限还没有被线程 A 设置，还是 10，而 7<10。当线程 A 和线程 B 都通过参数校验后，就把库存的下限和上限设置成 (7, 5) 了，显然此时的结果是不符合库存下限要小于库存上限这个约束条件的。

public class SafeWM {

  // 库存上限

  private final AtomicLong upper =

        new AtomicLong(0);

  // 库存下限

  private final AtomicLong lower =

        new AtomicLong(0);

  // 设置库存上限

  void setUpper(long v){

    // 检查参数合法性

    if (v < lower.get()) {

      throw new IllegalArgumentException();

    }

    upper.set(v);

  }

  // 设置库存下限

  void setLower(long v){

    // 检查参数合法性

    if (v > upper.get()) {

      throw new IllegalArgumentException();

    }

    lower.set(v);

  }

  // 省略其他业务代码

}

在没有识别出库存下限要小于库存上限这个约束条件之前，我们制定的并发访问策略是利用原子类，但是这个策略，完全不能保证库存下限要小于库存上限这个约束条件。所以说，在设计阶段，我们一定要识别出所有共享变量之间的约束条件，如果约束条件识别不足，很可能导致制定的并发访问策略南辕北辙。

共享变量之间的约束条件，反映在代码里，基本上都会有 if 语句，所以，一定要特别注意竞态条件。

三、制定并发访问策略

制定并发访问策略，是一个非常复杂的事情。应该说整个专栏都是在尝试搞定它。不过从方案上来看，无外乎就是以下“三件事”。

避免共享：避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。

不变模式：这个在 Java 领域应用的很少，但在其他领域却有着广泛的应用，例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。

管程及其他同步工具：Java 领域万能的解决方案是管程，但是对于很多特定场景，使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。

接下来在咱们专栏的第二模块我会仔细讲解 Java 并发工具类以及他们的应用场景，在第三模块我还会讲解并发编程的设计模式，这些都是和制定并发访问策略有关的。

除了这些方案之外，还有一些宏观的原则需要你了解。这些宏观原则，有助于你写出“健壮”的并发程序。这些原则主要有以下三条。

优先使用成熟的工具类：Java SDK 并发包里提供了丰富的工具类，基本上能满足你日常的需要，建议你熟悉它们，用好它们，而不是自己再“发明轮子”，毕竟并发工具类不是随随便便就能发明成功的。

迫不得已时才使用低级的同步原语：低级的同步原语主要指的是 synchronized、Lock、Semaphore 等，这些虽然感觉简单，但实际上并没那么简单，一定要小心使用。

避免过早优化：安全第一，并发程序首先要保证安全，出现性能瓶颈后再优化。在设计期和开发期，很多人经常会情不自禁地预估性能的瓶颈，并对此实施优化，但残酷的现实却是：性能瓶颈不是你想预估就能预估的。

总结

利用面向对象思想编写并发程序，一个关键点就是利用面向对象里的封装特性，由于篇幅原因，这里我只做了简单介绍，详细的你可以借助相关资料定向学习。而对共享变量进行封装，要避免“逸出”，所谓“逸出”简单讲就是共享变量逃逸到对象的外面，比如在《02 | Java 内存模型：看 Java 如何解决可见性和有序性问题》那一篇我们已经讲过构造函数里的 this“逸出”。这些都是必须要避免的。

这是我们专栏并发理论基础的最后一部分内容，这一部分内容主要是让你对并发编程有一个全面的认识，让你了解并发编程里的各种概念，以及它们之间的关系，当然终极目标是让你知道遇到并发问题该怎么思考。这部分的内容还是有点烧脑的，但专栏后面几个模块的内容都是具体的实践部分，相对来说就容易多了。我们一起坚持吧！

课后思考

本期示例代码中，类 SafeWM 不满足库存下限要小于库存上限这个约束条件，那你来试试修改一下，让它能够在并发条件下满足库存下限要小于库存上限这个约束条件。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

延伸阅读

关于这部分的内容，如果你觉得还不“过瘾”，这里我再给你推荐一本书吧——《Java 并发编程实战》，这本书的第三章《对象的共享》、第四章《对象的组合》全面地介绍了如何构建线程安全的对象，你可以拿来深入地学习。

13 | 理论基础模块热点问题答疑

到这里，专栏的第一模块——并发编程的理论基础，我们已经讲解完了，总共 12 篇，不算少，但“跳出来，看全景”你会发现这 12 篇的内容基本上是一个“串行的故事”。所以，在学习过程中，建议你从一个个单一的知识和技术中“跳出来”，看全局，搭建自己的并发编程知识体系。

为了便于你更好地学习和理解，下面我会先将这些知识点再简单地为你“串”一下，咱们一起复习下；然后就每篇文章的课后思考题、留言区的热门评论，我也集中总结和回复一下。

那这个“串行的故事”是怎样的呢？

起源是一个硬件的核心矛盾：CPU 与内存、I/O 的速度差异，系统软件（操作系统、编译器）在解决这个核心矛盾的同时，引入了可见性、原子性和有序性问题，这三个问题就是很多并发程序的 Bug 之源。这，就是01的内容。

那如何解决这三个问题呢？Java 语言自然有招儿，它提供了 Java 内存模型和互斥锁方案。所以，在02我们介绍了 Java 内存模型，以应对可见性和有序性问题；那另一个原子性问题该如何解决？多方考量用好互斥锁才是关键，这就是03和04的内容。

虽说互斥锁是解决并发问题的核心工具，但它也可能会带来死锁问题，所以05就介绍了死锁的产生原因以及解决方案；同时还引出一个线程间协作的问题，这也就引出了06这篇文章的内容，介绍线程间的协作机制：等待 - 通知。

你应该也看出来了，前六篇文章，我们更多地是站在微观的角度看待并发问题。而07则是换一个角度，站在宏观的角度重新审视并发编程相关的概念和理论，同时也是对前六篇文章的查漏补缺。

08介绍的管程，是 Java 并发编程技术的基础，是解决并发问题的万能钥匙。并发编程里两大核心问题——互斥和同步，都是可以由管程来解决的。所以，学好管程，就相当于掌握了一把并发编程的万能钥匙。

至此，并发编程相关的问题，理论上你都应该能找到问题所在，并能给出理论上的解决方案了。

而后在09、10和11我们又介绍了线程相关的知识，毕竟 Java 并发编程是要靠多线程来实现的，所以有针对性地学习这部分知识也是很有必要的，包括线程的生命周期、如何计算合适的线程数以及线程内部是如何执行的。

最后，在12我们还介绍了如何用面向对象思想写好并发程序，因为在 Java 语言里，面向对象思想能够让并发编程变得更简单。

并发编程理论基础模块思维导图

经过这样一个简要的总结，相信你此时对于并发编程相关的概念、理论、产生的背景以及它们背后的关系已经都有了一个相对全面的认识。至于更深刻的认识和应用体验，还是需要你“钻进去，看本质”，加深对技术本身的认识，拓展知识深度和广度。

另外，在每篇文章的最后，我都附上了一个思考题，这些思考题虽然大部分都很简单，但是隐藏的问题却很容易让人忽略，从而不经意间就引发了 Bug；再加上留言区的一些热门评论，所以我想着将这些隐藏的问题或者易混淆的问题，做一个总结也是很有必要的。

1. 用锁的最佳实践

例如，在《03 | 互斥锁（上）：解决原子性问题》和《04 | 互斥锁（下）：如何用一把锁保护多个资源？》这两篇文章中，我们的思考题都是关于如何创建正确的锁，而思考题里的做法都是错误的。

03的思考题的示例代码如下，synchronized (new Object()) 这行代码很多同学已经分析出来了，每次调用方法 get()、addOne() 都创建了不同的锁，相当于无锁。这里需要你再次加深一下记忆，“一个合理的受保护资源与锁之间的关联关系应该是 N:1”。只有共享一把锁才能起到互斥的作用。

另外，很多同学也提到，JVM 开启逃逸分析之后，synchronized (new Object()) 这行代码在实际执行的时候会被优化掉，也就是说在真实执行的时候，这行代码压根就不存在。不过无论你是否懂“逃逸分析”都不影响你学好并发编程，如果你对“逃逸分析”感兴趣，可以参考一些 JVM 相关的资料。

class SafeCalc {

  long value = 0L;

  long get() {

    synchronized (new Object()) {

      return value;

    }

  }

  void addOne() {

    synchronized (new Object()) {

      value += 1;

    }

  }

}

04的思考题转换成代码，是下面这个样子。它的核心问题有两点：一个是锁有可能会变化，另一个是 Integer 和 String 类型的对象不适合做锁。如果锁发生变化，就意味着失去了互斥功能。 Integer 和 String 类型的对象在 JVM 里面是可能被重用的，除此之外，JVM 里可能被重用的对象还有 Boolean，那重用意味着什么呢？意味着你的锁可能被其他代码使用，如果其他代码 synchronized(你的锁)，而且不释放，那你的程序就永远拿不到锁，这是隐藏的风险。

class Account {

  // 账户余额

  private Integer balance;

  // 账户密码

  private String password;

  // 取款

  void withdraw(Integer amt) {

    synchronized(balance) {

      if (this.balance > amt){

        this.balance -= amt;

      }

    }

  }

  // 更改密码

  void updatePassword(String pw){

    synchronized(password) {

      this.password = pw;

    }

  }

}

通过这两个反例，我们可以总结出这样一个基本的原则：锁，应是私有的、不可变的、不可重用的。我们经常看到别人家的锁，都长成下面示例代码这样，这种写法貌不惊人，却能避免各种意想不到的坑，这个其实就是最佳实践。最佳实践这方面的资料推荐你看《Java 安全编码标准》这本书，研读里面的每一条规则都会让你受益匪浅。

// 普通对象锁

private final Object

  lock = new Object();

// 静态对象锁

private static final Object

  lock = new Object();

2. 锁的性能要看场景

《05 | 一不小心就死锁了，怎么办？》的思考题是比较while(!actr.apply(this, target));这个方法和synchronized(Account.class)的性能哪个更好。

这个要看具体的应用场景，不同应用场景它们的性能表现是不同的。在这个思考题里面，如果转账操作非常费时，那么前者的性能优势就显示出来了，因为前者允许 A->B、C->D 这种转账业务的并行。不同的并发场景用不同的方案，这是并发编程里面的一项基本原则；没有通吃的技术和方案，因为每种技术和方案都是优缺点和适用场景的。

3. 竞态条件需要格外关注

《07 | 安全性、活跃性以及性能问题》里的思考题是一种典型的竞态条件问题（如下所示）。竞态条件问题非常容易被忽略，contains() 和 add() 方法虽然都是线程安全的，但是组合在一起却不是线程安全的。所以你的程序里如果存在类似的组合操作，一定要小心。

void addIfNotExist(Vector v,

    Object o){

  if(!v.contains(o)) {

    v.add(o);

  }

}

这道思考题的解决方法，可以参考《12 | 如何用面向对象思想写好并发程序？》，你需要将共享变量 v 封装在对象的内部，而后控制并发访问的路径，这样就能有效防止对 Vector v 变量的滥用，从而导致并发问题。你可以参考下面的示例代码来加深理解。

class SafeVector{

  private Vector v;

  // 所有公共方法增加同步控制

  synchronized

  void addIfNotExist(Object o){

    if(!v.contains(o)) {

      v.add(o);

    }

  }

}

4. 方法调用是先计算参数

不过，还有同学对07文中所举的例子有疑议，认为set(get()+1);这条语句是进入 set() 方法之后才执行 get() 方法，其实并不是这样的。方法的调用，是先计算参数，然后将参数压入调用栈之后才会执行方法体，方法调用的过程在11这篇文章中我们已经做了详细的介绍，你可以再次重温一下。

while(idx++ < 10000) {

  set(get()+1);

}

先计算参数这个事情也是容易被忽视的细节。例如，下面写日志的代码，如果日志级别设置为 INFO，虽然这行代码不会写日志，但是会计算"The var1：" + var1 + ", var2:" + var2的值，因为方法调用前会先计算参数。

logger.debug("The var1：" +

  var1 + ", var2:" + var2);

更好地写法应该是下面这样，这种写法仅仅是讲参数压栈，而没有参数的计算。使用{}占位符是写日志的一个良好习惯。

logger.debug("The var1：{}, var2:{}",

  var1, var2);

5. InterruptedException 异常处理需小心

《 09 | Java 线程（上）：Java 线程的生命周期》的思考题主要是希望你能够注意 InterruptedException 的处理方式。当你调用 Java 对象的 wait() 方法或者线程的 sleep() 方法时，需要捕获并处理 InterruptedException 异常，在思考题里面（如下所示），本意是通过 isInterrupted() 检查线程是否被中断了，如果中断了就退出 while 循环。当其他线程通过调用th.interrupt().来中断 th 线程时，会设置 th 线程的中断标志位，从而使th.isInterrupted()返回 true，这样就能退出 while 循环了。

Thread th = Thread.currentThread();

while(true) {

  if(th.isInterrupted()) {

    break;

  }

  // 省略业务代码无数

  try {

    Thread.sleep(100);

  }catch (InterruptedException e){

    e.printStackTrace();

  }

}

这看上去一点问题没有，实际上却是几乎起不了作用。原因是这段代码在执行的时候，大部分时间都是阻塞在 sleep(100) 上，当其他线程通过调用th.interrupt().来中断 th 线程时，大概率地会触发 InterruptedException 异常，在触发 InterruptedException 异常的同时，JVM 会同时把线程的中断标志位清除，所以这个时候th.isInterrupted()返回的是 false。

正确的处理方式应该是捕获异常之后重新设置中断标志位，也就是下面这样：

try {

  Thread.sleep(100);

}catch(InterruptedException e){

  // 重新设置中断标志位

  th.interrupt();

}

6. 理论值 or 经验值

《10 | Java 线程（中）：创建多少线程才是合适的？》的思考题是：经验值为“最佳线程 =2 * CPU 的核数 + 1”，是否合理？

从理论上来讲，这个经验值一定是靠不住的。但是经验值对于很多“I/O 耗时 / CPU 耗时”不太容易确定的系统来说，却是一个很好到初始值。

我们曾讲到最佳线程数最终还是靠压测来确定的，实际工作中大家面临的系统，“I/O 耗时 / CPU 耗时”往往都大于 1，所以基本上都是在这个初始值的基础上增加。增加的过程中，应关注线程数是如何影响吞吐量和延迟的。一般来讲，随着线程数的增加，吞吐量会增加，延迟也会缓慢增加；但是当线程数增加到一定程度，吞吐量就会开始下降，延迟会迅速增加。这个时候基本上就是线程能够设置的最大值了。

实际工作中，不同的 I/O 模型对最佳线程数的影响非常大，例如大名鼎鼎的 Nginx 用的是非阻塞 I/O，采用的是多进程单线程结构，Nginx 本来是一个 I/O 密集型系统，但是最佳进程数设置的却是 CPU 的核数，完全参考的是 CPU 密集型的算法。所以，理论我们还是要活学活用。

总结

这个模块，内容主要聚焦在并发编程相关的理论上，但是思考题则是聚焦在细节上，我们经常说细节决定成败，在并发编程领域尤其如此。理论主要用来给我们提供解决问题的思路和方法，但在具体实践的时候，还必须重点关注每一个细节，哪怕有一个细节没有处理好，都会导致并发问题。这方面推荐你认真阅读《Java 安全编码标准》这本书，如果你英文足够好，也可以参考这份文档。

最后总结一句，学好理论有思路，关注细节定成败。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

14 | Lock和Condition（上）：隐藏在并发包中的管程

Java SDK 并发包内容很丰富，包罗万象，但是我觉得最核心的还是其对管程的实现。因为理论上利用管程，你几乎可以实现并发包里所有的工具类。在前面《08 | 管程：并发编程的万能钥匙》中我们提到过在并发编程领域，有两大核心问题：一个是互斥，即同一时刻只允许一个线程访问共享资源；另一个是同步，即线程之间如何通信、协作。这两大问题，管程都是能够解决的。Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程，其中 Lock 用于解决互斥问题，Condition 用于解决同步问题。

今天我们重点介绍 Lock 的使用，在介绍 Lock 的使用之前，有个问题需要你首先思考一下：Java 语言本身提供的 synchronized 也是管程的一种实现，既然 Java 从语言层面已经实现了管程了，那为什么还要在 SDK 里提供另外一种实现呢？难道 Java 标准委员会还能同意“重复造轮子”的方案？很显然它们之间是有巨大区别的。那区别在哪里呢？如果能深入理解这个问题，对你用好 Lock 帮助很大。下面我们就一起来剖析一下这个问题。

再造管程的理由

你也许曾经听到过很多这方面的传说，例如在 Java 的 1.5 版本中，synchronized 性能不如 SDK 里面的 Lock，但 1.6 版本之后，synchronized 做了很多优化，将性能追了上来，所以 1.6 之后的版本又有人推荐使用 synchronized 了。那性能是否可以成为“重复造轮子”的理由呢？显然不能。因为性能问题优化一下就可以了，完全没必要“重复造轮子”。

到这里，关于这个问题，你是否能够想出一条理由来呢？如果你细心的话，也许能想到一点。那就是我们前面在介绍死锁问题的时候，提出了一个破坏不可抢占条件方案，但是这个方案 synchronized 没有办法解决。原因是 synchronized 申请资源的时候，如果申请不到，线程直接进入阻塞状态了，而线程进入阻塞状态，啥都干不了，也释放不了线程已经占有的资源。但我们希望的是：

对于“不可抢占”这个条件，占用部分资源的线程进一步申请其他资源时，如果申请不到，可以主动释放它占有的资源，这样不可抢占这个条件就破坏掉了。

如果我们重新设计一把互斥锁去解决这个问题，那该怎么设计呢？我觉得有三种方案。

能够响应中断。synchronized 的问题是，持有锁 A 后，如果尝试获取锁 B 失败，那么线程就进入阻塞状态，一旦发生死锁，就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号，也就是说当我们给阻塞的线程发送中断信号的时候，能够唤醒它，那它就有机会释放曾经持有的锁 A。这样就破坏了不可抢占条件了。

支持超时。如果线程在一段时间之内没有获取到锁，不是进入阻塞状态，而是返回一个错误，那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。

非阻塞地获取锁。如果尝试获取锁失败，并不进入阻塞状态，而是直接返回，那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。

这三种方案可以全面弥补 synchronized 的问题。到这里相信你应该也能理解了，这三个方案就是“重复造轮子”的主要原因，体现在 API 上，就是 Lock 接口的三个方法。详情如下：

// 支持中断的 API

void lockInterruptibly()

  throws InterruptedException;

// 支持超时的 API

boolean tryLock(long time, TimeUnit unit)

  throws InterruptedException;

// 支持非阻塞获取锁的 API

boolean tryLock();

如何保证可见性

Java SDK 里面 Lock 的使用，有一个经典的范例，就是try{}finally{}，需要重点关注的是在 finally 里面释放锁。这个范例无需多解释，你看一下下面的代码就明白了。但是有一点需要解释一下，那就是可见性是怎么保证的。你已经知道 Java 里多线程的可见性是通过 Happens-Before 规则保证的，而 synchronized 之所以能够保证可见性，也是因为有一条 synchronized 相关的规则：synchronized 的解锁 Happens-Before 于后续对这个锁的加锁。那 Java SDK 里面 Lock 靠什么保证可见性呢？例如在下面的代码中，线程 T1 对 value 进行了 +=1 操作，那后续的线程 T2 能够看到 value 的正确结果吗？

class X {

  private final Lock rtl =

  new ReentrantLock();

  int value;

  public void addOne() {

    // 获取锁

    rtl.lock();

    try {

      value+=1;

    } finally {

      // 保证锁能释放

      rtl.unlock();

    }

  }

}

答案必须是肯定的。Java SDK 里面锁的实现非常复杂，这里我就不展开细说了，但是原理还是需要简单介绍一下：它是利用了 volatile 相关的 Happens-Before 规则。Java SDK 里面的 ReentrantLock，内部持有一个 volatile 的成员变量 state，获取锁的时候，会读写 state 的值；解锁的时候，也会读写 state 的值（简化后的代码如下面所示）。也就是说，在执行 value+=1 之前，程序先读写了一次 volatile 变量 state，在执行 value+=1 之后，又读写了一次 volatile 变量 state。根据相关的 Happens-Before 规则：

顺序性规则：对于线程 T1，value+=1 Happens-Before 释放锁的操作 unlock()；

volatile 变量规则：由于 state = 1 会先读取 state，所以线程 T1 的 unlock() 操作 Happens-Before 线程 T2 的 lock() 操作；

传递性规则：线程 T1 的 value+=1 Happens-Before 线程 T2 的 lock() 操作。

class SampleLock {

  volatile int state;

  // 加锁

  lock() {

    // 省略代码无数

    state = 1;

  }

  // 解锁

  unlock() {

    // 省略代码无数

    state = 0;

  }

}

所以说，后续线程 T2 能够看到 value 的正确结果。如果你觉得理解起来还有点困难，建议你重温一下前面我们讲过的《02 | Java 内存模型：看 Java 如何解决可见性和有序性问题》里面的相关内容。

什么是可重入锁

如果你细心观察，会发现我们创建的锁的具体类名是 ReentrantLock，这个翻译过来叫可重入锁，这个概念前面我们一直没有介绍过。所谓可重入锁，顾名思义，指的是线程可以重复获取同一把锁。例如下面代码中，当线程 T1 执行到 ① 处时，已经获取到了锁 rtl ，当在 ① 处调用 get() 方法时，会在 ② 再次对锁 rtl 执行加锁操作。此时，如果锁 rtl 是可重入的，那么线程 T1 可以再次加锁成功；如果锁 rtl 是不可重入的，那么线程 T1 此时会被阻塞。

除了可重入锁，可能你还听说过可重入函数，可重入函数怎么理解呢？指的是线程可以重复调用？显然不是，所谓可重入函数，指的是多个线程可以同时调用该函数，每个线程都能得到正确结果；同时在一个线程内支持线程切换，无论被切换多少次，结果都是正确的。多线程可以同时执行，还支持线程切换，这意味着什么呢？线程安全啊。所以，可重入函数是线程安全的。

class X {

  private final Lock rtl =

  new ReentrantLock();

  int value;

  public int get() {

    // 获取锁

    rtl.lock();         ②

    try {

      return value;

    } finally {

      // 保证锁能释放

      rtl.unlock();

    }

  }

  public void addOne() {

    // 获取锁

    rtl.lock();

    try {

      value = 1 + get(); ①

    } finally {

      // 保证锁能释放

      rtl.unlock();

    }

  }

}

公平锁与非公平锁

在使用 ReentrantLock 的时候，你会发现 ReentrantLock 这个类有两个构造函数，一个是无参构造函数，一个是传入 fair 参数的构造函数。fair 参数代表的是锁的公平策略，如果传入 true 就表示需要构造一个公平锁，反之则表示要构造一个非公平锁。

// 无参构造函数：默认非公平锁

public ReentrantLock() {

    sync = new NonfairSync();

}

// 根据公平策略参数创建锁

public ReentrantLock(boolean fair){

    sync = fair ? new FairSync()

                : new NonfairSync();

}

在前面《08 | 管程：并发编程的万能钥匙》中，我们介绍过入口等待队列，锁都对应着一个等待队列，如果一个线程没有获得锁，就会进入等待队列，当有线程释放锁的时候，就需要从等待队列中唤醒一个等待的线程。如果是公平锁，唤醒的策略就是谁等待的时间长，就唤醒谁，很公平；如果是非公平锁，则不提供这个公平保证，有可能等待时间短的线程反而先被唤醒。

用锁的最佳实践

你已经知道，用锁虽然能解决很多并发问题，但是风险也是挺高的。可能会导致死锁，也可能影响性能。这方面有是否有相关的最佳实践呢？有，还很多。但是我觉得最值得推荐的是并发大师 Doug Lea《Java 并发编程：设计原则与模式》一书中，推荐的三个用锁的最佳实践，它们分别是：

永远只在更新对象的成员变量时加锁

永远只在访问可变的成员变量时加锁

永远不在调用其他对象的方法时加锁

这三条规则，前两条估计你一定会认同，最后一条你可能会觉得过于严苛。但是我还是倾向于你去遵守，因为调用其他对象的方法，实在是太不安全了，也许“其他”方法里面有线程 sleep() 的调用，也可能会有奇慢无比的 I/O 操作，这些都会严重影响性能。更可怕的是，“其他”类的方法可能也会加锁，然后双重加锁就可能导致死锁。

并发问题，本来就难以诊断，所以你一定要让你的代码尽量安全，尽量简单，哪怕有一点可能会出问题，都要努力避免。

总结

Java SDK 并发包里的 Lock 接口里面的每个方法，你可以感受到，都是经过深思熟虑的。除了支持类似 synchronized 隐式加锁的 lock() 方法外，还支持超时、非阻塞、可中断的方式获取锁，这三种方式为我们编写更加安全、健壮的并发程序提供了很大的便利。希望你以后在使用锁的时候，一定要仔细斟酌。

除了并发大师 Doug Lea 推荐的三个最佳实践外，你也可以参考一些诸如：减少锁的持有时间、减小锁的粒度等业界广为人知的规则，其实本质上它们都是相通的，不过是在该加锁的地方加锁而已。你可以自己体会，自己总结，最终总结出自己的一套最佳实践来。

课后思考

你已经知道 tryLock() 支持非阻塞方式获取锁，下面这段关于转账的程序就使用到了 tryLock()，你来看看，它是否存在死锁问题呢？

class Account {

  private int balance;

  private final Lock lock

          = new ReentrantLock();

  // 转账

  void transfer(Account tar, int amt){

    while (true) {

      if(this.lock.tryLock()) {

        try {

          if (tar.lock.tryLock()) {

            try {

              this.balance -= amt;

              tar.balance += amt;

            } finally {

              tar.lock.unlock();

            }

          }//if

        } finally {

          this.lock.unlock();

        }

      }//if

    }//while

  }//transfer

}

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

15 | Lock和Condition（下）：Dubbo如何用管程实现异步转同步？

在上一篇文章中，我们讲到 Java SDK 并发包里的 Lock 有别于 synchronized 隐式锁的三个特性：能够响应中断、支持超时和非阻塞地获取锁。那今天我们接着再来详细聊聊 Java SDK 并发包里的 Condition，Condition 实现了管程模型里面的条件变量。

在《08 | 管程：并发编程的万能钥匙》里我们提到过 Java 语言内置的管程里只有一个条件变量，而 Lock&Condition 实现的管程是支持多个条件变量的，这是二者的一个重要区别。

在很多并发场景下，支持多个条件变量能够让我们的并发程序可读性更好，实现起来也更容易。例如，实现一个阻塞队列，就需要两个条件变量。

那如何利用两个条件变量快速实现阻塞队列呢？

一个阻塞队列，需要两个条件变量，一个是队列不空（空队列不允许出队），另一个是队列不满（队列已满不允许入队），这个例子我们前面在介绍管程的时候详细说过，这里就不再赘述。相关的代码，我这里重新列了出来，你可以温故知新一下。

public class BlockedQueue<T>{

  final Lock lock =

    new ReentrantLock();

  // 条件变量：队列不满

  final Condition notFull =

    lock.newCondition();

  // 条件变量：队列不空

  final Condition notEmpty =

    lock.newCondition();

  // 入队

  void enq(T x) {

    lock.lock();

    try {

      while (队列已满){

        // 等待队列不满

        notFull.await();

      }

      // 省略入队操作...

      // 入队后, 通知可出队

      notEmpty.signal();

    }finally {

      lock.unlock();

    }

  }

  // 出队

  void deq(){

    lock.lock();

    try {

      while (队列已空){

        // 等待队列不空

        notEmpty.await();

      }

      // 省略出队操作...

      // 出队后，通知可入队

      notFull.signal();

    }finally {

      lock.unlock();

    }

  }

}

不过，这里你需要注意，Lock 和 Condition 实现的管程，线程等待和通知需要调用 await()、signal()、signalAll()，它们的语义和 wait()、notify()、notifyAll() 是相同的。但是不一样的是，Lock&Condition 实现的管程里只能使用前面的 await()、signal()、signalAll()，而后面的 wait()、notify()、notifyAll() 只有在 synchronized 实现的管程里才能使用。如果一不小心在 Lock&Condition 实现的管程里调用了 wait()、notify()、notifyAll()，那程序可就彻底玩儿完了。

Java SDK 并发包里的 Lock 和 Condition 不过就是管程的一种实现而已，管程你已经很熟悉了，那 Lock 和 Condition 的使用自然是小菜一碟。下面我们就来看看在知名项目 Dubbo 中，Lock 和 Condition 是怎么用的。不过在开始介绍源码之前，我还先要介绍两个概念：同步和异步。

同步与异步

我们平时写的代码，基本都是同步的。但最近几年，异步编程大火。那同步和异步的区别到底是什么呢？通俗点来讲就是调用方是否需要等待结果，如果需要等待结果，就是同步；如果不需要等待结果，就是异步。

比如在下面的代码里，有一个计算圆周率小数点后 100 万位的方法pai1M()，这个方法可能需要执行俩礼拜，如果调用pai1M()之后，线程一直等着计算结果，等俩礼拜之后结果返回，就可以执行 printf("hello world")了，这个属于同步；如果调用pai1M()之后，线程不用等待计算结果，立刻就可以执行 printf("hello world")，这个就属于异步。

// 计算圆周率小说点后 100 万位

String pai1M() {

  // 省略代码无数

}

pai1M()

printf("hello world")

同步，是 Java 代码默认的处理方式。如果你想让你的程序支持异步，可以通过下面两种方式来实现：

调用方创建一个子线程，在子线程中执行方法调用，这种调用我们称为异步调用；

方法实现的时候，创建一个新的线程执行主要逻辑，主线程直接 return，这种方法我们一般称为异步方法。

Dubbo 源码分析

其实在编程领域，异步的场景还是挺多的，比如 TCP 协议本身就是异步的，我们工作中经常用到的 RPC 调用，在 TCP 协议层面，发送完 RPC 请求后，线程是不会等待 RPC 的响应结果的。可能你会觉得奇怪，平时工作中的 RPC 调用大多数都是同步的啊？这是怎么回事呢？

其实很简单，一定是有人帮你做了异步转同步的事情。例如目前知名的 RPC 框架 Dubbo 就给我们做了异步转同步的事情，那它是怎么做的呢？下面我们就来分析一下 Dubbo 的相关源码。

对于下面一个简单的 RPC 调用，默认情况下 sayHello() 方法，是个同步方法，也就是说，执行 service.sayHello(“dubbo”) 的时候，线程会停下来等结果。

DemoService service = 初始化部分省略

String message =

  service.sayHello("dubbo");

System.out.println(message);

如果此时你将调用线程 dump 出来的话，会是下图这个样子，你会发现调用线程阻塞了，线程状态是 TIMED_WAITING。本来发送请求是异步的，但是调用线程却阻塞了，说明 Dubbo 帮我们做了异步转同步的事情。通过调用栈，你能看到线程是阻塞在 DefaultFuture.get() 方法上，所以可以推断：Dubbo 异步转同步的功能应该是通过 DefaultFuture 这个类实现的。

调用栈信息

不过为了理清前后关系，还是有必要分析一下调用 DefaultFuture.get() 之前发生了什么。DubboInvoker 的 108 行调用了 DefaultFuture.get()，这一行很关键，我稍微修改了一下列在了下面。这一行先调用了 request(inv, timeout) 方法，这个方法其实就是发送 RPC 请求，之后通过调用 get() 方法等待 RPC 返回结果。

public class DubboInvoker{

  Result doInvoke(Invocation inv){

    // 下面这行就是源码中 108 行

    // 为了便于展示，做了修改

    return currentClient

      .request(inv, timeout)

      .get();

  }

}

DefaultFuture 这个类是很关键，我把相关的代码精简之后，列到了下面。不过在看代码之前，你还是有必要重复一下我们的需求：当 RPC 返回结果之前，阻塞调用线程，让调用线程等待；当 RPC 返回结果后，唤醒调用线程，让调用线程重新执行。不知道你有没有似曾相识的感觉，这不就是经典的等待 - 通知机制吗？这个时候想必你的脑海里应该能够浮现出管程的解决方案了。有了自己的方案之后，我们再来看看 Dubbo 是怎么实现的。

// 创建锁与条件变量

private final Lock lock

    = new ReentrantLock();

private final Condition done

    = lock.newCondition();

// 调用方通过该方法等待结果

Object get(int timeout){

  long start = System.nanoTime();

  lock.lock();

  try {

	while (!isDone()) {

	  done.await(timeout);

      long cur=System.nanoTime();

	  if (isDone() ||

          cur-start > timeout){

	    break;

	  }

	}

  } finally {

	lock.unlock();

  }

  if (!isDone()) {

	throw new TimeoutException();

  }

  return returnFromResponse();

}

// RPC 结果是否已经返回

boolean isDone() {

  return response != null;

}

// RPC 结果返回时调用该方法

private void doReceived(Response res) {

  lock.lock();

  try {

    response = res;

    if (done != null) {

      done.signal();

    }

  } finally {

    lock.unlock();

  }

}

调用线程通过调用 get() 方法等待 RPC 返回结果，这个方法里面，你看到的都是熟悉的“面孔”：调用 lock() 获取锁，在 finally 里面调用 unlock() 释放锁；获取锁后，通过经典的在循环中调用 await() 方法来实现等待。

当 RPC 结果返回时，会调用 doReceived() 方法，这个方法里面，调用 lock() 获取锁，在 finally 里面调用 unlock() 释放锁，获取锁后通过调用 signal() 来通知调用线程，结果已经返回，不用继续等待了。

至此，Dubbo 里面的异步转同步的源码就分析完了，有没有觉得还挺简单的？最近这几年，工作中需要异步处理的越来越多了，其中有一个主要原因就是有些 API 本身就是异步 API。例如 websocket 也是一个异步的通信协议，如果基于这个协议实现一个简单的 RPC，你也会遇到异步转同步的问题。现在很多公有云的 API 本身也是异步的，例如创建云主机，就是一个异步的 API，调用虽然成功了，但是云主机并没有创建成功，你需要调用另外一个 API 去轮询云主机的状态。如果你需要在项目内部封装创建云主机的 API，你也会面临异步转同步的问题，因为同步的 API 更易用。

总结

Lock&Condition 是管程的一种实现，所以能否用好 Lock 和 Condition 要看你对管程模型理解得怎么样。管程的技术前面我们已经专门用了一篇文章做了介绍，你可以结合着来学，理论联系实践，有助于加深理解。

Lock&Condition 实现的管程相对于 synchronized 实现的管程来说更加灵活、功能也更丰富。

结合我自己的经验，我认为了解原理比了解实现更能让你快速学好并发编程，所以没有介绍太多 Java SDK 并发包里锁和条件变量是如何实现的。但如果你对实现感兴趣，可以参考《Java 并发编程的艺术》一书的第 5 章《Java 中的锁》，里面详细介绍了实现原理，我觉得写得非常好。

另外，专栏里对 DefaultFuture 的代码缩减了很多，如果你感兴趣，也可以去看看完整版。
Dubbo 的源代码在Github 上，DefaultFuture 的路径是：incubator-dubbo/dubbo-remoting/dubbo-remoting-api/src/main/java/org/apache/dubbo/remoting/exchange/support/DefaultFuture.java。

课后思考

DefaultFuture 里面唤醒等待的线程，用的是 signal()，而不是 signalAll()，你来分析一下，这样做是否合理呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

16 | Semaphore：如何快速实现一个限流器？

Semaphore，现在普遍翻译为“信号量”，以前也曾被翻译成“信号灯”，因为类似现实生活里的红绿灯，车辆能不能通行，要看是不是绿灯。同样，在编程世界里，线程能不能执行，也要看信号量是不是允许。

信号量是由大名鼎鼎的计算机科学家迪杰斯特拉（Dijkstra）于 1965 年提出，在这之后的 15 年，信号量一直都是并发编程领域的终结者，直到 1980 年管程被提出来，我们才有了第二选择。目前几乎所有支持并发编程的语言都支持信号量机制，所以学好信号量还是很有必要的。

下面我们首先介绍信号量模型，之后介绍如何使用信号量，最后我们再用信号量来实现一个限流器。

信号量模型

信号量模型还是很简单的，可以简单概括为：一个计数器，一个等待队列，三个方法。在信号量模型里，计数器和等待队列对外是透明的，所以只能通过信号量模型提供的三个方法来访问它们，这三个方法分别是：init()、down() 和 up()。你可以结合下图来形象化地理解。

信号量模型图

这三个方法详细的语义具体如下所示。

init()：设置计数器的初始值。

down()：计数器的值减 1；如果此时计数器的值小于 0，则当前线程将被阻塞，否则当前线程可以继续执行。

up()：计数器的值加 1；如果此时计数器的值小于或者等于 0，则唤醒等待队列中的一个线程，并将其从等待队列中移除。

这里提到的 init()、down() 和 up() 三个方法都是原子性的，并且这个原子性是由信号量模型的实现方保证的。在 Java SDK 里面，信号量模型是由 java.util.concurrent.Semaphore 实现的，Semaphore 这个类能够保证这三个方法都是原子操作。

如果你觉得上面的描述有点绕的话，可以参考下面这个代码化的信号量模型。

class Semaphore{

  // 计数器

  int count;

  // 等待队列

  Queue queue;

  // 初始化操作

  Semaphore(int c){

    this.count=c;

  }

  //

  void down(){

    this.count--;

    if(this.count<0){

      // 将当前线程插入等待队列

      // 阻塞当前线程

    }

  }

  void up(){

    this.count++;

    if(this.count<=0) {

      // 移除等待队列中的某个线程 T

      // 唤醒线程 T

    }

  }

}

这里再插一句，信号量模型里面，down()、up() 这两个操作历史上最早称为 P 操作和 V 操作，所以信号量模型也被称为 PV 原语。另外，还有些人喜欢用 semWait() 和 semSignal() 来称呼它们，虽然叫法不同，但是语义都是相同的。在 Java SDK 并发包里，down() 和 up() 对应的则是 acquire() 和 release()。

如何使用信号量

通过上文，你应该会发现信号量的模型还是很简单的，那具体该如何使用呢？其实你想想红绿灯就可以了。十字路口的红绿灯可以控制交通，得益于它的一个关键规则：车辆在通过路口前必须先检查是否是绿灯，只有绿灯才能通行。这个规则和我们前面提到的锁规则是不是很类似？

其实，信号量的使用也是类似的。这里我们还是用累加器的例子来说明信号量的使用吧。在累加器的例子里面，count+=1 操作是个临界区，只允许一个线程执行，也就是说要保证互斥。那这种情况用信号量怎么控制呢？

其实很简单，就像我们用互斥锁一样，只需要在进入临界区之前执行一下 down() 操作，退出临界区之前执行一下 up() 操作就可以了。下面是 Java 代码的示例，acquire() 就是信号量里的 down() 操作，release() 就是信号量里的 up() 操作。

static int count;

// 初始化信号量

static final Semaphore s

    = new Semaphore(1);

// 用信号量保证互斥

static void addOne() {

  s.acquire();

  try {

    count+=1;

  } finally {

    s.release();

  }

}

下面我们再来分析一下，信号量是如何保证互斥的。假设两个线程 T1 和 T2 同时访问 addOne() 方法，当它们同时调用 acquire() 的时候，由于 acquire() 是一个原子操作，所以只能有一个线程（假设 T1）把信号量里的计数器减为 0，另外一个线程（T2）则是将计数器减为 -1。对于线程 T1，信号量里面的计数器的值是 0，大于等于 0，所以线程 T1 会继续执行；对于线程 T2，信号量里面的计数器的值是 -1，小于 0，按照信号量模型里对 down() 操作的描述，线程 T2 将被阻塞。所以此时只有线程 T1 会进入临界区执行count+=1；。

当线程 T1 执行 release() 操作，也就是 up() 操作的时候，信号量里计数器的值是 -1，加 1 之后的值是 0，小于等于 0，按照信号量模型里对 up() 操作的描述，此时等待队列中的 T2 将会被唤醒。于是 T2 在 T1 执行完临界区代码之后才获得了进入临界区执行的机会，从而保证了互斥性。

快速实现一个限流器

上面的例子，我们用信号量实现了一个最简单的互斥锁功能。估计你会觉得奇怪，既然有 Java SDK 里面提供了 Lock，为啥还要提供一个 Semaphore ？其实实现一个互斥锁，仅仅是 Semaphore 的部分功能，Semaphore 还有一个功能是 Lock 不容易实现的，那就是：Semaphore 可以允许多个线程访问一个临界区。

现实中还有这种需求？有的。比较常见的需求就是我们工作中遇到的各种池化资源，例如连接池、对象池、线程池等等。其中，你可能最熟悉数据库连接池，在同一时刻，一定是允许多个线程同时使用连接池的，当然，每个连接在被释放前，是不允许其他线程使用的。

其实前不久，我在工作中也遇到了一个对象池的需求。所谓对象池呢，指的是一次性创建出 N 个对象，之后所有的线程重复利用这 N 个对象，当然对象在被释放前，也是不允许其他线程使用的。对象池，可以用 List 保存实例对象，这个很简单。但关键是限流器的设计，这里的限流，指的是不允许多于 N 个线程同时进入临界区。那如何快速实现一个这样的限流器呢？这种场景，我立刻就想到了信号量的解决方案。

信号量的计数器，在上面的例子中，我们设置成了 1，这个 1 表示只允许一个线程进入临界区，但如果我们把计数器的值设置成对象池里对象的个数 N，就能完美解决对象池的限流问题了。下面就是对象池的示例代码。

class ObjPool<T, R> {

  final List<T> pool;

  // 用信号量实现限流器

  final Semaphore sem;

  // 构造函数

  ObjPool(int size, T t){

    pool = new Vector<T>(){};

    for(int i=0; i<size; i++){

      pool.add(t);

    }

    sem = new Semaphore(size);

  }

  // 利用对象池的对象，调用 func

  R exec(Function<T,R> func) {

    T t = null;

    sem.acquire();

    try {

      t = pool.remove(0);

      return func.apply(t);

    } finally {

      pool.add(t);

      sem.release();

    }

  }

}

// 创建对象池

ObjPool<Long, String> pool =

  new ObjPool<Long, String>(10, 2);

// 通过对象池获取 t，之后执行

pool.exec(t -> {

    System.out.println(t);

    return t.toString();

});

我们用一个 List来保存对象实例，用 Semaphore 实现限流器。关键的代码是 ObjPool 里面的 exec() 方法，这个方法里面实现了限流的功能。在这个方法里面，我们首先调用 acquire() 方法（与之匹配的是在 finally 里面调用 release() 方法），假设对象池的大小是 10，信号量的计数器初始化为 10，那么前 10 个线程调用 acquire() 方法，都能继续执行，相当于通过了信号灯，而其他线程则会阻塞在 acquire() 方法上。对于通过信号灯的线程，我们为每个线程分配了一个对象 t（这个分配工作是通过 pool.remove(0) 实现的），分配完之后会执行一个回调函数 func，而函数的参数正是前面分配的对象 t ；执行完回调函数之后，它们就会释放对象（这个释放工作是通过 pool.add(t) 实现的），同时调用 release() 方法来更新信号量的计数器。如果此时信号量里计数器的值小于等于 0，那么说明有线程在等待，此时会自动唤醒等待的线程。

简言之，使用信号量，我们可以轻松地实现一个限流器，使用起来还是非常简单的。

总结

信号量在 Java 语言里面名气并不算大，但是在其他语言里却是很有知名度的。Java 在并发编程领域走的很快，重点支持的还是管程模型。 管程模型理论上解决了信号量模型的一些不足，主要体现在易用性和工程化方面，例如用信号量解决我们曾经提到过的阻塞队列问题，就比管程模型麻烦很多，你如果感兴趣，可以课下了解和尝试一下。

课后思考

在上面对象池的例子中，对象保存在了 Vector 中，Vector 是 Java 提供的线程安全的容器，如果我们把 Vector 换成 ArrayList，是否可以呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

17 | ReadWriteLock：如何快速实现一个完备的缓存？

前面我们介绍了管程和信号量这两个同步原语在 Java 语言中的实现，理论上用这两个同步原语中任何一个都可以解决所有的并发问题。那 Java SDK 并发包里为什么还有很多其他的工具类呢？原因很简单：分场景优化性能，提升易用性。

今天我们就介绍一种非常普遍的并发场景：读多写少场景。实际工作中，为了优化性能，我们经常会使用缓存，例如缓存元数据、缓存基础数据等，这就是一种典型的读多写少应用场景。缓存之所以能提升性能，一个重要的条件就是缓存的数据一定是读多写少的，例如元数据和基础数据基本上不会发生变化（写少），但是使用它们的地方却很多（读多）。

针对读多写少这种并发场景，Java SDK 并发包提供了读写锁——ReadWriteLock，非常容易使用，并且性能很好。

那什么是读写锁呢？

读写锁，并不是 Java 语言特有的，而是一个广为使用的通用技术，所有的读写锁都遵守以下三条基本原则：

允许多个线程同时读共享变量；

只允许一个线程写共享变量；

如果一个写线程正在执行写操作，此时禁止读线程读共享变量。

读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量，而互斥锁是不允许的，这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的，当一个线程在写共享变量的时候，是不允许其他线程执行写操作和读操作。

快速实现一个缓存

下面我们就实践起来，用 ReadWriteLock 快速实现一个通用的缓存工具类。

在下面的代码中，我们声明了一个 Cache<K, V> 类，其中类型参数 K 代表缓存里 key 的类型，V 代表缓存里 value 的类型。缓存的数据保存在 Cache 类内部的 HashMap 里面，HashMap 不是线程安全的，这里我们使用读写锁 ReadWriteLock 来保证其线程安全。ReadWriteLock 是一个接口，它的实现类是 ReentrantReadWriteLock，通过名字你应该就能判断出来，它是支持可重入的。下面我们通过 rwl 创建了一把读锁和一把写锁。

Cache 这个工具类，我们提供了两个方法，一个是读缓存方法 get()，另一个是写缓存方法 put()。读缓存需要用到读锁，读锁的使用和前面我们介绍的 Lock 的使用是相同的，都是 try{}finally{}这个编程范式。写缓存则需要用到写锁，写锁的使用和读锁是类似的。这样看来，读写锁的使用还是非常简单的。

class Cache<K,V> {

  final Map<K, V> m =

    new HashMap<>();

  final ReadWriteLock rwl =

    new ReentrantReadWriteLock();

  // 读锁

  final Lock r = rwl.readLock();

  // 写锁

  final Lock w = rwl.writeLock();

  // 读缓存

  V get(K key) {

    r.lock();

    try { return m.get(key); }

    finally { r.unlock(); }

  }

  // 写缓存

  V put(String key, Data v) {

    w.lock();

    try { return m.put(key, v); }

    finally { w.unlock(); }

  }

}

如果你曾经使用过缓存的话，你应该知道使用缓存首先要解决缓存数据的初始化问题。缓存数据的初始化，可以采用一次性加载的方式，也可以使用按需加载的方式。

如果源头数据的数据量不大，就可以采用一次性加载的方式，这种方式最简单（可参考下图），只需在应用启动的时候把源头数据查询出来，依次调用类似上面示例代码中的 put() 方法就可以了。

缓存一次性加载示意图

如果源头数据量非常大，那么就需要按需加载了，按需加载也叫懒加载，指的是只有当应用查询缓存，并且数据不在缓存里的时候，才触发加载源头相关数据进缓存的操作。下面你可以结合文中示意图看看如何利用 ReadWriteLock 来实现缓存的按需加载。

缓存按需加载示意图

实现缓存的按需加载

文中下面的这段代码实现了按需加载的功能，这里我们假设缓存的源头是数据库。需要注意的是，如果缓存中没有缓存目标对象，那么就需要从数据库中加载，然后写入缓存，写缓存需要用到写锁，所以在代码中的⑤处，我们调用了 w.lock() 来获取写锁。

另外，还需要注意的是，在获取写锁之后，我们并没有直接去查询数据库，而是在代码⑥⑦处，重新验证了一次缓存中是否存在，再次验证如果还是不存在，我们才去查询数据库并更新本地缓存。为什么我们要再次验证呢？

class Cache<K,V> {

  final Map<K, V> m =

    new HashMap<>();

  final ReadWriteLock rwl =

    new ReentrantReadWriteLock();

  final Lock r = rwl.readLock();

  final Lock w = rwl.writeLock();

  V get(K key) {

    V v = null;

    // 读缓存

    r.lock();         ①

    try {

      v = m.get(key); ②

    } finally{

      r.unlock();     ③

    }

    // 缓存中存在，返回

    if(v != null) {   ④

      return v;

    }

    // 缓存中不存在，查询数据库

    w.lock();         ⑤

    try {

      // 再次验证

      // 其他线程可能已经查询过数据库

      v = m.get(key); ⑥

      if(v == null){  ⑦

        // 查询数据库

        v= 省略代码无数

        m.put(key, v);

      }

    } finally{

      w.unlock();

    }

    return v;

  }

}

原因是在高并发的场景下，有可能会有多线程竞争写锁。假设缓存是空的，没有缓存任何东西，如果此时有三个线程 T1、T2 和 T3 同时调用 get() 方法，并且参数 key 也是相同的。那么它们会同时执行到代码⑤处，但此时只有一个线程能够获得写锁，假设是线程 T1，线程 T1 获取写锁之后查询数据库并更新缓存，最终释放写锁。此时线程 T2 和 T3 会再有一个线程能够获取写锁，假设是 T2，如果不采用再次验证的方式，此时 T2 会再次查询数据库。T2 释放写锁之后，T3 也会再次查询一次数据库。而实际上线程 T1 已经把缓存的值设置好了，T2、T3 完全没有必要再次查询数据库。所以，再次验证的方式，能够避免高并发场景下重复查询数据的问题。

读写锁的升级与降级

上面按需加载的示例代码中，在①处获取读锁，在③处释放读锁，那是否可以在②处的下面增加验证缓存并更新缓存的逻辑呢？详细的代码如下。

// 读缓存

r.lock();         ①

try {

  v = m.get(key); ②

  if (v == null) {

    w.lock();

    try {

      // 再次验证并更新缓存

      // 省略详细代码

    } finally{

      w.unlock();

    }

  }

} finally{

  r.unlock();     ③

}

这样看上去好像是没有问题的，先是获取读锁，然后再升级为写锁，对此还有个专业的名字，叫锁的升级。可惜 ReadWriteLock 并不支持这种升级。在上面的代码示例中，读锁还没有释放，此时获取写锁，会导致写锁永久等待，最终导致相关线程都被阻塞，永远也没有机会被唤醒。锁的升级是不允许的，这个你一定要注意。

不过，虽然锁的升级是不允许的，但是锁的降级却是允许的。以下代码来源自 ReentrantReadWriteLock 的官方示例，略做了改动。你会发现在代码①处，获取读锁的时候线程还是持有写锁的，这种锁的降级是支持的。

class CachedData {

  Object data;

  volatile boolean cacheValid;

  final ReadWriteLock rwl =

    new ReentrantReadWriteLock();

  // 读锁

  final Lock r = rwl.readLock();

  // 写锁

  final Lock w = rwl.writeLock();

  void processCachedData() {

    // 获取读锁

    r.lock();

    if (!cacheValid) {

      // 释放读锁，因为不允许读锁的升级

      r.unlock();

      // 获取写锁

      w.lock();

      try {

        // 再次检查状态

        if (!cacheValid) {

          data = ...

          cacheValid = true;

        }

        // 释放写锁前，降级为读锁

        // 降级是可以的

        r.lock(); ①

      } finally {

        // 释放写锁

        w.unlock();

      }

    }

    // 此处仍然持有读锁

    try {use(data);}

    finally {r.unlock();}

  }

}

总结

读写锁类似于 ReentrantLock，也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口，所以除了支持 lock() 方法外，tryLock()、lockInterruptibly() 等方法也都是支持的。但是有一点需要注意，那就是只有写锁支持条件变量，读锁是不支持条件变量的，读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。

今天我们用 ReadWriteLock 实现了一个简单的缓存，这个缓存虽然解决了缓存的初始化问题，但是没有解决缓存数据与源头数据的同步问题，这里的数据同步指的是保证缓存数据和源头数据的一致性。解决数据同步问题的一个最简单的方案就是超时机制。所谓超时机制指的是加载进缓存的数据不是长久有效的，而是有时效的，当缓存的数据超过时效，也就是超时之后，这条数据在缓存中就失效了。而访问缓存中失效的数据，会触发缓存重新从源头把数据加载进缓存。

当然也可以在源头数据发生变化时，快速反馈给缓存，但这个就要依赖具体的场景了。例如 MySQL 作为数据源头，可以通过近实时地解析 binlog 来识别数据是否发生了变化，如果发生了变化就将最新的数据推送给缓存。另外，还有一些方案采取的是数据库和缓存的双写方案。

总之，具体采用哪种方案，还是要看应用的场景。

课后思考

有同学反映线上系统停止响应了，CPU 利用率很低，你怀疑有同学一不小心写出了读锁升级写锁的方案，那你该如何验证自己的怀疑呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

18 | StampedLock：有没有比读写锁更快的锁？

在上一篇文章中，我们介绍了读写锁，学习完之后你应该已经知道“读写锁允许多个线程同时读共享变量，适用于读多写少的场景”。那在读多写少的场景中，还有没有更快的技术方案呢？还真有，Java 在 1.8 这个版本里，提供了一种叫 StampedLock 的锁，它的性能就比读写锁还要好。

下面我们就来介绍一下 StampedLock 的使用方法、内部工作原理以及在使用过程中需要注意的事项。

StampedLock 支持的三种锁模式

我们先来看看在使用上 StampedLock 和上一篇文章讲的 ReadWriteLock 有哪些区别。

ReadWriteLock 支持两种模式：一种是读锁，一种是写锁。而 StampedLock 支持三种模式，分别是：写锁、悲观读锁和乐观读。其中，写锁、悲观读锁的语义和 ReadWriteLock 的写锁、读锁的语义非常类似，允许多个线程同时获取悲观读锁，但是只允许一个线程获取写锁，写锁和悲观读锁是互斥的。不同的是：StampedLock 里的写锁和悲观读锁加锁成功之后，都会返回一个 stamp；然后解锁的时候，需要传入这个 stamp。相关的示例代码如下。

final StampedLock sl =

  new StampedLock();

// 获取 / 释放悲观读锁示意代码

long stamp = sl.readLock();

try {

  // 省略业务相关代码

} finally {

  sl.unlockRead(stamp);

}

// 获取 / 释放写锁示意代码

long stamp = sl.writeLock();

try {

  // 省略业务相关代码

} finally {

  sl.unlockWrite(stamp);

}

StampedLock 的性能之所以比 ReadWriteLock 还要好，其关键是 StampedLock 支持乐观读的方式。ReadWriteLock 支持多个线程同时读，但是当多个线程同时读的时候，所有的写操作会被阻塞；而 StampedLock 提供的乐观读，是允许一个线程获取写锁的，也就是说不是所有的写操作都被阻塞。

注意这里，我们用的是“乐观读”这个词，而不是“乐观读锁”，是要提醒你，乐观读这个操作是无锁的，所以相比较 ReadWriteLock 的读锁，乐观读的性能更好一些。

文中下面这段代码是出自 Java SDK 官方示例，并略做了修改。在 distanceFromOrigin() 这个方法中，首先通过调用 tryOptimisticRead() 获取了一个 stamp，这里的 tryOptimisticRead() 就是我们前面提到的乐观读。之后将共享变量 x 和 y 读入方法的局部变量中，不过需要注意的是，由于 tryOptimisticRead() 是无锁的，所以共享变量 x 和 y 读入方法局部变量时，x 和 y 有可能被其他线程修改了。因此最后读完之后，还需要再次验证一下是否存在写操作，这个验证操作是通过调用 validate(stamp) 来实现的。

class Point {

  private int x, y;

  final StampedLock sl =

    new StampedLock();

  // 计算到原点的距离

  int distanceFromOrigin() {

    // 乐观读

    long stamp =

      sl.tryOptimisticRead();

    // 读入局部变量，

    // 读的过程数据可能被修改

    int curX = x, curY = y;

    // 判断执行读操作期间，

    // 是否存在写操作，如果存在，

    // 则 sl.validate 返回 false

    if (!sl.validate(stamp)){

      // 升级为悲观读锁

      stamp = sl.readLock();

      try {

        curX = x;

        curY = y;

      } finally {

        // 释放悲观读锁

        sl.unlockRead(stamp);

      }

    }

    return Math.sqrt(

      curX * curX + curY * curY);

  }

}

在上面这个代码示例中，如果执行乐观读操作的期间，存在写操作，会把乐观读升级为悲观读锁。这个做法挺合理的，否则你就需要在一个循环里反复执行乐观读，直到执行乐观读操作的期间没有写操作（只有这样才能保证 x 和 y 的正确性和一致性），而循环读会浪费大量的 CPU。升级为悲观读锁，代码简练且不易出错，建议你在具体实践时也采用这样的方法。

进一步理解乐观读

如果你曾经用过数据库的乐观锁，可能会发现 StampedLock 的乐观读和数据库的乐观锁有异曲同工之妙。的确是这样的，就拿我个人来说，我是先接触的数据库里的乐观锁，然后才接触的 StampedLock，我就觉得我前期数据库里乐观锁的学习对于后面理解 StampedLock 的乐观读有很大帮助，所以这里有必要再介绍一下数据库里的乐观锁。

还记得我第一次使用数据库乐观锁的场景是这样的：在 ERP 的生产模块里，会有多个人通过 ERP 系统提供的 UI 同时修改同一条生产订单，那如何保证生产订单数据是并发安全的呢？我采用的方案就是乐观锁。

乐观锁的实现很简单，在生产订单的表 product_doc 里增加了一个数值型版本号字段 version，每次更新 product_doc 这个表的时候，都将 version 字段加 1。生产订单的 UI 在展示的时候，需要查询数据库，此时将这个 version 字段和其他业务字段一起返回给生产订单 UI。假设用户查询的生产订单的 id=777，那么 SQL 语句类似下面这样：

select id，... ，version

from product_doc

where id=777

用户在生产订单 UI 执行保存操作的时候，后台利用下面的 SQL 语句更新生产订单，此处我们假设该条生产订单的 version=9。

update product_doc

set version=version+1，...

where id=777 and version=9

如果这条 SQL 语句执行成功并且返回的条数等于 1，那么说明从生产订单 UI 执行查询操作到执行保存操作期间，没有其他人修改过这条数据。因为如果这期间其他人修改过这条数据，那么版本号字段一定会大于 9。

你会发现数据库里的乐观锁，查询的时候需要把 version 字段查出来，更新的时候要利用 version 字段做验证。这个 version 字段就类似于 StampedLock 里面的 stamp。这样对比着看，相信你会更容易理解 StampedLock 里乐观读的用法。

StampedLock 使用注意事项

对于读多写少的场景 StampedLock 性能很好，简单的应用场景基本上可以替代 ReadWriteLock，但是StampedLock 的功能仅仅是 ReadWriteLock 的子集，在使用的时候，还是有几个地方需要注意一下。

StampedLock 在命名上并没有增加 Reentrant，想必你已经猜测到 StampedLock 应该是不可重入的。事实上，的确是这样的，StampedLock 不支持重入。这个是在使用中必须要特别注意的。

另外，StampedLock 的悲观读锁、写锁都不支持条件变量，这个也需要你注意。

还有一点需要特别注意，那就是：如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时，此时调用该阻塞线程的 interrupt() 方法，会导致 CPU 飙升。例如下面的代码中，线程 T1 获取写锁之后将自己阻塞，线程 T2 尝试获取悲观读锁，也会阻塞；如果此时调用线程 T2 的 interrupt() 方法来中断线程 T2 的话，你会发现线程 T2 所在 CPU 会飙升到 100%。

final StampedLock lock

  = new StampedLock();

Thread T1 = new Thread(()->{

  // 获取写锁

  lock.writeLock();

  // 永远阻塞在此处，不释放写锁

  LockSupport.park();

});

T1.start();

// 保证 T1 获取写锁

Thread.sleep(100);

Thread T2 = new Thread(()->

  // 阻塞在悲观读锁

  lock.readLock()

);

T2.start();

// 保证 T2 阻塞在读锁

Thread.sleep(100);

// 中断线程 T2

// 会导致线程 T2 所在 CPU 飙升

T2.interrupt();

T2.join();

所以，使用 StampedLock 一定不要调用中断操作，如果需要支持中断功能，一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。这个规则一定要记清楚。

总结

StampedLock 的使用看上去有点复杂，但是如果你能理解乐观锁背后的原理，使用起来还是比较流畅的。建议你认真揣摩 Java 的官方示例，这个示例基本上就是一个最佳实践。我们把 Java 官方示例精简后，形成下面的代码模板，建议你在实际工作中尽量按照这个模板来使用 StampedLock。

StampedLock 读模板：

final StampedLock sl =

  new StampedLock();

// 乐观读

long stamp =

  sl.tryOptimisticRead();

// 读入方法局部变量

......

// 校验 stamp

if (!sl.validate(stamp)){

  // 升级为悲观读锁

  stamp = sl.readLock();

  try {

    // 读入方法局部变量

    .....

  } finally {

    // 释放悲观读锁

    sl.unlockRead(stamp);

  }

}

// 使用方法局部变量执行业务操作

......

StampedLock 写模板：

long stamp = sl.writeLock();

try {

  // 写共享变量

  ......

} finally {

  sl.unlockWrite(stamp);

}

课后思考

StampedLock 支持锁的降级（通过 tryConvertToReadLock() 方法实现）和升级（通过 tryConvertToWriteLock() 方法实现），但是建议你要慎重使用。下面的代码也源自 Java 的官方示例，我仅仅做了一点修改，隐藏了一个 Bug，你来看看 Bug 出在哪里吧。

private double x, y;

final StampedLock sl = new StampedLock();

// 存在问题的方法

void moveIfAtOrigin(double newX, double newY){

 long stamp = sl.readLock();

 try {

  while(x == 0.0 && y == 0.0){

    long ws = sl.tryConvertToWriteLock(stamp);

    if (ws != 0L) {

      x = newX;

      y = newY;

      break;

    } else {

      sl.unlockRead(stamp);

      stamp = sl.writeLock();

    }

  }

 } finally {

  sl.unlock(stamp);

}

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

19 | CountDownLatch和CyclicBarrier：如何让多线程步调一致？

前几天老板突然匆匆忙忙过来，说对账系统最近越来越慢了，能不能快速优化一下。我了解了对账系统的业务后，发现还是挺简单的，用户通过在线商城下单，会生成电子订单，保存在订单库；之后物流会生成派送单给用户发货，派送单保存在派送单库。为了防止漏派送或者重复派送，对账系统每天还会校验是否存在异常订单。

对账系统的处理逻辑很简单，你可以参考下面的对账系统流程图。目前对账系统的处理逻辑是首先查询订单，然后查询派送单，之后对比订单和派送单，将差异写入差异库。

对账系统流程图

对账系统的代码抽象之后，也很简单，核心代码如下，就是在一个单线程里面循环查询订单、派送单，然后执行对账，最后将写入差异库。

while(存在未对账订单){

  // 查询未对账订单

  pos = getPOrders();

  // 查询派送单

  dos = getDOrders();

  // 执行对账操作

  diff = check(pos, dos);

  // 差异写入差异库

  save(diff);

}

利用并行优化对账系统

老板要我优化性能，那我就首先要找到这个对账系统的瓶颈所在。

目前的对账系统，由于订单量和派送单量巨大，所以查询未对账订单 getPOrders() 和查询派送单 getDOrders() 相对较慢，那有没有办法快速优化一下呢？目前对账系统是单线程执行的，图形化后是下图这个样子。对于串行化的系统，优化性能首先想到的是能否利用多线程并行处理。

对账系统单线程执行示意图

所以，这里你应该能够看出来这个对账系统里的瓶颈：查询未对账订单 getPOrders() 和查询派送单 getDOrders() 是否可以并行处理呢？显然是可以的，因为这两个操作并没有先后顺序的依赖。这两个最耗时的操作并行之后，执行过程如下图所示。对比一下单线程的执行示意图，你会发现同等时间里，并行执行的吞吐量近乎单线程的 2 倍，优化效果还是相对明显的。

对账系统并行执行示意图

思路有了，下面我们再来看看如何用代码实现。在下面的代码中，我们创建了两个线程 T1 和 T2，并行执行查询未对账订单 getPOrders() 和查询派送单 getDOrders() 这两个操作。在主线程中执行对账操作 check() 和差异写入 save() 两个操作。不过需要注意的是：主线程需要等待线程 T1 和 T2 执行完才能执行 check() 和 save() 这两个操作，为此我们通过调用 T1.join() 和 T2.join() 来实现等待，当 T1 和 T2 线程退出时，调用 T1.join() 和 T2.join() 的主线程就会从阻塞态被唤醒，从而执行之后的 check() 和 save()。

while(存在未对账订单){

  // 查询未对账订单

  Thread T1 = new Thread(()->{

    pos = getPOrders();

  });

  T1.start();

  // 查询派送单

  Thread T2 = new Thread(()->{

    dos = getDOrders();

  });

  T2.start();

  // 等待 T1、T2 结束

  T1.join();

  T2.join();

  // 执行对账操作

  diff = check(pos, dos);

  // 差异写入差异库

  save(diff);

}

用 CountDownLatch 实现线程等待

经过上面的优化之后，基本上可以跟老板汇报收工了，但还是有点美中不足，相信你也发现了，while 循环里面每次都会创建新的线程，而创建线程可是个耗时的操作。所以最好是创建出来的线程能够循环利用，估计这时你已经想到线程池了，是的，线程池就能解决这个问题。

而下面的代码就是用线程池优化后的：我们首先创建了一个固定大小为 2 的线程池，之后在 while 循环里重复利用。一切看上去都很顺利，但是有个问题好像无解了，那就是主线程如何知道 getPOrders() 和 getDOrders() 这两个操作什么时候执行完。前面主线程通过调用线程 T1 和 T2 的 join() 方法来等待线程 T1 和 T2 退出，但是在线程池的方案里，线程根本就不会退出，所以 join() 方法已经失效了。

// 创建 2 个线程的线程池

Executor executor =

  Executors.newFixedThreadPool(2);

while(存在未对账订单){

  // 查询未对账订单

  executor.execute(()-> {

    pos = getPOrders();

  });

  // 查询派送单

  executor.execute(()-> {

    dos = getDOrders();

  });

  /* ？？如何实现等待？？*/

  // 执行对账操作

  diff = check(pos, dos);

  // 差异写入差异库

  save(diff);

}

那如何解决这个问题呢？你可以开动脑筋想出很多办法，最直接的办法是弄一个计数器，初始值设置成 2，当执行完pos = getPOrders();这个操作之后将计数器减 1，执行完dos = getDOrders();之后也将计数器减 1，在主线程里，等待计数器等于 0；当计数器等于 0 时，说明这两个查询操作执行完了。等待计数器等于 0 其实就是一个条件变量，用管程实现起来也很简单。

不过我并不建议你在实际项目中去实现上面的方案，因为 Java 并发包里已经提供了实现类似功能的工具类：CountDownLatch，我们直接使用就可以了。下面的代码示例中，在 while 循环里面，我们首先创建了一个 CountDownLatch，计数器的初始值等于 2，之后在pos = getPOrders();和dos = getDOrders();两条语句的后面对计数器执行减 1 操作，这个对计数器减 1 的操作是通过调用 latch.countDown(); 来实现的。在主线程中，我们通过调用 latch.await() 来实现对计数器等于 0 的等待。

// 创建 2 个线程的线程池

Executor executor =

  Executors.newFixedThreadPool(2);

while(存在未对账订单){

  // 计数器初始化为 2

  CountDownLatch latch =

    new CountDownLatch(2);

  // 查询未对账订单

  executor.execute(()-> {

    pos = getPOrders();

    latch.countDown();

  });

  // 查询派送单

  executor.execute(()-> {

    dos = getDOrders();

    latch.countDown();

  });

  // 等待两个查询操作结束

  latch.await();

  // 执行对账操作

  diff = check(pos, dos);

  // 差异写入差异库

  save(diff);

}

进一步优化性能

经过上面的重重优化之后，长出一口气，终于可以交付了。不过在交付之前还需要再次审视一番，看看还有没有优化的余地，仔细看还是有的。

前面我们将 getPOrders() 和 getDOrders() 这两个查询操作并行了，但这两个查询操作和对账操作 check()、save() 之间还是串行的。很显然，这两个查询操作和对账操作也是可以并行的，也就是说，在执行对账操作的时候，可以同时去执行下一轮的查询操作，这个过程可以形象化地表述为下面这幅示意图。

完全并行执行示意图

那接下来我们再来思考一下如何实现这步优化，两次查询操作能够和对账操作并行，对账操作还依赖查询操作的结果，这明显有点生产者 - 消费者的意思，两次查询操作是生产者，对账操作是消费者。既然是生产者 - 消费者模型，那就需要有个队列，来保存生产者生产的数据，而消费者则从这个队列消费数据。

不过针对对账这个项目，我设计了两个队列，并且两个队列的元素之间还有对应关系。具体如下图所示，订单查询操作将订单查询结果插入订单队列，派送单查询操作将派送单插入派送单队列，这两个队列的元素之间是有一一对应的关系的。两个队列的好处是，对账操作可以每次从订单队列出一个元素，从派送单队列出一个元素，然后对这两个元素执行对账操作，这样数据一定不会乱掉。

双队列示意图

下面再来看如何用双队列来实现完全的并行。一个最直接的想法是：一个线程 T1 执行订单的查询工作，一个线程 T2 执行派送单的查询工作，当线程 T1 和 T2 都各自生产完 1 条数据的时候，通知线程 T3 执行对账操作。这个想法虽看上去简单，但其实还隐藏着一个条件，那就是线程 T1 和线程 T2 的工作要步调一致，不能一个跑得太快，一个跑得太慢，只有这样才能做到各自生产完 1 条数据的时候，通知线程 T3。

下面这幅图形象地描述了上面的意图：线程 T1 和线程 T2 只有都生产完 1 条数据的时候，才能一起向下执行，也就是说，线程 T1 和线程 T2 要互相等待，步调要一致；同时当线程 T1 和 T2 都生产完一条数据的时候，还要能够通知线程 T3 执行对账操作。

同步执行示意图

用 CyclicBarrier 实现线程同步

下面我们就来实现上面提到的方案。这个方案的难点有两个：一个是线程 T1 和 T2 要做到步调一致，另一个是要能够通知到线程 T3。

你依然可以利用一个计数器来解决这两个难点，计数器初始化为 2，线程 T1 和 T2 生产完一条数据都将计数器减 1，如果计数器大于 0 则线程 T1 或者 T2 等待。如果计数器等于 0，则通知线程 T3，并唤醒等待的线程 T1 或者 T2，与此同时，将计数器重置为 2，这样线程 T1 和线程 T2 生产下一条数据的时候就可以继续使用这个计数器了。

同样，还是建议你不要在实际项目中这么做，因为 Java 并发包里也已经提供了相关的工具类：CyclicBarrier。在下面的代码中，我们首先创建了一个计数器初始值为 2 的 CyclicBarrier，你需要注意的是创建 CyclicBarrier 的时候，我们还传入了一个回调函数，当计数器减到 0 的时候，会调用这个回调函数。

线程 T1 负责查询订单，当查出一条时，调用 barrier.await() 来将计数器减 1，同时等待计数器变成 0；线程 T2 负责查询派送单，当查出一条时，也调用 barrier.await() 来将计数器减 1，同时等待计数器变成 0；当 T1 和 T2 都调用 barrier.await() 的时候，计数器会减到 0，此时 T1 和 T2 就可以执行下一条语句了，同时会调用 barrier 的回调函数来执行对账操作。

非常值得一提的是，CyclicBarrier 的计数器有自动重置的功能，当减到 0 的时候，会自动重置你设置的初始值。这个功能用起来实在是太方便了。

// 订单队列

Vector<P> pos;

// 派送单队列

Vector<D> dos;

// 执行回调的线程池

Executor executor =

  Executors.newFixedThreadPool(1);

final CyclicBarrier barrier =

  new CyclicBarrier(2, ()->{

    executor.execute(()->check());

  });

void check(){

  P p = pos.remove(0);

  D d = dos.remove(0);

  // 执行对账操作

  diff = check(p, d);

  // 差异写入差异库

  save(diff);

}

void checkAll(){

  // 循环查询订单库

  Thread T1 = new Thread(()->{

    while(存在未对账订单){

      // 查询订单库

      pos.add(getPOrders());

      // 等待

      barrier.await();

    }

  });

  T1.start();

  // 循环查询运单库

  Thread T2 = new Thread(()->{

    while(存在未对账订单){

      // 查询运单库

      dos.add(getDOrders());

      // 等待

      barrier.await();

    }

  });

  T2.start();

}

总结

CountDownLatch 和 CyclicBarrier 是 Java 并发包提供的两个非常易用的线程同步工具类，这两个工具类用法的区别在这里还是有必要再强调一下：CountDownLatch 主要用来解决一个线程等待多个线程的场景，可以类比旅游团团长要等待所有的游客到齐才能去下一个景点；而CyclicBarrier 是一组线程之间互相等待，更像是几个驴友之间不离不弃。除此之外 CountDownLatch 的计数器是不能循环利用的，也就是说一旦计数器减到 0，再有线程调用 await()，该线程会直接通过。但CyclicBarrier 的计数器是可以循环利用的，而且具备自动重置的功能，一旦计数器减到 0 会自动重置到你设置的初始值。除此之外，CyclicBarrier 还可以设置回调函数，可以说是功能丰富。

本章的示例代码中有两处用到了线程池，你现在只需要大概了解即可，因为线程池相关的知识咱们专栏后面还会有详细介绍。另外，线程池提供了 Future 特性，我们也可以利用 Future 特性来实现线程之间的等待，这个后面我们也会详细介绍。

课后思考

本章最后的示例代码中，CyclicBarrier 的回调函数我们使用了一个固定大小的线程池，你觉得是否有必要呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

20 | 并发容器：都有哪些“坑”需要我们填？

Java 并发包有很大一部分内容都是关于并发容器的，因此学习和搞懂这部分的内容很有必要。

Java 1.5 之前提供的同步容器虽然也能保证线程安全，但是性能很差，而 Java 1.5 版本之后提供的并发容器在性能方面则做了很多优化，并且容器的类型也更加丰富了。下面我们就对比二者来学习这部分的内容。

同步容器及其注意事项

Java 中的容器主要可以分为四个大类，分别是 List、Map、Set 和 Queue，但并不是所有的 Java 容器都是线程安全的。例如，我们常用的 ArrayList、HashMap 就不是线程安全的。在介绍线程安全的容器之前，我们先思考这样一个问题：如何将非线程安全的容器变成线程安全的容器？

在前面《12 | 如何用面向对象思想写好并发程序？》我们讲过实现思路其实很简单，只要把非线程安全的容器封装在对象内部，然后控制好访问路径就可以了。

下面我们就以 ArrayList 为例，看看如何将它变成线程安全的。在下面的代码中，SafeArrayList 内部持有一个 ArrayList 的实例 c，所有访问 c 的方法我们都增加了 synchronized 关键字，需要注意的是我们还增加了一个 addIfNotExist() 方法，这个方法也是用 synchronized 来保证原子性的。

SafeArrayList<T>{

  // 封装 ArrayList

  List<T> c = new ArrayList<>();

  // 控制访问路径

  synchronized

  T get(int idx){

    return c.get(idx);

  }

  synchronized

  void add(int idx, T t) {

    c.add(idx, t);

  }

  synchronized

  boolean addIfNotExist(T t){

    if(!c.contains(t)) {

      c.add(t);

      return true;

    }

    return false;

  }

}

看到这里，你可能会举一反三，然后想到：所有非线程安全的类是不是都可以用这种包装的方式来实现线程安全呢？其实这一点不止你想到了，Java SDK 的开发人员也想到了，所以他们在 Collections 这个类中还提供了一套完备的包装类，比如下面的示例代码中，分别把 ArrayList、HashSet 和 HashMap 包装成了线程安全的 List、Set 和 Map。

List list = Collections.

  synchronizedList(new ArrayList());

Set set = Collections.

  synchronizedSet(new HashSet());

Map map = Collections.

  synchronizedMap(new HashMap());

我们曾经多次强调，组合操作需要注意竞态条件问题，例如上面提到的 addIfNotExist() 方法就包含组合操作。组合操作往往隐藏着竞态条件问题，即便每个操作都能保证原子性，也并不能保证组合操作的原子性，这个一定要注意。

在容器领域一个容易被忽视的“坑”是用迭代器遍历容器，例如在下面的代码中，通过迭代器遍历容器 list，对每个元素调用 foo() 方法，这就存在并发问题，这些组合的操作不具备原子性。

List list = Collections.

  synchronizedList(new ArrayList());

Iterator i = list.iterator();

while (i.hasNext())

  foo(i.next());

而正确做法是下面这样，锁住 list 之后再执行遍历操作。如果你查看 Collections 内部的包装类源码，你会发现包装类的公共方法锁的是对象的 this，其实就是我们这里的 list，所以锁住 list 绝对是线程安全的。

List list = Collections.

  synchronizedList(new ArrayList());

synchronized (list) {

  Iterator i = list.iterator();

  while (i.hasNext())

    foo(i.next());

}

上面我们提到的这些经过包装后线程安全容器，都是基于 synchronized 这个同步关键字实现的，所以也被称为同步容器。Java 提供的同步容器还有 Vector、Stack 和 Hashtable，这三个容器不是基于包装类实现的，但同样是基于 synchronized 实现的，对这三个容器的遍历，同样要加锁保证互斥。

并发容器及其注意事项

Java 在 1.5 版本之前所谓的线程安全的容器，主要指的就是同步容器。不过同步容器有个最大的问题，那就是性能差，所有方法都用 synchronized 来保证互斥，串行度太高了。因此 Java 在 1.5 及之后版本提供了性能更高的容器，我们一般称为并发容器。

并发容器虽然数量非常多，但依然是前面我们提到的四大类：List、Map、Set 和 Queue，下面的并发容器关系图，基本上把我们经常用的容器都覆盖到了。

并发容器关系图

鉴于并发容器的数量太多，再加上篇幅限制，所以我并不会一一详细介绍它们的用法，只是把关键点介绍一下。

（一）List

List 里面只有一个实现类就是CopyOnWriteArrayList。CopyOnWrite，顾名思义就是写的时候会将共享变量新复制一份出来，这样做的好处是读操作完全无锁。

那 CopyOnWriteArrayList 的实现原理是怎样的呢？下面我们就来简单介绍一下

CopyOnWriteArrayList 内部维护了一个数组，成员变量 array 就指向这个内部数组，所有的读操作都是基于 array 进行的，如下图所示，迭代器 Iterator 遍历的就是 array 数组。

执行迭代的内部结构图

如果在遍历 array 的同时，还有一个写操作，例如增加元素，CopyOnWriteArrayList 是如何处理的呢？CopyOnWriteArrayList 会将 array 复制一份，然后在新复制处理的数组上执行增加元素的操作，执行完之后再将 array 指向这个新的数组。通过下图你可以看到，读写是可以并行的，遍历操作一直都是基于原 array 执行，而写操作则是基于新 array 进行。

执行增加元素的内部结构图

使用 CopyOnWriteArrayList 需要注意的“坑”主要有两个方面。一个是应用场景，CopyOnWriteArrayList 仅适用于写操作非常少的场景，而且能够容忍读写的短暂不一致。例如上面的例子中，写入的新元素并不能立刻被遍历到。另一个需要注意的是，CopyOnWriteArrayList 迭代器是只读的，不支持增删改。因为迭代器遍历的仅仅是一个快照，而对快照进行增删改是没有意义的。

（二）Map

Map 接口的两个实现是 ConcurrentHashMap 和 ConcurrentSkipListMap，它们从应用的角度来看，主要区别在于ConcurrentHashMap 的 key 是无序的，而 ConcurrentSkipListMap 的 key 是有序的。所以如果你需要保证 key 的顺序，就只能使用 ConcurrentSkipListMap。

使用 ConcurrentHashMap 和 ConcurrentSkipListMap 需要注意的地方是，它们的 key 和 value 都不能为空，否则会抛出NullPointerException这个运行时异常。下面这个表格总结了 Map 相关的实现类对于 key 和 value 的要求，你可以对比学习。

ConcurrentSkipListMap 里面的 SkipList 本身就是一种数据结构，中文一般都翻译为“跳表”。跳表插入、删除、查询操作平均的时间复杂度是 O(log n)，理论上和并发线程数没有关系，所以在并发度非常高的情况下，若你对 ConcurrentHashMap 的性能还不满意，可以尝试一下 ConcurrentSkipListMap。

（三）Set

Set 接口的两个实现是 CopyOnWriteArraySet 和 ConcurrentSkipListSet，使用场景可以参考前面讲述的 CopyOnWriteArrayList 和 ConcurrentSkipListMap，它们的原理都是一样的，这里就不再赘述了。

（四）Queue

Java 并发包里面 Queue 这类并发容器是最复杂的，你可以从以下两个维度来分类。一个维度是阻塞与非阻塞，所谓阻塞指的是当队列已满时，入队操作阻塞；当队列已空时，出队操作阻塞。另一个维度是单端与双端，单端指的是只能队尾入队，队首出队；而双端指的是队首队尾皆可入队出队。Java 并发包里阻塞队列都用 Blocking 关键字标识，单端队列使用 Queue 标识，双端队列使用 Deque 标识。

这两个维度组合后，可以将 Queue 细分为四大类，分别是：

1.单端阻塞队列：其实现有 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue 和 DelayQueue。内部一般会持有一个队列，这个队列可以是数组（其实现是 ArrayBlockingQueue）也可以是链表（其实现是 LinkedBlockingQueue）；甚至还可以不持有队列（其实现是 SynchronousQueue），此时生产者线程的入队操作必须等待消费者线程的出队操作。而 LinkedTransferQueue 融合 LinkedBlockingQueue 和 SynchronousQueue 的功能，性能比 LinkedBlockingQueue 更好；PriorityBlockingQueue 支持按照优先级出队；DelayQueue 支持延时出队。

单端阻塞队列示意图

2.双端阻塞队列：其实现是 LinkedBlockingDeque。

双端阻塞队列示意图

3.单端非阻塞队列：其实现是 ConcurrentLinkedQueue。
4.双端非阻塞队列：其实现是 ConcurrentLinkedDeque。

另外，使用队列时，需要格外注意队列是否支持有界（所谓有界指的是内部的队列是否有容量限制）。实际工作中，一般都不建议使用无界的队列，因为数据量大了之后很容易导致 OOM。上面我们提到的这些 Queue 中，只有 ArrayBlockingQueue 和 LinkedBlockingQueue 是支持有界的，所以在使用其他无界队列时，一定要充分考虑是否存在导致 OOM 的隐患。

总结

Java 并发容器的内容很多，但鉴于篇幅有限，我们只是对一些关键点进行了梳理和介绍。

而在实际工作中，你不单要清楚每种容器的特性，还要能选对容器，这才是关键，至于每种容器的用法，用的时候看一下 API 说明就可以了，这些容器的使用都不难。在文中，我们甚至都没有介绍 Java 容器的快速失败机制（Fail-Fast），原因就在于当你选对容器的时候，根本不会触发它。

课后思考

线上系统 CPU 突然飙升，你怀疑有同学在并发场景里使用了 HashMap，因为在 1.8 之前的版本里并发执行 HashMap.put() 可能会导致 CPU 飙升到 100%，你觉得该如何验证你的猜测呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

21 | 原子类：无锁工具类的典范

前面我们多次提到一个累加器的例子，示例代码如下。在这个例子中，add10K() 这个方法不是线程安全的，问题就出在变量 count 的可见性和 count+=1 的原子性上。可见性问题可以用 volatile 来解决，而原子性问题我们前面一直都是采用的互斥锁方案。

public class Test {

  long count = 0;

  void add10K() {

    int idx = 0;

    while(idx++ < 10000) {

      count += 1;

    }

  }

}

其实对于简单的原子性问题，还有一种无锁方案。Java SDK 并发包将这种无锁方案封装提炼之后，实现了一系列的原子类。不过，在深入介绍原子类的实现之前，我们先看看如何利用原子类解决累加器问题，这样你会对原子类有个初步的认识。

在下面的代码中，我们将原来的 long 型变量 count 替换为了原子类 AtomicLong，原来的 count +=1 替换成了 count.getAndIncrement()，仅需要这两处简单的改动就能使 add10K() 方法变成线程安全的，原子类的使用还是挺简单的。

public class Test {

  AtomicLong count =

    new AtomicLong(0);

  void add10K() {

    int idx = 0;

    while(idx++ < 10000) {

      count.getAndIncrement();

    }

  }

}

无锁方案相对互斥锁方案，最大的好处就是性能。互斥锁方案为了保证互斥性，需要执行加锁、解锁操作，而加锁、解锁操作本身就消耗性能；同时拿不到锁的线程还会进入阻塞状态，进而触发线程切换，线程切换对性能的消耗也很大。 相比之下，无锁方案则完全没有加锁、解锁的性能消耗，同时还能保证互斥性，既解决了问题，又没有带来新的问题，可谓绝佳方案。那它是如何做到的呢？

无锁方案的实现原理

其实原子类性能高的秘密很简单，硬件支持而已。CPU 为了解决并发问题，提供了 CAS 指令（CAS，全称是 Compare And Swap，即“比较并交换”）。CAS 指令包含 3 个参数：共享变量的内存地址 A、用于比较的值 B 和共享变量的新值 C；并且只有当内存中地址 A 处的值等于 B 时，才能将内存中地址 A 处的值更新为新值 C。作为一条 CPU 指令，CAS 指令本身是能够保证原子性的。

你可以通过下面 CAS 指令的模拟代码来理解 CAS 的工作原理。在下面的模拟程序中有两个参数，一个是期望值 expect，另一个是需要写入的新值 newValue，只有当目前 count 的值和期望值 expect 相等时，才会将 count 更新为 newValue。

class SimulatedCAS{

  int count；

  synchronized int cas(

    int expect, int newValue){

    // 读目前 count 的值

    int curValue = count;

    // 比较目前 count 值是否 == 期望值

    if(curValue == expect){

      // 如果是，则更新 count 的值

      count = newValue;

    }

    // 返回写入前的值

    return curValue;

  }

}

你仔细地再次思考一下这句话，“只有当目前 count 的值和期望值 expect 相等时，才会将 count 更新为 newValue。”要怎么理解这句话呢？

对于前面提到的累加器的例子，count += 1 的一个核心问题是：基于内存中 count 的当前值 A 计算出来的 count+=1 为 A+1，在将 A+1 写入内存的时候，很可能此时内存中 count 已经被其他线程更新过了，这样就会导致错误地覆盖其他线程写入的值（如果你觉得理解起来还有困难，建议你再重新看看《01 | 可见性、原子性和有序性问题：并发编程 Bug 的源头》）。也就是说，只有当内存中 count 的值等于期望值 A 时，才能将内存中 count 的值更新为计算结果 A+1，这不就是 CAS 的语义吗！

使用 CAS 来解决并发问题，一般都会伴随着自旋，而所谓自旋，其实就是循环尝试。例如，实现一个线程安全的count += 1操作，“CAS+ 自旋”的实现方案如下所示，首先计算 newValue = count+1，如果 cas(count,newValue) 返回的值不等于 count，则意味着线程在执行完代码①处之后，执行代码②处之前，count 的值被其他线程更新过。那此时该怎么处理呢？可以采用自旋方案，就像下面代码中展示的，可以重新读 count 最新的值来计算 newValue 并尝试再次更新，直到成功。

class SimulatedCAS{

  volatile int count;

  // 实现 count+=1

  addOne(){

    do {

      newValue = count+1; //①

    }while(count !=

      cas(count,newValue) //②

  }

  // 模拟实现 CAS，仅用来帮助理解

  synchronized int cas(

    int expect, int newValue){

    // 读目前 count 的值

    int curValue = count;

    // 比较目前 count 值是否 == 期望值

    if(curValue == expect){

      // 如果是，则更新 count 的值

      count= newValue;

    }

    // 返回写入前的值

    return curValue;

  }

}

通过上面的示例代码，想必你已经发现了，CAS 这种无锁方案，完全没有加锁、解锁操作，即便两个线程完全同时执行 addOne() 方法，也不会有线程被阻塞，所以相对于互斥锁方案来说，性能好了很多。

但是在 CAS 方案中，有一个问题可能会常被你忽略，那就是ABA的问题。什么是 ABA 问题呢？

前面我们提到“如果 cas(count,newValue) 返回的值不等于count，意味着线程在执行完代码①处之后，执行代码②处之前，count 的值被其他线程更新过”，那如果 cas(count,newValue) 返回的值等于count，是否就能够认为 count 的值没有被其他线程更新过呢？显然不是的，假设 count 原本是 A，线程 T1 在执行完代码①处之后，执行代码②处之前，有可能 count 被线程 T2 更新成了 B，之后又被 T3 更新回了 A，这样线程 T1 虽然看到的一直是 A，但是其实已经被其他线程更新过了，这就是 ABA 问题。

可能大多数情况下我们并不关心 ABA 问题，例如数值的原子递增，但也不能所有情况下都不关心，例如原子化的更新对象很可能就需要关心 ABA 问题，因为两个 A 虽然相等，但是第二个 A 的属性可能已经发生变化了。所以在使用 CAS 方案的时候，一定要先 check 一下。

看 Java 如何实现原子化的 count += 1

在本文开始部分，我们使用原子类 AtomicLong 的 getAndIncrement() 方法替代了count += 1，从而实现了线程安全。原子类 AtomicLong 的 getAndIncrement() 方法内部就是基于 CAS 实现的，下面我们来看看 Java 是如何使用 CAS 来实现原子化的count += 1的。

在 Java 1.8 版本中，getAndIncrement() 方法会转调 unsafe.getAndAddLong() 方法。这里 this 和 valueOffset 两个参数可以唯一确定共享变量的内存地址。

final long getAndIncrement() {

  return unsafe.getAndAddLong(

    this, valueOffset, 1L);

}

unsafe.getAndAddLong() 方法的源码如下，该方法首先会在内存中读取共享变量的值，之后循环调用 compareAndSwapLong() 方法来尝试设置共享变量的值，直到成功为止。compareAndSwapLong() 是一个 native 方法，只有当内存中共享变量的值等于 expected 时，才会将共享变量的值更新为 x，并且返回 true；否则返回 fasle。compareAndSwapLong 的语义和 CAS 指令的语义的差别仅仅是返回值不同而已。

public final long getAndAddLong(

  Object o, long offset, long delta){

  long v;

  do {

    // 读取内存中的值

    v = getLongVolatile(o, offset);

  } while (!compareAndSwapLong(

      o, offset, v, v + delta));

  return v;

}

// 原子性地将变量更新为 x

// 条件是内存中的值等于 expected

// 更新成功则返回 true

native boolean compareAndSwapLong(

  Object o, long offset,

  long expected,

  long x);

另外，需要你注意的是，getAndAddLong() 方法的实现，基本上就是 CAS 使用的经典范例。所以请你再次体会下面这段抽象后的代码片段，它在很多无锁程序中经常出现。Java 提供的原子类里面 CAS 一般被实现为 compareAndSet()，compareAndSet() 的语义和 CAS 指令的语义的差别仅仅是返回值不同而已，compareAndSet() 里面如果更新成功，则会返回 true，否则返回 false。

do {

  // 获取当前值

  oldV = xxxx；

  // 根据当前值计算新值

  newV = ...oldV...

}while(!compareAndSet(oldV,newV);

原子类概览

Java SDK 并发包里提供的原子类内容很丰富，我们可以将它们分为五个类别：原子化的基本数据类型、原子化的对象引用类型、原子化数组、原子化对象属性更新器和原子化的累加器。这五个类别提供的方法基本上是相似的，并且每个类别都有若干原子类，你可以通过下面的原子类组成概览图来获得一个全局的印象。下面我们详细解读这五个类别。

原子类组成概览图

1. 原子化的基本数据类型

相关实现有 AtomicBoolean、AtomicInteger 和 AtomicLong，提供的方法主要有以下这些，详情你可以参考 SDK 的源代码，都很简单，这里就不详细介绍了。

getAndIncrement() // 原子化 i++

getAndDecrement() // 原子化的 i--

incrementAndGet() // 原子化的 ++i

decrementAndGet() // 原子化的 --i

// 当前值 +=delta，返回 += 前的值

getAndAdd(delta)

// 当前值 +=delta，返回 += 后的值

addAndGet(delta)

//CAS 操作，返回是否成功

compareAndSet(expect, update)

// 以下四个方法

// 新值可以通过传入 func 函数来计算

getAndUpdate(func)

updateAndGet(func)

getAndAccumulate(x,func)

accumulateAndGet(x,func)

2. 原子化的对象引用类型

相关实现有 AtomicReference、AtomicStampedReference 和 AtomicMarkableReference，利用它们可以实现对象引用的原子化更新。AtomicReference 提供的方法和原子化的基本数据类型差不多，这里不再赘述。不过需要注意的是，对象引用的更新需要重点关注 ABA 问题，AtomicStampedReference 和 AtomicMarkableReference 这两个原子类可以解决 ABA 问题。

解决 ABA 问题的思路其实很简单，增加一个版本号维度就可以了，这个和我们在《18 | StampedLock：有没有比读写锁更快的锁？》介绍的乐观锁机制很类似，每次执行 CAS 操作，附加再更新一个版本号，只要保证版本号是递增的，那么即便 A 变成 B 之后再变回 A，版本号也不会变回来（版本号递增的）。AtomicStampedReference 实现的 CAS 方法就增加了版本号参数，方法签名如下：

boolean compareAndSet(

  V expectedReference,

  V newReference,

  int expectedStamp,

  int newStamp)

AtomicMarkableReference 的实现机制则更简单，将版本号简化成了一个 Boolean 值，方法签名如下：

boolean compareAndSet(

  V expectedReference,

  V newReference,

  boolean expectedMark,

  boolean newMark)

3. 原子化数组

相关实现有 AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray，利用这些原子类，我们可以原子化地更新数组里面的每一个元素。这些类提供的方法和原子化的基本数据类型的区别仅仅是：每个方法多了一个数组的索引参数，所以这里也不再赘述了。

4. 原子化对象属性更新器

相关实现有 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和 AtomicReferenceFieldUpdater，利用它们可以原子化地更新对象的属性，这三个方法都是利用反射机制实现的，创建更新器的方法如下：

public static <U>

AtomicXXXFieldUpdater<U>

newUpdater(Class<U> tclass,

  String fieldName)

需要注意的是，对象属性必须是 volatile 类型的，只有这样才能保证可见性；如果对象属性不是 volatile 类型的，newUpdater() 方法会抛出 IllegalArgumentException 这个运行时异常。

你会发现 newUpdater() 的方法参数只有类的信息，没有对象的引用，而更新对象的属性，一定需要对象的引用，那这个参数是在哪里传入的呢？是在原子操作的方法参数中传入的。例如 compareAndSet() 这个原子操作，相比原子化的基本数据类型多了一个对象引用 obj。原子化对象属性更新器相关的方法，相比原子化的基本数据类型仅仅是多了对象引用参数，所以这里也不再赘述了。

boolean compareAndSet(

  T obj,

  int expect,

  int update)

5. 原子化的累加器

DoubleAccumulator、DoubleAdder、LongAccumulator 和 LongAdder，这四个类仅仅用来执行累加操作，相比原子化的基本数据类型，速度更快，但是不支持 compareAndSet() 方法。如果你仅仅需要累加操作，使用原子化的累加器性能会更好。

总结

无锁方案相对于互斥锁方案，优点非常多，首先性能好，其次是基本不会出现死锁问题（但可能出现饥饿和活锁问题，因为自旋会反复重试）。Java 提供的原子类大部分都实现了 compareAndSet() 方法，基于 compareAndSet() 方法，你可以构建自己的无锁数据结构，但是建议你不要这样做，这个工作最好还是让大师们去完成，原因是无锁算法没你想象的那么简单。

Java 提供的原子类能够解决一些简单的原子性问题，但你可能会发现，上面我们所有原子类的方法都是针对一个共享变量的，如果你需要解决多个变量的原子性问题，建议还是使用互斥锁方案。原子类虽好，但使用要慎之又慎。

课后思考

下面的示例代码是合理库存的原子化实现，仅实现了设置库存上限 setUpper() 方法，你觉得 setUpper() 方法的实现是否正确呢？

public class SafeWM {

  class WMRange{

    final int upper;

    final int lower;

    WMRange(int upper,int lower){

    // 省略构造函数实现

    }

  }

  final AtomicReference<WMRange>

    rf = new AtomicReference<>(

      new WMRange(0,0)

    );

  // 设置库存上限

  void setUpper(int v){

    WMRange nr;

    WMRange or = rf.get();

    do{

      // 检查参数合法性

      if(v < or.lower){

        throw new IllegalArgumentException();

      }

      nr = new

        WMRange(v, or.lower);

    }while(!rf.compareAndSet(or, nr));

  }

}

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

22 | Executor与线程池：如何创建正确的线程池？

虽然在 Java 语言中创建线程看上去就像创建一个对象一样简单，只需要 new Thread() 就可以了，但实际上创建线程远不是创建一个对象那么简单。创建对象，仅仅是在 JVM 的堆里分配一块内存而已；而创建一个线程，却需要调用操作系统内核的 API，然后操作系统要为线程分配一系列的资源，这个成本就很高了，所以线程是一个重量级的对象，应该避免频繁创建和销毁。

那如何避免呢？应对方案估计你已经知道了，那就是线程池。

线程池的需求是如此普遍，所以 Java SDK 并发包自然也少不了它。但是很多人在初次接触并发包里线程池相关的工具类时，多少会都有点蒙，不知道该从哪里入手，我觉得根本原因在于线程池和一般意义上的池化资源是不同的。一般意义上的池化资源，都是下面这样，当你需要资源的时候就调用 acquire() 方法来申请资源，用完之后就调用 release() 释放资源。若你带着这个固有模型来看并发包里线程池相关的工具类时，会很遗憾地发现它们完全匹配不上，Java 提供的线程池里面压根就没有申请线程和释放线程的方法。

class XXXPool{

  // 获取池化资源

  XXX acquire() {

  }

  // 释放池化资源

  void release(XXX x){

  }

}

线程池是一种生产者 - 消费者模式

为什么线程池没有采用一般意义上池化资源的设计方法呢？如果线程池采用一般意义上池化资源的设计方法，应该是下面示例代码这样。你可以来思考一下，假设我们获取到一个空闲线程 T1，然后该如何使用 T1 呢？你期望的可能是这样：通过调用 T1 的 execute() 方法，传入一个 Runnable 对象来执行具体业务逻辑，就像通过构造函数 Thread(Runnable target) 创建线程一样。可惜的是，你翻遍 Thread 对象的所有方法，都不存在类似 execute(Runnable target) 这样的公共方法。

// 采用一般意义上池化资源的设计方法

class ThreadPool{

  // 获取空闲线程

  Thread acquire() {

  }

  // 释放线程

  void release(Thread t){

  }

}

// 期望的使用

ThreadPool pool；

Thread T1=pool.acquire();

// 传入 Runnable 对象

T1.execute(()->{

  // 具体业务逻辑

  ......

});

所以，线程池的设计，没有办法直接采用一般意义上池化资源的设计方法。那线程池该如何设计呢？目前业界线程池的设计，普遍采用的都是生产者 - 消费者模式。线程池的使用方是生产者，线程池本身是消费者。在下面的示例代码中，我们创建了一个非常简单的线程池 MyThreadPool，你可以通过它来理解线程池的工作原理。

// 简化的线程池，仅用来说明工作原理

class MyThreadPool{

  // 利用阻塞队列实现生产者 - 消费者模式

  BlockingQueue<Runnable> workQueue;

  // 保存内部工作线程

  List<WorkerThread> threads

    = new ArrayList<>();

  // 构造方法

  MyThreadPool(int poolSize,

    BlockingQueue<Runnable> workQueue){

    this.workQueue = workQueue;

    // 创建工作线程

    for(int idx=0; idx<poolSize; idx++){

      WorkerThread work = new WorkerThread();

      work.start();

      threads.add(work);

    }

  }

  // 提交任务

  void execute(Runnable command){

    workQueue.put(command);

  }

  // 工作线程负责消费任务，并执行任务

  class WorkerThread extends Thread{

    public void run() {

      // 循环取任务并执行

      while(true){ ①

        Runnable task = workQueue.take();

        task.run();

      }

    }

  }

}

/** 下面是使用示例 **/

// 创建有界阻塞队列

BlockingQueue<Runnable> workQueue =

  new LinkedBlockingQueue<>(2);

// 创建线程池

MyThreadPool pool = new MyThreadPool(

  10, workQueue);

// 提交任务

pool.execute(()->{

    System.out.println("hello");

});

在 MyThreadPool 的内部，我们维护了一个阻塞队列 workQueue 和一组工作线程，工作线程的个数由构造函数中的 poolSize 来指定。用户通过调用 execute() 方法来提交 Runnable 任务，execute() 方法的内部实现仅仅是将任务加入到 workQueue 中。MyThreadPool 内部维护的工作线程会消费 workQueue 中的任务并执行任务，相关的代码就是代码①处的 while 循环。线程池主要的工作原理就这些，是不是还挺简单的？

如何使用 Java 中的线程池

Java 并发包里提供的线程池，远比我们上面的示例代码强大得多，当然也复杂得多。Java 提供的线程池相关的工具类中，最核心的是ThreadPoolExecutor，通过名字你也能看出来，它强调的是 Executor，而不是一般意义上的池化资源。

ThreadPoolExecutor 的构造函数非常复杂，如下面代码所示，这个最完备的构造函数有 7 个参数。

ThreadPoolExecutor(

  int corePoolSize,

  int maximumPoolSize,

  long keepAliveTime,

  TimeUnit unit,

  BlockingQueue<Runnable> workQueue,

  ThreadFactory threadFactory,

  RejectedExecutionHandler handler)

下面我们一一介绍这些参数的意义，你可以把线程池类比为一个项目组，而线程就是项目组的成员。

corePoolSize：表示线程池保有的最小线程数。有些项目很闲，但是也不能把人都撤了，至少要留 corePoolSize 个人坚守阵地。

maximumPoolSize：表示线程池创建的最大线程数。当项目很忙时，就需要加人，但是也不能无限制地加，最多就加到 maximumPoolSize 个人。当项目闲下来时，就要撤人了，最多能撤到 corePoolSize 个人。

keepAliveTime & unit：上面提到项目根据忙闲来增减人员，那在编程世界里，如何定义忙和闲呢？很简单，一个线程如果在一段时间内，都没有执行任务，说明很闲，keepAliveTime 和 unit 就是用来定义这个“一段时间”的参数。也就是说，如果一个线程空闲了keepAliveTime & unit这么久，而且线程池的线程数大于 corePoolSize ，那么这个空闲的线程就要被回收了。

workQueue：工作队列，和上面示例代码的工作队列同义。

threadFactory：通过这个参数你可以自定义如何创建线程，例如你可以给线程指定一个有意义的名字。

handler：通过这个参数你可以自定义任务的拒绝策略。如果线程池中所有的线程都在忙碌，并且工作队列也满了（前提是工作队列是有界队列），那么此时提交任务，线程池就会拒绝接收。至于拒绝的策略，你可以通过 handler 这个参数来指定。ThreadPoolExecutor 已经提供了以下 4 种策略。

CallerRunsPolicy：提交任务的线程自己去执行该任务。

AbortPolicy：默认的拒绝策略，会 throws RejectedExecutionException。

DiscardPolicy：直接丢弃任务，没有任何异常抛出。

DiscardOldestPolicy：丢弃最老的任务，其实就是把最早进入工作队列的任务丢弃，然后把新任务加入到工作队列。

Java 在 1.6 版本还增加了 allowCoreThreadTimeOut(boolean value) 方法，它可以让所有线程都支持超时，这意味着如果项目很闲，就会将项目组的成员都撤走。

使用线程池要注意些什么

考虑到 ThreadPoolExecutor 的构造函数实在是有些复杂，所以 Java 并发包里提供了一个线程池的静态工厂类 Executors，利用 Executors 你可以快速创建线程池。不过目前大厂的编码规范中基本上都不建议使用 Executors 了，所以这里我就不再花篇幅介绍了。

不建议使用 Executors 的最重要的原因是：Executors 提供的很多方法默认使用的都是无界的 LinkedBlockingQueue，高负载情境下，无界队列很容易导致 OOM，而 OOM 会导致所有请求都无法处理，这是致命问题。所以强烈建议使用有界队列。

使用有界队列，当任务过多时，线程池会触发执行拒绝策略，线程池默认的拒绝策略会 throw RejectedExecutionException 这是个运行时异常，对于运行时异常编译器并不强制 catch 它，所以开发人员很容易忽略。因此默认拒绝策略要慎重使用。如果线程池处理的任务非常重要，建议自定义自己的拒绝策略；并且在实际工作中，自定义的拒绝策略往往和降级策略配合使用。

使用线程池，还要注意异常处理的问题，例如通过 ThreadPoolExecutor 对象的 execute() 方法提交任务时，如果任务在执行的过程中出现运行时异常，会导致执行任务的线程终止；不过，最致命的是任务虽然异常了，但是你却获取不到任何通知，这会让你误以为任务都执行得很正常。虽然线程池提供了很多用于异常处理的方法，但是最稳妥和简单的方案还是捕获所有异常并按需处理，你可以参考下面的示例代码。

try {

  // 业务逻辑

} catch (RuntimeException x) {

  // 按需处理

} catch (Throwable x) {

  // 按需处理

}

总结

线程池在 Java 并发编程领域非常重要，很多大厂的编码规范都要求必须通过线程池来管理线程。线程池和普通的池化资源有很大不同，线程池实际上是生产者 - 消费者模式的一种实现，理解生产者 - 消费者模式是理解线程池的关键所在。

创建线程池设置合适的线程数非常重要，这部分内容，你可以参考《10 | Java 线程（中）：创建多少线程才是合适的？》的内容。另外《Java 并发编程实战》的第 7 章《取消与关闭》的 7.3 节“处理非正常的线程终止” 详细介绍了异常处理的方案，第 8 章《线程池的使用》对线程池的使用也有更深入的介绍，如果你感兴趣或有需要的话，建议你仔细阅读。

课后思考

使用线程池，默认情况下创建的线程名字都类似pool-1-thread-2这样，没有业务含义。而很多情况下为了便于诊断问题，都需要给线程赋予一个有意义的名字，那你知道有哪些办法可以给线程池里的线程指定名字吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

23 | Future：如何用多线程实现最优的“烧水泡茶”程序？

在上一篇文章《22 | Executor 与线程池：如何创建正确的线程池？》中，我们详细介绍了如何创建正确的线程池，那创建完线程池，我们该如何使用呢？在上一篇文章中，我们仅仅介绍了 ThreadPoolExecutor 的 void execute(Runnable command) 方法，利用这个方法虽然可以提交任务，但是却没有办法获取任务的执行结果（execute() 方法没有返回值）。而很多场景下，我们又都是需要获取任务的执行结果的。那 ThreadPoolExecutor 是否提供了相关功能呢？必须的，这么重要的功能当然需要提供了。

下面我们就来介绍一下使用 ThreadPoolExecutor 的时候，如何获取任务执行结果。

如何获取任务执行结果

Java 通过 ThreadPoolExecutor 提供的 3 个 submit() 方法和 1 个 FutureTask 工具类来支持获得任务执行结果的需求。下面我们先来介绍这 3 个 submit() 方法，这 3 个方法的方法签名如下。

// 提交 Runnable 任务

Future<?>

  submit(Runnable task);

// 提交 Callable 任务

<T> Future<T>

  submit(Callable<T> task);

// 提交 Runnable 任务及结果引用

<T> Future<T>

  submit(Runnable task, T result);

你会发现它们的返回值都是 Future 接口，Future 接口有 5 个方法，我都列在下面了，它们分别是取消任务的方法 cancel()、判断任务是否已取消的方法 isCancelled()、判断任务是否已结束的方法 isDone()以及2 个获得任务执行结果的 get() 和 get(timeout, unit)，其中最后一个 get(timeout, unit) 支持超时机制。通过 Future 接口的这 5 个方法你会发现，我们提交的任务不但能够获取任务执行结果，还可以取消任务。不过需要注意的是：这两个 get() 方法都是阻塞式的，如果被调用的时候，任务还没有执行完，那么调用 get() 方法的线程会阻塞，直到任务执行完才会被唤醒。

// 取消任务

boolean cancel(

  boolean mayInterruptIfRunning);

// 判断任务是否已取消

boolean isCancelled();

// 判断任务是否已结束

boolean isDone();

// 获得任务执行结果

get();

// 获得任务执行结果，支持超时

get(long timeout, TimeUnit unit);

这 3 个 submit() 方法之间的区别在于方法参数不同，下面我们简要介绍一下。

提交 Runnable 任务 submit(Runnable task) ：这个方法的参数是一个 Runnable 接口，Runnable 接口的 run() 方法是没有返回值的，所以 submit(Runnable task) 这个方法返回的 Future 仅可以用来断言任务已经结束了，类似于 Thread.join()。

提交 Callable 任务 submit(Callable<T> task)：这个方法的参数是一个 Callable 接口，它只有一个 call() 方法，并且这个方法是有返回值的，所以这个方法返回的 Future 对象可以通过调用其 get() 方法来获取任务的执行结果。

提交 Runnable 任务及结果引用 submit(Runnable task, T result)：这个方法很有意思，假设这个方法返回的 Future 对象是 f，f.get() 的返回值就是传给 submit() 方法的参数 result。这个方法该怎么用呢？下面这段示例代码展示了它的经典用法。需要你注意的是 Runnable 接口的实现类 Task 声明了一个有参构造函数 Task(Result r) ，创建 Task 对象的时候传入了 result 对象，这样就能在类 Task 的 run() 方法中对 result 进行各种操作了。result 相当于主线程和子线程之间的桥梁，通过它主子线程可以共享数据。

ExecutorService executor

  = Executors.newFixedThreadPool(1);

// 创建 Result 对象 r

Result r = new Result();

r.setAAA(a);

// 提交任务

Future<Result> future =

  executor.submit(new Task(r), r);

Result fr = future.get();

// 下面等式成立

fr === r;

fr.getAAA() === a;

fr.getXXX() === x

class Task implements Runnable{

  Result r;

  // 通过构造函数传入 result

  Task(Result r){

    this.r = r;

  }

  void run() {

    // 可以操作 result

    a = r.getAAA();

    r.setXXX(x);

  }

}

下面我们再来介绍 FutureTask 工具类。前面我们提到的 Future 是一个接口，而 FutureTask 是一个实实在在的工具类，这个工具类有两个构造函数，它们的参数和前面介绍的 submit() 方法类似，所以这里我就不再赘述了。

FutureTask(Callable<V> callable);

FutureTask(Runnable runnable, V result);

那如何使用 FutureTask 呢？其实很简单，FutureTask 实现了 Runnable 和 Future 接口，由于实现了 Runnable 接口，所以可以将 FutureTask 对象作为任务提交给 ThreadPoolExecutor 去执行，也可以直接被 Thread 执行；又因为实现了 Future 接口，所以也能用来获得任务的执行结果。下面的示例代码是将 FutureTask 对象提交给 ThreadPoolExecutor 去执行。

// 创建 FutureTask

FutureTask<Integer> futureTask

  = new FutureTask<>(()-> 1+2);

// 创建线程池

ExecutorService es =

  Executors.newCachedThreadPool();

// 提交 FutureTask

es.submit(futureTask);

// 获取计算结果

Integer result = futureTask.get();

FutureTask 对象直接被 Thread 执行的示例代码如下所示。相信你已经发现了，利用 FutureTask 对象可以很容易获取子线程的执行结果。

// 创建 FutureTask

FutureTask<Integer> futureTask

  = new FutureTask<>(()-> 1+2);

// 创建并启动线程

Thread T1 = new Thread(futureTask);

T1.start();

// 获取计算结果

Integer result = futureTask.get();

实现最优的“烧水泡茶”程序

记得以前初中语文课文里有一篇著名数学家华罗庚先生的文章《统筹方法》，这篇文章里介绍了一个烧水泡茶的例子，文中提到最优的工序应该是下面这样：

烧水泡茶最优工序

下面我们用程序来模拟一下这个最优工序。我们专栏前面曾经提到，并发编程可以总结为三个核心问题：分工、同步和互斥。编写并发程序，首先要做的就是分工，所谓分工指的是如何高效地拆解任务并分配给线程。对于烧水泡茶这个程序，一种最优的分工方案可以是下图所示的这样：用两个线程 T1 和 T2 来完成烧水泡茶程序，T1 负责洗水壶、烧开水、泡茶这三道工序，T2 负责洗茶壶、洗茶杯、拿茶叶三道工序，其中 T1 在执行泡茶这道工序时需要等待 T2 完成拿茶叶的工序。对于 T1 的这个等待动作，你应该可以想出很多种办法，例如 Thread.join()、CountDownLatch，甚至阻塞队列都可以解决，不过今天我们用 Future 特性来实现。

烧水泡茶最优分工方案

下面的示例代码就是用这一章提到的 Future 特性来实现的。首先，我们创建了两个 FutureTask——ft1 和 ft2，ft1 完成洗水壶、烧开水、泡茶的任务，ft2 完成洗茶壶、洗茶杯、拿茶叶的任务；这里需要注意的是 ft1 这个任务在执行泡茶任务前，需要等待 ft2 把茶叶拿来，所以 ft1 内部需要引用 ft2，并在执行泡茶之前，调用 ft2 的 get() 方法实现等待。

// 创建任务 T2 的 FutureTask

FutureTask<String> ft2

  = new FutureTask<>(new T2Task());

// 创建任务 T1 的 FutureTask

FutureTask<String> ft1

  = new FutureTask<>(new T1Task(ft2));

// 线程 T1 执行任务 ft1

Thread T1 = new Thread(ft1);

T1.start();

// 线程 T2 执行任务 ft2

Thread T2 = new Thread(ft2);

T2.start();

// 等待线程 T1 执行结果

System.out.println(ft1.get());

// T1Task 需要执行的任务：

// 洗水壶、烧开水、泡茶

class T1Task implements Callable<String>{

  FutureTask<String> ft2;

  // T1 任务需要 T2 任务的 FutureTask

  T1Task(FutureTask<String> ft2){

    this.ft2 = ft2;

  }

  @Override

  String call() throws Exception {

    System.out.println("T1: 洗水壶...");

    TimeUnit.SECONDS.sleep(1);

    System.out.println("T1: 烧开水...");

    TimeUnit.SECONDS.sleep(15);

    // 获取 T2 线程的茶叶

    String tf = ft2.get();

    System.out.println("T1: 拿到茶叶:"+tf);

    System.out.println("T1: 泡茶...");

    return " 上茶:" + tf;

  }

}

// T2Task 需要执行的任务:

// 洗茶壶、洗茶杯、拿茶叶

class T2Task implements Callable<String> {

  @Override

  String call() throws Exception {

    System.out.println("T2: 洗茶壶...");

    TimeUnit.SECONDS.sleep(1);

    System.out.println("T2: 洗茶杯...");

    TimeUnit.SECONDS.sleep(2);

    System.out.println("T2: 拿茶叶...");

    TimeUnit.SECONDS.sleep(1);

    return " 龙井 ";

  }

}

// 一次执行结果：

T1: 洗水壶...

T2: 洗茶壶...

T1: 烧开水...

T2: 洗茶杯...

T2: 拿茶叶...

T1: 拿到茶叶: 龙井

T1: 泡茶...

上茶: 龙井

总结

利用 Java 并发包提供的 Future 可以很容易获得异步任务的执行结果，无论异步任务是通过线程池 ThreadPoolExecutor 执行的，还是通过手工创建子线程来执行的。Future 可以类比为现实世界里的提货单，比如去蛋糕店订生日蛋糕，蛋糕店都是先给你一张提货单，你拿到提货单之后，没有必要一直在店里等着，可以先去干点其他事，比如看场电影；等看完电影后，基本上蛋糕也做好了，然后你就可以凭提货单领蛋糕了。

利用多线程可以快速将一些串行的任务并行化，从而提高性能；如果任务之间有依赖关系，比如当前任务依赖前一个任务的执行结果，这种问题基本上都可以用 Future 来解决。在分析这种问题的过程中，建议你用有向图描述一下任务之间的依赖关系，同时将线程的分工也做好，类似于烧水泡茶最优分工方案那幅图。对照图来写代码，好处是更形象，且不易出错。

课后思考

不久前听说小明要做一个询价应用，这个应用需要从三个电商询价，然后保存在自己的数据库里。核心示例代码如下所示，由于是串行的，所以性能很慢，你来试着优化一下吧。

// 向电商 S1 询价，并保存

r1 = getPriceByS1();

save(r1);

// 向电商 S2 询价，并保存

r2 = getPriceByS2();

save(r2);

// 向电商 S3 询价，并保存

r3 = getPriceByS3();

save(r3);

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

24 | CompletableFuture：异步编程没那么难

前面我们不止一次提到，用多线程优化性能，其实不过就是将串行操作变成并行操作。如果仔细观察，你还会发现在串行转换成并行的过程中，一定会涉及到异步化，例如下面的示例代码，现在是串行的，为了提升性能，我们得把它们并行化，那具体实施起来该怎么做呢？

// 以下两个方法都是耗时操作

doBizA();

doBizB();

还是挺简单的，就像下面代码中这样，创建两个子线程去执行就可以了。你会发现下面的并行方案，主线程无需等待 doBizA() 和 doBizB() 的执行结果，也就是说 doBizA() 和 doBizB() 两个操作已经被异步化了。

new Thread(()->doBizA())

  .start();

new Thread(()->doBizB())

  .start();

异步化，是并行方案得以实施的基础，更深入地讲其实就是：利用多线程优化性能这个核心方案得以实施的基础。看到这里，相信你应该就能理解异步编程最近几年为什么会大火了，因为优化性能是互联网大厂的一个核心需求啊。Java 在 1.8 版本提供了 CompletableFuture 来支持异步编程，CompletableFuture 有可能是你见过的最复杂的工具类了，不过功能也着实让人感到震撼。

CompletableFuture 的核心优势

为了领略 CompletableFuture 异步编程的优势，这里我们用 CompletableFuture 重新实现前面曾提及的烧水泡茶程序。首先还是需要先完成分工方案，在下面的程序中，我们分了 3 个任务：任务 1 负责洗水壶、烧开水，任务 2 负责洗茶壶、洗茶杯和拿茶叶，任务 3 负责泡茶。其中任务 3 要等待任务 1 和任务 2 都完成后才能开始。这个分工如下图所示。

烧水泡茶分工方案

下面是代码实现，你先略过 runAsync()、supplyAsync()、thenCombine() 这些不太熟悉的方法，从大局上看，你会发现：

无需手工维护线程，没有繁琐的手工维护线程的工作，给任务分配线程的工作也不需要我们关注；

语义更清晰，例如 f3 = f1.thenCombine(f2, ()->{}) 能够清晰地表述“任务 3 要等待任务 1 和任务 2 都完成后才能开始”；

代码更简练并且专注于业务逻辑，几乎所有代码都是业务逻辑相关的。

// 任务 1：洗水壶 -> 烧开水

CompletableFuture<Void> f1 =

  CompletableFuture.runAsync(()->{

  System.out.println("T1: 洗水壶...");

  sleep(1, TimeUnit.SECONDS);

  System.out.println("T1: 烧开水...");

  sleep(15, TimeUnit.SECONDS);

});

// 任务 2：洗茶壶 -> 洗茶杯 -> 拿茶叶

CompletableFuture<String> f2 =

  CompletableFuture.supplyAsync(()->{

  System.out.println("T2: 洗茶壶...");

  sleep(1, TimeUnit.SECONDS);

  System.out.println("T2: 洗茶杯...");

  sleep(2, TimeUnit.SECONDS);

  System.out.println("T2: 拿茶叶...");

  sleep(1, TimeUnit.SECONDS);

  return " 龙井 ";

});

// 任务 3：任务 1 和任务 2 完成后执行：泡茶

CompletableFuture<String> f3 =

  f1.thenCombine(f2, (__, tf)->{

    System.out.println("T1: 拿到茶叶:" + tf);

    System.out.println("T1: 泡茶...");

    return " 上茶:" + tf;

  });

// 等待任务 3 执行结果

System.out.println(f3.join());

void sleep(int t, TimeUnit u) {

  try {

    u.sleep(t);

  }catch(InterruptedException e){}

}

// 一次执行结果：

T1: 洗水壶...

T2: 洗茶壶...

T1: 烧开水...

T2: 洗茶杯...

T2: 拿茶叶...

T1: 拿到茶叶: 龙井

T1: 泡茶...

上茶: 龙井

领略 CompletableFuture 异步编程的优势之后，下面我们详细介绍 CompletableFuture 的使用，首先是如何创建 CompletableFuture 对象。

创建 CompletableFuture 对象

创建 CompletableFuture 对象主要靠下面代码中展示的这 4 个静态方法，我们先看前两个。在烧水泡茶的例子中，我们已经使用了runAsync(Runnable runnable)和supplyAsync(Supplier<U> supplier)，它们之间的区别是：Runnable 接口的 run() 方法没有返回值，而 Supplier 接口的 get() 方法是有返回值的。

前两个方法和后两个方法的区别在于：后两个方法可以指定线程池参数。

默认情况下 CompletableFuture 会使用公共的 ForkJoinPool 线程池，这个线程池默认创建的线程数是 CPU 的核数（也可以通过 JVM option:-Djava.util.concurrent.ForkJoinPool.common.parallelism 来设置 ForkJoinPool 线程池的线程数）。如果所有 CompletableFuture 共享一个线程池，那么一旦有任务执行一些很慢的 I/O 操作，就会导致线程池中所有线程都阻塞在 I/O 操作上，从而造成线程饥饿，进而影响整个系统的性能。所以，强烈建议你要根据不同的业务类型创建不同的线程池，以避免互相干扰。

// 使用默认线程池

static CompletableFuture<Void>

  runAsync(Runnable runnable)

static <U> CompletableFuture<U>

  supplyAsync(Supplier<U> supplier)

// 可以指定线程池

static CompletableFuture<Void>

  runAsync(Runnable runnable, Executor executor)

static <U> CompletableFuture<U>

  supplyAsync(Supplier<U> supplier, Executor executor)

创建完 CompletableFuture 对象之后，会自动地异步执行 runnable.run() 方法或者 supplier.get() 方法，对于一个异步操作，你需要关注两个问题：一个是异步操作什么时候结束，另一个是如何获取异步操作的执行结果。因为 CompletableFuture 类实现了 Future 接口，所以这两个问题你都可以通过 Future 接口来解决。另外，CompletableFuture 类还实现了 CompletionStage 接口，这个接口内容实在是太丰富了，在 1.8 版本里有 40 个方法，这些方法我们该如何理解呢？

如何理解 CompletionStage 接口

我觉得，你可以站在分工的角度类比一下工作流。任务是有时序关系的，比如有串行关系、并行关系、汇聚关系等。这样说可能有点抽象，这里还举前面烧水泡茶的例子，其中洗水壶和烧开水就是串行关系，洗水壶、烧开水和洗茶壶、洗茶杯这两组任务之间就是并行关系，而烧开水、拿茶叶和泡茶就是汇聚关系。

串行关系

并行关系

汇聚关系

CompletionStage 接口可以清晰地描述任务之间的这种时序关系，例如前面提到的 f3 = f1.thenCombine(f2, ()->{}) 描述的就是一种汇聚关系。烧水泡茶程序中的汇聚关系是一种 AND 聚合关系，这里的 AND 指的是所有依赖的任务（烧开水和拿茶叶）都完成后才开始执行当前任务（泡茶）。既然有 AND 聚合关系，那就一定还有 OR 聚合关系，所谓 OR 指的是依赖的任务只要有一个完成就可以执行当前任务。

在编程领域，还有一个绕不过去的山头，那就是异常处理，CompletionStage 接口也可以方便地描述异常处理。

下面我们就来一一介绍，CompletionStage 接口如何描述串行关系、AND 聚合关系、OR 聚合关系以及异常处理。

1. 描述串行关系

CompletionStage 接口里面描述串行关系，主要是 thenApply、thenAccept、thenRun 和 thenCompose 这四个系列的接口。

thenApply 系列函数里参数 fn 的类型是接口 Function<T, R>，这个接口里与 CompletionStage 相关的方法是 R apply(T t)，这个方法既能接收参数也支持返回值，所以 thenApply 系列方法返回的是CompletionStage<R>。

而 thenAccept 系列方法里参数 consumer 的类型是接口Consumer<T>，这个接口里与 CompletionStage 相关的方法是 void accept(T t)，这个方法虽然支持参数，但却不支持回值，所以 thenAccept 系列方法返回的是CompletionStage<Void>。

thenRun 系列方法里 action 的参数是 Runnable，所以 action 既不能接收参数也不支持返回值，所以 thenRun 系列方法返回的也是CompletionStage<Void>。

这些方法里面 Async 代表的是异步执行 fn、consumer 或者 action。其中，需要你注意的是 thenCompose 系列方法，这个系列的方法会新创建出一个子流程，最终结果和 thenApply 系列是相同的。

CompletionStage<R> thenApply(fn);

CompletionStage<R> thenApplyAsync(fn);

CompletionStage<Void> thenAccept(consumer);

CompletionStage<Void> thenAcceptAsync(consumer);

CompletionStage<Void> thenRun(action);

CompletionStage<Void> thenRunAsync(action);

CompletionStage<R> thenCompose(fn);

CompletionStage<R> thenComposeAsync(fn);

通过下面的示例代码，你可以看一下 thenApply() 方法是如何使用的。首先通过 supplyAsync() 启动一个异步流程，之后是两个串行操作，整体看起来还是挺简单的。不过，虽然这是一个异步流程，但任务①②③却是串行执行的，②依赖①的执行结果，③依赖②的执行结果。

CompletableFuture<String> f0 =

  CompletableFuture.supplyAsync(

    () -> "Hello World")      //①

  .thenApply(s -> s + " QQ")  //②

  .thenApply(String::toUpperCase);//③

System.out.println(f0.join());

// 输出结果

HELLO WORLD QQ

2. 描述 AND 汇聚关系

CompletionStage 接口里面描述 AND 汇聚关系，主要是 thenCombine、thenAcceptBoth 和 runAfterBoth 系列的接口，这些接口的区别也是源自 fn、consumer、action 这三个核心参数不同。它们的使用你可以参考上面烧水泡茶的实现程序，这里就不赘述了。

CompletionStage<R> thenCombine(other, fn);

CompletionStage<R> thenCombineAsync(other, fn);

CompletionStage<Void> thenAcceptBoth(other, consumer);

CompletionStage<Void> thenAcceptBothAsync(other, consumer);

CompletionStage<Void> runAfterBoth(other, action);

CompletionStage<Void> runAfterBothAsync(other, action);

3. 描述 OR 汇聚关系

CompletionStage 接口里面描述 OR 汇聚关系，主要是 applyToEither、acceptEither 和 runAfterEither 系列的接口，这些接口的区别也是源自 fn、consumer、action 这三个核心参数不同。

CompletionStage applyToEither(other, fn);

CompletionStage applyToEitherAsync(other, fn);

CompletionStage acceptEither(other, consumer);

CompletionStage acceptEitherAsync(other, consumer);

CompletionStage runAfterEither(other, action);

CompletionStage runAfterEitherAsync(other, action);

下面的示例代码展示了如何使用 applyToEither() 方法来描述一个 OR 汇聚关系。

CompletableFuture<String> f1 =

  CompletableFuture.supplyAsync(()->{

    int t = getRandom(5, 10);

    sleep(t, TimeUnit.SECONDS);

    return String.valueOf(t);

});

CompletableFuture<String> f2 =

  CompletableFuture.supplyAsync(()->{

    int t = getRandom(5, 10);

    sleep(t, TimeUnit.SECONDS);

    return String.valueOf(t);

});

CompletableFuture<String> f3 =

  f1.applyToEither(f2,s -> s);

System.out.println(f3.join());

4. 异常处理

虽然上面我们提到的 fn、consumer、action 它们的核心方法都不允许抛出可检查异常，但是却无法限制它们抛出运行时异常，例如下面的代码，执行 7/0 就会出现除零错误这个运行时异常。非异步编程里面，我们可以使用 try{}catch{}来捕获并处理异常，那在异步编程里面，异常该如何处理呢？

CompletableFuture<Integer>

  f0 = CompletableFuture.

    .supplyAsync(()->(7/0))

    .thenApply(r->r*10);

System.out.println(f0.join());

CompletionStage 接口给我们提供的方案非常简单，比 try{}catch{}还要简单，下面是相关的方法，使用这些方法进行异常处理和串行操作是一样的，都支持链式编程方式。

CompletionStage exceptionally(fn);

CompletionStage<R> whenComplete(consumer);

CompletionStage<R> whenCompleteAsync(consumer);

CompletionStage<R> handle(fn);

CompletionStage<R> handleAsync(fn);

下面的示例代码展示了如何使用 exceptionally() 方法来处理异常，exceptionally() 的使用非常类似于 try{}catch{}中的 catch{}，但是由于支持链式编程方式，所以相对更简单。既然有 try{}catch{}，那就一定还有 try{}finally{}，whenComplete() 和 handle() 系列方法就类似于 try{}finally{}中的 finally{}，无论是否发生异常都会执行 whenComplete() 中的回调函数 consumer 和 handle() 中的回调函数 fn。whenComplete() 和 handle() 的区别在于 whenComplete() 不支持返回结果，而 handle() 是支持返回结果的。

CompletableFuture<Integer>

  f0 = CompletableFuture

    .supplyAsync(()->7/0))

    .thenApply(r->r*10)

    .exceptionally(e->0);

System.out.println(f0.join());

总结

曾经一提到异步编程，大家脑海里都会随之浮现回调函数，例如在 JavaScript 里面异步问题基本上都是靠回调函数来解决的，回调函数在处理异常以及复杂的异步任务关系时往往力不从心，对此业界还发明了个名词：回调地狱（Callback Hell）。应该说在前些年，异步编程还是声名狼藉的。

不过最近几年，伴随着ReactiveX的发展（Java 语言的实现版本是 RxJava），回调地狱已经被完美解决了，异步编程已经慢慢开始成熟，Java 语言也开始官方支持异步编程：在 1.8 版本提供了 CompletableFuture，在 Java 9 版本则提供了更加完备的 Flow API，异步编程目前已经完全工业化。因此，学好异步编程还是很有必要的。

CompletableFuture 已经能够满足简单的异步编程需求，如果你对异步编程感兴趣，可以重点关注 RxJava 这个项目，利用 RxJava，即便在 Java 1.6 版本也能享受异步编程的乐趣。

课后思考

创建采购订单的时候，需要校验一些规则，例如最大金额是和采购员级别相关的。有同学利用 CompletableFuture 实现了这个校验的功能，逻辑很简单，首先是从数据库中把相关规则查出来，然后执行规则校验。你觉得他的实现是否有问题呢？

// 采购订单

PurchersOrder po;

CompletableFuture<Boolean> cf =

  CompletableFuture.supplyAsync(()->{

    // 在数据库中查询规则

    return findRuleByJdbc();

  }).thenApply(r -> {

    // 规则校验

    return check(po, r);

});

Boolean isOk = cf.join();

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

25 | CompletionService：如何批量执行异步任务？

在《23 | Future：如何用多线程实现最优的“烧水泡茶”程序？》的最后，我给你留了道思考题，如何优化一个询价应用的核心代码？如果采用“ThreadPoolExecutor+Future”的方案，你的优化结果很可能是下面示例代码这样：用三个线程异步执行询价，通过三次调用 Future 的 get() 方法获取询价结果，之后将询价结果保存在数据库中。

// 创建线程池

ExecutorService executor =

  Executors.newFixedThreadPool(3);

// 异步向电商 S1 询价

Future<Integer> f1 =

  executor.submit(

    ()->getPriceByS1());

// 异步向电商 S2 询价

Future<Integer> f2 =

  executor.submit(

    ()->getPriceByS2());

// 异步向电商 S3 询价

Future<Integer> f3 =

  executor.submit(

    ()->getPriceByS3());

// 获取电商 S1 报价并保存

r=f1.get();

executor.execute(()->save(r));

// 获取电商 S2 报价并保存

r=f2.get();

executor.execute(()->save(r));

// 获取电商 S3 报价并保存

r=f3.get();

executor.execute(()->save(r));

上面的这个方案本身没有太大问题，但是有个地方的处理需要你注意，那就是如果获取电商 S1 报价的耗时很长，那么即便获取电商 S2 报价的耗时很短，也无法让保存 S2 报价的操作先执行，因为这个主线程都阻塞在了 f1.get() 操作上。这点小瑕疵你该如何解决呢？

估计你已经想到了，增加一个阻塞队列，获取到 S1、S2、S3 的报价都进入阻塞队列，然后在主线程中消费阻塞队列，这样就能保证先获取到的报价先保存到数据库了。下面的示例代码展示了如何利用阻塞队列实现先获取到的报价先保存到数据库。

// 创建阻塞队列

BlockingQueue<Integer> bq =

  new LinkedBlockingQueue<>();

// 电商 S1 报价异步进入阻塞队列

executor.execute(()->

  bq.put(f1.get()));

// 电商 S2 报价异步进入阻塞队列

executor.execute(()->

  bq.put(f2.get()));

// 电商 S3 报价异步进入阻塞队列

executor.execute(()->

  bq.put(f3.get()));

// 异步保存所有报价

for (int i=0; i<3; i++) {

  Integer r = bq.take();

  executor.execute(()->save(r));

}

利用 CompletionService 实现询价系统

不过在实际项目中，并不建议你这样做，因为 Java SDK 并发包里已经提供了设计精良的 CompletionService。利用 CompletionService 不但能帮你解决先获取到的报价先保存到数据库的问题，而且还能让代码更简练。

CompletionService 的实现原理也是内部维护了一个阻塞队列，当任务执行结束就把任务的执行结果加入到阻塞队列中，不同的是 CompletionService 是把任务执行结果的 Future 对象加入到阻塞队列中，而上面的示例代码是把任务最终的执行结果放入了阻塞队列中。

那到底该如何创建 CompletionService 呢？

CompletionService 接口的实现类是 ExecutorCompletionService，这个实现类的构造方法有两个，分别是：

ExecutorCompletionService(Executor executor)；

ExecutorCompletionService(Executor executor, BlockingQueue<Future<V>> completionQueue)。

这两个构造方法都需要传入一个线程池，如果不指定 completionQueue，那么默认会使用无界的 LinkedBlockingQueue。任务执行结果的 Future 对象就是加入到 completionQueue 中。

下面的示例代码完整地展示了如何利用 CompletionService 来实现高性能的询价系统。其中，我们没有指定 completionQueue，因此默认使用无界的 LinkedBlockingQueue。之后通过 CompletionService 接口提供的 submit() 方法提交了三个询价操作，这三个询价操作将会被 CompletionService 异步执行。最后，我们通过 CompletionService 接口提供的 take() 方法获取一个 Future 对象（前面我们提到过，加入到阻塞队列中的是任务执行结果的 Future 对象），调用 Future 对象的 get() 方法就能返回询价操作的执行结果了。

// 创建线程池

ExecutorService executor =

  Executors.newFixedThreadPool(3);

// 创建 CompletionService

CompletionService<Integer> cs = new

  ExecutorCompletionService<>(executor);

// 异步向电商 S1 询价

cs.submit(()->getPriceByS1());

// 异步向电商 S2 询价

cs.submit(()->getPriceByS2());

// 异步向电商 S3 询价

cs.submit(()->getPriceByS3());

// 将询价结果异步保存到数据库

for (int i=0; i<3; i++) {

  Integer r = cs.take().get();

  executor.execute(()->save(r));

}

CompletionService 接口说明

下面我们详细地介绍一下 CompletionService 接口提供的方法，CompletionService 接口提供的方法有 5 个，这 5 个方法的方法签名如下所示。

其中，submit() 相关的方法有两个。一个方法参数是Callable<V> task，前面利用 CompletionService 实现询价系统的示例代码中，我们提交任务就是用的它。另外一个方法有两个参数，分别是Runnable task和V result，这个方法类似于 ThreadPoolExecutor 的 <T> Future<T> submit(Runnable task, T result) ，这个方法在《23 | Future：如何用多线程实现最优的“烧水泡茶”程序？》中我们已详细介绍过，这里不再赘述。

CompletionService 接口其余的 3 个方法，都是和阻塞队列相关的，take()、poll() 都是从阻塞队列中获取并移除一个元素；它们的区别在于如果阻塞队列是空的，那么调用 take() 方法的线程会被阻塞，而 poll() 方法会返回 null 值。 poll(long timeout, TimeUnit unit) 方法支持以超时的方式获取并移除阻塞队列头部的一个元素，如果等待了 timeout unit 时间，阻塞队列还是空的，那么该方法会返回 null 值。

Future<V> submit(Callable<V> task);

Future<V> submit(Runnable task, V result);

Future<V> take()

  throws InterruptedException;

Future<V> poll();

Future<V> poll(long timeout, TimeUnit unit)

  throws InterruptedException;

利用 CompletionService 实现 Dubbo 中的 Forking Cluster

Dubbo 中有一种叫做Forking 的集群模式，这种集群模式下，支持并行地调用多个查询服务，只要有一个成功返回结果，整个服务就可以返回了。例如你需要提供一个地址转坐标的服务，为了保证该服务的高可用和性能，你可以并行地调用 3 个地图服务商的 API，然后只要有 1 个正确返回了结果 r，那么地址转坐标这个服务就可以直接返回 r 了。这种集群模式可以容忍 2 个地图服务商服务异常，但缺点是消耗的资源偏多。

geocoder(addr) {

  // 并行执行以下 3 个查询服务，

  r1=geocoderByS1(addr);

  r2=geocoderByS2(addr);

  r3=geocoderByS3(addr);

  // 只要 r1,r2,r3 有一个返回

  // 则返回

  return r1|r2|r3;

}

利用 CompletionService 可以快速实现 Forking 这种集群模式，比如下面的示例代码就展示了具体是如何实现的。首先我们创建了一个线程池 executor 、一个 CompletionService 对象 cs 和一个Future<Integer>类型的列表 futures，每次通过调用 CompletionService 的 submit() 方法提交一个异步任务，会返回一个 Future 对象，我们把这些 Future 对象保存在列表 futures 中。通过调用 cs.take().get()，我们能够拿到最快返回的任务执行结果，只要我们拿到一个正确返回的结果，就可以取消所有任务并且返回最终结果了。

// 创建线程池

ExecutorService executor =

  Executors.newFixedThreadPool(3);

// 创建 CompletionService

CompletionService<Integer> cs =

  new ExecutorCompletionService<>(executor);

// 用于保存 Future 对象

List<Future<Integer>> futures =

  new ArrayList<>(3);

// 提交异步任务，并保存 future 到 futures

futures.add(

  cs.submit(()->geocoderByS1()));

futures.add(

  cs.submit(()->geocoderByS2()));

futures.add(

  cs.submit(()->geocoderByS3()));

// 获取最快返回的任务执行结果

Integer r = 0;

try {

  // 只要有一个成功返回，则 break

  for (int i = 0; i < 3; ++i) {

    r = cs.take().get();

    // 简单地通过判空来检查是否成功返回

    if (r != null) {

      break;

    }

  }

} finally {

  // 取消所有任务

  for(Future<Integer> f : futures)

    f.cancel(true);

}

// 返回结果

return r;

总结

当需要批量提交异步任务的时候建议你使用 CompletionService。CompletionService 将线程池 Executor 和阻塞队列 BlockingQueue 的功能融合在了一起，能够让批量异步任务的管理更简单。除此之外，CompletionService 能够让异步任务的执行结果有序化，先执行完的先进入阻塞队列，利用这个特性，你可以轻松实现后续处理的有序性，避免无谓的等待，同时还可以快速实现诸如 Forking Cluster 这样的需求。

CompletionService 的实现类 ExecutorCompletionService，需要你自己创建线程池，虽看上去有些啰嗦，但好处是你可以让多个 ExecutorCompletionService 的线程池隔离，这种隔离性能避免几个特别耗时的任务拖垮整个应用的风险。

课后思考

本章使用 CompletionService 实现了一个询价应用的核心功能，后来又有了新的需求，需要计算出最低报价并返回，下面的示例代码尝试实现这个需求，你看看是否存在问题呢？

// 创建线程池

ExecutorService executor =

  Executors.newFixedThreadPool(3);

// 创建 CompletionService

CompletionService<Integer> cs = new

  ExecutorCompletionService<>(executor);

// 异步向电商 S1 询价

cs.submit(()->getPriceByS1());

// 异步向电商 S2 询价

cs.submit(()->getPriceByS2());

// 异步向电商 S3 询价

cs.submit(()->getPriceByS3());

// 将询价结果异步保存到数据库

// 并计算最低报价

AtomicReference<Integer> m =

  new AtomicReference<>(Integer.MAX_VALUE);

for (int i=0; i<3; i++) {

  executor.execute(()->{

    Integer r = null;

    try {

      r = cs.take().get();

    } catch (Exception e) {}

    save(r);

    m.set(Integer.min(m.get(), r));

  });

}

return m;

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

26 | Fork/Join：单机版的MapReduce

前面几篇文章我们介绍了线程池、Future、CompletableFuture 和 CompletionService，仔细观察你会发现这些工具类都是在帮助我们站在任务的视角来解决并发问题，而不是让我们纠缠在线程之间如何协作的细节上（比如线程之间如何实现等待、通知等）。对于简单的并行任务，你可以通过“线程池 +Future”的方案来解决；如果任务之间有聚合关系，无论是 AND 聚合还是 OR 聚合，都可以通过 CompletableFuture 来解决；而批量的并行任务，则可以通过 CompletionService 来解决。

我们一直讲，并发编程可以分为三个层面的问题，分别是分工、协作和互斥，当你关注于任务的时候，你会发现你的视角已经从并发编程的细节中跳出来了，你应用的更多的是现实世界的思维模式，类比的往往是现实世界里的分工，所以我把线程池、Future、CompletableFuture 和 CompletionService 都列到了分工里面。

下面我用现实世界里的工作流程图描述了并发编程领域的简单并行任务、聚合任务和批量并行任务，辅以这些流程图，相信你一定能将你的思维模式转换到现实世界里来。

从上到下，依次为简单并行任务、聚合任务和批量并行任务示意图

上面提到的简单并行、聚合、批量并行这三种任务模型，基本上能够覆盖日常工作中的并发场景了，但还是不够全面，因为还有一种“分治”的任务模型没有覆盖到。分治，顾名思义，即分而治之，是一种解决复杂问题的思维方法和模式；具体来讲，指的是把一个复杂的问题分解成多个相似的子问题，然后再把子问题分解成更小的子问题，直到子问题简单到可以直接求解。理论上来讲，解决每一个问题都对应着一个任务，所以对于问题的分治，实际上就是对于任务的分治。

分治思想在很多领域都有广泛的应用，例如算法领域有分治算法（归并排序、快速排序都属于分治算法，二分法查找也是一种分治算法）；大数据领域知名的计算框架 MapReduce 背后的思想也是分治。既然分治这种任务模型如此普遍，那 Java 显然也需要支持，Java 并发包里提供了一种叫做 Fork/Join 的并行计算框架，就是用来支持分治这种任务模型的。

分治任务模型

这里你需要先深入了解一下分治任务模型，分治任务模型可分为两个阶段：一个阶段是任务分解，也就是将任务迭代地分解为子任务，直至子任务可以直接计算出结果；另一个阶段是结果合并，即逐层合并子任务的执行结果，直至获得最终结果。下图是一个简化的分治任务模型图，你可以对照着理解。

简版分治任务模型图

在这个分治任务模型里，任务和分解后的子任务具有相似性，这种相似性往往体现在任务和子任务的算法是相同的，但是计算的数据规模是不同的。具备这种相似性的问题，我们往往都采用递归算法。

Fork/Join 的使用

Fork/Join 是一个并行计算的框架，主要就是用来支持分治任务模型的，这个计算框架里的Fork 对应的是分治任务模型里的任务分解，Join 对应的是结果合并。Fork/Join 计算框架主要包含两部分，一部分是分治任务的线程池 ForkJoinPool，另一部分是分治任务 ForkJoinTask。这两部分的关系类似于 ThreadPoolExecutor 和 Runnable 的关系，都可以理解为提交任务到线程池，只不过分治任务有自己独特类型 ForkJoinTask。

ForkJoinTask 是一个抽象类，它的方法有很多，最核心的是 fork() 方法和 join() 方法，其中 fork() 方法会异步地执行一个子任务，而 join() 方法则会阻塞当前线程来等待子任务的执行结果。ForkJoinTask 有两个子类——RecursiveAction 和 RecursiveTask，通过名字你就应该能知道，它们都是用递归的方式来处理分治任务的。这两个子类都定义了抽象方法 compute()，不过区别是 RecursiveAction 定义的 compute() 没有返回值，而 RecursiveTask 定义的 compute() 方法是有返回值的。这两个子类也是抽象类，在使用的时候，需要你定义子类去扩展。

接下来我们就来实现一下，看看如何用 Fork/Join 这个并行计算框架计算斐波那契数列（下面的代码源自 Java 官方示例）。首先我们需要创建一个分治任务线程池以及计算斐波那契数列的分治任务，之后通过调用分治任务线程池的 invoke() 方法来启动分治任务。由于计算斐波那契数列需要有返回值，所以 Fibonacci 继承自 RecursiveTask。分治任务 Fibonacci 需要实现 compute() 方法，这个方法里面的逻辑和普通计算斐波那契数列非常类似，区别之处在于计算 Fibonacci(n - 1) 使用了异步子任务，这是通过 f1.fork() 这条语句实现的。

static void main(String[] args){

  // 创建分治任务线程池

  ForkJoinPool fjp =

    new ForkJoinPool(4);

  // 创建分治任务

  Fibonacci fib =

    new Fibonacci(30);

  // 启动分治任务

  Integer result =

    fjp.invoke(fib);

  // 输出结果

  System.out.println(result);

}

// 递归任务

static class Fibonacci extends

    RecursiveTask<Integer>{

  final int n;

  Fibonacci(int n){this.n = n;}

  protected Integer compute(){

    if (n <= 1)

      return n;

    Fibonacci f1 =

      new Fibonacci(n - 1);

    // 创建子任务

    f1.fork();

    Fibonacci f2 =

      new Fibonacci(n - 2);

    // 等待子任务结果，并合并结果

    return f2.compute() + f1.join();

  }

}

ForkJoinPool 工作原理

Fork/Join 并行计算的核心组件是 ForkJoinPool，所以下面我们就来简单介绍一下 ForkJoinPool 的工作原理。

通过专栏前面文章的学习，你应该已经知道 ThreadPoolExecutor 本质上是一个生产者 - 消费者模式的实现，内部有一个任务队列，这个任务队列是生产者和消费者通信的媒介；ThreadPoolExecutor 可以有多个工作线程，但是这些工作线程都共享一个任务队列。

ForkJoinPool 本质上也是一个生产者 - 消费者的实现，但是更加智能，你可以参考下面的 ForkJoinPool 工作原理图来理解其原理。ThreadPoolExecutor 内部只有一个任务队列，而 ForkJoinPool 内部有多个任务队列，当我们通过 ForkJoinPool 的 invoke() 或者 submit() 方法提交任务时，ForkJoinPool 根据一定的路由规则把任务提交到一个任务队列中，如果任务在执行过程中会创建出子任务，那么子任务会提交到工作线程对应的任务队列中。

如果工作线程对应的任务队列空了，是不是就没活儿干了呢？不是的，ForkJoinPool 支持一种叫做“任务窃取”的机制，如果工作线程空闲了，那它可以“窃取”其他工作任务队列里的任务，例如下图中，线程 T2 对应的任务队列已经空了，它可以“窃取”线程 T1 对应的任务队列的任务。如此一来，所有的工作线程都不会闲下来了。

ForkJoinPool 中的任务队列采用的是双端队列，工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费，这样能避免很多不必要的数据竞争。我们这里介绍的仅仅是简化后的原理，ForkJoinPool 的实现远比我们这里介绍的复杂，如果你感兴趣，建议去看它的源码。

ForkJoinPool 工作原理图

模拟 MapReduce 统计单词数量

学习 MapReduce 有一个入门程序，统计一个文件里面每个单词的数量，下面我们来看看如何用 Fork/Join 并行计算框架来实现。

我们可以先用二分法递归地将一个文件拆分成更小的文件，直到文件里只有一行数据，然后统计这一行数据里单词的数量，最后再逐级汇总结果，你可以对照前面的简版分治任务模型图来理解这个过程。

思路有了，我们马上来实现。下面的示例程序用一个字符串数组 String[] fc 来模拟文件内容，fc 里面的元素与文件里面的行数据一一对应。关键的代码在 compute() 这个方法里面，这是一个递归方法，前半部分数据 fork 一个递归任务去处理（关键代码 mr1.fork()），后半部分数据则在当前任务中递归处理（mr2.compute()）。

static void main(String[] args){

  String[] fc = {"hello world",

          "hello me",

          "hello fork",

          "hello join",

          "fork join in world"};

  // 创建 ForkJoin 线程池

  ForkJoinPool fjp =

      new ForkJoinPool(3);

  // 创建任务

  MR mr = new MR(

      fc, 0, fc.length);

  // 启动任务

  Map<String, Long> result =

      fjp.invoke(mr);

  // 输出结果

  result.forEach((k, v)->

    System.out.println(k+":"+v));

}

//MR 模拟类

static class MR extends

  RecursiveTask<Map<String, Long>> {

  private String[] fc;

  private int start, end;

  // 构造函数

  MR(String[] fc, int fr, int to){

    this.fc = fc;

    this.start = fr;

    this.end = to;

  }

  @Override protected

  Map<String, Long> compute(){

    if (end - start == 1) {

      return calc(fc[start]);

    } else {

      int mid = (start+end)/2;

      MR mr1 = new MR(

          fc, start, mid);

      mr1.fork();

      MR mr2 = new MR(

          fc, mid, end);

      // 计算子任务，并返回合并的结果

      return merge(mr2.compute(),

          mr1.join());

    }

  }

  // 合并结果

  private Map<String, Long> merge(

      Map<String, Long> r1,

      Map<String, Long> r2) {

    Map<String, Long> result =

        new HashMap<>();

    result.putAll(r1);

    // 合并结果

    r2.forEach((k, v) -> {

      Long c = result.get(k);

      if (c != null)

        result.put(k, c+v);

      else

        result.put(k, v);

    });

    return result;

  }

  // 统计单词数量

  private Map<String, Long>

      calc(String line) {

    Map<String, Long> result =

        new HashMap<>();

    // 分割单词

    String [] words =

        line.split("\\s+");

    // 统计单词数量

    for (String w : words) {

      Long v = result.get(w);

      if (v != null)

        result.put(w, v+1);

      else

        result.put(w, 1L);

    }

    return result;

  }

}

总结

Fork/Join 并行计算框架主要解决的是分治任务。分治的核心思想是“分而治之”：将一个大的任务拆分成小的子任务去解决，然后再把子任务的结果聚合起来从而得到最终结果。这个过程非常类似于大数据处理中的 MapReduce，所以你可以把 Fork/Join 看作单机版的 MapReduce。

Fork/Join 并行计算框架的核心组件是 ForkJoinPool。ForkJoinPool 支持任务窃取机制，能够让所有线程的工作量基本均衡，不会出现有的线程很忙，而有的线程很闲的状况，所以性能很好。Java 1.8 提供的 Stream API 里面并行流也是以 ForkJoinPool 为基础的。不过需要你注意的是，默认情况下所有的并行流计算都共享一个 ForkJoinPool，这个共享的 ForkJoinPool 默认的线程数是 CPU 的核数；如果所有的并行流计算都是 CPU 密集型计算的话，完全没有问题，但是如果存在 I/O 密集型的并行流计算，那么很可能会因为一个很慢的 I/O 计算而拖慢整个系统的性能。所以建议用不同的 ForkJoinPool 执行不同类型的计算任务。

如果你对 ForkJoinPool 详细的实现细节感兴趣，也可以参考Doug Lea 的论文。

课后思考

对于一个 CPU 密集型计算程序，在单核 CPU 上，使用 Fork/Join 并行计算框架是否能够提高性能呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

27 | 并发工具类模块热点问题答疑

前面我们用 13 篇文章的内容介绍了 Java SDK 提供的并发工具类，这些工具类都是久经考验的，所以学好用好它们对于解决并发问题非常重要。我们在介绍这些工具类的时候，重点介绍了这些工具类的产生背景、应用场景以及实现原理，目的就是让你在面对并发问题的时候，有思路，有办法。只有思路、办法有了，才谈得上开始动手解决问题。

当然了，只有思路和办法还不足以把问题解决，最终还是要动手实践的，我觉得在实践中有两方面的问题需要重点关注：细节问题与最佳实践。千里之堤毁于蚁穴，细节虽然不能保证成功，但是可以导致失败，所以我们一直都强调要关注细节。而最佳实践是前人的经验总结，可以帮助我们不要阴沟里翻船，所以没有十足的理由，一定要遵守。

为了让你学完即学即用，我在每篇文章的最后都给你留了道思考题。这 13 篇文章的 13 个思考题，基本上都是相关工具类在使用中需要特别注意的一些细节问题，工作中容易碰到且费神费力，所以咱们今天就来一一分析。

1. while(true) 总不让人省心

《14 | Lock&Condition（上）：隐藏在并发包中的管程》的思考题，本意是通过破坏不可抢占条件来避免死锁问题，但是它的实现中有一个致命的问题，那就是： while(true) 没有 break 条件，从而导致了死循环。除此之外，这个实现虽然不存在死锁问题，但还是存在活锁问题的，解决活锁问题很简单，只需要随机等待一小段时间就可以了。

修复后的代码如下所示，我仅仅修改了两个地方，一处是转账成功之后 break，另一处是在 while 循环体结束前增加了Thread.sleep(随机时间)。

class Account {

  private int balance;

  private final Lock lock

          = new ReentrantLock();

  // 转账

  void transfer(Account tar, int amt){

    while (true) {

      if(this.lock.tryLock()) {

        try {

          if (tar.lock.tryLock()) {

            try {

              this.balance -= amt;

              tar.balance += amt;

              // 新增：退出循环

              break;

            } finally {

              tar.lock.unlock();

            }

          }//if

        } finally {

          this.lock.unlock();

        }

      }//if

      // 新增：sleep 一个随机时间避免活锁

      Thread.sleep(随机时间);

    }//while

  }//transfer

}

这个思考题里面的 while(true) 问题还是比较容易看出来的，但不是所有的 while(true) 问题都这么显而易见的，很多都隐藏得比较深。

例如，《21 | 原子类：无锁工具类的典范》的思考题本质上也是一个 while(true)，不过它隐藏得就比较深了。看上去 while(!rf.compareAndSet(or, nr)) 是有终止条件的，而且跑单线程测试一直都没有问题。实际上却存在严重的并发问题，问题就出在对 or 的赋值在 while 循环之外，这样每次循环 or 的值都不会发生变化，所以一旦有一次循环 rf.compareAndSet(or, nr) 的值等于 false，那之后无论循环多少次，都会等于 false。也就是说在特定场景下，变成了 while(true) 问题。既然找到了原因，修改就很简单了，只要把对 or 的赋值移到 while 循环之内就可以了，修改后的代码如下所示：

public class SafeWM {

  class WMRange{

    final int upper;

    final int lower;

    WMRange(int upper,int lower){

    // 省略构造函数实现

    }

  }

  final AtomicReference<WMRange>

    rf = new AtomicReference<>(

      new WMRange(0,0)

    );

  // 设置库存上限

  void setUpper(int v){

    WMRange nr;

    WMRange or;

    // 原代码在这里

    //WMRange or=rf.get();

    do{

      // 移动到此处

      // 每个回合都需要重新获取旧值

      or = rf.get();

      // 检查参数合法性

      if(v < or.lower){

        throw new IllegalArgumentException();

      }

      nr = new

        WMRange(v, or.lower);

    }while(!rf.compareAndSet(or, nr));

  }

}

2. signalAll() 总让人省心

《15 | Lock&Condition（下）：Dubbo 如何用管程实现异步转同步？》的思考题是关于 signal() 和 signalAll() 的，Dubbo 最近已经把 signal() 改成 signalAll() 了，我觉得用 signal() 也不能说错，但的确是用 signalAll() 会更安全。我个人也倾向于使用 signalAll()，因为我们写程序，不是做数学题，而是在搞工程，工程中会有很多不稳定的因素，更有很多你预料不到的情况发生，所以不要让你的代码铤而走险，尽量使用更稳妥的方案和设计。Dubbo 修改后的相关代码如下所示：

// RPC 结果返回时调用该方法

private void doReceived(Response res) {

  lock.lock();

  try {

    response = res;

    done.signalAll();

  } finally {

    lock.unlock();

  }

}

3. Semaphore 需要锁中锁

《16 | Semaphore：如何快速实现一个限流器？》的思考题是对象池的例子中 Vector 能否换成 ArrayList，答案是不可以的。Semaphore 可以允许多个线程访问一个临界区，那就意味着可能存在多个线程同时访问 ArrayList，而 ArrayList 不是线程安全的，所以对象池的例子中是不能够将 Vector 换成 ArrayList 的。Semaphore 允许多个线程访问一个临界区，这也是一把双刃剑，当多个线程进入临界区时，如果需要访问共享变量就会存在并发问题，所以必须加锁，也就是说 Semaphore 需要锁中锁。

4. 锁的申请和释放要成对出现

《18 | StampedLock：有没有比读写锁更快的锁？》思考题的 Bug 出在没有正确地释放锁。锁的申请和释放要成对出现，对此我们有一个最佳实践，就是使用try{}finally{}，但是 try{}finally{}并不能解决所有锁的释放问题。比如示例代码中，锁的升级会生成新的 stamp ，而 finally 中释放锁用的是锁升级前的 stamp，本质上这也属于锁的申请和释放没有成对出现，只是它隐藏得有点深。解决这个问题倒也很简单，只需要对 stamp 重新赋值就可以了，修复后的代码如下所示：

private double x, y;

final StampedLock sl = new StampedLock();

// 存在问题的方法

void moveIfAtOrigin(double newX, double newY){

 long stamp = sl.readLock();

 try {

  while(x == 0.0 && y == 0.0){

    long ws = sl.tryConvertToWriteLock(stamp);

    if (ws != 0L) {

      // 问题出在没有对 stamp 重新赋值

      // 新增下面一行

      stamp = ws;

      x = newX;

      y = newY;

      break;

    } else {

      sl.unlockRead(stamp);

      stamp = sl.writeLock();

    }

  }

 } finally {

  // 此处 unlock 的是 stamp

  sl.unlock(stamp);

}

5. 回调总要关心执行线程是谁

《19 | CountDownLatch 和 CyclicBarrier：如何让多线程步调一致？》的思考题是：CyclicBarrier 的回调函数使用了一个固定大小为 1 的线程池，是否合理？我觉得是合理的，可以从以下两个方面来分析。

第一个是线程池大小是 1，只有 1 个线程，主要原因是 check() 方法的耗时比 getPOrders() 和 getDOrders() 都要短，所以没必要用多个线程，同时单线程能保证访问的数据不存在并发问题。

第二个是使用了线程池，如果不使用，直接在回调函数里调用 check() 方法是否可以呢？绝对不可以。为什么呢？这个要分析一下回调函数和唤醒等待线程之间的关系。下面是 CyclicBarrier 相关的源码，通过源码你会发现 CyclicBarrier 是同步调用回调函数之后才唤醒等待的线程，如果我们在回调函数里直接调用 check() 方法，那就意味着在执行 check() 的时候，是不能同时执行 getPOrders() 和 getDOrders() 的，这样就起不到提升性能的作用。

try {

  //barrierCommand 是回调函数

  final Runnable command = barrierCommand;

  // 调用回调函数

  if (command != null)

	command.run();

  ranAction = true;

  // 唤醒等待的线程

  nextGeneration();

  return 0;

} finally {

  if (!ranAction)

	breakBarrier();

}

所以，当遇到回调函数的时候，你应该本能地问自己：执行回调函数的线程是哪一个？这个在多线程场景下非常重要。因为不同线程 ThreadLocal 里的数据是不同的，有些框架比如 Spring 就用 ThreadLocal 来管理事务，如果不清楚回调函数用的是哪个线程，很可能会导致错误的事务管理，并最终导致数据不一致。

CyclicBarrier 的回调函数究竟是哪个线程执行的呢？如果你分析源码，你会发现执行回调函数的线程是将 CyclicBarrier 内部计数器减到 0 的那个线程。所以我们前面讲执行 check() 的时候，是不能同时执行 getPOrders() 和 getDOrders()，因为执行这两个方法的线程一个在等待，一个正在忙着执行 check()。

再次强调一下：当看到回调函数的时候，一定问一问执行回调函数的线程是谁。

6. 共享线程池：有福同享就要有难同当

《24 | CompletableFuture：异步编程没那么难》的思考题是下列代码是否有问题。很多同学都发现这段代码的问题了，例如没有异常处理、逻辑不严谨等等，不过我更想让你关注的是：findRuleByJdbc() 这个方法隐藏着一个阻塞式 I/O，这意味着会阻塞调用线程。默认情况下所有的 CompletableFuture 共享一个 ForkJoinPool，当有阻塞式 I/O 时，可能导致所有的 ForkJoinPool 线程都阻塞，进而影响整个系统的性能。

// 采购订单

PurchersOrder po;

CompletableFuture<Boolean> cf =

  CompletableFuture.supplyAsync(()->{

    // 在数据库中查询规则

    return findRuleByJdbc();

  }).thenApply(r -> {

    // 规则校验

    return check(po, r);

});

Boolean isOk = cf.join();

利用共享，往往能让我们快速实现功能，所谓是有福同享，但是代价就是有难要同当。在强调高可用的今天，大多数人更倾向于使用隔离的方案。

7. 线上问题定位的利器：线程栈 dump

《17 | ReadWriteLock：如何快速实现一个完备的缓存？》和《20 | 并发容器：都有哪些“坑”需要我们填？》的思考题，本质上都是定位线上并发问题，方案很简单，就是通过查看线程栈来定位问题。重点是查看线程状态，分析线程进入该状态的原因是否合理，你可以参考《09 | Java 线程（上）：Java 线程的生命周期》来加深理解。

为了便于分析定位线程问题，你需要给线程赋予一个有意义的名字，对于线程池可以通过自定义 ThreadFactory 来给线程池中的线程赋予有意义的名字，也可以在执行 run() 方法时通过Thread.currentThread().setName();来给线程赋予一个更贴近业务的名字。

总结

Java 并发工具类到今天为止，就告一段落了，由于篇幅原因，不能每个工具类都详细介绍。Java 并发工具类内容繁杂，熟练使用是需要一个过程的，而且需要多加实践。希望你学完这个模块之后，遇到并发问题时最起码能知道用哪些工具可以解决。至于工具使用的细节和最佳实践，我总结的也只是我认为重要的。由于每个人的思维方式和编码习惯不同，也许我认为不重要的，恰恰是你的短板，所以这部分内容更多地还是需要你去实践，在实践中养成良好的编码习惯，不断纠正错误的思维方式。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

28 | Immutability模式：如何利用不变性解决并发问题？

我们曾经说过，“多个线程同时读写同一共享变量存在并发问题”，这里的必要条件之一是读写，如果只有读，而没有写，是没有并发问题的。

解决并发问题，其实最简单的办法就是让共享变量只有读操作，而没有写操作。这个办法如此重要，以至于被上升到了一种解决并发问题的设计模式：不变性（Immutability）模式。所谓不变性，简单来讲，就是对象一旦被创建之后，状态就不再发生变化。换句话说，就是变量一旦被赋值，就不允许修改了（没有写操作）；没有修改操作，也就是保持了不变性。

快速实现具备不可变性的类

实现一个具备不可变性的类，还是挺简单的。将一个类所有的属性都设置成 final 的，并且只允许存在只读方法，那么这个类基本上就具备不可变性了。更严格的做法是这个类本身也是 final 的，也就是不允许继承。因为子类可以覆盖父类的方法，有可能改变不可变性，所以推荐你在实际工作中，使用这种更严格的做法。

Java SDK 里很多类都具备不可变性，只是由于它们的使用太简单，最后反而被忽略了。例如经常用到的 String 和 Long、Integer、Double 等基础类型的包装类都具备不可变性，这些对象的线程安全性都是靠不可变性来保证的。如果你仔细翻看这些类的声明、属性和方法，你会发现它们都严格遵守不可变类的三点要求：类和属性都是 final 的，所有方法均是只读的。

看到这里你可能会疑惑，Java 的 String 方法也有类似字符替换操作，怎么能说所有方法都是只读的呢？我们结合 String 的源代码来解释一下这个问题，下面的示例代码源自 Java 1.8 SDK，我略做了修改，仅保留了关键属性 value[] 和 replace() 方法，你会发现：String 这个类以及它的属性 value[] 都是 final 的；而 replace() 方法的实现，就的确没有修改 value[]，而是将替换后的字符串作为返回值返回了。

public final class String {

  private final char value[];

  // 字符替换

  String replace(char oldChar,

      char newChar) {

    // 无需替换，直接返回 this

    if (oldChar == newChar){

      return this;

    }

    int len = value.length;

    int i = -1;

    /* avoid getfield opcode */

    char[] val = value;

    // 定位到需要替换的字符位置

    while (++i < len) {

      if (val[i] == oldChar) {

        break;

      }

    }

    // 未找到 oldChar，无需替换

    if (i >= len) {

      return this;

    }

    // 创建一个 buf[]，这是关键

    // 用来保存替换后的字符串

    char buf[] = new char[len];

    for (int j = 0; j < i; j++) {

      buf[j] = val[j];

    }

    while (i < len) {

      char c = val[i];

      buf[i] = (c == oldChar) ?

        newChar : c;

      i++;

    }

    // 创建一个新的字符串返回

    // 原字符串不会发生任何变化

    return new String(buf, true);

  }

}

通过分析 String 的实现，你可能已经发现了，如果具备不可变性的类，需要提供类似修改的功能，具体该怎么操作呢？做法很简单，那就是创建一个新的不可变对象，这是与可变对象的一个重要区别，可变对象往往是修改自己的属性。

所有的修改操作都创建一个新的不可变对象，你可能会有这种担心：是不是创建的对象太多了，有点太浪费内存呢？是的，这样做的确有些浪费，那如何解决呢？

利用享元模式避免创建重复对象

如果你熟悉面向对象相关的设计模式，相信你一定能想到享元模式（Flyweight Pattern）。利用享元模式可以减少创建对象的数量，从而减少内存占用。Java 语言里面 Long、Integer、Short、Byte 等这些基本数据类型的包装类都用到了享元模式。

下面我们就以 Long 这个类作为例子，看看它是如何利用享元模式来优化对象的创建的。

享元模式本质上其实就是一个对象池，利用享元模式创建对象的逻辑也很简单：创建之前，首先去对象池里看看是不是存在；如果已经存在，就利用对象池里的对象；如果不存在，就会新创建一个对象，并且把这个新创建出来的对象放进对象池里。

Long 这个类并没有照搬享元模式，Long 内部维护了一个静态的对象池，仅缓存了 [-128,127] 之间的数字，这个对象池在 JVM 启动的时候就创建好了，而且这个对象池一直都不会变化，也就是说它是静态的。之所以采用这样的设计，是因为 Long 这个对象的状态共有 2⁶⁴ 种，实在太多，不宜全部缓存，而 [-128,127] 之间的数字利用率最高。下面的示例代码出自 Java 1.8，valueOf() 方法就用到了 LongCache 这个缓存，你可以结合着来加深理解。

Long valueOf(long l) {

  final int offset = 128;

  // [-128,127] 直接的数字做了缓存

  if (l >= -128 && l <= 127) {

    return LongCache

      .cache[(int)l + offset];

  }

  return new Long(l);

}

// 缓存，等价于对象池

// 仅缓存 [-128,127] 直接的数字

static class LongCache {

  static final Long cache[]

    = new Long[-(-128) + 127 + 1];

  static {

    for(int i=0; i<cache.length; i++)

      cache[i] = new Long(i-128);

  }

}

前面我们在《13 | 理论基础模块热点问题答疑》中提到“Integer 和 String 类型的对象不适合做锁”，其实基本上所有的基础类型的包装类都不适合做锁，因为它们内部用到了享元模式，这会导致看上去私有的锁，其实是共有的。例如在下面代码中，本意是 A 用锁 al，B 用锁 bl，各自管理各自的，互不影响。但实际上 al 和 bl 是一个对象，结果 A 和 B 共用的是一把锁。

class A {

  Long al=Long.valueOf(1);

  public void setAX(){

    synchronized (al) {

      // 省略代码无数

    }

  }

}

class B {

  Long bl=Long.valueOf(1);

  public void setBY(){

    synchronized (bl) {

      // 省略代码无数

    }

  }

}

使用 Immutability 模式的注意事项

在使用 Immutability 模式的时候，需要注意以下两点：

对象的所有属性都是 final 的，并不能保证不可变性；

不可变对象也需要正确发布。

在 Java 语言中，final 修饰的属性一旦被赋值，就不可以再修改，但是如果属性的类型是普通对象，那么这个普通对象的属性是可以被修改的。例如下面的代码中，Bar 的属性 foo 虽然是 final 的，依然可以通过 setAge() 方法来设置 foo 的属性 age。所以，在使用 Immutability 模式的时候一定要确认保持不变性的边界在哪里，是否要求属性对象也具备不可变性。

class Foo{

  int age=0;

  int name="abc";

}

final class Bar {

  final Foo foo;

  void setAge(int a){

    foo.age=a;

  }

}

下面我们再看看如何正确地发布不可变对象。不可变对象虽然是线程安全的，但是并不意味着引用这些不可变对象的对象就是线程安全的。例如在下面的代码中，Foo 具备不可变性，线程安全，但是类 Bar 并不是线程安全的，类 Bar 中持有对 Foo 的引用 foo，对 foo 这个引用的修改在多线程中并不能保证可见性和原子性。

//Foo 线程安全

final class Foo{

  final int age=0;

  final int name="abc";

}

//Bar 线程不安全

class Bar {

  Foo foo;

  void setFoo(Foo f){

    this.foo=f;

  }

}

如果你的程序仅仅需要 foo 保持可见性，无需保证原子性，那么可以将 foo 声明为 volatile 变量，这样就能保证可见性。如果你的程序需要保证原子性，那么可以通过原子类来实现。下面的示例代码是合理库存的原子化实现，你应该很熟悉了，其中就是用原子类解决了不可变对象引用的原子性问题。

public class SafeWM {

  class WMRange{

    final int upper;

    final int lower;

    WMRange(int upper,int lower){

    // 省略构造函数实现

    }

  }

  final AtomicReference<WMRange>

    rf = new AtomicReference<>(

      new WMRange(0,0)

    );

  // 设置库存上限

  void setUpper(int v){

    while(true){

      WMRange or = rf.get();

      // 检查参数合法性

      if(v < or.lower){

        throw new IllegalArgumentException();

      }

      WMRange nr = new

          WMRange(v, or.lower);

      if(rf.compareAndSet(or, nr)){

        return;

      }

    }

  }

}

总结

利用 Immutability 模式解决并发问题，也许你觉得有点陌生，其实你天天都在享受它的战果。Java 语言里面的 String 和 Long、Integer、Double 等基础类型的包装类都具备不可变性，这些对象的线程安全性都是靠不可变性来保证的。Immutability 模式是最简单的解决并发问题的方法，建议当你试图解决一个并发问题时，可以首先尝试一下 Immutability 模式，看是否能够快速解决。

具备不变性的对象，只有一种状态，这个状态由对象内部所有的不变属性共同决定。其实还有一种更简单的不变性对象，那就是无状态。无状态对象内部没有属性，只有方法。除了无状态的对象，你可能还听说过无状态的服务、无状态的协议等等。无状态有很多好处，最核心的一点就是性能。在多线程领域，无状态对象没有线程安全问题，无需同步处理，自然性能很好；在分布式领域，无状态意味着可以无限地水平扩展，所以分布式领域里面性能的瓶颈一定不是出在无状态的服务节点上。

课后思考

下面的示例代码中，Account 的属性是 final 的，并且只有 get 方法，那这个类是不是具备不可变性呢？

public final class Account{

  private final

    StringBuffer user;

  public Account(String user){

    this.user =

      new StringBuffer(user);

  }

  public StringBuffer getUser(){

    return this.user;

  }

  public String toString(){

    return "user"+user;

  }

}

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

29 | Copy-on-Write模式：不是延时策略的COW

在上一篇文章中我们讲到 Java 里 String 这个类在实现 replace() 方法的时候，并没有更改原字符串里面 value[] 数组的内容，而是创建了一个新字符串，这种方法在解决不可变对象的修改问题时经常用到。如果你深入地思考这个方法，你会发现它本质上是一种Copy-on-Write 方法。所谓 Copy-on-Write，经常被缩写为 COW 或者 CoW，顾名思义就是写时复制。

不可变对象的写操作往往都是使用 Copy-on-Write 方法解决的，当然 Copy-on-Write 的应用领域并不局限于 Immutability 模式。下面我们先简单介绍一下 Copy-on-Write 的应用领域，让你对它有个更全面的认识。

Copy-on-Write 模式的应用领域

我们前面在《20 | 并发容器：都有哪些“坑”需要我们填？》中介绍过 CopyOnWriteArrayList 和 CopyOnWriteArraySet 这两个 Copy-on-Write 容器，它们背后的设计思想就是 Copy-on-Write；通过 Copy-on-Write 这两个容器实现的读操作是无锁的，由于无锁，所以将读操作的性能发挥到了极致。

除了 Java 这个领域，Copy-on-Write 在操作系统领域也有广泛的应用。

我第一次接触 Copy-on-Write 其实就是在操作系统领域。类 Unix 的操作系统中创建进程的 API 是 fork()，传统的 fork() 函数会创建父进程的一个完整副本，例如父进程的地址空间现在用到了 1G 的内存，那么 fork() 子进程的时候要复制父进程整个进程的地址空间（占有 1G 内存）给子进程，这个过程是很耗时的。而 Linux 中的 fork() 函数就聪明得多了，fork() 子进程的时候，并不复制整个进程的地址空间，而是让父子进程共享同一个地址空间；只用在父进程或者子进程需要写入的时候才会复制地址空间，从而使父子进程拥有各自的地址空间。

本质上来讲，父子进程的地址空间以及数据都是要隔离的，使用 Copy-on-Write 更多地体现的是一种延时策略，只有在真正需要复制的时候才复制，而不是提前复制好，同时 Copy-on-Write 还支持按需复制，所以 Copy-on-Write 在操作系统领域是能够提升性能的。相比较而言，Java 提供的 Copy-on-Write 容器，由于在修改的同时会复制整个容器，所以在提升读操作性能的同时，是以内存复制为代价的。这里你会发现，同样是应用 Copy-on-Write，不同的场景，对性能的影响是不同的。

在操作系统领域，除了创建进程用到了 Copy-on-Write，很多文件系统也同样用到了，例如 Btrfs (B-Tree File System)、aufs（advanced multi-layered unification filesystem）等。

除了上面我们说的 Java 领域、操作系统领域，很多其他领域也都能看到 Copy-on-Write 的身影：Docker 容器镜像的设计是 Copy-on-Write，甚至分布式源码管理系统 Git 背后的设计思想都有 Copy-on-Write……

不过，Copy-on-Write 最大的应用领域还是在函数式编程领域。函数式编程的基础是不可变性（Immutability），所以函数式编程里面所有的修改操作都需要 Copy-on-Write 来解决。你或许会有疑问，“所有数据的修改都需要复制一份，性能是不是会成为瓶颈呢？”你的担忧是有道理的，之所以函数式编程早年间没有兴起，性能绝对拖了后腿。但是随着硬件性能的提升，性能问题已经慢慢变得可以接受了。而且，Copy-on-Write 也远不像 Java 里的 CopyOnWriteArrayList 那样笨：整个数组都复制一遍。Copy-on-Write 也是可以按需复制的，如果你感兴趣可以参考Purely Functional Data Structures这本书，里面描述了各种具备不变性的数据结构的实现。

CopyOnWriteArrayList 和 CopyOnWriteArraySet 这两个 Copy-on-Write 容器在修改的时候会复制整个数组，所以如果容器经常被修改或者这个数组本身就非常大的时候，是不建议使用的。反之，如果是修改非常少、数组数量也不大，并且对读性能要求苛刻的场景，使用 Copy-on-Write 容器效果就非常好了。下面我们结合一个真实的案例来讲解一下。

一个真实案例

我曾经写过一个 RPC 框架，有点类似 Dubbo，服务提供方是多实例分布式部署的，所以服务的客户端在调用 RPC 的时候，会选定一个服务实例来调用，这个选定的过程本质上就是在做负载均衡，而做负载均衡的前提是客户端要有全部的路由信息。例如在下图中，A 服务的提供方有 3 个实例，分别是 192.168.1.1、192.168.1.2 和 192.168.1.3，客户端在调用目标服务 A 前，首先需要做的是负载均衡，也就是从这 3 个实例中选出 1 个来，然后再通过 RPC 把请求发送选中的目标实例。

RPC 路由关系图

RPC 框架的一个核心任务就是维护服务的路由关系，我们可以把服务的路由关系简化成下图所示的路由表。当服务提供方上线或者下线的时候，就需要更新客户端的这张路由表。

我们首先来分析一下如何用程序来实现。每次 RPC 调用都需要通过负载均衡器来计算目标服务的 IP 和端口号，而负载均衡器需要通过路由表获取接口的所有路由信息，也就是说，每次 RPC 调用都需要访问路由表，所以访问路由表这个操作的性能要求是很高的。不过路由表对数据的一致性要求并不高，一个服务提供方从上线到反馈到客户端的路由表里，即便有 5 秒钟，很多时候也都是能接受的（5 秒钟，对于以纳秒作为时钟周期的 CPU 来说，那何止是一万年，所以路由表对一致性的要求并不高）。而且路由表是典型的读多写少类问题，写操作的量相比于读操作，可谓是沧海一粟，少得可怜。

通过以上分析，你会发现一些关键词：对读的性能要求很高，读多写少，弱一致性。它们综合在一起，你会想到什么呢？CopyOnWriteArrayList 和 CopyOnWriteArraySet 天生就适用这种场景啊。所以下面的示例代码中，RouteTable 这个类内部我们通过ConcurrentHashMap<String, CopyOnWriteArraySet<Router>>这个数据结构来描述路由表，ConcurrentHashMap 的 Key 是接口名，Value 是路由集合，这个路由集合我们用是 CopyOnWriteArraySet。

下面我们再来思考 Router 该如何设计，服务提供方的每一次上线、下线都会更新路由信息，这时候你有两种选择。一种是通过更新 Router 的一个状态位来标识，如果这样做，那么所有访问该状态位的地方都需要同步访问，这样很影响性能。另外一种就是采用 Immutability 模式，每次上线、下线都创建新的 Router 对象或者删除对应的 Router 对象。由于上线、下线的频率很低，所以后者是最好的选择。

Router 的实现代码如下所示，是一种典型 Immutability 模式的实现，需要你注意的是我们重写了 equals 方法，这样 CopyOnWriteArraySet 的 add() 和 remove() 方法才能正常工作。

// 路由信息

public final class Router{

  private final String  ip;

  private final Integer port;

  private final String  iface;

  // 构造函数

  public Router(String ip,

      Integer port, String iface){

    this.ip = ip;

    this.port = port;

    this.iface = iface;

  }

  // 重写 equals 方法

  public boolean equals(Object obj){

    if (obj instanceof Router) {

      Router r = (Router)obj;

      return iface.equals(r.iface) &&

             ip.equals(r.ip) &&

             port.equals(r.port);

    }

    return false;

  }

  public int hashCode() {

    // 省略 hashCode 相关代码

  }

}

// 路由表信息

public class RouterTable {

  //Key: 接口名

  //Value: 路由集合

  ConcurrentHashMap<String, CopyOnWriteArraySet<Router>>

    rt = new ConcurrentHashMap<>();

  // 根据接口名获取路由表

  public Set<Router> get(String iface){

    return rt.get(iface);

  }

  // 删除路由

  public void remove(Router router) {

    Set<Router> set=rt.get(router.iface);

    if (set != null) {

      set.remove(router);

    }

  }

  // 增加路由

  public void add(Router router) {

    Set<Router> set = rt.computeIfAbsent(

      route.iface, r ->

        new CopyOnWriteArraySet<>());

    set.add(router);

  }

}

总结

目前 Copy-on-Write 在 Java 并发编程领域知名度不是很高，很多人都在无意中把它忽视了，但其实 Copy-on-Write 才是最简单的并发解决方案。它是如此简单，以至于 Java 中的基本数据类型 String、Integer、Long 等都是基于 Copy-on-Write 方案实现的。

Copy-on-Write 是一项非常通用的技术方案，在很多领域都有着广泛的应用。不过，它也有缺点的，那就是消耗内存，每次修改都需要复制一个新的对象出来，好在随着自动垃圾回收（GC）算法的成熟以及硬件的发展，这种内存消耗已经渐渐可以接受了。所以在实际工作中，如果写操作非常少，那你就可以尝试用一下 Copy-on-Write，效果还是不错的。

课后思考

Java 提供了 CopyOnWriteArrayList，为什么没有提供 CopyOnWriteLinkedList 呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

30 | 线程本地存储模式：没有共享，就没有伤害

民国年间某山东省主席参加某大学校庆演讲，在篮球场看到十来个人穿着裤衩抢一个球，观之实在不雅，于是怒斥学校的总务处长贪污，并且发话：“多买几个球，一人发一个，省得你争我抢！”小时候听到这个段子只是觉得好玩，今天再来看，却别有一番滋味。为什么呢？因为其间蕴藏着解决并发问题的一个重要方法：避免共享。

我们曾经一遍一遍又一遍地重复，多个线程同时读写同一共享变量存在并发问题。前面两篇文章我们突破的是写，没有写操作自然没有并发问题了。其实还可以突破共享变量，没有共享变量也不会有并发问题，正所谓是没有共享，就没有伤害。

那如何避免共享呢？思路其实很简单，多个人争一个球总容易出矛盾，那就每个人发一个球。对应到并发编程领域，就是每个线程都拥有自己的变量，彼此之间不共享，也就没有并发问题了。

我们在《11 | Java 线程（下）：为什么局部变量是线程安全的？》中提到过线程封闭，其本质上就是避免共享。你已经知道通过局部变量可以做到避免共享，那还有没有其他方法可以做到呢？有的，Java 语言提供的线程本地存储（ThreadLocal）就能够做到。下面我们先看看 ThreadLocal 到底该如何使用。

ThreadLocal 的使用方法

下面这个静态类 ThreadId 会为每个线程分配一个唯一的线程 Id，如果一个线程前后两次调用 ThreadId 的 get() 方法，两次 get() 方法的返回值是相同的。但如果是两个线程分别调用 ThreadId 的 get() 方法，那么两个线程看到的 get() 方法的返回值是不同的。若你是初次接触 ThreadLocal，可能会觉得奇怪，为什么相同线程调用 get() 方法结果就相同，而不同线程调用 get() 方法结果就不同呢？

static class ThreadId {

  static final AtomicLong

  nextId=new AtomicLong(0);

  // 定义 ThreadLocal 变量

  static final ThreadLocal<Long>

  tl=ThreadLocal.withInitial(

    ()->nextId.getAndIncrement());

  // 此方法会为每个线程分配一个唯一的 Id

  static long get(){

    return tl.get();

  }

}

能有这个奇怪的结果，都是 ThreadLocal 的杰作，不过在详细解释 ThreadLocal 的工作原理之前，我们再看一个实际工作中可能遇到的例子来加深一下对 ThreadLocal 的理解。你可能知道 SimpleDateFormat 不是线程安全的，那如果需要在并发场景下使用它，你该怎么办呢？

其实有一个办法就是用 ThreadLocal 来解决，下面的示例代码就是 ThreadLocal 解决方案的具体实现，这段代码与前面 ThreadId 的代码高度相似，同样地，不同线程调用 SafeDateFormat 的 get() 方法将返回不同的 SimpleDateFormat 对象实例，由于不同线程并不共享 SimpleDateFormat，所以就像局部变量一样，是线程安全的。

static class SafeDateFormat {

  // 定义 ThreadLocal 变量

  static final ThreadLocal<DateFormat>

  tl=ThreadLocal.withInitial(

    ()-> new SimpleDateFormat(

      "yyyy-MM-dd HH:mm:ss"));

  static DateFormat get(){

    return tl.get();

  }

}

// 不同线程执行下面代码

// 返回的 df 是不同的

DateFormat df =

  SafeDateFormat.get()；

通过上面两个例子，相信你对 ThreadLocal 的用法以及应用场景都了解了，下面我们就来详细解释 ThreadLocal 的工作原理。

ThreadLocal 的工作原理

在解释 ThreadLocal 的工作原理之前， 你先自己想想：如果让你来实现 ThreadLocal 的功能，你会怎么设计呢？ThreadLocal 的目标是让不同的线程有不同的变量 V，那最直接的方法就是创建一个 Map，它的 Key 是线程，Value 是每个线程拥有的变量 V，ThreadLocal 内部持有这样的一个 Map 就可以了。你可以参考下面的示意图和示例代码来理解。

ThreadLocal 持有 Map 的示意图

class MyThreadLocal<T> {

  Map<Thread, T> locals =

    new ConcurrentHashMap<>();

  // 获取线程变量

  T get() {

    return locals.get(

      Thread.currentThread());

  }

  // 设置线程变量

  void set(T t) {

    locals.put(

      Thread.currentThread(), t);

  }

}

那 Java 的 ThreadLocal 是这么实现的吗？这一次我们的设计思路和 Java 的实现差异很大。Java 的实现里面也有一个 Map，叫做 ThreadLocalMap，不过持有 ThreadLocalMap 的不是 ThreadLocal，而是 Thread。Thread 这个类内部有一个私有属性 threadLocals，其类型就是 ThreadLocalMap，ThreadLocalMap 的 Key 是 ThreadLocal。你可以结合下面的示意图和精简之后的 Java 实现代码来理解。

Thread 持有 ThreadLocalMap 的示意图

class Thread {

  // 内部持有 ThreadLocalMap

  ThreadLocal.ThreadLocalMap

    threadLocals;

}

class ThreadLocal<T>{

  public T get() {

    // 首先获取线程持有的

    //ThreadLocalMap

    ThreadLocalMap map =

      Thread.currentThread()

        .threadLocals;

    // 在 ThreadLocalMap 中

    // 查找变量

    Entry e =

      map.getEntry(this);

    return e.value;

  }

  static class ThreadLocalMap{

    // 内部是数组而不是 Map

    Entry[] table;

    // 根据 ThreadLocal 查找 Entry

    Entry getEntry(ThreadLocal key){

      // 省略查找逻辑

    }

    //Entry 定义

    static class Entry extends

    WeakReference<ThreadLocal>{

      Object value;

    }

  }

}

初看上去，我们的设计方案和 Java 的实现仅仅是 Map 的持有方不同而已，我们的设计里面 Map 属于 ThreadLocal，而 Java 的实现里面 ThreadLocalMap 则是属于 Thread。这两种方式哪种更合理呢？很显然 Java 的实现更合理一些。在 Java 的实现方案里面，ThreadLocal 仅仅是一个代理工具类，内部并不持有任何与线程相关的数据，所有和线程相关的数据都存储在 Thread 里面，这样的设计容易理解。而从数据的亲缘性上来讲，ThreadLocalMap 属于 Thread 也更加合理。

当然还有一个更加深层次的原因，那就是不容易产生内存泄露。在我们的设计方案中，ThreadLocal 持有的 Map 会持有 Thread 对象的引用，这就意味着，只要 ThreadLocal 对象存在，那么 Map 中的 Thread 对象就永远不会被回收。ThreadLocal 的生命周期往往都比线程要长，所以这种设计方案很容易导致内存泄露。而 Java 的实现中 Thread 持有 ThreadLocalMap，而且 ThreadLocalMap 里对 ThreadLocal 的引用还是弱引用（WeakReference），所以只要 Thread 对象可以被回收，那么 ThreadLocalMap 就能被回收。Java 的这种实现方案虽然看上去复杂一些，但是更加安全。

Java 的 ThreadLocal 实现应该称得上深思熟虑了，不过即便如此深思熟虑，还是不能百分百地让程序员避免内存泄露，例如在线程池中使用 ThreadLocal，如果不谨慎就可能导致内存泄露。

ThreadLocal 与内存泄露

在线程池中使用 ThreadLocal 为什么可能导致内存泄露呢？原因就出在线程池中线程的存活时间太长，往往都是和程序同生共死的，这就意味着 Thread 持有的 ThreadLocalMap 一直都不会被回收，再加上 ThreadLocalMap 中的 Entry 对 ThreadLocal 是弱引用（WeakReference），所以只要 ThreadLocal 结束了自己的生命周期是可以被回收掉的。但是 Entry 中的 Value 却是被 Entry 强引用的，所以即便 Value 的生命周期结束了，Value 也是无法被回收的，从而导致内存泄露。

那在线程池中，我们该如何正确使用 ThreadLocal 呢？其实很简单，既然 JVM 不能做到自动释放对 Value 的强引用，那我们手动释放就可以了。如何能做到手动释放呢？估计你马上想到try{}finally{}方案了，这个简直就是手动释放资源的利器。示例的代码如下，你可以参考学习。

ExecutorService es;

ThreadLocal tl;

es.execute(()->{

  //ThreadLocal 增加变量

  tl.set(obj);

  try {

    // 省略业务逻辑代码

  }finally {

    // 手动清理 ThreadLocal

    tl.remove();

  }

});

InheritableThreadLocal 与继承性

通过 ThreadLocal 创建的线程变量，其子线程是无法继承的。也就是说你在线程中通过 ThreadLocal 创建了线程变量 V，而后该线程创建了子线程，你在子线程中是无法通过 ThreadLocal 来访问父线程的线程变量 V 的。

如果你需要子线程继承父线程的线程变量，那该怎么办呢？其实很简单，Java 提供了 InheritableThreadLocal 来支持这种特性，InheritableThreadLocal 是 ThreadLocal 子类，所以用法和 ThreadLocal 相同，这里就不多介绍了。

不过，我完全不建议你在线程池中使用 InheritableThreadLocal，不仅仅是因为它具有 ThreadLocal 相同的缺点——可能导致内存泄露，更重要的原因是：线程池中线程的创建是动态的，很容易导致继承关系错乱，如果你的业务逻辑依赖 InheritableThreadLocal，那么很可能导致业务逻辑计算错误，而这个错误往往比内存泄露更要命。

总结

线程本地存储模式本质上是一种避免共享的方案，由于没有共享，所以自然也就没有并发问题。如果你需要在并发场景中使用一个线程不安全的工具类，最简单的方案就是避免共享。避免共享有两种方案，一种方案是将这个工具类作为局部变量使用，另外一种方案就是线程本地存储模式。这两种方案，局部变量方案的缺点是在高并发场景下会频繁创建对象，而线程本地存储方案，每个线程只需要创建一个工具类的实例，所以不存在频繁创建对象的问题。

线程本地存储模式是解决并发问题的常用方案，所以 Java SDK 也提供了相应的实现：ThreadLocal。通过上面我们的分析，你应该能体会到 Java SDK 的实现已经是深思熟虑了，不过即便如此，仍不能尽善尽美，例如在线程池中使用 ThreadLocal 仍可能导致内存泄漏，所以使用 ThreadLocal 还是需要你打起精神，足够谨慎。

课后思考

实际工作中，有很多平台型的技术方案都是采用 ThreadLocal 来传递一些上下文信息，例如 Spring 使用 ThreadLocal 来传递事务信息。我们曾经说过，异步编程已经很成熟了，那你觉得在异步场景中，是否可以使用 Spring 的事务管理器呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

31 | Guarded Suspension模式：等待唤醒机制的规范实现

前不久，同事小灰工作中遇到一个问题，他开发了一个 Web 项目：Web 版的文件浏览器，通过它用户可以在浏览器里查看服务器上的目录和文件。这个项目依赖运维部门提供的文件浏览服务，而这个文件浏览服务只支持消息队列（MQ）方式接入。消息队列在互联网大厂中用的非常多，主要用作流量削峰和系统解耦。在这种接入方式中，发送消息和消费结果这两个操作之间是异步的，你可以参考下面的示意图来理解。

消息队列（MQ）示意图

在小灰的这个 Web 项目中，用户通过浏览器发过来一个请求，会被转换成一个异步消息发送给 MQ，等 MQ 返回结果后，再将这个结果返回至浏览器。小灰同学的问题是：给 MQ 发送消息的线程是处理 Web 请求的线程 T1，但消费 MQ 结果的线程并不是线程 T1，那线程 T1 如何等待 MQ 的返回结果呢？为了便于你理解这个场景，我将其代码化了，示例代码如下。

class Message{

  String id;

  String content;

}

// 该方法可以发送消息

void send(Message msg){

  // 省略相关代码

}

//MQ 消息返回后会调用该方法

// 该方法的执行线程不同于

// 发送消息的线程

void onMessage(Message msg){

  // 省略相关代码

}

// 处理浏览器发来的请求

Respond handleWebReq(){

  // 创建一消息

  Message msg1 = new

    Message("1","{...}");

  // 发送消息

  send(msg1);

  // 如何等待 MQ 返回的消息呢？

  String result = ...;

}

看到这里，相信你一定有点似曾相识的感觉，这不就是前面我们在《15 | Lock 和 Condition（下）：Dubbo 如何用管程实现异步转同步？》中曾介绍过的异步转同步问题吗？仔细分析，的确是这样，不过在那一篇文章中我们只是介绍了最终方案，让你知其然，但是并没有介绍这个方案是如何设计出来的，今天咱们再仔细聊聊这个问题，让你知其所以然，遇到类似问题也能自己设计出方案来。

Guarded Suspension 模式

上面小灰遇到的问题，在现实世界里比比皆是，只是我们一不小心就忽略了。比如，项目组团建要外出聚餐，我们提前预订了一个包间，然后兴冲冲地奔过去，到那儿后大堂经理看了一眼包间，发现服务员正在收拾，就会告诉我们：“您预订的包间服务员正在收拾，请您稍等片刻。”过了一会，大堂经理发现包间已经收拾完了，于是马上带我们去包间就餐。

我们等待包间收拾完的这个过程和小灰遇到的等待 MQ 返回消息本质上是一样的，都是等待一个条件满足：就餐需要等待包间收拾完，小灰的程序里要等待 MQ 返回消息。

那我们来看看现实世界里是如何解决这类问题的呢？现实世界里大堂经理这个角色很重要，我们是否等待，完全是由他来协调的。通过类比，相信你也一定有思路了：我们的程序里，也需要这样一个大堂经理。的确是这样，那程序世界里的大堂经理该如何设计呢？其实设计方案前人早就搞定了，而且还将其总结成了一个设计模式：Guarded Suspension。所谓 Guarded Suspension，直译过来就是“保护性地暂停”。那下面我们就来看看，Guarded Suspension 模式是如何模拟大堂经理进行保护性地暂停的。

下图就是 Guarded Suspension 模式的结构图，非常简单，一个对象 GuardedObject，内部有一个成员变量——受保护的对象，以及两个成员方法——get(Predicate<T> p)和onChanged(T obj)方法。其中，对象 GuardedObject 就是我们前面提到的大堂经理，受保护对象就是餐厅里面的包间；受保护对象的 get() 方法对应的是我们的就餐，就餐的前提条件是包间已经收拾好了，参数 p 就是用来描述这个前提条件的；受保护对象的 onChanged() 方法对应的是服务员把包间收拾好了，通过 onChanged() 方法可以 fire 一个事件，而这个事件往往能改变前提条件 p 的计算结果。下图中，左侧的绿色线程就是需要就餐的顾客，而右侧的蓝色线程就是收拾包间的服务员。

Guarded Suspension 模式结构图

GuardedObject 的内部实现非常简单，是管程的一个经典用法，你可以参考下面的示例代码，核心是：get() 方法通过条件变量的 await() 方法实现等待，onChanged() 方法通过条件变量的 signalAll() 方法实现唤醒功能。逻辑还是很简单的，所以这里就不再详细介绍了。

class GuardedObject<T>{

  // 受保护的对象

  T obj;

  final Lock lock =

    new ReentrantLock();

  final Condition done =

    lock.newCondition();

  final int timeout=1;

  // 获取受保护对象

  T get(Predicate<T> p) {

    lock.lock();

    try {

      //MESA 管程推荐写法

      while(!p.test(obj)){

        done.await(timeout,

          TimeUnit.SECONDS);

      }

    }catch(InterruptedException e){

      throw new RuntimeException(e);

    }finally{

      lock.unlock();

    }

    // 返回非空的受保护对象

    return obj;

  }

  // 事件通知方法

  void onChanged(T obj) {

    lock.lock();

    try {

      this.obj = obj;

      done.signalAll();

    } finally {

      lock.unlock();

    }

  }

}

扩展 Guarded Suspension 模式

上面我们介绍了 Guarded Suspension 模式及其实现，这个模式能够模拟现实世界里大堂经理的角色，那现在我们再来看看这个“大堂经理”能否解决小灰同学遇到的问题。

Guarded Suspension 模式里 GuardedObject 有两个核心方法，一个是 get() 方法，一个是 onChanged() 方法。很显然，在处理 Web 请求的方法 handleWebReq() 中，可以调用 GuardedObject 的 get() 方法来实现等待；在 MQ 消息的消费方法 onMessage() 中，可以调用 GuardedObject 的 onChanged() 方法来实现唤醒。

// 处理浏览器发来的请求

Respond handleWebReq(){

  // 创建一消息

  Message msg1 = new

    Message("1","{...}");

  // 发送消息

  send(msg1);

  // 利用 GuardedObject 实现等待

  GuardedObject<Message> go

    =new GuardObjec<>();

  Message r = go.get(

    t->t != null);

}

void onMessage(Message msg){

  // 如何找到匹配的 go？

  GuardedObject<Message> go=???

  go.onChanged(msg);

}

但是在实现的时候会遇到一个问题，handleWebReq() 里面创建了 GuardedObject 对象的实例 go，并调用其 get() 方等待结果，那在 onMessage() 方法中，如何才能够找到匹配的 GuardedObject 对象呢？这个过程类似服务员告诉大堂经理某某包间已经收拾好了，大堂经理如何根据包间找到就餐的人。现实世界里，大堂经理的头脑中，有包间和就餐人之间的关系图，所以服务员说完之后大堂经理立刻就能把就餐人找出来。

我们可以参考大堂经理识别就餐人的办法，来扩展一下 Guarded Suspension 模式，从而使它能够很方便地解决小灰同学的问题。在小灰的程序中，每个发送到 MQ 的消息，都有一个唯一性的属性 id，所以我们可以维护一个 MQ 消息 id 和 GuardedObject 对象实例的关系，这个关系可以类比大堂经理大脑里维护的包间和就餐人的关系。

有了这个关系，我们来看看具体如何实现。下面的示例代码是扩展 Guarded Suspension 模式的实现，扩展后的 GuardedObject 内部维护了一个 Map，其 Key 是 MQ 消息 id，而 Value 是 GuardedObject 对象实例，同时增加了静态方法 create() 和 fireEvent()；create() 方法用来创建一个 GuardedObject 对象实例，并根据 key 值将其加入到 Map 中，而 fireEvent() 方法则是模拟的大堂经理根据包间找就餐人的逻辑。

class GuardedObject<T>{

  // 受保护的对象

  T obj;

  final Lock lock =

    new ReentrantLock();

  final Condition done =

    lock.newCondition();

  final int timeout=2;

  // 保存所有 GuardedObject

  final static Map<Object, GuardedObject>

  gos=new ConcurrentHashMap<>();

  // 静态方法创建 GuardedObject

  static <K> GuardedObject

      create(K key){

    GuardedObject go=new GuardedObject();

    gos.put(key, go);

    return go;

  }

  static <K, T> void

      fireEvent(K key, T obj){

    GuardedObject go=gos.remove(key);

    if (go != null){

      go.onChanged(obj);

    }

  }

  // 获取受保护对象

  T get(Predicate<T> p) {

    lock.lock();

    try {

      //MESA 管程推荐写法

      while(!p.test(obj)){

        done.await(timeout,

          TimeUnit.SECONDS);

      }

    }catch(InterruptedException e){

      throw new RuntimeException(e);

    }finally{

      lock.unlock();

    }

    // 返回非空的受保护对象

    return obj;

  }

  // 事件通知方法

  void onChanged(T obj) {

    lock.lock();

    try {

      this.obj = obj;

      done.signalAll();

    } finally {

      lock.unlock();

    }

  }

}

这样利用扩展后的 GuardedObject 来解决小灰同学的问题就很简单了，具体代码如下所示。

// 处理浏览器发来的请求

Respond handleWebReq(){

  int id= 序号生成器.get();

  // 创建一消息

  Message msg1 = new

    Message(id,"{...}");

  // 创建 GuardedObject 实例

  GuardedObject<Message> go=

    GuardedObject.create(id);

  // 发送消息

  send(msg1);

  // 等待 MQ 消息

  Message r = go.get(

    t->t != null);

}

void onMessage(Message msg){

  // 唤醒等待的线程

  GuardedObject.fireEvent(

    msg.id, msg);

}

总结

Guarded Suspension 模式本质上是一种等待唤醒机制的实现，只不过 Guarded Suspension 模式将其规范化了。规范化的好处是你无需重头思考如何实现，也无需担心实现程序的可理解性问题，同时也能避免一不小心写出个 Bug 来。但 Guarded Suspension 模式在解决实际问题的时候，往往还是需要扩展的，扩展的方式有很多，本篇文章就直接对 GuardedObject 的功能进行了增强，Dubbo 中 DefaultFuture 这个类也是采用的这种方式，你可以对比着来看，相信对 DefaultFuture 的实现原理会理解得更透彻。当然，你也可以创建新的类来实现对 Guarded Suspension 模式的扩展。

Guarded Suspension 模式也常被称作 Guarded Wait 模式、Spin Lock 模式（因为使用了 while 循环去等待），这些名字都很形象，不过它还有一个更形象的非官方名字：多线程版本的 if。单线程场景中，if 语句是不需要等待的，因为在只有一个线程的条件下，如果这个线程被阻塞，那就没有其他活动线程了，这意味着 if 判断条件的结果也不会发生变化了。但是多线程场景中，等待就变得有意义了，这种场景下，if 判断条件的结果是可能发生变化的。所以，用“多线程版本的 if”来理解这个模式会更简单。

课后思考

有同学觉得用 done.await() 还要加锁，太啰嗦，还不如直接使用 sleep() 方法，下面是他的实现，你觉得他的写法正确吗？

// 获取受保护对象

T get(Predicate<T> p) {

  try {

    while(!p.test(obj)){

      TimeUnit.SECONDS

        .sleep(timeout);

    }

  }catch(InterruptedException e){

    throw new RuntimeException(e);

  }

  // 返回非空的受保护对象

  return obj;

}

// 事件通知方法

void onChanged(T obj) {

  this.obj = obj;

}

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

32 | Balking模式：再谈线程安全的单例模式

上一篇文章中，我们提到可以用“多线程版本的 if”来理解 Guarded Suspension 模式，不同于单线程中的 if，这个“多线程版本的 if”是需要等待的，而且还很执着，必须要等到条件为真。但很显然这个世界，不是所有场景都需要这么执着，有时候我们还需要快速放弃。

需要快速放弃的一个最常见的例子是各种编辑器提供的自动保存功能。自动保存功能的实现逻辑一般都是隔一定时间自动执行存盘操作，存盘操作的前提是文件做过修改，如果文件没有执行过修改操作，就需要快速放弃存盘操作。下面的示例代码将自动保存功能代码化了，很显然 AutoSaveEditor 这个类不是线程安全的，因为对共享变量 changed 的读写没有使用同步，那如何保证 AutoSaveEditor 的线程安全性呢？

class AutoSaveEditor{

  // 文件是否被修改过

  boolean changed=false;

  // 定时任务线程池

  ScheduledExecutorService ses =

    Executors.newSingleThreadScheduledExecutor();

  // 定时执行自动保存

  void startAutoSave(){

    ses.scheduleWithFixedDelay(()->{

      autoSave();

    }, 5, 5, TimeUnit.SECONDS);

  }

  // 自动存盘操作

  void autoSave(){

    if (!changed) {

      return;

    }

    changed = false;

    // 执行存盘操作

    // 省略且实现

    this.execSave();

  }

  // 编辑操作

  void edit(){

    // 省略编辑逻辑

    ......

    changed = true;

  }

}

解决这个问题相信你一定手到擒来了：读写共享变量 changed 的方法 autoSave() 和 edit() 都加互斥锁就可以了。这样做虽然简单，但是性能很差，原因是锁的范围太大了。那我们可以将锁的范围缩小，只在读写共享变量 changed 的地方加锁，实现代码如下所示。

// 自动存盘操作

void autoSave(){

  synchronized(this){

    if (!changed) {

      return;

    }

    changed = false;

  }

  // 执行存盘操作

  // 省略且实现

  this.execSave();

}

// 编辑操作

void edit(){

  // 省略编辑逻辑

  ......

  synchronized(this){

    changed = true;

  }

}

如果你深入地分析一下这个示例程序，你会发现，示例中的共享变量是一个状态变量，业务逻辑依赖于这个状态变量的状态：当状态满足某个条件时，执行某个业务逻辑，其本质其实不过就是一个 if 而已，放到多线程场景里，就是一种“多线程版本的 if”。这种“多线程版本的 if”的应用场景还是很多的，所以也有人把它总结成了一种设计模式，叫做Balking 模式。

Balking 模式的经典实现

Balking 模式本质上是一种规范化地解决“多线程版本的 if”的方案，对于上面自动保存的例子，使用 Balking 模式规范化之后的写法如下所示，你会发现仅仅是将 edit() 方法中对共享变量 changed 的赋值操作抽取到了 change() 中，这样的好处是将并发处理逻辑和业务逻辑分开。

boolean changed=false;

// 自动存盘操作

void autoSave(){

  synchronized(this){

    if (!changed) {

      return;

    }

    changed = false;

  }

  // 执行存盘操作

  // 省略且实现

  this.execSave();

}

// 编辑操作

void edit(){

  // 省略编辑逻辑

  ......

  change();

}

// 改变状态

void change(){

  synchronized(this){

    changed = true;

  }

}

用 volatile 实现 Balking 模式

前面我们用 synchronized 实现了 Balking 模式，这种实现方式最为稳妥，建议你实际工作中也使用这个方案。不过在某些特定场景下，也可以使用 volatile 来实现，但使用 volatile 的前提是对原子性没有要求。

在《29 | Copy-on-Write 模式：不是延时策略的 COW》中，有一个 RPC 框架路由表的案例，在 RPC 框架中，本地路由表是要和注册中心进行信息同步的，应用启动的时候，会将应用依赖服务的路由表从注册中心同步到本地路由表中，如果应用重启的时候注册中心宕机，那么会导致该应用依赖的服务均不可用，因为找不到依赖服务的路由表。为了防止这种极端情况出现，RPC 框架可以将本地路由表自动保存到本地文件中，如果重启的时候注册中心宕机，那么就从本地文件中恢复重启前的路由表。这其实也是一种降级的方案。

自动保存路由表和前面介绍的编辑器自动保存原理是一样的，也可以用 Balking 模式实现，不过我们这里采用 volatile 来实现，实现的代码如下所示。之所以可以采用 volatile 来实现，是因为对共享变量 changed 和 rt 的写操作不存在原子性的要求，而且采用 scheduleWithFixedDelay() 这种调度方式能保证同一时刻只有一个线程执行 autoSave() 方法。

// 路由表信息

public class RouterTable {

  //Key: 接口名

  //Value: 路由集合

  ConcurrentHashMap<String, CopyOnWriteArraySet<Router>>

    rt = new ConcurrentHashMap<>();

  // 路由表是否发生变化

  volatile boolean changed;

  // 将路由表写入本地文件的线程池

  ScheduledExecutorService ses=

    Executors.newSingleThreadScheduledExecutor();

  // 启动定时任务

  // 将变更后的路由表写入本地文件

  public void startLocalSaver(){

    ses.scheduleWithFixedDelay(()->{

      autoSave();

    }, 1, 1, MINUTES);

  }

  // 保存路由表到本地文件

  void autoSave() {

    if (!changed) {

      return;

    }

    changed = false;

    // 将路由表写入本地文件

    // 省略其方法实现

    this.save2Local();

  }

  // 删除路由

  public void remove(Router router) {

    Set<Router> set=rt.get(router.iface);

    if (set != null) {

      set.remove(router);

      // 路由表已发生变化

      changed = true;

    }

  }

  // 增加路由

  public void add(Router router) {

    Set<Router> set = rt.computeIfAbsent(

      route.iface, r ->

        new CopyOnWriteArraySet<>());

    set.add(router);

    // 路由表已发生变化

    changed = true;

  }

}

Balking 模式有一个非常典型的应用场景就是单次初始化，下面的示例代码是它的实现。这个实现方案中，我们将 init() 声明为一个同步方法，这样同一个时刻就只有一个线程能够执行 init() 方法；init() 方法在第一次执行完时会将 inited 设置为 true，这样后续执行 init() 方法的线程就不会再执行 doInit() 了。

class InitTest{

  boolean inited = false;

  synchronized void init(){

    if(inited){

      return;

    }

    // 省略 doInit 的实现

    doInit();

    inited=true;

  }

}

线程安全的单例模式本质上其实也是单次初始化，所以可以用 Balking 模式来实现线程安全的单例模式，下面的示例代码是其实现。这个实现虽然功能上没有问题，但是性能却很差，因为互斥锁 synchronized 将 getInstance() 方法串行化了，那有没有办法可以优化一下它的性能呢？

class Singleton{

  private static

    Singleton singleton;

  // 构造方法私有化

  private Singleton(){}

  // 获取实例（单例）

  public synchronized static

  Singleton getInstance(){

    if(singleton == null){

      singleton=new Singleton();

    }

    return singleton;

  }

}

办法当然是有的，那就是经典的双重检查（Double Check）方案，下面的示例代码是其详细实现。在双重检查方案中，一旦 Singleton 对象被成功创建之后，就不会执行 synchronized(Singleton.class){}相关的代码，也就是说，此时 getInstance() 方法的执行路径是无锁的，从而解决了性能问题。不过需要你注意的是，这个方案中使用了 volatile 来禁止编译优化，其原因你可以参考《01 | 可见性、原子性和有序性问题：并发编程 Bug 的源头》中相关的内容。至于获取锁后的二次检查，则是出于对安全性负责。

class Singleton{

  private static volatile

    Singleton singleton;

  // 构造方法私有化

  private Singleton() {}

  // 获取实例（单例）

  public static Singleton

  getInstance() {

    // 第一次检查

    if(singleton==null){

      synchronize{Singleton.class){

        // 获取锁后二次检查

        if(singleton==null){

          singleton=new Singleton();

        }

      }

    }

    return singleton;

  }

}

总结

Balking 模式和 Guarded Suspension 模式从实现上看似乎没有多大的关系，Balking 模式只需要用互斥锁就能解决，而 Guarded Suspension 模式则要用到管程这种高级的并发原语；但是从应用的角度来看，它们解决的都是“线程安全的 if”语义，不同之处在于，Guarded Suspension 模式会等待 if 条件为真，而 Balking 模式不会等待。

Balking 模式的经典实现是使用互斥锁，你可以使用 Java 语言内置 synchronized，也可以使用 SDK 提供 Lock；如果你对互斥锁的性能不满意，可以尝试采用 volatile 方案，不过使用 volatile 方案需要你更加谨慎。

当然你也可以尝试使用双重检查方案来优化性能，双重检查中的第一次检查，完全是出于对性能的考量：避免执行加锁操作，因为加锁操作很耗时。而加锁之后的二次检查，则是出于对安全性负责。双重检查方案在优化加锁性能方面经常用到，例如《17 | ReadWriteLock：如何快速实现一个完备的缓存？》中实现缓存按需加载功能时，也用到了双重检查方案。

课后思考

下面的示例代码中，init() 方法的本意是：仅需计算一次 count 的值，采用了 Balking 模式的 volatile 实现方式，你觉得这个实现是否有问题呢？

class Test{

  volatile boolean inited = false;

  int count = 0;

  void init(){

    if(inited){

      return;

    }

    inited = true;

    // 计算 count 的值

    count = calc();

  }

}

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

33 | Thread-Per-Message模式：最简单实用的分工方法

我们曾经把并发编程领域的问题总结为三个核心问题：分工、同步和互斥。其中，同步和互斥相关问题更多地源自微观，而分工问题则是源自宏观。我们解决问题，往往都是从宏观入手，在编程领域，软件的设计过程也是先从概要设计开始，而后才进行详细设计。同样，解决并发编程问题，首要问题也是解决宏观的分工问题。

并发编程领域里，解决分工问题也有一系列的设计模式，比较常用的主要有 Thread-Per-Message 模式、Worker Thread 模式、生产者 - 消费者模式等等。今天我们重点介绍 Thread-Per-Message 模式。

如何理解 Thread-Per-Message 模式

现实世界里，很多事情我们都需要委托他人办理，一方面受限于我们的能力，总有很多搞不定的事，比如教育小朋友，搞不定怎么办呢？只能委托学校老师了；另一方面受限于我们的时间，比如忙着写 Bug，哪有时间买别墅呢？只能委托房产中介了。委托他人代办有一个非常大的好处，那就是可以专心做自己的事了。

在编程领域也有很多类似的需求，比如写一个 HTTP Server，很显然只能在主线程中接收请求，而不能处理 HTTP 请求，因为如果在主线程中处理 HTTP 请求的话，那同一时间只能处理一个请求，太慢了！怎么办呢？可以利用代办的思路，创建一个子线程，委托子线程去处理 HTTP 请求。

这种委托他人办理的方式，在并发编程领域被总结为一种设计模式，叫做Thread-Per-Message 模式，简言之就是为每个任务分配一个独立的线程。这是一种最简单的分工方法，实现起来也非常简单。

用 Thread 实现 Thread-Per-Message 模式

Thread-Per-Message 模式的一个最经典的应用场景是网络编程里服务端的实现，服务端为每个客户端请求创建一个独立的线程，当线程处理完请求后，自动销毁，这是一种最简单的并发处理网络请求的方法。

网络编程里最简单的程序当数 echo 程序了，echo 程序的服务端会原封不动地将客户端的请求发送回客户端。例如，客户端发送 TCP 请求"Hello World"，那么服务端也会返回"Hello World"。

下面我们就以 echo 程序的服务端为例，介绍如何实现 Thread-Per-Message 模式。

在 Java 语言中，实现 echo 程序的服务端还是很简单的。只需要 30 行代码就能够实现，示例代码如下，我们为每个请求都创建了一个 Java 线程，核心代码是：new Thread(()->{…}).start()。

final ServerSocketChannel ssc =

  ServerSocketChannel.open().bind(

    new InetSocketAddress(8080));

// 处理请求

try {

  while (true) {

    // 接收请求

    SocketChannel sc = ssc.accept();

    // 每个请求都创建一个线程

    new Thread(()->{

      try {

        // 读 Socket

        ByteBuffer rb = ByteBuffer

          .allocateDirect(1024);

        sc.read(rb);

        // 模拟处理请求

        Thread.sleep(2000);

        // 写 Socket

        ByteBuffer wb =

          (ByteBuffer)rb.flip();

        sc.write(wb);

        // 关闭 Socket

        sc.close();

      }catch(Exception e){

        throw new UncheckedIOException(e);

      }

    }).start();

  }

} finally {

  ssc.close();

}

如果你熟悉网络编程，相信你一定会提出一个很尖锐的问题：上面这个 echo 服务的实现方案是不具备可行性的。原因在于 Java 中的线程是一个重量级的对象，创建成本很高，一方面创建线程比较耗时，另一方面线程占用的内存也比较大。所以，为每个请求创建一个新的线程并不适合高并发场景。

于是，你开始质疑 Thread-Per-Message 模式，而且开始重新思索解决方案，这时候很可能你会想到 Java 提供的线程池。你的这个思路没有问题，但是引入线程池难免会增加复杂度。其实你完全可以换一个角度来思考这个问题，语言、工具、框架本身应该是帮助我们更敏捷地实现方案的，而不是用来否定方案的，Thread-Per-Message 模式作为一种最简单的分工方案，Java 语言支持不了，显然是 Java 语言本身的问题。

Java 语言里，Java 线程是和操作系统线程一一对应的，这种做法本质上是将 Java 线程的调度权完全委托给操作系统，而操作系统在这方面非常成熟，所以这种做法的好处是稳定、可靠，但是也继承了操作系统线程的缺点：创建成本高。为了解决这个缺点，Java 并发包里提供了线程池等工具类。这个思路在很长一段时间里都是很稳妥的方案，但是这个方案并不是唯一的方案。

业界还有另外一种方案，叫做轻量级线程。这个方案在 Java 领域知名度并不高，但是在其他编程语言里却叫得很响，例如 Go 语言、Lua 语言里的协程，本质上就是一种轻量级的线程。轻量级的线程，创建的成本很低，基本上和创建一个普通对象的成本相似；并且创建的速度和内存占用相比操作系统线程至少有一个数量级的提升，所以基于轻量级线程实现 Thread-Per-Message 模式就完全没有问题了。

Java 语言目前也已经意识到轻量级线程的重要性了，OpenJDK 有个 Loom 项目，就是要解决 Java 语言的轻量级线程问题，在这个项目中，轻量级线程被叫做Fiber。下面我们就来看看基于 Fiber 如何实现 Thread-Per-Message 模式。

用 Fiber 实现 Thread-Per-Message 模式

Loom 项目在设计轻量级线程时，充分考量了当前 Java 线程的使用方式，采取的是尽量兼容的态度，所以使用上还是挺简单的。用 Fiber 实现 echo 服务的示例代码如下所示，对比 Thread 的实现，你会发现改动量非常小，只需要把 new Thread(()->{…}).start() 换成 Fiber.schedule(()->{}) 就可以了。

final ServerSocketChannel ssc =

  ServerSocketChannel.open().bind(

    new InetSocketAddress(8080));

// 处理请求

try{

  while (true) {

    // 接收请求

    final SocketChannel sc =

      serverSocketChannel.accept();

    Fiber.schedule(()->{

      try {

        // 读 Socket

        ByteBuffer rb = ByteBuffer

          .allocateDirect(1024);

        sc.read(rb);

        // 模拟处理请求

        LockSupport.parkNanos(2000*1000000);

        // 写 Socket

        ByteBuffer wb =

          (ByteBuffer)rb.flip()

        sc.write(wb);

        // 关闭 Socket

        sc.close();

      } catch(Exception e){

        throw new UncheckedIOException(e);

      }

    });

  }//while

}finally{

  ssc.close();

}

那使用 Fiber 实现的 echo 服务是否能够达到预期的效果呢？我们可以在 Linux 环境下做一个简单的实验，步骤如下：

首先通过 ulimit -u 512 将用户能创建的最大进程数（包括线程）设置为 512；

启动 Fiber 实现的 echo 程序；

利用压测工具 ab 进行压测：ab -r -c 20000 -n 200000 http:// 测试机 IP 地址:8080/

压测执行结果如下：

Concurrency Level:      20000

Time taken for tests:   67.718 seconds

Complete requests:      200000

Failed requests:        0

Write errors:           0

Non-2xx responses:      200000

Total transferred:      16400000 bytes

HTML transferred:       0 bytes

Requests per second:    2953.41 [#/sec] (mean)

Time per request:       6771.844 [ms] (mean)

Time per request:       0.339 [ms] (mean, across all concurrent requests)

Transfer rate:          236.50 [Kbytes/sec] received

Connection Times (ms)

              min  mean[+/-sd] median   max

Connect:        0  557 3541.6      1   63127

Processing:  2000 2010  31.8   2003    2615

Waiting:     1986 2008  30.9   2002    2615

Total:       2000 2567 3543.9   2004   65293

你会发现即便在 20000 并发下，该程序依然能够良好运行。同等条件下，Thread 实现的 echo 程序 512 并发都抗不过去，直接就 OOM 了。

如果你通过 Linux 命令 top -Hp pid 查看 Fiber 实现的 echo 程序的进程信息，你可以看到该进程仅仅创建了 16（不同 CPU 核数结果会不同）个操作系统线程。

如果你对 Loom 项目感兴趣，也想上手试一把，可以下载源代码自己构建，构建方法可以参考Project Loom 的相关资料，不过需要注意的是构建之前一定要把代码分支切换到 Fibers。

总结

并发编程领域的分工问题，指的是如何高效地拆解任务并分配给线程。前面我们在并发工具类模块中已经介绍了不少解决分工问题的工具类，例如 Future、CompletableFuture 、CompletionService、Fork/Join 计算框架等，这些工具类都能很好地解决特定应用场景的问题，所以，这些工具类曾经是 Java 语言引以为傲的。不过这些工具类都继承了 Java 语言的老毛病：太复杂。

如果你一直从事 Java 开发，估计你已经习以为常了，习惯性地认为这个复杂度是正常的。不过这个世界时刻都在变化，曾经正常的复杂度，现在看来也许就已经没有必要了，例如 Thread-Per-Message 模式如果使用线程池方案就会增加复杂度。

Thread-Per-Message 模式在 Java 领域并不是那么知名，根本原因在于 Java 语言里的线程是一个重量级的对象，为每一个任务创建一个线程成本太高，尤其是在高并发领域，基本就不具备可行性。不过这个背景条件目前正在发生巨变，Java 语言未来一定会提供轻量级线程，这样基于轻量级线程实现 Thread-Per-Message 模式就是一个非常靠谱的选择。

当然，对于一些并发度没那么高的异步场景，例如定时任务，采用 Thread-Per-Message 模式是完全没有问题的。实际工作中，我就见过完全基于 Thread-Per-Message 模式实现的分布式调度框架，这个框架为每个定时任务都分配了一个独立的线程。

课后思考

使用 Thread-Per-Message 模式会为每一个任务都创建一个线程，在高并发场景中，很容易导致应用 OOM，那有什么办法可以快速解决呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

34 | Worker Thread模式：如何避免重复创建线程？

在上一篇文章中，我们介绍了一种最简单的分工模式——Thread-Per-Message 模式，对应到现实世界，其实就是委托代办。这种分工模式如果用 Java Thread 实现，频繁地创建、销毁线程非常影响性能，同时无限制地创建线程还可能导致 OOM，所以在 Java 领域使用场景就受限了。

要想有效避免线程的频繁创建、销毁以及 OOM 问题，就不得不提今天我们要细聊的，也是 Java 领域使用最多的 Worker Thread 模式。

Worker Thread 模式及其实现

Worker Thread 模式可以类比现实世界里车间的工作模式：车间里的工人，有活儿了，大家一起干，没活儿了就聊聊天等着。你可以参考下面的示意图来理解，Worker Thread 模式中Worker Thread 对应到现实世界里，其实指的就是车间里的工人。不过这里需要注意的是，车间里的工人数量往往是确定的。

车间工作示意图

那在编程领域该如何模拟车间的这种工作模式呢？或者说如何去实现 Worker Thread 模式呢？通过上面的图，你很容易就能想到用阻塞队列做任务池，然后创建固定数量的线程消费阻塞队列中的任务。其实你仔细想会发现，这个方案就是 Java 语言提供的线程池。

线程池有很多优点，例如能够避免重复创建、销毁线程，同时能够限制创建线程的上限等等。学习完上一篇文章后你已经知道，用 Java 的 Thread 实现 Thread-Per-Message 模式难以应对高并发场景，原因就在于频繁创建、销毁 Java 线程的成本有点高，而且无限制地创建线程还可能导致应用 OOM。线程池，则恰好能解决这些问题。

那我们还是以 echo 程序为例，看看如何用线程池来实现。

下面的示例代码是用线程池实现的 echo 服务端，相比于 Thread-Per-Message 模式的实现，改动非常少，仅仅是创建了一个最多线程数为 500 的线程池 es，然后通过 es.execute() 方法将请求处理的任务提交给线程池处理。

ExecutorService es = Executors

  .newFixedThreadPool(500);

final ServerSocketChannel ssc =

  ServerSocketChannel.open().bind(

    new InetSocketAddress(8080));

// 处理请求

try {

  while (true) {

    // 接收请求

    SocketChannel sc = ssc.accept();

    // 将请求处理任务提交给线程池

    es.execute(()->{

      try {

        // 读 Socket

        ByteBuffer rb = ByteBuffer

          .allocateDirect(1024);

        sc.read(rb);

        // 模拟处理请求

        Thread.sleep(2000);

        // 写 Socket

        ByteBuffer wb =

          (ByteBuffer)rb.flip();

        sc.write(wb);

        // 关闭 Socket

        sc.close();

      }catch(Exception e){

        throw new UncheckedIOException(e);

      }

    });

  }

} finally {

  ssc.close();

  es.shutdown();

}

正确地创建线程池

Java 的线程池既能够避免无限制地创建线程导致 OOM，也能避免无限制地接收任务导致 OOM。只不过后者经常容易被我们忽略，例如在上面的实现中，就被我们忽略了。所以强烈建议你用创建有界的队列来接收任务。

当请求量大于有界队列的容量时，就需要合理地拒绝请求。如何合理地拒绝呢？这需要你结合具体的业务场景来制定，即便线程池默认的拒绝策略能够满足你的需求，也同样建议你在创建线程池时，清晰地指明拒绝策略。

同时，为了便于调试和诊断问题，我也强烈建议你在实际工作中给线程赋予一个业务相关的名字。

综合以上这三点建议，echo 程序中创建线程可以使用下面的示例代码。

ExecutorService es = new ThreadPoolExecutor(

  50, 500,

  60L, TimeUnit.SECONDS,

  // 注意要创建有界队列

  new LinkedBlockingQueue<Runnable>(2000),

  // 建议根据业务需求实现 ThreadFactory

  r->{

    return new Thread(r, "echo-"+ r.hashCode());

  },

  // 建议根据业务需求实现 RejectedExecutionHandler

  new ThreadPoolExecutor.CallerRunsPolicy());

避免线程死锁

使用线程池过程中，还要注意一种线程死锁的场景。如果提交到相同线程池的任务不是相互独立的，而是有依赖关系的，那么就有可能导致线程死锁。实际工作中，我就亲历过这种线程死锁的场景。具体现象是应用每运行一段时间偶尔就会处于无响应的状态，监控数据看上去一切都正常，但是实际上已经不能正常工作了。

这个出问题的应用，相关的逻辑精简之后，如下图所示，该应用将一个大型的计算任务分成两个阶段，第一个阶段的任务会等待第二阶段的子任务完成。在这个应用里，每一个阶段都使用了线程池，而且两个阶段使用的还是同一个线程池。

应用业务逻辑示意图

我们可以用下面的示例代码来模拟该应用，如果你执行下面的这段代码，会发现它永远执行不到最后一行。执行过程中没有任何异常，但是应用已经停止响应了。

//L1、L2 阶段共用的线程池

ExecutorService es = Executors.

  newFixedThreadPool(2);

//L1 阶段的闭锁

CountDownLatch l1=new CountDownLatch(2);

for (int i=0; i<2; i++){

  System.out.println("L1");

  // 执行 L1 阶段任务

  es.execute(()->{

    //L2 阶段的闭锁

    CountDownLatch l2=new CountDownLatch(2);

    // 执行 L2 阶段子任务

    for (int j=0; j<2; j++){

      es.execute(()->{

        System.out.println("L2");

        l2.countDown();

      });

    }

    // 等待 L2 阶段任务执行完

    l2.await();

    l1.countDown();

  });

}

// 等着 L1 阶段任务执行完

l1.await();

System.out.println("end");

当应用出现类似问题时，首选的诊断方法是查看线程栈。下图是上面示例代码停止响应后的线程栈，你会发现线程池中的两个线程全部都阻塞在 l2.await(); 这行代码上了，也就是说，线程池里所有的线程都在等待 L2 阶段的任务执行完，那 L2 阶段的子任务什么时候能够执行完呢？永远都没那一天了，为什么呢？因为线程池里的线程都阻塞了，没有空闲的线程执行 L2 阶段的任务了。

原因找到了，那如何解决就简单了，最简单粗暴的办法就是将线程池的最大线程数调大，如果能够确定任务的数量不是非常多的话，这个办法也是可行的，否则这个办法就行不通了。其实这种问题通用的解决方案是为不同的任务创建不同的线程池。对于上面的这个应用，L1 阶段的任务和 L2 阶段的任务如果各自都有自己的线程池，就不会出现这种问题了。

最后再次强调一下：提交到相同线程池中的任务一定是相互独立的，否则就一定要慎重。

总结

我们曾经说过，解决并发编程里的分工问题，最好的办法是和现实世界做对比。对比现实世界构建编程领域的模型，能够让模型更容易理解。上一篇我们介绍的 Thread-Per-Message 模式，类似于现实世界里的委托他人办理，而今天介绍的 Worker Thread 模式则类似于车间里工人的工作模式。如果你在设计阶段，发现对业务模型建模之后，模型非常类似于车间的工作模式，那基本上就能确定可以在实现阶段采用 Worker Thread 模式来实现。

Worker Thread 模式和 Thread-Per-Message 模式的区别有哪些呢？从现实世界的角度看，你委托代办人做事，往往是和代办人直接沟通的；对应到编程领域，其实现也是主线程直接创建了一个子线程，主子线程之间是可以直接通信的。而车间工人的工作方式则是完全围绕任务展开的，一个具体的任务被哪个工人执行，预先是无法知道的；对应到编程领域，则是主线程提交任务到线程池，但主线程并不关心任务被哪个线程执行。

Worker Thread 模式能避免线程频繁创建、销毁的问题，而且能够限制线程的最大数量。Java 语言里可以直接使用线程池来实现 Worker Thread 模式，线程池是一个非常基础和优秀的工具类，甚至有些大厂的编码规范都不允许用 new Thread() 来创建线程的，必须使用线程池。

不过使用线程池还是需要格外谨慎的，除了今天重点讲到的如何正确创建线程池、如何避免线程死锁问题，还需要注意前面我们曾经提到的 ThreadLocal 内存泄露问题。同时对于提交到线程池的任务，还要做好异常处理，避免异常的任务从眼前溜走，从业务的角度看，有时没有发现异常的任务后果往往都很严重。

课后思考

小灰同学写了如下的代码，本义是异步地打印字符串“QQ”，请问他的实现是否有问题呢？

ExecutorService pool = Executors

  .newSingleThreadExecutor();

pool.submit(() -> {

  try {

    String qq=pool.submit(()->"QQ").get();

    System.out.println(qq);

  } catch (Exception e) {

  }

});

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

35 | 两阶段终止模式：如何优雅地终止线程？

前面两篇文章我们讲述的内容，从纯技术的角度看，都是启动多线程去执行一个异步任务。既启动，那又该如何终止呢？今天咱们就从技术的角度聊聊如何优雅地终止线程，正所谓有始有终。

在《09 | Java 线程（上）：Java 线程的生命周期》中，我曾讲过：线程执行完或者出现异常就会进入终止状态。这样看，终止一个线程看上去很简单啊！一个线程执行完自己的任务，自己进入终止状态，这的确很简单。不过我们今天谈到的“优雅地终止线程”，不是自己终止自己，而是在一个线程 T1 中，终止线程 T2；这里所谓的“优雅”，指的是给 T2 一个机会料理后事，而不是被一剑封喉。

Java 语言的 Thread 类中曾经提供了一个 stop() 方法，用来终止线程，可是早已不建议使用了，原因是这个方法用的就是一剑封喉的做法，被终止的线程没有机会料理后事。

既然不建议使用 stop() 方法，那在 Java 领域，我们又该如何优雅地终止线程呢？

如何理解两阶段终止模式

前辈们经过认真对比分析，已经总结出了一套成熟的方案，叫做两阶段终止模式。顾名思义，就是将终止过程分成两个阶段，其中第一个阶段主要是线程 T1 向线程 T2发送终止指令，而第二阶段则是线程 T2响应终止指令。

两阶段终止模式示意图

那在 Java 语言里，终止指令是什么呢？这个要从 Java 线程的状态转换过程说起。我们在《09 | Java 线程（上）：Java 线程的生命周期》中曾经提到过 Java 线程的状态转换图，如下图所示。

Java 中的线程状态转换图

从这个图里你会发现，Java 线程进入终止状态的前提是线程进入 RUNNABLE 状态，而实际上线程也可能处在休眠状态，也就是说，我们要想终止一个线程，首先要把线程的状态从休眠状态转换到 RUNNABLE 状态。如何做到呢？这个要靠 Java Thread 类提供的interrupt() 方法，它可以将休眠状态的线程转换到 RUNNABLE 状态。

线程转换到 RUNNABLE 状态之后，我们如何再将其终止呢？RUNNABLE 状态转换到终止状态，优雅的方式是让 Java 线程自己执行完 run() 方法，所以一般我们采用的方法是设置一个标志位，然后线程会在合适的时机检查这个标志位，如果发现符合终止条件，则自动退出 run() 方法。这个过程其实就是我们前面提到的第二阶段：响应终止指令。

综合上面这两点，我们能总结出终止指令，其实包括两方面内容：interrupt() 方法和线程终止的标志位。

理解了两阶段终止模式之后，下面我们看一个实际工作中的案例。

用两阶段终止模式终止监控操作

实际工作中，有些监控系统需要动态地采集一些数据，一般都是监控系统发送采集指令给被监控系统的监控代理，监控代理接收到指令之后，从监控目标收集数据，然后回传给监控系统，详细过程如下图所示。出于对性能的考虑（有些监控项对系统性能影响很大，所以不能一直持续监控），动态采集功能一般都会有终止操作。

动态采集功能示意图

下面的示例代码是监控代理简化之后的实现，start() 方法会启动一个新的线程 rptThread 来执行监控数据采集和回传的功能，stop() 方法需要优雅地终止线程 rptThread，那 stop() 相关功能该如何实现呢？

class Proxy {

  boolean started = false;

  // 采集线程

  Thread rptThread;

  // 启动采集功能

  synchronized void start(){

    // 不允许同时启动多个采集线程

    if (started) {

      return;

    }

    started = true;

    rptThread = new Thread(()->{

      while (true) {

        // 省略采集、回传实现

        report();

        // 每隔两秒钟采集、回传一次数据

        try {

          Thread.sleep(2000);

        } catch (InterruptedException e) {

        }

      }

      // 执行到此处说明线程马上终止

      started = false;

    });

    rptThread.start();

  }

  // 终止采集功能

  synchronized void stop(){

    // 如何实现？

  }

}

按照两阶段终止模式，我们首先需要做的就是将线程 rptThread 状态转换到 RUNNABLE，做法很简单，只需要在调用 rptThread.interrupt() 就可以了。线程 rptThread 的状态转换到 RUNNABLE 之后，如何优雅地终止呢？下面的示例代码中，我们选择的标志位是线程的中断状态：Thread.currentThread().isInterrupted() ，需要注意的是，我们在捕获 Thread.sleep() 的中断异常之后，通过 Thread.currentThread().interrupt() 重新设置了线程的中断状态，因为 JVM 的异常处理会清除线程的中断状态。

class Proxy {

  boolean started = false;

  // 采集线程

  Thread rptThread;

  // 启动采集功能

  synchronized void start(){

    // 不允许同时启动多个采集线程

    if (started) {

      return;

    }

    started = true;

    rptThread = new Thread(()->{

      while (!Thread.currentThread().isInterrupted()){

        // 省略采集、回传实现

        report();

        // 每隔两秒钟采集、回传一次数据

        try {

          Thread.sleep(2000);

        } catch (InterruptedException e){

          // 重新设置线程中断状态

          Thread.currentThread().interrupt();

        }

      }

      // 执行到此处说明线程马上终止

      started = false;

    });

    rptThread.start();

  }

  // 终止采集功能

  synchronized void stop(){

    rptThread.interrupt();

  }

}

上面的示例代码的确能够解决当前的问题，但是建议你在实际工作中谨慎使用。原因在于我们很可能在线程的 run() 方法中调用第三方类库提供的方法，而我们没有办法保证第三方类库正确处理了线程的中断异常，例如第三方类库在捕获到 Thread.sleep() 方法抛出的中断异常后，没有重新设置线程的中断状态，那么就会导致线程不能够正常终止。所以强烈建议你设置自己的线程终止标志位，例如在下面的代码中，使用 isTerminated 作为线程终止标志位，此时无论是否正确处理了线程的中断异常，都不会影响线程优雅地终止。

class Proxy {

  // 线程终止标志位

  volatile boolean terminated = false;

  boolean started = false;

  // 采集线程

  Thread rptThread;

  // 启动采集功能

  synchronized void start(){

    // 不允许同时启动多个采集线程

    if (started) {

      return;

    }

    started = true;

    terminated = false;

    rptThread = new Thread(()->{

      while (!terminated){

        // 省略采集、回传实现

        report();

        // 每隔两秒钟采集、回传一次数据

        try {

          Thread.sleep(2000);

        } catch (InterruptedException e){

          // 重新设置线程中断状态

          Thread.currentThread().interrupt();

        }

      }

      // 执行到此处说明线程马上终止

      started = false;

    });

    rptThread.start();

  }

  // 终止采集功能

  synchronized void stop(){

    // 设置中断标志位

    terminated = true;

    // 中断线程 rptThread

    rptThread.interrupt();

  }

}

如何优雅地终止线程池

Java 领域用的最多的还是线程池，而不是手动地创建线程。那我们该如何优雅地终止线程池呢？

线程池提供了两个方法：shutdown()和shutdownNow()。这两个方法有什么区别呢？要了解它们的区别，就先需要了解线程池的实现原理。

我们曾经讲过，Java 线程池是生产者 - 消费者模式的一种实现，提交给线程池的任务，首先是进入一个阻塞队列中，之后线程池中的线程从阻塞队列中取出任务执行。

shutdown() 方法是一种很保守的关闭线程池的方法。线程池执行 shutdown() 后，就会拒绝接收新的任务，但是会等待线程池中正在执行的任务和已经进入阻塞队列的任务都执行完之后才最终关闭线程池。

而 shutdownNow() 方法，相对就激进一些了，线程池执行 shutdownNow() 后，会拒绝接收新的任务，同时还会中断线程池中正在执行的任务，已经进入阻塞队列的任务也被剥夺了执行的机会，不过这些被剥夺执行机会的任务会作为 shutdownNow() 方法的返回值返回。因为 shutdownNow() 方法会中断正在执行的线程，所以提交到线程池的任务，如果需要优雅地结束，就需要正确地处理线程中断。

如果提交到线程池的任务不允许取消，那就不能使用 shutdownNow() 方法终止线程池。不过，如果提交到线程池的任务允许后续以补偿的方式重新执行，也是可以使用 shutdownNow() 方法终止线程池的。《Java 并发编程实战》这本书第 7 章《取消与关闭》的“shutdownNow 的局限性”一节中，提到一种将已提交但尚未开始执行的任务以及已经取消的正在执行的任务保存起来，以便后续重新执行的方案，你可以参考一下，方案很简答，这里就不详细介绍了。

其实分析完 shutdown() 和 shutdownNow() 方法你会发现，它们实质上使用的也是两阶段终止模式，只是终止指令的范围不同而已，前者只影响阻塞队列接收任务，后者范围扩大到线程池中所有的任务。

总结

两阶段终止模式是一种应用很广泛的并发设计模式，在 Java 语言中使用两阶段终止模式来优雅地终止线程，需要注意两个关键点：一个是仅检查终止标志位是不够的，因为线程的状态可能处于休眠态；另一个是仅检查线程的中断状态也是不够的，因为我们依赖的第三方类库很可能没有正确处理中断异常。

当你使用 Java 的线程池来管理线程的时候，需要依赖线程池提供的 shutdown() 和 shutdownNow() 方法来终止线程池。不过在使用时需要注意它们的应用场景，尤其是在使用 shutdownNow() 的时候，一定要谨慎。

课后思考

本文的示例代码中，线程终止标志位 isTerminated 被声明为 volatile，你觉得是否有必要呢？

class Proxy {

  // 线程终止标志位

  volatile boolean terminated = false;

  ......

}

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

36 | 生产者-消费者模式：用流水线思想提高效率

前面我们在《34 | Worker Thread 模式：如何避免重复创建线程？》中讲到，Worker Thread 模式类比的是工厂里车间工人的工作模式。但其实在现实世界，工厂里还有一种流水线的工作模式，类比到编程领域，就是生产者 - 消费者模式。

生产者 - 消费者模式在编程领域的应用也非常广泛，前面我们曾经提到，Java 线程池本质上就是用生产者 - 消费者模式实现的，所以每当使用线程池的时候，其实就是在应用生产者 - 消费者模式。

当然，除了在线程池中的应用，为了提升性能，并发编程领域很多地方也都用到了生产者 - 消费者模式，例如 Log4j2 中异步 Appender 内部也用到了生产者 - 消费者模式。所以今天我们就来深入地聊聊生产者 - 消费者模式，看看它具体有哪些优点，以及如何提升系统的性能。

生产者 - 消费者模式的优点

生产者 - 消费者模式的核心是一个任务队列，生产者线程生产任务，并将任务添加到任务队列中，而消费者线程从任务队列中获取任务并执行。下面是生产者 - 消费者模式的一个示意图，你可以结合它来理解。

生产者 - 消费者模式示意图

从架构设计的角度来看，生产者 - 消费者模式有一个很重要的优点，就是解耦。解耦对于大型系统的设计非常重要，而解耦的一个关键就是组件之间的依赖关系和通信方式必须受限。在生产者 - 消费者模式中，生产者和消费者没有任何依赖关系，它们彼此之间的通信只能通过任务队列，所以生产者 - 消费者模式是一个不错的解耦方案。

除了架构设计上的优点之外，生产者 - 消费者模式还有一个重要的优点就是支持异步，并且能够平衡生产者和消费者的速度差异。在生产者 - 消费者模式中，生产者线程只需要将任务添加到任务队列而无需等待任务被消费者线程执行完，也就是说任务的生产和消费是异步的，这是与传统的方法之间调用的本质区别，传统的方法之间调用是同步的。

你或许会有这样的疑问，异步化处理最简单的方式就是创建一个新的线程去处理，那中间增加一个“任务队列”究竟有什么用呢？我觉得主要还是用于平衡生产者和消费者的速度差异。我们假设生产者的速率很慢，而消费者的速率很高，比如是 1:3，如果生产者有 3 个线程，采用创建新的线程的方式，那么会创建 3 个子线程，而采用生产者 - 消费者模式，消费线程只需要 1 个就可以了。Java 语言里，Java 线程和操作系统线程是一一对应的，线程创建得太多，会增加上下文切换的成本，所以 Java 线程不是越多越好，适量即可。而生产者 - 消费者模式恰好能支持你用适量的线程。

支持批量执行以提升性能

前面我们在《33 | Thread-Per-Message 模式：最简单实用的分工方法》中讲过轻量级的线程，如果使用轻量级线程，就没有必要平衡生产者和消费者的速度差异了，因为轻量级线程本身就是廉价的，那是否意味着生产者 - 消费者模式在性能优化方面就无用武之地了呢？当然不是，有一类并发场景应用生产者 - 消费者模式就有奇效，那就是批量执行任务。

例如，我们要在数据库里 INSERT 1000 条数据，有两种方案：第一种方案是用 1000 个线程并发执行，每个线程 INSERT 一条数据；第二种方案是用 1 个线程，执行一个批量的 SQL，一次性把 1000 条数据 INSERT 进去。这两种方案，显然是第二种方案效率更高，其实这样的应用场景就是我们上面提到的批量执行场景。

在《35 | 两阶段终止模式：如何优雅地终止线程？》文章中，我们提到一个监控系统动态采集的案例，其实最终回传的监控数据还是要存入数据库的（如下图）。但被监控系统往往有很多，如果每一条回传数据都直接 INSERT 到数据库，那么这个方案就是上面提到的第一种方案：每个线程 INSERT 一条数据。很显然，更好的方案是批量执行 SQL，那如何实现呢？这就要用到生产者 - 消费者模式了。

动态采集功能示意图

利用生产者 - 消费者模式实现批量执行 SQL 非常简单：将原来直接 INSERT 数据到数据库的线程作为生产者线程，生产者线程只需将数据添加到任务队列，然后消费者线程负责将任务从任务队列中批量取出并批量执行。

在下面的示例代码中，我们创建了 5 个消费者线程负责批量执行 SQL，这 5 个消费者线程以 while(true){} 循环方式批量地获取任务并批量地执行。需要注意的是，从任务队列中获取批量任务的方法 pollTasks() 中，首先是以阻塞方式获取任务队列中的一条任务，而后则是以非阻塞的方式获取任务；之所以首先采用阻塞方式，是因为如果任务队列中没有任务，这样的方式能够避免无谓的循环。

// 任务队列

BlockingQueue<Task> bq=new

  LinkedBlockingQueue<>(2000);

// 启动 5 个消费者线程

// 执行批量任务

void start() {

  ExecutorService es=xecutors

    .newFixedThreadPool(5);

  for (int i=0; i<5; i++) {

    es.execute(()->{

      try {

        while (true) {

          // 获取批量任务

          List<Task> ts=pollTasks();

          // 执行批量任务

          execTasks(ts);

        }

      } catch (Exception e) {

        e.printStackTrace();

      }

    });

  }

}

// 从任务队列中获取批量任务

List<Task> pollTasks()

    throws InterruptedException{

  List<Task> ts=new LinkedList<>();

  // 阻塞式获取一条任务

  Task t = bq.take();

  while (t != null) {

    ts.add(t);

    // 非阻塞式获取一条任务

    t = bq.poll();

  }

  return ts;

}

// 批量执行任务

execTasks(List<Task> ts) {

  // 省略具体代码无数

}

支持分阶段提交以提升性能

利用生产者 - 消费者模式还可以轻松地支持一种分阶段提交的应用场景。我们知道写文件如果同步刷盘性能会很慢，所以对于不是很重要的数据，我们往往采用异步刷盘的方式。我曾经参与过一个项目，其中的日志组件是自己实现的，采用的就是异步刷盘方式，刷盘的时机是：

ERROR 级别的日志需要立即刷盘；

数据积累到 500 条需要立即刷盘；

存在未刷盘数据，且 5 秒钟内未曾刷盘，需要立即刷盘。

这个日志组件的异步刷盘操作本质上其实就是一种分阶段提交。下面我们具体看看用生产者 - 消费者模式如何实现。在下面的示例代码中，可以通过调用 info()和error() 方法写入日志，这两个方法都是创建了一个日志任务 LogMsg，并添加到阻塞队列中，调用 info()和error() 方法的线程是生产者；而真正将日志写入文件的是消费者线程，在 Logger 这个类中，我们只创建了 1 个消费者线程，在这个消费者线程中，会根据刷盘规则执行刷盘操作，逻辑很简单，这里就不赘述了。

class Logger {

  // 任务队列

  final BlockingQueue<LogMsg> bq

    = new BlockingQueue<>();

  //flush 批量

  static final int batchSize=500;

  // 只需要一个线程写日志

  ExecutorService es =

    Executors.newFixedThreadPool(1);

  // 启动写日志线程

  void start(){

    File file=File.createTempFile(

      "foo", ".log");

    final FileWriter writer=

      new FileWriter(file);

    this.es.execute(()->{

      try {

        // 未刷盘日志数量

        int curIdx = 0;

        long preFT=System.currentTimeMillis();

        while (true) {

          LogMsg log = bq.poll(

            5, TimeUnit.SECONDS);

          // 写日志

          if (log != null) {

            writer.write(log.toString());

            ++curIdx;

          }

          // 如果不存在未刷盘数据，则无需刷盘

          if (curIdx <= 0) {

            continue;

          }

          // 根据规则刷盘

          if (log!=null && log.level==LEVEL.ERROR ||

              curIdx == batchSize ||

              System.currentTimeMillis()-preFT>5000){

            writer.flush();

            curIdx = 0;

            preFT=System.currentTimeMillis();

          }

        }

      }catch(Exception e){

        e.printStackTrace();

      } finally {

        try {

          writer.flush();

          writer.close();

        }catch(IOException e){

          e.printStackTrace();

        }

      }

    });

  }

  // 写 INFO 级别日志

  void info(String msg) {

    bq.put(new LogMsg(

      LEVEL.INFO, msg));

  }

  // 写 ERROR 级别日志

  void error(String msg) {

    bq.put(new LogMsg(

      LEVEL.ERROR, msg));

  }

}

// 日志级别

enum LEVEL {

  INFO, ERROR

}

class LogMsg {

  LEVEL level;

  String msg;

  // 省略构造函数实现

  LogMsg(LEVEL lvl, String msg){}

  // 省略 toString() 实现

  String toString(){}

}

总结

Java 语言提供的线程池本身就是一种生产者 - 消费者模式的实现，但是线程池中的线程每次只能从任务队列中消费一个任务来执行，对于大部分并发场景这种策略都没有问题。但是有些场景还是需要自己来实现，例如需要批量执行以及分阶段提交的场景。

生产者 - 消费者模式在分布式计算中的应用也非常广泛。在分布式场景下，你可以借助分布式消息队列（MQ）来实现生产者 - 消费者模式。MQ 一般都会支持两种消息模型，一种是点对点模型，一种是发布订阅模型。这两种模型的区别在于，点对点模型里一个消息只会被一个消费者消费，和 Java 的线程池非常类似（Java 线程池的任务也只会被一个线程执行）；而发布订阅模型里一个消息会被多个消费者消费，本质上是一种消息的广播，在多线程编程领域，你可以结合观察者模式实现广播功能。

课后思考

在日志组件异步刷盘的示例代码中，写日志的线程以 while(true){} 的方式执行，你有哪些办法可以优雅地终止这个线程呢？

this.writer.execute(()->{

  try {

    // 未刷盘日志数量

    int curIdx = 0;

    long preFT=System.currentTimeMillis();

    while (true) {

    ......

    }

  } catch(Exception e) {}

}

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

37 | 设计模式模块热点问题答疑

多线程设计模式是前人解决并发问题的经验总结，当我们试图解决一个并发问题时，首选方案往往是使用匹配的设计模式，这样能避免走弯路。同时，由于大家都熟悉设计模式，所以使用设计模式还能提升方案和代码的可理解性。

在这个模块，我们总共介绍了 9 种常见的多线程设计模式。下面我们就对这 9 种设计模式做个分类和总结，同时也对前面各章的课后思考题做个答疑。

避免共享的设计模式

Immutability 模式、Copy-on-Write 模式和线程本地存储模式本质上都是为了避免共享，只是实现手段不同而已。这 3 种设计模式的实现都很简单，但是实现过程中有些细节还是需要格外注意的。例如，使用 Immutability 模式需要注意对象属性的不可变性，使用 Copy-on-Write 模式需要注意性能问题，使用线程本地存储模式需要注意异步执行问题。所以，每篇文章最后我设置的课后思考题的目的就是提醒你注意这些细节。

《28 | Immutability 模式：如何利用不变性解决并发问题？》的课后思考题是讨论 Account 这个类是不是具备不可变性。这个类初看上去属于不可变对象的中规中矩实现，而实质上这个实现是有问题的，原因在于 StringBuffer 不同于 String，StringBuffer 不具备不可变性，通过 getUser() 方法获取 user 之后，是可以修改 user 的。一个简单的解决方案是让 getUser() 方法返回 String 对象。

public final class Account{

  private final

    StringBuffer user;

  public Account(String user){

    this.user =

      new StringBuffer(user);

  }

  // 返回的 StringBuffer 并不具备不可变性

  public StringBuffer getUser(){

    return this.user;

  }

  public String toString(){

    return "user"+user;

  }

}

《29 | Copy-on-Write 模式：不是延时策略的 COW》的课后思考题是讨论 Java SDK 中为什么没有提供 CopyOnWriteLinkedList。这是一个开放性的问题，没有标准答案，但是性能问题一定是其中一个很重要的原因，毕竟完整地复制 LinkedList 性能开销太大了。

《30 | 线程本地存储模式：没有共享，就没有伤害》的课后思考题是在异步场景中，是否可以使用 Spring 的事务管理器。答案显然是不能的，Spring 使用 ThreadLocal 来传递事务信息，因此这个事务信息是不能跨线程共享的。实际工作中有很多类库都是用 ThreadLocal 传递上下文信息的，这种场景下如果有异步操作，一定要注意上下文信息是不能跨线程共享的。

多线程版本 IF 的设计模式

Guarded Suspension 模式和Balking 模式都可以简单地理解为“多线程版本的 if”，但它们的区别在于前者会等待 if 条件变为真，而后者则不需要等待。

Guarded Suspension 模式的经典实现是使用管程，很多初学者会简单地用线程 sleep 的方式实现，比如《31 | Guarded Suspension 模式：等待唤醒机制的规范实现》的思考题就是用线程 sleep 方式实现的。但不推荐你使用这种方式，最重要的原因是性能，如果 sleep 的时间太长，会影响响应时间；sleep 的时间太短，会导致线程频繁地被唤醒，消耗系统资源。

同时，示例代码的实现也有问题：由于 obj 不是 volatile 变量，所以即便 obj 被设置了正确的值，执行 while(!p.test(obj)) 的线程也有可能看不到，从而导致更长时间的 sleep。

// 获取受保护对象

T get(Predicate<T> p) {

  try {

    //obj 的可见性无法保证

    while(!p.test(obj)){

      TimeUnit.SECONDS

        .sleep(timeout);

    }

  }catch(InterruptedException e){

    throw new RuntimeException(e);

  }

  // 返回非空的受保护对象

  return obj;

}

// 事件通知方法

void onChanged(T obj) {

  this.obj = obj;

}

实现 Balking 模式最容易忽视的就是竞态条件问题。比如，《32 | Balking 模式：再谈线程安全的单例模式》的思考题就存在竞态条件问题。因此，在多线程场景中使用 if 语句时，一定要多问自己一遍：是否存在竞态条件。

class Test{

  volatile boolean inited = false;

  int count = 0;

  void init(){

    // 存在竞态条件

    if(inited){

      return;

    }

    // 有可能多个线程执行到这里

    inited = true;

    // 计算 count 的值

    count = calc();

  }

}

三种最简单的分工模式

Thread-Per-Message 模式、Worker Thread 模式和生产者 - 消费者模式是三种最简单实用的多线程分工方法。虽说简单，但也还是有许多细节需要你多加小心和注意。

Thread-Per-Message 模式在实现的时候需要注意是否存在线程的频繁创建、销毁以及是否可能导致 OOM。在《33 | Thread-Per-Message 模式：最简单实用的分工方法》文章中，最后的思考题就是关于如何快速解决 OOM 问题的。在高并发场景中，最简单的办法其实是限流。当然，限流方案也并不局限于解决 Thread-Per-Message 模式中的 OOM 问题。

Worker Thread 模式的实现，需要注意潜在的线程死锁问题。《34 | Worker Thread 模式：如何避免重复创建线程？》思考题中的示例代码就存在线程死锁。有名叫 vector 的同学关于这道思考题的留言，我觉得描述得很贴切和形象：“工厂里只有一个工人，他的工作就是同步地等待工厂里其他人给他提供东西，然而并没有其他人，他将等到天荒地老，海枯石烂！”因此，共享线程池虽然能够提供线程池的使用效率，但一定要保证一个前提，那就是：任务之间没有依赖关系。

ExecutorService pool = Executors

  .newSingleThreadExecutor();

// 提交主任务

pool.submit(() -> {

  try {

    // 提交子任务并等待其完成，

    // 会导致线程死锁

    String qq=pool.submit(()->"QQ").get();

    System.out.println(qq);

  } catch (Exception e) {

  }

});

Java 线程池本身就是一种生产者 - 消费者模式的实现，所以大部分场景你都不需要自己实现，直接使用 Java 的线程池就可以了。但若能自己灵活地实现生产者 - 消费者模式会更好，比如可以实现批量执行和分阶段提交，不过这过程中还需要注意如何优雅地终止线程，《36 | 生产者 - 消费者模式：用流水线思想提高效率》的思考题就是关于此的。

如何优雅地终止线程？我们在《35 | 两阶段终止模式：如何优雅地终止线程？》有过详细介绍，两阶段终止模式是一种通用的解决方案。但其实终止生产者 - 消费者服务还有一种更简单的方案，叫做“毒丸”对象。《Java 并发编程实战》第 7 章的 7.2.3 节对“毒丸”对象有过详细的介绍。简单来讲，“毒丸”对象是生产者生产的一条特殊任务，然后当消费者线程读到“毒丸”对象时，会立即终止自身的执行。

下面是用“毒丸”对象终止写日志线程的具体实现，整体的实现过程还是很简单的：类 Logger 中声明了一个“毒丸”对象 poisonPill ，当消费者线程从阻塞队列 bq 中取出一条 LogMsg 后，先判断是否是“毒丸”对象，如果是，则 break while 循环，从而终止自己的执行。

class Logger {

  // 用于终止日志执行的“毒丸”

  final LogMsg poisonPill =

    new LogMsg(LEVEL.ERROR, "");

  // 任务队列

  final BlockingQueue<LogMsg> bq

    = new BlockingQueue<>();

  // 只需要一个线程写日志

  ExecutorService es =

    Executors.newFixedThreadPool(1);

  // 启动写日志线程

  void start(){

    File file=File.createTempFile(

      "foo", ".log");

    final FileWriter writer=

      new FileWriter(file);

    this.es.execute(()->{

      try {

        while (true) {

          LogMsg log = bq.poll(

            5, TimeUnit.SECONDS);

          // 如果是“毒丸”，终止执行

          if(poisonPill.equals(logMsg)){

            break;

          }

          // 省略执行逻辑

        }

      } catch(Exception e){

      } finally {

        try {

          writer.flush();

          writer.close();

        }catch(IOException e){}

      }

    });

  }

  // 终止写日志线程

  public void stop() {

    // 将“毒丸”对象加入阻塞队列

    bq.add(poisonPill);

    es.shutdown();

  }

}

总结

到今天为止，“并发设计模式”模块就告一段落了，多线程的设计模式当然不止我们提到的这 9 种，不过这里提到的这 9 种设计模式一定是最简单实用的。如果感兴趣，你也可以结合《图解 Java 多线程设计模式》这本书来深入学习这个模块，这是一本不错的并发编程入门书籍，虽然重点是讲解设计模式，但是也详细讲解了设计模式中涉及到的方方面面的基础知识，而且深入浅出，非常推荐入门的同学认真学习一下。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

38 | 案例分析（一）：高性能限流器Guava RateLimiter

从今天开始，我们就进入案例分析模块了。 这个模块我们将分析四个经典的开源框架，看看它们是如何处理并发问题的，通过这四个案例的学习，相信你会对如何解决并发问题有个更深入的认识。

首先我们来看看Guava RateLimiter 是如何解决高并发场景下的限流问题的。Guava 是 Google 开源的 Java 类库，提供了一个工具类 RateLimiter。我们先来看看 RateLimiter 的使用，让你对限流有个感官的印象。假设我们有一个线程池，它每秒只能处理两个任务，如果提交的任务过快，可能导致系统不稳定，这个时候就需要用到限流。

在下面的示例代码中，我们创建了一个流速为 2 个请求 / 秒的限流器，这里的流速该怎么理解呢？直观地看，2 个请求 / 秒指的是每秒最多允许 2 个请求通过限流器，其实在 Guava 中，流速还有更深一层的意思：是一种匀速的概念，2 个请求 / 秒等价于 1 个请求 /500 毫秒。

在向线程池提交任务之前，调用 acquire() 方法就能起到限流的作用。通过示例代码的执行结果，任务提交到线程池的时间间隔基本上稳定在 500 毫秒。

// 限流器流速：2 个请求 / 秒

RateLimiter limiter =

  RateLimiter.create(2.0);

// 执行任务的线程池

ExecutorService es = Executors

  .newFixedThreadPool(1);

// 记录上一次执行时间

prev = System.nanoTime();

// 测试执行 20 次

for (int i=0; i<20; i++){

  // 限流器限流

  limiter.acquire();

  // 提交任务异步执行

  es.execute(()->{

    long cur=System.nanoTime();

    // 打印时间间隔：毫秒

    System.out.println(

      (cur-prev)/1000_000);

    prev = cur;

  });

}

输出结果：

...

500

499

499

500

499

经典限流算法：令牌桶算法

Guava 的限流器使用上还是很简单的，那它是如何实现的呢？Guava 采用的是令牌桶算法，其核心是要想通过限流器，必须拿到令牌。也就是说，只要我们能够限制发放令牌的速率，那么就能控制流速了。令牌桶算法的详细描述如下：

令牌以固定的速率添加到令牌桶中，假设限流的速率是 r/ 秒，则令牌每 1/r 秒会添加一个；

假设令牌桶的容量是 b ，如果令牌桶已满，则新的令牌会被丢弃；

请求能够通过限流器的前提是令牌桶中有令牌。

这个算法中，限流的速率 r 还是比较容易理解的，但令牌桶的容量 b 该怎么理解呢？b 其实是 burst 的简写，意义是限流器允许的最大突发流量。比如 b=10，而且令牌桶中的令牌已满，此时限流器允许 10 个请求同时通过限流器，当然只是突发流量而已，这 10 个请求会带走 10 个令牌，所以后续的流量只能按照速率 r 通过限流器。

令牌桶这个算法，如何用 Java 实现呢？很可能你的直觉会告诉你生产者 - 消费者模式：一个生产者线程定时向阻塞队列中添加令牌，而试图通过限流器的线程则作为消费者线程，只有从阻塞队列中获取到令牌，才允许通过限流器。

这个算法看上去非常完美，而且实现起来非常简单，如果并发量不大，这个实现并没有什么问题。可实际情况却是使用限流的场景大部分都是高并发场景，而且系统压力已经临近极限了，此时这个实现就有问题了。问题就出在定时器上，在高并发场景下，当系统压力已经临近极限的时候，定时器的精度误差会非常大，同时定时器本身会创建调度线程，也会对系统的性能产生影响。

那还有什么好的实现方式呢？当然有，Guava 的实现就没有使用定时器，下面我们就来看看它是如何实现的。

Guava 如何实现令牌桶算法

Guava 实现令牌桶算法，用了一个很简单的办法，其关键是记录并动态计算下一令牌发放的时间。下面我们以一个最简单的场景来介绍该算法的执行过程。假设令牌桶的容量为 b=1，限流速率 r = 1 个请求 / 秒，如下图所示，如果当前令牌桶中没有令牌，下一个令牌的发放时间是在第 3 秒，而在第 2 秒的时候有一个线程 T1 请求令牌，此时该如何处理呢？

线程 T1 请求令牌示意图

对于这个请求令牌的线程而言，很显然需要等待 1 秒，因为 1 秒以后（第 3 秒）它就能拿到令牌了。此时需要注意的是，下一个令牌发放的时间也要增加 1 秒，为什么呢？因为第 3 秒发放的令牌已经被线程 T1 预占了。处理之后如下图所示。

线程 T1 请求结束示意图

假设 T1 在预占了第 3 秒的令牌之后，马上又有一个线程 T2 请求令牌，如下图所示。

线程 T2 请求令牌示意图

很显然，由于下一个令牌产生的时间是第 4 秒，所以线程 T2 要等待两秒的时间，才能获取到令牌，同时由于 T2 预占了第 4 秒的令牌，所以下一令牌产生时间还要增加 1 秒，完全处理之后，如下图所示。

线程 T2 请求结束示意图

上面线程 T1、T2 都是在下一令牌产生时间之前请求令牌，如果线程在下一令牌产生时间之后请求令牌会如何呢？假设在线程 T1 请求令牌之后的 5 秒，也就是第 7 秒，线程 T3 请求令牌，如下图所示。

线程 T3 请求令牌示意图

由于在第 5 秒已经产生了一个令牌，所以此时线程 T3 可以直接拿到令牌，而无需等待。在第 7 秒，实际上限流器能够产生 3 个令牌，第 5、6、7 秒各产生一个令牌。由于我们假设令牌桶的容量是 1，所以第 6、7 秒产生的令牌就丢弃了，其实等价地你也可以认为是保留的第 7 秒的令牌，丢弃的第 5、6 秒的令牌，也就是说第 7 秒的令牌被线程 T3 占有了，于是下一令牌的的产生时间应该是第 8 秒，如下图所示。

线程 T3 请求结束示意图

通过上面简要地分析，你会发现，我们只需要记录一个下一令牌产生的时间，并动态更新它，就能够轻松完成限流功能。我们可以将上面的这个算法代码化，示例代码如下所示，依然假设令牌桶的容量是 1。关键是reserve() 方法，这个方法会为请求令牌的线程预分配令牌，同时返回该线程能够获取令牌的时间。其实现逻辑就是上面提到的：如果线程请求令牌的时间在下一令牌产生时间之后，那么该线程立刻就能够获取令牌；反之，如果请求时间在下一令牌产生时间之前，那么该线程是在下一令牌产生的时间获取令牌。由于此时下一令牌已经被该线程预占，所以下一令牌产生的时间需要加上 1 秒。

class SimpleLimiter {

  // 下一令牌产生时间

  long next = System.nanoTime();

  // 发放令牌间隔：纳秒

  long interval = 1000_000_000;

  // 预占令牌，返回能够获取令牌的时间

  synchronized long reserve(long now){

    // 请求时间在下一令牌产生时间之后

    // 重新计算下一令牌产生时间

    if (now > next){

      // 将下一令牌产生时间重置为当前时间

      next = now;

    }

    // 能够获取令牌的时间

    long at=next;

    // 设置下一令牌产生时间

    next += interval;

    // 返回线程需要等待的时间

    return Math.max(at, 0L);

  }

  // 申请令牌

  void acquire() {

    // 申请令牌时的时间

    long now = System.nanoTime();

    // 预占令牌

    long at=reserve(now);

    long waitTime=max(at-now, 0);

    // 按照条件等待

    if(waitTime > 0) {

      try {

        TimeUnit.NANOSECONDS

          .sleep(waitTime);

      }catch(InterruptedException e){

        e.printStackTrace();

      }

    }

  }

}

如果令牌桶的容量大于 1，又该如何处理呢？按照令牌桶算法，令牌要首先从令牌桶中出，所以我们需要按需计算令牌桶中的数量，当有线程请求令牌时，先从令牌桶中出。具体的代码实现如下所示。我们增加了一个resync() 方法，在这个方法中，如果线程请求令牌的时间在下一令牌产生时间之后，会重新计算令牌桶中的令牌数，新产生的令牌的计算公式是：(now-next)/interval，你可对照上面的示意图来理解。reserve() 方法中，则增加了先从令牌桶中出令牌的逻辑，不过需要注意的是，如果令牌是从令牌桶中出的，那么 next 就无需增加一个 interval 了。

class SimpleLimiter {

  // 当前令牌桶中的令牌数量

  long storedPermits = 0;

  // 令牌桶的容量

  long maxPermits = 3;

  // 下一令牌产生时间

  long next = System.nanoTime();

  // 发放令牌间隔：纳秒

  long interval = 1000_000_000;

  // 请求时间在下一令牌产生时间之后, 则

  // 1. 重新计算令牌桶中的令牌数

  // 2. 将下一个令牌发放时间重置为当前时间

  void resync(long now) {

    if (now > next) {

      // 新产生的令牌数

      long newPermits=(now-next)/interval;

      // 新令牌增加到令牌桶

      storedPermits=min(maxPermits,

        storedPermits + newPermits);

      // 将下一个令牌发放时间重置为当前时间

      next = now;

    }

  }

  // 预占令牌，返回能够获取令牌的时间

  synchronized long reserve(long now){

    resync(now);

    // 能够获取令牌的时间

    long at = next;

    // 令牌桶中能提供的令牌

    long fb=min(1, storedPermits);

    // 令牌净需求：首先减掉令牌桶中的令牌

    long nr = 1 - fb;

    // 重新计算下一令牌产生时间

    next = next + nr*interval;

    // 重新计算令牌桶中的令牌

    this.storedPermits -= fb;

    return at;

  }

  // 申请令牌

  void acquire() {

    // 申请令牌时的时间

    long now = System.nanoTime();

    // 预占令牌

    long at=reserve(now);

    long waitTime=max(at-now, 0);

    // 按照条件等待

    if(waitTime > 0) {

      try {

        TimeUnit.NANOSECONDS

          .sleep(waitTime);

      }catch(InterruptedException e){

        e.printStackTrace();

      }

    }

  }

}

总结

经典的限流算法有两个，一个是令牌桶算法（Token Bucket），另一个是漏桶算法（Leaky Bucket）。令牌桶算法是定时向令牌桶发送令牌，请求能够从令牌桶中拿到令牌，然后才能通过限流器；而漏桶算法里，请求就像水一样注入漏桶，漏桶会按照一定的速率自动将水漏掉，只有漏桶里还能注入水的时候，请求才能通过限流器。令牌桶算法和漏桶算法很像一个硬币的正反面，所以你可以参考令牌桶算法的实现来实现漏桶算法。

上面我们介绍了 Guava 是如何实现令牌桶算法的，我们的示例代码是对 Guava RateLimiter 的简化，Guava RateLimiter 扩展了标准的令牌桶算法，比如还能支持预热功能。对于按需加载的缓存来说，预热后缓存能支持 5 万 TPS 的并发，但是在预热前 5 万 TPS 的并发直接就把缓存击垮了，所以如果需要给该缓存限流，限流器也需要支持预热功能，在初始阶段，限制的流速 r 很小，但是动态增长的。预热功能的实现非常复杂，Guava 构建了一个积分函数来解决这个问题，如果你感兴趣，可以继续深入研究。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

39 | 案例分析（二）：高性能网络应用框架Netty

Netty 是一个高性能网络应用框架，应用非常普遍，目前在 Java 领域里，Netty 基本上成为网络程序的标配了。Netty 框架功能丰富，也非常复杂，今天我们主要分析 Netty 框架中的线程模型，而线程模型直接影响着网络程序的性能。

在介绍 Netty 的线程模型之前，我们首先需要把问题搞清楚，了解网络编程性能的瓶颈在哪里，然后再看 Netty 的线程模型是如何解决这个问题的。

网络编程性能的瓶颈

在《32 | Balking 模式：再谈线程安全的单例模式》中，我们写过一个简单的网络程序 echo，采用的是阻塞式 I/O（BIO）。BIO 模型里，所有 read() 操作和 write() 操作都会阻塞当前线程的，如果客户端已经和服务端建立了一个连接，而迟迟不发送数据，那么服务端的 read() 操作会一直阻塞，所以使用 BIO 模型，一般都会为每个 socket 分配一个独立的线程，这样就不会因为线程阻塞在一个 socket 上而影响对其他 socket 的读写。BIO 的线程模型如下图所示，每一个 socket 都对应一个独立的线程；为了避免频繁创建、消耗线程，可以采用线程池，但是 socket 和线程之间的对应关系并不会变化。

BIO 的线程模型

BIO 这种线程模型适用于 socket 连接不是很多的场景；但是现在的互联网场景，往往需要服务器能够支撑十万甚至百万连接，而创建十万甚至上百万个线程显然并不现实，所以 BIO 线程模型无法解决百万连接的问题。如果仔细观察，你会发现互联网场景中，虽然连接多，但是每个连接上的请求并不频繁，所以线程大部分时间都在等待 I/O 就绪。也就是说线程大部分时间都阻塞在那里，这完全是浪费，如果我们能够解决这个问题，那就不需要这么多线程了。

顺着这个思路，我们可以将线程模型优化为下图这个样子，可以用一个线程来处理多个连接，这样线程的利用率就上来了，同时所需的线程数量也跟着降下来了。这个思路很好，可是使用 BIO 相关的 API 是无法实现的，这是为什么呢？因为 BIO 相关的 socket 读写操作都是阻塞式的，而一旦调用了阻塞式 API，在 I/O 就绪前，调用线程会一直阻塞，也就无法处理其他的 socket 连接了。

理想的线程模型图

好在 Java 里还提供了非阻塞式（NIO）API，利用非阻塞式 API 就能够实现一个线程处理多个连接了。那具体如何实现呢？现在普遍都是采用 Reactor 模式，包括 Netty 的实现。所以，要想理解 Netty 的实现，接下来我们就需要先了解一下 Reactor 模式。

Reactor 模式

下面是 Reactor 模式的类结构图，其中 Handle 指的是 I/O 句柄，在 Java 网络编程里，它本质上就是一个网络连接。Event Handler 很容易理解，就是一个事件处理器，其中 handle_event() 方法处理 I/O 事件，也就是每个 Event Handler 处理一个 I/O Handle；get_handle() 方法可以返回这个 I/O 的 Handle。Synchronous Event Demultiplexer 可以理解为操作系统提供的 I/O 多路复用 API，例如 POSIX 标准里的 select() 以及 Linux 里面的 epoll()。

Reactor 模式类结构图

Reactor 模式的核心自然是Reactor 这个类，其中 register_handler() 和 remove_handler() 这两个方法可以注册和删除一个事件处理器；handle_events() 方式是核心，也是 Reactor 模式的发动机，这个方法的核心逻辑如下：首先通过同步事件多路选择器提供的 select() 方法监听网络事件，当有网络事件就绪后，就遍历事件处理器来处理该网络事件。由于网络事件是源源不断的，所以在主程序中启动 Reactor 模式，需要以 while(true){} 的方式调用 handle_events() 方法。

void Reactor::handle_events(){

  // 通过同步事件多路选择器提供的

  //select() 方法监听网络事件

  select(handlers);

  // 处理网络事件

  for(h in handlers){

    h.handle_event();

  }

}

// 在主程序中启动事件循环

while (true) {

  handle_events();

Netty 中的线程模型

Netty 的实现虽然参考了 Reactor 模式，但是并没有完全照搬，Netty 中最核心的概念是事件循环（EventLoop），其实也就是 Reactor 模式中的 Reactor，负责监听网络事件并调用事件处理器进行处理。在 4.x 版本的 Netty 中，网络连接和 EventLoop 是稳定的多对 1 关系，而 EventLoop 和 Java 线程是 1 对 1 关系，这里的稳定指的是关系一旦确定就不再发生变化。也就是说一个网络连接只会对应唯一的一个 EventLoop，而一个 EventLoop 也只会对应到一个 Java 线程，所以一个网络连接只会对应到一个 Java 线程。

一个网络连接对应到一个 Java 线程上，有什么好处呢？最大的好处就是对于一个网络连接的事件处理是单线程的，这样就避免了各种并发问题。

Netty 中的线程模型可以参考下图，这个图和前面我们提到的理想的线程模型图非常相似，核心目标都是用一个线程处理多个网络连接。

Netty 中的线程模型

Netty 中还有一个核心概念是EventLoopGroup，顾名思义，一个 EventLoopGroup 由一组 EventLoop 组成。实际使用中，一般都会创建两个 EventLoopGroup，一个称为 bossGroup，一个称为 workerGroup。为什么会有两个 EventLoopGroup 呢？

这个和 socket 处理网络请求的机制有关，socket 处理 TCP 网络连接请求，是在一个独立的 socket 中，每当有一个 TCP 连接成功建立，都会创建一个新的 socket，之后对 TCP 连接的读写都是由新创建处理的 socket 完成的。也就是说处理 TCP 连接请求和读写请求是通过两个不同的 socket 完成的。上面我们在讨论网络请求的时候，为了简化模型，只是讨论了读写请求，而没有讨论连接请求。

在 Netty 中，bossGroup 就用来处理连接请求的，而 workerGroup 是用来处理读写请求的。bossGroup 处理完连接请求后，会将这个连接提交给 workerGroup 来处理， workerGroup 里面有多个 EventLoop，那新的连接会交给哪个 EventLoop 来处理呢？这就需要一个负载均衡算法，Netty 中目前使用的是轮询算法。

下面我们用 Netty 重新实现以下 echo 程序的服务端，近距离感受一下 Netty。

用 Netty 实现 Echo 程序服务端

下面的示例代码基于 Netty 实现了 echo 程序服务端：首先创建了一个事件处理器（等同于 Reactor 模式中的事件处理器），然后创建了 bossGroup 和 workerGroup，再之后创建并初始化了 ServerBootstrap，代码还是很简单的，不过有两个地方需要注意一下。

第一个，如果 NettybossGroup 只监听一个端口，那 bossGroup 只需要 1 个 EventLoop 就可以了，多了纯属浪费。

第二个，默认情况下，Netty 会创建“2*CPU 核数”个 EventLoop，由于网络连接与 EventLoop 有稳定的关系，所以事件处理器在处理网络事件的时候是不能有阻塞操作的，否则很容易导致请求大面积超时。如果实在无法避免使用阻塞操作，那可以通过线程池来异步处理。

// 事件处理器

final EchoServerHandler serverHandler

  = new EchoServerHandler();

//boss 线程组

EventLoopGroup bossGroup

  = new NioEventLoopGroup(1);

//worker 线程组

EventLoopGroup workerGroup

  = new NioEventLoopGroup();

try {

  ServerBootstrap b = new ServerBootstrap();

  b.group(bossGroup, workerGroup)

   .channel(NioServerSocketChannel.class)

   .childHandler(new ChannelInitializer<SocketChannel>() {

     @Override

     public void initChannel(SocketChannel ch){

       ch.pipeline().addLast(serverHandler);

     }

    });

  //bind 服务端端口

  ChannelFuture f = b.bind(9090).sync();

  f.channel().closeFuture().sync();

} finally {

  // 终止工作线程组

  workerGroup.shutdownGracefully();

  // 终止 boss 线程组

  bossGroup.shutdownGracefully();

}

//socket 连接处理器

class EchoServerHandler extends

    ChannelInboundHandlerAdapter {

  // 处理读事件

  @Override

  public void channelRead(

    ChannelHandlerContext ctx, Object msg){

      ctx.write(msg);

  }

  // 处理读完成事件

  @Override

  public void channelReadComplete(

    ChannelHandlerContext ctx){

      ctx.flush();

  }

  // 处理异常事件

  @Override

  public void exceptionCaught(

    ChannelHandlerContext ctx,  Throwable cause) {

      cause.printStackTrace();

      ctx.close();

  }

}

总结

Netty 是一个款优秀的网络编程框架，性能非常好，为了实现高性能的目标，Netty 做了很多优化，例如优化了 ByteBuffer、支持零拷贝等等，和并发编程相关的就是它的线程模型了。Netty 的线程模型设计得很精巧，每个网络连接都关联到了一个线程上，这样做的好处是：对于一个网络连接，读写操作都是单线程执行的，从而避免了并发程序的各种问题。

你要想深入理解 Netty 的线程模型，还需要对网络相关知识有一定的理解，关于 Java IO 的演进过程，你可以参考Scalable IO in Java，至于 TCP/IP 网络编程的知识你可以参考韩国尹圣雨写的经典教程——《TCP/IP 网络编程》。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

40 | 案例分析（三）：高性能队列Disruptor

我们在《20 | 并发容器：都有哪些“坑”需要我们填？》介绍过 Java SDK 提供了 2 个有界队列：ArrayBlockingQueue 和 LinkedBlockingQueue，它们都是基于 ReentrantLock 实现的，在高并发场景下，锁的效率并不高，那有没有更好的替代品呢？有，今天我们就介绍一种性能更高的有界队列：Disruptor。

Disruptor 是一款高性能的有界内存队列，目前应用非常广泛，Log4j2、Spring Messaging、HBase、Storm 都用到了 Disruptor，那 Disruptor 的性能为什么这么高呢？Disruptor 项目团队曾经写过一篇论文，详细解释了其原因，可以总结为如下：

内存分配更加合理，使用 RingBuffer 数据结构，数组元素在初始化时一次性全部创建，提升缓存命中率；对象循环利用，避免频繁 GC。

能够避免伪共享，提升缓存利用率。

采用无锁算法，避免频繁加锁、解锁的性能消耗。

支持批量消费，消费者可以无锁方式消费多个消息。

其中，前三点涉及到的知识比较多，所以今天咱们重点讲解前三点，不过在详细介绍这些知识之前，我们先来聊聊 Disruptor 如何使用，好让你先对 Disruptor 有个感官的认识。

下面的代码出自官方示例，我略做了一些修改，相较而言，Disruptor 的使用比 Java SDK 提供 BlockingQueue 要复杂一些，但是总体思路还是一致的，其大致情况如下：

在 Disruptor 中，生产者生产的对象（也就是消费者消费的对象）称为 Event，使用 Disruptor 必须自定义 Event，例如示例代码的自定义 Event 是 LongEvent；

构建 Disruptor 对象除了要指定队列大小外，还需要传入一个 EventFactory，示例代码中传入的是LongEvent::new；

消费 Disruptor 中的 Event 需要通过 handleEventsWith() 方法注册一个事件处理器，发布 Event 则需要通过 publishEvent() 方法。

// 自定义 Event

class LongEvent {

  private long value;

  public void set(long value) {

    this.value = value;

  }

}

// 指定 RingBuffer 大小,

// 必须是 2 的 N 次方

int bufferSize = 1024;

// 构建 Disruptor

Disruptor<LongEvent> disruptor

  = new Disruptor<>(

    LongEvent::new,

    bufferSize,

    DaemonThreadFactory.INSTANCE);

// 注册事件处理器

disruptor.handleEventsWith(

  (event, sequence, endOfBatch) ->

    System.out.println("E: "+event));

// 启动 Disruptor

disruptor.start();

// 获取 RingBuffer

RingBuffer<LongEvent> ringBuffer

  = disruptor.getRingBuffer();

// 生产 Event

ByteBuffer bb = ByteBuffer.allocate(8);

for (long l = 0; true; l++){

  bb.putLong(0, l);

  // 生产者生产消息

  ringBuffer.publishEvent(

    (event, sequence, buffer) ->

      event.set(buffer.getLong(0)), bb);

  Thread.sleep(1000);

}

RingBuffer 如何提升性能

Java SDK 中 ArrayBlockingQueue 使用数组作为底层的数据存储，而 Disruptor 是使用RingBuffer作为数据存储。RingBuffer 本质上也是数组，所以仅仅将数据存储从数组换成 RingBuffer 并不能提升性能，但是 Disruptor 在 RingBuffer 的基础上还做了很多优化，其中一项优化就是和内存分配有关的。

在介绍这项优化之前，你需要先了解一下程序的局部性原理。简单来讲，程序的局部性原理指的是在一段时间内程序的执行会限定在一个局部范围内。这里的“局部性”可以从两个方面来理解，一个是时间局部性，另一个是空间局部性。时间局部性指的是程序中的某条指令一旦被执行，不久之后这条指令很可能再次被执行；如果某条数据被访问，不久之后这条数据很可能再次被访问。而空间局部性是指某块内存一旦被访问，不久之后这块内存附近的内存也很可能被访问。

CPU 的缓存就利用了程序的局部性原理：CPU 从内存中加载数据 X 时，会将数据 X 缓存在高速缓存 Cache 中，实际上 CPU 缓存 X 的同时，还缓存了 X 周围的数据，因为根据程序具备局部性原理，X 周围的数据也很有可能被访问。从另外一个角度来看，如果程序能够很好地体现出局部性原理，也就能更好地利用 CPU 的缓存，从而提升程序的性能。Disruptor 在设计 RingBuffer 的时候就充分考虑了这个问题，下面我们就对比着 ArrayBlockingQueue 来分析一下。

首先是 ArrayBlockingQueue。生产者线程向 ArrayBlockingQueue 增加一个元素，每次增加元素 E 之前，都需要创建一个对象 E，如下图所示，ArrayBlockingQueue 内部有 6 个元素，这 6 个元素都是由生产者线程创建的，由于创建这些元素的时间基本上是离散的，所以这些元素的内存地址大概率也不是连续的。

ArrayBlockingQueue 内部结构图

下面我们再看看 Disruptor 是如何处理的。Disruptor 内部的 RingBuffer 也是用数组实现的，但是这个数组中的所有元素在初始化时是一次性全部创建的，所以这些元素的内存地址大概率是连续的，相关的代码如下所示。

for (int i=0; i<bufferSize; i++){

  //entries[] 就是 RingBuffer 内部的数组

  //eventFactory 就是前面示例代码中传入的 LongEvent::new

  entries[BUFFER_PAD + i]

    = eventFactory.newInstance();

}

Disruptor 内部 RingBuffer 的结构可以简化成下图，那问题来了，数组中所有元素内存地址连续能提升性能吗？能！为什么呢？因为消费者线程在消费的时候，是遵循空间局部性原理的，消费完第 1 个元素，很快就会消费第 2 个元素；当消费第 1 个元素 E1 的时候，CPU 会把内存中 E1 后面的数据也加载进 Cache，如果 E1 和 E2 在内存中的地址是连续的，那么 E2 也就会被加载进 Cache 中，然后当消费第 2 个元素的时候，由于 E2 已经在 Cache 中了，所以就不需要从内存中加载了，这样就能大大提升性能。

Disruptor 内部 RingBuffer 结构图

除此之外，在 Disruptor 中，生产者线程通过 publishEvent() 发布 Event 的时候，并不是创建一个新的 Event，而是通过 event.set() 方法修改 Event， 也就是说 RingBuffer 创建的 Event 是可以循环利用的，这样还能避免频繁创建、删除 Event 导致的频繁 GC 问题。

如何避免“伪共享”

高效利用 Cache，能够大大提升性能，所以要努力构建能够高效利用 Cache 的内存结构。而从另外一个角度看，努力避免不能高效利用 Cache 的内存结构也同样重要。

有一种叫做“伪共享（False sharing）”的内存布局就会使 Cache 失效，那什么是“伪共享”呢？

伪共享和 CPU 内部的 Cache 有关，Cache 内部是按照缓存行（Cache Line）管理的，缓存行的大小通常是 64 个字节；CPU 从内存中加载数据 X，会同时加载 X 后面（64-size(X)）个字节的数据。下面的示例代码出自 Java SDK 的 ArrayBlockingQueue，其内部维护了 4 个成员变量，分别是队列数组 items、出队索引 takeIndex、入队索引 putIndex 以及队列中的元素总数 count。

/** 队列数组 */

final Object[] items;

/** 出队索引 */

int takeIndex;

/** 入队索引 */

int putIndex;

/** 队列中元素总数 */

int count;

当 CPU 从内存中加载 takeIndex 的时候，会同时将 putIndex 以及 count 都加载进 Cache。下图是某个时刻 CPU 中 Cache 的状况，为了简化，缓存行中我们仅列出了 takeIndex 和 putIndex。

CPU 缓存示意图

假设线程 A 运行在 CPU-1 上，执行入队操作，入队操作会修改 putIndex，而修改 putIndex 会导致其所在的所有核上的缓存行均失效；此时假设运行在 CPU-2 上的线程执行出队操作，出队操作需要读取 takeIndex，由于 takeIndex 所在的缓存行已经失效，所以 CPU-2 必须从内存中重新读取。入队操作本不会修改 takeIndex，但是由于 takeIndex 和 putIndex 共享的是一个缓存行，就导致出队操作不能很好地利用 Cache，这其实就是伪共享。简单来讲，伪共享指的是由于共享缓存行导致缓存无效的场景。

ArrayBlockingQueue 的入队和出队操作是用锁来保证互斥的，所以入队和出队不会同时发生。如果允许入队和出队同时发生，那就会导致线程 A 和线程 B 争用同一个缓存行，这样也会导致性能问题。所以为了更好地利用缓存，我们必须避免伪共享，那如何避免呢？

CPU 缓存失效示意图

方案很简单，每个变量独占一个缓存行、不共享缓存行就可以了，具体技术是缓存行填充。比如想让 takeIndex 独占一个缓存行，可以在 takeIndex 的前后各填充 56 个字节，这样就一定能保证 takeIndex 独占一个缓存行。下面的示例代码出自 Disruptor，Sequence 对象中的 value 属性就能避免伪共享，因为这个属性前后都填充了 56 个字节。Disruptor 中很多对象，例如 RingBuffer、RingBuffer 内部的数组都用到了这种填充技术来避免伪共享。

// 前：填充 56 字节

class LhsPadding{

    long p1, p2, p3, p4, p5, p6, p7;

}

class Value extends LhsPadding{

    volatile long value;

}

// 后：填充 56 字节

class RhsPadding extends Value{

    long p9, p10, p11, p12, p13, p14, p15;

}

class Sequence extends RhsPadding{

  // 省略实现

}

Disruptor 中的无锁算法

ArrayBlockingQueue 是利用管程实现的，中规中矩，生产、消费操作都需要加锁，实现起来简单，但是性能并不十分理想。Disruptor 采用的是无锁算法，很复杂，但是核心无非是生产和消费两个操作。Disruptor 中最复杂的是入队操作，所以我们重点来看看入队操作是如何实现的。

对于入队操作，最关键的要求是不能覆盖没有消费的元素；对于出队操作，最关键的要求是不能读取没有写入的元素，所以 Disruptor 中也一定会维护类似出队索引和入队索引这样两个关键变量。Disruptor 中的 RingBuffer 维护了入队索引，但是并没有维护出队索引，这是因为在 Disruptor 中多个消费者可以同时消费，每个消费者都会有一个出队索引，所以 RingBuffer 的出队索引是所有消费者里面最小的那一个。

下面是 Disruptor 生产者入队操作的核心代码，看上去很复杂，其实逻辑很简单：如果没有足够的空余位置，就出让 CPU 使用权，然后重新计算；反之则用 CAS 设置入队索引。

// 生产者获取 n 个写入位置

do {

  //cursor 类似于入队索引，指的是上次生产到这里

  current = cursor.get();

  // 目标是在生产 n 个

  next = current + n;

  // 减掉一个循环

  long wrapPoint = next - bufferSize;

  // 获取上一次的最小消费位置

  long cachedGatingSequence = gatingSequenceCache.get();

  // 没有足够的空余位置

  if (wrapPoint>cachedGatingSequence || cachedGatingSequence>current){

    // 重新计算所有消费者里面的最小值位置

    long gatingSequence = Util.getMinimumSequence(

        gatingSequences, current);

    // 仍然没有足够的空余位置，出让 CPU 使用权，重新执行下一循环

    if (wrapPoint > gatingSequence){

      LockSupport.parkNanos(1);

      continue;

    }

    // 从新设置上一次的最小消费位置

    gatingSequenceCache.set(gatingSequence);

  } else if (cursor.compareAndSet(current, next)){

    // 获取写入位置成功，跳出循环

    break;

  }

} while (true);

总结

Disruptor 在优化并发性能方面可谓是做到了极致，优化的思路大体是两个方面，一个是利用无锁算法避免锁的争用，另外一个则是将硬件（CPU）的性能发挥到极致。尤其是后者，在 Java 领域基本上属于经典之作了。

发挥硬件的能力一般是 C 这种面向硬件的语言常干的事儿，C 语言领域经常通过调整内存布局优化内存占用，而 Java 领域则用的很少，原因在于 Java 可以智能地优化内存布局，内存布局对 Java 程序员的透明的。这种智能的优化大部分场景是很友好的，但是如果你想通过填充方式避免伪共享就必须绕过这种优化，关于这方面 Disruptor 提供了经典的实现，你可以参考。

由于伪共享问题如此重要，所以 Java 也开始重视它了，比如 Java 8 中，提供了避免伪共享的注解：@sun.misc.Contended，通过这个注解就能轻松避免伪共享（需要设置 JVM 参数 -XX:-RestrictContended）。不过避免伪共享是以牺牲内存为代价的，所以具体使用的时候还是需要仔细斟酌。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

41 | 案例分析（四）：高性能数据库连接池HiKariCP

实际工作中，我们总会难免和数据库打交道；只要和数据库打交道，就免不了使用数据库连接池。业界知名的数据库连接池有不少，例如 c3p0、DBCP、Tomcat JDBC Connection Pool、Druid 等，不过最近最火的是 HiKariCP。

HiKariCP 号称是业界跑得最快的数据库连接池，这两年发展得顺风顺水，尤其是 Springboot 2.0 将其作为默认数据库连接池后，江湖一哥的地位已是毋庸置疑了。那它为什么那么快呢？今天咱们就重点聊聊这个话题。

什么是数据库连接池

在详细分析 HiKariCP 高性能之前，我们有必要先简单介绍一下什么是数据库连接池。本质上，数据库连接池和线程池一样，都属于池化资源，作用都是避免重量级资源的频繁创建和销毁，对于数据库连接池来说，也就是避免数据库连接频繁创建和销毁。如下图所示，服务端会在运行期持有一定数量的数据库连接，当需要执行 SQL 时，并不是直接创建一个数据库连接，而是从连接池中获取一个；当 SQL 执行完，也并不是将数据库连接真的关掉，而是将其归还到连接池中。

数据库连接池示意图

在实际工作中，我们都是使用各种持久化框架来完成数据库的增删改查，基本上不会直接和数据库连接池打交道，为了能让你更好地理解数据库连接池的工作原理，下面的示例代码并没有使用任何框架，而是原生地使用 HiKariCP。执行数据库操作基本上是一系列规范化的步骤：

通过数据源获取一个数据库连接；

创建 Statement；

执行 SQL；

通过 ResultSet 获取 SQL 执行结果；

释放 ResultSet；

释放 Statement；

释放数据库连接。

下面的示例代码，通过 ds.getConnection() 获取一个数据库连接时，其实是向数据库连接池申请一个数据库连接，而不是创建一个新的数据库连接。同样，通过 conn.close() 释放一个数据库连接时，也不是直接将连接关闭，而是将连接归还给数据库连接池。

// 数据库连接池配置

HikariConfig config = new HikariConfig();

config.setMinimumIdle(1);

config.setMaximumPoolSize(2);

config.setConnectionTestQuery("SELECT 1");

config.setDataSourceClassName("org.h2.jdbcx.JdbcDataSource");

config.addDataSourceProperty("url", "jdbc:h2:mem:test");

// 创建数据源

DataSource ds = new HikariDataSource(config);

Connection conn = null;

Statement stmt = null;

ResultSet rs = null;

try {

  // 获取数据库连接

  conn = ds.getConnection();

  // 创建 Statement

  stmt = conn.createStatement();

  // 执行 SQL

  rs = stmt.executeQuery("select * from abc");

  // 获取结果

  while (rs.next()) {

    int id = rs.getInt(1);

    ......

  }

} catch(Exception e) {

   e.printStackTrace();

} finally {

  // 关闭 ResultSet

  close(rs);

  // 关闭 Statement

  close(stmt);

  // 关闭 Connection

  close(conn);

}

// 关闭资源

void close(AutoCloseable rs) {

  if (rs != null) {

    try {

      rs.close();

    } catch (SQLException e) {

      e.printStackTrace();

    }

  }

}

HiKariCP 官方网站解释了其性能之所以如此之高的秘密。微观上 HiKariCP 程序编译出的字节码执行效率更高，站在字节码的角度去优化 Java 代码，HiKariCP 的作者对性能的执着可见一斑，不过遗憾的是他并没有详细解释都做了哪些优化。而宏观上主要是和两个数据结构有关，一个是 FastList，另一个是 ConcurrentBag。下面我们来看看它们是如何提升 HiKariCP 的性能的。

FastList 解决了哪些性能问题

按照规范步骤，执行完数据库操作之后，需要依次关闭 ResultSet、Statement、Connection，但是总有粗心的同学只是关闭了 Connection，而忘了关闭 ResultSet 和 Statement。为了解决这种问题，最好的办法是当关闭 Connection 时，能够自动关闭 Statement。为了达到这个目标，Connection 就需要跟踪创建的 Statement，最简单的办法就是将创建的 Statement 保存在数组 ArrayList 里，这样当关闭 Connection 的时候，就可以依次将数组中的所有 Statement 关闭。

HiKariCP 觉得用 ArrayList 还是太慢，当通过 conn.createStatement() 创建一个 Statement 时，需要调用 ArrayList 的 add() 方法加入到 ArrayList 中，这个是没有问题的；但是当通过 stmt.close() 关闭 Statement 的时候，需要调用 ArrayList 的 remove() 方法来将其从 ArrayList 中删除，这里是有优化余地的。

假设一个 Connection 依次创建 6 个 Statement，分别是 S1、S2、S3、S4、S5、S6，按照正常的编码习惯，关闭 Statement 的顺序一般是逆序的，关闭的顺序是：S6、S5、S4、S3、S2、S1，而 ArrayList 的 remove(Object o) 方法是顺序遍历查找，逆序删除而顺序查找，这样的查找效率就太慢了。如何优化呢？很简单，优化成逆序查找就可以了。

逆序删除示意图

HiKariCP 中的 FastList 相对于 ArrayList 的一个优化点就是将 remove(Object element) 方法的查找顺序变成了逆序查找。除此之外，FastList 还有另一个优化点，是 get(int index) 方法没有对 index 参数进行越界检查，HiKariCP 能保证不会越界，所以不用每次都进行越界检查。

整体来看，FastList 的优化点还是很简单的。下面我们再来聊聊 HiKariCP 中的另外一个数据结构 ConcurrentBag，看看它又是如何提升性能的。

ConcurrentBag 解决了哪些性能问题

如果让我们自己来实现一个数据库连接池，最简单的办法就是用两个阻塞队列来实现，一个用于保存空闲数据库连接的队列 idle，另一个用于保存忙碌数据库连接的队列 busy；获取连接时将空闲的数据库连接从 idle 队列移动到 busy 队列，而关闭连接时将数据库连接从 busy 移动到 idle。这种方案将并发问题委托给了阻塞队列，实现简单，但是性能并不是很理想。因为 Java SDK 中的阻塞队列是用锁实现的，而高并发场景下锁的争用对性能影响很大。

// 忙碌队列

BlockingQueue<Connection> busy;

// 空闲队列

BlockingQueue<Connection> idle;

HiKariCP 并没有使用 Java SDK 中的阻塞队列，而是自己实现了一个叫做 ConcurrentBag 的并发容器。ConcurrentBag 的设计最初源自 C#，它的一个核心设计是使用 ThreadLocal 避免部分并发问题，不过 HiKariCP 中的 ConcurrentBag 并没有完全参考 C# 的实现，下面我们来看看它是如何实现的。

ConcurrentBag 中最关键的属性有 4 个，分别是：用于存储所有的数据库连接的共享队列 sharedList、线程本地存储 threadList、等待数据库连接的线程数 waiters 以及分配数据库连接的工具 handoffQueue。其中，handoffQueue 用的是 Java SDK 提供的 SynchronousQueue，SynchronousQueue 主要用于线程之间传递数据。

// 用于存储所有的数据库连接

CopyOnWriteArrayList<T> sharedList;

// 线程本地存储中的数据库连接

ThreadLocal<List<Object>> threadList;

// 等待数据库连接的线程数

AtomicInteger waiters;

// 分配数据库连接的工具

SynchronousQueue<T> handoffQueue;

当线程池创建了一个数据库连接时，通过调用 ConcurrentBag 的 add() 方法加入到 ConcurrentBag 中，下面是 add() 方法的具体实现，逻辑很简单，就是将这个连接加入到共享队列 sharedList 中，如果此时有线程在等待数据库连接，那么就通过 handoffQueue 将这个连接分配给等待的线程。

// 将空闲连接添加到队列

void add(final T bagEntry){

  // 加入共享队列

  sharedList.add(bagEntry);

  // 如果有等待连接的线程，

  // 则通过 handoffQueue 直接分配给等待的线程

  while (waiters.get() > 0

    && bagEntry.getState() == STATE_NOT_IN_USE

    && !handoffQueue.offer(bagEntry)) {

      yield();

  }

}

通过 ConcurrentBag 提供的 borrow() 方法，可以获取一个空闲的数据库连接，borrow() 的主要逻辑是：

首先查看线程本地存储是否有空闲连接，如果有，则返回一个空闲的连接；

如果线程本地存储中无空闲连接，则从共享队列中获取。

如果共享队列中也没有空闲的连接，则请求线程需要等待。

需要注意的是，线程本地存储中的连接是可以被其他线程窃取的，所以需要用 CAS 方法防止重复分配。在共享队列中获取空闲连接，也采用了 CAS 方法防止重复分配。

T borrow(long timeout, final TimeUnit timeUnit){

  // 先查看线程本地存储是否有空闲连接

  final List<Object> list = threadList.get();

  for (int i = list.size() - 1; i >= 0; i--) {

    final Object entry = list.remove(i);

    final T bagEntry = weakThreadLocals

      ? ((WeakReference<T>) entry).get()

      : (T) entry;

    // 线程本地存储中的连接也可以被窃取，

    // 所以需要用 CAS 方法防止重复分配

    if (bagEntry != null

      && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {

      return bagEntry;

    }

  }

  // 线程本地存储中无空闲连接，则从共享队列中获取

  final int waiting = waiters.incrementAndGet();

  try {

    for (T bagEntry : sharedList) {

      // 如果共享队列中有空闲连接，则返回

      if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {

        return bagEntry;

      }

    }

    // 共享队列中没有连接，则需要等待

    timeout = timeUnit.toNanos(timeout);

    do {

      final long start = currentTime();

      final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);

      if (bagEntry == null

        || bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {

          return bagEntry;

      }

      // 重新计算等待时间

      timeout -= elapsedNanos(start);

    } while (timeout > 10_000);

    // 超时没有获取到连接，返回 null

    return null;

  } finally {

    waiters.decrementAndGet();

  }

}

释放连接需要调用 ConcurrentBag 提供的 requite() 方法，该方法的逻辑很简单，首先将数据库连接状态更改为 STATE_NOT_IN_USE，之后查看是否存在等待线程，如果有，则分配给等待线程；如果没有，则将该数据库连接保存到线程本地存储里。

// 释放连接

void requite(final T bagEntry){

  // 更新连接状态

  bagEntry.setState(STATE_NOT_IN_USE);

  // 如果有等待的线程，则直接分配给线程，无需进入任何队列

  for (int i = 0; waiters.get() > 0; i++) {

    if (bagEntry.getState() != STATE_NOT_IN_USE

      || handoffQueue.offer(bagEntry)) {

        return;

    } else if ((i & 0xff) == 0xff) {

      parkNanos(MICROSECONDS.toNanos(10));

    } else {

      yield();

    }

  }

  // 如果没有等待的线程，则进入线程本地存储

  final List<Object> threadLocalList = threadList.get();

  if (threadLocalList.size() < 50) {

    threadLocalList.add(weakThreadLocals

      ? new WeakReference<>(bagEntry)

      : bagEntry);

  }

}

总结

HiKariCP 中的 FastList 和 ConcurrentBag 这两个数据结构使用得非常巧妙，虽然实现起来并不复杂，但是对于性能的提升非常明显，根本原因在于这两个数据结构适用于数据库连接池这个特定的场景。FastList 适用于逆序删除场景；而 ConcurrentBag 通过 ThreadLocal 做一次预分配，避免直接竞争共享资源，非常适合池化资源的分配。

在实际工作中，我们遇到的并发问题千差万别，这时选择合适的并发数据结构就非常重要了。当然能选对的前提是对特定场景的并发特性有深入的了解，只有了解到无谓的性能消耗在哪里，才能对症下药。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

42 | Actor模型：面向对象原生的并发模型

上学的时候，有门计算机专业课叫做面向对象编程，学这门课的时候有个问题困扰了我很久，按照面向对象编程的理论，对象之间通信需要依靠消息，而实际上，像 C++、Java 这些面向对象的语言，对象之间通信，依靠的是对象方法。对象方法和过程语言里的函数本质上没有区别，有入参、有出参，思维方式很相似，使用起来都很简单。那面向对象理论里的消息是否就等价于面向对象语言里的对象方法呢？很长一段时间里，我都以为对象方法是面向对象理论中消息的一种实现，直到接触到 Actor 模型，才明白消息压根不是这个实现法。

Hello Actor 模型

Actor 模型本质上是一种计算模型，基本的计算单元称为 Actor，换言之，在 Actor 模型中，所有的计算都是在 Actor 中执行的。在面向对象编程里面，一切都是对象；在 Actor 模型里，一切都是 Actor，并且 Actor 之间是完全隔离的，不会共享任何变量。

当看到“不共享任何变量”的时候，相信你一定会眼前一亮，并发问题的根源就在于共享变量，而 Actor 模型中 Actor 之间不共享变量，那用 Actor 模型解决并发问题，一定是相当顺手。的确是这样，所以很多人就把 Actor 模型定义为一种并发计算模型。其实 Actor 模型早在 1973 年就被提出来了，只是直到最近几年才被广泛关注，一个主要原因就在于它是解决并发问题的利器，而最近几年随着多核处理器的发展，并发问题被推到了风口浪尖上。

但是 Java 语言本身并不支持 Actor 模型，所以如果你想在 Java 语言里使用 Actor 模型，就需要借助第三方类库，目前能完备地支持 Actor 模型而且比较成熟的类库就是Akka了。在详细介绍 Actor 模型之前，我们就先基于 Akka 写一个 Hello World 程序，让你对 Actor 模型先有个感官的印象。

在下面的示例代码中，我们首先创建了一个 ActorSystem（Actor 不能脱离 ActorSystem 存在）；之后创建了一个 HelloActor，Akka 中创建 Actor 并不是 new 一个对象出来，而是通过调用 system.actorOf() 方法创建的，该方法返回的是 ActorRef，而不是 HelloActor；最后通过调用 ActorRef 的 tell() 方法给 HelloActor 发送了一条消息 “Actor” 。

// 该 Actor 当收到消息 message 后，

// 会打印 Hello message

static class HelloActor

    extends UntypedActor {

  @Override

  public void onReceive(Object message) {

    System.out.println("Hello " + message);

  }

}

public static void main(String[] args) {

  // 创建 Actor 系统

  ActorSystem system = ActorSystem.create("HelloSystem");

  // 创建 HelloActor

  ActorRef helloActor =

    system.actorOf(Props.create(HelloActor.class));

  // 发送消息给 HelloActor

  helloActor.tell("Actor", ActorRef.noSender());

}

通过这个例子，你会发现 Actor 模型和面向对象编程契合度非常高，完全可以用 Actor 类比面向对象编程里面的对象，而且 Actor 之间的通信方式完美地遵守了消息机制，而不是通过对象方法来实现对象之间的通信。那 Actor 中的消息机制和面向对象语言里的对象方法有什么区别呢？

消息和对象方法的区别

在没有计算机的时代，异地的朋友往往是通过写信来交流感情的，但信件发出去之后，也许会在寄送过程中弄丢了，也有可能寄到后，对方一直没有时间写回信……这个时候都可以让邮局“背个锅”，不过无论如何，也不过是重写一封，生活继续。

Actor 中的消息机制，就可以类比这现实世界里的写信。Actor 内部有一个邮箱（Mailbox），接收到的消息都是先放到邮箱里，如果邮箱里有积压的消息，那么新收到的消息就不会马上得到处理，也正是因为 Actor 使用单线程处理消息，所以不会出现并发问题。你可以把 Actor 内部的工作模式想象成只有一个消费者线程的生产者 - 消费者模式。

所以，在 Actor 模型里，发送消息仅仅是把消息发出去而已，接收消息的 Actor 在接收到消息后，也不一定会立即处理，也就是说Actor 中的消息机制完全是异步的。而调用对象方法，实际上是同步的，对象方法 return 之前，调用方会一直等待。

除此之外，调用对象方法，需要持有对象的引用，所有的对象必须在同一个进程中。而在 Actor 中发送消息，类似于现实中的写信，只需要知道对方的地址就可以，发送消息和接收消息的 Actor 可以不在一个进程中，也可以不在同一台机器上。因此，Actor 模型不但适用于并发计算，还适用于分布式计算。

Actor 的规范化定义

通过上面的介绍，相信你应该已经对 Actor 有一个感官印象了，下面我们再来看看 Actor 规范化的定义是什么样的。Actor 是一种基础的计算单元，具体来讲包括三部分能力，分别是：

处理能力，处理接收到的消息。

存储能力，Actor 可以存储自己的内部状态，并且内部状态在不同 Actor 之间是绝对隔离的。

通信能力，Actor 可以和其他 Actor 之间通信。

当一个 Actor 接收的一条消息之后，这个 Actor 可以做以下三件事：

创建更多的 Actor；

发消息给其他 Actor；

确定如何处理下一条消息。

其中前两条还是很好理解的，就是最后一条，该如何去理解呢？前面我们说过 Actor 具备存储能力，它有自己的内部状态，所以你也可以把 Actor 看作一个状态机，把 Actor 处理消息看作是触发状态机的状态变化；而状态机的变化往往要基于上一个状态，触发状态机发生变化的时刻，上一个状态必须是确定的，所以确定如何处理下一条消息，本质上不过是改变内部状态。

在多线程里面，由于可能存在竞态条件，所以根据当前状态确定如何处理下一条消息还是有难度的，需要使用各种同步工具，但在 Actor 模型里，由于是单线程处理，所以就不存在竞态条件问题了。

用 Actor 实现累加器

支持并发的累加器可能是最简单并且有代表性的并发问题了，可以基于互斥锁方案实现，也可以基于原子类实现，但今天我们要尝试用 Actor 来实现。

在下面的示例代码中，CounterActor 内部持有累计值 counter，当 CounterActor 接收到一个数值型的消息 message 时，就将累计值 counter += message；但如果是其他类型的消息，则打印当前累计值 counter。在 main() 方法中，我们启动了 4 个线程来执行累加操作。整个程序没有锁，也没有 CAS，但是程序是线程安全的。

// 累加器

static class CounterActor extends UntypedActor {

  private int counter = 0;

  @Override

  public void onReceive(Object message){

    // 如果接收到的消息是数字类型，执行累加操作，

    // 否则打印 counter 的值

    if (message instanceof Number) {

      counter += ((Number) message).intValue();

    } else {

      System.out.println(counter);

    }

  }

}

public static void main(String[] args) throws InterruptedException {

  // 创建 Actor 系统

  ActorSystem system = ActorSystem.create("HelloSystem");

  //4 个线程生产消息

  ExecutorService es = Executors.newFixedThreadPool(4);

  // 创建 CounterActor

  ActorRef counterActor =

    system.actorOf(Props.create(CounterActor.class));

  // 生产 4*100000 个消息

  for (int i=0; i<4; i++) {

    es.execute(()->{

      for (int j=0; j<100000; j++) {

        counterActor.tell(1, ActorRef.noSender());

      }

    });

  }

  // 关闭线程池

  es.shutdown();

  // 等待 CounterActor 处理完所有消息

  Thread.sleep(1000);

  // 打印结果

  counterActor.tell("", ActorRef.noSender());

  // 关闭 Actor 系统

  system.shutdown();

}

总结

Actor 模型是一种非常简单的计算模型，其中 Actor 是最基本的计算单元，Actor 之间是通过消息进行通信。Actor 与面向对象编程（OOP）中的对象匹配度非常高，在面向对象编程里，系统由类似于生物细胞那样的对象构成，对象之间也是通过消息进行通信，所以在面向对象语言里使用 Actor 模型基本上不会有违和感。

在 Java 领域，除了可以使用 Akka 来支持 Actor 模型外，还可以使用 Vert.x，不过相对来说 Vert.x 更像是 Actor 模型的隐式实现，对应关系不像 Akka 那样明显，不过本质上也是一种 Actor 模型。

Actor 可以创建新的 Actor，这些 Actor 最终会呈现出一个树状结构，非常像现实世界里的组织结构，所以利用 Actor 模型来对程序进行建模，和现实世界的匹配度非常高。Actor 模型和现实世界一样都是异步模型，理论上不保证消息百分百送达，也不保证消息送达的顺序和发送的顺序是一致的，甚至无法保证消息会被百分百处理。虽然实现 Actor 模型的厂商都在试图解决这些问题，但遗憾的是解决得并不完美，所以使用 Actor 模型也是有成本的。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

43 | 软件事务内存：借鉴数据库的并发经验

很多同学反馈说，工作了挺长时间但是没有机会接触并发编程，实际上我们天天都在写并发程序，只不过并发相关的问题都被类似 Tomcat 这样的 Web 服务器以及 MySQL 这样的数据库解决了。尤其是数据库，在解决并发问题方面，可谓成绩斐然，它的事务机制非常简单易用，能甩 Java 里面的锁、原子类十条街。技术无边界，很显然要借鉴一下。

其实很多编程语言都有从数据库的事务管理中获得灵感，并且总结出了一个新的并发解决方案：软件事务内存（Software Transactional Memory，简称 STM）。传统的数据库事务，支持 4 个特性：原子性（Atomicity）、一致性（Consistency）、隔离性（Isolation）和持久性（Durability），也就是大家常说的 ACID，STM 由于不涉及到持久化，所以只支持 ACI。

STM 的使用很简单，下面我们以经典的转账操作为例，看看用 STM 该如何实现。

用 STM 实现转账

我们曾经在《05 | 一不小心就死锁了，怎么办？》这篇文章中，讲到了并发转账的例子，示例代码如下。简单地使用 synchronized 将 transfer() 方法变成同步方法并不能解决并发问题，因为还存在死锁问题。

class UnsafeAccount {

  // 余额

  private long balance;

  // 构造函数

  public UnsafeAccount(long balance) {

    this.balance = balance;

  }

  // 转账

  void transfer(UnsafeAccount target, long amt){

    if (this.balance > amt) {

      this.balance -= amt;

      target.balance += amt;

    }

  }

}

该转账操作若使用数据库事务就会非常简单，如下面的示例代码所示。如果所有 SQL 都正常执行，则通过 commit() 方法提交事务；如果 SQL 在执行过程中有异常，则通过 rollback() 方法回滚事务。数据库保证在并发情况下不会有死锁，而且还能保证前面我们说的原子性、一致性、隔离性和持久性，也就是 ACID。

Connection conn = null;

try{

  // 获取数据库连接

  conn = DriverManager.getConnection();

  // 设置手动提交事务

  conn.setAutoCommit(false);

  // 执行转账 SQL

  ......

  // 提交事务

  conn.commit();

} catch (Exception e) {

  // 出现异常回滚事务

  conn.rollback();

}

那如果用 STM 又该如何实现呢？Java 语言并不支持 STM，不过可以借助第三方的类库来支持，Multiverse就是个不错的选择。下面的示例代码就是借助 Multiverse 实现了线程安全的转账操作，相比较上面线程不安全的 UnsafeAccount，其改动并不大，仅仅是将余额的类型从 long 变成了 TxnLong ，将转账的操作放到了 atomic(()->{}) 中。

class Account{

  // 余额

  private TxnLong balance;

  // 构造函数

  public Account(long balance){

    this.balance = StmUtils.newTxnLong(balance);

  }

  // 转账

  public void transfer(Account to, int amt){

    // 原子化操作

    atomic(()->{

      if (this.balance.get() > amt) {

        this.balance.decrement(amt);

        to.balance.increment(amt);

      }

    });

  }

}

一个关键的 atomic() 方法就把并发问题解决了，这个方案看上去比传统的方案的确简单了很多，那它是如何实现的呢？数据库事务发展了几十年了，目前被广泛使用的是MVCC（全称是 Multi-Version Concurrency Control），也就是多版本并发控制。

MVCC 可以简单地理解为数据库事务在开启的时候，会给数据库打一个快照，以后所有的读写都是基于这个快照的。当提交事务的时候，如果所有读写过的数据在该事务执行期间没有发生过变化，那么就可以提交；如果发生了变化，说明该事务和有其他事务读写的数据冲突了，这个时候是不可以提交的。

为了记录数据是否发生了变化，可以给每条数据增加一个版本号，这样每次成功修改数据都会增加版本号的值。MVCC 的工作原理和我们曾经在《18 | StampedLock：有没有比读写锁更快的锁？》中提到的乐观锁非常相似。有不少 STM 的实现方案都是基于 MVCC 的，例如知名的 Clojure STM。

下面我们就用最简单的代码基于 MVCC 实现一个简版的 STM，这样你会对 STM 以及 MVCC 的工作原理有更深入的认识。

自己实现 STM

我们首先要做的，就是让 Java 中的对象有版本号，在下面的示例代码中，VersionedRef 这个类的作用就是将对象 value 包装成带版本号的对象。按照 MVCC 理论，数据的每一次修改都对应着一个唯一的版本号，所以不存在仅仅改变 value 或者 version 的情况，用不变性模式就可以很好地解决这个问题，所以 VersionedRef 这个类被我们设计成了不可变的。

所有对数据的读写操作，一定是在一个事务里面，TxnRef 这个类负责完成事务内的读写操作，读写操作委托给了接口 Txn，Txn 代表的是读写操作所在的当前事务， 内部持有的 curRef 代表的是系统中的最新值。

// 带版本号的对象引用

public final class VersionedRef<T> {

  final T value;

  final long version;

  // 构造方法

  public VersionedRef(T value, long version) {

    this.value = value;

    this.version = version;

  }

}

// 支持事务的引用

public class TxnRef<T> {

  // 当前数据，带版本号

  volatile VersionedRef curRef;

  // 构造方法

  public TxnRef(T value) {

    this.curRef = new VersionedRef(value, 0L);

  }

  // 获取当前事务中的数据

  public T getValue(Txn txn) {

    return txn.get(this);

  }

  // 在当前事务中设置数据

  public void setValue(T value, Txn txn) {

    txn.set(this, value);

  }

}

STMTxn 是 Txn 最关键的一个实现类，事务内对于数据的读写，都是通过它来完成的。STMTxn 内部有两个 Map：inTxnMap，用于保存当前事务中所有读写的数据的快照；writeMap，用于保存当前事务需要写入的数据。每个事务都有一个唯一的事务 ID txnId，这个 txnId 是全局递增的。

STMTxn 有三个核心方法，分别是读数据的 get() 方法、写数据的 set() 方法和提交事务的 commit() 方法。其中，get() 方法将要读取数据作为快照放入 inTxnMap，同时保证每次读取的数据都是一个版本。set() 方法会将要写入的数据放入 writeMap，但如果写入的数据没被读取过，也会将其放入 inTxnMap。

至于 commit() 方法，我们为了简化实现，使用了互斥锁，所以事务的提交是串行的。commit() 方法的实现很简单，首先检查 inTxnMap 中的数据是否发生过变化，如果没有发生变化，那么就将 writeMap 中的数据写入（这里的写入其实就是 TxnRef 内部持有的 curRef）；如果发生过变化，那么就不能将 writeMap 中的数据写入了。

// 事务接口

public interface Txn {

  <T> T get(TxnRef<T> ref);

  <T> void set(TxnRef<T> ref, T value);

}

//STM 事务实现类

public final class STMTxn implements Txn {

  // 事务 ID 生成器

  private static AtomicLong txnSeq = new AtomicLong(0);

  // 当前事务所有的相关数据

  private Map<TxnRef, VersionedRef> inTxnMap = new HashMap<>();

  // 当前事务所有需要修改的数据

  private Map<TxnRef, Object> writeMap = new HashMap<>();

  // 当前事务 ID

  private long txnId;

  // 构造函数，自动生成当前事务 ID

  STMTxn() {

    txnId = txnSeq.incrementAndGet();

  }

  // 获取当前事务中的数据

  @Override

  public <T> T get(TxnRef<T> ref) {

    // 将需要读取的数据，加入 inTxnMap

    if (!inTxnMap.containsKey(ref)) {

      inTxnMap.put(ref, ref.curRef);

    }

    return (T) inTxnMap.get(ref).value;

  }

  // 在当前事务中修改数据

  @Override

  public <T> void set(TxnRef<T> ref, T value) {

    // 将需要修改的数据，加入 inTxnMap

    if (!inTxnMap.containsKey(ref)) {

      inTxnMap.put(ref, ref.curRef);

    }

    writeMap.put(ref, value);

  }

  // 提交事务

  boolean commit() {

    synchronized (STM.commitLock) {

    // 是否校验通过

    boolean isValid = true;

    // 校验所有读过的数据是否发生过变化

    for(Map.Entry<TxnRef, VersionedRef> entry : inTxnMap.entrySet()){

      VersionedRef curRef = entry.getKey().curRef;

      VersionedRef readRef = entry.getValue();

      // 通过版本号来验证数据是否发生过变化

      if (curRef.version != readRef.version) {

        isValid = false;

        break;

      }

    }

    // 如果校验通过，则所有更改生效

    if (isValid) {

      writeMap.forEach((k, v) -> {

        k.curRef = new VersionedRef(v, txnId);

      });

    }

    return isValid;

  }

}

下面我们来模拟实现 Multiverse 中的原子化操作 atomic()。atomic() 方法中使用了类似于 CAS 的操作，如果事务提交失败，那么就重新创建一个新的事务，重新执行。

@FunctionalInterface

public interface TxnRunnable {

  void run(Txn txn);

}

//STM

public final class STM {

  // 私有化构造方法

  private STM() {

  // 提交数据需要用到的全局锁

  static final Object commitLock = new Object();

  // 原子化提交方法

  public static void atomic(TxnRunnable action) {

    boolean committed = false;

    // 如果没有提交成功，则一直重试

    while (!committed) {

      // 创建新的事务

      STMTxn txn = new STMTxn();

      // 执行业务逻辑

      action.run(txn);

      // 提交事务

      committed = txn.commit();

    }

  }

}

就这样，我们自己实现了 STM，并完成了线程安全的转账操作，使用方法和 Multiverse 差不多，这里就不赘述了，具体代码如下面所示。

class Account {

  // 余额

  private TxnRef<Integer> balance;

  // 构造方法

  public Account(int balance) {

    this.balance = new TxnRef<Integer>(balance);

  }

  // 转账操作

  public void transfer(Account target, int amt){

    STM.atomic((txn)->{

      Integer from = balance.getValue(txn);

      balance.setValue(from-amt, txn);

      Integer to = target.balance.getValue(txn);

      target.balance.setValue(to+amt, txn);

    });

  }

}

总结

STM 借鉴的是数据库的经验，数据库虽然复杂，但仅仅存储数据，而编程语言除了有共享变量之外，还会执行各种 I/O 操作，很显然 I/O 操作是很难支持回滚的。所以，STM 也不是万能的。目前支持 STM 的编程语言主要是函数式语言，函数式语言里的数据天生具备不可变性，利用这种不可变性实现 STM 相对来说更简单。

另外，需要说明的是，文中的“自己实现 STM”部分我参考了Software Transactional Memory in Scala这篇博文以及一个 GitHub 项目，目前还很粗糙，并不是一个完备的 MVCC。如果你对这方面感兴趣，可以参考Improving the STM: Multi-Version Concurrency Control 这篇博文，里面讲到了如何优化，你可以尝试学习下。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

44 | 协程：更轻量级的线程

Java 语言里解决并发问题靠的是多线程，但线程是个重量级的对象，不能频繁创建、销毁，而且线程切换的成本也很高，为了解决这些问题，Java SDK 提供了线程池。然而用好线程池并不容易，Java 围绕线程池提供了很多工具类，这些工具类学起来也不容易。那有没有更好的解决方案呢？Java 语言里目前还没有，但是其他语言里有，这个方案就是协程（Coroutine）。

我们可以把协程简单地理解为一种轻量级的线程。从操作系统的角度来看，线程是在内核态中调度的，而协程是在用户态调度的，所以相对于线程来说，协程切换的成本更低。协程虽然也有自己的栈，但是相比线程栈要小得多，典型的线程栈大小差不多有 1M，而协程栈的大小往往只有几 K 或者几十 K。所以，无论是从时间维度还是空间维度来看，协程都比线程轻量得多。

支持协程的语言还是挺多的，例如 Golang、Python、Lua、Kotlin 等都支持协程。下面我们就以 Golang 为代表，看看协程是如何在 Golang 中使用的。

Golang 中的协程

在 Golang 中创建协程非常简单，在下面的示例代码中，要让 hello() 方法在一个新的协程中执行，只需要go hello("World") 这一行代码就搞定了。你可以对比着想想在 Java 里是如何“辛勤”地创建线程和线程池的吧，我的感觉一直都是：每次写完 Golang 的代码，就再也不想写 Java 代码了。

import (

	"fmt"

	"time"

)

func hello(msg string) {

	fmt.Println("Hello " + msg)

}

func main() {

    // 在新的协程中执行 hello 方法

	go hello("World")

    fmt.Println("Run in main")

    // 等待 100 毫秒让协程执行结束

	time.Sleep(100 * time.Millisecond)

}

我们在《33 | Thread-Per-Message 模式：最简单实用的分工方法》中介绍过，利用协程能够很好地实现 Thread-Per-Message 模式。Thread-Per-Message 模式非常简单，其实越是简单的模式，功能上就越稳定，可理解性也越好。

下面的示例代码是用 Golang 实现的 echo 程序的服务端，用的是 Thread-Per-Message 模式，为每个成功建立连接的 socket 分配一个协程，相比 Java 线程池的实现方案，Golang 中协程的方案更简单。

import (

	"log"

	"net"

)

func main() {

    // 监听本地 9090 端口

	socket, err := net.Listen("tcp", "127.0.0.1:9090")

	if err != nil {

		log.Panicln(err)

	}

	defer socket.Close()

	for {

        // 处理连接请求

		conn, err := socket.Accept()

		if err != nil {

			log.Panicln(err)

		}

        // 处理已经成功建立连接的请求

		go handleRequest(conn)

	}

}

// 处理已经成功建立连接的请求

func handleRequest(conn net.Conn) {

	defer conn.Close()

	for {

		buf := make([]byte, 1024)

        // 读取请求数据

		size, err := conn.Read(buf)

		if err != nil {

			return

		}

        // 回写相应数据

		conn.Write(buf[:size])

	}

}

利用协程实现同步

其实协程并不仅限于实现 Thread-Per-Message 模式，它还可以将异步模式转换为同步模式。异步编程虽然近几年取得了长足发展，但是异步的思维模式对于普通人来讲毕竟是有难度的，只有线性的思维模式才是适合所有人的。而线性的思维模式反映到编程世界，就是同步。

在 Java 里使用多线程并发地处理 I/O，基本上用的都是异步非阻塞模型，这种模型的异步主要是靠注册回调函数实现的，那能否都使用同步处理呢？显然是不能的。因为同步意味着等待，而线程等待，本质上就是一种严重的浪费。不过对于协程来说，等待的成本就没有那么高了，所以基于协程实现同步非阻塞是一个可行的方案。

OpenResty 里实现的 cosocket 就是一种同步非阻塞方案，借助 cosocket 我们可以用线性的思维模式来编写非阻塞的程序。下面的示例代码是用 cosocket 实现的 socket 程序的客户端，建立连接、发送请求、读取响应所有的操作都是同步的，由于 cosocket 本身是非阻塞的，所以这些操作虽然是同步的，但是并不会阻塞。

-- 创建 socket

local sock = ngx.socket.tcp()

-- 设置 socket 超时时间

sock:settimeouts(connect_timeout, send_timeout, read_timeout)

-- 连接到目标地址

local ok, err = sock:connect(host, port)

if not ok then

-  -- 省略异常处理

end

-- 发送请求

local bytes, err = sock:send(request_data)

if not bytes then

  -- 省略异常处理

end

-- 读取响应

local line, err = sock:receive()

if err then

  -- 省略异常处理

end

-- 关闭 socket

sock:close()

-- 处理读取到的数据 line

handle(line)

结构化并发编程

Golang 中的 go 语句让协程用起来太简单了，但是这种简单也蕴藏着风险。要深入了解这个风险是什么，就需要先了解一下 goto 语句的前世今生。

在我上学的时候，各种各样的编程语言书籍中都会谈到不建议使用 goto 语句，原因是 goto 语句会让程序变得混乱，当时对于这个问题我也没有多想，不建议用那就不用了。那为什么 goto 语句会让程序变得混乱呢？混乱具体指的又是什么呢？多年之后，我才了解到所谓的混乱指的是代码的书写顺序和执行顺序不一致。代码的书写顺序，代表的是我们的思维过程，如果思维的过程与代码执行的顺序不一致，那就会干扰我们对代码的理解。我们的思维是线性的，傻傻地一条道儿跑到黑，而 goto 语句太灵活，随时可以穿越时空，实在是太“混乱”了。

首先发现 goto 语句是“毒药”的人是著名的计算机科学家艾兹格·迪科斯彻（Edsger Dijkstra），同时他还提出了结构化程序设计。在结构化程序设计中，可以使用三种基本控制结构来代替 goto，这三种基本的控制结构就是今天我们广泛使用的顺序结构、选择结构和循环结构。

顺序结构

选择结构

循环结构（while）

循环结构（do while）

这三种基本的控制结构奠定了今天高级语言的基础，如果仔细观察这三种结构，你会发现它们的入口和出口只有一个，这意味它们是可组合的，而且组合起来一定是线性的，整体来看，代码的书写顺序和执行顺序也是一致的。

我们以前写的并发程序，是否违背了结构化程序设计呢？这个问题以前并没有被关注，但是最近两年，随着并发编程的快速发展，已经开始有人关注了，而且剑指 Golang 中的 go 语句，指其为“毒药”，类比的是 goto 语句。详情可以参考相关的文章。

Golang 中的 go 语句不过是快速创建协程的方法而已，这篇文章本质上并不仅仅在批判 Golang 中的 go 语句，而是在批判开启新的线程（或者协程）异步执行这种粗糙的做法，违背了结构化程序设计，Java 语言其实也在其列。

当开启一个新的线程时，程序会并行地出现两个分支，主线程一个分支，子线程一个分支，这两个分支很多情况下都是天各一方、永不相见。而结构化的程序，可以有分支，但是最终一定要汇聚，不能有多个出口，因为只有这样它们组合起来才是线性的。

总结

最近几年支持协程的开发语言越来越多了，Java OpenSDK 中 Loom 项目的目标就是支持协程，相信不久的将来，Java 程序员也可以使用协程来解决并发问题了。

计算机里很多面向开发人员的技术，大多数都是在解决一个问题：易用性。协程作为一项并发编程技术，本质上也不过是解决并发工具的易用性问题而已。对于易用性，我觉得最重要的就是要适应我们的思维模式，在工作的前几年，我并没有怎么关注它，但是最近几年思维模式已成为我重点关注的对象。因为思维模式对工作的很多方面都会产生影响，例如质量。

一个软件产品是否能够活下去，从质量的角度看，最核心的就是代码写得好。那什么样的代码是好代码呢？我觉得，最根本的是可读性好。可读性好的代码，意味着大家都可以上手，而且上手后不会大动干戈。那如何让代码的可读性好呢？很简单，换位思考，用大众、普通的思维模式去写代码，而不是炫耀自己的各种设计能力。我觉得好的代码，就像人民的艺术一样，应该是为人民群众服务的，只有根植于广大群众之中，才有生命力。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

45 | CSP模型：Golang的主力队员

Golang 是一门号称从语言层面支持并发的编程语言，支持并发是 Golang 一个非常重要的特性。在上一篇文章《44 | 协程：更轻量级的线程》中我们介绍过，Golang 支持协程，协程可以类比 Java 中的线程，解决并发问题的难点就在于线程（协程）之间的协作。

那 Golang 是如何解决协作问题的呢？

总的来说，Golang 提供了两种不同的方案：一种方案支持协程之间以共享内存的方式通信，Golang 提供了管程和原子类来对协程进行同步控制，这个方案与 Java 语言类似；另一种方案支持协程之间以消息传递（Message-Passing）的方式通信，本质上是要避免共享，Golang 的这个方案是基于CSP（Communicating Sequential Processes）模型实现的。Golang 比较推荐的方案是后者。

什么是 CSP 模型

我们在《42 | Actor 模型：面向对象原生的并发模型》中介绍了 Actor 模型，Actor 模型中 Actor 之间就是不能共享内存的，彼此之间通信只能依靠消息传递的方式。Golang 实现的 CSP 模型和 Actor 模型看上去非常相似，Golang 程序员中有句格言：“不要以共享内存方式通信，要以通信方式共享内存（Don’t communicate by sharing memory, share memory by communicating）。”虽然 Golang 中协程之间，也能够以共享内存的方式通信，但是并不推荐；而推荐的以通信的方式共享内存，实际上指的就是协程之间以消息传递方式来通信。

下面我们先结合一个简单的示例，看看 Golang 中协程之间是如何以消息传递的方式实现通信的。我们示例的目标是打印从 1 累加到 100 亿的结果，如果使用单个协程来计算，大概需要 4 秒多的时间。单个协程，只能用到 CPU 中的一个核，为了提高计算性能，我们可以用多个协程来并行计算，这样就能发挥多核的优势了。

在下面的示例代码中，我们用了 4 个子协程来并行执行，这 4 个子协程分别计算 [1, 25 亿]、(25 亿, 50 亿]、(50 亿, 75 亿]、(75 亿, 100 亿]，最后再在主协程中汇总 4 个子协程的计算结果。主协程要汇总 4 个子协程的计算结果，势必要和 4 个子协程之间通信，Golang 中协程之间通信推荐的是使用 channel，channel 你可以形象地理解为现实世界里的管道。另外，calc() 方法的返回值是一个只能接收数据的 channel ch，它创建的子协程会把计算结果发送到这个 ch 中，而主协程也会将这个计算结果通过 ch 读取出来。

import (

	"fmt"

	"time"

)

func main() {

    // 变量声明

	var result, i uint64

    // 单个协程执行累加操作

	start := time.Now()

	for i = 1; i <= 10000000000; i++ {

		result += i

	}

	// 统计计算耗时

	elapsed := time.Since(start)

	fmt.Printf(" 执行消耗的时间为:", elapsed)

	fmt.Println(", result:", result)

    // 4 个协程共同执行累加操作

	start = time.Now()

	ch1 := calc(1, 2500000000)

	ch2 := calc(2500000001, 5000000000)

	ch3 := calc(5000000001, 7500000000)

	ch4 := calc(7500000001, 10000000000)

    // 汇总 4 个协程的累加结果

	result = <-ch1 + <-ch2 + <-ch3 + <-ch4

	// 统计计算耗时

	elapsed = time.Since(start)

	fmt.Printf(" 执行消耗的时间为:", elapsed)

	fmt.Println(", result:", result)

}

// 在协程中异步执行累加操作，累加结果通过 channel 传递

func calc(from uint64, to uint64) <-chan uint64 {

    // channel 用于协程间的通信

	ch := make(chan uint64)

    // 在协程中执行累加操作

	go func() {

		result := from

		for i := from + 1; i <= to; i++ {

			result += i

		}

        // 将结果写入 channel

		ch <- result

	}()

    // 返回结果是用于通信的 channel

	return ch

}

CSP 模型与生产者 - 消费者模式

你可以简单地把 Golang 实现的 CSP 模型类比为生产者 - 消费者模式，而 channel 可以类比为生产者 - 消费者模式中的阻塞队列。不过，需要注意的是 Golang 中 channel 的容量可以是 0，容量为 0 的 channel 在 Golang 中被称为无缓冲的 channel，容量大于 0 的则被称为有缓冲的 channel。

无缓冲的 channel 类似于 Java 中提供的 SynchronousQueue，主要用途是在两个协程之间做数据交换。比如上面累加器的示例代码中，calc() 方法内部创建的 channel 就是无缓冲的 channel。

而创建一个有缓冲的 channel 也很简单，在下面的示例代码中，我们创建了一个容量为 4 的 channel，同时创建了 4 个协程作为生产者、4 个协程作为消费者。

// 创建一个容量为 4 的 channel

ch := make(chan int, 4)

// 创建 4 个协程，作为生产者

for i := 0; i < 4; i++ {

	go func() {

		ch <- 7

	}()

}

// 创建 4 个协程，作为消费者

for i := 0; i < 4; i++ {

    go func() {

    	o := <-ch

    	fmt.Println("received:", o)

    }()

}

Golang 中的 channel 是语言层面支持的，所以可以使用一个左向箭头（<-）来完成向 channel 发送数据和读取数据的任务，使用上还是比较简单的。Golang 中的 channel 是支持双向传输的，所谓双向传输，指的是一个协程既可以通过它发送数据，也可以通过它接收数据。

不仅如此，Golang 中还可以将一个双向的 channel 变成一个单向的 channel，在累加器的例子中，calc() 方法中创建了一个双向 channel，但是返回的就是一个只能接收数据的单向 channel，所以主协程中只能通过它接收数据，而不能通过它发送数据，如果试图通过它发送数据，编译器会提示错误。对比之下，双向变单向的功能，如果以 SDK 方式实现，还是很困难的。

CSP 模型与 Actor 模型的区别

同样是以消息传递的方式来避免共享，那 Golang 实现的 CSP 模型和 Actor 模型有什么区别呢？

第一个最明显的区别就是：Actor 模型中没有 channel。虽然 Actor 模型中的 mailbox 和 channel 非常像，看上去都像个 FIFO 队列，但是区别还是很大的。Actor 模型中的 mailbox 对于程序员来说是“透明”的，mailbox 明确归属于一个特定的 Actor，是 Actor 模型中的内部机制；而且 Actor 之间是可以直接通信的，不需要通信中介。但 CSP 模型中的 channel 就不一样了，它对于程序员来说是“可见”的，是通信的中介，传递的消息都是直接发送到 channel 中的。

第二个区别是：Actor 模型中发送消息是非阻塞的，而 CSP 模型中是阻塞的。Golang 实现的 CSP 模型，channel 是一个阻塞队列，当阻塞队列已满的时候，向 channel 中发送数据，会导致发送消息的协程阻塞。

第三个区别则是关于消息送达的。在《42 | Actor 模型：面向对象原生的并发模型》这篇文章中，我们介绍过 Actor 模型理论上不保证消息百分百送达，而在 Golang 实现的CSP 模型中，是能保证消息百分百送达的。不过这种百分百送达也是有代价的，那就是有可能会导致死锁。

比如，下面这段代码就存在死锁问题，在主协程中，我们创建了一个无缓冲的 channel ch，然后从 ch 中接收数据，此时主协程阻塞，main() 方法中的主协程阻塞，整个应用就阻塞了。这就是 Golang 中最简单的一种死锁。

func main() {

    // 创建一个无缓冲的 channel

    ch := make(chan int)

    // 主协程会阻塞在此处，发生死锁

    <- ch

}

总结

Golang 中虽然也支持传统的共享内存的协程间通信方式，但是推荐的还是使用 CSP 模型，以通信的方式共享内存。

Golang 中实现的 CSP 模型功能上还是很丰富的，例如支持 select 语句，select 语句类似于网络编程里的多路复用函数 select()，只要有一个 channel 能够发送成功或者接收到数据就可以跳出阻塞状态。鉴于篇幅原因，我就点到这里，不详细介绍那么多了。

CSP 模型是托尼·霍尔（Tony Hoare）在 1978 年提出的，不过这个模型这些年一直都在发展，其理论远比 Golang 的实现复杂得多，如果你感兴趣，可以参考霍尔写的Communicating Sequential Processes这本电子书。另外，霍尔在并发领域还有一项重要成就，那就是提出了霍尔管程模型，这个你应该很熟悉了，Java 领域解决并发问题的理论基础就是它。

Java 领域可以借助第三方的类库JCSP来支持 CSP 模型，相比 Golang 的实现，JCSP 更接近理论模型，如果你感兴趣，可以下载学习。不过需要注意的是，JCSP 并没有经过广泛的生产环境检验，所以并不建议你在生产环境中使用。

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

结束语 | 十年之后，初心依旧

曾经有个特别好的朋友跟我说过：“你挺适合当老师的！”其实适不适合并不一定，但是好为人师是一定的。到这里，我已经分享了 45 篇的技术文章，估计你也看累了、听累了，需要些时间好好消化消化。所以，最后咱们轻松一下吧，聊聊人生、聊聊理想，正好我也和你聊聊我那些“不堪回首的往事”。

我曾经搞过 5 年的 ERP，其间我是很想在这条路上一直走下去，但在这个行业摸爬滚打了几年之后，我发现这个行业里懂业务比懂技术更重要。于是为了提高业务水平，我就去搞注册会计师了；但在我还没有搞定它的时候，我突然发现自己竟然失业了。这个时候我才意识到，选择拼搏于细分行业里的夕阳产业，是多么愚蠢。选择，永远比努力更重要。

可笑的是我们选择的，往往不是我们期望的那样。后来我阴错阳差去了一家央企，传统观点认为这里和养老院是对门儿，可实际上，在“养老院对门儿”的这三年多，是我成长最快的三年，包括技术。这三年属于被“骂”的最多的三年，做的东西被同行“骂”，汇报被领导“骂”，被“骂”的多了，渐渐就意识到自己的问题了。找到自己的问题，才是最重要的。

一哥们儿曾有过一段经典的总结：所有的失败都可以归结为“错估了形势，低估了敌人，高估了自己”。人，总是高估了自己，显然，我也是。很多时候，我也会一不小心就高估了自己，而且还一点都意识不到。感谢佛家经典《金刚经》，虽说到现在我也没有把它抄完，但是抄到不到一半的时候，我已经深深认识到自己是多么的浅薄与狂妄了。驱除虚妄，才能进步。

搞技术的瓶颈在哪里呢？每个人资质、机遇不同，其实没有必要强求。我也曾经兴趣广泛，大学时还买过全英文的《Intel 微处理器》，搬了几次家，都没舍得扔，前两年终于扔掉了，纯粹是浪费时间和空间。有时我们得承认，不是随便一个领域我们都能干得很深入的，实际场景和资质都很重要。拿不动的东西越早放弃越好，做了减法，才能做加法，生也有涯，该放就放。

工作十年，很多人已经在不同的轨道上了，有些人选择了做管理，有些人选择了创业，只有很少的人在搞技术。十年，很多面具下的脸都已千疮百孔，有些人摘下面具很丑，有些人摘下面具很怪，只有很少的人摘下面具你还认得。事实证明，你不认得的，基本都已落马；你还认得的，基本都混得不错。正所谓，路遥知马力，日久见人心。简单做人，挺好。

工作了十多年，最值得骄傲的是，更加相信善良。最后也祝你十年之后，初心依旧。