Synchronized

📕我是廖志伟,一名Java开发工程师、《Java项目实战——深入理解大型互联网企业通用技术》(基础篇)、(进阶篇)、(架构篇)清华大学出版社签约作家、Java领域优质创作者、优快云博客专家、阿里云专家博主、51CTO专家博主、产品软文专业写手、技术文章评审老师、技术类问卷调查设计师、幕后大佬社区创始人、开源项目贡献者。

📘拥有多年一线研发和团队管理经验,研究过主流框架的底层源码(Spring、SpringBoot、SpringMVC、SpringCloud、Mybatis、Dubbo、Zookeeper),消息中间件底层架构原理(RabbitMQ、RocketMQ、Kafka)、Redis缓存、MySQL关系型数据库、 ElasticSearch全文搜索、MongoDB非关系型数据库、Apache ShardingSphere分库分表读写分离、设计模式、领域驱动DDD、Kubernetes容器编排等。不定期分享高并发、高可用、高性能、微服务、分布式、海量数据、性能调优、云原生、项目管理、产品思维、技术选型、架构设计、求职面试、副业思维、个人成长等内容。

Java程序员廖志伟

🌾阅读前,快速浏览目录和章节概览可帮助了解文章结构、内容和作者的重点。了解自己希望从中获得什么样的知识或经验是非常重要的。建议在阅读时做笔记、思考问题、自我提问,以加深理解和吸收知识。阅读结束后,反思和总结所学内容,并尝试应用到现实中,有助于深化理解和应用知识。与朋友或同事分享所读内容,讨论细节并获得反馈,也有助于加深对知识的理解和吸收。💡在这个美好的时刻,笔者不再啰嗦废话,现在毫不拖延地进入文章所要讨论的主题。接下来,我将为大家呈现正文内容。

优快云

文章目录

    • 🍊 Synchronized
      • 🎉 定义
      • 🎉 应用场景
      • 🎉 对象加锁实现原理
        • 📝 JDK6以前
          • 📝 实现步骤
        • 📝 JDK6版本及以后
          • 🔥 对象从无锁到偏向锁转化的过程
          • 🔥 轻量级锁升级
          • 🔥 自旋锁
          • 🔥 重量级锁
          • 🔥 引入偏向锁的好处
          • 🔥 引入轻量级的好处


🍊 Synchronized

Java程序员廖志伟

🎉 定义

Synchronized是Java语言的关键字,它保证同一时刻被Synchronized修饰的代码最多只有1个线程执行。

Java程序员廖志伟

🎉 应用场景

synchronized如果加在方法上/对象上,那么,它作用的对象是非静态的,它取得的锁是对象锁;
synchronized如果作用的对象是一个静态方法或一个类,它取到的锁是类锁,这个类所有的对象用的是同一把锁。
每个对象只有一个锁,谁拿到这个锁,谁就可以运行它所控制的那段代码。

Java程序员廖志伟

🎉 对象加锁实现原理

在Java的设计中,每一个Java对象就带了一把看不见的锁,可以叫做内部锁或者Monitor锁,Synchronized在JVM里的实现是基于进入和退出Monitor对象来实现方法同步和代码块同步的。Monitor可以把它理解为一个同步工具,所有的Java对象是天生的Monitor,Monitor监视器对象就是存在于每个Java对象的对象头MarkWord里面,也就是存储指针的指向,Synchronized锁便是通过这种方式获取锁的。

Java程序员廖志伟

📝 JDK6以前

Synchronized加锁是通过对象内部的监视器锁来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的,操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要比较长的时间。

📝 实现步骤

第一步,当有二个线程A、线程B都要开始给变量+1,要进行操作的时候,发现方法上加了Synchronized锁,这时线程调度到A线程执行,A线程就抢先拿到了锁,当前已经获取到锁资源的线程被称为Owner,将MonitorObject中的_owner设置成A线程。

第二步,在锁升级为重量级锁时,Mark Word 会被替换为指向 Monitor 的指针。(重量级锁的标志位是 10,且对象的 Mark Word 会指向 Monitor 对象的地址)

第三步,将B线程阻塞,放到_cxq(ContentionList)队列(这是一个单向链表,通过 CAS 操作实现无锁插入)中。因为JVM每次从Waiting Queue的尾部取出一个线程放到OnDeck中,作为候选者,但是如果并发比较高,WaitingQueue会被大量线程执行CAS操作,为了降低对尾部元素的竞争,将WaitingQueue拆分成_cxq(ContentionList)和EntryList二个队列,所有请求锁的线程首先尝试自旋获取锁,如果获取不到,在 HotSpot 的实现中,新竞争线程首先进入 _cxq(ContentionList),而 _EntryList 用于存放候选线程。JVM 并不保证线程从 _cxq 或 _EntryList 中唤醒的顺序。_cxq(ContentionList)中那些有资格成为候选资源的线程被移动到EntryList中。

