ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/8/15 11:12:52 拓冰建站 浏览量
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 提供了两种主要的同步机制来解决这个问题:synchronizedLock。今天,我们就来全面学习它们。


1. 线程安全的根源 —— 共享可变数据

我们先回顾一下问题的本质。

publicclassCounter{privateintcount=0;publicvoidincrement(){count++;}publicintgetCount(){returncount;}}

在单线程环境中,这段代码完美无缺。但在多线程环境中,count是一个共享的可变数据——多个线程可以同时读取和修改它。当多个线程对同一份数据执行“读-改-写”操作时,如果这些操作不是原子的,就会产生数据竞争(race condition)。

解决思路只有一个:让多个线程对共享数据的访问“串行化”——即同一时刻,只有一个线程能执行这段代码,其他线程必须等待。这个机制就是同步(Synchronization)


2.synchronized关键字 —— Java 原生的同步机制

synchronized是 Java 语言提供的最基本、最直接的同步机制。它可以修饰方法,也可以用作代码块。

2.1synchronized方法

最简单的方式是在方法声明上加synchronized

publicclassCounter{privateintcount=0;publicsynchronizedvoidincrement(){count++;}publicsynchronizedintgetCount(){returncount;}}

现在,increment()getCount()都变成了同步方法。同一时刻,只有一个线程能进入同一个对象的任意一个synchronized方法。当线程 A 正在执行increment()时,线程 B 想执行getCount()也会被阻塞,直到线程 A 执行完毕。

我们重新运行上一篇文章的测试代码,这次count最终一定是 1000。

