并发 - 锁机制 (Synchronized vs ReentrantLock)
并发编程 —— 锁机制 (Synchronized vs ReentrantLock)
1. 核心理论:为什么需要锁?
在多线程环境下,如果多个线程同时对一个共享资源 (shared resource)(也叫临界资源,如一个全局变量、一个银行账户对象)进行写操作 (write operation),就会发生数据冲突,导致线程安全问题 (thread safety issue)。例如,两个线程同时对一个变量 i 执行 i++,结果可能只增加了1,而不是2。
锁 (Lock) 就是为了解决这个问题而生的。它是一种同步机制,用于控制多个线程对共享资源的访问。
-
核心思想: 在某个线程访问共享资源之前,先获取锁;访问结束后,再释放锁。只要这个线程还持有锁,其他任何试图获取该锁的线程都会被阻塞 (blocked),直到锁被释放。这保证了在同一时刻,只有一个线程能操作共享资源。
-
互斥性 (mutual exclusion): 锁的核心特性是互斥,即排他性。
2. 深度剖析:Synchronized vs ReentrantLock
Synchronized 和 ReentrantLock 是 Java 中最常用的两种锁,理解它们的区别是面试的重中之重。
2.1 Synchronized (内置锁/监视器锁)
- 本质: 是 JVM 层面实现的关键字 (keyword),而非 API。它通过进入和退出
Monitor (监视器)对象来实现方法同步和代码块同步。
什么是 Monitor (监视器)?
在 Java 中,每个对象都关联着一个 Monitor。当一个线程执行
synchronized方法或代码块时,它会尝试获取该对象 Monitor 的所有权。Monitor 确保了在任何给定时间点,只有一个线程可以持有锁并执行临界区代码,从而实现互斥。同时,它也通过wait(),notify()和notifyAll()方法提供了线程间的协调机制。
-
用法:
-
修饰实例方法: 锁是当前类的实例对象 (instance object) (
this)。 -
修饰静态方法: 锁是当前类的Class 对象 (Class object) (
Xxx.class)。 -
修饰代码块: 锁是括号里指定的任何对象 (any object)。
-
-
特点:
-
非公平锁 (non-fair lock): 默认情况下,线程获取锁的顺序是随机的,不保证先到先得。
-
可重入锁 (reentrant lock): 一个已经持有锁的线程,可以再次成功获取该锁而不会被自己阻塞。这是为了防止死锁 (deadlock)。
-
-
Synchronized的原理与“高科技厕所”:-
实现: JVM 通过在编译后的字节码中插入
monitorenter (进入监视器)和monitorexit (退出监视器)指令来控制同步。 -
锁升级 (lock escalation) 与比喻: 为了提高性能,
Synchronized引入了锁升级机制。我们可以把它想象成一个高科技厕所的门锁:-
偏向锁 (biased lock): 你是第一个来上厕所的人。厕所很智能,记住了你的样子(线程ID (thread ID))。你反复进出,门锁都直接为你敞开,效率最高。
-
轻量级锁 (lightweight lock): 这时,线程B也想上厕所,发现门偏向于你。他敲门,等你出来后,门锁意识到“不止一个人想用”,于是取消对你的偏爱,升级成一个普通的“门闩”。现在谁想进,都得自己先看看门闩有没有锁上,然后快速地自己锁上(CAS (Compare-And-Swap) 操作)。这个过程很快,不需要管理员。
-
重量级锁 (heavyweight lock): 如果很多人(多线程)同时来抢厕所,造成了混乱(CAS自旋失败)。这时,管理员(操作系统 (operating system))就出来了,拿出本子说:“都别抢,来我这排队登记!” 于是所有后来的线程都被安排去排队(进入等待队列 (waiting queue),线程被挂起),只有管理员叫到号的人才能进去。这个过程开销最大,但秩序井然。
- 代码示例:
public synchronized void methodA() { System.out.println("进入 methodA"); methodB(); // 在持有锁的情况下,调用另一个同步方法 } public synchronized void methodB() { System.out.println("进入 methodB"); } // 如果锁不是可重入的,线程在执行methodA时调用methodB会死锁。 -
-
自动释放 (automatic release): JVM 会自动在同步代码块执行完毕或抛出异常时释放锁,无需手动操作,不易出错。
-
不可中断 (non-interruptible): 一个线程在等待获取锁时,不能被中断。
-
2.2 ReentrantLock (可重入锁)
- 本质: 是 JUC 包 (
java.util.concurrent.locks) 提供的一个 API 类 (API class),它实现了Lock接口。
什么是 JUC?
JUC 是
java.util.concurrent包 的简称,是 Java 并发编程领域的核心工具包,由并发大师 Doug Lea 设计开发。它提供了比传统synchronized和wait/notify机制更高级、更灵活、更高效的并发工具,例如:
- 锁框架 (Locks): 如
ReentrantLock、ReadWriteLock。
- 原子类 (Atomics): 如
AtomicInteger。
- 线程池 (Executors): 用于高效管理线程。
- 并发集合 (Concurrent Collections): 如
ConcurrentHashMap。
- 同步工具 (Synchronizers): 如
Semaphore,CountDownLatch等。
-
用法: 必须手动 (manually) 加锁和释放锁。
Lock lock = new ReentrantLock(); lock.lock(); // 加锁 try { // 临界区代码 } finally { lock.unlock(); // 必须在 finally 块 (finally block) 中释放锁! } -
特点:
-
可重入锁 (reentrant lock): 和
Synchronized一样,也是可重入的。 -
可配置公平性: 可以在构造时选择创建公平锁 (fair lock) (
new ReentrantLock(true)) 或非公平锁 (non-fair lock)(默认)。公平锁会按线程请求的顺序分配锁,但性能通常低于非公平锁。 -
手动释放 (manual release): 必须手动调用
unlock()释放锁,如果忘记释放,会造成死锁 (deadlock)。 -
可中断获取 (interruptible acquisition): 提供了
lockInterruptibly()方法,允许线程在等待锁的过程中响应中断。 -
可超时获取 (timeout acquisition): 提供了
tryLock()方法,可以尝试获取锁,如果获取不到立即返回false,或者在指定时间内获取不到就放弃。 -
可绑定多个条件: 可以关联多个
Condition (条件)对象,实现更精确的线程等待和唤醒控制(生产者消费者模式 (producer-consumer pattern))。
-
-
ReentrantLock的原理与“手动排队厕所”:-
实现: 它的核心是基于 JUC 的 AQS (AbstractQueuedSynchronizer, 抽象队列同步器) 框架。
-
AQS 核心思想与比喻: 我们可以把它想象成一个需要手动操作的、带排队系统的公共厕所。
-
state(状态) 变量: 就是厕所门上那个“有人/无人”的牌子。state=0表示“无人”,state=1表示“有人”。 -
CAS操作: 你想上厕所,需要自己伸手去把牌子从“无人”翻成“有人”(CAS尝试修改state)。如果成功,你就获得了锁。 -
等待队列 (waiting queue): 当你伸手时,发现牌子已经是“有人”了(CAS失败)。你不会傻等,而是走到门外的一个排队区域(AQS的等待队列),坐下来玩手机(线程被打包成 Node (节点) 并挂起)。
-
unlock(解锁) 操作: 里面的人出来后,必须负责任地把牌子从“有人”翻回到“无人”(修改state为0),然后他会朝排队区的第一个人喊:“嘿,你可以进来了!”(唤醒 (wake up) 队列中的头节点)。
-
-
2.3 对比总结
| 特性 | Synchronized | ReentrantLock |
| :--- | :--- | :--- |
| 本质 | Java 关键字 (keyword),JVM 实现 | JUC 中的 API 类 (API class),基于 AQS |
| 锁释放 | 自动释放 (automatic release) | 必须手动释放 (manual release) |
| 公平性 | 非公平 (non-fair) | 可选公平/非公平 (fair/non-fair) |
| 功能 | 基本功能 (basic functionality) | 功能更丰富 (richer functionality)(可中断 (interruptible)、可超时 (timeout)、多条件 (multiple conditions)) |
| 性能 | JDK 1.6 后有锁升级优化,性能很高 | 理论上更高,尤其在高竞争 (contention) 下 |
如何选择?
-
优先
Synchronized: 在锁竞争 (lock contention) 不激烈、功能简单的场景下,优先使用Synchronized。因为它语法简单,且由 JVM 自动管理,不易出错。 -
使用
ReentrantLock: 当需要更高级的功能,如公平锁、可中断获取、超时获取或多条件等待时,才使用ReentrantLock。
2.4 Synchronized 如何保证原子性、可见性和有序性?
synchronized 关键字之所以能成为保证并发安全的重要手段,就是因为它能同时保证原子性、可见性、有序性三大特性。
-
如何保证原子性 (Atomicity)
-
synchronized通过互斥来实现原子性。当一个线程进入一个synchronized修饰的代码块或方法时,它会获取一个锁(也称为监视器锁或 Monitor)。在它释放锁之前,其他任何尝试进入同一个锁保护的代码块的线程都会被阻塞。 -
这就保证了在同一时刻,只有一个线程能执行被
synchronized保护的代码块,使得这个代码块内的所有操作对于其他线程来说,要么都执行完了,要么都还没开始,形成了一个不可分割的原子操作。
-
-
如何保证可见性 (Visibility)
-
synchronized的可见性是由 Java 内存模型(JMM)中的happens-before规则来保证的。JMM 规定:-
线程加锁时 (enter):会清空当前线程工作内存中共享变量的值,强制从主内存中重新读取最新值。
-
线程解锁时 (exit):必须将当前线程工作内存中对共享变量的修改刷新到主内存中。
-
-
这“一进一出”的操作,确保了一个线程在释放锁之前对共享变量的修改,对下一个获取到同一个锁的线程是完全可见的。
-
-
如何保证有序性 (Ordering)
-
synchronized同样可以保证有序性。因为一个线程在synchronized块内部的所有操作,在 JMM 的视角下,对于另一个随后获取同一个锁的线程来说,是完全可见且有序的。 -
更直接地说,由于每次只有一个线程能执行同步代码块,这实际上就等同于将多线程的并发执行,在同步代码块的范围内,强制变成了串行执行。既然是串行执行,自然就不会存在指令重排序导致的多线程问题。
-
3. 生活中的例子与代码示例
-
生活比喻: 想象一个会议室(共享资源 (shared resource)),多个团队(线程 (thread))都想使用它。
-
Synchronized: 就像会议室的门是一个声控自动门。第一个团队进去后,喊一声“开会!”,门自动锁上(获取锁 (acquire lock))。外面的人怎么喊,门都不会开。会议结束后,团队成员一出门,门自动就解锁了(释放锁 (release lock))。整个过程由门禁系统自动完成,非常省心。但如果里面的人开会时间特别长,外面等待的团队只能死等,不能说“我们不等了,先去干点别的”(不可中断 (non-interruptible))。 -
ReentrantLock: 就像会议室的门是一个需要刷员工卡的手动门禁。你需要主动刷卡开门进去(lock()),并且走的时候,必须记得在内部按一下“出门”按钮,把门打开(unlock())。如果你忘了按,下个团队就永远进不来了(死锁 (deadlock))。但这个门禁系统功能更强大:你可以尝试刷卡(tryLock),可以按刷卡顺序排队(公平锁 (fair lock)),也可以在排队时随时放弃去干别的事(可中断 (interruptible))。
-
-
核心代码示例: 模拟多线程同时取钱
package com.study.concurrency;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// 共享资源:银行账户
class UnsafeAccount {
private double balance;
public UnsafeAccount(double balance) {
this.balance = balance;
}
// 未加锁的取钱方法,会产生线程安全问题
public void withdraw(double amount) {
if (this.balance >= amount) {
System.out.println(Thread.currentThread().getName() + " 准备取款: " + amount);
try {
Thread.sleep(100); // 模拟网络延迟,放大问题
} catch (InterruptedException e) {
e.printStackTrace();
}
this.balance -= amount;
System.out.println(Thread.currentThread().getName() + " 取款成功,余额: " + this.balance);
} else {
System.out.println(Thread.currentThread().getName() + " 尝试取款失败,余额不足。");
}
}
}
// 使用 Synchronized 保证安全的账户
class SynchronizedAccount extends UnsafeAccount {
public SynchronizedAccount(double balance) { super(balance); }
// 使用 synchronized 修饰方法,锁是 this 对象
@Override
public synchronized void withdraw(double amount) {
super.withdraw(amount);
}
}
// 使用 ReentrantLock 保证安全的账户
class ReentrantLockAccount extends UnsafeAccount {
private final Lock lock = new ReentrantLock();
public ReentrantLockAccount(double balance) { super(balance); }
@Override
public void withdraw(double amount) {
lock.lock(); // 手动加锁
try {
super.withdraw(amount); // 调用父类不安全的方法,但此时被锁保护
} finally {
lock.unlock(); // 必须在 finally 中手动解锁
}
}
}
public class LockComparison {
public static void main(String[] args) throws InterruptedException {
// 为了演示,我们让两个线程取同一个账户的钱
UnsafeAccount account = new ReentrantLockAccount(1000); // 你可以换成 SynchronizedAccount 或 UnsafeAccount 观察不同结果
Runnable task = () -> account.withdraw(800);
Thread t1 = new Thread(task, "线程A");
Thread t2 = new Thread(task, "线程B");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终结果:两个线程都尝试取款800元...");
// 如果是 UnsafeAccount,你会看到两个线程都提示“准备取款”,最终余额变为负数或非预期值。
// 如果是 SynchronizedAccount 或 ReentrantLockAccount,你会看到一个线程成功,另一个失败,结果正确。
}
}

浙公网安备 33010602011771号