第四步,作为Owner的A线程执行过程中,调用 wait() 会释放锁,线程 A 进入 _WaitSet,等待被 notify() 或 notifyAll() 唤醒。唤醒后,线程会从 _WaitSet 转移到 _EntryList 或 _cxq 重新竞争锁。_cxq(ContentionList)、EntryList、WaitSet中的线程都处于阻塞状态,线程的阻塞确实依赖操作系统(如 Linux 的 pthread_mutex_lock 和 pthread_cond_wait,pthread_cond_wait 用于条件变量,与 notify()/notifyAll() 配合使用)。 ContentionList 和 EntryList 的管理是由 JVM 在用户态完成的,操作系统不感知这些队列。


Monitor 的本质是一个“排队机制”‌

想象一个房间(临界资源)只有一个门,门上挂着一个监视器(Monitor),负责管理线程的进出和排队。这个监视器内部维护了 ‌3 个关键队列‌:

  • CXQ(ContentionList)‌:新来的线程先在这里临时排队(单向链表,通过 CAS 无锁插入)。
  • EntryList‌:从 CXQ 中选出“候选线程”,进入这里等待获取锁。
  • WaitSet‌:调用 wait() 的线程在这里等待被唤醒。

简化后的四步流程‌

  1. 线程 A 抢到锁,成为 Owner‌
    动作‌:线程 A 发现方法上有 synchronized,尝试获取锁。
    底层‌:JVM 将对象头的 ‌Mark Word‌ 替换为指向 Monitor 的指针(锁标志位 10,表示重量级锁)。Monitor 的 _owner 字段指向线程 A。
    结果‌:线程 A 成为 Owner,进入房间执行代码。
  2. 线程 B 竞争失败,进入 CXQ 队列‌
    动作‌:线程 B 尝试获取锁,发现已被占用。
    底层‌:线程 B 被 JVM 放入 ‌CXQ 队列‌(单向链表,通过 CAS 操作插入尾部)。操作系统将线程 B ‌阻塞‌(通过 pthread_mutex_lock 等内核函数)。
    结果‌:线程 B 在 CXQ 中等待,不直接参与锁竞争。
  3. 锁释放时的队列调度‌
    动作‌:线程 A 执行完代码,释放锁。
    底层‌:JVM 将 Monitor 的 _owner 置空。
    策略选择‌:默认情况下,JVM 会先将 CXQ 中的所有线程‌一次性移动到 EntryList‌(避免尾部竞争)。从 EntryList 中选一个线程(如头部的线程 B)唤醒,成为新的 Owner。
    结果‌:线程 B 被操作系统唤醒,开始执行。
  4. 线程调用 wait() 的流程‌
    动作‌:线程 A 在代码中调用 wait()。
    底层‌:线程 A 释放锁,Monitor 的 _owner 置空。线程 A 被移动到 ‌WaitSet 队列‌,进入等待状态。其他线程(如线程 B)可以竞争锁。
    唤醒‌:当其他线程调用 notify()/notifyAll():线程 A 从 WaitSet 移动到 ‌EntryList 或 CXQ‌(具体策略由 JVM 决定)。线程 A 重新参与锁竞争。

为什么需要这些队列?‌

  • CXQ(ContentionList)‌:解决高并发时的“尾部竞争”问题。新线程直接插入 CXQ,避免频繁 CAS 操作 EntryList 的尾部。
  • EntryList‌:候选线程池,JVM 可以更公平地选择下一个 Owner(比如 FIFO 策略)。
  • WaitSet‌:将等待条件的线程与普通竞争线程隔离,确保 wait()/notify() 的正确性。

操作系统的角色‌

  • 线程阻塞与唤醒‌:由 pthread_mutex_lock 和 pthread_cond_wait 实现(用户态到内核态的切换)。
  • 不感知队列‌:CXQ、EntryList、WaitSet 是 JVM 在用户态维护的队列,操作系统只负责线程的阻塞和唤醒。

如何记忆?画一张图!‌

+-------------------+
|      Monitor      |
|-------------------|
|  _owner (ThreadA) |
|-------------------|
|      CXQ          | <--- 新线程插入这里(CAS 无锁)
|-------------------|
|    EntryList       | <--- 候选线程池(可能从 CXQ 转移)
|-------------------|
|     WaitSet        | <--- 调用 wait() 的线程
+-------------------+

