1. 完整回答
Java 锁是并发编程中用于控制多个线程访问共享资源的一种同步机制,主要解决线程安全问题,保证共享数据操作的原子性和内存可见性。
比如多个线程同时扣减库存,如果没有同步控制,可能同时读取到相同的库存数量,最终导致超卖。通过加锁,可以让关键操作按照预期的并发规则执行。
Java 中常用的锁主要有 synchronized、ReentrantLock 和 ReentrantReadWriteLock。
synchronized 是 Java 内置的同步机制,基于 JVM 的对象监视器实现。线程进入同步代码时需要先获得对应对象的锁,执行结束后自动释放。它支持可重入,同一个线程可以重复获取已经持有的锁。
ReentrantLock 是 JUC 提供的显式锁,底层基于 AQS 实现。AQS 通过同步状态、CAS 和等待队列管理锁竞争。相比 synchronized,它支持公平锁、可中断获取锁、超时获取锁以及多个条件队列,控制方式更加灵活。
对于读多写少的场景,还可以使用读写锁。读锁允许多个线程同时读取,写锁要求独占访问,从而提高读取并发能力。
锁的一个重要特性是,释放锁与后续获取同一把锁之间存在 happens-before 关系,可以保证线程之间的数据可见性。
实际开发中,普通互斥场景通常使用 synchronized;需要更灵活的锁控制时使用 ReentrantLock;读多写少且读操作具有一定开销时,可以考虑读写锁。同时需要控制锁的粒度和持有时间,减少竞争,并避免死锁。
2. 核心原理与关键概念
2.1 核心原理
Java 锁的核心机制可以概括为:获取锁 → 访问共享资源 → 释放锁。
不同锁的差异主要体现在获取锁、处理竞争和唤醒等待线程的方式上。
- synchronized 的工作流程
线程进入同步代码 → 尝试获取对象监视器(Monitor)→ 获取成功后执行临界区 → 执行结束自动释放锁 → 其他线程继续竞争。
关键环节:
-
对象监视器:Java 对象都关联一个监视器,同一时刻只能有一个线程持有对应的监视器锁。
-
可重入:同一个线程再次进入同一把锁保护的代码,可以直接重入,JVM 会记录锁的重入次数。
-
自动释放:同步代码正常结束或发生异常退出时,JVM 都会自动释放对应的锁。
-
内存可见性:线程释放锁之前的写操作,对后续成功获取同一把锁的线程可见。
在字节码层面,同步代码块通过 monitorenter 和 monitorexit 实现;同步方法通过 ACC_SYNCHRONIZED 标志,由 JVM 隐式完成加锁和解锁。
- ReentrantLock 的工作流程
线程调用 lock() → AQS 尝试获取同步状态 → 获取成功则进入临界区 → 获取失败则进入同步等待队列 → 等待被唤醒并重新竞争锁 → 最终调用 unlock() 释放锁。
关键环节:
-
state:AQS 的同步状态。对于
ReentrantLock,0 表示未被持有,正数表示当前持有线程的重入次数。 -
CAS:通过原子操作尝试修改同步状态,保证多个线程竞争时只有符合条件的线程能获取锁。
-
FIFO 等待队列:获取锁失败的线程进入队列,在适当时机挂起,等待重新竞争。
-
唤醒机制:锁完全释放后,AQS 会通知合适的等待线程重新尝试获取锁。
需要注意,AQS 使用 FIFO 队列管理等待线程,但不代表锁一定严格按照入队顺序分配,具体取决于公平策略。
2.2 关键概念
| 概念 | 含义 |
|---|---|
| 互斥锁 | 同一时刻只允许一个线程进入受保护的临界区 |
| 可重入锁 | 当前线程可以重复获取已经持有的同一把锁 |
| 公平锁 | 竞争时通常优先照顾等待时间较长的线程 |
| 非公平锁 | 允许新线程在特定情况下插队获取锁 |
| 读写锁 | 读操作允许共享,写操作要求独占 |
| 乐观锁 | 假设冲突较少,更新时通过版本校验或 CAS 检测冲突 |
| 悲观锁 | 假设可能发生冲突,访问共享资源前先获取锁 |
3. 高频面试追问
3.1 synchronized 锁的到底是什么?不同使用方式有什么区别?
synchronized 锁住的是对象对应的监视器,具体锁对象由使用方式决定。
-
同步实例方法:锁当前实例对象,也就是
this。 -
同步静态方法:锁当前类对应的
Class对象。 -
同步代码块:锁括号中指定的对象。
例如,两个线程分别调用同一个实例的同步方法,会竞争同一把锁;如果调用的是两个不同实例的同步方法,通常不会互相阻塞。
但是静态同步方法使用类锁,即使通过不同实例调用,也会竞争同一个 Class 对象的锁。
另外,类锁和实例锁属于不同锁对象,因此通常不会互相阻塞。
关键在于多个线程是否使用同一把锁保护同一份共享数据。
3.2 什么是可重入锁?为什么需要支持可重入?
可重入锁指的是一个线程已经持有某把锁时,可以再次获取同一把锁,不会因为重复加锁而阻塞自身。
例如,一个同步方法 A 内部调用另一个使用相同锁的同步方法 B。如果锁支持可重入,线程执行 B 时可以继续获取这把锁。
在 ReentrantLock 中,可重入主要通过持有线程标识和重入计数实现:
线程首次获取锁 → 设置持有线程并将计数设为 1 → 同一线程再次获取锁,计数加 1 → 每次释放计数减 1 → 计数归零后真正释放锁。
synchronized 和 ReentrantLock 都支持可重入。
可重入机制可以避免线程因重复获取自身持有的锁而陷入自我阻塞,使嵌套方法调用更加自然。
3.3 AQS 是什么?它如何实现锁的获取与释放?
AQS 全称是 AbstractQueuedSynchronizer,抽象队列同步器,是 JUC 中实现锁和线程同步工具的重要基础框架。
核心由两部分组成:同步状态 state 和 FIFO 等待队列。
以 ReentrantLock 为例:
-
获取锁时,首先尝试通过 CAS 修改 state。
-
如果锁已经被其他线程持有,当前线程进入 AQS 等待队列。
-
排队线程在合适时机挂起,避免持续占用 CPU。
-
持有锁的线程执行
unlock(),重入计数归零后释放锁。 -
AQS 唤醒等待线程,由其重新尝试获取锁。
AQS 还支持独占模式和共享模式。ReentrantLock 使用独占模式,Semaphore、CountDownLatch 等同步工具也利用 AQS 的机制实现各自的同步语义。
AQS 的价值在于统一处理竞争排队、线程阻塞和唤醒,让不同同步工具主要关注各自的资源获取规则。
3.4 synchronized 和 ReentrantLock 有什么区别?应该如何选择?
两者都属于可重入的独占锁,都能保证临界区的互斥访问和内存可见性。
主要区别是锁控制能力。
synchronized 由 JVM 管理,进入和退出同步代码时自动加锁、解锁,使用简单,能够减少忘记释放锁的问题。
ReentrantLock 需要显式调用 lock() 和 unlock(),通常通过 try-finally 保证释放,但提供了更多能力:
-
tryLock():尝试获取锁,失败可以直接返回。 -
tryLock(timeout):允许设置等待超时时间。 -
lockInterruptibly():等待获取锁时可以响应中断。 -
公平模式:可以配置公平锁。
-
Condition:可以创建多个条件等待队列。
实际选择时,普通同步代码优先使用 synchronized;涉及超时退出、响应中断、复杂线程协作时,使用 ReentrantLock 更合适。
两者的性能与竞争程度、临界区长度和 JDK 实现有关,不能简单认定某一种始终更快。
继续追问:为什么 ReentrantLock 必须在 finally 中释放锁?
因为 ReentrantLock 需要手动解锁。如果业务代码发生异常,没有执行 unlock(),锁就可能一直被持有,导致其他线程长期等待。使用 finally 可以保证成功获取锁之后,无论业务代码是否异常退出,都能执行相应的释放操作。
3.5 公平锁和非公平锁有什么区别?为什么默认使用非公平锁?
公平锁在存在竞争时,通常优先让等待时间较长的线程获取锁;非公平锁允许后来的线程在特定情况下直接抢占锁。
ReentrantLock 默认使用非公平锁。
公平锁获取锁时,会考虑等待队列中是否存在更早的等待线程,从而尽量减少插队。
非公平锁允许新线程在锁刚好空闲时直接竞争,减少部分等待和线程调度开销,因此通常有更高的吞吐量。
但非公平锁可能让部分线程长期得不到锁,产生饥饿问题。
公平锁可以改善等待公平性,但不能保证操作系统严格按照线程请求时间调度。
另外,ReentrantLock 的无参 tryLock() 即使在公平模式下,也可能直接获取空闲锁。
因此,大部分业务场景使用默认的非公平锁即可;只有对等待公平性有明确要求时,才考虑公平锁。
3.6 读写锁是什么?什么时候使用比较合适?
读写锁通过区分读操作和写操作提高共享数据的并发访问能力。
它的并发规则是:
-
读锁与读锁兼容:多个线程可以同时读取。
-
读锁与写锁互斥:读取过程中需要阻止其他线程写入。
-
写锁与写锁互斥:同一时刻只能有一个线程执行写操作。
Java 常见实现是 ReentrantReadWriteLock。
例如,一个本地配置缓存,大量请求只读取配置,少量操作更新配置。使用普通互斥锁会让读取操作相互等待,而读写锁允许多个读线程并发执行。
不过读写锁存在管理成本。如果写操作频繁、临界区很短,读写锁未必比普通互斥锁性能更好。
因此,它更适合读多写少、读操作具有一定开销的场景。
继续追问:读写锁支持锁升级和降级吗?
ReentrantReadWriteLock 支持写锁降级为读锁:持有写锁时先获取读锁,再释放写锁,可以保证数据更新后继续受到读锁保护。
它不支持直接从读锁升级为写锁,因为多个线程可能同时持有读锁并等待升级,造成相互等待。
3.7 wait/notify 和 Condition 有什么区别?为什么等待要使用 while?
两者都用于线程之间的条件等待和通知,核心思想是:条件不满足时释放关联的锁并等待,收到通知后重新竞争锁,再检查条件。
wait()、notify() 和 notifyAll() 属于 Object 的监视器机制,需要配合 synchronized 使用。
Condition 通常配合 ReentrantLock 使用,通过 await()、signal() 和 signalAll() 实现等待通知。
两者最明显的区别是,一把 ReentrantLock 可以关联多个 Condition,将不同等待条件分开管理。
例如有界阻塞队列,可以分别建立“队列非空”和“队列未满”两个条件队列,减少无关线程被唤醒。
等待条件通常使用 while 检查,因为线程可能发生虚假唤醒,也可能在重新获得锁之前,发现条件已经被其他线程改变。
另外,notify() 和 signal() 只负责通知等待线程,不会立即释放当前持有的锁。被唤醒线程仍需要重新获得锁才能继续执行。
3.8 悲观锁、乐观锁、CAS 和 volatile 有什么关系?
悲观锁和乐观锁是处理并发冲突的两种思路。
悲观锁通常在操作共享资源之前获取锁,阻止其他线程同时修改。例如 synchronized 和 ReentrantLock。
乐观锁允许线程先尝试操作,通过 CAS 或版本号等方式检查冲突,失败后重试或执行其他处理。
CAS 即 Compare-And-Set,比较并设置。其基本过程是:
读取预期值 → 比较当前值是否等于预期值 → 相等则原子更新 → 不相等则更新失败。
例如 AtomicInteger 可以使用原子操作完成计数器自增,避免普通 count++ 在多线程下发生数据竞争。
volatile 主要提供可见性和一定的有序性保证,但单独使用无法保证 count++ 这种读、改、写复合操作的原子性。
CAS 适合简单共享状态的原子更新,但高竞争时可能产生较多重试,也存在 ABA 问题。
继续追问:CAS 的 ABA 问题是什么?
ABA 指一个值原来是 A,被其他线程修改成 B,随后又改回 A。此时 CAS 只比较当前值,可能认为数据没有发生变化。
如果业务逻辑关心中间变化过程,可以通过增加版本号解决,例如使用 AtomicStampedReference 将引用和版本信息一起进行比较更新。
3.9 什么是死锁?如何预防和排查?
死锁指多个线程互相等待对方持有的资源,导致所有相关线程都无法继续执行。
例如,线程 A 持有锁 1,等待锁 2;线程 B 持有锁 2,等待锁 1,双方形成循环等待。
经典死锁需要同时满足四个条件:
-
互斥条件:资源同一时刻只能被一个线程占用。
-
请求并保持:线程持有资源时继续请求其他资源。
-
不可抢占:资源不能被其他线程强制夺走。
-
循环等待:多个线程形成循环等待关系。
实际开发中,最常用的预防方式是统一加锁顺序。例如所有线程都先获取锁 1,再获取锁 2。
另外,还可以缩短持锁时间,避免持锁执行耗时操作,并在适合的场景下使用 tryLock(timeout) 设置超时退出机制。
排查时,可以通过 jstack 或 jcmd Thread.print -l 查看线程堆栈和锁持有情况,检查是否存在循环等待。
如果系统出现线程大量阻塞、接口超时,可以重点分析线程状态、锁竞争关系和持锁代码。
3.10 synchronized 有哪些锁优化?锁升级是固定流程吗?
synchronized 的优化目标主要是降低无竞争时的加锁成本,以及减少竞争时不必要的阻塞和线程切换。
HotSpot JVM 在不同版本中采用过偏向锁、轻量级锁、自旋和监视器膨胀等技术。
这些机制的基本思路是:
-
轻量级锁:在竞争较少时,尽量使用成本较低的方式完成同步。
-
自旋:在合适情况下短暂重试获取锁,减少立即挂起线程的成本。
-
监视器膨胀:竞争无法通过轻量级机制有效处理时,使用更完整的监视器竞争与等待机制。
-
锁消除、锁粗化:JIT 编译器通过分析减少不必要的加锁,或者合并相邻的同步区域。
需要注意,常见的“无锁 → 偏向锁 → 轻量级锁 → 重量级锁”描述主要来自较早的 HotSpot 实现。
从 JDK 15 开始,偏向锁已经默认禁用,现代 JDK 的具体锁实现也在持续演进,因此不能把这套顺序理解成所有版本都遵循的固定流程。
此外,JDK 24 通过 JEP 491 改进了虚拟线程与 synchronized 的协作,解决了此前大量因监视器同步而发生的虚拟线程固定平台线程问题。
实际开发中,锁竞争的优化通常优先从缩小临界区、减少共享状态、降低持锁时间入手,再结合运行时指标判断是否需要调整锁的实现。
4. 易混淆概念对比
4.1 synchronized 与 ReentrantLock
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现方式 | JVM 内置监视器机制 | JUC,基于 AQS |
| 可重入 | 支持 | 支持 |
| 释放锁 | 自动释放 | 显式 unlock() |
| 公平锁 | 不提供公平模式配置 | 支持配置 |
| 等待获取锁可中断 | 不支持直接中断锁获取 | lockInterruptibly() |
| 超时获取锁 | 不支持 | tryLock(timeout) |
| 条件等待 | wait/notify |
Condition |
| 适用场景 | 普通互斥同步 | 复杂锁控制 |
4.2 synchronized、volatile 与 CAS
| 对比维度 | synchronized | volatile | CAS |
|---|---|---|---|
| 核心作用 | 临界区互斥访问 | 可见性、有序性 | 单次原子条件更新 |
| 复合操作原子性 | 锁保护范围内可以保证 | 单独使用无法保证 | 需要设计原子更新逻辑 |
| 是否可能阻塞线程 | 是 | 否 | CAS 操作本身不会等待锁 |
| 常见场景 | 共享对象状态修改 | 状态标记 | 原子计数、状态更新 |
4.3 公平锁与非公平锁
| 对比维度 | 公平锁 | 非公平锁 |
|---|---|---|
| 获取顺序 | 优先较早等待的线程 | 允许插队 |
| 吞吐量 | 通常相对较低 | 通常相对较高 |
| 饥饿风险 | 相对较低 | 可能发生 |
| 典型选择 | 对等待公平性有要求 | 大多数普通业务场景 |
4.4 ReentrantReadWriteLock 与 StampedLock
| 对比维度 | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| 读模式 | 共享读锁 | 悲观读锁、乐观读 |
| 写模式 | 独占写锁 | 独占写锁 |
| 可重入 | 支持 | 不支持 |
| 乐观读校验 | 不支持 | 通过版本戳校验 |
| 锁转换 | 支持写锁降级为读锁 | 支持尝试转换锁模式 |
| 使用场景 | 一般读多写少 | 读操作短、写冲突少且能处理重试 |
StampedLock 的乐观读需要在读取后执行 validate() 校验。如果期间发生写入干扰,需要重新读取,通常会退回悲观读锁。
由于乐观读期间可能观察到不一致的中间状态,它更适合能够安全处理这种情况的数据结构和短读取逻辑。
4.5 wait、sleep 与 Condition.await
| 对比维度 | wait() | sleep() | Condition.await() |
|---|---|---|---|
| 所属机制 | Object 监视器 | Thread 线程休眠 | Condition 条件等待 |
| 是否释放关联锁 | 释放调用对象的监视器锁 | 不释放已持有的锁 | 释放关联的 Lock |
| 使用前是否需要持锁 | 是 | 否 | 是 |
| 唤醒方式 | notify、notifyAll、中断等 | 时间到期、中断 | signal、signalAll、中断等 |
| 典型场景 | 监视器线程协作 | 延迟执行 | 复杂条件等待 |
5. 总结
Java 锁主要用于解决多线程访问共享资源时的线程安全问题,通过互斥访问和内存同步保证数据操作的正确性。常用的 synchronized 基于 JVM 监视器实现,支持可重入和自动释放锁,适合一般互斥场景;ReentrantLock 基于 AQS,通过同步状态、CAS 和等待队列管理线程竞争,提供公平锁、可中断获取锁、超时获取锁等能力。对于读多写少的场景,还可以通过读写锁提升读取并发能力。实际使用时,需要根据共享资源的访问方式和竞争程度选择锁,同时控制锁粒度、缩短持锁时间,避免死锁和不必要的性能开销。