Previous Next

Java并发实现原理:JDK源码剖析 (余春龙 著)(Z-Library)

Author: 余春龙 著

Java

No Description

📄 File Format: PDF
💾 File Size: 7.3 MB
26
Views
0
Downloads
0.00
Total Donations

📄 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.

📄 Page 1
(This page has no text content)
📄 Page 2
作者简介 余春龙 中科院软件所计算机硕士毕业。热衷于高并发高可用架构、业务 建模、领域驱动设计。在十年的工作中,经历过游戏、社交、广告、 电商等各种类型的项目,积累了较丰富的工程经验。
📄 Page 3
前言 并发编程可选择的方式有多进程、多线程和多协程。作者在另一 本书《软件架构设计:大型网站技术架构与业务架构融合之道》中, 曾对这三种方式进行了详细的比较。对于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原子操作、 锁、无锁等底层原理的认识,不再停留于一个“似是而非”的阶段,
📄 Page 4
而是深刻地认识其本质。 (3)吸收借鉴大师的思维。在 Concurrent 包中,可以看到各种 巧妙的并发处理策略。看了Concurrent包,才会发现在多线程中,不 是只有简陋的互斥锁、通知机制和线程池。 本书将从多线程基础知识讲起,逐步地深入整个Concurrent包。 读完本书,你将对多线程的原理、各种并发的设计原理有一个全面而 深刻的理解。 限于时间和水平,书中难免有不足之处,望广大读者批评指正。 作者 读者服务 微信扫码回复:37972 ● 获取博文视点学院20元付费内容抵扣券。 ● 获取免费增值资源。 ● 加入读者交流群,与更多读者互动。 ● 获取精选书单推荐。 轻松注册成为博文视点社区(www.broadview.com.cn)用户,您 对书中内容的修改意见可在本书页面的“提交勘误”处提交,若被采 纳,将获赠博文视点社区积分。在您购买电子书时,积分可用来抵扣 相应金额。
📄 Page 5
第1章 多线程基础 1.1 线程的优雅关闭 1.1.1 stop()与destory()函数 线程是“一段运行中的代码”,或者说是一个运行中的函数。既 然是在运行中,就存在一个最基本的问题:运行到一半的线程能否强 制杀死? 答案肯定是不能。在Java中,有stop()、destory()之类的函 数,但这些函数都是官方明确不建议使用的。原因很简单,如果强制 杀死线程,则线程中所使用的资源,例如文件描述符、网络连接等不 能正常关闭。 因此,一个线程一旦运行起来,就不要去强行打断它,合理的关 闭办法是让其运行完(也就是函数执行完毕),干净地释放掉所有资 源,然后退出。如果是一个不断循环运行的线程,就需要用到线程间 的通信机制,让主线程通知其退出。 1.1.2 守护线程 在下面的一段代码中:在main(..)函数中开了一个线程,不断 循环打印。请问:main(..)函数退出之后,该线程是否会被强制退 出?整个进程是否会强制退出?
📄 Page 6
答案是不会的。在C语言中,main(..)函数退出后,整个程序也 就退出了,但在Java中并非如此。 对于上面的程序,在t1.start()前面加一行代码t1.setDaemon (true)。当main(..)函数退出后,线程t1就会退出,整个进程也 会退出。 当在一个JVM进程里面开多个线程时,这些线程被分成两类:守护 线程和非守护线程。默认开的都是非守护线程。在Java中有一个规 定:当所有的非守护线程退出后,整个JVM进程就会退出。意思就是守 护线程“不算作数”,守护线程不影响整个 JVM 进程的退出。例如, 垃圾回收线程就是守护线程,它们在后台默默工作,当开发者的所有 前台线程(非守护线程)都退出之后,整个JVM进程就退出了。 1.1.3 设置关闭的标志位 在上面的代码中,线程是一个死循环。但在实际工作中,开发人 员通常不会这样编写,而是通过一个标志位来实现,如下面的代码所 示。 代码1
📄 Page 7
但上面的代码有一个问题:如果MyThread t在while循环中阻塞在 某个地方,例如里面调用了 object.wait()函数,那它可能永远没 有机会再执行 while(!stopped)代码,也就一直无法退出循环。 此时,就要用到下面所讲的InterruptedException()与interru pt()函数。 1.2 InterruptedException()函数与 interrupt()函数 1.2.1 什么情况下会抛出Interrupted异常 Interrupt这个词很容易让人产生误解。从字面意思来看,好像是 说一个线程运行到一半,把它中断了,然后抛出了InterruptedExcept
📄 Page 8
ion异常,其实并不是。仍以上面的代码为例,假设while循环中没有 调用任何的阻塞函数,就是通常的算术运算,或者打印一行日志,如 下所示。 这个时候,在主线程中调用一句t.interrupt(),请问该线程是 否会抛出异常?答案是不会。 再举一个例子,假设这个线程阻塞在一个 synchronized 关键字 的地方,正准备拿锁,如下代码所示。 这个时候,在主线程中调用一句t.interrupt(),请问该线程是 否会抛出异常?答案是不会。 实际上,只有那些声明了会抛出 InterruptedException 的函数 才会抛出异常,也就是下面这些常用的函数:
📄 Page 9
1.2.2 轻量级阻塞与重量级阻塞 能够被中断的阻塞称为轻量级阻塞,对应的线程状态是WAITING或 者TIMED_WAITING;而像 synchronized 这种不能被中断的阻塞称为重 量级阻塞,对应的状态是 BLOCKED。如图1-1所示的是在调用不同的函 数之后,一个线程完整的状态迁移过程。 图1-1 线程的状态迁移过程 初始线程处于NEW状态,调用start()之后开始执行,进入RUNNI NG或者READY状态。如果没有调用任何的阻塞函数,线程只会在RUNNIN G和READY之间切换,也就是系统的时间片调度。这两种状态的切换是 操作系统完成的,开发者基本没有机会介入,除了可以调用yield() 函数,放弃对CPU的占用。
📄 Page 10
一旦调用了图中的任何阻塞函数,线程就会进入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关键字。
📄 Page 11
等价于如下代码: 对于非静态成员函数,锁其实是加在对象a上面的;对于静态成员 函数,锁是加在A.class上面的。当然,class本身也是对象。 这间接回答了关于 synchronized 的常见问题:一个静态成员函 数和一个非静态成员函数,都加了synchronized关键字,分别被两个 线程调用,它们是否互斥?很显然,因为是两把不同的锁,所以不会 互斥。 1.3.2 锁的本质是什么 无论使用什么编程语言,只要是多线程的,就一定会涉及锁。既 然锁如此常见,那么锁的本质到底是什么呢? 如图1-2所示,多个线程要访问同一个资源。线程就是一段段运行 的代码;资源就是一个变量、一个对象或一个文件等;而锁就是要实 现线程对资源的访问控制,保证同一时间只能有一个线程去访问某一 个资源。打个比方,线程就是一个个游客,资源就是一个待参观的房
📄 Page 12
子。这个房子同一时间只允许一个游客进去参观,当一个人出来后下 一个人才能进去。而锁,就是这个房子门口的守卫。如果同一时间允 许多个游客参观,锁就变成信号量,这点后面会专门讨论。 图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上面的。
📄 Page 13
资源和锁合二为一,使得在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所 示。 一个内存队列,多个生产者线程往内存队列中放数据;多个消费 者线程从内存队列中取数据。要实现这样一个编程模型,需要做下面 几件事情:
📄 Page 14
图1-3 生产者-消费者模型 (1)内存队列本身要加锁,才能实现线程安全。 (2)阻塞。当内存队列满了,生产者放不进去时,会被阻塞;当 内存队列是空的时候,消费者无事可做,会被阻塞。 (3)双向通知。消费者被阻塞之后,生产者放入新数据,要noti fy()消费者;反之,生产者被阻塞之后,消费者消费了数据,要not ify()生产者。 第(1)件事情必须要做,第(2)件和第(3)件事情不一定要 做。例如,可以采取一个简单的办法,生产者放不进去之后,睡眠几 百毫秒再重试,消费者取不到数据之后,睡眠几百毫秒再重试。但这 个办法效率低下,也不实时。所以,我们只讨论如何阻塞、如何通知 的问题。 1.如何阻塞? 办法1:线程自己阻塞自己,也就是生产者、消费者线程各自调用 wait()和notify()。 办法2:用一个阻塞队列,当取不到或者放不进去数据的时候,入 队/出队函数本身就是阻塞的。这也就是BlockingQueue的实现,后面 会详细讲述。 2.如何双向通知? 办法1:wait()与notify()机制。 办法2:Condition机制。
📄 Page 15
此处,先讲wait()与notify()机制,后面会专门讲Condition 机制与BlockingQueue机制。 1.4.2 为什么必须和synchronized一起使用 在 Java 里面,wait()和 notify()是 Object 的成员函数, 是基础中的基础。为什么 Java 要把wait()和 notify()放在如此 基础的类里面,而不是作为像 Thread 一类的成员函数,或者其他类 的成员函数呢? 在回答这个问题之前,先要回答为什么wait()和notify()必 须和synchronized一起使用?请看下面的代码: 或者下面的代码:
📄 Page 16
然后,开两个线程,线程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()内部的伪代码如下:
📄 Page 17
只有如此,才能避免上面所说的死锁问题。后面讲Condition实现 的时候,会再详细讨论这个问题。 1.4.4 wait()与notify()的问题 以上述的生产者-消费者模型来看,其伪代码大致如下: 生产者本来只想通知消费者,但它把其他的生产者也通知了;消 费者本来只想通知生产者,但它被其他的消费者通知了。原因就是wai t()和notify()所作用的对象和synchronized所作用的对象是同一 个,只能有一个对象,无法区分队列空和列队满两个条件。这正是Con dition要解决的问题。 1.5 volatile关键字
📄 Page 18
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 内存可见性
📄 Page 19
不仅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),如下所示。
📄 Page 20
上述的instance=new Instance()代码有问题:其底层会分为三 个操作: (1)分配一块内存。 (2)在内存上初始化成员变量。 (3)把instance引用指向内存。 在这三个操作中,操作(2)和操作(3)可能重排序,即先把ins tance指向内存,再初始化成员变量,因为二者并没有先后的依赖关 系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时, 直接访问里面的成员变量,就可能出错。这就是典型的“构造函数溢 出”问题。解决办法也很简单,就是为instance变量加上volatile修 饰。 通过上面的例子,可以总结出volatile的三重功效:64位写入的 原子性、内存可见性和禁止重排序。接下来,我们进入volatile原理 的探究。 1.6 JMM与happen-before
The above is a preview of the first 20 pages. Register to read the complete e-book.

💝 Support Author

0.00
Total Amount (¥)
0
Donation Count

Login to support the author

Login Now

Recommended for You

Loading recommended books...
Failed to load, please try again later
Back to List