总结给初学者‌

  • 锁的本质是排队‌:Monitor 通过三个队列管理线程的竞争和等待。
  • 重量级锁成本高‌:线程阻塞和唤醒需要操作系统介入,避免滥用 synchronized。
  • Wait/Notify 的代价‌:调用 wait() 会导致线程切换两次(用户态→内核态→用户态)。

为什么不需要死记硬背?‌

  • JDK6 之后引入了偏向锁、轻量级锁,这些复杂队列仅在 ‌重量级锁‌ 时触发。
  • 实际开发中只需理解:‌synchronized 是悲观锁,通过 Monitor 管理线程排队‌,底层细节由 JVM 优化。
📝 JDK6版本及以后

Sun程序员发现大部分程序大多数时间都不会发生多个线程同时访问竞态资源的情况,大多数对象的加锁和解锁都是在特定的线程中完成,出现线程竞争锁的情况概率比较低,比例非常高,所以引入了偏向锁和轻量级锁。

64位JVM下的对象结构描述:
在这里插入图片描述对象头的最后两位存储了锁的标志位
没加锁状态,锁标志位01,是否偏向是0,对象头里存储的是对象本身的哈希码。
偏向锁状态,锁标志位01,是否偏向是1,存储的是当前占用对象的线程ID。
轻量级锁状态,锁标志位00,存储指向线程栈中锁记录的指针。
重量级锁状态,锁标志位10,存储的就是重量级锁的指针了。

🔥 对象从无锁到偏向锁转化的过程

第一步,检测MarkWord是否为可偏向状态,是偏向锁是1,锁标识位是01。
第二步,如果是可偏向状态,测试线程ID是不是当前线程ID。如果是,就直接执行同步代码块。
第三步,如果测试线程ID不是当前线程ID,且当前对象的MarkWord标识为可偏向状态,这说明该对象已经被其他线程获取过锁,不能偏向于当前线程,就需要通过CAS操作竞争锁,竞争成功,就把MarkWord的线程ID替换为当前线程ID。
第四步,假设线程B来CAS失败了,需要将当前对象的偏向锁撤销,然后再进行锁竞争,这个时候JVM后台线程会启动偏向锁撤销过程,偏向锁撤销触发通常有二种情况:当在对象头的Epoch字段计数到一定次数时会触发偏向锁撤销或者有多个线程尝试竞争该对象的锁,都失败了,也会触发偏向锁的撤销。

多个线程尝试竞争该对象的锁,但只有其中一个线程竞争成功,而其他线程都失败了。这个时候JVM会把偏向锁撤销,让竞争成功的线程重新获取锁,而不是所有线程都失败才触发偏向锁撤销。

撤销偏向锁的过程会挂起所有持有偏向锁的线程(例如线程A),并清除它们的MarkWord中的偏向锁信息(标记位、偏向线程ID、Epoch次数)。然后遍历偏向锁的对象所在的线程的栈,查找锁对象的锁记录,将那些已经被访问过的记录清除,以表明这些线程都不再持有该锁。
第五步,完成偏向锁撤销后,持有偏向锁的线程不会被挂起,而是会继续执行同步代码块。线程A尝试获取该锁,如果获取成功,就可以继续执行同步代码块了,当线程A尝试获取该锁失败时,视情况继续进行自旋或者进行阻塞等待,导致进一步升级为轻量级锁或重量级锁。

安全点是jvm为了保证在垃圾回收的过程中引用关系不会发生变化,设置的安全状态,在这个状态上会暂停所有线程工作。一般有循环的末尾,方法临返回前,调用方法的call指令后,可能抛异常的位置,这些位置都可以算是安全点。


偏向锁是“单线程快速通道”‌

想象一个房间(对象)有一把智能锁(Mark Word),初始状态是 ‌“无人租用”‌(无锁)。当第一个租客(线程)进入时,锁会记住他的身份证号(线程 ID),后续只要是他来,直接开门(无需额外操作)。这就是偏向锁的优化思想:‌消除无竞争场景下的同步成本‌。

