☰
Java并发编程之synchronized深度解析:用法、原理与实战避坑
2026/10/1 23:18:05 网站建设 项目流程

做Java开发这些年,凡是涉及多线程的项目,synchronized几乎是逃不开的关键字。它简单到一行代码就能锁住临界区,又复杂到能把偏向锁、Monitor、内存可见性这些底层机制全串起来。面试官爱问synchronized,不是因为八股,而是因为这一个点就能看出你对并发模型理解到什么程度。这篇博文就把“synchronized”掰开揉碎讲清楚,从基本用法、锁对象选择、底层原理、锁升级过程,到和ReentrantLock的对比,再到线上排查和实战避坑。既适合刚接触 Java 并发的同学建立正确认知,也适合准备跳槽面试的工程师用来做一轮系统梳理。

1. synchronized的三种用法:锁对象才是核心

1.1 修饰实例方法:锁的是this

synchronized最直观的用法是直接加在实例方法上。

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

很多人背过“实例方法锁的是当前对象 this”,但没想过这句话意味着什么。两个线程调用同一个Counter实例的increment()时,必须排队执行,因为锁是同一个对象。如果两个线程持有的是两个不同的Counter实例,各自调用increment(),那就完全不互斥,因为锁对象不是同一个。这解释了为什么用synchronized修饰实例方法时,锁的粒度实际上是“对象级别”,而不是“类级别”,也不是“方法级别”。

还有一点很容易被忽略:synchronized修饰的是整个方法体,等价于在方法内部写上synchronized(this) { ... },并且范围覆盖到整个方法。如果方法体里有大量耗时不依赖共享资源的操作,这种写法会把锁粒度撑得很大,后续优化时通常要改造为代码块。

1.2 修饰静态方法:锁的是Class对象

静态方法用synchronized修饰时,锁的对象是Class对象,比如Counter.class。

public class Counter { private static int total = 0; public static synchronized void addTotal() { total++; } }

这意味着所有调用这个静态方法的线程,无论创建了多少个Counter实例,都要争抢同一把“类锁”。注意,静态锁和实例锁不是同一把锁,它们之间不会互相阻塞。很多初学者在这里犯迷糊:一个线程在执行静态同步方法时,另一个线程能不能执行实例同步方法?答案是可以,因为前者锁的是Class对象,后者锁的是this,两者是不同对象。

这个差异在写工具类、单例模式或缓存服务时非常关键。如果你用静态锁保护的是类级别的共享状态,那实例方法里再用synchronized去保护同一份状态,就会产生逻辑漏洞,因为两把锁互相不认账。

1.3 修饰代码块:锁对象由你决定

代码块是使用最灵活、出现频率最高的形式。

