第一章 多线程基础

守护线程

当在一个JVM进程里面开多个线程时,这些线程被分成两类:守护线程和非守护线程。默认开的都是非守护线程。在Java中有一个规定:当所有的非守护线程退出后,整个JVM进程就会退出。意思就是守护线程“不算作数”,守护线程不影响整个JVM进程的退出。例如,垃圾回收线程就是守护线程,它们在后台默默工作,当开发者的所有前台线程(非守护线程)都退出之后,整个JVM进程就退出了。

InterruptedException()函数与interrupt()函数

只有那些声明了会抛出InterruptedException 的函数才会抛出异常,也就是下面这些常用的函数:

image-20220309092337338

轻量级阻塞与重量级阻塞

能够被中断的阻塞称为轻量级阻塞,对应的线程状态是WAITING或者TIMED_WAITING;而像synchronized 这种不能被中断的阻塞称为重量级阻塞,对应的状态是BLOCKED。

线程的状态迁移过程

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

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

除了常用的阻塞/唤醒函数,还有一对不太常见的阻塞/唤醒函数,LockSupport.park()/unpark()。这对函数非常关键,Concurrent包中Lock的实现即依赖这一对操作原语。

故而t.interrupted()的精确含义是“唤醒轻量级阻塞”,而不是字面意思“中断一个线程”。

t.isInterrupted()与Thread.interrupted()的区别

因为t.interrupted()相当于给线程发送了一个唤醒的信号,所以如果线程此时恰好处于WAITING或者TIMED_WAITING状态,就会抛出一个InterruptedException,并且线程被唤醒。而如果线程此时并没有被阻塞,则线程什么都不会做。但在后续,线程可以判断自己是否收到过其他线程发来的中断信号,然后做一些对应的处理,这也是本节要讲的两个函数。

这两个函数都是线程用来判断自己是否收到过中断信号的,前者是非静态函数,后者是静态函数。二者的区别在于,前者只是读取中断状态,不修改状态;后者不仅读取中断状态,还会重置中断标志位。

synchronized关键字

锁的对象是什么

synchronized关键字是“给某个对象加了把锁”。

image-20220309092925523

等价于下代码:

对于非静态成员函数,锁其实是加在对象a上面的;对于静态成员函数,锁是加在A.class上面的。当然,class本身也是对象。

这间接回答了关于synchronized 的常见问题:一个静态成员函数和一个非静态成员函数,都加了synchronized关键字,分别被两个线程调用,它们是否互斥?很显然,因为是两把不同的锁,所以不会互斥。

锁的本质是什么

多个线程要访问同一个资源。线程就是一段段运行的代码;资源就是一个变量、一个对象或一个文件等;而锁就是要实现线程对资源的访问控制,保证同一时间只能有一个线程去访问某一个资源。

从程序角度来看,锁其实就是一个“对象”,这个对象要完成以下几件事情:

  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关键字可以加在任何对象的成员上面。这意味着,这个对象既是共享资源,同时也具备“锁”的功能!

synchronized实现原理

在Java的对象头里,有一块数据叫MarkWord。在64位机器上,Mark Word是8字节(64位)的,这64位中有2个重要字段:锁标志位和占用该锁的thread ID。

wait()与notify()

为什么必须和synchronized一起使用

synchronized关键字可以加在任何对象的成员函数上面,任何对象都可能成为锁。那么,wait()和notify()要同样如此普及,也只能放在Object里面了。

wait()与notify()的问题

产者-消费者模型伪代码大致如下:

image-20220309095026286

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

volatile关键字

64位写入的原子性(Half Write)

JVM的规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到“一半的值”。

解决办法很简单,在long前面加上volatile关键字。

内存可见性

内存可见性”指的是“写完之后立即对其他线程可见”,它的反面不是“不可见”,而是“稍后才能可见”。解决这个问题很容易,给变量加上volatile关键字即可。

重排序:DCL问题

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

image-20220309095406143

上述的instance=new Instance()代码有个问题:其底层会分为三个操作:

  1. 分配一块内存。
  2. 在内存上初始化成员变量。
  3. 把instance引用指向内存。

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

JMM与happen-before

为什么会存在“内存可见性”问题

