Java 并发中的各种“锁”事
锁能做什么?众所周知,锁是来控制多个线程访问共享资源的方式。一般来说,一个锁能够防止多个线程同时访问共享资源,保证原子性(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。
Java 对锁本身进行了良好的封装,降低了我们开发时的使用难度。在 Lock 接口出现之前,Java 程序是靠 synchronized 关键字实现锁功能的,而 JavaSE 5 之后,J.U.C 包中新增了 Lock 接口以及相关实现类(比如 ReentrantLock)用来实现锁功能。
上述两种也就是我们日常挂在嘴边的 “锁” 了,而事实上,“锁” 其实还有更细粒度的划分,熟练掌握各种锁的底层原理,才能灵活地在不同场景下选择最适合的锁。
本篇文章会总结 Java 在并发中常用的锁以及其适用场景,并会加入小部分的源码分析。
需要注意的是,这些锁是广义上的概念,是一种思想,并不是真真切切的实现,概念之间又会存在不同程度的交错,比如 synchronized 它既是悲观锁的实现又是可重入锁的实现。
总览
先来给 Java 中的各种锁分个类。
1)根据线程要不要锁住共享资源,可以分为:
- 悲观锁:锁住共享资源
- 乐观锁:不锁住共享资源
2)如果试图锁住共享资源失败,那么线程要不要阻塞?如果不想要阻塞线程,可以通过以下两种锁实现:
- 自旋锁
- 适应性自旋锁
3)以下这 4 种锁都是专门针对 synchronized 关键字的:
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
4)根据多个线程竞争锁时是否需要排队,可以分为:
- 公平锁:需要排队
- 非公平锁:先尝试插队,插队失败再排队
5)根据一个线程是否能够重复获取同一把锁,可以分为:
- 可重入锁:能被同一个线程重复获取
- 不可重入锁:不能被同一个线程重复获取
6)根据锁只能被单个线程持有还是能被多个线程共同持有,可以分为:
- 排他锁:锁只能被单个线程持有
- 共享锁:锁能被多个线程共同持有
悲观锁 | 乐观锁
悲观锁和乐观锁不仅在 Java 中,各位学习数据库的时候应该也都遇到过。
先来看悲观锁的定义:
悲观锁是一种悲观思想,认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
synchronized
关键字和 Lock
接口的实现类就是悲观锁。
再来看乐观锁的定义:
乐观锁是一种乐观思想,认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在提交修改的时候去判断一下,在此之前有没有其他的线程也修改了这个数据:
- 如果其他的线程还没有提交修改,那么当前线程就将自己修改的数据成功写入;
- 如果其他的线程已经提交了修改,则当前线程会根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
乐观锁在 Java 中是采用 CAS 算法实现的,J.U.C 包中的原子类就是通过 CAS 算法来实现了乐观锁。
使用这种 CAS 算法的代码也常被称为无锁编程(Lock-Free)。
简单介绍下 CAS,后序文章会详细解释的。CAS(Compare And Set),比较并替换,比较当前值(主内存中的值)与预期值(当前线程中的值,主内存中值的一份拷贝)是否一样,如果一样则执行更新,否则通过 do-while 循环进行重试。
很多文章会把 CAS 和乐观锁等同起来,我觉得这样非常不利于小白的理解,CAS 它是一种算法,用这种算法可以实现乐观锁,也可以实现其他的锁,比如自旋锁。
就好比数组,它是一种工具,可以用来实现链表,也可以用来实现线性表。
自旋锁 | 自旋适应锁
前文说过,Java 中的线程是与操作系统中的线程一一对应的,所以阻塞或者唤醒一个 Java 线程是需要操作系统切换到内核态来完成的,这显然给程序的并发能力带来了一定的性能损耗。
正常情况下锁获取失败就应该阻塞入队,但是有时候可能刚一阻塞,别的线程就释放锁了,然后再唤醒刚刚阻塞的线程,这就显得有点多余了。甚至,如果同步代码块中的内容比较简单,同步资源被锁的时间比较短,可能操作系统状态转换消耗的时间比用户代码执行的时间还要长。
所以说,为了这一小段时间去切换操作系统状态是得不偿失的。
现在绝大多数的计算机都是多核处理器系统,支持两个或以上的线程同时并行执行,综合上述考虑,我们可以让后面请求锁的那个线程 “稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
这个 “稍等一下” 就是自旋,其实就是个 do-while
循环。更底层来说自旋操作就是空转 CPU,执行一些无意义的指令,目的就是不让出 CPU 等待锁的释放。
如果这个线程自旋完成后,前面锁定共享资源的线程已经释放了锁,那么这个线程就可以不必被阻塞而是直接获取共享资源,从而避免切换操作系统状态的开销。
这就是自旋锁。
OK,回顾下乐观锁,上文提到 J.U.C 包中的原子类就是通过 CAS 算法来实现了乐观锁。而 CAS 算法就是先比较再交换,比较当前值与预期值是否一样,如果一样则执行更新,否则通过 do-while 循环进行重试。
各位发现了没有,如果当前值与预期值不一样,CAS 算法就会通过 do-while 循环进行重试。
这不就是自旋!
随便拿段源码出来看看,比如 AtomicInteger 中的自增方法 getAndIncrement:
所以说,Java 中自旋锁的使用就体现在 CAS 比较操作失败后的自旋等待。这也就是为啥有些文章会说 “自旋锁的实现原理是 CAS” 了。
看到这里不知道各位有没有疑问。如果这个 do-while 循环里面的条件一直无法满足呢?也就是说 CAS 比较操作一直失败呢?难道就这样一直无休止地自旋等待下去吗?
我们先说结论,不会一直自旋等待下去。
因为自旋等待虽然避免了线程切换的开销,但它既然在循环就肯定需要占用处理器时间。那如果锁被占用的时间很短,自旋等待的效果就会非常好。
但是,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源,空转,死等,还不如被阻塞住。这也就是为什么我们说:纵使阻塞会使操作系统陷入内核态,但是它仍然无法被自旋锁替代。
所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数没有成功获得锁,就应当挂起线程。
默认是 10 次,可以使用参数 -XX:PreBlockSpin
来更改。
而在 JDK 1.6 中,对于自旋等待的次数这个问题,做出了一次优化,即引入了适应性自旋锁(自适应的自旋锁)。
自适应意味着自旋的次数(时间)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么 JVM 就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间;
- 如果对于某个锁,很少有线程通过自旋等待成功获得过,那么当以后有线程尝试获取这个锁时, JVM 可能省略掉自旋过程,直接阻塞住线程,避免空转浪费处理器资源。
无锁 | 偏向锁 | 轻量级锁 | 重量级锁
这四种锁是指锁的状态,专门针对 synchronized 关键字的,是一个锁逐渐升级(膨胀)的过程。
关于这四种锁《Java 并发编程的艺术》中给出了一段笼统的概括:
Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。
需要注意的是,书中还提到这句话,以及网络上大范围流传的:锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
在 openjdk 的 hotsopt jdk8u 源码里是有锁降级的机制,只不过条件很苛刻,所以《Java 并发编程的艺术》作者可能看的 JDK 版本还没有引入降级机制。不过既然条件很苛刻,我觉得可以简单地理解为大部分情况下都是只能升级不能降级吧。
对于 “无锁” 的定义我没有找到明确的资料。这里的 “锁”,指的应该是 synchronized 和 Lock 接口下的锁,也就是悲观锁。
上文我们也说过,使用 CAS 算法的代码也常被称为无锁编程(Lock-Free)。也就是说,上述我们提到的 CAS 及其应用就是无锁的实现。
再来看偏向锁的定义:
HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。
- 如果测试成功,表示线程已经获得了锁。
- 如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成 1(表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。
这里涉及一些对象头的知识,本文不做过多赘述,后序文章会详细讲解的。
简单来说,偏就是 “偏心” 的意思,就是说这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有该偏向锁的线程将永远不需要再进行 CAS 操作来竞争这个锁。
偏向锁使用了一种等到竞争出现才释放锁的机制,所以只要出现了但凡其他的一个线程来尝试竞争偏向锁,持有偏向锁的那个线程就会释放锁。
显然,如果程序中大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的,还会带来额外的锁撤销的消耗。
这四个锁状态其实是一个不断升级的过程,所以当线程持有的锁是偏向锁的时候,如果出现了另外的线程来竞争锁,偏向锁就会升级为轻量级锁。
如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要升级为重量级锁(注意这里是很多人的误区所在,轻量级锁事实上并不会涉及自旋操作),而升级成重量级锁之后,线程如果没有争抢到锁,会进行一段自旋等待锁的释放。那么上文说过,自旋等待是需要有一定限度的,如果自旋等待超过了一定的次数(时间),那么这个线程就要被阻塞住了。只有当持有锁的线程释放锁之后才会唤醒这些被阻塞的线程,然后被唤醒的线程就会进行新一轮的夺锁之争。
至于为什么被称为重量级锁,看到这里各位也应该明白了吧,因为操作系统介入了,阻塞或者唤醒一个 Java 线程是需要操作系统切换到内核态来完成的。
做个小结:抛开无锁这个状态不谈,Java 中的 synchronized
有偏向锁、轻量级锁、重量级锁,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。当条件不满足时,锁就会按照顺序进行升级。
公平锁 | 非公平锁
公平锁:多个线程按照申请锁的顺序来获取锁。在并发环境中,每个线程会先查看此锁维护的等待队列,如果该锁的等待队列为空,则直接占有锁;如果该锁的等待队列不为空,则该线程加入到等待队列的末尾,按照 FIFO 的原则从队列中取出线程,然后占有锁。
非公平锁:线程会先尝试获取锁,如果获取不到,则再采用公平锁的方式也就是进入等待队列。也就是说,多个线程获取锁的顺序,不是按照 FIFO 的顺序,有可能后申请锁的线程比先申请的线程优先获取到锁。
通俗来讲就是,获取公平锁需要排队,而获取非公平锁优先选择插队,如果插队失败,再采取排队的方式。
公平锁和非公平锁优缺参半。
公平锁的优点就是等待锁的线程不会饿死。缺点就是整体效率比非公平锁要低,因为等待队列中除了队头的第一个线程以外,其他所有线程都会被阻塞住,而阻塞线程的唤醒需要操作系统陷入内核态。
非公平锁的优点是相对于公平锁来说可以减少唤醒线程的开销,整体的效率比较高,因为线程有几率不阻塞直接获得锁。缺点就是处于等待队列中的线程可能会饿死,或者说等待很久才能获得锁(饥饿)。
在 Java 中,synchronized 就是非公平锁的实现,Lock 接口的实现类 ReentrantLock 可以通过构造函数来指定该锁是公平的还是非公平的(默认是非公平的)。
我们深入一步来看看 ReentranLock 的构造函数是怎么实现公平锁和非公平锁的。
从代码中可以看见其构造函数调用了 FairSync 和 NonfairSync 这两个类的构造函数。而事实上,这俩个类都继承了 ReentrantLock 的中的一个内部类 Sync,Sync 又继承了 AQS,关于 AQS 虽然很重要,不过这里不再过多赘述:
Sync 类中定义了非公平锁的加锁方法 nonfairTryAcquire
,NonfairSync 就是直接调用了这个方法:
FairSync 中定义了公平锁的加锁方法,和非公平锁只有细微的区别:
可以明显的看出,公平锁与非公平锁的加索方法唯一的区别就在于公平锁在获取锁之前多了一个判断条件:hasQueuedPredecessors()。这东西是个啥呢,点进去一看便知:
没错,它就是用来判断当前线程是否是等待队列中的第一个。如果是则返回 true,否则返回 false。
可重入锁 | 不可重入锁
很多文章会直接用 ReentrantLock 代替可重入锁这个名词,那对于萌新来说是有一定误导性的。除了 ReentrantLock,synchronized 也是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
可重入锁:也称为递归锁,同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。
这个很好理解,以 synchronized 举例:
类中的两个方法都是被 synchronized 修饰的,func1 方法中调用了 func2 方法,因为 synchronized 是可重入锁,所以同一个线程在调用 func1 时可以直接获得当前对象的锁,进入 func2 进行操作。
而如果是不可重入锁,那这段代码就会出现死锁。
不可重入锁就是说,当前线程在调用 func2 之前需要将执行 func1 时获取到的对象锁释放掉,然而,由于 func2 是在 func1 内部被调用的,也就是说该对象锁正在被当前线程所持有,并且无法释放。所以造成死锁。
以 ReentrantLock 为例,我们来分析下可重入和不可重入的底层原理,其实还是刚刚分析公平锁非公平锁那段 Sync 中的代码:
我先来说下上述代码中的 getState 方法 get 到的 state 到底是个啥。
state 就是 ReentranLock 的父类 AQS 维护的一个同步状态 status,用来表示锁的重入次数,初始值为 0。
当线程尝试获取可重入锁时,会先获取 state 值,如果 state = 0 表示没有其他线程正在占用这把锁,则把 state 置为 1,当前线程开始执行;
如果当前线程获取到的 state != 0,则 JVM 会判断当前线程是否是占用这把锁的线程,如果是的话则执行 state + 1,且当前线程可以再次获取锁。
而如果是非可重入锁,那可想而知,如果 status != 0 的话就会直接判定获取锁失败,则当前线程被阻塞住。
OK,上面讲的是加锁方法,再来看看如何释放掉可重入锁:
简单来说,就是在当前线程是持有锁的线程的前提下,每执行一次释放锁操作,就将 status - 1,只有当 status = 0 的时候,该线程才会真正地释放锁。
共享锁 | 排他锁
这个概念在数据库中大家应该也听说过。
排他锁:也称互斥锁、独享锁,该锁一次只能被一个线程所持有。如果线程 A 对数据 B 加上排它锁后,则其他线程不能再对 B 加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。
Java 中的 synchronized 和 ReentrantLock 就是排他锁。
为什么说 ReentrantLock 是排他锁,解释一下。仍然回顾下公平锁和非公平锁的那段加锁代码,可以发现,如果在当前线程不是拥有锁的线程的时候,就不能添加锁,也就是说锁一次只能被一个线程所持有。所以 ReentrantLock 是排他锁。
排他锁是一种悲观锁,每次访问资源都先加上排他锁,但是读操作其实并不会影响数据的一致性,而排他锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取,这限制了并发性。
共享锁:该锁可被多个线程所持有。如果线程 A 对数据 B 加上共享锁后,则其他线程只能对 B 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
共享锁是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。
ReentrantReadWriteLock(读写锁)中的读锁就是共享锁。
顾名思义,ReentrantReadWriteLock 中包含两种锁:读锁 ReadLock(共享锁)和写锁 WriteLock(排他锁):
References
- 《Java 并发编程的艺术》
- 《Java 并发编程之美》
- 美团技术团队 - https://tech.meituan.com/2018/11/15/java-lock.html
初识 synchronized 关键字
各位从之前的文章【「跬步千里」详解 Java 内存模型与原子性、可见性、有序性】中应该已经知道了,synchronized 这个元老级关键字近乎是万能的,原子性、可见性、有序性,你都可以用它来实现。
很多人都会称呼它为重量级锁,但事实上,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,在有些场景下它可能就不是重量级锁了。
对于这个关键字,要说的东西实在太多,大概会分成三篇文章:
- 初识 synchronized 关键字(包括 synchronized 的基本使用、synchronized 的 Happens-before 关系、锁的内存语义)
- synchronized 底层原理之 Monitor 与 Java 对象头
- synchronized 锁升级的过程
synchronized 的基本使用
首先,我们需要记住的是,Java 中的每一个对象都可以作为锁!(至于为什么,我们会在下篇文章详细讲解),这是 synchronized 实现同步的基础。
具体表现为以下 3 种形式:
1)对于普通同步方法(synchronized 修饰的普通方法),锁是当前实例对象。
class Test {
// 普通同步方法
public synchronized void test1() {
......
}
}
2)对于静态同步方法(synchronized 修饰的静态方法),锁是当前类的 Class 对象。
class Test {
// 静态同步方法
public static synchronized void test2() {
......
}
}
3)对于同步方法块(synchronized 修饰的块),锁是 Synchonized 括号里配置的对象。
class Test {
// 同步方法块
static final Object room = new Object(); // 声明一个锁
public void test3(){
synchronized (room) { // 锁住 room 对象
......
}
}
}
当一个线程试图访问同步方法或者同步方法块时,它首先必须得到锁才能进入这些代码块,并且退出或抛出异常时必须释放锁。
synchronized 的 Happens-before 关系
不知道各位可还记得那条针对 synchronized 关键字的 Happens-before 原则:管程锁定规则(Monitor Lock Rule),也被称为监视器锁规则。为了知识体系的完整性,这里简单回顾下:
管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是 “同一个锁”,而 “后面” 是指时间上的先后。
这个规则其实就是针对 synchronized 的。JVM 并没有把 lock
和 unlock
操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter
和 monitorexit
来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块 — synchronized
。这里我们下篇文章会详细解释。
举个例子:
假设线程 A 执行 writer 方法,线程 B 执行 reader 方法,那么,根据 Happens-before 原则,我们可以得到这样的关系:
1)根据程序次序规则:
- 1 happens-before 2
- 2 happens-before 3
- 4 happens-before 5
- 5 happens-before 6
2)根据管程锁定规则:
- 3 happens-before 4
3)根据传递性规则:
- 2 happens-before 5
- ......
如图所示:
注意图中我用橙色框出来的部分,就是说,线程 A 在释放锁之前对共享变量做的所有修改,在线程 B 获取同一个锁之后,将立刻对线程 B 可见。
锁的释放和获取的内存语义
各位注意没有,这里我们没有说 synchronized 的内存语义,而是说锁的内存语义。众所周知,Java 中锁有两套实现,一个是内置锁 synchronized,另一个是 J.U.C 包下 Lock 接口的实现类。
那么,很显然了,这里不做区分的意思就是,J.U.C 包下 Lock 接口的实现类具有和 synchronized 一样的内存语义。具体出处可以看 Java 8 的官方文档 Lock (Java Platform SE 8 ) (oracle.com):
All
Lock
implementations must enforce the same memory synchronization semantics as provided by the built-in monitor lock.
大致意思就是:所有 Lock 实现都必须强制执行与内置监视器锁 synchronization 提供的相同的内存同步语义。
OK,那么,锁的释放和获取的内存语义到底是啥呢。我们仍然以这段代码为例:
1)锁释放的内存语义:当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。
举个例子,如图所示,当线程 A 释放锁后,JMM 会把线程 A 本地内存中的 a = 1 刷新到主内存中:
2)锁获取的内存语义:当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被锁保护的临界区代码必须从主内存中读取共享变量。
比如说,如图所示,在上图的基础之上,线程 A 执行完了,线程 B 想要获取锁了,JMM 会把线程 B 的本地内存中的 a = 0 设置为无效,从而使得同步代码块必须从主内存中读取共享变量 a = 1:
可以看出来,如果线程 A 释放锁,然后线程 B 获取了这个锁,那这个过程其实就相当于线程 A 通过主内存向线程 B 发送消息。
更具体来说:
- 线程 A 释放一个锁,实质上是线程 A 向接下来将要获取这个锁的某个线程发出了消息,比如说告知对方我对某个共享变量做了修改;
- 线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的消息,比如说接收了线程 A 在释放这个锁之前对共享变量所做的修改;
Synchronized 底层原理之 Monitor 与 Java 对象头
在上一节,我们说过,当一个线程试图访问同步方法或者同步方法块时,它首先必须得到锁才能进入这些代码块,并且退出或抛出异常时必须释放锁。But,编程不是想象,不能说你觉得这是个锁这就是锁,那么,锁到底存储在哪里?锁里面存储的信息又是什么呢?
从字节码入手
这样,我们从最底层入手,通过反编译来看看 synchronized 修饰的方法或者方法块和不加 synchronized 的字节码有什么不同。
1)普通同步方法(synchronized 修饰的普通方法)反编译结果:
public class Test {
// 普通同步方法
public synchronized void test1() {
System.out.println("hello");
}
}
对比下不加 synchronized 的方法反编译结果:
可以发现,添加了 synchronized 关键字的方法,多了 ACC_SYNCHRONIZED
标记。那我们是不是可以这么理解:JVM 通过在方法访问标识符(flags)中加入 ACC_SYNCHRONIZED
来实现同步功能。
2)静态同步方法(synchronized 修饰的静态方法)反编译结果:
class Test {
// 静态同步方法
public static synchronized void test2() {
System.out.println("hello");
}
}
synchronized 修饰的静态方法和 synchronized 修饰的普通方法基本没啥区别,都是 JVM 在方法访问标识符(flags)中加入 ACC_SYNCHRONIZED
标记。
3)同步方法块(synchronized 修饰的块)反编译结果:
public class Test {
// 同步方法块
static final Object room = new Object(); // 声明一个锁
public void test3(){
synchronized (room) { // 锁住 room 对象
System.out.println("hello");
}
}
}
可以看见,synchronized 修饰的方法块和方法有所不同,多出了两个指令 monitorenter
和 monitorexit
,也就是说在同步方法块中,JVM 使用 monitorenter
和 monitorexit
这两个指令实现同步。
需要注意的是,monitorenter
指令是在编译后插入到同步代码块的开始位置,而 monitorexit
是插入到方法结束处和异常处。
初始 ACC_SYNCHRONIZED
我们先来看 JVM 的官方文档是如何解释 ACC_SYNCHRONIZED
的:
Method-level synchronization is performed implicitly, as part of method invocation and return. A synchronized method is distinguished in the run-time constant pool's method_info structure by the
ACC_SYNCHRONIZED
flag, which is checked by the method invocation instructions.When invoking a method for which
ACC_SYNCHRONIZED
is set, the executing thread enters a monitor, invokes the method itself, and exits the monitor whether the method invocation completes normally or abruptly.During the time the executing thread owns the monitor, no other thread may enter it.
If an exception is thrown during invocation of the synchronized method and the synchronized method does not handle the exception, the monitor for the method is automatically exited before the exception is rethrown out of the synchronized method.
大致意思就是,方法级别的同步是隐式执行的,是作为方法调用和返回的一部分的,在运行时常量池中通过 ACC_SYNCHRONIZED
标志来区分是同步方法还是普通方法。
当调用设置了 ACC_SYNCHRONIZED
的方法时,执行线程进入监视器(monitor),然后执行这个方法,方法执行完毕后退出监视器。需要注意的是,无论这个方法是正常完成还是突然完成,在执行线程拥有监视器期间,没有其他线程可以进入这个方法。
另外,如果在调用同步方法过程中抛出异常并且同步方法没有处理该异常,则在异常重新抛出同步方法之前,该方法的监视器会自动退出。
这里提到了 monitor,非常重要,我们先暂且不谈,各位有个印象就行,在下面【监视器(monitor)详解】这节中会仔细解释的。
初始 monitorenter、monitorexit
JVM 官方文档 是这么解释 monitorenter
指令的:
Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
- If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
- If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
- If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.
具体意思是这样:任何一个对象都与一个监视器(monitor)相关联。 当一个监视器有拥有者(owner)\的时候,这个监视器就会被锁定(locked),所谓拥有者(owner)就是说**执行 monitorenter
的线程会尝试获得监视器的所有权,或者说尝试获得对象的锁**(反过来说就是不加 synchronized 的对象是不会被锁住的)。
另外,每个监视器都维护着一个自己被持有次数(或者说被锁住 locked)的计数器(count),具体如下:
- 如果与对象关联的监视器的计数器为零,则线程进入监视器成为该监视器的拥有者,并将计数器设置为 1。
- 当同一个线程再次进入该对象的监视器的时候,计数器会再次自增。
- 当其他线程想获得该对象关联的监视器的时候,就会被阻塞住,直到该监视器的计数器为 0 才会再次尝试获得其所有权。
再来看 monitorexit:
The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.
意思就是说,如何某个线程想要执行 monitorexit
指令,那它一定得是某个监视器的拥有者才行。
当某个线程执行 monitorexit
指令的时候,该线程拥有的监视器的计数器就会减一。如果计数器为 0,就表明该线程不再拥有监视器了,这样,其他线程就可以去尝试获得这个监视器了。
结合 monitorenter
和 monitorexit
这俩个指令,我们可以得出这样的结论:
- 被
synchronized
修饰的同步方法块对同一条线程来说是可重入的。所谓可重入就是说即使同一个线程反复进入一个同步方法块也不会出现自己把自己锁死的情况。
monitor 详解
OK,这节,我们来揭开 monitor 的神秘面纱。
上文我们总是把 monitor 翻译为监视器,其实各位如果系统地学习过操作系统,对 monitor 一定不陌生,它也被翻译为管程。常见的进程同步与互斥机制就是信号量和管程,相比起信号量,管程有一个重要特性:在一个时刻只能有一个进程使用管程,即进程在无法继续执行的时候不能一直占用管程,否则其它进程将永远不能使用管程。也就是说管程天生支持进程互斥。
其实使用管程是能够实现信号量的,并且也能用信号量实现管程。但是管程封装的比较好,相比起信号量来需要我们编写的代码更少,更加易用。
把管程翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。
在 Java 虚拟机(HotSpot)中,管程(monitor)的具体实现是 ObjectMonitor
类,接下来我们就来瞅瞅它的源码一探究竟。
Hotspot 的代码 JDK 并没有开源,但是社区版本的 JDK 是开源的,在 opoenjdk 上可以查看源码,地址在这里:jdk8u/jdk8u/hotspot: 782f3b88b5ba /src/share/vm/runtime/ (java.net)
.hpp 是 C++ 头文件,.cpp 是具体实现,点开 objectMonitor.hpp
,找到 ObjectMonitor
类:
上述代码定义了各个异常枚举。再来看一下它的成员变量,这是重点:
注意上图用橙色圈出来的两个字段,我们来看看源码中是如何定义的:
_EntryList
:Threads blocked on entry or reentry. 阻塞在入口处的线程(处于 BLOCKED 状态)的集合。
我们来回顾下什么情况下线程会进入 BLOCKED 状态?(之前的文章中已经详细解释过线程的状态,不熟悉的小伙伴可以自行浏览)
Thread state for a thread blocked waiting for a monitor lock.
A thread in the blocked state is waiting for a monitor lock
to enter a synchronized block/method or
reenter a synchronized block/method after calling {
Object.wait
}.
就是说当线程试图获取 synchronized 锁住的对象的时候(或者说正在尝试获取 monitor 所有权的时候),如果竞争锁失败了,那么这个线程就进入 BLOCKED 态。
_WaitSet
:LL of threads wait()ing on the monitor. 正在等待 monitor 的线程(处于 WAITING 状态)的集合
这么说可能还不好理解,举个例子,如下图:
1)刚开始 monitor 中 owner(拥有者) 为 null
2)当 Thread-2 执行 synchronized(obj)
就会将 monitor 的所有者 owner 置为 Thread-2,同时计数器 count 加 1。注意,每个 monitor 中只能有一个 owner
3)在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),即想要获取与 Thread-2 锁住的相同对象 obj 的 monitor,那么这三个线程会转为 BLOCKED 态,进入 EntryList 队列。
4)Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,注意竞争时是非公平的。(公平锁、非公平锁在【Java 并发中的各种 “锁” 事】中已经详细解释过)
5)图中 WaitSet 队列中的 Thread-0,Thread-1 是之前获得过锁,但由于条件不满足(比如调用了 wait 方法等)进入了 WAITING 状态的线程。
Java 对象头详解
我们上述讲到的所有底层原理,其实都在这句话的基础之上:Each object is associated with a monitor
那么,一个对象和一个 monitor 是如何关联起来的呢?
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
其中,如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头(Mard Word、类型指针、数组长度),如果对象是非数组类型,则用 2 字宽存储对象头(Mard Word、类型指针)。在 32 位虚拟机中,1 字宽等于 4 字节,即 32 bit,如表所示:
长度 | 内容 | 说明 |
---|---|---|
32/64 bit | Mark Word | 存储对象的 hashCode 和锁信息等 |
32/64 bit | Class Metadata(类型指针) | 对象指向它的类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例 |
32/64 bit | Array Length(数组长度) | 数组的长度 |
Mark Word 就是对象与 monitor 关联的重点所在! 《深入理解 Java 虚拟机 - 第 3 版》中是这样描述 Mark Word 的:
HotSpot 虚拟机对象的对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 个比特和 64 个比特,官方称它为 “Mark Word”。
对象需要存储的运行时数据很多,其实已经超出了 32、64 位 Bitmap 结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。
例如在 32 位的 HotSpot 虚拟机中,如对象未被同步锁锁定的状态下,Mark Word 的 32 个比特存储空间中的 25 个比特用于存储对象哈希码,4 个比特用于存储对象分代年龄,2 个比特用于存储锁标志位,1 个比特固定为 0,如图所示:
在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Word 可能变化为存储以下 4 种数据,如图所示:
在 64 位虚拟机下,Mark Word 是 64bit 大小的,其存储结构如图所示:
至于 Mark Word 到底是怎么动态变化的,我们在下篇锁升级的时候会详细解释。
Synchronized 锁优化技术之自旋与轻量级锁
文题把自旋和轻量级锁放一起说并不是因为它们之间存在关系,恰恰是因为它们之间没有关系,但是《Java 并发编程的艺术》书中以及很多博客中都会提到 “当轻量级锁 CAS 失败,则当前线程会尝试使用自旋来获取锁”,这是错误的!!!下文会根据源码来详细解释。
锁优化技术概述
很多人都会称呼 synchronized 为重量级锁,但事实上,随着 JDK1.6 对 synchronized 进行了各种优化之后,在有些场景下它可能就不是重量级锁了。
这里有必要先解释下我们为什么把 JDK1.6 之前的 synchronized 称之为重量级锁?
在上篇文章中我们提到过,在尝试获取或持有 synchronized 锁的过程,线程可能会陷入阻塞态或等待态。
而在主流 Java 虚拟机实现中(比如 HotSpot),Java 的线程是映射到操作系统的原生内核线程之上的,这也就意味着,如果想要阻塞或唤醒一条线程,就需要操作系统的干预,这就不可避免地会导致操作系统陷入用户态到核心态的转换中,众所周知,进行这种状态转换需要耗费很多的处理器时间。
更甚至,对于一些代码特别简单的同步块来说(比如被 synchronized 修饰的 getter() 或 setter() 方法),操作系统状态转换消耗的时间可能会比用户代码本身执行的时间还要长。
因此,我们才说 JDK1.6 之前的 synchronized 是 Java 语言中一个重量级(Heavy-Weight)操作。
而在 JDK1.6 之后,出现了各种锁优化技术,包括 轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening) 等。正是由于这些优化技术的出现,synchronized 锁的性能得到了极大提高,并且用起来还简单,语义清晰,何乐而不为。
适应性自旋
为了避免上述所说的操作系统频繁陷入用户态到核心态的转换中,自旋锁和适应性自旋(也称为自旋适应锁)应运而生。
这俩个概念我们在之前的文章【Java 并发中的各种锁事】中其实已经提过了,为了知识的完整性以及方便各位理解下文,这里再简单过一遍。
正常情况下锁获取失败就应该阻塞入队,但是有时候可能刚一阻塞,别的线程就释放锁了,然后再唤醒刚刚阻塞的线程,这就显得有点多余了。甚至,如果同步代码块中的内容比较简单,同步资源被锁的时间比较短,可能操作系统状态转换消耗的时间比用户代码执行的时间还要长。
所以说,为了这一小段时间去切换操作系统状态是得不偿失的。
现在绝大多数的计算机都是多核处理器系统,支持两个或以上的线程同时并行执行,综合上述考虑,我们可以让后面请求锁的那个线程 “稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
这个 “稍等一下” 就是自旋,其实就是个 do-while
循环。更底层来说自旋操作就是空转 CPU,执行一些无意义的指令,目的就是不让出 CPU 等待锁的释放。
如果这个线程自旋完成后,前面锁定共享资源的线程已经释放了锁,那么这个线程就可以不必被阻塞而是直接获取共享资源,从而避免切换操作系统状态的开销。
这就是自旋锁。
需要注意的是,自旋锁并不是 JDK1.6 的新优化手段,在 JDK 1.4.2 中它就已经被引入了,并且在 JDK 1.6 中默认开启。
But,自旋锁可以使线程免于阻塞但并不能代替阻塞,因为虽然说自旋等待避免了线程切换的开销,但它是要占用处理器时间的(毕竟你的程序一直在跑 while 循环呢),所以如果锁被占用的时间很长,那么自旋的线程只会白白地浪费处理器资源。
因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,那就还不如把线程阻塞住,让处理器去干点有意义的事情。
默认自旋等待次数是 10 次,用户也可以使用参数 -XX:PreBlockSpin
来自行更改。
而在 JDK 1.6 中,对于自旋等待的次数这个问题,做出了一次优化,即引入了适应性自旋(也称为自旋适应锁)。
所谓自适应也就是说自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的:
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么 JVM 就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间;
- 如果对于某个锁,很少有线程通过自旋等待成功获得过,那么当以后有线程尝试获取这个锁时, JVM 可能省略掉自旋过程,直接阻塞住线程,避免空转浪费处理器资源。
有了适应性自旋,随着程序运行时间的增长及性能监控信息的不断完善,Java 虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越 “聪明” 了。
轻量级锁概述
JDK1.6 中引入了 “轻量级锁” 和 “偏向锁” 这俩种新型锁机制,也就是说,从 JDK1.6 开始,synchronized 锁事实上一共有 4 种状态,级别从低到高依次是:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
这几个锁的概念在前文也已经详细解释过了,各位有遗忘的可以复习一下。这 4 个状态随着竞争情况而逐渐升级。需要注意的是!锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能再降级回偏向锁。至于为什么要使用这样的策略,主要就是为了提高获得锁和释放锁的效率。
上述 synchronized 锁升级过程也被很多人称为锁膨胀过程,作为面试中的常客,几乎和 HashMap 一样成为送分题,各位小伙伴们务必掌握。
就先从本篇的轻量级锁开始吧~
所谓 “轻量级” 是相对于需要操作系统状态转换的 “重量级” 锁而言的。和自旋锁无法代替阻塞一样,轻量级锁也并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁由于操作系统状态转换而产生的性能消耗。
再讲解轻量级加锁解锁过程之前,我们有必要回顾下对象头 Mark Word 里存储的数据:
Mark Word 结构之所以搞得这么复杂,是因为需要节省内存,让同一个内存区域在不同阶段有不同的用处。
表格很好理解,需要解释的就是最后 “偏向锁” 那一行,锁标志位 “01” 和是否是偏向锁这两个字段是需要结合起来看的:
- 锁标志位 “01” + 是否是偏向锁 “0” 表示无锁状态,也就是说该对象没有被锁定
- 锁标志位 “01” + 是否是偏向锁 “1” 表示偏向锁状态
轻量级锁加锁
轻量级锁加锁的过程是这样的:
1)Mark Word 的初始状态:在代码即将进入同步块的时候,如果此同步对象没有被锁定,也即 Mark Word 中的锁标志位 “01” + 是否是偏向锁 “0”:
2)在当前线程的栈帧中建立一个锁记录:Java 虚拟机会在将在当前线程的栈帧中建立一个名为 锁记录(Lock Record) 的空间,Lock Record 中有一个字段 displaced_header
,用于后续存储锁对象的 Mark Word 的拷贝:
3)复制锁对象的 Mark Word 到锁记录中:把锁对象的 Mark Word 复制到锁记录中,更具体来讲,是将 Mark Word 放到锁记录的 displaced_header
属性中。官方给这个复制过来的记录起名 Displaced Mark Word:
4)使用 CAS 操作更新锁对象的 Mark Word。Java 虚拟机使用 CAS 操作尝试把锁对象的 Mark Word 更新为指向锁记录的指针,并将锁记录里的 owner 指针指向对象的 Mark Word。
如果这个更新操作成功了,就表明获取轻量级锁成功,也就是说该线程拥有了这个对象的锁!并且该对象 Mark Word 的锁标志位会被改为 00
,即表示此对象处于轻量级锁定状态。
如下图:
源码在这里:jdk8u/jdk8u/hotspot: 9ce27f0a4683 src/share/vm/runtime/synchronizer.cpp (java.net):
如果这个更新操作失败了,那有两种可能性:
- 当前线程已经拥有了这个对象锁(直接进入同步块继续执行)
- 存在其他的线程竞争获取这个对象锁(膨胀成重量级锁,锁标志的状态值变为
10
,Mark Word 中存储的就是指向重量级锁(互斥量)的指针)
为了证实到底是哪种情况,虚拟机首先会检查该对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行(synchronized 是可重入锁)。
看源码:
举个例子:
synchronized(obj) {
synchronized(obj) {
synchronized(obj) {
}
}
}
假设锁的状态是轻量级锁,下图反应了对象的 Mark word 和线程栈中锁记录的状态,可以看到左边线程栈中包含3个指向当前锁对象的 Lock Record。其中栈中最高位的锁记录为第一次获取轻量级锁时分配的,其 Displaced Mark word 的值为锁对象 obj 加锁之前的 Mark word,之后的每次锁重入都会在线程栈中分配一个 Displaced Mark word 为 null
的锁记录。
那么问题来了,为什么 synchronized 重入的时候 Java 虚拟机要在线程栈中添加 Displaced Mark word 为 null
的锁记录呢?
首先锁重入次数是一定要记录下来的,因为每次解锁都需要对应一次加锁,只有解锁次数等于加锁次数时,该锁才真正的被释放,也就是在解锁时需要用到说锁的重入次数。
最简单的记录锁重入次数的方案就是将其记录在对象头的 Mark word 中,但 Mark word 大小有限,没有多出来的地方存放该信息了。另一个方案就是在锁纪录中记录重入次数,但这样做的话,每次重入获得锁的时候都需要遍历该线程的栈找到对应的锁纪录,然后去修改重入次数的值,显然这样效率不是很高。
所以最终 Hotspot 选择了每次重入获得锁都添加一个锁记录来表示锁的重入,这样有几个 Displaced Mark word 为 null
的锁记录就表示发生了几次锁重入,非常简单。
以上,就是 synchronized 锁重入的原理。
再来看 CAS 更新操作失败的第二种情况,如果这个更新操作失败了并且该对象的 Mark Word 并没有指向当前线程的栈帧,就说明多个线程竞争锁。注意!!!这里就是我说的《Java 并发编程的艺术》书中出现错误的地方,我们来看原文:
线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
看上方划线的句子,话不多说,我们接着上面那段源码往下看:
可以看到并没有什么自旋操作,如果 CAS 成功就直接 return 了,如果失败就会执行下面的锁膨胀方法 ObjectSynchronizer::inflate
,这里面同样也没有自旋操作。
所以从源码来看轻量级锁 CAS 失败(存在其他的线程竞争获取这个对象锁的情况)并不会自旋而是直接膨胀成重量级锁。
《深入理解 Java 虚拟机》书中也是这样写的:
如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为 “10”,此时 Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
不过自旋/自适应自旋操作在 synchronized 里确实是存在的,在成功膨胀成重量级锁之后,其他线程如果没有争抢到这个对象锁,为了防止操作系统陷入频繁地阻塞态和内核态的转换,线程会进行一段时间的自旋等待这个锁的释放。如果自旋等待超过了一定的次数(时间),那么这个线程就要被阻塞住了。当持有锁的当前线程释放锁之后这些阻塞线程才会被唤醒,然后被唤醒的线程接着进行新一轮的锁竞争。
所以我们可以得出这样的结论:重量级锁竞争失败会有自旋操作,轻量级锁并不会自旋(至少 JDK1.8 中是这样)
轻量级锁解锁
轻量级锁解锁的过程是这样的:
轻量级锁加锁的时候会复制锁对象的 Mark Word 到锁记录中嘛,那么同样的,解锁的时候 Java 虚拟机会用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来:
- 如果替换成功,那整个同步过程就顺利完成了
- 如果替换失败,则说明有其他线程尝试过获取该锁,轻量级锁会先膨胀成重量级锁然后再解锁(调用重量级锁的 exit 方法)
源码看这里:jdk8u/jdk8u/hotspot: 9ce27f0a4683 src/share/vm/runtime/synchronizer.cpp (java.net)
Synchronized 锁优化技术之偏向锁
上篇文章我们讲了一下轻量级锁的具体细节,总结来说,轻量级锁就是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量(也就是避免使用重量级锁带来的内核态与用户态相互转换的开销)。但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了 CAS 操作的开销,因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。这也就是为什么 synchronized
中一旦轻量级锁 CAS 失败(存在其他的线程竞争获取这个对象锁的情况)就会直接膨胀成重量级锁。
对于轻量级锁来说,线程在进入和退出同步块时需要进行 CAS 操作来进行加锁和解锁,这在多线程的情况下确实是有必要的。但是,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,也就是说一个线程多次重入同一个锁。所以,我们考虑能不能在这种情况下把锁重入的 CAS 开销也给免了。为此,JDK1.6 做了进一步的优化,即引入了偏向锁。
偏向锁在 Java 6 之后都是默认启用的,但是它在应用程序启动几秒钟之后才激活,可以使用 JVM 参数来关闭延迟:-XX:BiasedLockingStartupDelay=0
。
偏向锁的获取
偏就是 “偏心” 的意思,就是说这个锁会偏向于第一个获得它的线程。具体来说,当一个线程第一次访问同步块并获取锁时,Java 虚拟机会使用 CAS 操作把这个线程的 ID 设置到对象头的 Mark Word 中。如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有该偏向锁的线程将永远不需要再像轻量级锁那样进行 CAS 操作来竞争这个锁,直接进入同步块执行就行了。
具体来说,偏向锁的获取流程是这样的:
1)查看对象头的 Mark Word 中偏向锁的标识以及锁标志位,若 ”是否偏向锁“ 为 1 且 ”锁标志位“ 为 01,则该锁为可偏向状态;
2)若为可偏向状态,则测试 Mark Word 中的线程 ID 是否与当前线程相同,若相同,则不用执行 CAS 操作,直接进入同步块执行,否则进入下一步。
3)当前线程通过 CAS 操作竞争锁,若竞争成功,则使用 CAS 操作将 Mark Word 中线程 ID 设置为当前线程 ID(重新偏向),然后执行同步块;若竞争失败,则进入偏向锁撤销的流程。
举个例子来对比下偏向锁重入和轻量级锁重入:
static final Object obj = new Object();
public static void m1() {
synchronized( obj ) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized( obj ) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized( obj ) {
// 同步块 C
}
}
轻量级锁重入:
偏向锁重入:
偏向锁的撤销
偏向锁的撤销采用了 一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。
具体来说,如果当前线程通过 CAS 竞争偏向锁失败,说明存在锁竞争,则进入偏向锁撤销的流程,偏向锁的撤销需要等待 全局安全点 safe point(这个时间点上没有正在执行的代码),其具体步骤如下:
1)JVM 会先暂停拥有偏向锁的线程,判断持有偏向锁的线程是否还存活;
2)如果持有偏向锁的线程存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到轻量级锁竞争的逻辑里。如果持有偏向锁的线程已经不存活或者不在同步块中,则将对象头的 Mark Word 改为无锁状态(01),以允许其他线程竞争锁,之后再升级为轻量级锁;
综上,我们可以得出这样的结论:当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。不过,这个说法不绝对,因为还有批量重偏向这一机制,下个段落我们会简单介绍下。
《Java 并发编程的艺术》书中关于偏向锁的获取和撤销流程图画的非常清晰:
批量重偏向和批量撤销
可以看出,偏向锁虽然可以提高带有同步但无竞争的程序性能,但它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的,还会带来额外的锁撤销的消耗。
也就是说,如果运行时的场景本身就存在多线程竞争,那偏向锁的存在不仅不能提高性能,反而会导致性能下降。因此,JVM 中增加了一种批量重偏向/撤销的机制。
批量重偏向(bulk rebias) 简单来说就是,如果对象被多个线程访问,但不存在竞争,这时偏向了线程 T1 的锁对象仍有机会重新偏向 T2,重偏向会重置锁对象的 Thread ID。当偏向锁的撤销次数超过重偏向阈值(默认 20 次)后,JVM 会这样觉得,我是不是偏向错了呢,于是在给这些对象加锁时会重新偏向至试图加锁的线程。
批量撤销(bulk revoke) 指的是,当偏向锁的撤销次数超过批量撤销阈值(默认40 次)后,JVM 会这样觉得,自己确实偏向错了,根本就不该偏向,于是整个类的所有对象 都会变为不可偏向的,新建的对象也是不可偏向的。通俗来说,JVM 会认为这个类的所有对象的使用场景都存在多线程竞争,会标记该类为不可偏向,之后,对于该类的对象的锁,都会直接走轻量级锁的逻辑。
当然了,你也可以直接通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false
,那么程就会序默认进入轻量级锁状态。
被废弃的偏向锁
大人,时代变了。
JDK 15 在 2020 年 9 月 15 日发布更新计划,详情见 JDK 15 (java.net)。其中有一项更新就是 ”废弃偏向锁“,官方的详细说明在这里:JEP 374: Disable and Deprecate Biased Locking
JDK 1.6 引进的偏向锁带来的性能提升,在现在看来已经不那么明显了。受益于偏向锁的应用程序,往往是使用了早期集合 API 的程序,比如 HashTable 和 Vector,每次访问时都需要进行同步。而 JDK 1.2 引入了针对单线程场景的非同步集合,比如 HashMap 和 ArrayList,JDK 1.5 又针对多线程场景推出了性能更高的并发数据结构比如 ConcurrentHashMap。这意味着如果代码更新为使用这些较新的类,可能偏向锁就不是那么的需要了。此外,围绕线程池构建的应用程序,性能通常在禁用偏向锁的情况下更好。
官方文档还提到:
Biased locking introduced a lot of complex code into the synchronization subsystem and is invasive to other HotSpot components as well.
意思就是偏向锁为整个同步子系统引入了大量的复杂度,并且这些复杂度也入侵到了 HotSpot 的其它组件。
考虑到兼容性,所以决定先废弃该特性,最终的目标是移除它。
Synchronized 锁优化技术之锁消除和锁粗化
前两篇文章介绍了适应性自旋、轻量级锁和偏向锁,这篇文章就来介绍下 JDK1.6 引进的锁优化技术中的剩下的两个:锁消除和锁粗化。
锁消除 Lock Elimination
锁消除的概念比较容易理解,就是如果编译器认定一个锁只会被单个线程访问,那么这个锁就可以被消除。
可能有些小伙伴就有疑问了,既然这个锁一定不会存在竞争,那咱写代码的时候肯定是知道的啊,直接不写同步代码不就行了嘛,哪还轮得到编译器来插手。
事实上,有许多同步措施并不是咱程序员自己加入的,同步代码在 Java 程序中出现的频繁程度也许超过了大部分人的想象。举个例子:
public String test (String s1, String s2, String s3) {
return s1 + s2 + s3;
}
简单的三个字符串拼接,无论是从表面代码,还是程序语义上来看,都没有进行任何的同步,对吧。
众所周知的是,String
是一个不可变对象,String
之间通过 +
进行的拼接,在 JDK1.5 之前,实际上会在底层转换成 StringBuffer
对象(JDK5 及以后的版本中,用的 StringBuilder
),上述代码的实际执行代码其实是这样的:
public String test (String s1, String s2, String s3) {
StringBuffer res = new StringBuffer();
res.append(s1);
res.append(s2);
res.append(s3);
return res.toString();
}
问题就出现在 StringBuffer
的 append
方法上:
synchronized
修饰普通方法,锁就是当前实例对象 res。
所以我们上述那段简单的三个字符串拼接的代码在 JDK1.5 之前其实是涉及同步的。
不过,在虚拟机观察变量 res 经过逃逸分析后,发现它的动态作用域被限制在 test()
方法内部,不同的线程同时调用 test()
方法时,都会创建不同的 res 对象。也就是说 res 的所有引用都永远不会逃逸到本线程的 test()
方法之外,其他线程都无法访问到它。所以虽然这里有锁,但是不存在锁竞争问题,这个锁可以被安全地消除掉。
显然,“锁消除” 可以节省毫无意义的请求锁时间。
不过,需要注意的是,在解释执行的时候这里仍然会加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽略所有的同步措施而直接执行。
逃逸分析 Escape Analysis
上面提到了逃逸分析,这里我们简单介绍下。
逃逸分析是编译器的一种优化技术,它并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
所谓逃逸,包括方法逃逸和线程逃逸,线程逃逸的逃逸程度高于方法逃逸:
- 当一个对象在方法里面被定义后,它如果被外部方法所引用(例如作为调用参数传递到其他方法中),这种称为方法逃逸;
- 可能被外部其他线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;
如果虚拟机能够确定一个对象不会发生方法逃逸和线程逃逸,或者逃逸程度比较低(只发生方法逃逸,不发生线程逃逸),则可以为这个对象实例采取不同程度的优化,我们上文说到的锁消除(也称为 “同步消除 Synchronization Elimination”)就是这其中的一种优化手段, 除此之外,还有 栈上分配(Stack Allocations) 和 标量替换(Scalar Replacement)。
介绍完了逃逸分析再来看锁消除,是不是更好理解了。其实就是,如果虚拟机能够确定一个变量(锁对象)不会发生线程逃逸,即无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉了。
锁消除在 JDK1.8 中是默认开启的,我们可以通过参数 -XX:-DoEscapeAnalysis
禁用逃逸分析,这样锁消除也就无法发挥作用了。
这样,我们让 test()
方法运行一百万次,看看开启逃逸分析和禁用逃逸分析之间的性能差距:
public class Test {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i ++) {
test("ab", "cd", "ef");
}
System.out.println("用时: " + (System.currentTimeMillis() - start) + " ms");
}
}
开启逃逸分析:
禁用逃逸分析:
锁粗化 Lock Coarsening
周志明老师的第 3 版《深入理解 Java 虚拟机》书中把 Lock Coarsening 翻译为 “锁膨胀”,而我们又经常会说比如轻量级锁膨胀为重量级锁等,这难免会让新手困惑,所以老师在勘误中已经修正过来了,地址在这里 fenixsoft/jvm_book: 《深入理解Java虚拟机(第3版)》样例代码&勘误 (github.com)
相对于普通代码来说,同步代码是一个比较耗时的过程,所以我们总是希望同步块的作用范围尽可能地小,那些不需要进行同步的代码就不要放在同步块里。这样的话,即使存在锁竞争,由于需要同步的操作数量尽可能地变少了,等待锁的线程也可以尽快地拿到锁。
当然,这是我们美好的想法,实际的编码可能并不总是如我们所愿,仍然用我们上段代码举例:
public String test (String s1, String s2, String s3) {
StringBuffer res = new StringBuffer();
res.append(s1);
res.append(s2);
res.append(s3);
return res.toString();
}
append 就是一个同步方法,在这个 test 方法里,连着三次操作都是对同一个 res 对象进行反复地加锁和解锁,那即使没有线程竞争,频繁地进行加锁解锁操作也会导致不必要的性能损耗。
But 这些代码又都是必须地,同步块的作用范围在我们能够做到的范围已经是最小了。为此,针对上述这种情况,虚拟机就整了个 “锁粗化” 的解决方案。
如果虚拟机检查到有这样一串连续的操作都是对同一个对象进行加锁,就会把加锁同步的范围粗化(扩大)到整个操作序列的外部。
简单来说,锁粗化就是把多次加锁请求合并成一次。
经过 “锁粗化” 后,上述代码加锁同步的范围会扩展到第一个 append()
操作之前至最后一个 append()
操作之后,这样只需要加锁一次就可以了。
为什么 wait 方法必须在 synchronized 同步块中调用
源码注释解读
回顾下线程状态转换中的这张图:
可以看出,wait
和 notify/notifyAll
方法是成对出现的,调用 wait
方法会使得线程进入 Waiting 态,而调用 notify/notifyAll
方法会唤醒 Waiting 线程,被唤醒的线程就可以重新开始竞争对象锁。
这三个方法都位于 Object
类中,以 wait
方法为例,我们来读一下源码及其注释,搞清楚它的底层原理。
wait
方法有 3 种实现,其中只带一个参数的方法是 native 方法,其他的俩个本质上还是调用了这个 native,这个方法的注释有点多,我就不一下子全贴出来了,各位可以自己去找一下,这里我针对 native wait
方法的注释来挨个翻译解释下:
Causes the current thread to wait until either another thread invokes the
notify()
method or thenotifyAll()
method for this object, or a specified amount of time has elapsed.
这段比较简单,上面我们就说过了:调用 wait
方法使当前线程处于 WAITING(TIMED_WAITING) 态,直到另一个线程为此对象调用了 notify()
方法或 notifyAll()
方法,或超过了指定的时间。
需要注意的是,notify 唤醒的线程是随机的,而 notifyAll 会唤醒所有线程,并不是说被唤醒的线程就可以立即拥有锁了,它们只是拥有了和其他线程一样竞争锁的资格。notify 方法的注释上有这样一句话我觉得很应景:“The awakened thread enjoys no reliable privilege or disadvantage in being the next thread to lock this object.(被唤醒的线程在成为下一个锁定该对象的线程时没有特权或者劣势)”。
The current thread must own this object's monitor.
This method causes the current thread (call it T) to place itself in the wait set for this object and then to relinquish any and all synchronization claims on this object. Thread T becomes disabled for thread scheduling purposes and lies dormant until one of four things happens:
- Some other thread invokes the
notify
method for this object and thread T happens to be arbitrarily chosen as the thread to be awakened.- Some other thread invokes the
notifyAll
method for this object.- Some other thread
interrupt()
interrupts Thread T.- The specified amount of real time has elapsed, more or less. If
timeout
is zero, however, then real time is not taken into consideration and the thread simply waits until notified.
调用 wait
方法的线程必须拥有此对象的监视器。
该方法将当前线程(称为 T)置于此对象的 WaitSet 中,然后放弃该对对象的锁。直到发生以下四种情况之一,该线程才会被唤醒:
- 其他线程为此对象调用了
notify
方法,并且线程 T 恰好被操作系统选择为要唤醒的线程。 - 其他线程为此对象调用了
notifyAll
方法,唤醒了所有线程。 - 其他一些线程
interrupt()
中断了线程 T。 - 超过了指定的等待时间(当然,如果
timeout
为零,线程会一直等待直到被通知)。
从这段注释我们就已经可以看出来了,调用 wait
方法的前提,那就是必须放在 synchronized
同步块中,因为得拥有对象的监视器啊。
我们不妨写段代码试验下,如果 wait
方法不在同步块中会怎样:
public void test() {
try {
new Object().wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
结果就是抛出 IllegalMonitorStateException
异常。
同样的,notify
和 notifyAll
也得放在 synchronized
同步块中用,我们接着往下看注释:
The thread T is then removed from the wait set for this object and re-enabled for thread scheduling. It then competes in the usual manner with other threads for the right to synchronize on the object;
once it has gained control of the object, all its synchronization claims on the object are restored to the status quo ante - that is, to the situation as of the time that the
wait
method was invoked. Thread T then returns from the invocation of thewait
method. Thus, on return from thewait
method, the synchronization state of the object and of thread T is exactly as it was when the wait method was invoked.
大概意思就是说,如果发生了上述四种情况之一,线程 T 就会被从该对象的 WaitSet 中移除,并由操作系统来重新进行线程调度,也就是说它和其他线程一样,拥有了竞争该对象锁的权利。一旦这个线程重新获得了该对象锁,那么它对对象的所有同步声明都将恢复到调用 wait
方法时的状态,可以接着往下执行。
简单总结下:调用 wait
方法就是对象通知持有自己锁的线程释放该锁,上一边等着去;而 notify
和 notifyAll
方法就是对象通知在一边等着的线程又可以来竞争我的锁了。
无效唤醒 Lost Wakeup
上面只是说明了 wait
、notify
、notifyAll
必须放在 synchronized
同步块中,但是并没有解释到底是为什么。
我们不妨方向思考一波,如果不要求 wait
方法放在同步块中,而是可以随意调用,会怎样呢?
// 生产者伪代码
Producer:
count ++;
notify();
// 消费者伪代码
Consumer:
while(count <= 0){
wait();
count --;
}
显然,多线程环境下这样写肯定会出问题。
假设 count = 0,这个时候消费者通过 while 循环检查 count 的值,发现 count <= 0 的条件成立,就在消费者准备进入循环代码进行处理的时候,发生了上下文切换,生产者开始执行,并且走完了它的全部流程,也就是调用了 notify 方法准备去唤醒一个线程。然而,这个时候消费者还没睡呢(还没执行 wait 方法),所以生产者的这个唤醒通知就会被丢掉。而再次线程上下文切换后,消费者就执行 wait 方法睡觉了。
这就是无效唤醒。为了避免这个问题,Java 强制我们将 wait 方法放在 synchronized 同步块中调用
虚假唤醒 Spurious Wakeup
源码注释中还提到了一个名词:Spurious Wakeup(虚假唤醒)
简单来说,当一个条件满足时,很多线程都可能被唤醒,但是只有其中部分是有用的唤醒,其它的唤醒都是多余的。比如说自动贩卖机卖货,如果本来没有货物,突然进了一件货物,这时所有的顾客都被通知了,但是只能一个人买,那么其他人都是无用的通知。
也就是说,如果条件不满足那么线程应该继续等待,也即 wait
应该总是在循环中发生(wait
should always occur in loops),就像这样:
synchronized (obj) {
while (condition does not hold)
obj.wait(timeout);
... // Perform action appropriate to condition
}
这么说可能还是比较苍白,下面我们来举个例子。
首先定义一个产品类,具有添加一个产品和消费一个产品这俩个功能,如果产品数量大于 0, 则不需要添加产品, 如果产品数量小于等于 0, 则无法消费产品。也就相当于缓冲区为 1,一旦生产者生产了产品,消费者就要去消费而生产者不能再生产:
// 产品
class Product {
// 产品数量
private int count = 0;
// 添加产品 (count ++)
public synchronized void produce() throws InterruptedException {
System.out.println(Thread.currentThread().getName() + " 进入 produce 方法");
// 如果产品数量大于 0, 则不需要添加产品
if (count > 0) {
System.out.println(Thread.currentThread().getName() + ": 产品数量大于 0, 进入等待...");
this.wait();
}
// 添加产品
count ++;
System.out.println(Thread.currentThread().getName() + " 添加产品, 剩余 " + count + " 件产品");
// 添加完产品后, 唤醒其他线程
System.out.println(Thread.currentThread().getName() + " 唤醒其他线程");
this.notifyAll();
}
// 消费产品(count --)
public synchronized void consume() throws InterruptedException {
System.out.println(Thread.currentThread().getName() + " 进入 consume 方法");
// 如果产品数量小于等于 0, 则无法消费产品
if (count <= 0) {
System.out.println(Thread.currentThread().getName() + ": 产品数量小于等于 0, 进入等待...");
this.wait();
}
// 消费产品
count --;
System.out.println(Thread.currentThread().getName() + " 消费产品, 剩余 " + count + " 件产品");
// 消费完产品后, 唤醒其他线程
System.out.println(Thread.currentThread().getName() + " 唤醒其他线程");
this.notifyAll();
}
}
再定义两个生产者和两个消费者:
public static void main(String[] args) {
Product product = new Product();
// 创建生产者的任务类对象
Runnable produceRunnable = new Runnable() {
// 要执行的任务
public void run() {
for (int i = 0; i < 10; i++) {
try {
product.produce();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
// 创建消费者的任务类对象
Runnable consumeRunnable = new Runnable() {
// 要执行的任务
public void run() {
for (int i = 0; i < 10; i++) {
try {
product.consume();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread producer1 = new Thread(produceRunnable, "Producer 1");
producer1.start();
Thread producer2 = new Thread(produceRunnable, "Producer 2");
producer2.start();
Thread consumer1 = new Thread(consumeRunnable, "Consumer 1");
consumer1.start();
Thread consumer2 = new Thread(consumeRunnable, "Consumer 2");
consumer2.start();
}
按理来说,生产者和消费应该交替执行,生产者生产 1 个,消费者就消费一个,But,程序的执行结果并不如我们所想,这里我只截取到出错的那部分哈,后面的就不放上来了:
Producer 1 进入 produce 方法
Producer 1 添加产品, 剩余1件产品
Producer 1 唤醒其他线程
Producer 1 进入 produce 方法
Producer 1: 产品数量大于 0, 进入等待...
Consumer 2 进入 consume 方法
Consumer 2 消费产品, 剩余0件产品
Consumer 2 唤醒其他线程
Consumer 2 进入 consume 方法
Consumer 2: 产品数量小于等于 0, 进入等待...
Consumer 1 进入 consume 方法
Consumer 1: 产品数量小于等于 0, 进入等待...
Producer 2 进入 produce 方法
Producer 2 添加产品, 剩余1件产品
Producer 2 唤醒其他线程
Producer 2 进入 produce 方法
Producer 2: 产品数量大于 0, 进入等待...
Consumer 1 消费产品, 剩余0件产品
Consumer 1 唤醒其他线程
Consumer 1 进入 consume 方法
Consumer 1: 产品数量小于等于 0, 进入等待...
Consumer 2 消费产品, 剩余 -1 件产品
Consumer 2 唤醒其他线程
Consumer 2 进入 consume 方法
Consumer 2: 产品数量小于等于 0, 进入等待...
针对这个结果,我来挨个解释下。
-
首先,Producer1 进入
produce
方法,此时产品数量为 0,条件判断不成立,则 Producer1 开始添加产品,随后唤醒其他线程:Producer 1 进入 produce 方法 Producer 1 添加产品, 剩余1件产品 Producer 1 唤醒其他线程
-
Producer1 唤醒其他线程后,
synchronized
块也执行结束了,于是 Producer1 释放锁,四个线程开始互相竞争这个锁,幸运的是,Producer1 顺利竞争到了,所以 Producer1 得以继续执行。此时已有一个产品,条件满足,所以 Producer1 进入 WaitSet 并释放锁:Producer 1 进入 produce 方法 Producer 1: 产品数量大于 0, 进入等待...
-
Consumer2 竞争到锁,进入 consume 方法,此时有 1 个产品,条件判断不成立,则 Consumer2 开始消费产品,随后唤醒其他线程:
Consumer 2 进入 consume 方法Consumer 2 消费产品, 剩余0件产品Consumer 2 唤醒其他线程
-
Consumer2 唤醒其他线程后,
synchronized
块也执行结束了,于是 Consumer2 释放锁,四个线程开始互相竞争这个锁,幸运的是,Consumer2 顺利竞争到了,所以 Consumer2 得以继续执行。此时产品数量为 0,条件满足,所以 Consumer2 进入 WaitSet 并释放锁:Consumer 2 进入 consume 方法Consumer 2: 产品数量小于等于 0, 进入等待...
-
Consumer1 竞争到锁,此时产品数量为 0,条件满足,所以 Consumer1 进入 WaitSet 并释放锁:
Consumer 1 进入 consume 方法Consumer 1: 产品数量小于等于 0, 进入等待...
-
Producer2 竞争到锁,进入 produce 方法,此时产品数量为 0,条件判断不成立,则 Producer2 开始添加产品,随后唤醒其他线程:
Producer 2 进入 produce 方法Producer 2 添加产品, 剩余1件产品Producer 2 唤醒其他线程
-
Producer2 唤醒其他线程后,
synchronized
块也执行结束了,于是 Producer2 释放锁,四个线程开始互相竞争这个锁,幸运的是,Producer2 顺利竞争到了,所以 Producer2 得以继续执行。此时已有一个产品,条件满足,所以 Producer2 进入 WaitSet 并释放锁:Producer 2 进入 produce 方法Producer 2: 产品数量大于 0, 进入等待...
-
Consumer1 竞争到锁,注意,由于 Consumer1 之前调用过 wait 方法陷入 WAITING,所以 Consumer1 会接着 wait 方法后面开始执行,即消费产品并唤醒其他线程:
Consumer 1 消费产品, 剩余0件产品Consumer 1 唤醒其他线程
-
同样的,Consumer1 唤醒其他线程后,
synchronized
块也执行结束了,于是 Consumer1 释放锁,四个线程开始互相竞争这个锁,幸运的是,Consumer1 顺利竞争到了,所以 Consumer1 得以继续执行。此时产品数量为 0,条件满足,所以 Consumer1 进入 WaitSet 并释放锁:Consumer 1 进入 consume 方法Consumer 1: 产品数量小于等于 0, 进入等待...
-
Consumer2 竞争到锁,同样的,由于 Consumer2 之前调用过 wait 方法陷入 WAITING,所以 Consumer2 会接着 wait 方法后面开始执行,即消费产品并唤醒其他线程,于是,问题就出现了!
Consumer 2 消费产品, 剩余 -1 件产品Consumer 2 唤醒其他线程
呼,分析了这么多,可以看出来,虚假唤醒的问题就出现 “一旦这个线程重新获得了该对象锁,那么它对对象的所有同步声明都将恢复到调用 wait
方法时的状态,可以接着往下执行”,所以解决方法也很简单,把 if
条件判断改成 while
条件判断就好了,这样即使是接着 wait 方法后面继续执行,也会进入循环判断。
Volatile 与双重校验锁
读本文之前建议各位先看一下【重磅开篇-形成完善的多线程世界观】、【详解 Java 内存模型与原子性、可见性、有序性】、【JMM 最最最核心的概念:Happens-before 原则】这三篇文章。
volatile
的重要性自不用我再多说了,在提到下文一些比较拗口的概念之前,我先来通俗的概括下 volatile
关键字,当一个变量被定义成 volatile
之后,它将具备两项特性:
1)第一项是保证此变量对所有线程的可见性。所谓 “可见性” 就是指当一条线程修改了这个变量的值,其他线程可以立即得知这个修改。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。
2)第二项是禁止指令重排序。事实上,普通的变量是无法保证变量赋值操作的顺序与程序代码的执行顺序是一致的,在某些情况下,可能会出现意想不到的结果。
可见性简单回顾
回顾下 Java 内存模型:
从上图来看,如果线程 A 与线程 B 之间要通信的话,必须要经历下面 2 个步骤:
- 1)线程 A 把工作内存 A 中更新过的共享变量刷新到主内存中去
- 2)线程 B 到主内存中去读取线程 A 之前已更新过的共享变量
也就是说,线程 A 在向线程 B 的通信过程必须要经过主内存。
那么,这就可能出现一个问题,举个简单的例子,看下面这段代码:
// 线程 1 执行的代码
int i = 0;
i = 1;
// 线程 2 执行的代码
j = i;
当线程 1 执行 i = 1 这句时,会先去主内存中读取 i 的初始值,然后加载到线程 1 的的工作内存中,再赋值为1,至此,线程 1 的工作内存当中 i 的值变为 1 了,不过还没有写入到主内存当中。
如果在线程 1 准备把新的 i 值写回主内存的时候,线程 2 执行了 j = i 这条语句,它会去主存读取 i 的值并加载到线程 2 的工作内存当中,而此时主内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 1。
这就是内存可见性问题,线程 1 修改了共享变量 i 的值,线程 2 并没有立即得知这个修改。
volatile 是如何保证可见性的
这就要提到 volatile 读写的内存语义。
class Test {
volatile int a = 0;
volatile boolean flag = true;
// 线程 A
public void write() {
a = 1;
flag = true;
}
// 线程 B
public void read() {
if (flag) {
int i = a;
}
}
}
1)volatile 写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
2)volatile 读的内存语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
这样,我们把 volatile 写和 volatile 读两个步骤综合起来,上述这个过程不过就是线程 A 通过主内存向线程 B 发送消息罢了:
1)线程 A 写一个 volatile 变量,可以理解为线程 A 向接下来将要读这个 volatile 变量的某个线程发出了消息,告知其我对共享变量做了修改,你待会不要读错了。
2)而线程 B 读一个 volatile 变量,就相当于线程 B 接收到了之前某个线程发出的消息。
事实上,更底层来讲,有 volatile 变量修饰的共享变量进行写操作的时候会多出一条 Lock
前缀的指令,根据 IA-32 架构软件开发者手册,这条指令会引发两件事情:
1)将当前处理器缓存行的数据写回到系统内存
2)这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效
禁止指令重排(内存屏障)
讲完了可见性,再来看 volatile 的第二项特性:禁止指令重排。
关于指令重排序的问题前面文章已经讲过了,这里就不再赘述了。为了实现 volatile 读写的内存语义,防止指令重排序导致的不可预期的错误结果,JMM 会分别限制编译器和处理器重排序的类型,如下图,红色箭头表示不可重排序,绿色箭头表示可以重排序:
可以发现:
- 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
- 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
- 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。
更具体来讲,禁止指令重排的底层原理是插入内存屏障:编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序:
1)在每个 volatile 写操作的前面插入一个 StoreStore 屏障;在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为 StoreStore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。
StoreLoad 屏障的作用是避免 volatile 写与后面可能有的 volatile 读操作重排序。
看到这句话肯定有读者就有疑惑了,那如果 volatile 写完直接 return,JMM 还会在 volatile 写的后面添加 StoreLoad 屏障吗?
答案是 “会”。因为为了保证在任意处理器平台,任意的程序中都能得到正确的 volatile 内存语义,JMM 就采取了这种保守的策略。
还有一个问题,既然可以在 volatile 写后面添加 StoreLoad 屏障防止 volatile 写与后面的 volatile 读操作重排序,那为什么不选择在 volatile 读前面添加 StoreLoad 屏障呢?
事实上,这是 JMM 从整体执行效率的角度做出的最优选择:因为并发场景的常见使用模式是,一个写线程,多个读线程。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障显然能够获得更高的执行效率。
从这里可以看到 JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率,当然,这也是我们日常开发时的准则。
2)在每个 volatile 读操作的后面插入一个 LoadLoad 屏障和一个 LoadStore 屏障
双重校验锁
关于 volatile 最出名的应用就是单例模式的 双重校验锁(Double Checked Locking,DCL) 写法了。如下:
public class SingleTon {
// 私有化构造方法
private SingleTon(){};
private static volatile SingleTon instance = null;
public static SingleTon getInstance() {
// 第一次校验
if (instance == null) {
synchronized (SingleTon.class) {
// 第二次校验
if (instance == null) {
instance = new SingleTon();
}
}
}
}
return instance;
}
先来解释下这两重校验分别作了什么:
1)第一重校验:由于单例模式只需要创建一次实例,所以如果多次调用 getInstance
方法的话,应该直接返回第一次创建的实例。因此其实大部分时间都是不需要去执行同步方法里面的代码的,这样,第一重校验大大提高了性能。
2)第二重校验:我们先假设没有第二重校验:
- 假设线程 t1 执行了第一重校验后,判断为
instance == null
; - 就在这个时候,发生上下文切换,另一个线程 t2 获得了 CPU 调度,并且也执行了第一重校验,也判断
instance == null
,随后 t2 获得锁,创建实例; - 然后,发生上下文切换,t1 又重新获得 CPU 调度,由于之前已经进行了第一重校验,结果为 true(不会再次判断),所以 t1 也会去获得锁并创建实例。这样就会导致创建多个实例。
所以需要在同步块里面进行第二重校验,如果实例为空,才进行创建。
再来解释下为什么 instance
一定要用 volatile 这个关键字来修饰。
这里就是 volatile 第二项特性 - 禁止指令重排的应用。在 Java 语言层面上,创建对象仅仅是一个 new 关键字而已,而在 JVM 中,对象的创建其实并不是一蹴而就的,忽略掉一些 JVM 底层的细节比如设置对象头啥的,对象的创建可以大致分三个步骤:
- 在堆中为对象分配内存空间
- 调用构造函数,初始化实例
- 将栈中的对象引用指向刚分配的内存空间
那么由于 JVM 指令重排优化的存在,有可能第二步和第三步发生交换:
- 在堆中为对象分配内存空间
- 将栈中的对象引用指向刚分配的内存空间
- 调用构造函数,初始化实例
现在考虑重排序后,两个线程发生了以下调用:
Thread T1 | Thread T2 |
---|---|
检查到 instance 为空 |
|
获取锁 | |
再次检查到 instance 为空 |
|
为 instance 分配堆中的内存空间 |
|
将 instance 指向内存空间 |
|
检查到 instance 不为空 |
|
直接访问 instance (由于指令重排序,此时对象尚未完成初始化) |
|
初始化 instance |
在这种情况下,线程 T2 访问到的就是一个未完成初始化的对象,是个半成品,会报空指针异常的错误。
所以说,instance
一定要用 volatile 这个关键字来修饰,从而禁止指令重排。
Final 还能保证可见性?
旧的 Java 内存模型存在的问题
关于如何保证可见性这个问题,很多小伙伴一定都能脱口而出 volatile,再思考一下还能想到 synchronized,而使用 final 关键字保证可见性这个特点,可能会被大多数人忽略。
值得注意的是,final 能够保证可见性这一特征,得益于 JSR-133 对其内存语义进行了一波增强。
在旧的 Java 内存模型(JDK 1.5 以前)中,一个最严重的缺陷就是线程可能看到 final 域的值会改变。比如一个线程看到一个 int 类型 final 值为 0,此时该值是未初始化前的零值,一段时间后该值被某线程初始化,再去读这个 final 值会发现值变为 1。
以 JDK 1.4 的 String
类来举个例子:
String 类包含三个字段:一个字符串数组的引用 value、一个记录数组中开始位置的 offset、字符串长度 length。
通过这种方式,可以实现多个 String 对象共享一个相同的字符串数组,从而避免为每个对象分配额外的空间。看如下代码:
String s1 = "hello";
String s2 = s1.substring(2);
在 JDK 1.4 中,s2 和 s1 事实上是共享一个字符串数组 "hello" 的,不同的是 s2 的 offset = 2,length = 3,s1 的 offset = 0,length = 5。
但是,length 和 offset 并不是一开始就被定义为某个期望的值,在 String 的构造函数运行之前,父类 Object 的构造函数会先初始化所有字段为默认值,包括被 final 修饰的 length 和 offset 字段。只有当 String 的构造函数运行时,length 和 offset 才会被赋予为期望的值。
那如果没有使用同步手段,这一过程在旧的内存模型中,另一个线程就可能会看到 s2.offset 的默认值 0,然后再看到被构造函数赋予的值 4。
这就导致了一个很迷幻的现象,开始看到字符串 s2 的内容是 “hel",随后再去看就变成了 "llo"。这显然不合理,但是在 JDK 1.5 之前的旧内存模型中确实存在这样的问题。
那么为了修补这个漏洞,JSR-133 专家组定义了一个新的 Java 内存模型并增强了 final 的语义(前文还提到过 JSR-133 也修改了 volatile 语义)。通过为 final 域增加写和读重排序规则,为 Java 程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(lock、volatile)就可以保证任意线程都能看到这个 final 域在构造函数中被初始化之后的值。
简单来说,只要一个不可变对象被正确地构建出来(即没有发生 this 引用逃逸的情况),那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态。甚至我们可以下结论:不可变对象永远是线程安全的。
至于什么是 this 引用逃逸,下文会详细解释。
写 final 域的重排序规则
写 final 域的重排序规则包含以下两个方面:
1)JMM 禁止编译器把对 final 域的写指令重排序到构造函数之外。
2)编译器会在对 final 域的写指令之后,构造函数 return 之前,插入一个 StoreStore 屏障(这个屏障的作用就是禁止处理器把对 final 域的写指令重排序到构造函数之外)
下面举个例子来对比下写 final 域和写普通域的区别:
public class FinalTest {
private int a; // 普通域
private final int b; // final 域
private static FinalTest finalTest;
public FinalTest() {
a = 1; // 1. 写普通域
b = 2; // 2. 写 final 域
}
// 线程 A 执行
public static void writer() {
finalTest = new FinalTest();
}
// 线程 B 执行
public static void reader() {
FinalTest object = finalTest; // 3. 读对象引用
int a = object.a; // 4. 读普通域
int b = object.b; // 5. 读 final 域
}
}
如上,writer 方法只有一行代码,但实际上包含两个大步骤:
- 构造 FinalTest 对象
- 写普通域:a = 1
- 写 final 域:b = 2
- 把这个对象的引用赋值给引用变量 finalTest
JMM 禁止编译器把对 final 域的写指令重排序到构造函数之外,但对于写普通域并没有类似的要求,所以 writer 方法可能的会出现下面这样的执行顺序:
- 构造 FinalTest 对象
- 写 final 域:b = 2
- 把这个对象的引用赋值给引用变量 finalTest
- 写普通域:a = 1
如下图所示:
在上图中,读线程 B 看到对象引用的时候,对象还没有构造完成,因为写普通域的操作被编译器重排序到了构造函数之外,此时初始值 1 还没有被写入普通域 a。
所以,读线程 B 错误地读取到了普通变量 a 初始化之前的值。而写 final 域的操作,由于写 final 域的重排序规则的存在,被限定在了构造函数之内,所以读线程 B 正确地读取到了 final 变量初始化之后的值。
读 final 域的重排序规则
读 final 域的重排序规则指的是:
- 处理器:在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。
- 编译器:编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。
至于为什么要特地把处理器拎出来制定一条规则,首先,对于编译器和大部分处理器来说,肯定是不会对初次读对象引用与初次读该对象包含的 final 域这两个操作进行重排序的,因为这两个操作之间存在间接依赖关系。但有少数处理器是允许对存在间接依赖关系的操作做重排序的,所以这个规则就是专门用来针对这种处理器的。
仍然看上一小节的例子,reader 方法包含三个步骤:
st=>start: Start
op1=>operation: 读对象引用
op2=>operation: 读对象引用的普通域
op3=>operation: 读对象引用的 final 域
e=>end
st->op1->op2->op3->e
同样的,JMM 禁止处理器对这两个操作进行重排序,但对于读普通域并没有类似的要求,所以如果在不遵守间接依赖关系的处理器上,reader 方法可能的会出现这样的执行顺序:
st=>start: Start
op1=>operation: 读对象引用
op2=>operation: 读对象引用的普通域
op3=>operation: 读对象引用的 final 域
e=>end
st->op2->op1->op3->e
如下图所示:
在上图中,读对象的普通域的操作被处理器重排序到读对象引用之前。线程 B 读普通域时,该域还没有被线程 A 写入。而读 final 域的重排序规则会把读对象 final 域的操作限定在读对象引用之后,此时该 final 域已经被线程 A 初始化过了,所以能够正确的读取到。
this 引用逃逸
之前在锁消除那一篇文章里我们提到过 “逃逸” 这个概念,现在来回顾下:
所谓逃逸,包括方法逃逸和线程逃逸,线程逃逸的逃逸程度高于方法逃逸:
- 当一个对象在方法里面被定义后,它如果被外部方法所引用(例如作为调用参数传递到其他方法中),这种称为方法逃逸;
- 可能被外部其他线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;
this 引用逃逸就是一种线程逃逸:在构造器构造还未彻底完成前(即实例初始化阶段还未完成),将自身 this 引用向外抛出并被其他线程复制(访问)了该引用,那么其他线程就可能会访问到该还未被初始化的变量。
举个例子:
public class FinalReferenceEscapeTest {
final int i;
static FinalReferenceEscapeTest obj;
public FinalReferenceEscapeTest () {
i = 1; // 1. 写 final 域
obj = this; // 2. this 引用在此 "逸出"
}
// 线程 A
public static void writer() {
new FinalReferenceEscapeExample();
}
// 线程 B
public static void reader() {
if (obj != null) { // 3
int temp = obj.i; // 4
}
}
}
假设一个线程 A 执行 writer() 方法,另一个线程 B 执行 reader() 方法。这里的操作 2 将自身 this 引用向外抛出,使得 FinalReferenceEscapeTest
对象还未完成构造前就为其他线程可见。
有的同学可能会问,这个操作 2 不是在构造函数的最后一步吗,它执行完构造函数也执行完了,对象不就已经完成构造了吗?
But 这里的操作 1 和操作 2 之间可能被重排序。如下图所示:
所以,我们可以得出这样的结论:在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的各个字段(域)可能还没有被初始化。
这也是为什么说我们说 “只要一个不可变对象被正确地构建出来(即没有发生 this 引用逃逸的情况),那其外部的可见状态永远都不会改变”。
1 Comment