public void process() { // 不需要同步的区域 doSomethingSlow(); synchronized (lock) { // 需要同步的临界区 sharedValue++; } }

锁对象可以是this、某个专门的锁实例、Counter.class,甚至是字符串常量,但字符串常量属于典型反模式,后面会单独讲。用代码块的好处是能精确控制锁的范围,只在真正操作共享数据时才加锁,锁外代码可以并行执行。

从字节码层面看,代码块同步是通过monitorenter和monitorexit两条指令实现的。JVM 会在代码块的入口插入monitorenter,在正常出口和异常出口分别插入monitorexit,保证无论方法是否抛异常,锁都能被释放。对比方法级别的synchronized,虽然 JVM 规范里没有明确要求用monitorenter实现,但在 HotSpot 的实现中,方法级同步也会通过类似机制完成,区别只是不需要显式的字节码指令,而是通过方法访问标志ACC_SYNCHRONIZED来标记。

看到这里你应该明白,synchronized的可变维度并不是语法本身,而是锁对象的选择。锁对象决定了互斥范围,这是整个关键字最容易理解、也最容易出错的地方。

2. 从字节码到Monitor:底层到底在锁什么

2.1 字节码里的一进一出

写一段简单的同步代码块,用javap -verbose反编译一下,能看到清晰的结构:

public void demo() { synchronized (this) { int x = 0; } }

对应字节码中会出现:

monitorenter ... monitorexit

monitorenter尝试获取对象的 Monitor 锁,如果获取成功,锁计数器加 1;如果获取失败,线程进入阻塞等待。monitorexit释放锁,计数器减 1。因为synchronized是支持重入的,同一个线程再次进入同一把锁时,计数器继续增加,退出一次减少一次。这个计数器机制是理解“可重入”的关键,也是synchronized和某些不可重入锁方案最本质的区别。

monitorenter和monitorexit之间夹着的才是真正受保护的临界区。字节码里看到两个monitorexit也不用奇怪,一个对应正常退出,一个对应异常退出,编译器保证异常路径上也会释放锁。这正是synchronized比手动lock()/unlock()更安全的根本原因,你不需要写try-finally,JVM 在字节码层面已经替你兜底了。

2.2 JDK 6以后的锁升级:偏向锁、轻量级锁、重量级锁

JDK 6 以前,synchronized的每次加锁都是重量级操作,直接依赖操作系统底层的 Mutex Lock,线程挂起、恢复要经历用户态和内核态的切换,性能很差。这也是为什么早期并发编程书里经常建议少用synchronized,改用java.util.concurrent包里的显式锁。

JDK 6 对synchronized做了大版本优化,引入了偏向锁、轻量级锁、重量级锁的升级路径。锁升级的方向只有一个:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。升级过程不可逆,所以也叫锁膨胀。

偏向锁的核心假设是:大部分情况下,一个锁不仅被某一线程持有,而且总是被同一个线程反复获取。因此锁对象头里记录持有偏向锁的线程 ID,后续该线程再次进入时,不需要任何 CAS 操作就能拿到锁。如果出现其他线程竞争,偏向锁撤销并膨胀为轻量级锁。

轻量级锁通过CAS尝试把对象头中的 Mark Word 替换为指向当前线程栈帧中锁记录的指针。只要有竞争,就自旋一段时间再重试,避免直接陷入内核态。自旋默认有次数限制,自旋超过阈值或同时有多个线程竞争时,轻量级锁膨胀为重量级锁,线程真正挂起阻塞。

理解这个升级路径对调优很有实际意义。一个低并发场景下,synchronized可能全程停留在偏向锁或轻量级锁阶段,性能并不差;真正可怕的是高激烈竞争下频繁升级到重量级锁,造成大量线程阻塞、上下文切换。所以别一听到synchronized就觉得性能不行,先判断你的锁竞争强度。

2.3 锁消除与锁粗化:编译器的小动作

很多人不知道,JIT 编译器还会帮你做锁消除和锁粗化。

锁消除发生在逃逸分析之后。如果 JVM 发现一个对象只会被当前线程访问,根本没有其他线程能看到它,那么对这个对象加锁就是多余的。比如在一个方法内部创建了一个StringBuffer(它的append方法是同步的),且这个对象没有被方法外部引用,JIT 就可能把锁消除掉。这种优化对开发者透明,但前提是确实没有逃逸。

锁粗化则相反,它把多个相邻的、对同一把锁的加锁解锁操作合并成一次大范围的加锁。比如在循环里反复对同一锁对象做同步操作,每次进入和退出都有额外开销,JIT 检测到这个模式后会把锁范围扩大到整个循环,减少锁操作次数。

但要注意,锁消除和锁粗化是 JIT 编译期的优化,不是程序运行前能强控的行为。你写代码时仍然应该遵循“锁粒度精细”的原则,不要把希望都寄托在编译器优化上。

3. 多线程环境下你真正要解决的三个问题

3.1 可见性:内存模型下的主内存与工作内存

synchronized解决的并不是只有“原子性”,它还解决了内存可见性问题。Java 内存模型规定,每个线程有自己的工作内存,线程对共享变量的操作必须先把主内存的值读到工作内存,修改后再写回主内存。这个读写过程中,其他线程是感知不到中间状态的。

synchronized的语义是:线程进入同步代码块前会清空自己的工作内存中涉及共享变量的缓存,使得后续读取直接从主内存获取最新值;线程释放锁时,会把修改后的共享变量强制刷新到主内存。换句话说,锁的获取和释放充当了内存屏障,它不止保护临界区内的代码,还顺带把可见性问题一并解决了。

我在实际项目中遇到过一种情况:两个线程通过volatile boolean控制任务启停,但任务内部还依赖另一个共享状态,结果开关状态总是能读到,内部状态却偶尔读到旧值。后来定位到是共享状态没有纳入volatile或锁的保护范围。这个教训说明,synchronized的可见性保障只能覆盖同步块内部的共享变量访问,走锁之外的路径去读同一个变量,仍然可能读到旧值。

3.2 原子性:check-then-act是重灾区

count++从 Java 语法看是一行,但底层其实是“读取 -> 修改 -> 写回”三步操作,线程在执行过程中随时可能被切换。如果不加锁,两个线程同时执行count++,最终 count 只增加 1,这就是经典的原子性缺失。

synchronized保证临界区内的代码作为一个不可分割的整体执行,线程持有锁期间,其他线程无法进入同一把锁保护的代码块。这样“检查-再执行”这类复合操作就不会被穿插执行。比如常见的单例双重检查锁模式:

public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }

