44. 【Java】线程安全与同步:synchronized与Lock

发布时间:2026/8/15 11:04:02
44. 【Java】线程安全与同步:synchronized与Lock 摘要本文深入解析Java多线程编程中的线程安全问题根源——共享可变数据的非原子操作并系统讲解两种核心同步机制synchronized关键字和Lock接口。通过对比分析synchronized方法、代码块、静态方法以及ReentrantLock的tryLock、可中断锁、公平锁等特性帮助开发者理解如何选择合适锁机制。文章还涵盖死锁避免、锁粒度优化等实战技巧并提供一个完整的银行账户转账综合示例为编写安全高效的多线程程序提供全面指导。关键词Java多线程, synchronized, ReentrantLock, 线程安全, 死锁上一篇文章的结尾我留了一个“危险”的例子多个线程同时对一个计数器执行count结果却不是预期的 1000。你可能已经自己运行过那个例子了也看到了奇怪的结果——有时候是 998有时候是 999有时候是 1000。这到底是为什么今天我们就要来彻底搞清楚这个问题并且学习如何解决它。简单来说问题的根源在于多个线程同时访问和修改同一个共享数据而操作不是“原子”的。count看起来只是一行代码但在底层它其实是三步操作读取 count 的值 → 加 1 → 把新值写回去。如果两个线程同时执行这三步就可能出现“丢失更新”的情况——两个线程都读取了同一个旧值分别加 1 后写回结果只增加了 1而不是 2。这就是典型的线程安全问题。Java 提供了两种主要的同步机制来解决这个问题synchronized和Lock。今天我们就来全面学习它们。1. 线程安全的根源 —— 共享可变数据我们先回顾一下问题的本质。publicclassCounter{privateintcount0;publicvoidincrement(){count;}publicintgetCount(){returncount;}}在单线程环境中这段代码完美无缺。但在多线程环境中count是一个共享的可变数据——多个线程可以同时读取和修改它。当多个线程对同一份数据执行“读-改-写”操作时如果这些操作不是原子的就会产生数据竞争race condition。解决思路只有一个让多个线程对共享数据的访问“串行化”——即同一时刻只有一个线程能执行这段代码其他线程必须等待。这个机制就是同步Synchronization。2.synchronized关键字 —— Java 原生的同步机制synchronized是 Java 语言提供的最基本、最直接的同步机制。它可以修饰方法也可以用作代码块。2.1synchronized方法最简单的方式是在方法声明上加synchronizedpublicclassCounter{privateintcount0;publicsynchronizedvoidincrement(){count;}publicsynchronizedintgetCount(){returncount;}}现在increment()和getCount()都变成了同步方法。同一时刻只有一个线程能进入同一个对象的任意一个synchronized方法。当线程 A 正在执行increment()时线程 B 想执行getCount()也会被阻塞直到线程 A 执行完毕。我们重新运行上一篇文章的测试代码这次count最终一定是 1000。这里的关键synchronized方法锁的是当前对象this。也就是说不同对象的synchronized方法互不影响。2.2synchronized代码块有时候你不需要把整个方法都同步只需要同步其中的一部分代码。这时候可以用synchronized代码块更精细地控制锁的范围。publicclassCounter{privateintcount0;privatefinalObjectlocknewObject();// 专门用作锁的对象publicvoidincrement(){// 这里可以放一些不需要同步的代码System.out.println(准备增加...);synchronized(lock){count;// 只有这一小段是同步的}// 这里也可以放不需要同步的代码System.out.println(增加完成);}publicintgetCount(){synchronized(lock){returncount;}}}synchronized代码块需要指定一个锁对象。任何对象都可以作为锁Object、this、甚至某个类的Class对象。锁对象的选择锁对象作用范围synchronized(this)锁定当前实例和synchronized实例方法效果一样synchronized(SomeClass.class)锁定整个类所有实例共享和synchronized static方法效果一样synchronized(lockObject)自定义锁对象更灵活可以针对不同业务使用不同锁2.3synchronized静态方法静态方法加synchronized时锁的是类的Class对象而不是实例。publicclassCounter{privatestaticintcount0;publicstaticsynchronizedvoidincrement(){count;}}此时所有线程共享同一把锁Counter.class无论它们使用的是哪个Counter实例。3. 显式锁 ——Lock接口和ReentrantLocksynchronized简单易用但有些场景下它不够灵活你无法尝试获取锁如果不能立即获取你就想放弃或重试。你无法在获取锁时设置超时时间。你无法中断一个正在等待锁的线程。你无法实现“读锁”和“写锁”分离ReentrantReadWriteLock可以。Java 5 引入了java.util.concurrent.locks包提供了更强大的锁机制。其中最常用的是ReentrantLock可重入锁。3.1 基本用法importjava.util.concurrent.locks.Lock;importjava.util.concurrent.locks.ReentrantLock;publicclassCounter{privateintcount0;privatefinalLocklocknewReentrantLock();publicvoidincrement(){lock.lock();// 获取锁如果锁被占用线程阻塞直到获得try{count;}finally{lock.unlock();// 必须在 finally 中释放锁}}publicintgetCount(){lock.lock();try{returncount;}finally{lock.unlock();}}}ReentrantLock的使用模式是固定的lock()→try→ 业务代码 →finally→unlock()。千万别忘记在 finally 中释放锁否则一旦抛出异常锁永远不会被释放其他线程就会永远等待下去。3.2tryLock()—— 尝试获取锁synchronized一旦尝试获取锁如果锁被占用线程会无限期阻塞。而tryLock()可以尝试获取锁如果获取不到可以立即返回false让线程做其他事情而不是死等。if(lock.tryLock()){try{// 处理共享资源}finally{lock.unlock();}}else{// 没有获取到锁做其他事情System.out.println(锁被占用稍后重试);}tryLock()也有带超时参数的版本tryLock(long time, TimeUnit unit)在指定时间内等待锁超时则返回false。3.3 可中断的锁获取lockInterruptibly()允许在等待锁的过程中响应中断信号。如果在等待锁时线程被中断会抛出InterruptedException。try{lock.lockInterruptibly();try{// 处理共享资源}finally{lock.unlock();}}catch(InterruptedExceptione){System.out.println(等待锁时被中断);Thread.currentThread().interrupt();}3.4 公平锁默认的ReentrantLock是非公平锁多个线程争抢锁时谁抢到谁用不保证先来先得。如果想让锁按申请顺序分配先来先得可以在构造时传入trueLockfairLocknewReentrantLock(true);公平锁能避免“线程饥饿”但性能比非公平锁略低因为需要维护等待队列。4.synchronizedvsReentrantLock—— 怎么选对比维度synchronizedReentrantLock语法简洁自动释放锁需要手动lock()/unlock()容易出错灵活性低不能尝试获取、不能超时高tryLock、超时、可中断公平性不支持支持公平锁性能现代 JVM 对synchronized优化很好两者差距不大略高但在大多数场景下差距微乎其微可读性更简洁更推荐稍显繁琐适用场景简单的同步需求需要高级锁特性的复杂场景我的建议优先使用synchronized因为更简洁、更不容易出错。如果遇到了synchronized无法满足的需求比如需要尝试获取锁、需要超时、需要公平锁再换ReentrantLock。5. 死锁 —— 同步的“隐形杀手”当你使用锁的时候有一个非常危险的问题需要时刻警惕死锁Deadlock。死锁发生在两个或多个线程互相持有对方需要的锁导致所有线程都无法继续执行。经典的死锁示例publicclassDeadlockDemo{privatestaticfinalObjectlockAnewObject();privatestaticfinalObjectlockBnewObject();publicstaticvoidmain(String[]args){Threadt1newThread(()-{synchronized(lockA){System.out.println(线程1 持有锁A);try{Thread.sleep(100);}catch(InterruptedExceptione){}synchronized(lockB){System.out.println(线程1 持有锁A 和 锁B);}}});Threadt2newThread(()-{synchronized(lockB){System.out.println(线程2 持有锁B);try{Thread.sleep(100);}catch(InterruptedExceptione){}synchronized(lockA){System.out.println(线程2 持有锁B 和 锁A);}}});t1.start();t2.start();}}运行后程序很可能卡住永远不结束——这就是死锁。避免死锁的基本原则按相同的顺序获取锁如果所有线程都按同样的顺序获取多个锁比如先 A 后 B就不会出现循环等待。使用tryLock设置超时如果在指定时间内获取不到所有锁就释放已持有的锁稍后重试。尽量减少锁的持有时间只在不必要时才持有锁持有锁时尽快释放。使用更高级的并发工具如ConcurrentHashMap、BlockingQueue它们内部已经实现了良好的并发控制。6. 锁的粒度 —— 粗粒度 vs 细粒度锁的粒度指锁保护的范围。粒度越粗锁的范围大并发性越低粒度越细锁的范围小并发性越高。// 粗粒度整个方法都锁住publicsynchronizedvoiddoSomething(){// 耗时操作包括不共享资源的代码}// 细粒度只锁必要的部分publicvoiddoSomething(){// 不共享资源的代码...synchronized(this){// 只有这一小段访问共享资源}// 更多的非共享代码...}一般来说应该尽量缩小锁的粒度只在真正需要保护共享数据的地方加锁让其他不相关的代码可以并发执行。但也不要过度细粒度比如把每个简单的操作都加锁会增加锁获取和释放的开销。要在并发性能和代码复杂性之间取得平衡。7.synchronized的底层原理简要了解synchronized在 JVM 层面是基于监视器Monitor实现的。每个对象都有一个关联的监视器。当线程进入synchronized方法或代码块时它会尝试获取该对象的监视器如果获取成功线程就“持有”了该对象的锁退出时释放锁。从 Java 6 开始JVM 对synchronized做了大量优化包括偏向锁、轻量级锁、自旋锁等使得synchronized的性能在很多场景下接近ReentrantLock。所以你不需要过于担心synchronized的性能问题。8. 综合示例 —— 银行账户转账我们来写一个实际的例子银行账户转账。多个线程可能同时从不同的账户向另一个账户转账我们需要确保转账操作的原子性扣款和加款要么都成功要么都失败并且要避免死锁。importjava.util.concurrent.locks.Lock;importjava.util.concurrent.locks.ReentrantLock;publicclassBankAccount{privatefinalStringaccountId;privatedoublebalance;privatefinalLocklocknewReentrantLock();publicBankAccount(StringaccountId,doubleinitialBalance){this.accountIdaccountId;this.balanceinitialBalance;}publicStringgetAccountId(){returnaccountId;}publicdoublegetBalance(){returnbalance;}/** * 转账从当前账户转出 amount 到目标账户 * 使用 ReentrantLock 并避免死锁按账户 ID 顺序获取锁 */publicvoidtransfer(BankAccounttarget,doubleamount)throwsInterruptedException{if(thistarget){thrownewIllegalArgumentException(不能给自己转账);}// 按账户 ID 的字典序获取锁避免死锁BankAccountfirstLockthis.accountId.compareTo(target.accountId)0?this:target;BankAccountsecondLockthis.accountId.compareTo(target.accountId)0?target:this;// 获取第一个锁firstLock.lock.lock();try{// 获取第二个锁带超时防止意外死锁if(!secondLock.lock.tryLock(1,java.util.concurrent.TimeUnit.SECONDS)){System.out.println(获取锁超时转账取消);return;}try{// 检查余额if(this.balanceamount){System.out.println(余额不足this.accountId 余额 this.balance需要 amount);return;}// 执行转账this.balance-amount;target.balanceamount;System.out.printf(转账成功%s → %s金额 %.2f%s 余额%.2f%s 余额%.2f%n,this.accountId,target.accountId,amount,this.accountId,this.balance,target.accountId,target.balance);}finally{secondLock.lock.unlock();}}finally{firstLock.lock.unlock();}}publicstaticvoidmain(String[]args)throwsInterruptedException{BankAccountaccountAnewBankAccount(A001,1000);BankAccountaccountBnewBankAccount(B001,500);BankAccountaccountCnewBankAccount(C001,200);// 模拟多线程并发转账Threadt1newThread(()-{try{accountA.transfer(accountB,300);}catch(InterruptedExceptione){e.printStackTrace();}});Threadt2newThread(()-{try{accountB.transfer(accountC,200);}catch(InterruptedExceptione){e.printStackTrace();}});Threadt3newThread(()-{try{accountC.transfer(accountA,150);}catch(InterruptedExceptione){e.printStackTrace();}});t1.start();t2.start();t3.start();t1.join();t2.join();t3.join();System.out.println(\n最终余额);System.out.println(A001: accountA.getBalance());System.out.println(B001: accountB.getBalance());System.out.println(C001: accountC.getBalance());}}这个例子展示了用ReentrantLock保护共享数据balance。按固定顺序获取多个锁按账户 ID 排序避免死锁。使用tryLock设置超时防止无限等待。finally中释放锁确保无论如何都会释放。9. 今天的总结今天我们学习了 Java 中实现线程安全的两种主要同步机制synchronizedJava 原生同步机制简单易用适合大多数场景。可以修饰方法或代码块锁对象可以是this、自定义对象或Class对象。ReentrantLock显式锁提供了更强大的功能tryLock尝试获取、超时、可中断锁、公平锁。使用更灵活但需要手动管理锁的获取和释放更容易出错。死锁多个线程互相持有对方需要的锁导致程序卡死。可以通过按固定顺序获取锁、设置超时、减少锁持有时间等方式避免。锁粒度尽量缩小同步范围提高并发性能。理解线程安全和同步是编写正确并发程序的基础。在实际开发中大多数情况下synchronized已经足够用了。只有当你需要更高级的锁特性时才考虑ReentrantLock。动手试试把Counter例子分别用synchronized方法和ReentrantLock实现对比两种写法的代码量和可读性。修改synchronized的Counter把getCount()也加上同步观察是否会影响性能在大量线程同时读取时。运行上面的死锁示例观察程序卡住的现象。然后按“按相同顺序获取锁”的原则修改代码解决死锁问题。写一个模拟“生产者-消费者”的小程序一个生产者线程往队列里放数据一个消费者线程从队列里取数据。用synchronizedwait()/notify()实现线程间的协作这是下一篇文章的内容你可以先尝试预习。思考synchronized方法和synchronized(this)代码块有什么区别synchronized静态方法和synchronized(ClassName.class)有什么区别我们下一篇会深入讲线程间的协作——wait()、notify()、Condition以及生产者-消费者模式的实现。我们下一篇见。 获取本系列示例代码请访问 GitCode。

相关新闻