Java中的synchronized和锁

张开发
2026/6/7 17:58:47 15 分钟阅读
Java中的synchronized和锁
前几天面试时被问到了Java中的自旋锁、轻量锁等我只略有印象似乎在哪看见过但是说不上来面试结束后就开始搜索现在的感觉就是——以我工作10余年的经验来看是否知道这些这么底层的东西对于实际工作来说毫无影响但是现在就是这么内卷就像一个每天开着三轮车送快递的快递员去面试要求快递员明白发动机的原理并会修发动机——虽然实际工作中发动机坏了要去找专业的修车员。无奈还是学习下吧。先讲一些更加靠近底层的机制和名词。自旋锁、自适应自旋锁不管是采用synchronized还是锁只要存在多线程对共享数据的竞争和同步就少不了线程的切换而切换是需要开销的比如线程挂起并释放CPU等待其他线程执行、线程拿到锁继续运行而实际应用中经常会出现线程切换开销比数据处理更耗时的情况例如线程切换耗费了10ms的时间但拿到锁后处理数据实际只用了2ms为了优化这种场景虚拟机的开发团队想到了一种优化措施即等待锁的线程不再挂起也不释放CPU而是让线程空跑执行一个忙循环---循环检查锁的状态术语叫自旋。假设执行了3ms直到另一个持有锁的线程释放了锁该线程再中断空跑拿到锁去执行正常逻辑这样原本需要12ms的操作现在就只需要325ms了。这就是自旋锁技术。默认是忙循环10次超过了限定次数还是会转换为传统的锁方式执行。自适应自旋锁简单来说就是更加智能的自旋锁它基于以往经验例如上一次在同一个锁上自旋了多久来决定这次自旋的次数可能增加自旋次数也可能取消自旋。这样程序跑得越多程序预测得就会越准。注意自旋锁包括下面要讲的锁消除、锁粗化都是程序运行时由系统自动完成的并非由程序员通过Java代码实现。锁消除有些场景下虽然我们出于安全的考虑做了同步措施但是实际执行时如果虚拟机发现根本不可能存在竞争的情况例如我们调用别人包括Java的API写的同步方法但实际始终只有一条线程在访问共享数据此时即时编译器就会把这个锁或叫同步逻辑消除掉以提高运行效率。下面就是一个例子public String concatString(String s1,String s2,String s3){ StringBuffer sb new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); }大家看StringBuffer的源码如下public synchronized StringBuffer append(String str) { toStringCache null; super.append(str); return this; }锁粗化我们在编写代码时通常会尽可能使同步块的范围最小以避免其他线程不必要的等待但是如果一系列的操作都是对同一个对象反复的加锁和解锁甚至加锁操作是发生在一个循环体中就会导致不必要的性能损耗。上面 concatString(s1,s2,s3) 就是一个例子三次调用sb.append(实际每次调用都是一次加锁、解锁的过程。此时虚拟机会将锁的范围扩大变为如下伪代码的形式public String concatString(String s1,String s2,String s3){ StringBuffer sb new StringBuffer(); //加锁 sb.append(s1);//即时编译器删掉同步逻辑 sb.append(s2);//即时编译器删掉同步逻辑 sb.append(s3);//即时编译器删掉同步逻辑 //解锁 return sb.toString(); }轻量级锁和重量级锁轻量级是相对于传统的锁即重量级锁而言的。重量级锁是基于操作系统的互斥量Mutex实现的依赖于底层操作系统的线程调度需在用户态和内核态之间切换。轻量级锁是为了在没有多线程竞争或竞争不激烈的情况下减少传统重量级锁的性能消耗而在JDK 1.6设计和引入的主要通过CASCompare-And-Swap操作实现。CAS操作是一种无锁的原子操作算法由CPU的硬件指令保证原子性。在JDK 1.6之前synchronized也是采用操作系统互斥量实现的即也属于重量锁的范畴JDK 1.6开始引入了偏向锁、轻量级锁等优化措施性能得到了大幅提升。偏向锁Java中的每个对象都有一个内置锁。这个锁并不是一个Lock的实例对象说它是一种机制更合适。它的基础就是对象的头中一块叫Mark Word的区域简单来说就是在Mark Word中记录一些同步所需的数据用以标识目前锁或同步的情况。为便于讲解 先贴出一段代码public class Wind { private Object synObj new Object(); public static synchronized void play1(){ // ... } public synchronized void play2(){ //... } public void play3(){ synchronized (synObj){ //... } } }同步对象就是被当做锁的对象例如上面代码中的synObj和Wind对象。当一条线程进入同步代码块但实际上又没有其他线程跟它竞争的时候同步对象就会使用CAS操作一种由CPU硬件指令来保证原子性的原子操作在自己的Mark Word区记录下当前这条线程的ID意思就是——现在我全听您的调遣只为您一个人服务。这样当这条线程再次走到这块同步代码块的时候就会省略掉不必要的同步措施直接允许该条线程进入同步代码区。就像同步对象承认这条线程是免检产品一样---偏袒这条线程。注意偏向锁的生效场景是 虽然有同步逻辑但是只有一条线程进入同步逻辑没有其他线程来竞争。换句话说适用于单线程环境且锁对象的锁总是被同一个线程多次获取的场景。当有其他线程来竞争锁时偏向锁就会被打破进而转为轻量级锁或重量级锁。当一个线程释放偏向锁时JVM并不会立即清除对象头中的偏向线程ID而是将其设置为一个特殊值表示该锁已经被释放。这样当其他线程尝试获取这个锁时JVM会检测到这个特殊值从而知道该锁已经被释放可以重新进行偏向锁的获取操作。轻量级锁轻量级锁是为了减少获得锁和释放锁所带来的性能消耗而引入的。在锁竞争不激烈的情况下轻量级锁可以提高程序的性能。它的实现主要依赖于自旋和CAS操作。在代码进入同步块的时候如果同步对象锁状态为无锁状态锁标志位为“01”状态是否为偏向锁为“0”都属于Mark Word区虚拟机首先将在当前线程的栈帧中建立一个名为锁记录Lock Record的空间用于存储锁对象目前的Mark Word的拷贝官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如下图所示此时还未发生下方所说的CAS操作然后虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了那么这个线程就拥有了该对象的锁并且对象Mark Word的锁标志位将转变为“00”即表示此对象出于轻量级锁定状态这时候线程堆栈与对象头的状态如下图所示如果这个更新操作失败了虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧如果是就说明当前线程已经拥有了这个对象的锁那就可以直接进入同步块继续执行。否则说明多个线程竞争锁轻量级锁就要膨胀为重量级锁锁标志的状态值变为“10”Mark Word中存储的就是指向重量级锁互斥量的指针后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁自旋就是为了不让线程阻塞而采用循环去获取锁的过程。轻量级锁适用于多线程交替执行同步块的场景即线程间不存在锁竞争或竞争很少的场景。当线程尝试获取一个被其他线程持有的轻量级锁时它会进入自旋状态尝试通过CAS操作获取锁。如果自旋等待超过预定的次数仍然没有成功获得锁那么该线程将会被挂起转换为重量级锁。在Java 6之后对于非偏向锁的同步块在第一次被访问时也会尝试使用轻量级锁。至此再回头捋一下就会发现有一条锁升级的策略无锁 - 偏向锁 - 轻量级锁 - 重量级锁。下面就来描述一下这条升级策略的典型顺序一条线程A进入同步块此时没有其他线程跟它竞争所以它通过CAS操作强调CAS操作具有原子性不会出现脏数据把自己的线程ID写进了锁的Mark Word从而持有了这个锁此时这个 锁就是偏向锁以后每次线程A进入同步块时都不必再通过CAS操作了可以直接进入同步代码块再后来又来了一条线程B要跟A线程争夺这个锁系统就会等线程A到达安全点线程并不是随处都可暂停的此时会有两种情况1是恰好线程A已经退出了同步代码块此时锁的Mark Word就会恢复为无锁状态偏向锁被撤销线程B可以通过CAS操作将 Mark Word 设置为自己的锁记录指针进入轻量级锁状态如果CAS操作失败了说明此时有其他线程在跟B竞争进入重量级锁状态2是线程A仍在同步代码块内此时偏向锁就会先撤销随后膨胀为轻量级锁线程A在它的栈帧中创建Lock Record - Mark Word指向线程A栈帧线程B通过CAS去竞争这个锁即先自旋仍持有CPU没有阻塞一下如果还没有抢到锁此时的轻量级锁就会膨胀为重量级锁线程B挂起释放CPU线程阻塞。偏向锁在只有一条线程进入同步块的情况下避免了使用重量级锁的开销轻量级锁利用自旋会在一定概率上拿到锁的特点通过CAS操作也避免了使用重量级锁的开销但是无论偏向锁还是轻量级锁它们在遇到比自己适用场景更复杂的场景的时候都会升级锁这也同样带来了花销如果最后都升级到了重量级锁那肯定是比直接采用重量级锁更加耗费资源和时间的但根据现实中的数据统计来看大部分的同步场景偏向锁和轻量级锁就足够应对了所以从总体上来看它们还是提高了程序性能的。Synchronized和锁Lock的比较先说锁也就是通常用到的Lock、显式锁比如可重入锁ReentrantLock有点编程经验的人应该都知道锁的使用方法这里不再多述。关于synchronized先贴出如下代码和上面的代码一样public class Wind { private Object synObj new Object(); public static synchronized void play1(){ // ... } public synchronized void play2(){ //... } public void play3(){ synchronized (synObj){ //... } } }该关键字既可用于方法也可用于对象它的底层实现原理其实也是锁。例如它的方法 play2()其实就是相当于public void play2(){ synchronized (this){ //... } }也就是Wind类的实例对象充作了一个锁而play1()则相当于如下public static void play1(){ synchronized (Wind.class){ //... } }可能有人要疑惑了Wind.class是什么在Java中每个类都有一个且仅有一个与之对应的.class对象这是Java的类加载机制的一部分在虚拟机加载该类时由虚拟机创建。这个.class对象代表了类的元数据包含了类的结构信息例如类的字段、方法、构造函数等。这些信息在Java虚拟机JVM中是必需的以便进行诸如方法调用、类型检查等操作。play3()就是特意指定一个同步对象。维度synchronizedLock以ReentrantLock为例实现层面关键字由 JVM 底层实现基于 Monitor 机制接口 / 类由 Java 代码实现基于 AQS 框架锁获取方式自动获取与释放出作用域或异常时自动释放需手动调用lock()获取unlock()释放锁释放保障无需手动释放避免死锁JVM 自动处理必须在finally块中释放否则可能死锁锁的公平性非公平锁默认无法指定公平性可通过构造函数指定公平锁fairtrue锁的可中断性不可中断阻塞时无法响应中断支持lockInterruptibly()响应中断尝试获取锁无法主动尝试获取只能阻塞等待支持tryLock()立即返回和tryLock(timeout, unit)超时返回锁状态查询无法查询锁是否被获取可通过isLocked()、isHeldByCurrentThread()等方法查询绑定条件变量内置wait()/notify()机制可通过newCondition()创建多个条件变量锁升级机制支持偏向锁、轻量级锁、重量级锁的自动升级无锁升级概念始终为重量级锁基于 AQS性能优化JDK 1.6 后引入多种优化如自旋锁基于 CAS 和 AQS 实现性能稳定AQS抽象队列同步器是 Java 并发包java.util.concurrent的核心基础框架它通过一个 int 类型的状态变量state和双向链表等待队列实现了锁和同步器的基础功能。许多并发工具如ReentrantLock、CountDownLatch、Semaphore都是基于 AQS 构建的。synchronized和Lock的实现原理重量级锁的实现依赖于底层的 Monitor 机制。Java中每个对象都有一个与之关联的 Monitor偏向锁、轻量级锁阶段都不会激活Monitor只使用Mark Word就够了当锁对象即同步对象转为重量级锁后JVM就会给锁对象实例化出一个关联的Monitor当线程尝试获取重量级锁时会被放入 Monitor 的入口等待队列中。如果获取锁失败线程会被阻塞并放入等待队列直到持有锁的线程释放锁。Lock的实现方案是 CAS操作AQS 为主、重量级锁为辅的方案Lock会通过大量CAS和自旋/自适应自旋的方式在用户态完成同步尽量避免进入内核态只有在竞争很激烈时CAS总是失败、自旋总是失败或锁长时间被持有才会转入内核态此时性能开销将大幅增加进入广义上的重量级锁状态。在JDK 1.6之前synchronized的实现方案是 Monitor重量级锁 所以它的性能通常就比Lock要差从JDK 1.6开始实现方案变为 偏向锁 - 轻量级锁 - 重量级锁即Monitor重量级锁的动态升级方案性能得到大幅提升和Lock一样都是由重量级锁来兜底如今synchronized和Lock性能已相差无几应用中到底采用哪个要根据具体场景来看简单场景优先使用synchronized因其代码简洁、自动管理锁释放且 JDK 优化后性能足够好尤其在无竞争或低竞争的场景更推荐使用。复杂场景选择Lock利用其灵活的 API 实现公平性、可中断性、多条件变量等需求但需注意手动释放锁的正确性。为什么重量级锁成为synchronized和Lock的终极方案自旋会让线程空跑CPU仍被线程占用如果长时间自旋就是在长时间让CPU空跑而重量级锁方案可以挂起线程需要进入内核态释放CPU资源。

更多文章