ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java多线程同步:synchronized、ReentrantLock与读写锁实战解析

Java多线程同步:synchronized、ReentrantLock与读写锁实战解析 1. 项目概述为什么我们需要锁在Java的世界里多线程编程是提升应用性能、充分利用多核CPU能力的核心手段。但多线程带来的不仅仅是速度还有“混乱”的风险。想象一下你和几个同事同时编辑一份共享的在线文档如果没有任何协调机制你刚写好的段落可能下一秒就被别人覆盖了最终文档会变得一团糟。程序中的共享变量、共享对象就是这份“在线文档”而多个线程就是同时编辑的“同事”。我见过太多因为线程安全问题导致的线上故障账户余额莫名其妙多扣了钱、商品库存卖成了负数、日志文件内容错乱交织。这些问题在低并发下可能潜伏数月一旦流量高峰来临就会瞬间爆发排查起来犹如大海捞针。因此理解并正确使用线程同步机制是每一位Java开发者从“会写代码”到“能写好代码”的必经之路也是面试中绕不开的经典话题。本次我们将深入探讨Java中最核心、最常用的三种线程同步工具内置的synchronized关键字、功能更丰富的ReentrantLock以及针对读多写少场景优化的ReentrantReadWriteLock。我不会只停留在概念和API介绍上而是会结合我多年踩坑的经验带你理解它们的设计哲学、适用场景、性能差异以及那些官方文档里不会写的“坑点”。无论你是正在准备面试还是希望优化现有系统性能这篇文章都能提供直接的参考和可复现的示例。2. 核心同步机制深度解析与选型在开始敲代码之前我们必须从设计层面理解这几个工具。选择哪种锁不是一个简单的“哪个更新就用哪个”的问题而是一个基于场景、性能、复杂度综合权衡的技术决策。2.1 synchronizedJava同步的基石synchronized是Java语言层面提供的同步原语它最简单也最基础。你可以把它理解为每个Java对象与生俱来的一把“内置锁”也叫监视器锁。它的工作方式非常直观修饰实例方法锁住的是当前对象实例this。修饰静态方法锁住的是当前类的Class对象。修饰代码块需要显式指定锁对象锁住的是给定的对象。为什么它如此重要JVM原生支持它的加锁、解锁操作是由JVM底层实现的在字节码层面通过monitorenter和monitorexit指令完成。这意味着它的行为是语言规范的一部分非常稳定。自动释放无论是正常执行完毕还是抛出异常锁都会被自动释放几乎不可能造成锁泄漏。可重入性一个线程已经持有了某个对象的锁它可以再次进入被这个锁保护的其他代码块或方法。这是避免自身死锁的关键特性。但它并非万能其局限性也很明显功能单一它只提供基本的互斥功能。你想尝试获取锁tryLock不行。你想设置公平锁不行。你想让等待锁的线程响应中断也很麻烦。性能旧闻在早期版本如Java 5之前synchronized的性能确实是个问题因为它的实现涉及到用户态和内核态的切换。但经过多年的优化如锁升级偏向锁-轻量级锁-重量级锁在非极端竞争的场景下它的性能已经与ReentrantLock相差无几。很多性能对比文章没有更新这个认知。不够灵活锁的获取和释放必须在一个方法或代码块的结构内完成这种“块结构”的锁有时不符合更复杂的并发控制逻辑。实操心得对于大多数简单的同步场景比如保护一个简单的计数器、一个共享配置对象优先使用synchronized。它的简洁性和可靠性是首要优势。不要为了“炫技”而盲目使用更复杂的锁。2.2 ReentrantLock灵活强大的显式锁ReentrantLock是java.util.concurrent.locks包下提供的一个类它实现了Lock接口。顾名思义它也是可重入的。与synchronized最大的不同在于它是一个“显式锁”你需要手动调用lock()和unlock()。它的核心优势在于提供了synchronized所不具备的灵活性尝试非阻塞获取锁tryLock()方法可以立即返回获取结果不会使线程阻塞。这在避免死锁按顺序获取锁或实现锁超时机制时非常有用。if (lock.tryLock(3, TimeUnit.SECONDS)) { // 尝试等待3秒 try { // 操作共享资源 } finally { lock.unlock(); } } else { // 获取锁失败执行备选方案 log.warn(获取锁超时执行降级逻辑); }可中断的锁等待lockInterruptibly()方法允许在等待锁的过程中响应线程中断。这对于实现可取消的任务至关重要。公平锁与非公平锁构造函数可以传入一个boolean值决定是否创建公平锁。公平锁保证等待时间最长的线程优先获取锁避免了“线程饥饿”但会带来更大的性能开销因为需要维护一个有序队列。非公平锁是默认的也是高性能的常见选择它允许“插队”但可能导致某些线程长时间得不到锁。可以绑定多个条件一个ReentrantLock可以创建多个Condition对象用于实现更精细的线程间通信如生产者-消费者模型这比Object.wait()/notify()更清晰、更安全。然而能力越大责任越大必须手动释放必须在finally块中调用unlock()否则锁将永远不会释放导致灾难性后果。这是使用ReentrantLock时最容易出错的地方。代码更复杂相比synchronized的语法糖ReentrantLock的代码模板更冗长。避坑指南使用ReentrantLock的黄金法则是“lock() 操作必须紧跟 try 块且 unlock() 必须在 finally 块中第一行”。我建议使用以下模板形成肌肉记忆ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 访问共享资源 } finally { lock.unlock(); // 确保无论如何锁都会被释放 }2.3 ReentrantReadWriteLock读写分离的高并发优化这是专门为“读多写少”的并发场景设计的锁。在这种场景下如果使用synchronized或ReentrantLock那么所有读操作之间也是互斥的这严重限制了系统的并发吞吐量。事实上多个线程同时读取共享资源是完全安全的只有写入时才需要互斥。ReentrantReadWriteLock将锁分成了两部分读锁共享锁由readLock()方法返回。只要没有线程持有写锁多个线程可以同时持有读锁。写锁排他锁由writeLock()方法返回。同一时刻只能有一个线程持有写锁并且当有线程持有写锁时任何其他线程都无法获取读锁或写锁。它的设计哲学是最大化读的并发性保证写的独占性。一个典型的使用场景是缓存缓存的数据会被频繁读取但更新写入的频率相对较低。public class CacheWithReadWriteLockK, V { private final MapK, V map new HashMap(); private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); private final Lock readLock rwLock.readLock(); private final Lock writeLock rwLock.writeLock(); public V get(K key) { readLock.lock(); try { return map.get(key); } finally { readLock.unlock(); } } public void put(K key, V value) { writeLock.lock(); try { map.put(key, value); } finally { writeLock.unlock(); } } }需要注意的复杂性锁降级这是ReentrantReadWriteLock支持的一个高级特性允许一个线程在持有写锁的情况下获取读锁然后释放写锁从而“降级”为读锁。这可以在保持数据可见性的同时立刻允许其他读线程访问。但锁升级读锁 - 写锁是不允许的这会导致死锁。写锁饥饿在极端读多写少的场景下如果读锁一直不断被获取写锁线程可能会永远等待下去。ReentrantReadWriteLock的公平模式可以在一定程度上缓解这个问题但非公平模式默认下需要特别注意。开销读写锁的内部状态比普通互斥锁更复杂因此其本身的开销也更大。如果读写操作都很简短或者写操作频繁那么使用读写锁可能反而比普通互斥锁性能更差。经验之谈不要一看到“缓存”或“读多写少”就无脑上ReentrantReadWriteLock。先用synchronized或ReentrantLock实现一个简单版本进行压测。如果监控发现读操作确实存在明显的锁竞争可通过线程堆栈分析或JMX监控发现再考虑引入读写锁进行优化。很多时候简单的互斥锁已经足够。3. 从理论到实践核心示例与场景剖析理解了原理我们通过具体的代码示例来感受它们的用法和差异。我会模拟一个经典的“银行账户”场景并逐步引入更复杂的需求。3.1 基础场景使用synchronized保护账户余额假设我们有一个BankAccount类最基础的需求是保证取款(withdraw)操作的线程安全。/** * 使用 synchronized 关键字保证线程安全 */ public class BankAccountSync { private double balance; public BankAccountSync(double initialBalance) { this.balance initialBalance; } // 同步实例方法锁是 this当前账户对象 public synchronized void deposit(double amount) { if (amount 0) { balance amount; System.out.println(Thread.currentThread().getName() 存款: amount , 余额: balance); } } // 同步实例方法 public synchronized void withdraw(double amount) { if (amount 0 balance amount) { balance - amount; System.out.println(Thread.currentThread().getName() 取款: - amount , 余额: balance); } else { System.out.println(Thread.currentThread().getName() 取款失败余额不足。尝试取款: amount , 当前余额: balance); } } public synchronized double getBalance() { return balance; } }测试与验证public class SyncDemo { public static void main(String[] args) throws InterruptedException { BankAccountSync account new BankAccountSync(1000); Runnable task () - { for (int i 0; i 5; i) { account.withdraw(100); try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } } }; Thread t1 new Thread(task, 线程-A); Thread t2 new Thread(task, 线程-B); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(最终余额: account.getBalance()); // 正确结果应为 0 } }这个例子简单有效。两个线程并发取款由于withdraw是同步的所以余额永远不会被透支最终结果稳定为0。3.2 进阶场景使用ReentrantLock实现更复杂的逻辑现在需求升级了取款时如果余额不足我们不直接拒绝而是让线程等待一段时间期间如果其他线程存了款使得余额足够则唤醒它继续尝试取款。这需要锁的“条件等待”机制synchronized配合wait()/notify()也能做但ReentrantLock的Condition更清晰。import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; /** * 使用 ReentrantLock 和 Condition 实现条件等待 */ public class BankAccountWithCondition { private double balance; private final ReentrantLock lock new ReentrantLock(); private final Condition sufficientFunds lock.newCondition(); // 条件资金充足 public BankAccountWithCondition(double initialBalance) { this.balance initialBalance; } public void deposit(double amount) { lock.lock(); try { if (amount 0) { balance amount; System.out.println(Thread.currentThread().getName() 存款: amount , 余额: balance); // 存款后通知所有等待“资金充足”条件的线程 sufficientFunds.signalAll(); } } finally { lock.unlock(); } } /** * 尝试取款如果余额不足等待最多指定时间 * param amount 取款金额 * param maxWaitSeconds 最大等待秒数 * return 取款是否成功 */ public boolean tryWithdraw(double amount, long maxWaitSeconds) throws InterruptedException { lock.lock(); try { long waitTime maxWaitSeconds * 1000; while (balance amount) { if (waitTime 0) { System.out.println(Thread.currentThread().getName() 等待超时取款失败。); return false; // 等待超时 } System.out.println(Thread.currentThread().getName() 余额不足等待中... 需 amount , 现有 balance); // 在sufficientFunds条件上等待并返回剩余的等待时间 waitTime sufficientFunds.awaitNanos(TimeUnit.SECONDS.toNanos(maxWaitSeconds)); } // 余额充足执行取款 balance - amount; System.out.println(Thread.currentThread().getName() 取款成功: - amount , 余额: balance); return true; } finally { lock.unlock(); } } }这个例子展示了ReentrantLock的灵活性。Condition对象sufficientFunds将“等待余额充足”这个逻辑从锁本身中解耦出来代码意图更明确。awaitNanos方法提供了更精确的超时控制。3.3 高性能场景使用ReentrantReadWriteLock优化查询假设我们有一个“产品库存中心”成千上万的用户会频繁查询读库存但管理员更新写库存的频率相对较低。import java.util.HashMap; import java.util.Map; import java.util.concurrent.locks.ReentrantReadWriteLock; /** * 使用 ReentrantReadWriteLock 保护库存数据 */ public class Inventory { private final MapString, Integer stock new HashMap(); private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(true); // 使用公平锁避免写锁饥饿 public Inventory() { // 初始化一些库存 stock.put(item_001, 100); stock.put(item_002, 50); } // 查询库存高频操作 public Integer getStock(String itemId) { rwLock.readLock().lock(); // 获取读锁 try { // 模拟一个相对耗时的读取过程如查询数据库或复杂计算 Thread.sleep(1); return stock.getOrDefault(itemId, 0); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } finally { rwLock.readLock().unlock(); // 释放读锁 } } // 更新库存低频操作 public void updateStock(String itemId, int quantity) { rwLock.writeLock().lock(); // 获取写锁 try { // 模拟一个耗时的写入过程 Thread.sleep(10); if (quantity 0) { stock.remove(itemId); } else { stock.put(itemId, quantity); } System.out.println(Thread.currentThread().getName() 更新库存: itemId - quantity); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { rwLock.writeLock().unlock(); // 释放写锁 } } // 一个需要锁降级的场景获取当前所有库存的快照 public MapString, Integer getStockSnapshot() { rwLock.writeLock().lock(); // 先获取写锁防止其他线程修改 try { // 这里可以进行一些只有持有写锁时才能做的准备工作... // 然后在持有写锁的情况下获取读锁锁降级的关键步骤 rwLock.readLock().lock(); try { // 返回一个副本避免外部修改影响内部数据 return new HashMap(stock); } finally { rwLock.readLock().unlock(); // 释放读锁 } } finally { rwLock.writeLock().unlock(); // 释放写锁此时降级为读锁状态实际上读锁已释放但数据一致性已保证 } } }性能对比测试我们可以设计一个简单的压测对比使用ReentrantLock互斥和ReentrantReadWriteLock在大量读操作下的吞吐量差异。你会发现在读线程远多于写线程的场景下读写锁的性能优势是数量级的。4. 深入原理与性能调优要点了解了怎么用我们还需要知道为什么这么用以及如何用得更好。这部分内容往往是区分普通开发者和资深开发者的关键。4.1 synchronized的锁升级过程偏向锁/轻量级锁/重量级锁这是JVM为了优化synchronized性能引入的精妙机制。理解这个过程有助于你写出对锁更友好的代码。无锁状态对象刚创建还没有任何线程竞争。偏向锁第一个线程访问同步块时JVM会将对象头中的标记设为偏向模式并记录这个线程的ID。以后该线程再进入同步块时无需任何同步操作如CAS直接检查线程ID即可开销极小。适用于只有一个线程访问同步块的场景。轻量级锁当有第二个线程尝试获取锁时发生轻度竞争偏向锁会升级为轻量级锁。线程会在自己的栈帧中创建一个锁记录Lock Record并通过CAS操作尝试将对象头指向这个锁记录。如果成功则获取锁如果失败说明有竞争则自旋等待一小段时间。重量级锁如果自旋等待失败竞争加剧或者自旋次数超过阈值轻量级锁会升级为重量级锁。此时未获取到锁的线程会被阻塞进入操作系统内核的等待队列涉及用户态到内核态的切换开销最大。调优启示如果你的应用明确是单线程的或者某些锁确实只有一个线程访问可以通过JVM参数-XX:UseBiasedLocking开启偏向锁现代JDK默认开启。但对于高并发、锁竞争激烈的场景偏向锁的撤销开销可能反而成为负担可以考虑使用-XX:-UseBiasedLocking关闭。轻量级锁的自旋等待消耗CPU。如果同步块内的代码执行时间非常短“细粒度锁”自旋是高效的。但如果同步块内执行的是IO操作、复杂计算等耗时任务自旋就会浪费大量CPU。此时可以考虑减小锁的粒度或者直接使用ReentrantLock因为它提供了更灵活的控制如tryLock。4.2 AQSReentrantLock与ReentrantReadWriteLock的基石AbstractQueuedSynchronizer(AQS) 是java.util.concurrent包的核心框架ReentrantLock、ReentrantReadWriteLock、CountDownLatch、Semaphore等都是基于它构建的。你可以把它理解为一个构建锁和同步器的“脚手架”。AQS内部维护了一个volatile int state同步状态和一个FIFO线程等待队列CLH队列的变体。对于ReentrantLockstate为0表示锁空闲为1或n表示被持有并且记录了重入次数。对于ReentrantReadWriteLock高16位表示读锁的持有数量低16位表示写锁的重入次数。线程获取锁失败时会被构造成一个节点Node加入等待队列并可能被挂起LockSupport.park。当持有锁的线程释放锁时会唤醒队列中的后继节点。理解AQS的价值在于它解释了为什么这些锁的性能和功能如此强大。它们将复杂的同步队列管理、线程阻塞/唤醒封装在了底层。当你需要实现一个自定义的同步器时比如一个特殊的闸门可以直接继承AQS只需要重写tryAcquire、tryRelease等少数几个方法极大地降低了开发难度。4.3 锁的粒度与性能权衡锁的粒度是影响并发性能的关键因素。粗粒度锁锁住一个大对象或一大段代码。优点是简单不易死锁缺点是并发度低容易成为性能瓶颈。细粒度锁将共享数据拆分成多个独立的部分用不同的锁保护。优点是并发度高缺点是设计复杂容易引发死锁且加锁/解锁开销增大。一个经典的例子是ConcurrentHashMap的分段锁JDK 7和synchronized CASJDK 8设计。它没有用一个全局锁锁住整个Map而是将数据分段每段用一个独立的锁。这样不同线程操作不同段的数据时可以完全并行。在你的业务代码中可以考虑如果一个类有多个独立的成员变量且它们被不同的线程组访问可以考虑使用多个锁如ReentrantLock分别保护。使用不可变对象或线程局部变量ThreadLocal来彻底避免同步。对于读多写少的集合优先考虑CopyOnWriteArrayList或ConcurrentHashMap而不是自己用锁封装ArrayList或HashMap。5. 实战避坑与高级技巧实录理论再完美也要经过实战的检验。下面是我在项目中积累的一些真实经验和常见问题。5.1 死锁成因、诊断与预防死锁是并发编程的噩梦。它发生在两个或多个线程互相持有对方所需的资源并无限期地等待对方释放。一个简单的死锁例子Object lockA new Object(); Object lockB new Object(); Thread t1 new Thread(() - { synchronized (lockA) { System.out.println(t1 got lockA); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockB) { // 等待t2释放lockB System.out.println(t1 got lockB); } } }); Thread t2 new Thread(() - { synchronized (lockB) { System.out.println(t2 got lockB); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockA) { // 等待t1释放lockA System.out.println(t2 got lockA); } } }); t1.start(); t2.start();如何诊断jstack命令在应用运行时使用jstack pid导出线程堆栈。死锁的线程会显示BLOCKED状态并且JVM通常会在最后给出一个明确的 “Found one Java-level deadlock” 报告指出哪些线程在等待哪些锁。可视化工具JConsole, VisualVM, JProfiler 等工具都有检测死锁的功能。如何预防固定顺序获取锁这是最有效的方法。为所有需要获取的锁定义一个全局的获取顺序例如按对象的哈希值排序所有线程都按这个顺序申请锁。上述死锁例子中如果t1和t2都先申请lockA再申请lockB死锁就不会发生。使用带超时的锁ReentrantLock.tryLock(long timeout, TimeUnit unit)。获取锁失败后可以释放已持有的锁回退并重试或者记录日志告警。减少锁的持有时间只在必要的最小代码段上加锁。尽快做完共享资源的操作然后释放锁。使用更高级的并发工具有时可以用ConcurrentHashMap、CountDownLatch、CyclicBarrier等工具来替代复杂的锁逻辑。5.2 性能瓶颈定位与锁竞争优化不是用了锁就万事大吉用不好反而会成为瓶颈。如何发现锁竞争线程堆栈分析使用jstack或 profiling 工具查看有多少线程处于BLOCKED状态以及它们在等待哪个锁。如果大量线程阻塞在同一个锁上这就是热点锁。JMX监控ReentrantLock可以通过ReentrantLock.getQueueLength()查看等待队列长度。等待队列长意味着竞争激烈。Profiling工具像Async-Profiler这样的工具可以生成火焰图直观地显示CPU时间或锁等待时间花在了哪里。优化策略缩小锁范围锁细化检查同步块或同步方法看是否包含了不必要的操作如IO、远程调用、复杂计算。将这些操作移到锁外。降低锁粒度如前所述将一个大锁拆分成多个小锁。用读写锁替代互斥锁严格评估场景是否真的是“读多写少”。使用无锁数据结构对于计数器优先考虑AtomicInteger或LongAdder在高度竞争下性能更好。LongAdder采用分段累加的思想最后再汇总在高并发写场景下比AtomicLong性能高得多。考虑乐观锁如果冲突概率很低可以使用CASCompare-And-Swap乐观锁机制例如数据库的版本号控制或者AtomicReference的compareAndSet方法。5.3 内存可见性与volatile关键字这是一个比锁更隐蔽的问题。由于现代CPU的多级缓存架构和编译器的指令重排序一个线程对共享变量的修改可能不会立即被其他线程看到。// 一个典型的问题代码 public class VisibilityProblem { private boolean flag false; // 共享变量 // private volatile boolean flag false; // 正确的声明 public void writer() { flag true; // 步骤1 } public void reader() { while (!flag) { // 步骤2可能永远看不到flag变为true // 空循环 } System.out.println(Flag is now true); } }在这个例子中即使writer()线程将flag设为truereader()线程也可能因为从自己的CPU缓存中读取旧的false值而陷入死循环。synchronized和Lock能保证可见性因为它们在释放锁前会将工作内存中的变量刷新到主内存在获取锁后会从主内存重新读取变量。volatile关键字提供了比锁更轻量级的可见性保证。它确保对volatile变量的写操作会立即刷新到主内存。对volatile变量的读操作会从主内存中读取最新值。它能禁止指令重排序通过内存屏障。使用场景修饰状态标志位如上面的flag。修饰一次性安全发布的引用如单例模式的双重检查锁中的实例变量。volatile不能保证复合操作的原子性如i。对于这种情况仍需使用synchronized或Atomic类。选择哪种锁从来都不是一个孤立的决定。它与你项目的并发规模、性能要求、团队熟悉度、甚至JDK版本都息息相关。对于我而言我的选择路径通常是这样的首先尝试无锁设计如不可变对象、ThreadLocal如果不行使用最简单的synchronized只有当synchronized在功能或性能上无法满足时比如需要可中断、超时、公平性、多个条件队列我才会引入ReentrantLock。而对于缓存、配置中心这类典型的读多写少场景ReentrantReadWriteLock则是我工具箱里的利器。最后记住多线程调试是困难的。养成在关键同步点添加详细日志使用Thread.currentThread().getName()的习惯善用JVM提供的监控和诊断工具这些都能在你遇到诡异的并发bug时为你照亮前路。并发编程的学习曲线是陡峭的但每理解一个概念每解决一个问题你对程序世界的掌控力就会更深一分。
返回列表