当前位置: 首页 > news >正文

synchronized 和 ReentrantLock 到底差在哪——从底层扒到应用场景

前两篇写了线程怎么停、锁有哪些,这篇想深挖一下。

原因是有一天我在看代码的时候突然想到一个问题:synchronized 不用手动 unlock,ReentrantLock 要——那 synchronized 是怎么知道自己什么时候该释放锁的?它的底层到底在干什么?

我跑去翻了翻这方面的资料,发现比我想象的要有意思得多。


先说说 synchronized 在 JVM 里到底是怎么工作的

字节码层面:monitorenter 和 monitorexit

synchronized 是 Java 关键字,编译之后会在字节码里变成几条指令。

拿同步代码块来说,编译后你会看到字节码里插入了monitorentermonitorexit指令。进入的时候执行 monitorenter,退出的时候(不管是正常结束还是抛异常)执行 monitorexit。

那同步方法呢?它不靠这两条指令,而是靠方法表里的一个标志位叫ACC_SYNCHRONIZED。JVM 看到这个标志,就知道这个方法需要先拿到锁再执行。

这解释了为什么 synchronized 不需要手动释放——编译器在生成字节码的时候就帮你安排好了释放的路径,包括异常路径。

对象头与 Mark Word:锁存在哪里

每个 Java 对象在内存里,除了你写的那些字段之外,还有一部分"隐藏"的信息,叫对象头。对象头里有一块叫Mark Word的区域,它存的东西会随着对象的状态变化而变化。

一开始它可能存的是哈希码、GC 分代年龄这些东西。一旦这个对象被当作锁来用了,Mark Word 里的内容就变成了锁的相关信息——比如当前持有锁的线程是谁、当前处于什么锁状态。

锁升级的过程,说白了就是 Mark Word 里的数据在不断被改写。

重量级锁与 ObjectMonitor

如果锁升级到了重量级锁(就是竞争很激烈的时候),JVM 会为这个锁创建一个ObjectMonitor对象。这个对象里维护了三个关键的结构:

  • _owner:当前拿着锁的线程是谁
  • _EntryList:想拿锁但是没拿到、在那等着的线程们
  • _WaitSet:调用了wait()方法先歇会儿的线程们

重量级锁的"重量"体现在哪呢?因为这个时候它要依赖操作系统的Mutex Lock(互斥量)来阻塞线程。把一个线程从用户态切到内核态阻塞,再切回来唤醒——这一来一回开销很大。所以才有锁升级那套机制,尽量让锁停留在轻量级状态,别走到重量级这一步。

锁升级:JDK 15 之后有变化

之前那篇博客里我简单提过锁升级的路径:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。

但这里有个更新要交待一下。从JDK 15 开始,偏向锁已经被默认禁用了,并且标记为废弃。原因是偏向锁的撤销成本太高了——在高并发场景下,为了撤销偏向锁,JVM 需要做很多事情,算下来还不如直接走轻量级锁划算。

所以现在默认的路径变成:

无锁 → 轻量级锁(CAS) → 重量级锁(操作系统互斥量)

少了一个环节。


再说 ReentrantLock:它靠的是 AQS

ReentrantLock 跟 synchronized 不一样。它不属于 JVM 层面,而是 JDK 提供的 API——用纯 Java 代码写出来的锁。核心就是一个类:AbstractQueuedSynchronizer,缩写AQS

我第一次看到 AQS 这个缩写的时候还以为是某个高深莫测的东西,读了一点发现,它的核心思路其实不复杂。

AQS 内部长什么样

AQS 内部维护了两个东西:

  1. 一个volatile int state变量。0 表示锁没人拿,1 表示有人拿着了(大于 1 表示可重入,同一个线程反复拿锁)。
  2. 一个双向链表(CLH 变体队列),用来排队的。拿不到锁的线程都被塞到这个链表里。

拿锁的过程

当线程调用lock()的时候,AQS 做的第一件事是:用CAS操作尝试把 state 从 0 改成 1。

如果用大白话说就是:

