Java程序员进阶必备:深入分析 Synchronized 原理
2022-03-17 11:23:20来源:今日头条
我们在开发中肯定会遇到在同一个 JVM 中,存在多个线程同时操作同一个资源时,此时需要想要确保操作的结果满足预期,就需要使用同步方法。
官方解释:同步方法支持一种简单的策略来防止线程干扰和内存一致性错误:如果一个对象对多个线程可见,则对该对象变量的所有读取或写入都是通过同步方法完成的。
官方推荐使用的同步方法 (JDK 1.6后):Synchronized 基于 JVM 实现(此次主角);当然还有 ReentrantLock 基于 JDK 实现的。
我们先简单地热个身,举一个常用 synchronized 的方式(锁的是该类的实例对象)。
public class SynchronizedCodeTest { public void testSynchronized() throws InterruptedException { synchronized (this) { System.out.println("进入同步代码块"); Thread.sleep(100); System.out.println("离开同步代码块"); } } public static void main(String[] args) throws InterruptedException { new SynchronizedCodeTest().testSynchronized(); }}Synchronized 常用场景
任何对象(都有Mark Word结构,后面会详细描述) 都可以能作为 synchronized 锁的对象,根据使用的方式不同,锁的对象和对应的粒度也是有所不同。
并发编程三大特性简单回顾了下 synchronized ,一聊到锁就会提到 原子性、有序性、可见性,简单的介绍下这些(就不具体展开说明了,有需要的读者可以查阅相关资料,或者感兴趣的话我后续补充)。
原子性原子性:一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
简单理解为:如果将下单和支付2个操作看作一个整体,只要其中一个操作失败了,都算失败,反之成功。
有序性有序性:即程序执行的顺序按照代码的先后顺序执行。
大家可能或多或少,有听说过 Java 为了提高性能允许重排序(编译器重排序 和 处理器重排序),因此程序执行可能出现乱序也是由此而来。
简单理解为:有序性保证了 同样的代码 在多线程和单线程执行的最后结果相同,按照代码的先后顺序执行。
可见性可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
某个类的一个成员变量 Integer A = 0;# 线程1执行操作 A = 10;# 与此同时 线程2执行操作(B的值是0,而不是10,这就是可见性的问题) Integer B = A; # 常用的解决方案使用:volatile修饰 A 或者 使用synchronized修饰代码块 都可以解决这个问题
既然提到 synchronized 再多延伸出2个特性。
可重入性synchronized monitor(锁对象) 有个计数器,获取锁时 会记录当前线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,锁就会被释放了。
不可中断性不可中断:一个线程获取锁之后,另外一个线程处于阻塞或者等待状态,前一个不释放,后一个也一直会阻塞或者等待,不可以被中断。
Synchronized是不可中断,而 ReentrantLock是可中断(二者比较重要的区别之一)。
Synchronized 字节码介绍完一些基本的特性后,我们正式开始进入 synchronized 实现原理分析。
# 将上面 热身例子反编译成字节码javac -verbose SynchronizedCodeTest.javajavap -c SynchronizedCodeTest
我们主要关注下,monitorenter 和 monitorexit 这2个指令,对应的是 当前线程获取锁&计数器加一 和 释放锁&计数器减一。多个线程获取对象的监视器monitor获取是互斥。
对象,对象监视器,同步队列以及执行线程状态之间的关系任意线程对 Object 的访问,首先要获得 Object 的监视器,如果获取失败,该线程就进入同步状态,线程状态变为 BLOCKED,当 Object 的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。
Java 对象头(Mark Word)前面提到所有对象都可以作为synchronized锁的对象,在同步的时候是获取对象的monitor,即操作Java对象头里的Mark Word 。
下面是32位为JVM Mark Word默认存储结构(无锁状态)对象的 hashCode:25位的对象标识hashCode,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。**对象分代年龄 **:4位的Java对象年龄。每次 GC 未被回收累加的年龄就记录在此处,默认达到15次进入老年代(-XX:MaxTenuringThreshold 可通过该配置进行修改进入老年代的阈值,最大值为15[age 只有 4bit])。是否是偏向锁:1位的偏向锁标志位。锁标志位:2位锁标志位,4种标志位后面展示说明。锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
接下来分别介绍这三种锁的实现原理和步骤与上图结合思考。
偏向锁HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁的获取当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁的获得和撤销流程线程1--展示了偏向锁获取的过程。
线程2--展示了偏向锁撤销的过程。
轻量级锁轻量级锁介于 偏向锁与重量级锁之间,竞争的线程不会阻塞。
轻量级加锁线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量级解锁轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。
轻量级锁及膨胀流程图因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。
当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
重量级锁Synchronized 是通过对象内部的一个叫做 监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 “重量级锁”。
三种锁的比较分析了原理后,选择哪种锁就得看对应适用场景决定。
最后提一个 Synchronzied 避坑点(美团大佬分享):如果你的系统有很明确的 高低峰期,不建议使用 Synchronized,可以考虑使用 ReentrantLock。原因是 上面提到过 Synchronized 锁的膨胀是不可逆的,导致一旦经历了高峰期后就一直是重量级锁,性能也会由此一直达到一个瓶颈上不去了。