五步流程(类比现实场景)‌

  1. 检查锁状态:是否是“可偏向”的门锁?‌
    底层动作‌:对象头的 Mark Word 检查两个标志位:
    锁标志位(最后 2 位):‌01‌(可偏向状态)。
    偏向锁标志位(倒数第 3 位):‌1‌(允许偏向)。
    类比‌:门锁显示“可登记租客”状态,允许绑定第一个租客。

  2. 如果是可偏向状态,检查“租客身份证”‌
    场景一:当前线程是已登记的租客‌(线程 ID 匹配):
    底层动作‌:直接进入同步代码块,无需任何锁操作。
    类比‌:租客 A 刷脸进门,门锁自动识别他的身份。
    场景二:当前线程未登记‌(线程 ID 不匹配):
    底层动作‌:尝试用 ‌CAS 操作‌将自己的线程 ID 写入 Mark Word。
    类比‌:新租客 B 尝试在门锁上登记自己的身份证。

  3. CAS 竞争:谁能成为新房东?‌
    成功‌:CAS 将 Mark Word 的线程 ID 更新为当前线程。
    结果‌:偏向锁绑定新线程,线程 B 成为 Owner,直接执行代码。
    类比‌:租客 B 成功登记,门锁记住他的身份,后续他来直接开门。
    失败‌:其他线程已抢先登记(出现竞争)。
    结果‌:触发偏向锁撤销‌(Revoke Bias)。
    类比‌:发现门锁已被其他租客登记,需要物业(JVM)介入处理。

  4. 偏向锁撤销:物业的强制清退‌
    触发条件‌(满足其一)
    1.多个线程竞争同一把锁(至少两个线程尝试 CAS 失败)。
    2.偏向锁的“租期计数器”(Epoch)超过阈值(防止长期偏向)。

底层动作‌:暂停所有相关线程‌(在安全点 Stop The World,确保内存状态一致)。
检查对象是否仍被偏向‌:

  • 若原线程(如租客 A)已不再需要锁,直接撤销偏向。
  • 若原线程仍在同步代码块中,升级为 ‌轻量级锁‌(通过栈锁记录竞争)。
  • 清除 Mark Word 的偏向信息‌(线程 ID、Epoch 等),回到无锁或轻量级锁状态。
    类比‌:物业发现多个租客争抢,强制清退原登记,改为“临时钥匙”模式(轻量级锁)。
  1. 撤销后的锁升级‌

场景一:无竞争‌(如原线程已退出同步块):

  • 对象回到无锁状态,重新尝试偏向新线程。

场景二:有竞争‌(多个线程活跃):

  • 升级为 ‌轻量级锁‌:线程通过 CAS 在各自栈帧中创建 Lock Record(锁记录)。
  • 若 CAS 自旋失败,进一步升级为 ‌重量级锁‌(操作系统的 Mutex Lock)。

为什么偏向锁在 Java 15+ 被废弃?‌

  • 现实问题‌:现代应用大多是高并发场景,偏向锁的撤销成本(STW)反而成为负担。
  • 替代方案‌:默认优先使用轻量级锁(通过 -XX:-UseBiasedLocking 关闭偏向锁)。

关键记忆点‌

  • 偏向锁的核心目的‌:消除单线程重复进入同步块的代价。
  • 撤销的代价‌:需要进入安全点(STW),‌高并发场景下慎用偏向锁‌。
  • 安全点(Safe Point)‌:JVM 选择线程暂停的位置(如方法调用、循环末尾),确保内存状态一致。

总结给开发者‌

  • 偏向锁适用场景‌:明确单线程重复访问的代码块(如历史遗留的 StringBuffer)。
  • 监控工具‌:通过 jol-core 打印对象头,观察锁状态变化:
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
🔥 轻量级锁升级

轻量级锁升级过程是,在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的MarkWord的拷贝,拷贝无锁状态对象头中的MarkWord复制到锁记录中。

  • 这么做是因为在申请对象锁时,需要以该值作为CAS的比较条件。
  • 同时在升级到重量级锁的时候,能通过这个比较,判定是否在持有锁的过程中,这个锁被其他线程申请过,如果被其他线程申请了,在释放锁的时候要唤醒被挂起的线程。
  • 无锁的markword中可能存有hashCode,锁撤销之后必须恢复,这个markword要用于锁撤销后的还原。如果轻量级锁解锁为无锁状态,直接将拷贝的markword CAS修改到锁对象的markword里面就可以了。

拷贝成功后,虚拟机将使用CAS操作把对象中对象头MarkWord替换为指向锁记录的指针,然后把锁记录空间里的owner指针指向加锁的对象。

这个过程的目的是为了实现轻量级锁的互斥访问。CAS操作的作用是将对象头MarkWord指针指向锁记录空间,从而表示当前线程持有这个对象的锁。锁记录空间里的owner指针指向加锁的对象,是为了在释放锁的时候,能够知道哪个对象需要进行通知,从而唤醒被挂起等待锁的线程。同时,轻量级锁的升级过程也可以通过锁记录空间来判断是否在持有锁的过程中,这个锁被其他线程申请过,如果被其他线程申请了,在释放锁的时候要唤醒被挂起的线程,从而保证多个线程对同一个对象的访问是互斥的。

