1. 从一把锁到多把钥匙为什么我们需要不同的锁机制如果你写过Java并发代码那么synchronized这个关键字你一定不陌生。它就像一把最简单的锁你用它把一段代码或者一个方法锁起来告诉其他线程“这里我在用你们先等着。” 在很多简单的场景下这把锁确实够用了比如给一个共享的计数器做加法。但当你开始处理更复杂的业务比如一个读多写少的缓存系统或者需要尝试获取锁、公平地获取锁时你就会发现这把“万能钥匙”开始变得不那么好用了。它要么太“霸道”比如读操作也被阻塞要么不够灵活比如无法中断一个正在等待锁的线程。这就是ReentrantLock和ReentrantReadWriteLock登场的背景。它们不是要取代synchronized而是提供了更丰富、更精细的并发控制工具让你能根据具体的业务场景“量体裁衣”。synchronized是Java语言内置的、隐式的锁而ReentrantLock等是java.util.concurrent.locks包下提供的显式锁。显式锁意味着锁的获取和释放都需要你手动写代码控制这带来了更大的灵活性同时也要求开发者承担更多的责任比如必须在finally块中释放锁否则可能导致死锁。最近在面试和实际项目调优中关于锁和线程池的讨论热度一直很高。从“synchronized锁方法时一个线程sleep了另一个线程能不能进另一个方法”这类底层原理探讨到“线程池队列大小和并发量的关系”这种实战性能调优问题都指向一个核心在高并发环境下对共享资源的安全、高效访问是系统稳定性的基石。理解这几把锁的差异不仅是应对“八股文”面试更是解决实际线上问题的关键技能。接下来我们就抛开枯燥的概念从它们到底怎么用、以及为什么这么用说起。2. synchronized内置锁的功与过synchronized是Java中最古老、最基础的线程同步机制。它的用法简单粗暴主要用在三个地方实例方法、静态方法和代码块。2.1 基本用法与底层原理当你把一个实例方法声明为synchronized时锁住的是当前对象实例this。这意味着同一时刻同一个对象的这个同步方法只能被一个线程执行。public class Counter { private int count 0; // 同步实例方法锁是当前Counter对象(this) public synchronized void increment() { count; } public synchronized int getCount() { return count; } }对于静态同步方法锁住的是这个类的Class对象。这意味着它锁的是整个类所有该类的实例在调用这个静态方法时都会互斥。public class StaticCounter { private static int count 0; // 同步静态方法锁是StaticCounter.class public static synchronized void increment() { count; } }最灵活的是同步代码块你可以指定任意一个对象作为锁监视器。public class FineGrainedLock { private final Object lockA new Object(); private final Object lockB new Object(); private int valueA 0; private int valueB 0; public void updateA() { synchronized (lockA) { // 使用专门的锁对象lockA valueA; // 一些操作... } } public void updateB() { synchronized (lockB) { // 使用专门的锁对象lockB与updateA互不干扰 valueB; // 一些操作... } } }注意选择哪个对象作为锁至关重要。通常建议使用私有的、final的专门对象如private final Object lock new Object();而不是锁住this或者一个可能被外部修改的对象。这样可以避免外部代码意外地持有你的锁导致死锁或性能问题。synchronized的底层实现依赖于JVM中的对象头Object Header里的Mark Word。当一个线程进入同步块时JVM会尝试通过CAS操作在Mark Word中设置锁记录。它的锁升级过程是优化的重点无锁 - 偏向锁 - 轻量级锁自旋锁- 重量级锁。在竞争不激烈的情况下这种优化能极大提升性能。2.2 一个经典误区的澄清synchronized方法与对象锁网络热词里有一个问题“一个对象的两个方法加synchronized一个线程进去sleep另一个线程可以进入到另一个吗” 这是一个非常好的理解锁范围的问题。答案是不能。synchronized实例方法锁的是当前对象实例this。只要锁没被释放即第一个线程没有退出同步方法其他线程就无法获取这个对象锁因此也就无法进入该对象的任何一个同步实例方法。sleep()方法并不会释放锁它只是让当前线程暂停执行指定的时间但依然持有锁。所以即使第一个线程在同步方法里sleep了10秒钟第二个线程在这10秒内也无法进入同一个对象的另一个同步方法。public class SleepLockDemo { public synchronized void methodA() { System.out.println(Thread.currentThread().getName() 进入methodA); try { Thread.sleep(5000); // sleep不释放锁 } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() 离开methodA); } public synchronized void methodB() { System.out.println(Thread.currentThread().getName() 进入methodB); } public static void main(String[] args) { SleepLockDemo demo new SleepLockDemo(); new Thread(demo::methodA, Thread-1).start(); // 确保Thread-1先拿到锁 try { Thread.sleep(100); } catch (InterruptedException e) {} new Thread(demo::methodB, Thread-2).start(); } } // 输出 // Thread-1 进入methodA // 等待5秒后 // Thread-1 离开methodA // Thread-2 进入methodB2.3 synchronized的局限性尽管synchronized简单易用且JVM不断优化其性能但它存在几个明显的局限性不可中断一个线程在尝试获取synchronized锁时如果锁被其他线程持有它只能无限期地阻塞等待无法响应中断。非公平锁synchronized的锁获取默认是非公平的。这意味着后请求锁的线程可能比先等待的线程先获取到锁在高并发下可能导致某些线程“饥饿”。单一条件每个锁对象只有一个隐式的等待/通知机制wait(),notify(),notifyAll()。如果你需要基于多个条件让线程等待synchronized就显得力不从心。无法尝试锁你无法尝试获取锁如果获取不到就立即返回或做其他事情。你只能阻塞。正是这些局限性催生了更强大的ReentrantLock。3. ReentrantLock灵活强大的显式锁ReentrantLock实现了Lock接口提供了比synchronized更丰富的功能。顾名思义它也是可重入的一个线程可以多次获取同一把锁。3.1 基础使用模板使用ReentrantLock必须遵循“锁申请-释放”的模板务必在finally块中释放锁以确保异常发生时锁也能被释放。import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class ReentrantLockCounter { private final Lock lock new ReentrantLock(); private int count 0; public void increment() { lock.lock(); // 手动获取锁 try { count; // 其他受保护的资源操作... } finally { lock.unlock(); // 必须在finally中手动释放锁 } } }3.2 核心特性详解3.2.1 可中断与超时获取这是ReentrantLock相对于synchronized的一大优势。public class InterruptibleLockDemo { private final Lock lock new ReentrantLock(); public void tryLockInterruptibly() throws InterruptedException { System.out.println(Thread.currentThread().getName() 尝试获取锁); lock.lockInterruptibly(); // 可中断的获取锁方式 try { System.out.println(Thread.currentThread().getName() 获取到锁开始长时间工作...); Thread.sleep(10000); // 模拟长时间工作 } finally { lock.unlock(); System.out.println(Thread.currentThread().getName() 释放锁); } } public boolean tryLockWithTimeout() { System.out.println(Thread.currentThread().getName() 尝试在1秒内获取锁); try { if (lock.tryLock(1, TimeUnit.SECONDS)) { // 尝试获取锁最多等待1秒 try { System.out.println(Thread.currentThread().getName() 成功获取锁); return true; } finally { lock.unlock(); } } else { System.out.println(Thread.currentThread().getName() 等待超时放弃获取锁); return false; } } catch (InterruptedException e) { System.out.println(Thread.currentThread().getName() 在等待锁时被中断); return false; } } }lockInterruptibly()方法允许在等待锁的过程中响应线程中断。tryLock(timeout, unit)则允许尝试获取锁如果指定时间内获取不到就返回false去做其他事情避免了无限期阻塞。3.2.2 公平锁与非公平锁ReentrantLock的构造函数可以接受一个boolean参数指定是否创建公平锁。Lock fairLock new ReentrantLock(true); // 公平锁 Lock unfairLock new ReentrantLock(); // 或 new ReentrantLock(false) 非公平锁公平锁多个线程按照申请锁的先后顺序来获取锁。就像排队先来后到。优点是避免了线程“饥饿”缺点是整体吞吐量相对较低因为维护队列需要开销。非公平锁线程获取锁的顺序不一定与申请顺序一致。允许“插队”。优点是吞吐量高因为减少了线程切换的开销一个线程释放锁的瞬间另一个正在运行的线程可以立刻获取到而不必唤醒队列中第一个等待的线程。缺点是可能导致某些线程长时间等待。实操心得在绝大多数情况下使用非公平锁是更好的选择。因为公平性是以性能为代价的。只有当你的业务对线程执行的绝对顺序有严格要求并且你确定性能下降在可接受范围内时才考虑使用公平锁。synchronized就是非公平锁。3.2.3 条件变量Condition这是ReentrantLock另一个杀手级特性。一个Lock可以关联多个Condition对象用于更精细的线程等待/通知。经典的生产者-消费者问题可以用Condition优雅地实现import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class BoundedBuffer { private final Lock 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(); // 生产者等待在“未满”条件上 } items[putPtr] x; if (putPtr items.length) putPtr 0; count; notEmpty.signal(); // 放入一个元素后队列肯定非空了唤醒一个消费者 } finally { lock.unlock(); } } public Object take() throws InterruptedException { lock.lock(); try { while (count 0) { // 队列为空 notEmpty.await(); // 消费者等待在“非空”条件上 } Object x items[takePtr]; if (takePtr items.length) takePtr 0; count--; notFull.signal(); // 取走一个元素后队列肯定不满了唤醒一个生产者 return x; } finally { lock.unlock(); } } }使用两个ConditionnotFull和notEmpty可以精准地唤醒某一类等待的线程生产者或消费者避免了使用synchronized时notifyAll()带来的“惊群效应”唤醒所有等待线程但可能只有一部分能继续执行。4. ReentrantReadWriteLock读写分离的高并发利器在很多实际场景中比如缓存、配置中心读操作read的频率远高于写操作write。如果使用synchronized或ReentrantLock这种互斥锁即使多个线程都只是读数据也会被强制串行化这严重限制了系统的并发吞吐量。ReentrantReadWriteLock应运而生。它维护了一对锁一个读锁共享锁和一个写锁独占锁。读锁允许多个线程同时持有。只要没有线程持有写锁任何线程都可以成功获取读锁。写锁是独占锁。同一时刻只能有一个线程持有写锁。获取写锁时不能有任何线程持有读锁或写锁。它的核心规则可以总结为读读共享读写互斥写写互斥。4.1 基本用法import java.util.concurrent.locks.ReentrantReadWriteLock; public class CachedData { private Object data null; private volatile boolean cacheValid false; private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); public void processCachedData() { rwLock.readLock().lock(); // 先获取读锁 if (!cacheValid) { // 缓存失效需要重新加载写操作 // 必须在获取写锁之前释放读锁 rwLock.readLock().unlock(); rwLock.writeLock().lock(); // 获取写锁 try { // 再次检查因为释放读锁后到获取写锁前状态可能已被其他线程改变 if (!cacheValid) { data loadDataFromDB(); // 模拟从数据库加载 cacheValid true; } // 在释放写锁前降级为读锁 rwLock.readLock().lock(); // 锁降级的关键步骤 } finally { rwLock.writeLock().unlock(); // 释放写锁此时仍持有读锁 } // 锁降级完成继续以读锁保护使用data } try { use(data); // 使用数据 } finally { rwLock.readLock().unlock(); // 最终释放读锁 } } private Object loadDataFromDB() { /* ... */ return new Object(); } private void use(Object data) { /* ... */ } }4.2 锁降级与锁升级上面的示例演示了一个重要概念锁降级。锁降级持有写锁 - 获取读锁 - 释放写锁。这个过程是允许的并且是ReentrantReadWriteLock的一个重要特性。它保证了在数据更新后其他读线程能看到最新数据之前当前线程仍然能持有读锁来安全地使用数据。锁升级持有读锁 - 尝试获取写锁。ReentrantReadWriteLock不支持锁升级如果你试图在持有读锁的情况下去获取写锁将会导致死锁。因为写锁需要等待所有读锁包括自己持有的这个释放而自己又在等待写锁于是就卡死了。所以必须像示例中那样先释放读锁再去获取写锁。4.3 适用场景与性能考量ReentrantReadWriteLock非常适合读多写少的场景。它能极大提升系统的并发读性能。但在写多读少或者读写均衡的场景下由于读写锁本身比普通互斥锁更复杂其开销可能反而比ReentrantLock或synchronized更大甚至成为性能瓶颈。踩坑实录我曾经在一个配置热更新的服务中使用了ReentrantReadWriteLock。起初读多写少性能很好。后来业务变化写操作配置更新变得非常频繁导致写锁竞争激烈而且大量的读锁也阻塞了写锁的获取性能急剧下降。最后不得不重构将配置按维度拆分使用更细粒度的ConcurrentHashMap配合AtomicReference来管理才解决了问题。所以选择读写锁前一定要先分析清楚业务的读写比例。5. 三种锁机制的综合对比与选型指南为了更直观地对比我将三者的核心特性和适用场景总结如下表特性synchronizedReentrantLockReentrantReadWriteLock锁类型隐式、内置锁显式锁显式锁读写分离实现层面JVM层面由JVM实现和优化JDK层面Java代码实现基于AQSJDK层面基于AQS锁的获取自动获取和释放进入/退出同步块手动lock()/unlock()必须在finally中释放手动获取读锁readLock()或写锁writeLock()可中断否等待时不可中断是lockInterruptibly()读锁和写锁的获取都支持可中断方式超时尝试否是tryLock(long time, TimeUnit unit)读锁和写锁都支持tryLock公平锁仅非公平可选构造函数传true可选构造函数传true条件变量单一隐式条件wait/notify支持多个Condition写锁可以提供Condition读锁不能可重入性是是是读锁和写锁分别可重入性能JDK不断优化在低竞争下性能很好高竞争下可能更有优势功能多带来轻微开销读多写少场景下性能优势巨大锁降级不涉及不涉及支持写锁降级为读锁锁升级不涉及不涉及不支持读锁升级为写锁会死锁编码复杂度低语法简单中需注意手动释放锁中高需仔细处理读写锁的获取释放顺序5.1 如何选择一个简单的决策流首先考虑synchronized如果你的同步需求非常简单竞争不激烈并且不需要ReentrantLock的那些高级功能可中断、超时、公平锁、多个条件那么优先使用synchronized。它的代码更简洁由JVM负责优化不容易出错比如忘记释放锁。需要高级功能时选择ReentrantLock当你需要可中断的锁获取、超时机制、公平锁或者需要绑定多个条件变量Condition来实现复杂的线程协作如前面提到的生产者-消费者时ReentrantLock是你的不二之选。明确读多写少时选择ReentrantReadWriteLock当你能清晰界定业务场景中读取数据的频率远高于修改数据例如缓存系统、配置信息、元数据查询并且读操作本身不会修改共享状态时使用ReentrantReadWriteLock可以显著提升并发性能。务必警惕“写多读少”的场景此时它可能适得其反。考虑StampedLockJDK8在JDK8中引入了性能更强的StampedLock。它通过“乐观读”模式在读多写少的场景下性能可能比ReentrantReadWriteLock更好。但它不是可重入锁并且API更复杂容易用错。除非你在性能压测中明确发现ReentrantReadWriteLock是瓶颈否则建议谨慎使用。5.2 性能与死锁永恒的注意事项无论使用哪种锁都要牢记锁粒度要尽可能小只锁住必要的共享资源或代码段减少线程等待时间。锁时间要尽可能短在锁保护的代码块里不要执行耗时操作如IO、网络调用。避免嵌套锁与锁顺序死锁如果必须获取多把锁要定义全局的获取顺序并始终遵守这个顺序。使用工具排查利用jstack、jconsole或可视化Profiler工具定期检查应用是否存在死锁或锁竞争热点。6. 结合线程池锁在并发框架中的实战定位很多热词提到了线程池配置。锁和线程池是高并发编程中相辅相成的两大工具。线程池管理着线程的生命周期和执行任务而锁则保护着这些线程并发访问的共享资源。6.1 线程池任务中的锁使用提交到线程池的任务Runnable/Callable经常需要访问共享数据。此时锁的选择和使用至关重要。public class ThreadPoolWithLockDemo { // 一个共享的、需要保护的任务计数器 private final AtomicInteger taskCounter new AtomicInteger(0); // 使用ReentrantLock保护一个复杂的共享状态 private final Lock stateLock new ReentrantLock(); private SomeComplexState sharedState new SomeComplexState(); private final ExecutorService executor Executors.newFixedThreadPool(10); public void submitTasks() { for (int i 0; i 100; i) { executor.submit(() - { // 场景1简单的原子操作使用Atomic类无锁化性能最高 int taskId taskCounter.incrementAndGet(); // 场景2复杂的复合操作必须加锁 stateLock.lock(); try { sharedState.updatePartA(); // 一些其他操作... sharedState.updatePartB(); // 整个updatePartA和updatePartB需要作为一个原子操作 } finally { stateLock.unlock(); } System.out.println(Task taskId processed by Thread.currentThread().getName()); }); } executor.shutdown(); } }6.2 锁竞争对线程池性能的影响线程池的核心参数核心线程数、最大线程数、队列容量与锁竞争密切相关。如果任务中锁保护的临界区很长或者锁竞争非常激烈那么增加线程数可能不仅不能提升吞吐量反而会因为大量的线程上下文切换和锁竞争等待而降低性能。这就联系到热词中的问题“线程池 queuecapacity 队列大小怎么设置 和并发量的关系 和系统最大并发量的”。队列大小queueCapacity的设置是一个权衡如果队列太大任务堆积响应时间变长。如果队列太小且线程数达到最大值新任务会被拒绝。这个“最佳值”没有银弹它取决于任务性质是CPU密集型还是IO密集型任务中锁竞争是否激烈可接受延迟任务能容忍在队列中等待多久系统资源CPU核心数、内存。一个基本的思路是对于CPU密集型且锁竞争激烈的任务线程数不宜过多接近CPU核数队列可以设短一些以便快速触发拒绝策略防止系统被拖垮。对于IO密集型或锁竞争不激烈的任务可以适当增加线程数并使用有界队列来平滑流量。6.3 诊断锁相关问题当系统出现响应慢、吞吐量上不去时锁可能是元凶之一。使用jstackjstack pid可以打印线程栈。查找状态为BLOCKED (on object monitor)或WAITING (on object monitor)的线程看它们等待的是哪个锁以及谁持有这个锁。使用可视化工具JProfiler、YourKit、Java Mission Control等工具可以直观地显示锁竞争热点、持有锁时间最长的线程等信息。查看日志在锁获取和释放处添加DEBUG日志注意日志输出本身不能成为性能瓶颈可以帮助分析锁的争用情况。我个人在排查一次线上服务卡顿问题时就是通过jstack发现大量线程阻塞在同一个ReentrantReadWriteLock的写锁上。原因是某个定时任务频繁更新缓存写操作且更新逻辑复杂耗时导致所有读请求被长时间阻塞。最终通过将定时更新改为增量更新、并缩短写锁持有时间解决了问题。锁是并发编程的基石理解synchronized、ReentrantLock和ReentrantReadWriteLock的差异与适用场景能帮助你在设计和优化系统时做出更合适的选择。记住没有最好的锁只有最适合当前场景的锁。从简单的synchronized开始当遇到其无法满足的需求时再考虑更强大的ReentrantLock当明确存在读多写少的场景时ReentrantReadWriteLock会给你带来惊喜。同时永远要将锁的使用与线程池等并发框架结合起来考量才能构建出真正健壮、高效的多线程应用。