下图所示为x86架构下CPU缓存的布局,即在一个CPU 4核下,L1、L2、L3三级缓存与主内存的布局。每个核上面有L1、L2缓存,L3缓存为所有核共用。

x86架构下CPU缓存布局

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

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

加了Store Buffer和Load Buffer的CPU缓存体系

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

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

操作系统内核视角下的CPU缓存模型

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

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

image-20220309100225327

由于CPU架构下复杂的缓存体系,也就回答了为什么会出现“内存可见性”问题。

重排序与内存可见性的关系

tore Buffer的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)。除此之外,还有编译器和CPU的指令重排序。下面对重排序做一个分类:

  1. 编译器重排序。对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
  2. CPU指令重排序。在指令级别,让没有依赖关系的多条指令并行。
  3. CPU内存重排序。CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。

在三种重排序中,第三类就是造成“内存可见性”问题的主因。

指令没有重排序,是写入内存的操作被延迟了,也就是内存被重排序了,这就造成内存可见性问题。

as-if-serial语义

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

单线程程序的重排序规则

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

多线程程序的重排序规则

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

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

happen-before是什么

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

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

为了描述这个规范,JMM引入了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变量做这个承诺,所以出现了前面例子中的各种问题。

happen-before的传递性

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

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

一个例子:

image-20220309101555083

假设线程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可见。

C++中的volatile关键字

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

image-20220309101808967

通过done这个标志位来标志一个任务是否完成。假设一个线程A调用process,另一个线程不断地轮询调用afterProcess,当done=true的时候,做一些额外工作。

这个代码在C中是错误的,但在Java中却是正确的。因为Java中的volatile关键字不仅具有内存可见性,还会禁止volatile变量写入和非volatile变量写入的重排序,但C中的volatile关键字不会禁止这种重排序。

JSR-133对volatile语义的增强

Java的volatile比C++多出的这点特性,正是JSR-133对volatile语义的增强。下面这段话摘自JSR-133的原文:

What was wrong with the old memory model?The old memory model allowed for volatile writes to bereordered with nonvolatile reads and writes,which was notconsistent with most developers intuitions about volatileand therefore caused confusion.

也就是说,在旧的JMM模型中,volatile变量的写入会和非volatile变量的读取或写入重排序,正如C++中所做的。但新的模型不会,这也正体现了Java对happen-before规则的严格遵守。

内存屏障

为了禁止编译器重排序和CPU 重排序,在编译器和CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是JMM和happen-before规则的底层实现原理。

编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。

而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。下面主要讲CPU的内存屏障。

JDK中的内存屏障

内存屏障是很底层的概念,对于Java 开发者来说,一般用volatile 关键字就足够了。但从JDK 8开始,Java在Unsafe类中提供了三个内存屏障函数,如下所示:

public final class Unsafe {
	public void loadFence() {
        theInternalUnsafe.loadFence();
    }
    
    public void storeFence() {
        theInternalUnsafe.storeFence();
    }
    
    public void fullFence() {
        theInternalUnsafe.fullFence();
    }
}

要说明的是,这三个屏障并不是最基本的内存屏障。在理论层面,可以把基本的CPU内存屏障分成四种:

  1. LoadLoad:禁止读和读的重排序。
  2. StoreStore:禁止写和写的重排序。
  3. LoadStore:禁止读和写的重排序。
  4. StoreLoad:禁止写和读的重排序。

JDK定义的三种内存屏障和理论层面划分的四类内存屏障关系如下:

  • loadFence=LoadLoad+LoadStore
  • storeFence=StoreStore+LoadStore
  • fullFence=loadFence+storeFence+StoreLoad

volatile实现原理

由于不同的CPU架构的缓存体系不一样,重排序的策略不一样,所提供的内存屏障指令也就有差异。

这里只探讨为了实现volatile关键字的语义的一种参考做法:

  1. 在volatile写操作的前面插入一个StoreStore屏障。保证volatile写操作不会和之前的写操作重排序。
  2. 在volatile写操作的后面插入一个StoreLoad屏障。保证volatile写操作不会和之后的读操作重排序。
  3. 在volatile读操作的后面插入一个LoadLoad屏障+LoadStore屏障。保证volatile读操作不会和之后的读操作、写操作重排序。