如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象MarkWord的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。

如果这个更新操作失败了,虚拟机首先会检查对象MarkWord中的Lock Word是否指向当前线程的栈帧。

Lock Word是一个对象头的一部分,用于实现Java对象的锁定。当一个线程获取了对象的锁时,Lock Word就会被设置为指向该线程的栈帧,表示这个对象被该线程持有了锁。其他线程如果要获取该对象的锁,会检查Lock Word中的锁标志是否为0,如果为0则表示该对象没有被锁定,可以获取锁。如果锁标志为1,则表示对象已经被锁定,其他线程必须等待锁的释放。

如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。如果不是说明多个线程竞争锁,进入自旋,若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,MarkWord中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。

当锁升级为轻量级锁之后,如果依然有新线程过来竞争锁,首先新线程会自旋尝试获取锁,尝试到一定次数(默认10次)依然没有拿到,锁就会升级成重量级锁。一般来说,同步代码块内的代码应该很快就执行结束,这时候线程B自旋一段时间是很容易拿到锁的,但是如果不巧,没拿到,自旋其实就是死循环,很耗CPU的,因此就直接转成重量级锁咯,这样就不用了线程一直自旋了。


轻量级锁是“临时保管钥匙”的机制‌

想象一个公共储物柜(对象),它的锁有两种模式:

  • 轻量级锁模式‌:用户(线程)自己保管钥匙(栈帧中的锁记录),通过快速贴标签(CAS)声明使用权。
  • 重量级锁模式‌:钥匙交给管理员(操作系统),严格排队(线程阻塞)。

轻量级锁的目标是:‌在低并发场景下,用用户态操作(CAS)代替内核态阻塞,提升性能‌。

五步流程(类比现实场景)‌

  1. 创建钥匙保管单(锁记录)‌
    底层动作‌:线程在自己的栈帧中创建 ‌Lock Record‌(钥匙保管单)。将对象头的 Mark Word(如柜门上的标签)‌拷贝到保管单‌。
    目的‌:后续通过对比保管单中的标签,判断是否有人动过柜子。解锁时若发现标签未变,可直接还原状态(避免锁升级)。
  2. 尝试贴标签声明使用权(CAS 替换 Mark Word)‌
    底层动作‌:用 CAS 操作将对象头的 Mark Word ‌替换为指向保管单的指针‌(锁标志位变为 00)。保管单中的 owner 字段指向柜子对象。
    类比‌:用户在柜门上贴一个标签:“此柜由张三保管,钥匙在张三手中”。其他用户通过检查标签判断柜子是否被占用。
  3. CAS 成功:获得使用权‌
    结果‌:线程进入同步代码块执行。无内核态切换‌,全程在用户态完成(性能高)。
    关键点‌:轻量级锁的 Mark Word 指向线程栈中的 Lock Record。锁标志位 00 表示轻量级锁定状态。
  4. CAS 失败:两种可能性‌
    场景一:自己已持有锁(可重入)‌
    检查‌:对象头的指针指向当前线程的栈帧。
    结果‌:直接进入同步块(如递归调用同步方法)。
    场景二:其他线程持有锁(竞争发生)‌
    动作‌:线程开始 ‌自旋(空转等待)‌,默认尝试 10 次(可通过 -XX:PreBlockSpin 调整)。
    成功‌:自旋期间原线程释放锁,CAS 成功获得锁。
    失败‌:自旋超限,触发 ‌锁膨胀(升级为重量级锁)‌。
  5. 锁膨胀:交给管理员(操作系统)‌
    底层动作‌:创建 Monitor 对象(重量级锁结构)。对象头的 Mark Word ‌指向 Monitor‌,锁标志位变为 10。阻塞所有竞争线程‌(通过 pthread_mutex_lock 内核调用)。
    结果‌:后续锁操作由操作系统管理(公平排队,但性能下降)。原持有轻量级锁的线程释放时,需唤醒阻塞线程。

关键记忆点‌
锁记录(Lock Record)的作用‌:

  • 备份原始 Mark Word(用于还原)。
  • 存储锁的指向关系(实现轻量级互斥)。

自旋的代价与权衡‌:

  • 优势:避免线程切换(用户态操作)。
  • 风险:CPU 空转消耗资源(适用于短同步块)。