“我想拿锁,我先试一下看看 state 是不是 0。如果是,我就改成 1,锁归我了。如果不是,说明被人占了,我去排队。”

CAS 成功了,锁就拿到了,当前线程的 ID 会被记下来——这样下次这个线程再来拿锁,发现已经是自己了,就直接进去,这就是可重入的实现。

CAS 失败了,AQS 会把当前线程和它的等待状态包装成一个Node节点,挂到双向链表尾部。然后调用LockSupport.park()把线程阻塞住。

等持有锁的线程调用unlock(),会做三件事:

  1. 把 state 减回 0
  2. 把记录的线程 ID 清掉
  3. 调用LockSupport.unpark()唤醒队列头部的下一个线程

公平锁和非公平锁的区别

这俩的区别我一开始总搞混,后来记了一个简单的版本:

  • 非公平锁(默认):新来的线程不管队列里有没有人排队,先 CAS 抢一把。抢到了就插队进去了。抢不到才去排队。
  • 公平锁:新来的线程先看一眼 AQS 队列里有没有人在排队。有人就乖乖去队尾站着,不抢。

非公平锁虽然"不公平",但性能通常更好——因为线程刚释放锁,下一个线程立刻拿到的概率很大,省了线程挂起唤醒的开销。公平锁排队虽然公平,但是整体吞吐量会低一些。


那到底差在哪——从头到尾比一遍

1. 实现方式不同

synchronizedReentrantLock
层面JVM 关键字JDK API 类
获取/释放隐式,JVM 自动管显式,手动 lock/unlock
怎么解锁的编译器生成的字节码里有 monitorexit开发者在 finally 里调用 unlock

2. synchronized 做不到的事

等待可中断
synchronized 等锁的时候不会被 interrupt() 打断。ReentrantLock 有lockInterruptibly(),可以做到"等着等着被喊停"。

超时放弃
synchronized 只能一直等。ReentrantLock 可以tryLock(5, TimeUnit.SECONDS)——等五秒拿不到就不等了。

公平锁
synchronized 只有非公平。ReentrantLock 可以选公平。

精准唤醒
这个差别挺大的。synchronized 配合 wait/notify/notifyAll 只有一个等待队列。调用 notifyAll 的时候,所有等着的线程都被叫醒了——哪怕有些线程等的是不同的条件,这叫惊群效应

ReentrantLock 可以配合多个 Condition,每个 Condition 有自己的等待队列。比如:

class BoundedQueue { private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); // notFull.await() — 等着不满 // notEmpty.signal() — 唤醒等着取数据的线程 }

生产者线程满了就等 notFull,消费者线程空了就等 notEmpty。唤醒的时候只唤醒对方,不会把所有人都吵醒。

3. synchronized 的优势

自动释放锁这一点,对新手来说是巨大的安全感。写了 lock() 忘了 unlock(),线上事故就来了。用 synchronized 不可能出现这种问题。

而且 synchronized 能修饰方法级别,代码看起来更简洁。比如你给一个方法加上 synchronized,整个方法体都是同步的,不用像 ReentrantLock 那样在方法里面包一层 try-finally。

4. 内存语义上其实一样

这个是我之前没想到的——synchronized 和 ReentrantLock 在内存可见性方面是等价的。

规则都是一样:线程释放锁的时候,会把工作内存里的变量刷新到主内存。另一个线程拿到锁的时候,会从主内存重新加载。所以它们都能保证:你改完的变量,我能看到。


实际写代码的时候怎么选

我自己的感觉是分三步来判断:

第一步:synchronized 够不够?

如果只是简单的计数、加锁保护一个共享变量、或者方法级别的同步,synchronized 完全够用,而且不容易写错。绝大多数场景到这里就结束了。

第二步:需要高级功能吗?

如果发现需要超时等待、响应中断、公平锁或者精确唤醒,那才考虑换成 ReentrantLock。

第三步:用了 ReentrantLock 的话,规范写好了吗?

