引言:并发编程的痛点与锁的重要性
在当今这个多核处理器盛行的时代,并发编程已经成为 Java 开发者绕不开的话题。想象一下,你的程序就像一个繁忙的工厂,里面有多个工人(线程)同时处理任务。如果这些工人都要操作同一个机器(共享资源),比如往同一个箱子里放零件,那问题就来了:谁先放?谁后放?会不会出现两个人同时伸手,结果零件掉地上的情况?
这就是并发编程中常见的“共享资源竞争”问题。如果处理不好,轻则数据错乱,重则程序崩溃,简直是程序员的噩梦!为了解决这个问题,我们需要一种机制来协调这些“工人”,让他们有条不紊地工作,这就是“锁”的登场!
在 Java 中,我们最熟悉的锁可能就是 synchronized 关键字了。它简单易用,但有时候又显得不够灵活。比如,你想尝试获取锁,但如果获取不到就先干点别的,或者你想在等待锁的时候中断当前操作,synchronized 就有点力不从心了。这时候,我们的主角 ReentrantLock 就闪亮登场了!
ReentrantLock 登场:它是什么?为什么需要它?
ReentrantLock,顾名思义,它是一个“可重入锁”。啥叫可重入呢?简单来说,就是同一个线程可以多次获取同一把锁。就像你进了自己家门,可以再开一次门,然后又开一次门,只要是你自己,就可以反复开门,而不用担心被锁在外面。
那么,为什么我们需要 ReentrantLock 呢?它和我们熟悉的 synchronized 有啥区别呢?
相比 synchronized 的优势
虽然 synchronized 用起来很方便,但它有一些局限性:
1.无法中断等待:如果一个线程在等待 synchronized 锁,它就只能一直等下去,直到获取到锁或者程序崩溃,没有办法中断。就像你在排队买票,队伍太长了,你想放弃去干点别的,synchronized 不允许你插队或者离开。
2.无法设置超时:synchronized 无法设置等待锁的超时时间。如果一个线程一直拿不到锁,它就会永远阻塞在那里。这在某些场景下可能会导致死锁或者程序响应缓慢。
3.无法实现公平锁:synchronized 是一种非公平锁,它不保证等待时间最长的线程优先获取锁。谁抢到算谁的,这在某些对公平性有要求的场景下就不太合适了。
4.功能扩展性:ReentrantLock 提供了更丰富的功能,比如 tryLock()(尝试获取锁,不阻塞)、tryLock(long timeout, TimeUnit unit)(尝试获取锁,带超时)、lockInterruptibly()(可中断地获取锁)以及 newCondition()(条件变量)等,这些都是 synchronized 不具备的。这些功能让 ReentrantLock 在处理复杂并发场景时更加灵活和强大。
基本用法:lock() 和 unlock()
ReentrantLock 的基本用法非常简单,就像开关门一样:
java
体验AI代码助手
代码解读
复制代码
import java.util.concurrent.locks.ReentrantLock; public class ReentrantLockDemo { private static final ReentrantLock lock = new ReentrantLock(); private static int count = 0; public static void increment() { lock.lock(); // 获取锁 try { // 临界区代码,只有获取到锁的线程才能执行 count++; System.out.println(Thread.currentThread().getName() + ": count = " + count); } finally { lock.unlock(); // 释放锁 } } public static void main(String[] args) { for (int i = 0; i < 5; i++) { new Thread(() -> { for (int j = 0; j < 1000; j++) { increment(); } }, "Thread-" + i).start(); } } }
划重点! 在使用 ReentrantLock 时,一定要记得在 finally 块中调用 unlock() 方法来释放锁,否则一旦程序发生异常,锁就永远不会被释放,导致其他线程永远无法获取锁,造成死锁!这是非常重要的编程习惯!
公平锁:排队讲究“先来后到”
想象一下,你去银行办业务,如果银行的叫号系统是“公平”的,那么先取号的人就先办理业务,后来的人就得排队等着。这就是公平锁的原则:按照线程请求锁的顺序来获取锁。谁先来的,谁就先拿到锁,非常讲究“先来后到”的规矩。
概念解释
当多个线程同时竞争一把锁时,如果这把锁是公平锁,那么这些线程会按照它们请求锁的先后顺序,依次排队。当锁被释放时,队列中等待时间最长的那个线程(也就是排在最前面的线程)会优先获得锁。这就像一个文明的队伍,大家自觉排队,不插队。
底层实现原理(AQS 队列、CAS 操作)
ReentrantLock 的公平锁和非公平锁的实现都离不开一个核心组件:AQS(AbstractQueuedSynchronizer),抽象队列同步器。AQS 是 Java 并发包中一个非常重要的基础框架,它提供了一个骨架,用于构建锁和同步器。简单来说,AQS 内部维护了一个双向链表,这个链表就是等待队列,也叫 CLH 队列(Craig, Landin, and Hagersten)。当线程获取锁失败时,就会被封装成一个 Node 节点,然后加入到这个等待队列中,并被阻塞起来。当锁被释放时,AQS 会唤醒队列中排在最前面的那个线程。
让我们用一张图来形象地理解 AQS 的等待队列:
上图展示了 AQS 队列的基本结构。head 节点代表当前持有锁的线程,tail 节点代表等待队列的尾部。当有新的线程来竞争锁但失败时,它就会被添加到 tail 后面,形成一个等待的队伍。每个 Node 节点都包含了等待的线程信息以及它在队列中的前驱和后继节点。
公平锁在获取锁时,会先检查 AQS 队列中是否有其他线程在等待。如果队列中有线程在等待,那么当前线程即使可以直接获取锁,也会乖乖地排到队列的末尾,等待轮到自己。这个检查队列的操作,保证了“先来后到”的原则。整个过程中,AQS 会利用 CAS(Compare And Swap) 操作来保证对 state 变量(表示锁的状态)的原子性修改,以及对队列的并发操作的安全性。
公平锁获取锁的流程大致如下:
1.线程尝试获取锁。
2.如果锁当前没有被占用,并且 AQS 队列中没有其他线程在等待,那么线程会尝试通过 CAS 操作修改 state 变量,成功则获取锁。
3.如果锁已经被占用,或者 AQS 队列中有其他线程在等待,那么当前线程会被封装成一个 Node 节点,加入到 AQS 队列的尾部,并被阻塞。
4.当持有锁的线程释放锁时,会唤醒 AQS 队列中排在最前面的那个线程,让它尝试获取锁。
这张流程图可以帮助我们更好地理解公平锁的加锁过程:
适用场景与优缺点
优点:
•公平性:保证了所有线程都能按照请求顺序获取锁,避免了饥饿现象(某些线程长时间无法获取锁)。
•可预测性:线程获取锁的顺序是可预测的,这对于某些需要严格顺序的业务场景非常重要。
缺点:
•性能开销:每次尝试获取锁时,都需要检查 AQS 队列中是否有其他线程在等待,这会增加额外的开销。在高并发场景下,公平锁的性能通常低于非公平锁。
•吞吐量低:由于需要维护严格的排队顺序,公平锁可能会导致更多的上下文切换,从而降低系统的整体吞吐量。
非公平锁:谁抢到算谁的!
如果说公平锁是银行的“叫号系统”,那么非公平锁就像是公交车上的“抢座大战”! 谁手快,谁眼疾,谁就能抢到座位。非公平锁的原则就是:当锁可用时,任何线程都可以尝试获取锁,而不管它是不是等待时间最长的那个线程。它不讲究“先来后到”,只看谁能“抢”到。
概念解释
当锁被释放时,如果是非公平锁,那么等待队列中的线程和新来的线程都有机会去竞争这把锁。谁先通过 CAS 操作成功修改 state 变量,谁就获得了锁。这种机制下,新来的线程可能会“插队”成功,比等待已久的线程更早地获取到锁。这就像一个自由竞争的市场,效率优先,但可能牺牲一点点“公平”。
底层实现原理(直接尝试获取锁、CAS 操作)
非公平锁的实现同样基于 AQS。与公平锁不同的是,非公平锁在尝试获取锁时,不会先去检查 AQS 队列中是否有其他线程在等待。它会直接尝试通过 CAS 操作去抢占锁。如果抢占成功,那就直接获取锁,皆大欢喜;如果抢占失败,说明锁已经被其他线程持有,或者有其他线程同时在抢,那么当前线程才会被封装成 Node 节点,加入到 AQS 队列的尾部,然后被阻塞。
非公平锁获取锁的流程大致如下:
1.线程尝试获取锁。
2.线程直接尝试通过 CAS 操作修改 state 变量。如果成功,则获取锁。
3.如果 CAS 操作失败,说明锁已经被占用,或者有其他线程同时在抢。此时,当前线程才会被封装成一个 Node 节点,加入到 AQS 队列的尾部,并被阻塞。
4.当持有锁的线程释放锁时,会唤醒 AQS 队列中排在最前面的那个线程,让它尝试获取锁。但此时,新来的线程仍然有机会直接抢占锁。
这张流程图可以帮助我们更好地理解非公平锁的加锁过程:
适用场景与优缺点
优点:
•性能高:由于减少了线程上下文切换的开销,并且避免了每次获取锁都去检查队列的开销,非公平锁在大多数情况下性能优于公平锁。
•吞吐量高:在高并发场景下,非公平锁能够提供更高的吞吐量,因为它允许线程“插队”,减少了线程阻塞和唤醒的次数。
缺点:
•可能导致饥饿:如果新来的线程总是能够抢占到锁,那么等待队列中的某些线程可能会长时间无法获取锁,导致“饥饿”现象。
•不可预测性:线程获取锁的顺序是不可预测的,这对于某些需要严格顺序的业务场景可能不适用。
公平锁与非公平锁的底层实现对比:
公平锁和非公平锁的核心区别,就在于线程在尝试获取锁时,是否会去检查 AQS 队列中是否有其他线程在等待。
•公平锁:当线程尝试获取锁时,会先判断 AQS 队列中是否有等待的线程。如果有,即使当前锁是空闲的,新来的线程也会乖乖地排队。这保证了线程获取锁的顺序与它们请求锁的顺序一致。
•非公平锁:当线程尝试获取锁时,会直接尝试去抢占锁(通过 CAS 操作修改 state)。如果抢占成功,就直接获取锁;如果失败,才会被加入到 AQS 队列中排队。这意味着新来的线程有“插队”的可能。
总结一下,它们的本质区别在于:
特性 | 公平锁 | 非公平锁 |
---|---|---|
获取锁顺序 | 严格按照线程请求锁的顺序(先来后到) | 不保证顺序,新来的线程可能“插队” |
性能 | 相对较低(有额外队列检查开销,上下文切换多) | 相对较高(减少了队列检查,上下文切换少) |
吞吐量 | 相对较低 | 相对较高 |
饥饿问题 | 不会发生饥饿 | 可能发生饥饿(某些线程长时间无法获取锁) |
适用场景 | 对顺序和公平性要求严格的场景 | 大多数并发场景,追求高吞吐量和性能的场景 |
在实际开发中,如果对锁的公平性没有特殊要求,通常会优先选择非公平锁,因为它能提供更高的性能和吞吐量。Java 中的 ReentrantLock 默认就是非公平锁。如果你需要公平锁,可以在创建 ReentrantLock 实例时传入 true 参数,例如 new ReentrantLock(true)。
实例演示:用代码说话,公平与非公平的实际表现
光说不练假把式!下面我们通过一个简单的代码示例,来直观地感受一下公平锁和非公平锁在实际运行中的表现差异。我们将创建多个线程,让它们竞争同一个锁,然后观察它们获取锁的顺序。
java
体验AI代码助手
代码解读
复制代码
import java.util.concurrent.locks.ReentrantLock; public class FairAndNonFairLockDemo { private static class Worker extends Thread { private ReentrantLock lock; public Worker(ReentrantLock lock, String name) { super(name); this.lock = lock; } @Override public void run() { for (int i = 0; i < 2; i++) { // 每个线程尝试获取锁两次 lock.lock(); try { System.out.println(Thread.currentThread().getName() + " 获取了锁"); // 模拟业务处理时间 Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); System.out.println(Thread.currentThread().getName() + " 释放了锁"); } } } } public static void main(String[] args) throws InterruptedException { System.out.println("\n--- 非公平锁示例 ---"); ReentrantLock nonFairLock = new ReentrantLock(false); // 传入 false,创建非公平锁 for (int i = 0; i < 5; i++) { new Worker(nonFairLock, "非公平线程-" + i).start(); } Thread.sleep(2000); // 等待非公平锁线程执行完毕 System.out.println("\n--- 公平锁示例 ---"); ReentrantLock fairLock = new ReentrantLock(true); // 传入 true,创建公平锁 for (int i = 0; i < 5; i++) { new Worker(fairLock, "公平线程-" + i).start(); } } }
运行上面的代码,你会发现输出结果有明显的不同:
非公平锁的输出(部分示例,每次运行可能不同):
text
体验AI代码助手
代码解读
复制代码
--- 非公平锁示例 --- 非公平线程-0 获取了锁 非公平线程-0 释放了锁 非公平线程-2 获取了锁 非公平线程-2 释放了锁 非公平线程-1 获取了锁 非公平线程-1 释放了锁 非公平线程-0 获取了锁 非公平线程-0 释放了锁 非公平线程-3 获取了锁 非公平线程-3 释放了锁 ...
你会发现,非公平锁的输出顺序是比较“混乱”的,线程获取锁的顺序并不总是按照它们启动的顺序。某个线程可能连续获取多次锁,或者新来的线程直接抢占了锁,而等待队列中的线程还在等待。
公平锁的输出(部分示例,每次运行基本一致):
text
体验AI代码助手
代码解读
复制代码
--- 公平锁示例 --- 公平线程-0 获取了锁 公平线程-0 释放了锁 公平线程-1 获取了锁 公平线程-1 释放了锁 公平线程-2 获取了锁 公平线程-2 释放了锁 公平线程-3 获取了锁 公平线程-3 释放了锁 公平线程-4 获取了锁 公平线程-4 释放了锁 公平线程-0 获取了锁 公平线程-0 释放了锁 ...
而公平锁的输出则会严格按照线程启动的顺序(或者说它们进入等待队列的顺序)来获取锁。线程-0 获取并释放锁后,线程-1 才会获取并释放锁,以此类推。这完美地诠释了“先来后到”的原则。是不是很神奇?
总结:ReentrantLock 的最佳实践与思考
通过上面的讲解,相信你对 ReentrantLock,特别是它的公平锁和非公平锁的底层实现,已经有了更深入的理解。那么,在实际开发中,我们应该如何选择和使用 ReentrantLock 呢?
1.优先使用 synchronized:在大多数简单的并发场景下,synchronized 仍然是首选。它由 JVM 自动管理锁的获取和释放,使用起来更简单,也更不容易出错。JVM 在 synchronized 的优化上也做得非常好,性能通常不比 ReentrantLock 差。
2.ReentrantLock 的适用场景:当你需要更高级的锁功能时,比如:
•可中断的锁:lockInterruptibly(),允许在等待锁的过程中响应中断。
•尝试获取锁:tryLock() 和 tryLock(long timeout, TimeUnit unit),避免无限等待。
•公平性选择:需要严格的“先来后到”顺序时,可以选择公平锁。
•条件变量:newCondition(),实现更复杂的线程间协作(await() / signal() / signalAll())。
3.注意 unlock() 的调用:再次强调,使用 ReentrantLock 时,务必在 finally 块中调用 unlock() 方法,确保锁的正确释放,避免死锁和资源泄露。
4.公平性选择的权衡:
•非公平锁(默认):性能通常更高,吞吐量更大,适用于大多数对公平性要求不高的场景。它的“插队”机制减少了上下文切换,提高了效率。
•公平锁:保证了线程获取锁的顺序,避免了饥饿问题,适用于对顺序和公平性有严格要求的场景,但会带来一定的性能开销。
并发编程是一个充满挑战但也充满乐趣的领域。理解 ReentrantLock 及其公平锁和非公平锁的底层原理,能让你在处理并发问题时更加游刃有余。希望这篇文章能帮助你更好地驾驭 Java 并发编程这匹“野马”!