这里两个if (instance == null)就是典型的 check-then-act。第一个判断为了性能,避免每次都要拿锁;第二个判断为了保证原子性,防止多个线程同时通过第一次校验后各自创建实例。volatile在这里负责禁止指令重排序,防止拿到一个构造还未完成的对象。

3.3 有序性:重排序不是玄学

编译器、CPU 都可能在不改变单线程语义的前提下对指令进行重排序。单线程里重排序无所谓,但多线程环境下,一个线程看到的执行顺序可能和另一个线程实际执行顺序不一致。

synchronized也可以保证有序性,因为同步块内的代码在持有锁期间对其他线程是不可见的,只要在锁的保护下,临界区内部的乱序不会影响外部观察者。释放锁后做的操作,一定是基于临界区内最新状态展开的。但这个保障仍然只在锁边界内有效,锁外的无序读写不在保护范围。

3.4 wait/notify与管程模型

提到synchronized,绕不开wait()和notify()/notifyAll()。这三个方法必须在同步代码块或同步方法中调用,原因是它们要操作锁对象的 Monitor 等待集:wait()让当前线程释放锁并进入等待集,直到被通知或超时;notify()从等待集中唤醒一个线程,notifyAll()唤醒全部。

经典的生产者消费者模型就是基于这一套机制实现的。注意notify()唤醒的线程并不会立刻继续执行,它需要重新竞争锁,拿到锁之后才能从wait()返回。所以从wait()返回后,仍然要重新检查条件,防止“虚假唤醒”或条件已被其他线程改变。这正是教科书里推荐while (condition) wait();而不是if (condition) wait();的原因。

这个管程模型理解起来有点绕,但记住一条主线:synchronized负责互斥,wait/notify负责协作,两者配合才能实现完整的管程语义。

4. 面试和工程里最常见的几个对比

4.1 synchronized与ReentrantLock怎么选

面试官几乎必问“synchronized和ReentrantLock的区别”,工程里选型时也确实需要考虑。

ReentrantLock比synchronized多了几项能力:可中断获取锁(lockInterruptibly)、可定时获取锁(tryLock(timeout))、支持多个条件变量(Condition)、支持公平锁和非公平锁切换。synchronized则胜在简单、语法安全,出了异常 JVM 会自动释放锁,不用担心忘记 unlock 导致死锁。

Java 官方其实也在推动两者能力对齐。JDK 6 优化了synchronized的锁实现,JDK 8 之后synchronized在大多数常规场景下的性能已经不输ReentrantLock。锁测试里常看到“在低竞争下两者几乎没区别,高竞争下早期版本 ReentrantLock 有明显优势,但现在差距已经很小”,我自己的压测结果也基本符合这个结论。

我做选型的经验是:如果只需要基础的互斥和可见性保障,优先用synchronized,代码最简洁;如果需要做超时等待、可中断、条件队列等高级控制,再考虑ReentrantLock。尤其是tryLock在避免死锁场景里有明显优势,因为可以拿不到锁就放弃,而不是无限等下去。

另外有个容易被忽略的点:synchronized是可重入锁,ReentrantLock也是可重入锁,但它们各自内部有独立的锁计数。换锁不是换语义,而是换 API 能力。

4.2 synchronized(this)、synchronized(class)与全局锁:粒度之争

