【Java从入门到精通】第22篇:JUC并发工具库(上)——Lock、Condition与读写锁的分离式并发
目录
一、隐式锁的局限与显式锁的补全
二、Condition:多路等待与精准唤醒
三、读写锁:读读共享、读写互斥的分离式并发
四、公平锁与非公平锁:吞吐量与公平性的权衡
五、结语
一、隐式锁的局限与显式锁的补全
synchronized是Java语言层面的内置锁机制——一个关键字、一对花括号,锁的获取和释放由JVM自动管理。这种简洁性让同步代码易于编写,但也带来了几个结构性限制。等待synchronized锁的线程会一直阻塞直到获取锁,不能被中断。尝试获取锁时只能一直等待,不能设置超时,也不能先试探性地尝试获取、失败时转而执行备选逻辑。synchronized的等待队列是无条件等待——被notifyAll唤醒的线程需要重新检查条件,因为无法区分被唤醒的原因。
Lock接口以显式锁的形式补全了这些缺失的能力。lock()方法获取锁——如果锁被占用则阻塞。tryLock()方法非阻塞地尝试获取锁——立即返回成功或失败。tryLock(long, TimeUnit)方法在指定时间内尝试获取锁——超时则返回失败,线程可以被中断。lockInterruptibly()方法在获取锁的等待过程中响应中断。Lock将锁的获取从“只能一直等”扩展为“可以尝试、可以超时、可以中断”。
Lock是一个接口,定义了对锁的操作契约。ReentrantLock是其标准实现——一个可重入的互斥锁。可重入意味着已经持有锁的线程再次获取同一把锁时不会被自己阻塞。synchronized也是可重入的——一个线程进入自己已持有的synchronized保护范围内的另一个synchronized块时,自动放行。
Lock是显式的——锁的获取和释放都需要显式调用。这一特性意味着释放锁的责任完全在程序员手中。标准的用法是将释放锁放在finally块中,确保无论正常执行还是异常退出,锁都能被安全释放。不在finally中释放锁,一旦代码抛出异常,锁永远不被释放,其他线程永久阻塞。
二、Condition:多路等待与精准唤醒
synchronized与Object的wait/notify/notifyAll构成了隐式锁的等待通知机制。这个机制的局限在于,等待在同一个锁对象上的所有线程共享同一个等待队列。当notifyAll被调用时,所有等待线程都被唤醒竞争锁,即使大多数线程等待的条件并不满足。
Condition是Lock体系对等待通知机制的增强。一个Lock对象可以创建多个Condition对象,每个Condition维护独立的等待队列。一个线程在Condition上调用await进入等待,另一个线程在同一个Condition上调用signal唤醒一个等待线程,signalAll唤醒该Condition上的所有等待线程。
多路条件的核心优势是精准唤醒。在一个有界缓冲区的场景中,生产者线程和消费者线程共享同一个锁但等待不同的条件——生产者等待缓冲区非满,消费者等待缓冲区非空。使用两个Condition分别管理生产者等待队列和消费者等待队列,当消费者从缓冲区取出一个元素后,它知道应该唤醒一个生产者——它调用生产者Condition的signal,精确唤醒一个等待空间的生产者,而非唤醒所有线程让它们自行判断自己是谁。
这种精准唤醒在性能上具有显著优势。大量等待不同条件的线程被盲目唤醒、检查条件不满足后再次进入等待——这种无效的唤醒-睡眠循环在Condition的多路等待模型中被大幅削减。
三、读写锁:读读共享、读写互斥的分离式并发
synchronized和ReentrantLock都是互斥锁——无论线程是要读共享数据还是写共享数据,获取锁后其他线程全部被阻塞。这种设计在写多读少的场景中合理,但在读多写少的场景中牺牲了大量并发度。多个读操作同时进行不会破坏数据一致性——读操作不修改数据,没有互斥的必要。
ReadWriteLock接口为读写场景提供了分离式并发控制。它暴露两个Lock对象——readLock和writeLock。读锁是共享锁——多个线程可以同时持有读锁,只要没有线程持有写锁。写锁是互斥锁——写锁与读锁互斥,写锁与写锁互斥。线程只有在没有任何其他线程持有读锁或写锁时才能获取写锁。
ReentrantReadWriteLock是ReadWriteLock的标准实现。在读多写少的场景中——例如配置信息、缓存数据——ReentrantReadWriteLock将并发度从“一次一个线程”提升到“一次多个读线程”,并发吞吐量成倍增加。
读写锁的锁降级是一个特殊机制。一个持有写锁的线程可以先获取读锁,再释放写锁——从写锁降级为读锁。这一机制用于在修改完数据后需要立即读取的场景——持有写锁期间修改的数据,降级后持有的读锁保证在读取期间数据不会被其他写线程修改。读写锁不支持锁升级——从读锁升级到写锁会导致死锁,因为多个读线程可能同时尝试升级,都持有读锁等待写锁,形成循环等待。
四、公平锁与非公平锁:吞吐量与公平性的权衡
ReentrantLock和ReentrantReadWriteLock都支持公平模式。在公平模式下,等待时间最长的线程优先获取锁——锁被释放时,将其分配给等待队列中的第一个线程。在非公平模式下,新来的线程可以插队——在锁被释放的瞬间,如果恰好有一个新线程尝试获取锁,它可能抢在等待队列头部线程之前获得锁。
公平锁的优势是防止线程饥饿——长期等待的线程一定会在某个时刻获得锁。代价是吞吐量降低——每次锁释放都需要将锁转交给等待队列中的固定线程,涉及线程挂起和唤醒的上下文切换。非公平锁以可能引入线程饥饿的风险换取更高的吞吐量——新线程可能恰好赶在锁释放时直接获取,避免了上下文切换的开销。
synchronized在JDK内部使用的是非公平策略,ReentrantLock的默认构造方法也是非公平锁。在大多数业务场景中,非公平锁的吞吐量优势远大于公平性风险。
五、结语
Lock体系以显式控制补全了synchronized在可中断、超时、非阻塞尝试上的缺失,Condition以多路等待队列实现了精准唤醒,ReentrantReadWriteLock以读写锁分离在读多写少场景中成倍提升并发吞吐量。这些工具的引入不是为了取代synchronized,而是为synchronized力所不及的场景提供补充方案。
下一篇,我们将进入JUC的同步器工具——CountDownLatch的一发多等、CyclicBarrier的多发多等、Semaphore的许可模型,以及线程池的生命周期管理。