四、synchronized之锁优化
1. 概述
刚学Java并发的时候,多线程情况的处理一般都是用synchronized来实现,我们称其为“同步”,但随着我们深入学习才知道,它是一个重量级锁,相对于Lock,显得非常笨重,严重影响程序的效率。当然,随着Java版本的逐步升级,synchronized也已经做了各种优化,但对其实现机制,是每一个Java coder必须掌握的。
synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时,它还可以保证共享变量的内存可见性
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:
-
普通同步方法,锁是当前实例对象
-
静态同步方法,锁是当前类的class对象
-
同步方法块,锁是括号里面的对象
当一个线程访问同步代码块时,它首先是要得到锁才能执行同步代码,当退出或者抛出异常时,必须要释放锁,那么它是如何来实现这个机制的呢?
public class SynchronizedTest {
public synchronized void test1(){
}
public void test2(){
synchronized (this){
}
}
}
利用javap工具查看生成的class文件信息来分析synchronized的实现
image-20190725162347089.png
从上面可以看出,同步代码块使用的是monitorenter和monitorexit指令实现的,monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁。
2 Java对象头之Mark Word
synchronized用的锁是存在Java对象头里的。如果对象时数组类型,则虚拟机用3字宽存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,如表所示:
长 度 | 内 容 | 说 明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或锁信息 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/64bit | Array Length | 数组的长度(如果当前对象是数组) |
Java对象头里的Mark Word默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下表所示:
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的HashCode | 对象分代年龄 | 0 | 01 |
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:
image-20190725164742803.png
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:
锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit | 2bit |
---|---|---|---|---|---|---|
cms_free | 分代年龄 | 偏向锁 | 锁标志位 | |||
无锁 | unused | HashCode | 0 | 01 | ||
偏向锁 | ThreadID(54bit) Epoch(2bit) | 1 | 01 |
3. 锁的升级
JavaSE1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,在JavaSE1.6中,锁一共有四种状态,级别从低到高依次为:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,这几个状态随着竞争情况逐渐升级。锁可以升级但不能降级,这就意味着偏向锁升级为轻量级锁后,不能降为偏向锁。这种策略实际是为了提高获得锁和释放锁的效率。
3.1 偏向锁
在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了让线程获得锁的代码更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程的进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁,如果失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1,如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
上面这段话理解起来有些难理解,看下流程图:
获取锁:
- 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
- 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
- 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
- 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
- 执行同步代码块
释放锁
偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
- 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
- 撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态;
3.2 轻量级锁
轻量级锁的主要目的是在没有多线程的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争变相所导致偏向锁升级为轻量级锁时,则会尝试获取轻量级锁。
获取锁:
- 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
- JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);
- 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;
释放锁
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
-
取出在获取轻量级锁保存在Displaced Mark Word中的数据;
-
用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
-
如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。
image-20190725225010912.png
3.3 重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。
与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
4. 锁的其他优化
4.1 适应性自旋(Adaptive Spinning)
从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的,但问题在于自旋是需要消耗CPU,如果一直获取不到锁的话,那该线程就一直处在自旋状态,这样就白白浪费了CPU资源。为解决这个问题,最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没有获取到锁就进入阻塞状态。但是JDK有更聪明的方式,就是适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会相应减少。
4.2 锁粗化(Lock Coarsening)
锁粗化实际上就是将多次连接在一起的加锁、解锁操作合并未一次,将多个连续的锁扩展成一个范围更大的锁。例如:
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
4.3 锁消除(Lock Elimination)
锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。例如:
public class SynchronizedTest02 {
public static void main(String[] args) {
SynchronizedTest02 test02 = new SynchronizedTest02();
//启动预热
for (int i = 0; i < 10000; i++) {
i++;
}
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
test02.append("abc", "def");
}
System.out.println("Time=" + (System.currentTimeMillis() - start));
}
public void append(String str1, String str2) {
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
}
虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。
5. 总结
以上主要介绍了轻量级锁和偏向锁等对Synchronized的优化,但是这两种锁也不是完全没缺点的,比如竞争比较激烈的时候,不但无法提升效率,反而会降低效率,因为多了一个锁升级的过程,这个时候就需要通过-XX:-UseBiasedLocking来禁用偏向锁。下面是这几种锁的对比:
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。同步块执行速度较长。 |