锁膨胀的触发条件‌:

  • CAS 失败 + 自旋超限。
  • 等待线程数超过内核管理能力。

为什么轻量级锁比重量级锁快?‌
轻量级锁‌:

  • 用户态 CAS(约 10 纳秒)。
  • 无线程切换。
    重量级锁‌:
  • 内核态阻塞(约 1 微秒,千倍差距)。
  • 上下文切换开销。

实际开发中的注意事项‌

  • 避免长同步块‌:
// 错误示例:同步块内包含耗时操作
synchronized(obj) {
    doHeavyWork(); // 容易导致锁膨胀
}
  • 监控锁状态‌:使用 jol-core 工具打印对象头:
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
  • 调整自旋策略‌(谨慎使用):
-XX:PreBlockSpin=20 # 调整自旋次数(默认 10)

总结给开发者‌

  • 轻量级锁的本质‌:用 CAS + 自旋实现用户态互斥。
  • 适用场景‌:低并发、短临界区(如计数器累加)。
  • 升级信号‌:CAS 失败 + 自旋超限 → 交给操作系统管理。

通过这种“钥匙保管”的类比,你可以绕过底层二进制操作和指针细节,直击轻量级锁的设计哲学:‌在无竞争或低竞争时,用最小的代价实现线程安全‌。

🔥 自旋锁

自旋锁不是一种锁状态,而是一种策略。线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。

引入自旋锁,当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。

自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好。

自旋的次数必须要有一个限度,如果自旋超过了定义的限度仍然没有获取到锁,就应该被挂起。但是这个限度不能固定,程序锁的状况是不可预估的,所以JDK1.6引入自适应的自旋锁,线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少,甚至省略掉自旋过程,以免浪费处理器资源。

通过–XX:+UseSpinning参数来开启自旋(JDK1.6之前默认关闭自旋)。
通过–XX:PreBlockSpin修改自旋次数,默认值是10次。


自旋锁是“宁可空转也不躺平”的等待策略‌

想象公共厕所只有一个坑位(临界资源),当有人(线程)在使用时,其他人的两种策略:

阻塞(躺平)‌:去休息室睡觉(线程挂起),等管理员叫醒(内核调度),但醒来需要时间。
自旋(敲门询问)‌:每隔 5 秒敲一次门问“好了吗?”(循环检测),虽然费体力(CPU),但能第一时间抢占。

自旋锁的核心思想:‌用 CPU 空转的时间换取避免线程切换的开销‌。

关键知识点拆解‌

  1. 自旋 VS 阻塞的成本对比‌
    Java程序员廖志伟

  2. 自旋锁的黄金法则‌
    短时间原则‌:如果锁持有时间 < 线程切换时间,自旋才有意义。
    举例:若线程切换需要 1 微秒,自旋最多空转 1 微秒(如循环 1000 次)。

  3. 自适应自旋锁(JDK1.6+ 的智能策略)‌
    动态调整自旋次数‌:- 成功案例‌:如果上次自旋后成功拿到锁,下次允许自旋更久(信任机制)。- 失败教训‌:如果多次自旋失败,减少次数甚至直接阻塞(止损机制)。
    类比‌:像智能客服,根据历史对话调整等待时间:- 老客户常快速解决问题 → 允许等待更久。- 新客户问题复杂 → 快速转人工。

为什么需要自旋锁?‌

  • 线程切换是昂贵的‌:需要保存/恢复寄存器状态、更新内核数据结构。自旋锁将开销留在用户态,避免进入内核态。
  • 现代多核 CPU 的优势‌:一个核空转检测,其他核可正常运行,整体吞吐量更高。

开发者必须知道的注意事项‌

  • 临界区必须短小‌:
// 错误示例:自旋锁灾难(长时间占用)
while (locked) {} // 自旋等待
doHeavyWork();    // 耗时操作
  • 避免滥用自旋‌:高并发场景下,自旋会导致 CPU 飚高(用 top 命令观察 CPU 使用率)。
  • 监控工具‌:
    使用 perf 或 async-profiler 分析自旋热点。
    通过 -XX:+PrintAssembly 观察自旋的汇编指令(高阶玩法)。

JVM 参数与默认行为‌

  • 历史版本‌:
    JDK1.5 及之前:自旋默认关闭(-XX:+UseSpinning 手动开启)。
    JDK1.6+:‌自适应自旋默认开启‌(无需配置)。
  • 调整策略‌(谨慎修改):
-XX:PreBlockSpin=20      # 初始自旋次数(默认 10)
-XX:-UseSpinning         # 关闭自旋(不推荐)

