synchronized的小故事
在并发编程中,对于synchronized
和Lock
的使用是很频繁的。Lock
是基于Java的一个框架,synchronized
是基于C语言的JVM层级的锁,使用起来很方便。
但是呢,在jdk1.6以前,synchronized
是一个重量级锁,他的底层需要操作系统来支持,必然涉及到了用户态和内核态的切换,还需要挂起当前线程,直到竞争到锁才执行,因此是很耗性能的。再者,一个C语言程序性能竟然不如Java的Lock
框架性能,也是让人哭笑不得。
所以啊,Oracle公司决定对synchronized
进行优化,在jdk1.6对该锁进行升级,增加了锁膨胀升级、锁粗化、锁消除等特性,使得两种锁的性能达到了差不多的水平。
本文重点介绍锁的膨胀升级,并使用代码验证其过程。
锁其实是一个对象
锁其实就是一个Object对象,对于新手来说,可能不好理解。这里简单解释一下,方便理解后面的概念。
synchronized
是基于JVM内置锁——Monitor(监视器)实现。任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态,即可以执行同步块里的代码。synchronized
在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。
简单理解就是任何一个对象都可以成为一把锁,类的Class也可以成为一把锁,这把锁锁定的级别就是类级别,可以对静态方法进行锁定。
既然,锁是对象,那么回顾一下对象的布局:对象头、实例数据、对齐填充。锁相关的信息就保存在对象头里边。
HotSpot虚拟机的对象头包括两部分信息:Mark Word、类型信息,如果是数组需要多加一个数组长度。
Mark Word,用于存储对象自身的运行时数据, 如哈希码
(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键。
这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits。考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
这里的Mark Word空间复用怎么理解呢?就是一块空间,会根据锁当前的状态(锁会升级,下文会介绍)进行调整,具体布局如下
假设当前是无锁状态,那么Mark Word保存的就是对象的hashcode、对象分代年龄、是否偏向、锁标志位
如果锁是轻量级锁,那么此时Mark Word保存的就是pinter to Lock Record,是一个指针,指针所指向的位置,会拷贝以根当前的Mark Word信息(分代年龄、hashcode…)
锁怎么膨胀升级
对于可能会有线程安全问题的代码,我们一般会对其加锁,使得程序能串行执行。但是很多时候,我们系统的并发度是很低的,甚至几乎没有出现过并发情况,这样加锁就有点浪费性能了(每次都让操作系统去加锁、释放锁)。
故此,可以多加几种锁,让锁能够适配不同场景,同时也能够根据合适的场景自动升级:偏向锁、轻量级锁、重量锁。这些锁是可以进行单向升级的,也就是说不能降级。
偏向锁
顾名思义,锁会偏向某一个线程。如果在一定时间内,只有一条线程去获取锁,那么就没有必要去中断线程、竞争锁。
如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,
无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
默认是开启偏向锁
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
轻量级锁
对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的。倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
有些文章、博客会提到自旋锁,其实就是轻量级锁的一种实现:不中断线程,通过不断循环获取锁,当然这个循环是有次数限制的。
重量级锁
该锁就是需要由操作系统进行加锁、释放锁,性能开销较大,但是在激烈的锁竞争环境下,重量级锁性能会比多条线程不断自旋要来的好。
锁膨胀升级验证
首先引入一个能够动态打印对象头信息的依赖
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.16</version> </dependency>
public class MarkWordTest { private static Object lock = new Object(); public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { synchronized (lock) { try { TimeUnit.MILLISECONDS.sleep(2); System.out.println("t1:" + ClassLayout.parseInstance(lock).toPrintable()); } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t2 = new Thread(() -> { synchronized (lock) { try { TimeUnit.MILLISECONDS.sleep(2); System.out.println("t2:" + ClassLayout.parseInstance(lock).toPrintable()); } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t3 = new Thread(() -> { synchronized (lock) { try { TimeUnit.MILLISECONDS.sleep(2); System.out.println("t3:" + ClassLayout.parseInstance(lock).toPrintable()); } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t4 = new Thread(() -> { synchronized (lock) { try { TimeUnit.MILLISECONDS.sleep(2); System.out.println("t4:" + ClassLayout.parseInstance(lock).toPrintable()); } catch (InterruptedException e) { e.printStackTrace(); } } }); System.out.println("start:" + ClassLayout.parseInstance(lock).toPrintable()); // t1开始 t1.start(); TimeUnit.SECONDS.sleep(5); // t2、t3开始 t2.start(); t3.start(); TimeUnit.SECONDS.sleep(5); System.out.println("t2、t3end:" + ClassLayout.parseInstance(lock).toPrintable()); }}
由图可见,这段程序锁状态变化过程是:无锁—->轻量级锁—->重量级锁—->无锁
有心的人应该会注意到一个问题,偏向锁状态去哪儿了呢?默认不是开启的么?
我在代码中把锁对象实例化进行延迟,待虚拟机都启动后,我再进行实例化,可以得到如下结果:
由图可见,这段程序锁状态变化过程是:可偏向—->偏向锁—->重量级锁—->无锁
这是什么原因导致的呢?
这个问题,我也没有深入研究过,大家可以看看其他博文,结论就是虚拟机把一开始就创建的对象锁状态直接跳过偏向的状态。
锁不能降级
对于锁不能降级的概念,我之前一直以为的是:锁状态升到了重量级锁,之后再有线程竞争锁时候就直接用重量级锁。实际上并非如此。这里结合代码一起看看吧。
public class MarkWordTest { private static Object lock; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { synchronized (lock) { try { TimeUnit.MILLISECONDS.sleep(2); System.out.println("t1:" + ClassLayout.parseInstance(lock).toPrintable()); } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t2 = new Thread(() -> { synchronized (lock) { try { TimeUnit.MILLISECONDS.sleep(2); System.out.println("t2:" + ClassLayout.parseInstance(lock).toPrintable()); } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t3 = new Thread(() -> { synchronized (lock) { try { TimeUnit.MILLISECONDS.sleep(2); System.out.println("t3:" + ClassLayout.parseInstance(lock).toPrintable()); } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t4 = new Thread(() -> { synchronized (lock) { try { TimeUnit.MILLISECONDS.sleep(2); System.out.println("t4:" + ClassLayout.parseInstance(lock).toPrintable()); } catch (InterruptedException e) { e.printStackTrace(); } } }); TimeUnit.SECONDS.sleep(5); lock = new Object(); System.out.println("start:" + ClassLayout.parseInstance(lock).toPrintable()); // t1开始 ---->偏向锁 t1.start(); TimeUnit.SECONDS.sleep(5); // t2、t3开始 ---->重量级锁 t2.start(); t3.start(); TimeUnit.SECONDS.sleep(5); System.out.println("t2、t3end:" + ClassLayout.parseInstance(lock).toPrintable()); // t4开始 ---->轻量级锁 t4.start(); }}
这段代码大致意思如下:
一开始有一条线程来获取锁,由于只有当前一条线程竞争,所以就进行CAS,将当前线程id设置到锁对象Mark Word上面,锁状态变更为偏向锁。
接着两条线程一起进来,由于竞争比较激烈,直接升级成重量级锁。
前两条线程都结束,此时锁状态是无锁
最后一条线程进来竞争锁,虽然此时只有一条线程进来,但是锁状态还是变成轻量级锁。
我一开始会觉得,锁状态怎么能从重量级锁降级成轻量级锁???这么神奇?
这里应该是自己理解不到位。这里锁膨胀升级是针对一轮锁竞争的过程,竞争结束后,自然又会从轻量级开始。可能有人会问为什么不是从偏向锁开始?我也实验了好几次,确实只会偏向一次,后边不会再去偏向另一个线程,都是从轻量级锁开始。可能虚拟机觉得没有必要吧,都出现过轻量级锁、重量级锁的状态,何必从最低级的开始呢?