Java并发实现原理:JDK源码剖析(txt+pdf+epub+mobi电子书下载)


发布时间:2020-08-09 23:06:34

点击下载

作者:余春龙

出版社:电子工业出版社

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

Java并发实现原理:JDK源码剖析

Java并发实现原理:JDK源码剖析试读:

前言

并发编程可选择的方式有多进程、多线程和多协程。作者在另一本书《软件架构设计:大型网站技术架构与业务架构融合之道》中,曾对这三种方式进行了详细的比较。对于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的新实现、StampedLock、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()与interrupt()函数。1.2 InterruptedException()函数与interrupt()函数1.2.1 什么情况下会抛出Interrupted异常

Interrupt这个词很容易让人产生误解。从字面意思来看,好像是说一个线程运行到一半,把它中断了,然后抛出了InterruptedException异常,其实并不是。仍以上面的代码为例,假设while循环中没有调用任何的阻塞函数,就是通常的算术运算,或者打印一行日志,如下所示。

这个时候,在主线程中调用一句t.interrupt(),请问该线程是否会抛出异常?答案是不会。

再举一个例子,假设这个线程阻塞在一个 synchronized 关键字的地方,正准备拿锁,如下代码所示。

这个时候,在主线程中调用一句t.interrupt(),请问该线程是否会抛出异常?答案是不会。

实际上,只有那些声明了会抛出 InterruptedException 的函数才会抛出异常,也就是下面这些常用的函数:1.2.2 轻量级阻塞与重量级阻塞

能够被中断的阻塞称为轻量级阻塞,对应的线程状态是WAITING或者TIMED_WAITING;而像 synchronized 这种不能被中断的阻塞称为重量级阻塞,对应的状态是 BLOCKED。如图1-1所示的是在调用不同的函数之后,一个线程完整的状态迁移过程。图1-1 线程的状态迁移过程

初始线程处于NEW状态,调用start()之后开始执行,进入RUNNING或者READY状态。如果没有调用任何的阻塞函数,线程只会在RUNNING和READY之间切换,也就是系统的时间片调度。这两种状态的切换是操作系统完成的,开发者基本没有机会介入,除了可以调用yield()函数,放弃对CPU的占用。

一旦调用了图中的任何阻塞函数,线程就会进入WAITING或者TIMED_WAITING状态,两者的区别只是前者为无限期阻塞,后者则传入了一个时间参数,阻塞一个有限的时间。如果使用了synchronized关键字或者synchronized块,则会进入BLOCKED状态。

