1.前言
自从上次写文章已经过去了大半年了,感觉刚过去的2020年还是有挺多变化的。比如,我最终还是选择成为一个开发人员。
既然选择了做开发,那也要做一个好的开发。这半年,读了挺多的书,成长还是挺快的。现在再看半年前写的代码,不忍直视呀。
自己看书比较喜欢做笔记,这个系列还是希望可以发扬光大一下,对于自己来说,就是一个复习的过程;对于看文章的人来说,可以相互交流相互讨论,增强理解。之所以选这本书的读书笔记作为“首发”,是因为这本书的笔记算是做得比较像样的。对于之前的笔记,需要自己再重新整理之后发出来。
对于这本书,个人觉得写得还可以,虽然讲的东西比较底层,但是没那么枯燥。对于看这本书的收获,我想,
1.深入地理解了JMM的内存模型,以及并发关键字的内存语义,尤其是happens-before原则
2.掌握AQS的原理,而AQS是所有锁的实现基础
3.会用锁了,知道了wait-notify机制的用法
4.知道了并发容器框架的存在
虽然内容主要靠搬运,但是还是有自己的理解。那么,希望看下去的你也会有所收获~
----序二----
经过了一轮春招,对这篇笔记也进行了一定的修补,丰富了例如Condition实现原理,JDK1.8中ConcurrentHashMap的源码解析等。
2 JAVA并发机制的底层实现原理
JAVA使用的并发机制依赖于JVM的实现和CPU指令。
一些基础知识
缓存一致性协议&lock前缀指令:
MESI协议:是以缓存行(缓存的基本数据单位,在Intel的CPU上一般是64字节)的几个状态来命名的(全名是Modified、Exclusive、 Share or Invalid)。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:
M:被修改的。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。
E:独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。
S:共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。
I:无效的。本CPU中的这份缓存已经无效。
一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。
缓存一致性协议会阻止同时修改由两个及以上处理器缓存的内存区域。
x86处理器中,lock前缀指令确保在声言该信号期间,处理器可以独占任何共享内存。
可以通过锁缓存、锁总线(开销大)的方式实现。会造成其他处理器的缓存失效。
2.1 volatile
volatile 是轻量级的synchronized。 保证变量的可见性——当一个线程修改一个共享变量时,另一个线程能立即读取到这个修改的值。
volatile写,会形成lock前缀的指令。
2.2 synchronized
利用synchronized声明同步方法,隐式地利用了锁。对于普通同步方法(实例方法),锁是当前的实例对象;对于静态同步方法,锁是当前类的Class对象。
当然,synchronized同步块的锁是括号里配置的对象。
从同步方法的原理来看,(普通)同步方法并非表明同一时间只能有一个线程进入这个同步方法,而是同一时间只能有一个线程进入该对象的同步方法之中,如果有多个同步方法,那任意时间都不能有多个线程在执行多个方法。
2.2.1 JAVA对象头
synchronized用的锁是存在JAVA对象头里的。如果对象为数组类型,用3个字宽(JAVA内字宽=机器字长,但是第三个字是32位的)存储对象头;若为非数组类型,2字宽。数组类型多的字宽为数组长度。还会存储到Class对象的指针,以支持RTTI。由此还可见得,数组是一种类(由JVM自动生成)。
2.2.2 锁的升级与对比
为了减少锁的获得与释放带来的性能消耗,引入偏向锁和轻量级锁。除此之外,锁的优化方式还有锁消除,即对于不会产生线程逃逸的变量的锁,可以安全地消除锁。
锁共有4种状态,无锁、偏向锁、轻量级锁、重量级锁。这几个状况会随着竞争情况逐渐升级。锁能升级但是不能降级。这是为了提高获得和释放锁的效率。
1.偏向锁
[1]大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。
[2]当一个线程访问同步块并获得锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID。
[3]以后线程进入和退出同步块时,不需要用CAS来加锁和解锁,而是测试一下对象头Mark Word里是否存储指向当前线程的偏向锁。如果存储了,则表示已经获得了锁;如果失败,1)偏向锁未被设置,则CAS获得锁;2)反之,竞争这个偏向锁。
[4]偏向锁的撤销:当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁
2. 轻量级锁
[1]执行同步块之前,JVM在栈帧中创建用于存储锁记录的空间,并将对象头中的MW复制到锁记录中。然后线程尝试用CAS将MW替换成指向锁记录的指针,如果成功则获得锁;反之,自旋来获得锁。
[2]如果自旋获取锁失败,锁会膨胀成重量级锁(自旋消耗CPU,等待过长则不如阻塞等待),并阻塞等待持有轻量级锁的线程退出;
[3]获得轻量级锁的线程退出线程时用CAS写回Mark Word,如果写回失败,则表明锁已经膨胀。
3.三种锁的优缺点
偏向锁:加锁解锁无需额外消耗,适用于基本只有一个线程进入同步块的情况
轻量级锁:采用自旋来获取锁,适用于同步块执行速度很快的情况。
重量级锁:利用操作系统来实现锁,线程阻塞,适用于同步块执行时间长的情况。
2.3 原子操作的实现原理
原子操作:不能被中断的一个或一系列操作
上表中的术语中,CAS、缓存行、假共享的概念需要理解。
CPU实现原子操作的方法
总线锁:使某个处理器独占该共享内存
缓存锁:如果内存区域被缓存在处理器缓存行中,并且在LOCK操作期间被锁定,那么当其它处理器回写已被锁定的缓存行数据时,会使缓存行无效。具体请见基础知识中的MESI协议说明。
由于总线锁的开销较大,因此常用缓存锁来优化。
JAVA实现原子操作的方法
锁和循环CAS。
循环CAS
利用了处理器的cmpxchg指令。
CAS实现原子操作存在的问题:ABA问题(可以通过版本标志来解决);循环开销;只能保证一个变量的原子操作
锁
锁机制保证了只有获得锁的线程才能操作锁定的内存区域
3.JAVA内存模型
3.1 JMM基础
线程通信机制分为:共享内存和消息传递
同步是指:程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型中,程序员必须显式代码需要在线程中互斥执行。
JMM抽象模型:主内存、本地内存(线程私有)
指令重排序:编译器优化、指令级并行(如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序)、内存系统重排序(处理器使用读/写缓冲区,因此load\store操作可能是乱序)。禁止重排序需要插入内存屏障。
Happens-before原则:
这是本章最重要的一个原理。如果A happens-before B,那么A的操作结果必须对A可见。这条规则可以在单线程也可以在多线程下遵循。
h-b的几条规则:
一个线程内每个操作,都h-b该线程内任意后续操作
锁的解锁h-b随后对这个锁的加锁
对一个volatile域的写,h-b任意后续(指时间上的后续)对这个volatile的读
A h-b B, B h-b C --> A h-b C
3.2 重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
数据依赖性:写后读、写后写、读后写。若重排序,则会改变程序运行结果。但是这里的依赖性仅针对单个处理器/单个线程中的操作。多处理器多线程的数据依赖不被考虑(不然我们还同步个啥?不然并发还这么难)
as-if-serial:不管怎么重排序,单线程程序执行结果都必须与顺序执行一样。
重排序对多线程的影响:eg,init线程在init后修改状态为已经初始化。但是经过重排序后,可能先设置该变量,再初始化,那么就会产生非常严重的影响。
3.3 顺序一致性
这是一个理想化的参考模型,有两大特性:一个线程中所有操作都按顺序执行;每个操作都是原子且立刻对所有线程可见。就类似一个多路开关一样。
未同步程序的执行特性:只提供最小安全性。线程执行时读到的值,不然是之前某个线程写入的值,不然是默认值(false,0,null)。即JMM保证读到的值不会无中生有(不像cpp/c)。这也是为什么类的字段会初始化,但是函数的局部变量不会的原因(因为局部变量一定是线程私有的,不会被别的线程访问到)。
3.4 volatile的内存语义
可以将volatile变量视为读写皆加了synchronized的同步方法。
volatile变量的两大特性两大特性:
可见性:
对于volatile的读,总是能看到任意线程对这个volatile变量最后的写入。
实现原理:写时,JMM会将该线程对应的本地共享变量刷新到主内存;读时,会将该volatile变量本地内存置为无效,接下来线程从主内存读取共享变量值。
原子性:对单个变量的读/写具有原子性。尤其是指对于64位变量的读写,具有原子性,不会出现高低位各被两个线程写的情况。但是对于a++这种操作,不具有原子性。
实现写-读内存语义的方式:
使用内存屏障禁止指令重排序。
禁止将v写之前的任何指令重排序到v写之后,禁止将v读之后任何指令的排序到v读之前。这一可以保证在v写之前,前面所有操作(尤其是写操作)已经对任意处理器可见。
3.5 锁的内存语义
3.5.1 锁的释放-获取建立的happens-before关系
锁除了让临界区互斥执行外,还能让释放锁的线程向获取同一个锁的线程发送消息。
也就是说:线程A释放锁之前所有可见的共享变量,在B线程获取同一个锁之后,也立刻对B可见。
当线程获取锁时,JMM使线程对应的本地内存置为无效。
3.6 final域的内存语义
构造函数内对final域的写入,与随后将这个被构造对象的引用赋值给一个引用变量,这两个操作不可重排序;
初次读一个包含final 域对象的引用,与随后初次读这个final域,这两个操作不可重排序。
final域不能从构造函数中逸出:在构造函数返回之前,引用不可为其他线程所见,因为final域可能还未初始化。只要在构造函数返回之后,那么可以保证任意线程都能看到final正常初始化之后的值。
final在处理器中的实现:构造函数之前插入S-S屏障;读final域前插入L-L屏障。而对于x86来说,不会有任何屏障插入(毕竟只有S-L屏障,而且不会对有依赖关系的进行重排序)
3.7 理解happens-before
JMM禁止会改变程序执行结果的重排序;对于不改变执行结果的重排序,不做要求。
happens-before的规则
1.程序顺序规则;
2.监视器锁规则(解锁-加锁);
3.volatile规则(写-读);
4.传递性;
5.start()(Thread.start()-新线程的任意操作);
6.join()(线程内任意操作-Thread.join()return);
3.8 双重检查锁定与延迟初始化
双重检查锁定的问题:new Instance()并非原子操作。可能存在先分配内存,然后内存指针逸出,然后对象再初始化的重排序。
解决办法:采用volatile域,采用类静态字段初始化。
类在下列情况下会被初始化:1.实例创建;2.静态方法调用;3.静态字段赋值;4.静态字段(且非常量字段)被使用;5.???是一个顶级类且一个断言语句嵌套在T内被执行。
对于每个类和接口,都有一个唯一的初始化锁LC与之对应。类初始化时会获取这个锁,且每个线程至少获取一次LC锁来保证这个类已经被初始化过了。
4. JAVA并发编程的基础
线程的状态:初始化,运行(包括被、不被操作系统调度两个状态),等待(wait,join,park,lock(因为采用了pak方法)),超时等待(sleep),阻塞(synchronized获取锁时),终止
Daemon线程:当JAVA虚拟机内不存在非Daemon线程时,虚拟机会退出。
线程的中断:可以理解为线程的标识位,由外部调用interrupt()进行置位,线程内部通过isInterrupted()进行判断,通过interruputed()进行复位。线程可以在自己需要的地方响应中断,安全退出线程。
过期的suspend() resume() stop():会造成程序状态未知,已废弃。推荐使用一个boolean变量来通知线程是否需要终止。
4.3 线程间通信
4.3.1 volatile & synchronized
实际上采用共享变量来进行通信。
4.3.2 等待-通知机制
一个线程修改了一个值,然后通知另一个线程,类似生产者-消费者机制。
等待-通知机制,一个线程进入同步块后调用对象O的wait()方法等待(同时也会释放锁),另一个线程进入同步块后,修改共享变量并通过对象O的notify()/notifyAll()方法通知之前的线程。经典应用代码如下:
synchronized(lock) {
while(!flag) {
// 必须检查条件是否满足。比如,有多个生产者竞争同一个条件,生产者采用notifyAll(),那么很可能后续被唤醒的线程看到这个条件已经被消耗了
lock.wait();//这一步同时会释放锁
}
// do something...
}
synchronized(lock) {
// do something...
flag = true;
lock.notify();// 这一步会通知wait的线程。但是要等同步块退出后,其他线程才有可能拿到锁并被唤醒。
}
notify实质上是将一个线程从锁的等待队列移动到同步队列中,被移动的线程状态由waiting->blocked.所以实际上有两个队列,后续的AQS会具体说明。实际过程如下图所示
4.3.4 管道输入输出
创建管道(pipe)的输入输出流,将输出流与输入流连接,然后生成者往输入流推消息,消费者从输出流里拉消息。
4.3.5 Thread.join()
Thread.join()等待线程返回。内部实际上就是调用了待返回对象自身的wait方法。
当线程终止时,会调用自身的notifyAll()方法,通知所有等待该线程返回的对象。
4.3.6 ThreadLocal
一个线程可以根据TL查询到绑定在这个线程上的一个值。
4.4 线程应用实例
4.4.1 等待-超时
等待一件事情发生->判断是否到时间了->不到,就等这么长的时间。
这边有一个小问题,为啥要循环?因为外部可能延迟这件事的发生。
4.4.3 线程池
5.JAVA中的锁
本章会从使用和实现两个方面来剖析JAVA并发包中与锁相关的API和组件
5.1 Lock接口
synchronized同步块适用于简单的获取锁的场景。不需要显式地获取和释放锁,因此也比较方便。而Lock接口则需要显式地这么做,而且必须有finally块保证锁可以成功释放。应用代码如下:
Lock lock = new ReentrantLock();
lock.lock();
try {
// do something
} finally {
lock.unlock();
}
Lock接口实现一些synchronized没有的功能,比如:超时获取锁,被中断地获取锁,尝试非阻塞获取锁(tryLock),条件锁。
5.2 AQS
AQS,AbstractQueueSynchronizer,队列同步器,是用来构建锁和其它同步组件的基础框架。采用int成员变量表示同步状态,内置的FIFO队列完成资源获取线程的排队工作。
AQS主要通过继承来实现其抽象方法来构造锁。模板设计模式。
锁是面向使用者的,它是接口,定义了交互接口;同步器是面向锁的实现者,简化了锁的实现,屏蔽了同步状态管理、线程排队、等待-唤醒等底层操作。
// 同步器提供改变和访问同步状态的三个方法
getState();
setState(int newState);
compareAndSet();
// 同步器可重写的方法。
// 之所以表示可重写,是因为一个锁会有特定的用途,比方说就是独占式的或者就是共享式的,在JDK源码中,这些方法不是被声明成了抽象方法,也不是空函数体,而是抛出UnsupportedOperationException。
// 基本上这些方法全是tryXXXX,因为这些方法都会被下面的模板方法调用。
protected boolean tryAcquire(int arg);// 独占式获取
protected boolean tryRelease(int arg);
protected int tryAcquireShared(int arg);// 共享式获取
protected boolean tryReleaseShared(int arg);
protected boolean isHeldExclusively(); // 是否已经被占用了
// 同步器提供的模板方法
void acquire(int arg); // 独占式获取,如果获取成功则返回;反之进入同步队列等待。
void acquireInterruptibly(int arg); // 等待获取同步状态时可以响应中断,即catch InterruptedException
boolean tryAcquireNanos(int arg, long nanos); // 超时获取同步状态
void acquireShared(int arg); // 共享式获取同步状态,与独占式的区别在于可以同一时间多个线程获取到同步状态
void acquireSharedInterruptibly(int arg);
boolean tryAcquireSharedNanos(int arg, long nanos);
boolean release(int arg); //独占式地释放同步状态,该方法还会在释放同步状态之后唤醒同步队列第一个线程
boolean releaseShared(int arg);
Collection<Thread> getQueuedThreads();// 获取等待在同步队列上的线程集合
自定义锁的常见模板:
class Mutex implements Lock {
private static class Sync extends AbstractQueueSynchronizier {
protected boolean isHeldExclusively() {
....
}
public boolean tryAcquire(int acquires) {
....
}
public boolean tryRelease(int releases) {
....
}
}
// 接下来将Lock所有的接口都实现。采用的是代理的方式实现,全都代理到内部的Sync对象上。
private final Sync sync = new Sync();
public void lock() {
sync.acquire(1);}
// ...其它Lock的接口均要实现
}
5.2.2 队列同步器的实现分析
这里主要分析同步器是如何完成线程同步的,主要包括同步队列、独占式同步状态获取与释放,共享式同步状态获取与释放,超时获取同步状态等同步器的核心数据结构与模板方法。
同步队列
同步器依赖内部的FIFO队列来实现同步状态的管理。
当线程获取同步状态失败时,同步器将当前线程及等待状态等信息构造成一个节点加入同步队列,并阻塞当前线程。
当线程释放同步状态时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
我们再试着思考一下入队和出队环节:
入队时,构造状态节点,通过CAS设置队尾,若成功,再连接之前的尾节点;
首节点是获取同步状态成功的节点(也就是正在占用锁的线程),首节点释放同步状态时,将会唤醒后续节点,后继节点负责重新设置head节点。设置head节点的步骤不会出现竞争问题,因为只会由获取锁的线程设置。
独占式、共享式获取与释放
独占式获取(同一时间只能由一个线程获取锁)时,若获取成功,则不做任何动作。释放锁时,唤醒后续节点
共享式获取(同一时间可以有多个线程得到锁)时,若获取成功且剩余资源大于0,则会唤醒后续节点,实现同一时间可以有多个线程获得锁
因此这两种获取锁的方式的区别在于,一个只在释放时唤醒,一个是获取和释放时都可以唤醒。
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer {
// 独占式获取锁
public final void acquire(int arg) {
// 尝试获取一次,若失败,则加入同步队列中。
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))// 该函数返回true则表示线程被提前唤醒
selfInterrupt();
}
// 共享式获取锁
public final void acquireShared(int arg) {
// 尝试获取一次,若失败,则加入同步队列中。
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);</