尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Java并发锁实战:synchronized、ReentrantLock与读写锁核心解析

Java并发锁实战:synchronized、ReentrantLock与读写锁核心解析 1. 项目概述深入Java并发锁的实战核心在Java后端开发和高并发场景里锁是绕不开的核心话题。无论是处理电商秒杀时的库存扣减还是管理多用户同时编辑的配置中心我们都需要一种机制来保证数据在并发访问下的正确性。很多开发者尤其是工作一两年的朋友对synchronized、ReentrantLock这些名词耳熟能详面试时也能背出“可重入”、“公平非公平”等概念。但一到实际编码面对具体场景该如何选择ReentrantReadWriteLock的读写锁到底在什么情况下能带来性能提升配置不当会不会反而成为瓶颈这些问题往往需要在踩过坑之后才能有深刻体会。我自己在构建高并发服务时就曾因为锁的误用导致接口性能急剧下降。比如在一个读多写少的配置服务中初期图省事全部用了synchronized结果在流量上来后大量并发的读操作被无谓地串行化QPS死活上不去。后来换成了ReentrantReadWriteLock性能立竿见影地提升了数十倍。这个项目我就想结合这些真实的开发经历把Java中这三个最核心的锁机制——synchronized、ReentrantLock和ReentrantReadWriteLock——掰开揉碎了讲清楚。不止是API怎么用更重要的是背后的设计思想、适用场景以及那些官方文档里不会写的、只有在真实生产环境压测和故障排查中才能获得的经验。无论你是正在准备面试还是已经在项目中遇到了并发难题希望这篇内容都能给你提供可以直接“抄作业”的解决方案和避坑指南。2. 锁机制的核心设计思想与选型考量在动手写一行代码之前理解不同锁的设计哲学至关重要。这决定了你在什么场景下该用什么工具而不是盲目地拿着锤子看什么都像钉子。2.1 synchronizedJVM内置的“自动挡”锁synchronized是Java语言层面提供的互斥同步原语你可以把它理解为“自动挡”汽车。它的最大优点是使用简单JVM负责了锁的获取、释放以及锁状态的管理开发者不易出错。其核心设计思想是悲观锁和独占锁。它悲观地认为只要你对共享资源进行操作就一定有其他线程来竞争所以直接加锁让其他线程排队。这种机制保证了最强的数据一致性但代价是可能带来性能开销。从实现上看在JDK 1.6之后synchronized进行了大幅优化引入了锁升级机制无锁 - 偏向锁 - 轻量级锁 - 重量级锁这使得它在低竞争场景下的性能已经非常接近显式锁如ReentrantLock。它的可重入性是隐式的由JVM在对象头Mark Word中记录当前持有锁的线程和重入次数。选型考量当你需要快速实现一个简单的临界区保护且竞争强度不高时synchronized是首选。它的简洁性意味着更低的编码错误风险。例如在单例模式的getInstance()方法上加synchronized或者在Spring Bean的一个非高频调用的服务方法上使用都是非常合适的。2.2 ReentrantLock灵活可控的“手动挡”锁ReentrantLock是java.util.concurrent.locks包下提供的显式锁。它像“手动挡”汽车把更多的控制权交给了开发者。其核心设计思想在提供互斥和可重入的基础上强调了灵活性和可扩展性。与synchronized相比它的灵活性体现在可中断的锁获取在尝试获取锁时如果线程被中断lockInterruptibly()方法会抛出InterruptedException允许程序响应中断。超时获取锁tryLock(long time, TimeUnit unit)方法可以尝试在指定时间内获取锁避免线程无限期等待是解决死锁的常用手段。公平性选择构造函数可以传入一个boolean值决定是否创建公平锁。公平锁按申请顺序分配能减少线程饥饿但性能开销较大非公平锁允许“插队”吞吐量通常更高。绑定多个条件一个ReentrantLock对象可以关联多个Condition对象可以实现更精细的线程间通信如生产者-消费者模型中的多个等待队列。选型考量当你需要上述灵活特性时就必须选择ReentrantLock。例如实现一个带有超时机制的分布式锁客户端模拟、构建一个复杂的多条件等待的生产者-消费者队列或者在高竞争场景下希望通过非公平锁来提升吞吐量。但记住能力越大责任越大你必须手动在finally块中调用unlock()释放锁否则会导致灾难性后果。2.3 ReentrantReadWriteLock读写分离的“特种锁”ReentrantReadWriteLock是对于“读多写少”这一特定场景的极致优化。它的核心设计思想是锁分离。它将锁分成了读锁ReadLock和写锁WriteLock。读锁是共享的允许多个线程同时持有写锁是独占的与ReentrantLock性质相同并且与读锁互斥。这种设计基于一个常见的观察在很多业务场景如商品信息查询、新闻资讯浏览中读取数据的频率远高于修改数据。如果使用独占锁所有读操作也必须串行这严重浪费了系统资源。读写锁允许并发读只有在写操作时才会独占资源从而大幅提升系统整体吞吐量。选型考量它的适用场景非常明确——读操作极其频繁写操作相对较少且数据一致性要求允许在写操作未完成时读到旧的数据。典型的例子就是缓存。例如使用一个ConcurrentHashMap来缓存数据但某些热点key的元信息需要偶尔更新这时用ReentrantReadWriteLock来保护这个更新过程就非常合适。但是如果读锁和写锁的竞争非常激烈或者读操作的临界区代码执行时间很长可能会导致“写线程饥饿”读锁一直不释放写锁永远拿不到。因此在JDK 1.8之后对于简单的缓存场景更推荐直接使用ConcurrentHashMap它的实现更为精妙。ReentrantReadWriteLock更适合保护一个复杂的、需要原子性读写的复合操作。注意ReentrantReadWriteLock有一个重要的锁降级特性一个线程在持有写锁的情况下可以再获取读锁然后释放写锁从而降级为读锁。这个特性常用于在更新数据后仍然需要以锁保护的方式读取数据并防止其他写线程的干扰。但反之锁升级读锁 - 写锁是不允许的这会导致死锁。3. 核心细节解析与避坑指南了解了设计思想我们深入到每个锁的实现细节和那些容易踩坑的地方。这些细节往往决定了线上系统的稳定性和性能天花板。3.1 synchronized的锁升级与对象头synchronized的锁信息存储在Java对象头的Mark Word中。理解这个过程对性能调优很有帮助无锁状态对象刚创建时。偏向锁当第一个线程访问同步块时JVM会将锁标志位设为偏向模式并将线程ID记录在Mark Word中。以后该线程再进入时只需简单检查线程ID是否一致无需任何原子操作开销极小。适用于只有一个线程访问同步块的场景。轻量级锁当有第二个线程尝试获取锁时偏向锁会升级为轻量级锁。线程会在自己的栈帧中创建锁记录Lock Record并通过CAS操作将Mark Word指向这个锁记录。竞争表现为线程通过CAS自旋尝试获取锁。适用于线程交替执行同步块竞争很低的场景。重量级锁如果轻量级锁自旋失败或自旋次数超过阈值锁会膨胀为重量级锁。此时Mark Word指向一个操作系统层面的互斥量mutex未获取到锁的线程会被挂起进入阻塞队列等待操作系统调度唤醒。开销最大适用于高竞争场景。避坑指南避免锁住大对象或耗时操作synchronized锁的是对象不要锁住一个可能会被频繁修改或本身很大的对象如new Object()不如锁一个专用的、私有的final Object lock new Object();。更不要在同步块内进行IO、网络调用等耗时操作这会让其他线程长时间等待严重降低并发度。警惕锁粗化与锁消除JVM会进行锁优化。锁粗化是指将连续多个细粒度的锁合并为一个粗粒度的锁减少锁的申请/释放开销。锁消除是指JVM通过逃逸分析发现某些锁对象不可能被其他线程访问从而直接取消锁。这些是好事但意味着你的代码行为可能与预期略有不同。String.intern()与类锁的坑synchronized锁字符串常量池中的对象是极度危险的行为因为String.intern()返回的是全局唯一的对象可能导致毫不相干的代码模块发生意外的锁竞争。同理锁XXX.class这样的类对象也要格外小心其影响范围。3.2 ReentrantLock的Condition精准通知ReentrantLock配合Condition可以实现比Object.wait()/notify()更精准的线程间通信。这是其强大之处但也容易用错。import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class ConditionDemo { private final ReentrantLock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); // 条件队列未满 private final Condition notEmpty lock.newCondition(); // 条件队列非空 private final Object[] items new Object[100]; private int putptr, takeptr, count; public void put(Object x) throws InterruptedException { lock.lock(); try { while (count items.length) // 队列已满 notFull.await(); // 释放锁在notFull条件上等待 items[putptr] x; if (putptr items.length) putptr 0; count; notEmpty.signal(); // 队列非空了唤醒一个在notEmpty上等待的线程消费者 } finally { lock.unlock(); } } public Object take() throws InterruptedException { lock.lock(); try { while (count 0) // 队列为空 notEmpty.await(); // 释放锁在notEmpty条件上等待 Object x items[takeptr]; if (takeptr items.length) takeptr 0; --count; notFull.signal(); // 队列不满了唤醒一个在notFull上等待的线程生产者 return x; } finally { lock.unlock(); } } }避坑指南必须在lock()和unlock()之间使用ConditionCondition对象必须从当前锁实例获取并且调用await()/signal()时当前线程必须持有该锁否则会抛出IllegalMonitorStateException。await()通常要在while循环中检查条件因为被唤醒的线程可能因为“虚假唤醒”spurious wakeup或其他线程的操作导致条件仍未满足。用while循环进行条件重检是标准做法。signal()与signalAll()的选择signal()只唤醒一个等待线程效率高但如果你不确定哪个线程该被唤醒或者条件满足后所有等待线程都能继续执行则使用signalAll()。错误使用可能导致线程无法被唤醒而“饿死”。3.3 ReentrantReadWriteLock的锁降级与线程饥饿锁降级是ReentrantReadWriteLock的一个关键特性也是面试常考点。它指的是持有写锁的线程可以继续获取读锁然后释放写锁的过程。这样做的好处是在数据更新后仍然能保证当前线程读取数据时其他写线程无法修改从而保证了数据可见性同时又不阻塞其他读线程。// 锁降级示例保证数据更新后其他线程读到的是最新值且当前线程的读取不受干扰 public class CacheWithLockDowngrade { private final MapString, Object cache new HashMap(); private final ReentrantReadWriteLock rwl new ReentrantReadWriteLock(); private final ReentrantReadWriteLock.ReadLock readLock rwl.readLock(); private final ReentrantReadWriteLock.WriteLock writeLock rwl.writeLock(); public Object get(String key) { readLock.lock(); try { return cache.get(key); } finally { readLock.unlock(); } } public void put(String key, Object value) { writeLock.lock(); try { // 1. 更新数据 cache.put(key, value); // 2. 锁降级在持有写锁的情况下获取读锁 readLock.lock(); } finally { writeLock.unlock(); // 3. 释放写锁降级为读锁 } try { // 4. 这里仍然持有读锁可以安全地读取或其他基于新数据的操作 // 例如触发一个事件事件处理中需要读这个数据 System.out.println(Data updated for key: key , value: cache.get(key)); } finally { readLock.unlock(); // 5. 释放读锁 } } }避坑指南写线程饥饿问题这是读写锁最经典的陷阱。如果读锁被长期持有比如读操作很重或读线程非常多写线程可能永远无法获取写锁。ReentrantReadWriteLock的“非公平”模式默认偏向读线程更容易导致饥饿。解决方案是1) 评估场景是否真的“读多写少”2) 如果写操作也频繁考虑使用其他并发容器或锁3) 使用公平锁new ReentrantReadWriteLock(true)但会牺牲部分吞吐量。锁升级死锁尝试在持有读锁的情况下获取写锁锁升级会导致死锁因为读锁未释放写锁永远无法获取需要等待所有读锁释放。JDK明确不支持锁升级。性能反优化如果读操作非常简单比如只是读一个volatile变量那么使用读写锁带来的开销维护读锁计数、检查写锁状态可能会超过其收益。在这种情况下使用volatile或原子变量可能是更好的选择。4. 从理论到实践三种锁的完整示例与性能对比光说不练假把式。下面我们通过一个模拟的“商品库存服务”场景来展示三种锁的具体用法并分析其性能差异。假设我们有一个ProductStock类它有一个库存数量stock我们需要安全地对其进行“查询”和“扣减”操作。4.1 使用synchronized实现public class ProductStockSync { private int stock; public ProductStockSync(int initialStock) { this.stock initialStock; } // 查询库存 public synchronized int getStock() { // 模拟一个轻微的读延迟 try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } return stock; } // 扣减库存 public synchronized boolean reduceStock(int quantity) { if (stock quantity) { // 模拟一个处理逻辑 try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } stock - quantity; return true; } return false; } }特点实现最简单所有方法串行执行。在读多写少的场景下大量读线程会被不必要的阻塞。4.2 使用ReentrantLock实现import java.util.concurrent.locks.ReentrantLock; public class ProductStockReentrantLock { private int stock; private final ReentrantLock lock new ReentrantLock(); // 默认非公平锁 public ProductStockReentrantLock(int initialStock) { this.stock initialStock; } public int getStock() { lock.lock(); try { try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } return stock; } finally { lock.unlock(); // 必须手动释放 } } public boolean reduceStock(int quantity) { lock.lock(); try { if (stock quantity) { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } stock - quantity; return true; } return false; } finally { lock.unlock(); } } // 演示tryLock避免死等 public boolean tryReduceStock(int quantity, long timeout, TimeUnit unit) throws InterruptedException { if (lock.tryLock(timeout, unit)) { try { if (stock quantity) { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } stock - quantity; return true; } return false; } finally { lock.unlock(); } } return false; // 指定时间内未获取到锁 } }特点提供了tryLock等灵活功能但锁的粒度和synchronized版本一样粗读操作依然互斥。4.3 使用ReentrantReadWriteLock实现import java.util.concurrent.locks.ReentrantReadWriteLock; public class ProductStockReadWriteLock { private int stock; private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); private final ReentrantReadWriteLock.ReadLock readLock rwLock.readLock(); private final ReentrantReadWriteLock.WriteLock writeLock rwLock.writeLock(); public ProductStockReadWriteLock(int initialStock) { this.stock initialStock; } public int getStock() { readLock.lock(); try { try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } return stock; } finally { readLock.unlock(); } } public boolean reduceStock(int quantity) { writeLock.lock(); try { if (stock quantity) { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } stock - quantity; return true; } return false; } finally { writeLock.unlock(); } } }特点读锁共享多个查询可以并发执行只有在扣减库存写操作时才互斥。在读取非常频繁的场景下性能优势巨大。4.4 性能压测对比我们可以写一个简单的压测程序模拟100个线程同时进行1000次查询和10个线程进行100次扣减。public class LockBenchmark { public static void main(String[] args) throws InterruptedException { test(new ProductStockSync(1000), Synchronized); test(new ProductStockReentrantLock(1000), ReentrantLock); test(new ProductStockReadWriteLock(1000), ReadWriteLock); } static void test(ProductStockSync stock, String name) throws InterruptedException { // 实现略创建线程池执行读/写任务计算总耗时 System.out.println(name 总耗时: totalTime ms); } // 为另外两个接口需要定义相同的测试方法通过接口或抽象类 }预期结果在模拟了读延迟和写延迟的情况下Synchronized和ReentrantLock版本耗时最长因为所有读操作被串行化。ReadWriteLock版本耗时显著缩短因为读操作可以并发。如果写操作比例极高ReadWriteLock的优势会变小甚至因为锁结构更复杂而略慢于ReentrantLock。实操心得 压测时一定要模拟真实的操作耗时比例。如果读操作只是内存访问纳秒级而锁操作本身CAS、系统调用开销成为主导那么读写锁的优势可能不明显甚至因为内部状态维护更复杂而变慢。因此性能优化一定要基于 profiling性能剖析而不是凭空猜想。可以使用Arthas、Async-Profiler等工具查看热点方法确认锁竞争是否真的是瓶颈。5. 线上问题排查与线程池结合实战锁很少单独使用它通常在线程池管理的并发任务中发挥作用。错误地将锁与线程池结合是生产环境常见的问题源。5.1 线程池配置不当引发的死锁想象一个场景我们有一个固定大小为5的线程池。提交了5个任务每个任务都需要获取锁A然后在锁A内部又提交了一个需要获取锁B的新任务到同一个线程池。ExecutorService executor Executors.newFixedThreadPool(5); ReentrantLock lockA new ReentrantLock(); ReentrantLock lockB new ReentrantLock(); for (int i 0; i 5; i) { executor.submit(() - { lockA.lock(); try { // 内部提交一个需要lockB的任务 Future? innerFuture executor.submit(() - { lockB.lock(); try { System.out.println(Got lock B); } finally { lockB.unlock(); } }); innerFuture.get(); // 等待内部任务完成 } finally { lockA.unlock(); } }); }问题5个外部任务占满了线程池每个都在等待内部任务完成。但内部任务也在等待线程池有空闲线程来执行而空闲线程被外部任务占着它们在等待innerFuture.get()。这就形成了经典的线程池死锁。解决方案使用更大的线程池确保线程池核心线程数大于可能产生嵌套提交的任务链深度。使用不同的线程池将嵌套任务提交到另一个独立的线程池。避免在持有锁的情况下等待另一个任务完成重新设计任务流程例如使用CompletableFuture进行非阻塞的组合。使用tryLock并设置超时在获取锁时设置超时时间超时后记录日志并执行降级或重试逻辑避免无限期等待。5.2 synchronized与线程池的隐蔽坑锁对象被共享public class ProblematicService { // 这是一个Spring单例Bean public void process(ListString dataList) { synchronized (dataList) { // 危险锁的是传入的参数对象 // 处理dataList } } }如果多个线程调用process方法并传入了同一个List对象那么同步是有效的。但如果传入的是不同的List对象即使内容一样锁就完全失效了因为锁的是不同的对象。更危险的是如果传入的List对象来自外部如RPC反序列化你根本无法控制。正确做法锁一个私有的、final的、专门用于同步的成员变量。public class SafeService { private final Object lock new Object(); // 专用锁对象 public void process(ListString dataList) { synchronized (lock) { // 处理dataList } } }5.3 排查工具与技巧当线上出现疑似死锁或锁竞争导致性能问题时查看线程Dump使用jstack pid命令或jvisualvm等工具获取线程快照。在Dump中搜索“BLOCKED”状态的线程和“waiting to lock 0x000000071abc0f00”这样的信息可以清晰地看到哪些线程在等待哪些锁以及锁被哪个线程持有。使用Arthas的thread -b命令这个命令可以直接找出当前JVM中阻塞其他线程最多的“罪魁祸首”线程快速定位热点锁。监控锁竞争指标对于ReentrantLock可以通过getQueueLength()方法监控等待队列长度但生产环境慎用影响性能。更好的方式是通过JMX或APM应用性能监控工具来监控同步方法的平均耗时或调用等待时间。代码审查检查是否在同步块或锁内执行了慢操作如数据库查询、HTTP调用、是否存在嵌套锁、锁的范围是否过大。6. 进阶StampedLock与锁的性能终极权衡在Java 8中引入了一个新的锁机制StampedLock。它可以看作是ReentrantReadWriteLock的一个更高效的、支持乐观读的替代方案。理解它有助于你形成完整的锁选型视野。StampedLock的核心思想是乐观读。在乐观读模式下线程读取数据时并不获取读锁而是获取一个“邮票”stamp。读完数据后再验证这个“邮票”是否仍然有效即在此期间没有写操作发生。如果有效则读操作成功完全避免了锁的开销如果无效则可以退化为悲观读锁。import java.util.concurrent.locks.StampedLock; public class Point { private double x, y; private final StampedLock sl new StampedLock(); // 写方法使用写锁 void move(double deltaX, double deltaY) { long stamp sl.writeLock(); // 获取写锁返回邮票 try { x deltaX; y deltaY; } finally { sl.unlockWrite(stamp); // 释放写锁需传入邮票 } } // 只读方法使用乐观读 double distanceFromOrigin() { long stamp sl.tryOptimisticRead(); // 1. 尝试乐观读获取邮票 double currentX x, currentY y; // 2. 读取数据到局部变量 if (!sl.validate(stamp)) { // 3. 验证邮票是否有效 stamp sl.readLock(); // 4. 无效则升级为悲观读锁 try { currentX x; currentY y; } finally { sl.unlockRead(stamp); } } return Math.sqrt(currentX * currentX currentY * currentY); } }选型考量优势在读非常多、写非常少且数据一致性要求不是极端严格的场景下乐观读带来的性能提升是巨大的。它避免了读锁的获取和释放开销。劣势API比ReentrantReadWriteLock更复杂容易用错。它不是可重入锁一个线程持有写锁时不能再获取读锁但可以通过tryConvertToReadLock转换。错误使用可能导致死锁。适用场景适用于类似Point这种简单的、读远大于写的聚合状态或者作为某些高性能缓存组件的内部实现。对于复杂的业务逻辑ReentrantReadWriteLock的语义更清晰安全。个人建议在大部分业务开发中优先使用synchronized和ReentrantLock/ReentrantReadWriteLock。只有在性能 profiling 明确显示读锁成为瓶颈且你深刻理解StampedLock的语义和风险时才考虑使用它。不要为了追求“高级”而引入不必要的复杂性。锁的选择没有银弹从synchronized的简洁可靠到ReentrantLock的灵活强大再到ReentrantReadWriteLock和StampedLock的场景化优化每一种都是工具箱里应对不同情况的利器。最关键的是理解其背后的原理和代价结合真实的数据监控、压测来做决策而不是盲目追随所谓的最佳实践。在实际编码中我通常会先用synchronized实现功能在验证阶段通过压测和监控发现瓶颈后再有针对性地升级到更复杂的锁机制并且一定会加上详细的注释说明为什么这里不能用更简单的锁。这样既能保证开发效率又能确保系统在需要时具备足够的扩展性和性能。
返回列表