锁
并行编程有两个经典的问题:一是数据竞争,二是死锁。

数据竞争
数据竞争(data race)是指多个线程同时访问同一个共享变量,其中至少有一个线程执行写操作,而访问顺序没有被正确同步(如加锁、volatile 等)时,程序结果不确定的问题。
经典的例子是 i++:
public class DataRaceExample {
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
i++;
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
i++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i); // 期望输出 20000,实际大概率小于 20000
}
}i++ 看似一行代码,在字节码层面却不是一个原子操作,它包含"读-改-写"三个步骤:
- 从内存中读取
i的值; - 将读到的值加 1;
- 将新值写回内存。
当两个线程同时执行 i++ 时,可能都读到了同一个旧值,各自加 1 后写回,两次自增最终只生效一次,产生"丢失更新"。
一种常用的解决数据竞争的办法是借助于锁。
锁
锁的本质是控制同一时刻能进入临界区的线程数量:独享锁同一时刻只允许一个线程进入临界区,共享锁则允许一类线程同时进入,从而保证对共享数据的操作是原子的、可见的。
Java提供了种类丰富的锁,由于不同的特性,使得在合适的场景下效率非常高。以下是按照特性分类的锁种类。 
悲观锁 & 乐观锁
悲观锁和乐观锁是看待并发冲突的两种不同哲学:
- 悲观锁:悲观的认为每次访问共享数据都一定会发生冲突,所以在访问之前先加锁,独占数据直到操作完成才释放。同一时刻只有一个线程能访问数据,其他线程阻塞等待。Java 中的
synchronized、ReentrantLock以及数据库中的行锁、表锁都属于悲观锁。 - 乐观锁:乐观的认为冲突不会经常发生,所以不加锁直接操作,在提交修改时再检查是否有其他线程修改过数据,如果有则重试或放弃。Java 中的 CAS 以及数据库中的版本号机制都属于乐观锁。

悲观锁适合写操作多、冲突激烈的场景;乐观锁适合读多写少、冲突较少的场景,省去了加锁和线程切换的开销。
CAS
CAS(Compare And Swap,比较并交换)是乐观锁的实现基础,是一条 CPU 原子指令,包含三个操作数:内存地址 V、期望值 A 和新值 B。执行时先比较 V 的当前值是否等于 A,相等则把 V 更新为 B,否则不做任何操作,整个"比较+替换"过程是原子的。
Java 通过 sun.misc.Unsafe 类提供 CAS 操作,AtomicInteger、AtomicLong 等原子类以及 AQS(AbstractQueuedSynchronizer)都是基于 CAS 实现的:
// AtomicInteger 的 getAndIncrement 底层实现
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}CAS与其问题
- ABA 问题:CAS 只比较值是否相等,无法感知值在"读取-比较"期间是否被其他线程修改过又改回来。例如线程 1 读到值 A,此时线程 2 把 A 改成 B 又改回 A,线程 1 执行 CAS 时发现值还是 A,认为没有被修改过,于是成功更新——但实际上数据已经变过了。解决方法是给值附加版本号,Java 提供了
AtomicStampedReference和AtomicMarkableReference来记录版本信息。 - 循环时间长开销大:CAS 失败后会不断自旋重试,如果竞争激烈,长时间自旋会大量消耗 CPU 资源。
- 只能保证一个共享变量的原子操作:CAS 只能对单个变量进行原子操作。当多个变量需要原子更新时,可以把它们封装成一个对象,用
AtomicReference保证引用替换的原子性;或者直接使用锁。
自旋锁 & 适应性自旋锁
自旋锁:线程在尝试获取锁失败时不进入阻塞状态,而是在原地循环(自旋)等待锁的释放。阻塞和唤醒线程需要操作系统在内核态和用户态之间切换,开销较大,自旋避免了线程切换的开销;但自旋会一直占用 CPU,如果锁被持有的时间很长,自旋就是在白白浪费 CPU 资源。因此自旋锁适用于临界区代码执行时间很短的场景。
synchronized 在升级为重量级锁之前,会先自旋尝试获取锁。
适应性自旋锁:JDK 1.6 引入。自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态决定:
- 如果线程上一次在同一个锁上自旋后成功获得了锁,那么这次自旋的时间会更长,因为 JVM 认为这次成功的概率也高;
- 如果线程很少在某个锁上自旋成功,那么之后可能会直接放弃自旋进入阻塞,避免浪费 CPU。
适应性自旋让 JVM 能够根据历史运行情况动态调整自旋策略。