这里的关键synchronized方法锁的是当前对象(this。也就是说,不同对象的synchronized方法互不影响。

2.2synchronized代码块

有时候,你不需要把整个方法都同步,只需要同步其中的一部分代码。这时候可以用synchronized代码块,更精细地控制锁的范围。

publicclassCounter{privateintcount=0;privatefinalObjectlock=newObject();// 专门用作锁的对象publicvoidincrement(){// 这里可以放一些不需要同步的代码System.out.println("准备增加...");synchronized(lock){count++;// 只有这一小段是同步的}// 这里也可以放不需要同步的代码System.out.println("增加完成!");}publicintgetCount(){synchronized(lock){returncount;}}}

synchronized代码块需要指定一个锁对象。任何对象都可以作为锁(Objectthis、甚至某个类的Class对象)。

锁对象的选择

锁对象作用范围
synchronized(this)锁定当前实例,和synchronized实例方法效果一样
synchronized(SomeClass.class)锁定整个类(所有实例共享),和synchronized static方法效果一样
synchronized(lockObject)自定义锁对象,更灵活,可以针对不同业务使用不同锁
2.3synchronized静态方法

静态方法加synchronized时,锁的是类的Class对象,而不是实例。

publicclassCounter{privatestaticintcount=0;publicstaticsynchronizedvoidincrement(){count++;}}

此时,所有线程共享同一把锁(Counter.class),无论它们使用的是哪个Counter实例。


3. 显式锁 ——Lock接口和ReentrantLock

synchronized简单易用,但有些场景下它不够灵活:

  • 你无法尝试获取锁(如果不能立即获取,你就想放弃或重试)。
  • 你无法在获取锁时设置超时时间。
  • 你无法中断一个正在等待锁的线程。
  • 你无法实现“读锁”和“写锁”分离(ReentrantReadWriteLock可以)。

Java 5 引入了java.util.concurrent.locks包,提供了更强大的锁机制。其中最常用的是ReentrantLock(可重入锁)。

3.1 基本用法
importjava.util.concurrent.locks.Lock;importjava.util.concurrent.locks.ReentrantLock;publicclassCounter{privateintcount=0;privatefinalLocklock=newReentrantLock();publicvoidincrement(){lock.lock();// 获取锁(如果锁被占用,线程阻塞直到获得)try{count++;}finally{lock.unlock();// 必须在 finally 中释放锁!}}publicintgetCount(){lock.lock();try{returncount;}finally{lock.unlock();}}}

ReentrantLock的使用模式是固定的:lock()try→ 业务代码 →finallyunlock()千万别忘记在 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是非公平锁(多个线程争抢锁时,谁抢到谁用,不保证先来先得)。如果想让锁按申请顺序分配(先来先得),可以在构造时传入true

LockfairLock=newReentrantLock(true);

公平锁能避免“线程饥饿”,但性能比非公平锁略低,因为需要维护等待队列。


4.synchronizedvsReentrantLock—— 怎么选?

对比维度synchronizedReentrantLock
语法简洁,自动释放锁需要手动lock()/unlock(),容易出错
灵活性低(不能尝试获取、不能超时)高(tryLock、超时、可中断)
公平性不支持支持公平锁
性能现代 JVM 对synchronized优化很好,两者差距不大略高(但在大多数场景下差距微乎其微)
可读性更简洁,更推荐稍显繁琐
适用场景简单的同步需求需要高级锁特性的复杂场景

我的建议

  • 优先使用synchronized,因为更简洁、更不容易出错。
  • 如果遇到了synchronized无法满足的需求(比如需要尝试获取锁、需要超时、需要公平锁),再换ReentrantLock

5. 死锁 —— 同步的“隐形杀手”

当你使用锁的时候,有一个非常危险的问题需要时刻警惕:死锁(Deadlock)

死锁发生在两个或多个线程互相持有对方需要的锁,导致所有线程都无法继续执行。

经典的死锁示例:

publicclassDeadlockDemo{privatestaticfinalObjectlockA=newObject();privatestaticfinalObjectlockB=newObject();publicstaticvoidmain(String[]args){Threadt1=newThread(()->{synchronized(lockA){System.out.println("线程1 持有锁A");try{Thread.sleep(100);}catch(InterruptedExceptione){}synchronized(lockB){System.out.println("线程1 持有锁A 和 锁B");}}});Threadt2=newThread(()->{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();}}

运行后,程序很可能卡住,永远不结束——这就是死锁。

避免死锁的基本原则

  1. 按相同的顺序获取锁:如果所有线程都按同样的顺序获取多个锁(比如先 A 后 B),就不会出现循环等待。
  2. 使用tryLock设置超时:如果在指定时间内获取不到所有锁,就释放已持有的锁,稍后重试。
  3. 尽量减少锁的持有时间:只在不必要时才持有锁,持有锁时尽快释放。
  4. 使用更高级的并发工具(如ConcurrentHashMapBlockingQueue),它们内部已经实现了良好的并发控制。

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;privatefinalLocklock=newReentrantLock();publicBankAccount(StringaccountId,doubleinitialBalance){this.accountId=accountId;this.balance=initialBalance;}publicStringgetAccountId(){returnaccountId;}publicdoublegetBalance(){returnbalance;}/** * 转账:从当前账户转出 amount 到目标账户 * 使用 ReentrantLock 并避免死锁(按账户 ID 顺序获取锁) */publicvoidtransfer(BankAccounttarget,doubleamount)throwsInterruptedException{if(this==target){thrownewIllegalArgumentException("不能给自己转账");}// 按账户 ID 的字典序获取锁,避免死锁BankAccountfirstLock=this.accountId.compareTo(target.accountId)<0?this:target;BankAccountsecondLock=this.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.balance<amount){System.out.println("余额不足:"+this.accountId+" 余额 "+this.balance+",需要 "+amount);return;}// 执行转账this.balance-=amount;target.balance+=amount;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{BankAccountaccountA=newBankAccount("A001",1000);BankAccountaccountB=newBankAccount("B001",500);BankAccountaccountC=newBankAccount("C001",200);// 模拟多线程并发转账Threadt1=newThread(()->{try{accountA.transfer(accountB,300);}catch(InterruptedExceptione){e.printStackTrace();}});Threadt2=newThread(()->{try{accountB.transfer(accountC,200);}catch(InterruptedExceptione){e.printStackTrace();}});Threadt3=newThread(()->{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 中实现线程安全的两种主要同步机制:

  • synchronized:Java 原生同步机制,简单易用,适合大多数场景。可以修饰方法或代码块,锁对象可以是this、自定义对象或Class对象。
  • ReentrantLock:显式锁,提供了更强大的功能:tryLock(尝试获取)、超时、可中断锁、公平锁。使用更灵活,但需要手动管理锁的获取和释放,更容易出错。
  • 死锁:多个线程互相持有对方需要的锁,导致程序卡死。可以通过按固定顺序获取锁、设置超时、减少锁持有时间等方式避免。
  • 锁粒度:尽量缩小同步范围,提高并发性能。

理解线程安全和同步,是编写正确并发程序的基础。在实际开发中,大多数情况下synchronized已经足够用了。只有当你需要更高级的锁特性时,才考虑ReentrantLock


动手试试

  1. Counter例子分别用synchronized方法和ReentrantLock实现,对比两种写法的代码量和可读性。

  2. 修改synchronizedCounter,把getCount()也加上同步,观察是否会影响性能(在大量线程同时读取时)。

  3. 运行上面的死锁示例,观察程序卡住的现象。然后按“按相同顺序获取锁”的原则修改代码,解决死锁问题。

  4. 写一个模拟“生产者-消费者”的小程序:一个生产者线程往队列里放数据,一个消费者线程从队列里取数据。用synchronized+wait()/notify()实现线程间的协作(这是下一篇文章的内容,你可以先尝试预习)。

  5. 思考:synchronized方法和synchronized(this)代码块有什么区别?synchronized静态方法和synchronized(ClassName.class)有什么区别?

我们下一篇会深入讲线程间的协作——wait()notify()Condition,以及生产者-消费者模式的实现。

我们下一篇见。😃

📌 获取本系列示例代码请访问 GitCode。