☰
Java多线程synchronized详解:从锁升级到死锁排查与性能调优
2026/10/15 14:04:20 网站建设 项目流程

学习Java多线程,synchronized关键字几乎是绕不过去的第一道坎。我当年就是在接手一个模拟抢票项目时,看着两个线程把同一个共享变量加得乱七八糟,才真正从"知道这个关键字"变成"理解这个关键字"。这一篇笔记,我会从最原始的并发问题说起,把synchronized的三种用法、锁对象的选择、底层的监视器锁与锁升级机制,再到死锁排查和性能调优,一次聊透。不管你是刚接触线程的新手,还是已经用过Lock但想回头夯实基础的老手,这篇文章都应该能给你一些启发。

1. 一个计数器的崩溃现场:synchronized到底在解决什么问题

先说一个我再熟悉不过的场景。某同学写了一个极其简单的计数器:

public class Counter { private int count = 0; public void increment() { count++; } public int getCount() { return count; } }

然后他开两个线程,每个线程循环执行一万次increment(),按常理来说最后count应该是20000。但实际跑下来,结果经常是19987、19962这种数字,甚至更低。第一次看到这个现象的人都会很困惑,代码明明只有一行,怎么就丢了更新呢?

1.1 问题一:count++不是原子操作

count++在Java里看起来是一个操作,但到了字节码层面会被拆成至少三条指令:从主内存读取字段值、对值进行加1、把新值写回字段。我把demo反编译过,对应的字节码大概是这样:

getfield // 读取 count 字段 iconst_1 iadd // 加 1 putfield // 写回 count 字段

问题就出在这个"读取"和"写回"之间。线程A读到了count=99,正准备写回100时,线程B也读到了count=99,然后两个线程都写回了100。于是两次自增变成了一次,count就比预期少了1。这就是竞态条件,多线程环境下最常见也最隐蔽的bug。

想通这一点后,你就能理解synchronized的核心作用了:它给这段代码加了一扇门,同一时刻只允许一个线程进入,其他线程必须在门外等待,直到持锁线程执行完并退出才放行。这保证了临界区里的操作不会被打断,本质上是把"读-改-写"这个组合操作变成了一个不可分割的原子操作。

1.2 问题二:工作内存与主内存之间的可见性难题

除了一count++这种复合操作本身不安全之外,另一个坑是可见性。Java内存模型规定,每个线程有自己的工作内存,变量读进来之后先放到工作内存里操作,操作完成后再把结果写回主内存。问题在于:这个"写回"的时机是不确定的。

举个具体例子,假设线程A把count从100改成了101,但新值还在它的工作内存里,还没来得及刷到主内存;此时线程B从主内存里读到的仍然是100。在高并发场景下,一个线程对共享变量的修改,对另一个线程来说可能是"不可见"的。如果代码里没有同步机制,这种延迟可见性就会导致程序逻辑错误。

synchronized在进入临界区时会让线程从主内存重新读取最新的值,退出临界区时会把工作内存中修改过的值强制刷新到主内存,相当于在锁的两侧各加了一道数据同步的屏障。

1.3 可见性、原子性和有序性,一把锁全包了

很多人容易把并发编程的几个核心概念搞混,这里我帮你理一下。原子性解决的是"操作会不会被打断"的问题,可见性解决的是"修改对其他线程能不能看到"的问题,有序性解决的是"指令重排会不会破坏逻辑"的问题。synchronized这三者都占了:原子性靠临界区互斥保证,可见性靠锁释放时的内存屏障保证,有序性靠临界区内的代码不会被重排序到锁边界之外来保证。

这也是synchronized从入门到进阶的第一层认知:它不只是一个"锁",更是一个完整的内存语义约定。

2. synchronized的三种用法与锁对象选择

synchronized的用法虽然看起来就那么几种写法,但每种写法背后的锁对象完全不同。锁选错了,逻辑就全错了。这部分我按使用场景逐一拆开讲,并附上锁对象的底层含义。

2.1 同步实例方法:锁的是当前对象this

最直接的用法是给方法加synchronized修饰:

public synchronized void increment() { count++; }

当一个线程进入这个方法时,它获取的是当前实例对象this的监视器锁。其他线程如果再调用同一个实例上的这个同步方法,就必须等前一个线程释放锁。这里有个关键点:锁是绑定在实例上的。两个不同的Counter对象,各自的increment()方法用的是各自对象自己的锁,互相完全不干扰。

