前言
Java不秃,面试不慌!
欢迎来到这片 Java修炼场!这里没有枯燥的教科书,只有每日一更的 硬核知识+幽默吐槽,让你在欢笑中掌握 Java基础、算法、面试套路,摆脱“写代码如写诗、看代码如看天书”的困境。
什么是锁?用通俗易懂的方式解释
锁(Lock)就像是一把 “门锁”,控制多个线程(或者多个任务)访问 同一个资源,防止它们互相踩踏,导致数据混乱。
想象一下,你和朋友们一起去 共享单车停车点,但是 只有一辆单车:
- 如果没有锁,大家可能会 同时抢车,结果要么 谁也骑不了(车被抬翻),要么 两个人一起骑(灾难现场)。
- 但如果有锁,一个人上车之前先上锁,骑完之后再解锁,其他人才有机会骑。
在计算机里,多个线程可能同时想修改 同一个变量、同一个文件、同一个数据库,如果没有锁,它们可能会 互相干扰,导致数据错误,比如:、
class BankAccount {
private int balance = 100;
public void withdraw(int amount) {
if (balance >= amount) { // 检查余额
balance -= amount; // 扣钱
}
}
}
假设 两个线程 同时调用 withdraw(50)
:
- 线程 A 检查余额:发现余额是 100(可以取款)。
- 线程 B 也检查余额:发现余额也是 100(可以取款)。
- 线程 A 扣款后,余额变成 50。
- 线程 B 也扣款后,余额变成 50(本应该是 0,结果数据错误了!)。
为了解决这个问题,我们可以 加锁,确保 同一时刻只能有一个线程操作账户:
class BankAccount {
private int balance = 100;
private final ReentrantLock lock = new ReentrantLock();
public void withdraw(int amount) {
lock.lock(); // 🔒加锁
try {
if (balance >= amount) {
balance -= amount;
}
} finally {
lock.unlock(); // 🔓解锁
}
}
}
常见的锁类型
- 互斥锁(Mutex):一把锁 同时只能被一个线程持有,别人只能等它释放,比如
synchronized
或ReentrantLock
。 - 读写锁(ReadWriteLock):允许多个线程同时 读取,但只允许 一个线程写入,比如
ReentrantReadWriteLock
。 - 乐观锁:不加锁,假设不会冲突,更新数据时 检查是否被改动,失败则重试,比如
CAS(Compare And Swap)
机制。 - 悲观锁:假设会有冲突,每次操作前都先加锁,确保安全。
锁的底层实现:它是怎么工作的?
锁(Lock)并不是凭空出现的,它的实现依赖 硬件支持 和 软件机制 结合,主要通过 CPU 指令、操作系统的调度 以及 JVM 内部机制 来保证线程的安全访问。
我们可以从硬件层、操作系统层和 Java 层来看看锁是怎么实现的。
1. 硬件层:CPU 指令级别的保证
在现代计算机中,多个线程是由 CPU 的多个核心或多个线程并发执行的,但 CPU 需要保证某些关键操作的 原子性(要么全部执行,要么完全不执行)。
🔹 CAS(Compare-And-Swap,比较并交换)
CAS 是一种 乐观锁 机制,它的原理是:
- 读取一个变量的 当前值。
- 判断这个值是否仍然是预期值(没有被别的线程改动)。
- 如果是,就修改它;如果不是,说明被别人动过了,操作失败,可能需要重试。
💡 CPU 提供了一些原子操作指令(如 cmpxchg
),用于实现 CAS:
AtomicInteger count = new AtomicInteger(0);
count.compareAndSet(0, 1); // 只有当 count 是 0 时,才会修改成 1
锁底层就是调用 CPU 的 CAS 指令,确保修改是原子的,避免加锁带来的性能开销。
2. 操作系统层:锁的调度和同步
操作系统提供了一些 底层同步机制,让线程能够安全地访问共享资源:
🔹 自旋锁(SpinLock)
- 自旋锁是一种 轻量级锁,当一个线程发现锁被占用时,它 不会马上阻塞,而是 短时间内不断循环尝试获取锁,等锁释放后立刻执行。
- 适用于 锁竞争不严重 或 锁持有时间短 的情况,避免了线程切换的开销。
class SpinLock {
private AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
while (!lock.compareAndSet(false, true)) {
// 自旋等待
}
}
public void unlock() {
lock.set(false);
}
}
💡 缺点:如果锁竞争激烈,自旋会导致 CPU 资源浪费,影响性能。
🔹 互斥锁(Mutex,基于内核的锁)
如果线程长时间无法获取锁,操作系统会让线程进入等待状态,这样就不会占用 CPU 资源。
常见的操作系统锁机制包括:
futex
(Fast User-space Mutex):Linux 下的快速用户态互斥锁,减少内核态切换。- 信号量(Semaphore):控制多个线程的访问。
- 条件变量(Condition Variable):用于线程间的等待和唤醒。
当 Java 代码使用 synchronized
或 ReentrantLock
时,底层可能会使用这些 系统级锁,尤其是当线程需要被挂起时。
3. Java 层:JVM 里的锁
Java 提供了多种锁机制,底层主要由 synchronized
和 Lock
(ReentrantLock) 来实现。
🔹 synchronized:JVM 内置锁
synchronized
是 Java 提供的最基本的锁,它的实现依赖于 JVM 内部的对象头,可以有 不同的锁优化级别:
- 无锁状态:对象刚创建,没有竞争。
- 偏向锁:如果一个线程多次获取同一把锁,JVM 偏向它,让它以后获取锁时 不再需要 CAS 操作(提升性能)。
- 轻量级锁:多个线程尝试获取锁,但没有强烈竞争时,使用 CAS 让一个线程先获得锁。
- 重量级锁:如果锁竞争严重,JVM 会升级为 重量级锁,使用 操作系统的 Mutex 机制,让线程 进入等待状态,避免 CPU 资源浪费。
🔹 synchronized 的底层实现 synchronized
依赖于 对象头的 MarkWord,当 JVM 发现竞争加剧时,会升级锁的状态:
synchronized (obj) {
// 临界区代码
}
JVM 在编译后,会转换成类似的底层指令:
monitorenter // 进入同步块,加锁
monitorexit // 退出同步块,解锁
当 monitorenter
执行时:
- JVM 先检查 当前对象的锁状态(MarkWord)。
- 如果锁是 偏向锁,直接获取。
- 如果锁是 轻量级锁,CAS 竞争。
- 如果锁竞争严重,升级为 重量级锁,进入操作系统 Mutex 机制。
🔹 ReentrantLock:显式锁
Java 提供了 ReentrantLock
作为 synchronized
的替代,它的底层实现是 AQS(AbstractQueuedSynchronizer)。
🔹 AQS 的核心
- AQS 维护了一个
state
变量 来表示锁的状态。 - 线程尝试用 CAS 修改 state,如果成功,就获得锁。
- 失败的线程会被放入 FIFO 队列,等待唤醒。
class ReentrantLockDemo {
private final ReentrantLock lock = new ReentrantLock();
public void doWork() {
lock.lock(); // 获取锁
try {
System.out.println("线程 " + Thread.currentThread().getName() + " 执行任务");
} finally {
lock.unlock(); // 释放锁
}
}
}
ReentrantLock vs. synchronized
特性 | synchronized | ReentrantLock |
---|---|---|
底层实现 | JVM 内置(基于对象头) | AQS(基于 CAS 和队列) |
可重入 | ✅ 是 | ✅ 是 |
公平锁支持 | ❌ 不能设置 | ✅ 可以设为公平锁 |
尝试获取锁 | ❌ 不能 | ✅ tryLock() 非阻塞获取 |
超时获取锁 | ❌ 不能 | ✅ tryLock(time) 支持超时 |
中断支持 | ❌ 不能被中断 | ✅ 可被 lockInterruptibly() 中断 |
总结
锁的底层实现涉及多个层次:
- CPU 层:
- 通过 CAS 进行原子操作,确保多线程安全。
- 操作系统层:
- 依靠 自旋锁(轻量级锁)、互斥锁(Mutex,重量级锁)来调度线程。
- JVM 层:
synchronized
通过 对象头的 MarkWord 进行锁优化(偏向锁、轻量级锁、重量级锁)。ReentrantLock
依赖 AQS(AbstractQueuedSynchronizer),使用 CAS + 队列机制。
所以,锁并不是“凭空”存在的,而是 CPU、操作系统、JVM 三者配合的结果! 🚀、
📌 一、哪些场景需要加锁?
一般来说,只要有多个线程同时访问共享资源,并且可能导致数据错误,就需要加锁。常见的场景有:
1️⃣ 共享变量的并发修改
多个线程同时修改一个 共享变量,如果没有加锁,可能会发生数据不一致问题:
class Counter {
private int count = 0;
public void increment() {
count++; // 这里没有加锁,可能导致并发问题
}
}
多个线程执行 increment()
时,可能出现 count < 期望值 的情况。
✅ 正确做法:
class Counter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++; // 线程安全
} finally {
lock.unlock();
}
}
}
这样可以保证 count++
操作是 原子的,不会被多个线程同时修改。
2️⃣ 银行转账等事务操作
假设有两个线程:
- 线程 A:从账户 1 转 100 元到账户 2
- 线程 B:从账户 2 转 200 元到账户 1
如果没有加锁,可能会导致 数据不一致,甚至钱凭空消失或凭空增加!
✅ 正确做法:
class BankAccount {
private int balance;
private final ReentrantLock lock = new ReentrantLock();
public void transfer(BankAccount target, int amount) {
lock.lock();
try {
if (balance >= amount) {
balance -= amount;
target.deposit(amount);
}
} finally {
lock.unlock();
}
}
public void deposit(int amount) {
lock.lock();
try {
balance += amount;
} finally {
lock.unlock();
}
}
}
3️⃣ 生产者-消费者模型(多线程队列)
在多线程队列(如消息队列、任务队列)中,多个线程可能同时向队列添加任务或从队列取任务,如果没有加锁,可能会导致:
- 任务丢失
- 任务重复消费
- 队列数据损坏
✅ 正确做法:
class TaskQueue {
private Queue<String> queue = new LinkedList<>();
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
public void addTask(String task) {
lock.lock();
try {
queue.offer(task);
notEmpty.signal(); // 通知等待的消费者
} finally {
lock.unlock();
}
}
public String getTask() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 没任务就等待
}
return queue.poll();
} finally {
lock.unlock();
}
}
}
这样可以确保:
- 生产者添加任务时不会被多个线程同时修改队列。
- 消费者在任务队列为空时等待,而不是空轮询浪费 CPU。
4️⃣ 单例模式
在多线程环境下,单例模式 可能会被多个线程同时创建多个实例。比如:
class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 线程不安全
instance = new Singleton();
}
return instance;
}
}
如果两个线程同时执行 getInstance()
,可能会创建 两个不同的实例,导致 单例模式失效!
✅ 正确做法(双重检查 + volatile
)
class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
📌 二、加锁后,其他线程去哪了?
当一个线程获取了锁,其他线程如果没有获得锁,会进入以下几种状态:
1️⃣ 自旋等待(自旋锁)
- 如果使用的是 自旋锁(如
CAS
机制),线程会不断尝试获取锁,而不进入休眠状态。 - 适用于 锁持有时间短 的情况,否则会浪费 CPU 资源。
class SpinLock {
private AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
while (!lock.compareAndSet(false, true)) {
// 自旋等待
}
}
public void unlock() {
lock.set(false);
}
}
2️⃣ 阻塞等待(线程进入等待队列)
如果是普通锁(如 ReentrantLock
),没有获得锁的线程 不会一直占用 CPU,而是进入阻塞状态:
private final ReentrantLock lock = new ReentrantLock();
public void doWork() {
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock();
}
}
没有获得锁的线程会被挂起,等锁释放后再恢复执行。
3️⃣ 进入 wait()
/ await()
队列
如果线程在 synchronized
代码块中调用 wait()
,它会进入 等待队列,等待 notify()
重新唤醒:
synchronized (lock) {
while (!conditionMet) {
lock.wait(); // 进入等待状态
}
}
三、总结
场景 | 是否需要加锁? | 加锁后其他线程的状态 |
---|---|---|
共享变量修改 | ✅ 需要 | 其他线程 等待锁释放 |
银行转账 | ✅ 需要 | 其他线程 进入阻塞状态 |
生产者-消费者 | ✅ 需要 | 生产者/消费者 等待队列 |
单例模式 | ✅ 需要 | 只有第一次创建实例时需要加锁 |
计算密集型操作 | ❌ 不需要 | 加锁会影响性能 |
🎉 最后的话:锁住知识,解锁成长! 🚀
看到这里,你已经掌握了 锁的概念、底层实现、应用场景以及线程等待机制,恭喜你迈向了并发编程的大门!💪
如果你觉得这篇文章对你有所帮助,别忘了:
✅ 点赞 ❤️ 让我知道你喜欢这类内容!
✅ 关注 ⭐ 解锁更多并发编程、Java高并发、JVM底层原理等硬核知识!
✅ 分享 🔄 让更多人受益,一起提升技术!
感谢阅读,期待下次见!🚀🚀🚀