无锁 & 偏向锁 & 轻量级锁 & 重量级锁
这四种状态是 synchronized 锁记录在对象头(mark word)中的锁状态,会随着竞争情况自动升级(且不可降级):
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
- 无锁:对象刚创建时没有任何线程竞争,mark word 中不包含锁信息。
- 偏向锁:锁第一次被线程获取时记录持有线程的 ID,之后这个线程再次进入同步块时无需任何同步操作(连 CAS 都不需要),只检查线程 ID 即可,适用于只有一个线程反复获取同一把锁的场景。当第二个线程尝试获取锁时,偏向锁撤销,升级为轻量级锁。偏向锁在 JDK 15 默认关闭,JDK 18 已被废弃。
- 轻量级锁:当有第二个线程竞争锁时,偏向锁升级为轻量级锁。轻量级锁通过 CAS 将 mark word 指向线程栈中的锁记录(Lock Record),获取失败的线程先自旋等待。适用于线程交替执行同步块、竞争不激烈的场景,避免了重量级锁的线程阻塞开销。
- 重量级锁:当自旋的线程增多、自旋等待超过一定限度时,轻量级锁升级为重量级锁。重量级锁基于操作系统的互斥量(Mutex)实现,未获得锁的线程进入阻塞状态由操作系统调度,线程切换开销大,但不会空耗 CPU。
公平锁 & 非公平锁
- 公平锁:多个线程严格按照请求锁的顺序(FIFO)获取锁,先请求的线程先获得锁,不存在"插队",因此不会产生饥饿。但每次加锁前都需要检查等待队列,且刚被唤醒的线程未必抢得过新来的线程,吞吐量较低。
ReentrantLock(true)即公平锁。 - 非公平锁:线程获取锁时先直接尝试抢锁(CAS),抢不到再进入等待队列。新来的线程可能与等待队列中的线程同时竞争锁,可能"插队",存在饥饿风险,但省去了排队检查的开销,吞吐量更高。
synchronized和ReentrantLock()(默认构造)都是非公平锁。
选择:对吞吐量要求高、能容忍个别线程偶尔"插队"时使用非公平锁;对公平性有严格要求(避免饥饿)时使用公平锁。
可重入锁 & 非可重入锁
可重入锁:同一个线程可以多次获取同一把锁而不会被自己阻塞。可重入锁内部维护持有线程和计数器:线程第一次获取锁时计数器加 1,之后每次重入再加 1,释放锁时减 1,计数器归零时才真正释放锁。
Java 中的 synchronized 和 ReentrantLock 都是可重入锁。可重入的意义在于:同步方法内部调用另一个同步方法、递归调用等场景不会产生死锁:
public synchronized void methodA() {
methodB(); // methodB 需要获取同一把锁,可重入锁不会死锁
}
public synchronized void methodB() {
// ...
}非可重入锁:同一个线程获取锁之后再次获取同一把锁会被阻塞,最终导致死锁。下面是一个简单的非可重入锁实现:
class NonReentrantLock {
private boolean locked = false;
public synchronized void lock() throws InterruptedException {
while (locked) {
wait();
}
locked = true;
}
public synchronized void unlock() {
locked = false;
notify();
}
}如果同一个线程连续两次调用 lock(),第二次调用会因为 locked == true 而进入 wait(),把自己阻塞住,产生死锁。
独享锁 & 共享锁
- 独享锁(排他锁):同一时刻只能被一个线程持有。
synchronized、ReentrantLock都是独享锁;ReentrantReadWriteLock的写锁也是独享锁——写锁被持有时,其他线程既不能读也不能写。 - 共享锁:可以被多个线程同时持有。
ReentrantReadWriteLock的读锁是共享锁——多个线程可以同时持有读锁进行读操作;Semaphore、CountDownLatch也属于共享锁。
读写锁的组合规则:
- 读 + 读:可以共存,多个线程同时读;
- 读 + 写:互斥,有线程在读时不能写,有线程在写时不能读;
- 写 + 写:互斥。
读写锁适合读多写少的场景,读操作之间完全并行,只有写操作才互斥,相比 synchronized 大大提高了并发度。需要注意的是,写锁可以降级为读锁,但读锁不能升级为写锁。