除了常用的阻塞/唤醒函数,还有一对不太常见的阻塞/唤醒函数,LockSupport.park()/unpark()。这对函数非常关键,Concurrent包中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,这两个对象可以合成一个对象。代码就变成synchronized(this){…},我们要访问的共享资源是对象a,锁也是加在对象a上面的。当然,也可以另外新建一个对象,代码变成synchronized(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的详细实现时,也基于类似的思路。在这个基本的思路之上,synchronized还会有偏向、自旋等优化策略,ReentrantLock同样会用到这些优化策略,到时会结合代码详细展开。1.4 wait()与notify()1.4.1 生产者-消费者模型

生产者-消费者模型是一个常见的多线程编程模型,如图1-3所示。

一个内存队列,多个生产者线程往内存队列中放数据;多个消费者线程从内存队列中取数据。要实现这样一个编程模型,需要做下面几件事情:图1-3 生产者-消费者模型(1)内存队列本身要加锁,才能实现线程安全。(2)阻塞。当内存队列满了,生产者放不进去时,会被阻塞;当内存队列是空的时候,消费者无事可做,会被阻塞。(3)双向通知。消费者被阻塞之后,生产者放入新数据,要notify()消费者;反之,生产者被阻塞之后,消费者消费了数据,要notify()生产者。

第(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()的内部,会先释放锁obj1,然后进入阻塞状态,之后,它被另外一个线程用notify()唤醒,去重新拿锁!其次,wait()调用完成后,执行后面的业务逻辑代码,然后退出synchronized同步块,再次释放锁。

wait()内部的伪代码如下:

只有如此,才能避免上面所说的死锁问题。后面讲Condition实现的时候,会再详细讨论这个问题。1.4.4 wait()与notify()的问题

以上述的生产者-消费者模型来看,其伪代码大致如下:

生产者本来只想通知消费者,但它把其他的生产者也通知了;消费者本来只想通知生产者,但它被其他的消费者通知了。原因就是wait()和notify()所作用的对象和synchronized所作用的对象是同一个,只能有一个对象,无法区分队列空和列队满两个条件。这正是Condition要解决的问题。1.5 volatile关键字

volatile这个关键字很不起眼,其使用场景和语义不像synchronized、wait()和notify()那么明显。正因为其隐晦,volatile 关键字可能是在多线程编程领域中被误解最多的一个。而关键字越隐晦,背后隐含的含义往往越复杂、越深刻。接下来的几个小节将一步步由浅入深地从使用场景讨论到其底层的实现。1.5.1 64位写入的原子性(Half Write)

举一个简单的例子,对于一个long型变量的赋值和取值操作而言,在多线程场景下,线程A调用set(100),线程B调用get(),在某些场景下,返回值可能不是100。

这有点反直觉,如此简单的一个赋值和取值操作,在多线程下面为什么会不对呢?这是因为JVM的规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到“一半的值”。解决办法也很简单,在long前面加上volatile关键字。1.5.2 内存可见性

不仅64位,32位或者位数更小的赋值和取值操作,其实也有问题。以1.1节中,线程关闭的标志位stopped为例,它是一个boolean类型的数字,也可能出现主线程把它设置成true,而工作线程读到的却还是false的情形,这就更反直觉了。

注意,这里并不是说永远读到的都是 false,而是说一个线程写完之后,另外一个线程立即去读,读到的是 false,但之后能读到 true,也就是“最终一致性”,不是“强一致性”。这种特性,对于1.1节中的例子而言并没有太大影响,但如果想实现无锁算法,例如实现一把自旋锁,就会出现一个线程把状态置为了 true,另外一个线程读到的却还是 false,然后两个线程都会拿到这把锁的问题。

所以,我们所说的“内存可见性”,指的是“写完之后立即对其他线程可见”,它的反面不是“不可见”,而是“稍后才能可见”。解决这个问题很容易,给变量加上volatile关键字即可。“内存可见性”问题的出现,跟现代CPU的架构密切相关,1.6节会详细探讨。1.5.3 重排序:DCL问题

单例模式的线程安全的写法不止一种,常用写法为DCL(Double Checking Locking),如下所示。

上述的instance=new Instance()代码有问题:其底层会分为三个操作:(1)分配一块内存。(2)在内存上初始化成员变量。(3)把instance引用指向内存。

在这三个操作中,操作(2)和操作(3)可能重排序,即先把instance指向内存,再初始化成员变量,因为二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时,直接访问里面的成员变量,就可能出错。这就是典型的“构造函数溢出”问题。解决办法也很简单,就是为instance变量加上volatile修饰。

通过上面的例子,可以总结出volatile的三重功效:64位写入的原子性、内存可见性和禁止重排序。接下来,我们进入volatile原理的探究。1.6 JMM与happen-before1.6.1 为什么会存在“内存可见性”问题

要解释清楚这个问题,就涉及现代CPU的架构。图1-4所示为x86架构下CPU缓存的布局,即在一个CPU 4核下,L1、L2、L3三级缓存与主内存的布局。每个核上面有L1、L2缓存,L3缓存为所有核共用。图1-4 x86架构下CPU缓存布局

因为存在CPU缓存一致性协议,例如MESI,多个CPU之间的缓存不会出现不同步的问题,不会有“内存可见性”问题。

但是,缓存一致性协议对性能有很大损耗,为了解决这个问题,CPU 的设计者们在这个基础上又进行了各种优化。例如,在计算单元和L1之间加了Store Buffer、Load Buffer(还有其他各种Buffer),如图1-5所示。

L1、L2、L3和主内存之间是同步的,有缓存一致性协议的保证,但是Store Buffer、Load Buffer和L1之间却是异步的。也就是说,往内存中写入一个变量,这个变量会保存在Store Buffer里面,稍后才异步地写入L1中,同时同步写入主内存中。

注意,这里只是简要画了x86的CPU缓存体系,还没有进一步讨论SMP架构和NUMA的区别,还有其他CPU架构体系,例如PowerPC、MIPS、ARM等,不同CPU的缓存体系会有各种差异。图1-5 加了Store Buffer和Load Buffer的CPU缓存体系

但站在操作系统内核的角度,可以统一看待这件事情,也就是图1-6所示的操作系统内核视角下的CPU缓存模型。图1-6 操作系统内核视角下的CPU缓存模型

多CPU,每个CPU多核,每个核上面可能还有多个硬件线程,对于操作系统来讲,就相当于一个个的逻辑CPU。每个逻辑CPU都有自己的缓存,这些缓存和主内存之间不是完全同步的。

对应到Java里,就是JVM抽象内存模型,如图1-7所示。

到此为止,介绍了不同CPU架构下复杂的缓存体系,也就回答了为什么会出现“内存可见性”问题。图1-7 JVM抽象内存模型1.6.2 重排序与内存可见性的关系

Store Buffer的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)。除此之外,还有编译器和CPU的指令重排序。下面对重排序做一个分类:(1)编译器重排序。对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。(2)CPU指令重排序。在指令级别,让没有依赖关系的多条指令并行。(3)CPU内存重排序。CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。

在三种重排序中,第三类就是造成“内存可见性”问题的主因,下面再举一个例子来进一步说明这个问题。如下所示。

线程1:

X=1

a=Y

线程2:

Y=1

b=X

假设X、Y是两个全局变量,初始的时候,X=0,Y=0。请问,这两个线程执行完毕之后,a、b的正确结果应该是什么?

很显然,线程1和线程2的执行先后顺序是不确定的,可能顺序执行,也可能交叉执行,最终正确的结果可能是:

(1)a=0,b=1

(2)a=1,b=0

(3)a=1,b=1

也就是不管谁先谁后,执行结果应该是这三种场景中的一种。但实际可能是a=0,b=0。

两个线程的指令都没有重排序,执行顺序就是代码的顺序,但仍然可能出现a=0,b=0。原因是线程1先执行X=1,后执行a=Y,但此时X=1还在自己的Store Buffer里面,没有及时写入主内存中。所以,线程2看到的X还是0。线程2的道理与此相同。

这就是一个有意思的地方,虽然线程1觉得自己是按代码顺序正常执行的,但在线程2看来,a=Y和X=1顺序却是颠倒的。指令没有重排序,是写入内存的操作被延迟了,也就是内存被重排序了,这就造成内存可见性问题。1.6.3 as-if-serial语义

对开发者而言,当然希望不要有任何的重排序,这样理解起来最简单,指令执行顺序和代码顺序严格一致,写内存的顺序也严格地和代码顺序一致。但是,从编译器和CPU的角度来看,希望尽最大可能进行重排序,提升运行效率。于是,问题就来了,重排序的原则是什么?什么场景下可以重排序,什么场景下不能重排序呢?

1.单线程程序的重排序规则

无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行结果不能改变,这就是单线程程序的重排序规则。换句话说,只要操作之间没有数据依赖性,如上例所示,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码看起来就像是完全串行地一行行从头执行到尾,这也就是as-if-serial语义。对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问题。

2.多线程程序的重排序规则

编译器和CPU的这一行为对于单线程程序没有影响,但对多线程程序却有影响。对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性并据此做出最合理的优化。所以,编译器和CPU只能保证每个线程的as-if-serial语义。线程之间的数据依赖和相互影响,需要编译器和CPU的上层来确定。上层要告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。

如图1-8所示,编译器和CPU遵守了as-if-serial语义,保证每个线程内部都是“看似完全串行的”。但多个线程会互相读取和写入共享的变量,对于这种相互影响,编译器和 CPU 不会考虑。图1-8 编译器和CPU保证每个线程的as-if-serial语义1.6.4 happen-before是什么

为了明确定义在多线程场景下,什么时候可以重排序,什么时候不能重排序,Java 引入了JMM(Java Memory Model),也就是Java内存模型(单线程场景不用说明,有as-if-serial语义保证)。这个模型就是一套规范,对上,是JVM和开发者之间的协定;对下,是JVM和编译器、CPU之间的协定。

定义这套规范,其实是要在开发者写程序的方便性和系统运行的效率之间找到一个平衡点。一方面,要让编译器和CPU可以灵活地重排序;另一方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile、synchronized等线程同步机制来禁止重排序。

为了描述这个规范,JMM引入了happen-before,使用happen-before描述两个操作之间的内存可见性。那么,happen-before是什么呢?

如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。A happen before B不代表A一定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。happen-before只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约束,也就定义了一系列重排序的约束。

基于happen-before的这种描述方法,JMM对开发者做出了一系列承诺:(1)单线程中的每个操作,happen-before 对应该线程中任意后续操作(也就是 as-if-serial语义保证)。(2)对volatile变量的写入,happen-before对应后续对这个变量的读取。(3)对synchronized的解锁,happen-before对应后续对这个锁的加锁。

……

对于非volatile变量的写入和读取,不在这个承诺之列。通俗来讲,就是JMM对编译器和CPU 来说,volatile 变量不能重排序;非 volatile 变量可以任意重排序。JMM 没有对非 volatile变量做这个承诺,所以出现了前面例子中的各种问题。1.6.5 happen-before的传递性

除了这些基本的happen-before规则,happen-before还具有传递性,即若A happen-before B,B happen-before C,则A happen-before C。

如果一个变量不是volatile变量,当一个线程读取、一个线程写入时可能有问题。那岂不是说,在多线程程序中,我们要么加锁,要么必须把所有变量都声明为volatile变量?这显然不可能,而这就得归功于happen-before的传递性。

来看下面的例子:

假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值一定是a=5。为什么呢?

操作1和操作2是在同一个线程内存中执行的,操作1 happen-before 操作2,同理,操作3 happen-before操作4。又因为c是volatile变量,对c的写入happen-before对c的读取,所以操作2 happen-before操作3。利用happen-before的传递性,就得到:

操作1 happen-before 操作2 happen-before 操作3 happen-before操作4。

所以,操作1的结果,一定对操作4可见。

再看一个例子:

假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值也一定是a=5。

因为与volatile一样,synchronized同样具有happen-before语义。展开上面的代码可得到类似于下面的伪代码:

根据synchronized的happen-before语义,操作4 happen-before 操作5,再结合传递性,最终就会得到:

操作1 happen-before 操作2……happen-before 操作7。所以,a、c都不是volatile变量,但仍然有内存可见性。

happen-before的传递性非常有用,后面讲到Concurrent包的很多实现的时候,还会用到这个特性。1.6.6 C++中的volatile关键字

在C++中也有volatile关键字,但其含义和Java中的有一些差别。考虑下面的代码:

试读结束[说明:试读内容隐藏了图片]

下载完整电子书


相关推荐

最新文章


© 2020 txtepub下载