在java语言中,有两个锁一个是jdk层面的ReentrantLock
,一个是jvm层面的synchronized
,这两个锁是我们掌握并发编程中不可或缺的两个类。本文主要从使用、原理两个方面来介绍synchronized
。
使用
synchronized用法
public class SynchronizedTest { Object o1 = new Object(); static Object o2 = new Object(); @SneakyThrows public synchronized void test1() { Thread.sleep(1000); } @SneakyThrows public static synchronized void test2() { Thread.sleep(1000); } @SneakyThrows public void test3() { synchronized (o1) { Thread.sleep(1000); } } @SneakyThrows public void test4() { synchronized (o2) { Thread.sleep(1000); } } @SneakyThrows public void test5() { synchronized (this) { Thread.sleep(1000); } } @SneakyThrows public void test6() { synchronized (SynchronizedTest.class) { Thread.sleep(1000); } } }
这是synchronized的几种常见用法,但你了解其中锁的粒度么?哪些是锁对象、哪些是锁整个类的呢?下面写了一个测试的执行代码,通过代码的执行结果最有说服力。
代码说明:
获取开始时间;
分别创建两个线程,并且new两个
SynchronizedTest
,使用对象分别调用上述方法;开启两个线程;
等待两个线程执行完成;
打印执行的总时长;
如果时长为1s,则说明锁的粒度是当前new出来的对象;
如果时长为2s,则说明锁的力度是当前类。
@SneakyThrows public static void main(String[] args) { long startTime = System.currentTimeMillis(); Thread t1 = new Thread(() -> new SynchronizedTest().test1()); Thread t2 = new Thread(() -> new SynchronizedTest().test1()); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("执行时间: " + (System.currentTimeMillis() - startTime) / 1000); }
锁粒度
大家可以根据上述代码自己执行一遍,这样印象会更深;下面我直接贴出采用各使用方法时锁的粒度。
test1 : 锁的是当前实体的该方法,执行结果为1s.
test2 : 锁的是当前类
,执行结果为2s.
test3 : 锁的是当前实体的o1属性,执行结果为1s.
test4 : 锁的是当前类的o2属性
,执行结果为2s.
test5 : 锁的是当前对象,执行结果为1s.
test6 : 锁的是当前类
,执行结果为1s.
原理
在原理方面,会先看一下加了synchronized后字节码会有什么变化,再介绍一下在jdk1.6做了哪些优化,然后着重介绍一下锁的各个阶段以及重量级锁的实现方法。
字节码
首先我们来分析一下,使用synchronized时字节码层面会如何变化,这里我写了一个类再通过javac、javap来查看一下字节码有什么变化。java代码如下:
public class Test { Object o = new Object(); public void test1(){ } public void test2(){ synchronized (o){ } } }
在该java类型目录下,执行javac Test.java
,再执行 javap -v Test.class
,执行完后会出现稍微能看明白一点的汇编代码,其中这两个方法汇编代码如下:
public void test1(); descriptor: ()V flags: (0x0001) ACC_PUBLIC Code: stack=0, locals=1, args_size=1 0: return LineNumberTable: line 11: 0 public void test2(); descriptor: ()V flags: (0x0001) ACC_PUBLIC Code: stack=2, locals=3, args_size=1 /** * 0: 将对象引用压入操作数栈 */ 0: aload_0 /** * 1: 获取指定字段的值并压入操作数栈 */ 1: getfield #7 // Field o:Ljava/lang/Object; /** * 4: 复制操作数栈顶元素 */ 4: dup /** * 5: 将操作数栈顶元素存储到局部变量 1 中 */ 5: astore_1 /** * 6: 进入同步块,获取对象的监视器锁 */ 6: monitorenter /** * 7: 将局部变量 1 所引用的对象压入操作数栈 */ 7: aload_1 /** * 8: 退出同步块,释放对象的监视器锁 */ 8: monitorexit /** * 9: 跳转到偏移量为 17 的指令 */ 9: goto 17 /** * 12: 将异常对象引用存储到局部变量 2 中 */ 12: astore_2 /** * 13: 将局部变量 1 所引用的对象压入操作数栈 */ 13: aload_1 /** * 14: 退出同步块,释放对象的监视器锁 */ 14: monitorexit /** * 15: 将局部变量 2 所引用的异常对象压入操作数栈 */ 15: aload_2 /** * 16: 抛出操作数栈顶的异常 */ 16: athrow /** * 17: 方法返回 */ 17: return
这里我给test2
方法增加了注释,可以观察到到加了synchronized后会多出来monitorenter
,monitorexit
两个指令,后面我们分析源码时可以着重看一下monitor
是如何实现的锁。
锁优化
在JDK1.5的时候,Doug Lee推出了JUC包,在这里实现了ReentrantLock,并且它的性能远高于synchronized,所以JDK团队就在JDK1.6中,对synchronized做了大量的优化。例如:
锁消除:在synchronized修饰的代码中,如果不存在操作共享变量的情况,会触发锁消除,即便写了synchronized,也不会触发。如:
public int test(){ synchronized (o){ int i = 0; return ++i; } }
锁膨胀:如果在一个方法中,频繁的获取和释放锁资源,就会优化为将锁的范围扩大,避免频繁的竞争和获取锁资源带来不必要的消耗。如:
public void test1() { for (int i = 0; i < 100000; i++) { synchronized (o) { } } }
锁升级:在synchronized
在1.6版本之前,获取不到锁时,会立即挂起当前线程,后续释放锁时再唤醒竞争线程进行锁的竞争。而到了1.6版本时,对synchronized
做了锁升级的优化;
无锁、匿名偏向:当前对象没有作为锁存在。
偏向锁:如果当前锁资源,只有一个线程在使用,那么这个线程过来,只需要判断,该对象的threadId指向的是否为当前线程。
如果是,直接获取锁;
如果不是,基于CAS的方式,尝试通过cas将threadId指向当前线程。如果获取不到,触发锁升级,升级为轻量级锁。(偏向锁状态出现了锁竞争的情况)
轻量级锁:会采用自旋锁的方式去频繁的以CAS的形式获取锁资源(采用的是自适应自旋锁)
如果cas成功则获取锁;
如果自旋了一定次数,没获取到锁,锁升级。
重量级锁:就是最传统的synchronized方式,获取不到锁资源,就挂起当前线程,等待其他线程释放锁并唤起该线程进行锁资源竞争。
观察锁升级
为了更直观的观察到锁的升级过程,首先我在下面我贴了一张锁资源对象头中markdown在各个状态下各个字节含义的示意图,大家对比着看一下。
其次在代码中引用了jol-core
,它可以在代码层面打印出对象头信息,接下来我会举一些例子让大家能更直观的看到锁的升级。
implementation 'org.openjdk.jol:jol-core:0.9'
无锁
public static void main(String[] args) { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); }
上面代码就是打印对象o的实例信息,打印结果如下:
可以看到我用红色框框圈出来的就是锁的标识位001为无锁状态
偏向锁
public static void main(String[] args) { Object o = new Object(); new Thread(() -> { synchronized (o) { //t1 - 偏向锁 System.out.println(ClassLayout.parseInstance(o).toPrintable()); } }).start(); }
上面代码就是用一个线程获取锁资源,此时该锁资源就会偏向到该线程,再看一下打印结果:
这两张图的结果都是正确的,不同的jdk版本会出现不同的结果,如果你本地执行发现是000
轻量级锁的标识不用惊讶,这是因为你使用的jdk版本默认开启了锁的偏向延迟。我本地测试使用open jdk8是第一种结果,在代码执行前加上Thread.sleep(5000L);
会变成第二种结果,使用zulu jdk11、jdk17、jdk21都是都是000
,他们默认都不开启偏向锁。
偏向锁在升级为轻量级锁时,会涉及到偏向锁撤销,需要等到一个安全点(STW),才会让偏向锁撤销,在知道有并发时,就可以选择不开启偏向锁,或者是设置偏向锁延迟开启。
因为JVM在启动时,需要加载大量的.class文件到内存中,这个操作会涉及到synchronized的使用,为了避免出现偏向锁撤销操作,JVM启动初期,有一个延迟4s开启偏向锁的操作
命令参数如下:
-XX:+/-UseBiasedLocking 启动/禁用偏向锁,默认虚拟机启动4秒后启动偏向锁 -XX:BiasedLockingStartupDelay 虚拟机启动后,立刻启动偏向锁
轻量级锁
这里不就举例了,上个例子中不开启偏向锁会直接变更为轻量级锁的。
重量级锁
大家还记得上面在看字节码时有一个Monitor
对象,我们就来看一下这个对象中有什么内容。
ObjectMonitor() { // 头节点,存储着 MarkWord _header = NULL; // 竞争锁的线程个数 _count = 0; // 等待(wait)的线程个数 _waiters = 0; // 标识当前 synchronized 锁重入的次数 _recursions = 0; // 关联的对象 _object = NULL; // 持有锁的线程 _owner = NULL; // 保存等待(wait)线程的信息,双向链表 _WaitSet = NULL; // WaitSet 的锁 _WaitSetLock = 0 ; // 负责的线程(可能用于特定的职责标识) _Responsible = NULL ; // 后继节点(可能用于链表结构) _succ = NULL ; // 获取锁资源失败后,线程要放到当前的单向链表中 _cxq = NULL ; // 下一个空闲节点(可能用于链表管理) FreeNext = NULL ; // 等待获取锁的线程队列(_cxq 以及被唤醒的 WaitSet 中的线程,在一定机制下,会放到这里) _EntryList = NULL ; // 自旋频率 _SpinFreq = 0 ; // 自旋时钟 _SpinClock = 0 ; // 是否为线程所有者的标识 OwnerIsThread = 0 ; // 前一个所有者线程的线程 ID _previous_owner_tid = 0; }
上述标识我自己翻译了一遍,这个类还有很多实现方法,但由于都是c语言了,就不再详细解释了,可以观察到这里面有几个属性和ReentrantLock很像,大概也借鉴了ReentrantLock的实现。如重入次数
、链表
、等待线程
、持有锁的线程
等,有一个区别是这里采用了单向链表
,而ReentrantLock采用了双向链表
因为ReentrantLock有找前驱节点的需求所以采用了双向链表,例如在释放锁资源唤醒后置节点时,此时后置已经被取消,那么就需要从tail节点往前查询,又或者加入到链表中,从后插入只需要O(1)的时间复杂度。而ObjectMonitor
采用单向链表,由于我没详细看过代码,没法给大家具体的观点,如果有会c语言的同学可以帮忙解答一下。
总结
在jdk1.6以后在低并发时使用synchronized
的性能是高于lock
的,因为它是在JVM层面实现的,而且不需要显式地获取和释放锁。但是在高并发下synchronized
是不如lock
,例如lock支持读写锁分离、线程挂起时插入到链表中时间复杂度为O(1)等。
除此之外lock
还支持更多使用场景,例如:公平锁和非公平锁、尝试获取锁等。具体选择哪个还是要根据具体的应用场景和需求来选择,大家平时都使用哪个锁呢?