所以我测试过一段代码:创建两个Counter实例,各自开两个线程去并发调用,结果每个实例内部的count都是对的,但两个实例之间没有任何竞争。这个特性在实际开发中很实用:如果你希望同一个对象的多个方法互斥,但对不同对象之间允许并行,同步实例方法就是天然符合需求的。

2.2 同步代码块:锁粒度由你说了算

方法级同步有个问题:如果一个方法里只有两行代码需要保护,而整个方法体有五十行业务逻辑,把方法整个加锁就太亏了。这时候应该用同步代码块收窄临界区:

public void saveUser(User user) { // 这里做一些耗时操作:参数校验、格式转换等 validate(user); convert(user); // 只有这段需要并发保护 synchronized (this) { userCache.put(user.getId(), user); } // 后面还可以做日志、通知等操作 log(user); }

同步代码块的精髓在于:只锁需要锁的部分,减少线程持锁时间,让锁的竞争程度明显降低。我在实际项目里特别喜欢控制临界区范围,因为持锁时间越长,其他线程等待越久,系统吞吐量下降越明显。这里要注意一点,synchronized代码块里的锁对象必须是被多个线程共享的同一个对象,否则就起不到互斥作用。

2.3 静态同步方法与类锁

当synchronized加在static方法上时,情况不一样了:

public static synchronized void resetCounter() { counter = 0; }

它锁的是Class对象,也就是Counter.class这个全局唯一的类元信息对象。类锁和实例锁不是同一把锁。这意味着:一个线程在执行静态同步方法时,其他线程仍然可以访问同一个类的实例同步方法,二者互不干扰。

这一点很容易踩坑。之前某同学写了个工具类,里面有一个静态的共享资源,他用实例方法加锁去保护,结果开了十个线程并发操作,资源依然被同时修改。原因就是实例锁在每个实例上都有一把,十个线程各拿各的锁,等于没锁。保护静态资源必须用类锁,也就是静态同步方法,或者手动synchronized(XXX.class)。

2.4 锁对象选择最容易翻车的三个坑

第一个坑是拿字符串常量当锁对象。有些人图省事,随手写synchronized("lock")。字符串常量在JVM里是共享的,如果代码里其他无关模块也用了同一个字符串常量做锁,那就等于你和别人共用了一把锁,极易发生莫名其妙的阻塞。

第二个坑是拿Integer、Long这些包装类当锁对象。包装类有个自动装箱的机制,两次装箱可能产生不同的对象,也可能指向同一个缓存值。比如synchronized(Integer.valueOf(1))在-128到127之间取的是缓存对象,看起来好像锁的是同一个东西,但一旦数值超过缓存范围,每次装箱都是新对象,锁就失效了。

第三个坑更隐蔽:锁对象被替换。假设你把锁指向一个普通的Object成员变量,代码运行过程中某处把这个变量重新指向了一个新对象。由于synchronized锁的是对象实例,换对象就等于换锁,原来的互斥关系瞬间瓦解。

我给自己定了一个规矩:锁对象必须用private final修饰的成员变量,专门用来做锁,不参与任何业务逻辑。

3. 从字节码到锁升级:synchronized的底层原理

理解了用法之后,下一步是看看它底层到底怎么运转。这一部分我拆成三个层次:字节码指令、对象头里的Mark Word、以及JVM在JDK 1.6之后做的锁优化。

3.1 字节码层的monitorenter与monitorexit

写一段同步代码块,然后用javap -c反编译,你会看到字节码中出现了两个关键指令:

public void increment() { synchronized (this) { count++; } }

反编译结果大约如下:

aload_0 dup monitorenter ... monitorexit

monitorenter在进入临界区时执行,表示线程尝试获取这个对象上的监视器锁;monitorexit在退出临界区时释放锁。有意思的是,JVM为了保证异常情况下锁也能被释放,会在字节码层面生成两个monitorexit出口:一个在正常路径上,另一个在异常处理器里。所以即使临界区代码抛了异常,锁也会被释放,不会造成死锁。

3.2 对象头与Mark Word:锁状态都存在对象的脑袋里

Java对象在堆内存里的布局分为对象头、实例数据和对齐填充三部分。对象头里有一个Mark Word,这个词里面记录了哈希码、GC分代年龄、锁状态标识等信息。关键在于:Mark Word的内容会随着锁状态变化而改变。

锁状态大致可以分成四种:无锁、偏向锁、轻量级锁、重量级锁。Mark Word里用两位(严格来说是低两位)标记当前锁状态。JVM根据竞争激烈程度,让锁对象在这四态之间升级。这个机制是JDK 1.6以后引入的大优化,也是synchronized今天性能不可同日而语的根本原因。

3.3 偏向锁到轻量级锁到重量级锁的升级路径

我用一个生活化的类比来理解锁升级:假设一间办公室有台公共打印机。

最开始没人抢,第一个来的人就把自己的名字贴在门上,表示"这台打印机归我用了"。如果只有他一个人一直用,他每次来都不用登记确认,直接进来就打,开销几乎为零。这就是偏向锁,锁会偏向第一个获得它的线程。

后来来了第二个人也想用打印机,他发现门上的名字不是自己,于是两个人开始用CAS方式去竞争这把锁。竞争成功的人拿到锁,失败的人短暂自旋等待,这就是轻量级锁。轻量级锁适合线程交替执行、竞争不激烈的场景,自旋时线程不阻塞,避免了操作系统层面的上下文切换。

如果竞争进一步加剧,比如十几个线程同时抢这台打印机,自旋等待的成本太高了。JVM就把锁升级为重量级锁,让没抢到锁的线程直接进入阻塞状态,由操作系统调度唤醒。这就是synchronized让人感觉"重"的地方:线程阻塞和唤醒涉及用户态和内核态的切换,开销很大。

这里还有一个关键点:锁只能升级,不能降级。一旦锁从轻量级升级到重量级,后续即使竞争降下来了,它依然保持重量级状态。这也是为什么有些高频次但低竞争的场景不建议用同步方法的原因之一。

v加上JDK的另一个优化:锁消除。如果JIT编译器通过逃逸分析发现一个对象根本不会被其他线程访问,那它上面加的synchronized就是多余的,编译器会直接把这些同步指令去掉。我在测试一段局部变量上加锁的代码时,用JMH基准测试测出来的性能损耗几乎为零,就是这个原因。

锁粗化则相反:如果JIT发现同一个对象在一段循环体里反复加锁解锁,它会把锁的范围扩大到循环外面,避免频繁地获取和释放锁。

4. synchronized与volatile、Lock:怎么选才对

很多初学者会纠结:有了synchronized,为什么还要volatile,为什么还要ReentrantLock?其实它们解决的问题有重叠,但各有侧重,不是简单的替代关系。

4.1 synchronized和volatile各管一摊

volatile能保证可见性和一定程度的有序性,但不能保证原子性。也就是说,如果一个变量被多个线程同时读和写,且这个操作本身是单步的(比如读一个布尔状态位),volatile是轻量且高效的。但如果操作是count++这种读改写组合,volatile就无能为力了,因为更新丢失的问题依旧存在。

所以我的选择习惯是这样的:状态标记用volatile,复合操作用synchronized。比如一个开关标志、一个任务是否完成的标识、一个缓存是否失效的判断,用volatile就够了,没必要动用锁的重量级机制。

4.2 synchronized和ReentrantLock怎么取舍

ReentrantLock在功能上比synchronized丰富,主要体现在几点:

第一,ReentrantLock支持可中断等待。线程在等待锁的过程中可以被interrupt打断,而synchronized一旦进入等待就没有退路。第二,ReentrantLock支持超时等待,tryLock(3, TimeUnit.SECONDS)可以在指定时间内没拿到锁就放弃,处理应急场景非常实用。第三,ReentrantLock可以设置公平锁,而synchronized天生是非公平的。第四,ReentrantLock支持多个Condition条件队列,而synchronized只能配合Object的wait/notify一组条件,复杂场景下Condition更好用。

但功能多也意味着出错的概率大。ReentrantLock必须手动在finally里释放锁,写漏了就死锁;而synchronized由JVM自动管理锁释放,写起来简单不容易错。我的观点很明确:如果不依赖中断、超时、公平性这些高级特性,优先用synchronized;只有当synchronized确实不能满足需求时,才引入ReentrantLock。

4.3 wait/notify与synchronized的搭配

这一篇虽然讲的是synchronized,但它在线程通信中还有一个重要搭档:Object类的wait和notify。这两个方法必须放在synchronized代码块里调用,否则会抛IllegalMonitorStateException。原因是wait的本质是"让出锁并等待条件满足",notify的本质是"唤醒等待该锁的线程",非持锁线程没有叫醒别人的资格。

经典的生产者消费者模式,就是synchronized + wait/notify协同工作的典型:生产者发现队列满了就wait,消费者消费完一个就notifyAll;消费者发现队列空了就wait,生产者生产完就notifyAll。这里强调一下,notifyAll比notify更安全,因为notify随机唤醒一个线程,如果唤醒的是同类线程,可能造成所有线程都阻塞的假死现象。

理解了wait/notify,你对synchronized的认知才真正闭环:它不仅是互斥工具,也是线程协作的基础设施。

5. 常见问题与排查技巧实录

这一部分我汇总一下在实际开发和排障过程中遇到的典型问题,每一个我都亲手踩过,写成速查表给各位参考。

5.1 问题一:一个synchronized方法居然没起到保护作用

你写了synchronized方法,但并发测试时数据还是错乱。这种情况下十有八九是锁对象不一致。比如一个类里有两个方法,一个加了synchronized修饰,另一个用的是synchronized(this)或synchronized(某成员变量),表面上看都是锁,实际锁的是不同对象。或者更常见的是,你用了静态方法锁的是Class对象,但调用方以为锁的是实例,另一段代码用实例锁去保护同一个共享数据,两边用的根本不是同一把锁。

排查方法很简单,在锁的入口和出口打日志,打印锁对象的toString和hashCode,看看并发线程进入时锁对象是不是同一个。

5.2 问题二:程序卡死,怎么确认是死锁

死锁的经典场景是两个线程各持有一把锁,同时在等对方的锁。比如线程A持有锁1等待锁2,线程B持有锁2等待锁1,两边谁都不放手,程序就永久卡住。

遇到这种情况,第一反应是先把线程堆栈dump出来。用jstack或者jcmd Thread.print,工具会在输出的结尾部分直白地提示"Found one Java-level deadlock"之类的话,并列出每个线程等待的锁和持有的锁。根据提示找到两个线程之间的关系,然后审视代码里的加锁顺序。避免死锁的金科玉律只有一个:所有线程都按同样的全局顺序加锁。比如统一先锁A再锁B,死锁的前提条件就不存在了。

5.3 问题三:锁粒度太粗导致系统吞吐量下降

这里我举一个反例。某同学写了一个库存扣减服务,把扣减方法整个加了synchronized,方法里包含了大量的日志打印、库存查询和外部接口调用。结果就是所有请求串行排队,接口的TPS掉到惨不忍睹。

正确的做法是,先把耗时操作移出临界区,只保留真正需要互斥的几行更新语句在锁内。如果发现临界区里只剩下内存变量的读写或数据库的原子更新,那甚至可以考虑去掉synchronized,改用原子类或数据库本身的原子操作,让应用层锁退场。

5.4 问题四:读多写少的场景用锁感觉太重

如果共享数据是读多写少的场景,比如一个配置对象,大部分线程只是读取,偶尔一个线程修改,全部读线程都互斥就显得代价太高。这时synchronized不是最优选择。你可以考虑volatile保证可见性,或者用读写锁ReadWriteLock分离读锁和写锁,多个读者可以并行进入,只有写者才独占。不过从实现复杂度来看,如果能接受短暂的不一致,volatile往往就够了;如果必须严格一致,读写锁是合适的方向。

5.5 jstack之外的几个辅助工具

除了jstack,我实际排查中还会配合使用jcmd、jconsole和visualvm。jcmd Thread.print是jstack的现代替代品,用法更规范;jconsole可以直接在图形界面里看到线程状态和锁等待情况;visualvm可以dump线程快照。线上环境如果不方便用图形工具,linux下用jstack + grep管道过滤"BLOCKED"和"deadlock"基本就能快速定位。

另外一个小技巧:线上问题无法复现时,保留现场最重要。遇到疑似锁相关的问题,第一时间连续dump三次线程堆栈,每次间隔3秒,比只dump一次更能看出线程是不是长期卡死在某个锁上。

6. 写在最后:我的synchronized使用体会

从我自己的实战经验来说,synchronized远没有很多人想象中那么可怕。JDK 1.6之后的锁升级机制,让它在竞争不激烈时的开销已经很低;反过来,一个没分析清楚就胡乱加上的锁,反而会变成性能瓶颈。我个人的习惯是,先想清楚三个问题再做决定:共享的是什么、临界区最小能缩小到哪、这个锁的粒度和存活周期是否和业务匹配。

写代码时多花两分钟确认锁对象,排查问题时少踩两小时的坑。synchronized的入门很容易,但用好的关键从来不在那些花哨的技巧上,而是踏踏实实地把临界区画对。这个系列接下来我可能还会继续写volatile、ReentrantLock和线程池的内容,到时候再看看这些并发工具之间怎么配合,才能搭建一个既正确又高效的多线程系统。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询