lock() 写在 try 外面,unlock() 写在 finally 里。每次写都确认一遍这俩有没有配对。写完后检查一遍。


最后的避坑提醒

不要在锁里做耗时操作。

不管是 synchronized 还是 ReentrantLock,在同步块里做 I/O、网络请求或者 sleep 都是在给自己挖坑。锁被拿着的每一毫秒,其他线程都在等着。如果你非得做这些操作,尽量把锁的范围缩到最小——只有保护共享数据的那几行代码在锁里,其他的放外面。

锁分离

如果一个类里有多个完全不相关的共享变量,别偷懒用同一个锁。比如一个缓存系统里的读操作和写操作可以用读写锁;如果连读写锁都用不上,至少给不同的变量分配不同的锁实例。


写到这里我回头看了一下这篇的内容,发现比前两篇要深不少。说实话写之前我也没完全搞明白 ObjectMonitor 和 AQS 的区别,写的过程中一边查一边理,写完之后自己清楚了很多。

所以这篇既是分享,也是我自己的学习笔记。如果哪里写得不对或者有遗漏,欢迎指出来。

http://www.jsqmd.com/news/1077149/

相关文章:

  • RHEL8-9 RPM 全参数详解
  • MySQL - extra->>“$.multiple“用法详解
  • 【系统优化】C盘空间清理终极指南:8种方法从原理到实操
  • GLM-5.1深度集成Coding Plan:AI编码从API调用到开发动作的范式升级
  • Java毕设项目: 基于 SpringBoot+Java 的社区数字化治理中智慧物业综合运维系统设计与实现(源码+文档,讲解、调试运行,定制等)
  • 如何为Windows系统上的LG Ultrafine显示器实现专业级亮度控制
  • 用 Codex 联动 Agnes 搭建 AI 视频流水线:从单镜到连贯短片
  • 快充充电器电压取电芯片可请求9V、12V、20V等
  • 文化IP联名服饰收益计算器,输入IP授权费,定价,销量,自动核算联名纯利润。
  • 抖音无水印下载终极指南:3分钟学会批量保存任何内容
  • CentOS 7 上升级 GCC版本
  • 如何在5分钟内完成系统镜像烧录:Balena Etcher终极指南
  • 塞尔达传说旷野之息存档编辑器:打造完美海拉鲁冒险的终极指南
  • 湛江中学无人机科创课程落地案例 一年斩获十五项省级科创奖拆解分析
  • 2026手机条码标签打印软件盘点:4款移动端工具适配多场景选型指南
  • 51-C20+实时时钟+校时+吃药检测+药品分类+药量显示+3次定时+声光提醒+OLED屏+(无线方式选择)-3(设计源文件+万字报告+讲解)(支持资料、图片参考_相关定制)_文章底部可以扫码
  • Wedecode:如何用3分钟破解微信小程序的黑盒困境?
  • 3步掌握LeetDown:A6/A7设备iOS降级终极指南
  • Spring Boot 异步任务线程池配置
  • OpenClaw 对接淘宝商品详情 API,搭建自动化竞品监控选品系统(完整实操教程)
  • 3步完成黑苹果配置:OpCore Simplify让OpenCore EFI创建变得简单快速
  • AI 机器人神经与基建核心
  • STM32-S82+RTC时钟+校时+剩余药量+语音提醒+吃药检测+药品分类+药量显示+3定时+TFT屏+(无线方式选择)-3(设计源文件+万字报告+讲解)(支持资料、图片参考_相关定制)_文章底部可
  • 社交平台推荐算法三阶段解析:召回、排序与重排
  • 糯玉米供应商哪家比较靠谱
  • 从单点工具到自动化狩猎:构建高效漏洞挖掘工具链的工程实践
  • Gemma 4:端侧智能体操作系统架构与实战部署指南
  • 叉车海运出口全攻略!新手零踩坑,新旧叉车、电动叉车运输细则
  • 异常检测实战指南:从原理、选型到工业落地
  • Okbiye AI PPT 生成器:解锁毕业论文答辩文稿新路径,告别通宵制作困境