Statistics
23
Views
0
Downloads
0
Donations
Support
Share
Uploader

高宏飞

Shared on 2026-04-13
Support Statistics
¥.00 · 0times
Text Preview (First 20 pages)
Registered users can read the full content for free

Register as a Gaohf Library member to read the complete e-book online for free and enjoy a better reading experience.

(This page has no text content)
作者简介 余春龙 中科院软件所计算机硕士毕业。热衷于高并发高可用架构、业务 建模、领域驱动设计。在十年的工作中,经历过游戏、社交、广告、 电商等各种类型的项目,积累了较丰富的工程经验。
前言 并发编程可选择的方式有多进程、多线程和多协程。作者在另一 本书《软件架构设计:大型网站技术架构与业务架构融合之道》中, 曾对这三种方式进行了详细的比较。对于Java来说,它既不像C++那 样,在运行中调用Linux的系统API去“fork”出多个进程;也不像Go 那样,在语言层面原生提供多协程。在Java中,并发就是多线程模 式。 对于人脑的认知来说,“代码一行行串行”当然最容易理解。但 在多线程下,多个线程的代码交叉并行,要访问互斥资源,要互相通 信。作为开发者,需要仔细设计线程之间的互斥与同步,稍不留心, 就会写出非线程安全的代码。正因此,多线程编程一直是一个被广泛 而深入讨论的领域。 在JDK 1.5发布之前,Java只在语言级别上提供一些简单的线程互 斥与同步机制,也就是synchronized关键字、wait与notify。如果遇 到复杂的多线程编程场景,就需要开发者基于这些简单的机制解决复 杂的线程同步问题。而从JDK 1.5开始,并发编程大师Doug Lea奉上了 一个系统而全面的并发编程框架——JDK Concurrent包,里面包含了 各种原子操作、线程安全的容器、线程池和异步编程等内容。 本书基于JDK 7和JDK 8,对整个Concurrent包进行全面的源码剖 析。JDK 8中大部分并发功能的实现和JDK 7一样,但新增了一些额外 特性。例如CompletableFuture、ConcurrentHashMap的新实现、Stamp edLock、LongAdder等。 对整个Concurrent包的源码进行分析,有以下几个目的: (1)帮助使用者合理地选择解决方案。Concurrent包很庞大,有 各式各样的线程互斥与同步机制。明白实现原理,使用者可以根据自 己的业务场景,选择最适合自己的解决方案。避免重复造轮子,也避 免因为使用不当而掉到“坑”里。 (2)对源码的分析,将让使用者对内存屏障、CAS原子操作、 锁、无锁等底层原理的认识,不再停留于一个“似是而非”的阶段,
而是深刻地认识其本质。 (3)吸收借鉴大师的思维。在 Concurrent 包中,可以看到各种 巧妙的并发处理策略。看了Concurrent包,才会发现在多线程中,不 是只有简陋的互斥锁、通知机制和线程池。 本书将从多线程基础知识讲起,逐步地深入整个Concurrent包。 读完本书,你将对多线程的原理、各种并发的设计原理有一个全面而 深刻的理解。 限于时间和水平,书中难免有不足之处,望广大读者批评指正。 作者 读者服务 微信扫码回复:37972 ● 获取博文视点学院20元付费内容抵扣券。 ● 获取免费增值资源。 ● 加入读者交流群,与更多读者互动。 ● 获取精选书单推荐。 轻松注册成为博文视点社区(www.broadview.com.cn)用户,您 对书中内容的修改意见可在本书页面的“提交勘误”处提交,若被采 纳,将获赠博文视点社区积分。在您购买电子书时,积分可用来抵扣 相应金额。
第1章 多线程基础 1.1 线程的优雅关闭 1.1.1 stop()与destory()函数 线程是“一段运行中的代码”,或者说是一个运行中的函数。既 然是在运行中,就存在一个最基本的问题:运行到一半的线程能否强 制杀死? 答案肯定是不能。在Java中,有stop()、destory()之类的函 数,但这些函数都是官方明确不建议使用的。原因很简单,如果强制 杀死线程,则线程中所使用的资源,例如文件描述符、网络连接等不 能正常关闭。 因此,一个线程一旦运行起来,就不要去强行打断它,合理的关 闭办法是让其运行完(也就是函数执行完毕),干净地释放掉所有资 源,然后退出。如果是一个不断循环运行的线程,就需要用到线程间 的通信机制,让主线程通知其退出。 1.1.2 守护线程 在下面的一段代码中:在main(..)函数中开了一个线程,不断 循环打印。请问:main(..)函数退出之后,该线程是否会被强制退 出?整个进程是否会强制退出?
答案是不会的。在C语言中,main(..)函数退出后,整个程序也 就退出了,但在Java中并非如此。 对于上面的程序,在t1.start()前面加一行代码t1.setDaemon (true)。当main(..)函数退出后,线程t1就会退出,整个进程也 会退出。 当在一个JVM进程里面开多个线程时,这些线程被分成两类:守护 线程和非守护线程。默认开的都是非守护线程。在Java中有一个规 定:当所有的非守护线程退出后,整个JVM进程就会退出。意思就是守 护线程“不算作数”,守护线程不影响整个 JVM 进程的退出。例如, 垃圾回收线程就是守护线程,它们在后台默默工作,当开发者的所有 前台线程(非守护线程)都退出之后,整个JVM进程就退出了。 1.1.3 设置关闭的标志位 在上面的代码中,线程是一个死循环。但在实际工作中,开发人 员通常不会这样编写,而是通过一个标志位来实现,如下面的代码所 示。 代码1
但上面的代码有一个问题:如果MyThread t在while循环中阻塞在 某个地方,例如里面调用了 object.wait()函数,那它可能永远没 有机会再执行 while(!stopped)代码,也就一直无法退出循环。 此时,就要用到下面所讲的InterruptedException()与interru pt()函数。 1.2 InterruptedException()函数与 interrupt()函数 1.2.1 什么情况下会抛出Interrupted异常 Interrupt这个词很容易让人产生误解。从字面意思来看,好像是 说一个线程运行到一半,把它中断了,然后抛出了InterruptedExcept
ion异常,其实并不是。仍以上面的代码为例,假设while循环中没有 调用任何的阻塞函数,就是通常的算术运算,或者打印一行日志,如 下所示。 这个时候,在主线程中调用一句t.interrupt(),请问该线程是 否会抛出异常?答案是不会。 再举一个例子,假设这个线程阻塞在一个 synchronized 关键字 的地方,正准备拿锁,如下代码所示。 这个时候,在主线程中调用一句t.interrupt(),请问该线程是 否会抛出异常?答案是不会。 实际上,只有那些声明了会抛出 InterruptedException 的函数 才会抛出异常,也就是下面这些常用的函数:
1.2.2 轻量级阻塞与重量级阻塞 能够被中断的阻塞称为轻量级阻塞,对应的线程状态是WAITING或 者TIMED_WAITING;而像 synchronized 这种不能被中断的阻塞称为重 量级阻塞,对应的状态是 BLOCKED。如图1-1所示的是在调用不同的函 数之后,一个线程完整的状态迁移过程。 图1-1 线程的状态迁移过程 初始线程处于NEW状态,调用start()之后开始执行,进入RUNNI NG或者READY状态。如果没有调用任何的阻塞函数,线程只会在RUNNIN G和READY之间切换,也就是系统的时间片调度。这两种状态的切换是 操作系统完成的,开发者基本没有机会介入,除了可以调用yield() 函数,放弃对CPU的占用。
一旦调用了图中的任何阻塞函数,线程就会进入WAITING或者TIME D_WAITING状态,两者的区别只是前者为无限期阻塞,后者则传入了一 个时间参数,阻塞一个有限的时间。如果使用了synchronized关键字 或者synchronized块,则会进入BLOCKED状态。 除了常用的阻塞/唤醒函数,还有一对不太常见的阻塞/唤醒函 数,LockSupport.park()/unpark()。这对函数非常关键,Concur rent包中Lock的实现即依赖这一对操作原语。 故而t.interrupted()的精确含义是“唤醒轻量级阻塞”,而不 是字面意思“中断一个线程”。 1.2.3 t.isInterrupted()与Thread.interrupted()的区别 因为 t.interrupted()相当于给线程发送了一个唤醒的信号, 所以如果线程此时恰好处于WAITING或者TIMED_WAITING状态,就会抛 出一个InterruptedException,并且线程被唤醒。而如果线程此时并 没有被阻塞,则线程什么都不会做。但在后续,线程可以判断自己是 否收到过其他线程发来的中断信号,然后做一些对应的处理,这也是 本节要讲的两个函数。 这两个函数都是线程用来判断自己是否收到过中断信号的,前者 是非静态函数,后者是静态函数。二者的区别在于,前者只是读取中 断状态,不修改状态;后者不仅读取中断状态,还会重置中断标志 位。 1.3 synchronized关键字 1.3.1 锁的对象是什么 对不熟悉多线程原理的人来说,很容易误解 synchronized 关键 字:它通常加在所有的静态成员函数和非静态成员函数的前面,表面 看好像是“函数之间的互斥”,其实不是。synchronized关键字其实 是“给某个对象加了把锁”,这个锁究竟加在了什么对象上面?如下 面的代码所示,给函数f1()、f2()加上synchronized关键字。
等价于如下代码: 对于非静态成员函数,锁其实是加在对象a上面的;对于静态成员 函数,锁是加在A.class上面的。当然,class本身也是对象。 这间接回答了关于 synchronized 的常见问题:一个静态成员函 数和一个非静态成员函数,都加了synchronized关键字,分别被两个 线程调用,它们是否互斥?很显然,因为是两把不同的锁,所以不会 互斥。 1.3.2 锁的本质是什么 无论使用什么编程语言,只要是多线程的,就一定会涉及锁。既 然锁如此常见,那么锁的本质到底是什么呢? 如图1-2所示,多个线程要访问同一个资源。线程就是一段段运行 的代码;资源就是一个变量、一个对象或一个文件等;而锁就是要实 现线程对资源的访问控制,保证同一时间只能有一个线程去访问某一 个资源。打个比方,线程就是一个个游客,资源就是一个待参观的房
子。这个房子同一时间只允许一个游客进去参观,当一个人出来后下 一个人才能进去。而锁,就是这个房子门口的守卫。如果同一时间允 许多个游客参观,锁就变成信号量,这点后面会专门讨论。 图1-2 线程、锁和资源三者关系示意图 从程序角度来看,锁其实就是一个“对象”,这个对象要完成以 下几件事情: (1)这个对象内部得有一个标志位(state变量),记录自己有 没有被某个线程占用(也就是记录当前有没有游客已经进入了房 子)。最简单的情况是这个state有0、1两个取值,0表示没有线程占 用这个锁,1表示有某个线程占用了这个锁。 (2)如果这个对象被某个线程占用,它得记录这个线程的thread ID,知道自己是被哪个线程占用了(也就是记录现在是谁在房子里 面)。 (3)这个对象还得维护一个thread id list,记录其他所有阻塞 的、等待拿这个锁的线程(也就是记录所有在外边等待的游客)。在 当前线程释放锁之后(也就是把state从1改回0),从这个thread id list里面取一个线程唤醒。 既然锁是一个“对象”,要访问的共享资源本身也是一个对象, 例如前面的对象 a,这两个对象可以合成一个对象。代码就变成synch ronized(this){…},我们要访问的共享资源是对象a,锁也是加在 对象a上面的。当然,也可以另外新建一个对象,代码变成synchroniz ed(obj1){…}。这个时候,访问的共享资源是对象a,而锁是加在新 建的对象obj1上面的。
资源和锁合二为一,使得在Java里面,synchronized关键字可以 加在任何对象的成员上面。这意味着,这个对象既是共享资源,同时 也具备“锁”的功能! 下面来看 Java 是如何做到让任何一个对象都具备“锁”的功能 的,这也就是 synchronized的实现原理。 1.3.3 synchronized实现原理 答案在Java的对象头里。在对象头里,有一块数据叫Mark Word。 在64位机器上,Mark Word是8字节(64位)的,这64位中有2个重要字 段:锁标志位和占用该锁的thread ID。因为不同版本的JVM实现,对 象头的数据结构会有各种差异,此处不再进一步讨论。 此处主要是想说明锁实现的思路,因为后面讲ReentrantLock的详 细实现时,也基于类似的思路。在这个基本的思路之上,synchronize d还会有偏向、自旋等优化策略,ReentrantLock同样会用到这些优化 策略,到时会结合代码详细展开。 1.4 wait()与notify() 1.4.1 生产者-消费者模型 生产者-消费者模型是一个常见的多线程编程模型,如图1-3所 示。 一个内存队列,多个生产者线程往内存队列中放数据;多个消费 者线程从内存队列中取数据。要实现这样一个编程模型,需要做下面 几件事情:
图1-3 生产者-消费者模型 (1)内存队列本身要加锁,才能实现线程安全。 (2)阻塞。当内存队列满了,生产者放不进去时,会被阻塞;当 内存队列是空的时候,消费者无事可做,会被阻塞。 (3)双向通知。消费者被阻塞之后,生产者放入新数据,要noti fy()消费者;反之,生产者被阻塞之后,消费者消费了数据,要not ify()生产者。 第(1)件事情必须要做,第(2)件和第(3)件事情不一定要 做。例如,可以采取一个简单的办法,生产者放不进去之后,睡眠几 百毫秒再重试,消费者取不到数据之后,睡眠几百毫秒再重试。但这 个办法效率低下,也不实时。所以,我们只讨论如何阻塞、如何通知 的问题。 1.如何阻塞? 办法1:线程自己阻塞自己,也就是生产者、消费者线程各自调用 wait()和notify()。 办法2:用一个阻塞队列,当取不到或者放不进去数据的时候,入 队/出队函数本身就是阻塞的。这也就是BlockingQueue的实现,后面 会详细讲述。 2.如何双向通知? 办法1:wait()与notify()机制。 办法2:Condition机制。
此处,先讲wait()与notify()机制,后面会专门讲Condition 机制与BlockingQueue机制。 1.4.2 为什么必须和synchronized一起使用 在 Java 里面,wait()和 notify()是 Object 的成员函数, 是基础中的基础。为什么 Java 要把wait()和 notify()放在如此 基础的类里面,而不是作为像 Thread 一类的成员函数,或者其他类 的成员函数呢? 在回答这个问题之前,先要回答为什么wait()和notify()必 须和synchronized一起使用?请看下面的代码: 或者下面的代码:
然后,开两个线程,线程A调用f1(),线程B调用f2()。答案 已经很明显:两个线程之间要通信,对于同一个对象来说,一个线程 调用该对象的wait(),另一个线程调用该对象的notify(),该对 象本身就需要同步!所以,在调用wait()、notify()之前,要先 通过synchronized关键字同步给对象,也就是给该对象加锁。 前面已经讲了,synchronized关键字可以加在任何对象的成员函 数上面,任何对象都可能成为锁。那么,wait()和notify()要同 样如此普及,也只能放在Object里面了。 1.4.3 为什么wait()的时候必须释放锁 当线程A进入synchronized(obj1)中之后,也就是对obj1上了 锁。此时,调用wait()进入阻塞状态,一直不能退出synchronized 代码块;那么,线程B永远无法进入synchronized(obj1)同步块里, 永远没有机会调用notify(),岂不是死锁了? 这就涉及一个关键的问题:在wait()的内部,会先释放锁obj 1,然后进入阻塞状态,之后,它被另外一个线程用notify()唤醒, 去重新拿锁!其次,wait()调用完成后,执行后面的业务逻辑代 码,然后退出synchronized同步块,再次释放锁。 wait()内部的伪代码如下:
只有如此,才能避免上面所说的死锁问题。后面讲Condition实现 的时候,会再详细讨论这个问题。 1.4.4 wait()与notify()的问题 以上述的生产者-消费者模型来看,其伪代码大致如下: 生产者本来只想通知消费者,但它把其他的生产者也通知了;消 费者本来只想通知生产者,但它被其他的消费者通知了。原因就是wai t()和notify()所作用的对象和synchronized所作用的对象是同一 个,只能有一个对象,无法区分队列空和列队满两个条件。这正是Con dition要解决的问题。 1.5 volatile关键字
volatile这个关键字很不起眼,其使用场景和语义不像synchroni zed、wait()和notify()那么明显。正因为其隐晦,volatile 关 键字可能是在多线程编程领域中被误解最多的一个。而关键字越隐 晦,背后隐含的含义往往越复杂、越深刻。接下来的几个小节将一步 步由浅入深地从使用场景讨论到其底层的实现。 1.5.1 64位写入的原子性(Half Write) 举一个简单的例子,对于一个long型变量的赋值和取值操作而 言,在多线程场景下,线程A调用set(100),线程B调用get(),在 某些场景下,返回值可能不是100。 这有点反直觉,如此简单的一个赋值和取值操作,在多线程下面 为什么会不对呢?这是因为JVM的规范并没有要求64位的long或者doub le的写入是原子的。在32位的机器上,一个64位变量的写入可能被拆 分成两个32位的写操作来执行。这样一来,读取的线程就可能读到 “一半的值”。解决办法也很简单,在long前面加上volatile关键 字。 1.5.2 内存可见性
不仅64位,32位或者位数更小的赋值和取值操作,其实也有问 题。以1.1节中,线程关闭的标志位stopped为例,它是一个boolean类 型的数字,也可能出现主线程把它设置成true,而工作线程读到的却 还是false的情形,这就更反直觉了。 注意,这里并不是说永远读到的都是 false,而是说一个线程写 完之后,另外一个线程立即去读,读到的是 false,但之后能读到 tr ue,也就是“最终一致性”,不是“强一致性”。这种特性,对于1.1 节中的例子而言并没有太大影响,但如果想实现无锁算法,例如实现 一把自旋锁,就会出现一个线程把状态置为了 true,另外一个线程读 到的却还是 false,然后两个线程都会拿到这把锁的问题。 所以,我们所说的“内存可见性”,指的是“写完之后立即对其 他线程可见”,它的反面不是“不可见”,而是“稍后才能可见”。 解决这个问题很容易,给变量加上volatile关键字即可。 “内存可见性”问题的出现,跟现代CPU的架构密切相关,1.6节 会详细探讨。 1.5.3 重排序:DCL问题 单例模式的线程安全的写法不止一种,常用写法为DCL(Double C hecking Locking),如下所示。
上述的instance=new Instance()代码有问题:其底层会分为三 个操作: (1)分配一块内存。 (2)在内存上初始化成员变量。 (3)把instance引用指向内存。 在这三个操作中,操作(2)和操作(3)可能重排序,即先把ins tance指向内存,再初始化成员变量,因为二者并没有先后的依赖关 系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时, 直接访问里面的成员变量,就可能出错。这就是典型的“构造函数溢 出”问题。解决办法也很简单,就是为instance变量加上volatile修 饰。 通过上面的例子,可以总结出volatile的三重功效:64位写入的 原子性、内存可见性和禁止重排序。接下来,我们进入volatile原理 的探究。 1.6 JMM与happen-before