做Java开发这几年,我最大的体会是:只要聊到并发,锁就是绕不开的话题。JUC 多线程里的synchronized和Lock这两个名词,既是面试官最爱问的考点,也是线上高并发服务里真正决定能不能扛住压力的核心工具。很多人背过八股文,知道synchronized是 JVM 内置锁,Lock是java.util.concurrent包下的接口,但一碰到实际问题,还是会犹豫该选谁,甚至写出有死锁风险的加锁代码。这篇文章不打算念 API 文档,而是把这两套锁机制的原理、使用姿势、选型依据和踩坑经验一次说清楚。适合准备 Java 多线程面试的同学,也适合日常工作中想写出更靠谱并发代码的开发者参考。
1. 为什么在多线程里要先解决“抢资源”的问题
1.1 从 i++ 看并发问题的本质
很多并发教程喜欢从i++讲起,不是因为简单,而是因为它能暴露并发问题的核心。i++在 JVM 里并不是一条指令,而是“读取变量、加 1、写回变量”三步操作。两个线程同时执行i++,可能同时读到同一个值,比如都读到 100,然后各自加 1,最后写回 101,而不是 102。这就是典型的原子性被破坏。
除了原子性,并发还会带来可见性问题和有序性问题。可见性是指一个线程修改了共享变量,另一个线程不能及时看到最新值,因为每个线程都有自己的本地缓存,JMM(Java 内存模型)并不保证即时同步。有序性则更加隐蔽,JIT 在运行时可能对指令做重排,单线程结果不变,但多线程下可能会让代码以意想不到的顺序执行。
锁就是为了解决这三个问题而存在的。它不是简单地把代码“包住”,而是通过互斥让同一时刻只有一个线程能进入临界区,同时通过内存屏障语义保证锁内的写操作在释放锁后对其他线程可见。理解了这一点,后面再看 Monitor、AQS 这些概念,就不会觉得是在背名词,而是清楚它们都是为了达成这三个目标。
1.2 锁到底锁了什么:互斥与内存可见性
很多人会把“锁”理解成锁住某个对象或者锁住某段代码,这个说法不够准确。准确地说,锁是在建立一个“临界区”,在进入临界区之前必须获取到对应锁的持有权,没抢到锁的线程只能等待。synchronized锁的是对象头里的 Mark Word,ReentrantLock锁的是 AQS 里的 state,本质上都是在做同一件事:控制临界区的访问权。
锁的另一个重要作用是内存可见性。Java 内存模型里有一条 happens-before 规则:释放锁的操作 happens-before 同一个锁的后续加锁操作。这意味着,线程 A 在释放锁之前对共享变量的修改,线程 B 在获取同一把锁之后一定能看到。这个语义非常关键,它解释了为什么临界区内的共享变量不需要额外加volatile。锁本身已经内建了刷新内存和读取最新值的屏障。
所以,判断一个锁是否用对,不能只看“有没有同一把锁”,还要看共享变量是否真的被锁的保护范围覆盖。如果锁的是 A 对象,却修改了和 A 完全没有关系的 B 字段,那整个互斥和可见性就都失效了,代码看起来加了锁,实际并发问题依然存在。
1.3 synchronized 和 Lock 的宏观定位
从历史背景来看,synchronized是 Java 关键字,JDK 1.0 就有了。Lock是 JDK 1.5 引入的接口,核心原因就是早期synchronized不够灵活:等待锁的线程不能被中断,不能设置获取锁的超时时间,不能实现公平锁,也没有多个条件队列。这些限制在高并发场景下会很痛,所以 Doug Lea 设计了java.util.concurrent包,连带提供了Lock接口和AbstractQueuedSynchronizer(AQS)这个底层工具。
到了 JDK 1.6,JVM 对synchronized做了大量优化,引入了偏向锁、轻量级锁、锁升级、锁消除、锁粗化,性能已经不再像早期那样和Lock有代差。所以现在synchronized和Lock不是替代关系,而是互补关系:代码简单时用synchronized,需要高级特性时用Lock。
2. synchronized 锁机制的原理拆解
2.1 三种使用方式与字节码真相
synchronized有三种用法:修饰实例方法、修饰静态方法、修饰同步代码块。实例方法锁的是当前实例this;静态方法锁的是当前类的Class对象;同步代码块可以指定任意对象作为锁,这是最灵活的方式。我的建议是,锁对象的生命周期要和共享资源的生命周期保持一致,否则锁就白加了。比如你用new Object()作为锁,但这个新对象每次都被替换,那就锁不住任何东西。
看字节码会更直观。同步代码块编译后会生成monitorenter指令进入,以及monitorexit指令退出。注意,同步代码块在正常和异常两个出口都会生成monitorexit,也就是说即使代码抛出异常,JVM 也会自动释放锁。这一点是synchronized的一大优势,程序员不会被“忘记释放锁”这种问题困扰。而Lock必须手动释放,后面我会再强调。
对于同步方法,字节码层面并不生成monitorenter/monitorexit,而是给方法打上ACC_SYNCHRONIZED标志。JVM 在方法调用时根据这个标志来判断是否需要加锁。无论是代码块还是方法,底层都离不开 Monitor 机制。
2.2 对象头、Monitor 与锁升级
Java 对象在内存布局上有一个对象头,其中 Mark Word 存放锁状态信息。JDK 1.6 之后,synchronized锁不是一上来就是重量级锁,而是有一个升级过程:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
偏向锁的设计思路很巧妙:假设大多数情况下一个锁只会被同一个线程反复拥有,那第一次获取锁时通过 CAS 把线程 ID 记录到 Mark Word 里,后面这个线程再次进入同步块就不需要任何同步开销。如果另一个线程来竞争了,偏向锁会撤销并膨胀为轻量级锁。轻量级锁并不马上把线程挂起,而是采用自旋方式,在多个线程交替执行临界区时,用小段空转代替线程切换,因为线程挂起和唤醒涉及用户态到内核态的切换,开销很大。自旋次数不能无限长,否则 CPU 被白白浪费,JDK 后来还引入了自适应自旋,根据上次等待成功率动态调整自旋次数。
如果再竞争加剧,轻量级锁就会膨胀为重量级锁,也就是 ObjectMonitor。重量级锁依赖操作系统底层的互斥量,这也是“挣不到锁就阻塞”的开销来源。弄懂这个升级过程,对面试很有用,对实际调优也有启发:不要动不动就认为synchronized很慢,在低竞争情况下它已经足够轻量。
2.3 synchronized 的可见性和可重入性
synchronized是可重入锁,这点很多面试题会考。底层所谓“可重入”,其实就是每个对象关联的 Monitor 维护了一个计数器。同一个线程第二次进入同一把锁时,计数器加 1,退出时减 1,只有计数器归 0 才真正释放锁。正因为可重入,子类重写父类同步方法时调用父类同步方法才不会死锁,递归调用时也不会自己锁死自己。
可见性方面,synchronized不是靠锁的对象“记住”变量,而是靠内存屏障来保证的。进入同步块时加读屏障,退出时加写屏障,确保锁内修改的变量能刷新到主内存。这也是为什么 Java 内存模型里的 happens-before 规则里会有“解锁后对后续加锁线程可见”这一条。实际排查并发问题时,如果发现一个线程修改了共享变量,另一个线程读不到,首先就要确认读写是否都包在同一个锁的临界区内,而不是关注锁对象本身是不是同一个“名字”。
3. Lock 锁机制的原理与 API 实战
3.1 Lock 接口到底解决了什么痛点
java.util.concurrent.locks.Lock是一个接口,早期synchronized解决不了的四个痛点,它都能补上:可中断等待锁、可超时获取锁、公平排队、多个条件队列。接口方法包括lock()、lockInterruptibly()、tryLock()、tryLock(long time, TimeUnit unit)、unlock()和newCondition()。
灵活是需要付出代价的。synchronized是自动释放锁,而Lock必须手动释放。哪怕业务逻辑抛了异常,一旦忘记在finally块里unlock(),锁就一直占着,其他线程全都会卡住。业界标准写法是这样:
Lock lock = new ReentrantLock(); // 注意:lock() 放在 try 外,防止获取锁失败后 finally 里执行 unlock lock.lock(); try { // 临界区代码 } finally { lock.unlock(); }记住一个原则:用了Lock,就要把unlock()放在finally里。这不是小题大做,线上卡顿事故很多就是从这个细节来的。
3.2 AQS:Lock 的底层骨架
都说理解Lock就要理解 AQS,确实如此。AQS 是AbstractQueuedSynchronizer的简称,它是 JUC 里很多同步器的基础骨架。AQS 核心有两个东西:一个volatile int state,一个双向等待队列(CLH 变体)。state语义由子类决定,比如ReentrantLock用state表示锁的持有次数,读多写少的ReentrantReadWriteLock把state拆成高 16 位和低 16 位分别表示读锁和写锁状态。
获取锁的流程可以简化成:先用 CAS 尝试把state从 0 改成 1,成功就拿到锁;失败则把当前线程封装成等待节点插入 FIFO 队列尾部,然后在合适时机挂起。释放锁的流程反过来,CAS 把state改回 0,然后唤醒队列里排在最前面的等待线程。看起来不复杂,但 AQS 的精华在于对并发状态的控制非常精细。
公平锁和非公平锁的区别也在 AQS 里体现出来。非公平锁在线程来抢锁时,会先直接尝试 CAS 插队抢一次,抢不到才去排队。公平锁则严格按 FIFO 顺序,来了就排队,不允许插队。为什么要做非公平锁?因为能减少线程挂起和唤醒的次数,整体吞吐量更高。代价是后到线程可能连续插队,导致排队的线程饥饿。ReentrantLock默认是非公平锁,只有确有需求时才用公平锁。
3.3 ReentrantLock 的常用姿势
日常开发中,ReentrantLock最实用的三个能力是超时获取、可中断等待和多路 Condition。超时获取用tryLock(timeout, unit)非常方便,拿不到锁就按自己的策略处理,不会无限等下去,这在有外部超时要求的接口里特别有用。
if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 拿到锁后的业务逻辑 } finally { lock.unlock(); } } else { // 3 秒内没拿到锁,做兜底处理 }lockInterruptibly()则是在等待锁的过程中可以被interrupt()打断,线程不用一直傻等。条件队列方面,Condition提供了await()、signal()、signalAll(),作用类似Object.wait()和notify(),但它的价值是可以创建多个条件。比如一个有界队列,可以分别用notFull和notEmpty两个 Condition 来管理写和读的等待,避免用单一 notifyAll 把不相关的线程全部唤醒。这里有个细节:await()会让当前线程释放锁,但signal()只是把等待线程移到可抢锁队列,并不会立刻释放锁,必须要等到当前线程真正执行完finally里的unlock()之后,被唤醒的线程才有机会抢锁。
3.4 读写锁与 StampedLock
如果只是互斥地读数据,那读写锁的收益就很大。ReentrantReadWriteLock把锁拆成读锁和写锁:读锁是共享锁,多个线程可以同时持有;写锁是排他锁,写锁和任何锁都互斥。典型场景是缓存:读远多于写,多个读者并发读不互相干扰,只有当写者出现时才阻塞后续读和写。
使用读写锁时要注意锁降级。所谓锁降级,是指持有写锁时获取读锁,再释放写锁,这样一个线程从写状态平滑切到读状态,中间不会被其他写者插入。代码上就是先writeLock().lock(),改完数据后再readLock().lock(),然后writeLock().unlock()。如果不做锁降级,直接释放写锁,两个线程可能同时进入临界区,数据又会对不上。
JDK 1.8 还加入了StampedLock,支持乐观读:读线程先读,读完后检查 stamp 是否有效,确认没有被写线程打断,如果被打断再升级为读锁重新读。它在读多写极少的场景下性能很高,但不可重入,状态管理也更复杂,我一般不建议新手优先使用。
4. 实操对照:两套锁在真实场景怎么写
4.1 线程安全计数器的两种实现
光讲原理不如动手写一遍。最简单的线程安全计数器,用synchronized可以这样写:
public class SyncCounter { private int count; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } }用ReentrantLock写则长这样:
public class LockCounter { private final Lock lock = new ReentrantLock(); private int count; public void increment() { lock.lock(); try { count++; } finally { lock.unlock(); } } public int getCount() { lock.lock(); try { return count; } finally { lock.unlock(); } } }两种都能保证线程安全,区别在于LockCounter如果要扩展出tryLock超时逻辑,会更容易。但如果只是需要保证计数安全,我会直接用synchronized,甚至更推荐AtomicInteger,连锁都不用。判断标准是:临界区是否真的需要锁提供的额外能力,如果没有,就别为了用锁而用锁。
4.2 生产者-消费者:wait/notify 与 Condition 的对比
经典的生产者-消费者模型,用synchronized+wait/notify也能写,但要处理虚假唤醒,所以必须用while循环条件判断:
synchronized (queue) { while (queue.isFull()) { queue.wait(); } queue.put(1); queue.notifyAll(); }消费者侧类似,while (queue.isEmpty()) wait();然后消费。这种方式看着简单,但notifyAll()会把生产和消费两个方向的等待线程一起唤醒,其中总有一批线程醒了之后发现自己状态不符,继续 wait,造成不必要的上下文切换。
用ReentrantLock的Condition可以做得更精细。生产者只唤醒等待非满条件的消费者,消费者只唤醒等待非空条件的生产者:
private final Lock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); public void put(Object item) throws InterruptedException { lock.lock(); try { while (queue.isFull()) { notFull.await(); } queue.add(item); notEmpty.signal(); } finally { lock.unlock(); } } public Object take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } Object item = queue.remove(); notFull.signal(); return item; } finally { lock.unlock(); } }使用Condition时,signal()不会让当前线程释放锁,只有unlock()之后,其他等待线程才会被唤醒去抢锁。这一点和wait/notify的行为类似,但更精确。单条件或多条件的选择,本质上是在“代码复杂度”和“性能”之间取舍。通常业务场景下,单缓冲区用两个 Condition 最舒服。
4.3 一个缓存场景中的读写锁
假设要实现一个简单的本地缓存,读多写少,用ReentrantReadWriteLock很合适。在没有缓存时查询原始数据,写入缓存,然后返回。关键点是写入后做锁降级,避免其他线程在释放写锁到获取读锁这个空隙里读到旧数据。
public class Cache { private final Map<String, Object> data = new HashMap<>(); private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); public Object get(String key) { readLock.lock(); try { return data.get(key); } finally { readLock.unlock(); } } public Object putIfAbsent(String key, Object value) { // 先拿读锁看是否已有 readLock.lock(); try { if (data.containsKey(key)) { return data.get(key); } } finally { readLock.unlock(); } // 没有则升级写锁 writeLock.lock(); try { // 双重检查,防止并发下重复初始化 Object old = data.get(key); if (old != null) { return old; } data.put(key, value); return value; } finally { writeLock.unlock(); } } }这里读锁无法直接升级为写锁,因为可能造成死锁:两个线程都持读锁然后互相等写锁。所以先释放读锁,再抢写锁,是常见的可行策略。需要注意,读锁只会阻塞写锁,不会阻塞读锁,所以如果写操作频率并不低,读写锁的“读共享”优势会变成劣势,大量读锁可能会让写锁长时间拿不到,这个叫写锁饥饿。
4.4 锁选型决策:什么时候选谁
很多人选锁时纠结,其实就是没理清场景。我个人的决策思路可以归纳成一张表:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 临界区很短,只做简单变量保护 | synchronized或原子类 | 写法简单,锁升级后开销可控 |
| 需要超时获取锁,拿不到就快速失败 | ReentrantLock.tryLock(timeout) | synchronized不支持超时 |
| 等待锁过程中需要响应中断 | ReentrantLock.lockInterruptibly() | 线程可以被定期唤醒检查状态 |
| 需要公平排队,防止线程饿死 | ReentrantLock(true) | 公平锁按 FIFO 排队 |
| 读多写少,且写操作不频繁 | ReentrantReadWriteLock | 读读共享可提升并发度 |
| 极低竞争、读远多于写 | StampedLock乐观读 | 更大程度减少阻塞 |
| 没有特殊需求,默认选择 | synchronized | 自动释放、语义清晰、异常安全 |
不要一上来就选择最复杂的方案。锁是并发工具,不是炫技工具。在 JDK 1.6 之后,synchronized的性能在大多数低竞争场景下已经不逊于ReentrantLock,而且代码可读性更好。真正的性能瓶颈往往不是锁本身,而是锁粒度太大、锁内执行耗时操作、锁对象选择错误。
5. 常见问题与排查技巧实录
5.1 面试高频锁机制问题速查
聊锁机制,下面这些问题属于“背不下来也要理解”的高频题。我整理成表格,方便快速回顾:
| 高频问题 | 核心回答 |
|---|---|
| synchronized 是可重入的吗? | 是。每个 Monitor 有计数器,进入 +1,退出 -1 |
| synchronized 和 Lock 有什么区别? | 后者支持可中断、超时、公平锁、多条件,但需要手动释放 |
| 锁升级过程是怎样的? | 无锁 -> 偏向锁 -> 轻量级锁(自旋) -> 重量级锁 |
| AQS 是什么? | 抽象队列同步器,基于 state + FIFO 等待队列 |
| ReentrantLock 默认公平还是非公平? | 默认非公平,吞吐更高;可构造参数改公平 |
| Condition 和 wait/notify 有什么区别? | Condition 支持多路等待、可中断、超时;更精确 |
| 为什么不能在 synchronized 块外调用 wait/notify? | 会抛 IllegalMonitorStateException,当前线程不是锁持有者 |
| 如何定位死锁? | 用 jps 找到 Java 进程,再执行 jstack 查看线程栈 |
需要注意的是,面试官经常会加问“为什么synchronized早期慢,现在不慢了?”这时候要能把锁升级、自适应自旋、锁消除、锁粗化这些点讲清楚,而不是只背结论。
5.2 死锁和锁泄漏的定位方法
死锁是并发里最经典的问题了。比如两个账户互相转账,线程 A 持有账户 1 的锁等待账户 2,线程 B 持有账户 2 的锁等待账户 1,两个线程就僵住了。复现代码通常是嵌套synchronized,内层锁获取顺序不同。
排查时我一般这样做:先用jps找到 Java 进程 ID,再用jstack <pid>导出线程栈。文件里这类关键信息非常显眼:
Found one Java-level deadlock: ... "thread-1": waiting to lock monitor ... "thread-2": waiting to lock monitor ...看到“Found one Java-level deadlock”就是确认死锁了。解决思路有几个:一是约定锁顺序,所有线程按相同的全局顺序加锁,比如按账户 ID 排序,而不是按传入参数顺序;二是用tryLock(timeout)拿不到锁就回滚,从机制上避免永久僵持。
锁泄漏则是Lock特有的问题。如果lock()后没有在finally里unlock(),方法抛异常后锁不会自动释放,别的线程会一直卡在等待锁获取上。jstack里会看到大量线程处于WAITING (parking)状态。排查并不难,难的是改掉“随手写 lock,忘写 unlock”的毛病。我的经验是,刚接手这类代码时先全局搜索lock.lock(),然后逐个确认是否都有try/finally,没有的优先修。
5.3 容易踩的坑和经验小结
代码写得多了,踩坑是难免的。下面这几个,都是我实际见到过或自己掉进去过的:
wait()必须放在synchronized块里,否则抛IllegalMonitorStateException;用Condition也一样,await()前必须先拿到对应的锁。wait/await的唤醒条件要用while而不是if,因为存在虚假唤醒。- 不要用
String常量或缓存的值作为锁对象,比如Integer因为缓存机制,可能导致意外的锁范围。 - 锁内尽量避免执行网络 IO、数据库操作、
Thread.sleep()这类耗时操作,不然锁的阻塞时间会被放大。 - 多个锁时,所有线程一定按一致的顺序加锁。反向加锁是死锁最常见的温床。
synchronized虽然自动释放,但如果方法体很大,锁的持有时间会很长;条件允许时缩小同步块范围。- JDK 更新后,
synchronized的性能表现已经很好,别在没有压测的情况下把所有synchronized盲目替换成ReentrantLock。
多线程最好用的调试工具不是 IDE 的断点,而是线程转储和日志。断点一挂,线程状态全变了,反而更难复现。遇到线程卡住、服务无响应,先抓jstack,看线程是RUNNABLE、WAITING还是BLOCKED,再配合代码定位锁的位置,一般很快就有结论。
写到这里,想起之前帮一个团队排查线上卡顿,折腾了大半天才发现是有个ReentrantLock没在finally里释放。异常路径上锁被永远占住,所有调用方全部阻塞。这种问题不大,但排查成本极高。我现在写并发代码的经验是:默认用synchronized,除非你明确需要Lock的超时、可中断、公平或多条件能力;一旦用了ReentrantLock,请一定把unlock()放进finally,然后配合jstack做好现场分析。多线程的最终目的不是会写几个锁的 API,而是搞清楚锁到底在保护什么、什么时候释放、退了之后其他线程能不能安全顶上。