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{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代码块需要指定一个锁对象。任何对象都可以作为锁(Object、this、甚至某个类的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→ 业务代码 →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是非公平锁(多个线程争抢锁时,谁抢到谁用,不保证先来先得)。如果想让锁按申请顺序分配(先来先得),可以在构造时传入true:
LockfairLock=newReentrantLock(true);公平锁能避免“线程饥饿”,但性能比非公平锁略低,因为需要维护等待队列。
4.synchronizedvsReentrantLock—— 怎么选?
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 语法 | 简洁,自动释放锁 | 需要手动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();}}运行后,程序很可能卡住,永远不结束——这就是死锁。
避免死锁的基本原则:
- 按相同的顺序获取锁:如果所有线程都按同样的顺序获取多个锁(比如先 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;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。
动手试试
把
Counter例子分别用synchronized方法和ReentrantLock实现,对比两种写法的代码量和可读性。修改
synchronized的Counter,把getCount()也加上同步,观察是否会影响性能(在大量线程同时读取时)。运行上面的死锁示例,观察程序卡住的现象。然后按“按相同顺序获取锁”的原则修改代码,解决死锁问题。
写一个模拟“生产者-消费者”的小程序:一个生产者线程往队列里放数据,一个消费者线程从队列里取数据。用
synchronized+wait()/notify()实现线程间的协作(这是下一篇文章的内容,你可以先尝试预习)。思考:
synchronized方法和synchronized(this)代码块有什么区别?synchronized静态方法和synchronized(ClassName.class)有什么区别?
我们下一篇会深入讲线程间的协作——wait()、notify()、Condition,以及生产者-消费者模式的实现。
我们下一篇见。😃
📌 获取本系列示例代码请访问 GitCode。