总结给开发者‌

  • 自旋锁是策略,不是锁状态‌:它是轻量级锁竞争时的优化手段。
  • 自适应的智慧‌:JVM 根据历史成功率动态调整,无需手动干预。

性能双刃剑‌:

  • 用得好:减少 90% 的线程切换。
  • 用不好:CPU 空转导致性能雪崩。

在短等待场景下,用空间换时间‌。

🔥 重量级锁

当一个线程在等锁时会不停的自旋(底层就是一个while循环),当自旋的线程达到CPU核数的1/2时,就会升级为重量级锁。

将锁标志为置为10,将MarkWord中指针指向重量级的monitor,阻塞所有没有获取到锁的线程。

Synchronized是通过对象内部的监视器锁(Monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的MutexLock来实现的,操作系统实现线程之间的切换这就需要从用户态转换到核心态,状态之间的转换需要比较长的时间,这就是为什么Synchronized效率低的原因,这种依赖于操作系统MutexLock所实现的锁我们称之为“重量级锁”。

重量级锁的加锁-等待-撤销流程:
曾经获得过锁的线程,被唤醒后,优先得到锁。

举个例子,假设有A,B,C三个线程依次进入synchronized区,并且A已经膨胀成重量级锁。如果有一个线程 a 先进入 synchronized , 但是调用了 wait释放锁,这是线程 b 进入了 synchronized,b还在synchronized中执行,c线程又进来了。此时 a 在 wait_set ,b 不在任何队列,c 在 cxq_list ,假如 b 调用 notify唤醒线程,会把 a 插到 c 前面,也就是 b 退出synchronized的时候,会唤醒 a,a退出之后再唤醒 c。

重量级锁撤销之后是无锁状态,撤销锁之后会清除创建的monitor对象并修改markword,这个过程需要一段时间。Monitor对象是通过GC来清除的。GC清除掉monitor对象之后,就会撤销为无锁状态。


重量级锁是“严格叫号排队”的机制‌

想象银行(JVM)有一个 VIP 窗口(临界资源),当客户(线程)过多时,银行会启动叫号系统(Monitor),规则如下:

  • 严格排队‌:取号后等待叫号(线程阻塞)。
  • 专人管理‌:由大堂经理(操作系统)统一调度(Mutex Lock)。
  • 代价高昂‌:每次叫号需广播通知(用户态→内核态切换)。

重量级锁的设计目标:‌在高并发场景下,用严格排队避免 CPU 空转浪费‌。

五步流程(类比现实场景)‌

  1. 触发锁膨胀的条件‌
    场景‌:自旋线程数超过 CPU 核数的一半(如 4 核机器有 3 个线程自旋)。
    底层动作‌:锁标志位变为 10。Mark Word 指向 Monitor 对象(叫号系统)。
    类比‌:银行发现排队客户太多,启动叫号机并关闭自助服务。

  2. Monitor 的结构(叫号系统三队列)‌

每个重量级锁对应一个 Monitor 对象,包含三个核心队列:
Java程序员廖志伟

  1. 线程唤醒的优先级策略‌
    规则‌:曾经持有锁的线程(如调用 wait() 后)被唤醒时,优先获取锁。
    案例解析‌(A、B、C 三个线程):
    1.初始状态‌:
  • A 调用 wait() 释放锁,进入 WaitSet(喝茶)。
  • B 获得锁,正在执行业务(在柜台办理)。
  • C 新来竞争锁,进入 cxq(直接排队)。
    2.B 调用 notify()‌:将 A 从 WaitSet 移到 EntryList ‌头部‌(插队到 C 前面)。
    3.B 释放锁时‌:优先唤醒 EntryList 中的 A(而不是 cxq 的 C)。A 办理完后,再唤醒 cxq 中的 C。
    目的‌:减少线程饥饿,提高公平性。
  1. 重量级锁的性能缺陷‌
    根源‌:线程阻塞和唤醒依赖操作系统内核(大堂经理调度)。
    耗时对比‌:
    Java程序员廖志伟

  2. 锁的降级与销毁‌
    降级条件‌:当所有线程释放锁且无竞争时。
    底层动作‌:

  • GC 清理 Monitor 对象‌(银行下班回收叫号机)。
  • 将 Mark Word 恢复为无锁状态(拆除叫号系统)。
    关键点‌:降级过程依赖垃圾回收,‌不会立即发生‌。

实际开发中的注意事项‌

  • 1.避免过早膨胀‌:
// 错误示例:短操作使用重量级锁
synchronized(obj) {
    counter++; // 本可用 CAS 或轻量级锁
}
  • 2.谨慎使用 wait()/notify()‌:

必须配合 synchronized 使用。
推荐改用 java.util.concurrent 工具类(如 Condition)。

  • 3.监控锁状态‌:
# 查看线程阻塞情况
jstack <pid> | grep "BLOCKED"

关键记忆点‌

  • 1.Monitor 是锁的管理者‌:

包含 EntryList、WaitSet、cxq 三个队列。
通过 wait()/notify() 调度线程。

  • 2.锁膨胀的代价‌:

用户态→内核态切换(严格排队)。
适用于高并发长临界区,但应尽量避免。

  • 3.唤醒策略的优先级‌:

WaitSet 线程 > cxq 线程(减少饥饿)。

总结给开发者‌

  • 重量级锁的本质‌:用内核态严格排队解决高并发竞争。
  • 适用场景‌:长时间同步块(如数据库事务)或高并发争用。
    优化方向‌:
  • 缩短临界区代码。
  • 用 ReentrantLock 替代(可控制公平性)。
🔥 引入偏向锁的好处
  • 偏向锁的好处是并发度很低的情况下,同一个线程获取锁不需要内存拷贝的操作,免去了轻量级锁的在线程栈中建LockRecord,拷贝MarkDown的内容。

  • 免了重量级锁的底层操作系统用户态到内核态的切换,节省毫无意义的请求锁的时间。

  • 另外Hotspot也做了另一项优化,基于锁对象的epoch批量偏移和批量撤销偏移,这样大大降低了偏向锁的CAS和锁撤销带来的损耗。因为基于epoch批量撤销偏向锁和批量加偏向锁能大幅提升吞吐量,但是并发量特别大的时候性能就没有什么特别的提升了。

  • 偏向锁减少CAS操作,降低Cache一致性流量,CAS操作会延迟本地调用。

为什么这么说呢?这要从SMP(对称多处理器)架构说起,所有的CPU会共享一条系统总线BUS,靠此总线连接主内存,每个核都有自己的一级缓存,每个核相对于BUS对称分布。
举个例子,我电脑是六核的,假设一个核是Core1,一个核是Core2,这二个核可能会同时把主存中某个位置的值Load到自己的一级缓存中。当Core1在自己的L1Cache中修改这个位置的值时,会通过总线,使Core2中L1Cache对应的值“失效”,而Core2一旦发现自己L1Cache中的值失效,也就是所谓的Cache命中缺失,一旦发现失效就会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信叫做“Cache一致性流量”。如果Cache一致性流量过大,总线将成为瓶颈。而当Core1和Core2中的值再次一致时,称为“Cache一致性”,从这个层面来说,锁设计的终极目标便是减少Cache一致性流量。而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对象,当某个CoreCAS成功时必然会引起总线风暴,这就是所谓的本地延迟。

所以偏向锁比较适用于只有一个线程访问同步块场景。

🔥 引入轻量级的好处

对于绝大部分的锁,在整个同步周期内都是不存在竞争的。如果没有竞争,轻量级锁通过CAS操作成功,避免了使用互斥量的开销。

对于竞争的线程不会阻塞,提高了程序的响应速度。

如果确实存在锁竞争,始终得不到锁竞争的线程使用自旋会消耗CPU,除了互斥量的本身开销外,还额外发生了CAS操作的开销,轻量级锁反而会比传统的重量级锁更慢。

所以轻量级追求的是响应时间,同步块执行速度非常快的场景。

优快云

📥博主的人生感悟和目标

Java程序员廖志伟

希望各位读者大大多多支持用心写文章的博主,现在时代变了,信息爆炸,酒香也怕巷子深,博主真的需要大家的帮助才能在这片海洋中继续发光发热,所以,赶紧动动你的小手,点波关注❤️,点波赞👍,点波收藏⭐,甚至点波评论✍️,都是对博主最好的支持和鼓励!

📙经过多年在优快云创作上千篇文章的经验积累,我已经拥有了不错的写作技巧。同时,我还与清华大学出版社签下了四本书籍的合约,并将陆续出版。这些书籍包括了基础篇进阶篇、架构篇的📌《Java项目实战—深入理解大型互联网企业通用技术》📌,以及📚《解密程序员的思维密码–沟通、演讲、思考的实践》📚。具体出版计划会根据实际情况进行调整,希望各位读者朋友能够多多支持!

🔔如果您需要转载或者搬运这篇文章的话,非常欢迎您私信我哦~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Java程序员廖志伟

赏我包辣条呗

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值