ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java并发编程之synchronized深度解析:用法、原理与实战避坑

Java并发编程之synchronized深度解析:用法、原理与实战避坑 做Java开发这些年凡是涉及多线程的项目synchronized几乎是逃不开的关键字。它简单到一行代码就能锁住临界区又复杂到能把偏向锁、Monitor、内存可见性这些底层机制全串起来。面试官爱问synchronized不是因为八股而是因为这一个点就能看出你对并发模型理解到什么程度。这篇博文就把“synchronized”掰开揉碎讲清楚从基本用法、锁对象选择、底层原理、锁升级过程到和ReentrantLock的对比再到线上排查和实战避坑。既适合刚接触 Java 并发的同学建立正确认知也适合准备跳槽面试的工程师用来做一轮系统梳理。1. synchronized的三种用法锁对象才是核心1.1 修饰实例方法锁的是thissynchronized最直观的用法是直接加在实例方法上。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 ... monitorexitmonitorenter尝试获取对象的 Monitor 锁如果获取成功锁计数器加 1如果获取失败线程进入阻塞等待。monitorexit释放锁计数器减 1。因为synchronized是支持重入的同一个线程再次进入同一把锁时计数器继续增加退出一次减少一次。这个计数器机制是理解“可重入”的关键也是synchronized和某些不可重入锁方案最本质的区别。monitorenter和monitorexit之间夹着的才是真正受保护的临界区。字节码里看到两个monitorexit也不用奇怪一个对应正常退出一个对应异常退出编译器保证异常路径上也会释放锁。这正是synchronized比手动lock()/unlock()更安全的根本原因你不需要写try-finallyJVM 在字节码层面已经替你兜底了。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 修饰实例方法锁谁当前实例 thissynchronized 修饰静态方法锁谁当前类的 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的变量只会让你想骂人。锁的命名的确看起来是很小的事情但维护并发代码时它带来的清晰度远比想象中重要。
返回列表