具体到x86平台上,其实不会有LoadLoad、LoadStore和StoreStore重排序,只有StoreLoad一种重排序(内存屏障),也就是只需要在volatile写操作后面加上StoreLoad屏障。其他的CPU架构做法不一,此处就不再进一步深入讨论。

final关键字

构造函数溢出问题

obj=new Example()这行代码,分解成三个操作:

  1. ① 分配一块内存;
  2. ② 在内存上初始化i=1,j=2;
  3. ③ 把obj指向这块内存。

操作②和操作③可能重排序,因此线程B可能看到未正确初始化的值。对于构造函数溢出,通俗来讲,就是一个对象的构造并不是“原子的”,当一个线程正在构造对象时,另外一个线程却可以读到未构造好的“一半对象”。

final的happen-before语义

要解决这个问题,不止有一种办法。

  • 办法1:给i,j都加上volatile关键字。
  • 办法2:为read/write函数都加上synchronized关键字。
  • 如果i,j只需要初始化一次,则后续值就不会再变了,还有办法3,为其加上final关键字。

之所以能解决问题,是因为同volatile一样,final关键字也有相应的happen-before语义:

  1. 对final域的写(构造函数内部),happen-before于后续对final域所在对象的读。
  2. 对final域所在对象的读,happen-before于后续对final域的读。

通过这种happen-before语义的限定,保证了final域的赋值,一定在构造函数之前完成,不会出现另外一个线程读取到了对象,但对象里面的变量却还没有初始化的情形,避免出现构造函数溢出的问题。

关于final和volatile的特性与背后的原理,到此为止就讲完了,在后续Concurrent包的源码分析中会反复看到这两个关键字的身影。接下来总结常用的几个happen-before规则。

happen-before规则总结

  1. 单线程中的每个操作,happen-before于该线程中任意后续操作。
  2. 对volatile变量的写,happen-before于后续对这个变量的读。
  3. 对synchronized的解锁,happen-before于后续对这个锁的加锁。
  4. 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的读。

**四个基本规则再加上happen-before的传递性,就构成JMM对开发者的整个承诺。**在这个承诺以外的部分,程序都可能被重排序,都需要开发者小心地处理内存可见性问题。

volatile背后的原理如下图:

从底向上看volatile背后的原理

综合应用:无锁编程

提到多线程编程,就绕不开“锁”,在Java中就是指synchronized关键字和Lock。在Linux中,主要是指pthread的mutex。但锁又是性能杀手,所以很多的前辈大师们研究如何可以不用锁,也能实现线程安全。无锁编程是一个庞大而深入的话题,既涉及底层的CPU架构(例如前面讲的内存屏障),又涉及不同语言的具体实现。

一写一读的无锁队列:内存屏障

一写一读的无锁队列即Linux内核的kfifo队列,一写一读两个线程,不需要锁,只需要内存屏障。

一写多读的无锁队列:volatile关键字

在Martin Fowler关于LMAX架构的介绍中,谈到了Disruptor。Disruptor是一个开源的并发框架,能够在无锁的情况下实现Queue并发操作。

Disruptor的RingBuffer之所以可以做到完全无锁,也是因为“单线程写”,这是“前提的前提”。离开了这个前提条件,没有任何技术可以做到完全无锁。借用Disruptor官方提到的一篇博客文章Sharing Data Among Threads WithoutContention,也就是single-writer principle。

在这个原则下,利用volatile 关键字可以实现一写多读的线程安全。具体来说,就是RingBuffer有一个头指针,对应一个生产者线程;多个尾指针对应多个消费者线程。每个消费者线程只会操作自己的尾指针。所有这些指针的类型都是volatile变量,通过头指针和尾指针的比较,判断队列是否为空。

多写多读的无锁队列:CAS

同内存屏障一样,CAS(Compare And Set)也是CPU提供的一种原子指令。

基于CAS和链表,可以实现一个多写多读的队列。具体来说,就是链表有一个头指针head和尾指针tail。入队列,通过对tail进行CAS操作完成;出队列,对head进行CAS操作完成。

无锁栈

无锁栈比无锁队列的实现更简单,只需要对head 指针进行CAS操纵,就能实现多线程的入栈和出栈。

无锁链表

相比无锁队列与无锁栈,无锁链表要复杂得多,因为无锁链表要在中间插入和删除元素。

ConcurrentSkipListMap实现就是基于无锁链表的。