这三个看起来差不多,但互斥范围完全不同:

  • synchronized(this)锁的是当前实例,两个线程只要不是操作同一个对象就可以并行。
  • synchronized(Counter.class)锁的是类对象,所有线程只要用到这个类就共享同一把锁。
  • synchronized(lock)锁的是自定义锁实例,完全由你的锁对象生命周期决定。

如果一份共享数据是实例级别的,却用Counter.class去锁,相当于把范围扩大到了全局,导致不必要的竞争;反过来,如果共享数据是类级别的静态字段,却用this去锁,又会出现多个实例线程同时修改静态字段的漏洞。

我之前接手的旧项目里出过一个经典 bug:静态缓存用synchronized修饰的方法更新,但调用方又在外层用synchronized(instance)包了一层“二次保护”,结果方法是静态锁,外面是实例锁,两层锁完全没有交集,缓存还是会并发覆盖。这个问题的根子就在于没有统一锁对象的选择逻辑,加锁的人根本不知道锁的是谁。

4.3 静态锁与实例锁不可互锁

静态同步方法锁的是Class对象,实例同步方法锁的是this。这两种锁互不干扰,不能互相替代。如果你用静态锁保护了一个静态计数器,又在实例方法里用实例锁去增加同一个计数器,两个线程可能同时进入两段代码,最终计数器丢失更新。

解决思路是:同一份数据只能用同一把锁保护,不要混用。如果要锁静态数据,代码里统一使用synchronized(Xxx.class);如果要锁实例数据,统一使用synchronized(this)或专门的锁对象。在代码评审中发现有人混用静态锁和实例锁时,基本可以直接判定为并发隐患。

5. 实战中的显式避坑清单

5.1 不要锁字符串常量

new String("lock")看起来是两个不同字符串对象,但如果用intern()或者直接使用字符串字面量,JVM 可能把它们指向同一个常量池对象。也就是说:

public class A { private String lock = "lock"; } public class B { private String lock = "lock"; }

如果 A 和 B 各自用synchronized(lock),表面看是两把锁,实际上两个线程可能互锁,因为它们锁的是同一个字符串常量对象。更麻烦的是,这种锁对象完全不可控,类加载器里的相同字符串字面量都可能共用。我建议锁对象一律用new Object()或专门命名的 final 对象,不要用字符串,不要用Integer等包装类型。

有个经典连环坑是锁Integer对象时自动装箱导致锁对象被替换。比如:

synchronized (count) { count++; }

count++会创建新的Integer对象,后续线程拿到的锁对象可能已经变了,锁就形同虚设。所以锁对象必须是final且不可变的引用,不能用会被重新赋值的变量。

5.2 减少锁粒度:分段与并发集合

synchronized 锁粒度越细,竞争越小,吞吐越高。经典做法是分段锁,比如把一批数据按 key 哈希划分成多个 segment,每个 segment 一把锁,不同 segment 之间可以并行处理。Java 的ConcurrentHashMap在 JDK 7 时代就是分段锁设计,JDK 8 改成了 CAS + synchronized,锁粒度进一步缩小到单个桶。

如果场景能用并发容器,尽量别自己手工加锁。读多写少的缓存用ConcurrentHashMap,需要复合操作的再用compute系列方法。很多表面上需要加锁的地方,换一个数据结构就能省掉锁,这是最理想的优化方向。

我见过一个例子:一个排行榜服务,更新分数时用了全局限定锁保护整个 map,后来改成ConcurrentHashMap,再用compute原子更新每个用户的分数,性能提升了 3 倍以上。加锁不是目的,保护数据一致性才是,能用无锁或细粒度同步解决,就不要一刀切上全局锁。

5.3 控制临界区大小,别把IO放进锁

锁内不要做耗时操作,尤其是网络 IO、磁盘 IO、远程调用。耗时操作会大大延长锁持有时间,后续排队线程全部阻塞,系统吞吐瞬间下降,还可能引发线程池队列堆积。

正确做法是先在锁内快速完成共享状态更新,然后把耗时的外部调用移到锁外:

public void handle(Request req) { Data data = null; synchronized (lock) { data = sharedCache.get(req.getId()); if (data == null) { data = new Data(req); sharedCache.put(req.getId(), data); } } // 锁外执行远程调用 remoteService.push(data); }

这个模式叫“锁内读状态、锁外执行不可控操作”。尤其当外部接口超时时间较长时,锁内持有锁会让整个应用像被冻住一样。线上排查遇到线程大量BLOCKED时,第一反应就应该去看看是不是锁里放了慢请求。

还有一个容易忽视的点:不要在循环里反复进入同一个锁并做无意义竞争。锁粗化在编译器层面能帮你合并,但这个合并是有代价的,如果循环体里只有极短的同步操作,还不如直接把锁提到循环外,自己管理粒度。

5.4 线上排查:jstack一眼定位死锁与卡顿

线上出问题时,最常用的排查命令是jstack。线程状态里能直接看出很多信息:

  • java.lang.Thread.State: BLOCKED:线程在等待锁进入同步块。
  • java.lang.Thread.State: WAITING:线程在wait()上等待。
  • 出现deadlock字样时,jstack 会直接打印死锁检测结果,列出两个线程各自持有的锁和等待的锁。

拿到线程 dump 后,先找found one Java-level deadlock提示,再看线程栈里的锁信息和业务方法名,基本能定位到是哪两个类、哪两段代码互相等待。如果没有死锁,只是大量线程BLOCKED,则说明锁竞争太严重,此时配合jstat看 GC、配合业务日志看耗时,可以判断是临界区太大还是某把锁被异常长时间占用。

我处理过最难的锁问题是“看起来没竞争,但请求一直卡住”。后来抓了几次 dump 才发现,锁对象是一个被equals重载过的业务对象,业务逻辑里把锁对象的 hashCode 改了,导致 hash 散列变化,Monitor 记录错乱。这种事情很难预判,唯一的建议就是锁对象要纯净,不要用会被业务修改状态的复杂对象。

6. 常见问题速查与实测心得

6.1 快速速查表

我把高频疑点整理成一张速查表,面试或自查时可以直接对照:

问题结论
synchronized 修饰实例方法锁谁当前实例 this
synchronized 修饰静态方法锁谁当前类的 Class 对象
synchronized 代码块锁谁括号里显式指定的对象
静态锁和实例锁互相阻塞吗不阻塞,两把不同的锁
synchronized 可重入吗可重入,同一个线程可重复获取同一把锁
线程执行同步块抛异常锁会释放吗会释放,JVM 自动释放
wait 之后线程会立刻执行吗不会,需要重新竞争锁
偏向锁偏向谁偏向第一个获取锁的线程
轻量级锁通过什么实现CAS 自旋
重量级锁依赖什么操作系统 Mutex,涉及内核态切换
synchronized 能保证可见性吗能,锁释放时刷新共享变量到主内存
synchronized 能禁止指令重排序吗在锁边界内能保证安全,锁外不保证
为什么不要锁字符串常量可能指向常量池同一个对象,锁不可控
为什么不要在锁里做 IO锁持有时间过长,大量线程阻塞

这张表适合贴在手边,但真正理解还是要回到前文的原理。

6.2 我的实测心得

压测过无数遍之后,我对synchronized的整体判断是:它被低估了。JDK 8 以后,单把锁、低竞争的同步场景里,它的性能和ReentrantLock已经拉不开差距,代码又短又稳,出错的概率远小于手动 lock/unlock 的写法。真正的性能瓶颈很少出在锁本身,而是出在锁粒度过大、锁内做慢操作、锁对象混乱。

项目上新功能时,我通常按这个顺序做设计:先确认共享数据范围;再选最小粒度的锁对象;能通过不可变对象、ConcurrentHashMap、Atomic类解决的,尽量不加锁;确实需要加锁的,优先synchronized代码块,控制临界区大小;需要高级协调能力时才引入ReentrantLock或Condition。

最后分享一个小技巧:写锁相关的代码时,给锁对象起一个有意义的名字,比如lock、stateLock、cacheLock,同时用final修饰。别小看这个习惯,线上查问题时,好的锁对象名字能让你几秒内看明白代码意图,而一个叫lock1的变量只会让你想骂人。锁的命名的确看起来是很小的事情,但维护并发代码时,它带来的清晰度远比想象中重要。

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

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

立即咨询