深入解析Java并发编程中的锁机制与底层原理
悲观锁与乐观锁的并发控制策略
在多线程环境下处理共享资源竞争时,通常会采用悲观或乐观的并发控制策略。
悲观锁假设数据在修改时极易发生冲突,因此在操作前必须强制加锁,确保独占访问。synchronized 关键字和 Lock 接口的实现类(如 ReentrantLock)均属于此类。它适用于写多读少的场景,通过显式锁定保障数据一致性。
public class StockManager {
private final ReentrantLock lock = new ReentrantLock();
private int inventory = 1000;
public void reduceStock(int quantity) {
lock.lock();
try {
if (inventory >= quantity) {
inventory -= quantity;
}
} finally {
lock.unlock();
}
}
}
乐观锁则假设冲突概率较低,操作时不加锁,仅在更新时校验数据是否被其他线程篡改。常见实现包括版本号机制和 CAS(Compare-And-Swap)算法。为避免 CAS 中的 ABA 问题(即值从 A 变为 B 再变回 A),可使用 AtomicStampedReference 引入版本戳进行双重校验。乐观锁适用于读多写少的场景,能大幅提升吞吐量。
public class OptimisticMetric {
private final AtomicStampedReference<Integer> metric =
new AtomicStampedReference<>(0, 1);
public void increment() {
int currentStamp = metric.getStamp();
int currentValue = metric.getReference();
metric.compareAndSet(currentValue, currentValue + 1,
currentStamp, currentStamp + 1);
}
}
synchronized 锁对象解析与字节码原理
探讨 synchronized 时,核心在于明确"锁住的到底是什么"。高并发编码规范建议:能用对象锁就不用类锁,能锁代码块就不锁整个方法,以最小化锁的粒度。
- 实例同步方法:锁对象为当前实例(
this)。 - 静态同步方法:锁对象为当前类的
Class对象。 - 同步代码块:锁对象为括号内指定的引用对象。
基于此规则,若两个线程分别调用同一实例的两个实例同步方法,会产生互斥;若调用两个不同实例的同步方法,则互不干扰。同理,实例同步方法与静态同步方法由于锁对象不同(实例 vs Class),也不会产生竞争。
从 JVM 字节码层面分析其实现机制:
- 同步代码块:通过
monitorenter和monitorexit指令实现。编译器会确保每个monitorenter都有对应的monitorexit。即使在代码块中抛出异常,JVM 也会通过异常表(Exception table)隐式插入monitorexit指令,保证锁的释放。 - 同步方法:依赖方法的
ACC_SYNCHRONIZED访问标志。JVM 在方法调用时检查该标志,若被设置,则要求线程先持有对应的 Monitor 对象才能执行方法体。
Monitor(管程)底层机制
在 HotSpot 虚拟机中,Monitor 由 C++ 实现的 ObjectMonitor 类支撑。每个 Java 对象在内存中都关联着一个 Monitor。当线程尝试获取锁时,实际上是尝试获取该对象关联的 Monitor。ObjectMonitor 的核心属性包括:
_owner:指向当前持有该锁的线程。_count:记录锁的重入次数。_EntryList:存放处于阻塞状态等待获取锁的线程队列。_WaitSet:存放调用wait()方法后处于等待状态的线程队列。
这种设计确保了在同一时刻,最多只有一个线程能执行 Monitor 保护的临界区代码。
ReentrantLock:公平锁与非公平锁
ReentrantLock 提供了比 synchronized 更灵活的锁控制,支持公平与非公平模式。
- 非公平锁(默认):允许线程"插队"。当锁释放时,刚释放锁的线程或新来的线程可能直接通过 CAS 获取锁,无需排队。这减少了线程上下文切换的开销,吞吐量更高,但可能导致部分线程饥饿。
- 公平锁:严格按照线程申请锁的顺序(FIFO)分配。通过
new ReentrantLock(true)创建。适用于对资源分配公平性要求极高的场景,但性能略低于非公平锁。
public class TaskDispatcher {
// 非公平锁,追求高吞吐量
private final ReentrantLock unfairLock = new ReentrantLock(false);
// 公平锁,保证任务按顺序执行
private final ReentrantLock fairLock = new ReentrantLock(true);
public void executeTaskFairly(Runnable task) {
fairLock.lock();
try {
task.run();
} finally {
fairLock.unlock();
}
}
}
可重入锁(递归锁)的设计
可重入锁允许同一个线程在已持有锁的情况下,再次进入由该锁保护的代码块而不会被阻塞。这有效避免了单线程内的死锁问题(例如递归调用)。
- 隐式可重入:
synchronized默认支持。底层通过ObjectMonitor的_count计数器实现,每次重入_count加 1,退出时减 1,直到为 0 才真正释放锁。 - 显式可重入:
ReentrantLock同样支持。使用时必须确保lock()和unlock()严格成对出现,否则会导致锁计数异常,引发多线程死锁。
public class NodeTraversal {
private final Lock lock = new ReentrantLock();
public void traverse(Node node) {
lock.lock();
try {
System.out.println("Processing node: " + node.getId());
if (node.hasChildren()) {
// 递归调用,由于是可重入锁,当前线程不会被自己阻塞
traverse(node.getFirstChild());
}
} finally {
lock.unlock();
}
}
}
死锁的产生与排查手段
死锁是指多个线程因循环等待彼此持有的资源而陷入永久阻塞的状态。产生死锁通常需要满足四个必要条件:互斥、请求与保持、不剥夺、循环等待。在代码层面,最典型的场景是双向资源锁定。
public class FundTransfer {
private final String accountId;
private double balance;
public FundTransfer(String accountId, double balance) {
this.accountId = accountId;
this.balance = balance;
}
public void transferTo(FundTransfer target, double amount) {
synchronized (this) {
synchronized (target) {
if (this.balance >= amount) {
this.balance -= amount;
target.balance += amount;
}
}
}
}
}
// 若线程A执行 account1.transferTo(account2),同时线程B执行 account2.transferTo(account1),即会触发死锁。
排查死锁的常用工具与手段:
- 命令行工具:使用
jps -l获取 Java 进程 PID,随后执行jstack <PID>查看线程堆栈。JVM 会自动检测并打印出形成死锁的线程信息及它们正在等待的锁对象。 - 图形化工具:使用
jconsole或jvisualvm,连接到目标进程后,在"线程"面板点击"检测死锁"功能,即可直观定位产生死锁的具体代码行。