
前两篇写了线程怎么停、锁有哪些这篇想深挖一下。原因是有一天我在看代码的时候突然想到一个问题synchronized 不用手动 unlockReentrantLock 要——那 synchronized 是怎么知道自己什么时候该释放锁的它的底层到底在干什么我跑去翻了翻这方面的资料发现比我想象的要有意思得多。先说说 synchronized 在 JVM 里到底是怎么工作的字节码层面monitorenter 和 monitorexitsynchronized 是 Java 关键字编译之后会在字节码里变成几条指令。拿同步代码块来说编译后你会看到字节码里插入了monitorenter和monitorexit指令。进入的时候执行 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它靠的是 AQSReentrantLock 跟 synchronized 不一样。它不属于 JVM 层面而是 JDK 提供的 API——用纯 Java 代码写出来的锁。核心就是一个类AbstractQueuedSynchronizer缩写AQS。我第一次看到 AQS 这个缩写的时候还以为是某个高深莫测的东西读了一点发现它的核心思路其实不复杂。AQS 内部长什么样AQS 内部维护了两个东西一个volatile int state变量。0 表示锁没人拿1 表示有人拿着了大于 1 表示可重入同一个线程反复拿锁。一个双向链表CLH 变体队列用来排队的。拿不到锁的线程都被塞到这个链表里。拿锁的过程当线程调用lock()的时候AQS 做的第一件事是用CAS操作尝试把 state 从 0 改成 1。如果用大白话说就是“我想拿锁我先试一下看看 state 是不是 0。如果是我就改成 1锁归我了。如果不是说明被人占了我去排队。”CAS 成功了锁就拿到了当前线程的 ID 会被记下来——这样下次这个线程再来拿锁发现已经是自己了就直接进去这就是可重入的实现。CAS 失败了AQS 会把当前线程和它的等待状态包装成一个Node节点挂到双向链表尾部。然后调用LockSupport.park()把线程阻塞住。等持有锁的线程调用unlock()会做三件事把 state 减回 0把记录的线程 ID 清掉调用LockSupport.unpark()唤醒队列头部的下一个线程公平锁和非公平锁的区别这俩的区别我一开始总搞混后来记了一个简单的版本非公平锁默认新来的线程不管队列里有没有人排队先 CAS 抢一把。抢到了就插队进去了。抢不到才去排队。公平锁新来的线程先看一眼 AQS 队列里有没有人在排队。有人就乖乖去队尾站着不抢。非公平锁虽然不公平但性能通常更好——因为线程刚释放锁下一个线程立刻拿到的概率很大省了线程挂起唤醒的开销。公平锁排队虽然公平但是整体吞吐量会低一些。那到底差在哪——从头到尾比一遍1. 实现方式不同synchronizedReentrantLock层面JVM 关键字JDK API 类获取/释放隐式JVM 自动管显式手动 lock/unlock怎么解锁的编译器生成的字节码里有 monitorexit开发者在 finally 里调用 unlock2. 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 的区别写的过程中一边查一边理写完之后自己清楚了很多。所以这篇既是分享也是我自己的学习笔记。如果哪里写得不对或者有遗漏欢迎指出来。