news 2026/10/7 12:37:22

synchronized 深度解析:从并发原理到锁升级与实战排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
synchronized 深度解析:从并发原理到锁升级与实战排查

做Java开发的朋友,只要接触过并发编程,synchronized这个关键字一定不陌生。它是Java里最基础、最直接的线程同步手段,也是面试八股里的常客。咱们继续Thread学习系列,这篇专门把synchronized从头到尾聊透。很多人用它写代码很顺手,但问到它到底锁的是什么、锁是怎么升级的、为什么有人说它“重”又有人说它“轻”,可能就说不清了。这篇文章会把synchronized的关键概念、三种用法、底层原理、性能取舍和实际排查都过一遍,目标是让你看完之后不只会用,还能在面试和实战里把原理讲明白。

1. synchronized到底在解决什么问题:从一个并发事故讲清楚

1.1 先看一段会出事的代码

先来看一段很典型的计数器代码。这个例子我几乎逢人就讲,因为它在并发环境下会稳定地“出事”,而且问题非常容易理解。

public class UnsafeCounter { private int count = 0; public void increment() { count++; } public int getCount() { return count; } public static void main(String[] args) throws InterruptedException { UnsafeCounter counter = new UnsafeCounter(); int threadCount = 20; int loopCount = 10000; Thread[] threads = new Thread[threadCount]; for (int i = 0; i < threadCount; i++) { threads[i] = new Thread(() -> { for (int j = 0; j < loopCount; j++) { counter.increment(); } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println("最终值: " + counter.getCount()); } }

如果程序是线程安全的,20个线程各加10000次,最终值应该是200000。但你把这段代码丢到机器上跑几次,大概率看到的是十几万,而且每次结果都不一样。

原因其实很简单:count++不是一条原子指令。它内部至少拆成三步——读取当前count的值、把值加1、把新值写回。两个线程可能同时读到同一个旧值,都加1,再写回去,结果只增加了一次。这就是典型的“丢失更新”,也就是竞争条件。

我第一次遇到这个问题时也走了一段弯路,第一反应是给getCount()也加上同步,后来才明白读方法加不加要看具体业务语义。读方法只读一个int变量,在32位/64位JVM上不会出现“读到半个值”的撕裂读,所以只靠getCount()加锁解决不了计数丢失的问题。必须保证“读-改-写”这个整体过程不被别的线程插进来。

这其实就是synchronized最核心的用途:让一段代码在同一时刻只能被一个线程执行。这段被保护起来的代码,我们习惯叫“临界区”。

1.2 三大特性:原子性、可见性、有序性

聊并发编程,绕不开JMM(Java内存模型)里的三个概念:原子性、可见性、有序性。很多文章把它们摆在一起讲,结果读者越看越糊涂。我换个方式,用大白话拆开。

原子性,意思是操作不可分割。对应到我们的案例,就是“读count、count加1、写count”这三个动作要看作一个整体,中间不能被别的线程打断。synchronized通过互斥锁保证临界区的代码不会交错执行,这就解决了原子性问题。

可见性,意思是线程A改了变量之后,线程B要能看到最新值。JMM里有个约定:线程修改变量时是在自己的工作内存中操作,然后刷回主存。如果两个线程各忙各的,B读到的可能还是A修改前的旧值。synchronized在进入同步块时会让线程重新从主存读取变量,在退出同步块时把改过的变量强制刷回主存,所以可见性也有保障。

有序性,指的是代码不能随意被重排到影响程序语义的程度。现代CPU和编译器为了性能会做指令重排,但在单线程内要保证最终结果一致。在多线程环境下,重排可能让另一个线程观察到“不正常的执行顺序”。synchronized通过内存屏障限制了临界区与外部之间的重排,让并发环境下的执行顺序符合预期。

这里有一个容易混淆的点:volatile关键字也能保证可见性和有序性,但它保证不了复合操作的原子性。volatile适合那种“单线程写、多线程读”的标记位场景,而synchronized更适合“多个线程同时读改写同一份数据”的场景。两者不是替代关系,是不同层次的工具。

1.3 synchronized不是Thread类的方法,而是JVM内置的锁

很多人第一次接触synchronized时,会下意识把它和Thread类的方法放到一起记忆。实际上synchronized是Java语言层面的关键字,由JVM直接支持,它不是Thread类或者哪个工具类提供的方法。它和wait()、notify()、notifyAll()配合使用,构成了最经典的线程协作模型。

Thread类解决的是“线程怎么创建、怎么启动、怎么控制状态”的问题,而synchronized解决的是“多个线程访问共享资源时怎么互斥、怎么协作”的问题。两者经常一起出现,但职责完全不同。这也是为什么在Thread学习系列里,讲到线程安全问题时会专门腾出一篇来讲synchronized。只有把互斥和协作这套机制理解透了,后面再看ReentrantLock、CountDownLatch、Semaphore这些JUC组件,才会觉得它们是同一个问题域里的不同解法。

2. 三种用法,锁对象千万别搞混

2.1 同步实例方法:默认锁的是this

synchronized修饰实例方法,是最常见的用法,也是最初级的入门姿势。

public class TicketSystem { private int tickets = 100; public synchronized boolean buyTicket() { if (tickets <= 0) { return false; } // 模拟一些耗时处理 tickets--; return true; } }

当buyTicket()被多个线程通过同一个TicketSystem实例调用时,JVM会拿当前实例this作为锁对象。同一时间只有一个线程能执行到buyTicket()内部。这看起来很美好,但有一个很容易被忽略的问题:如果两个线程调用的是不同实例的同步方法,它们之间是不会互斥的。

我见过一个真实案例:一开始项目里有个OrderService,把扣库存方法写成synchronized实例方法。测试环境单实例部署,一切正常。后来灰度验证时做了多实例部署,结果并发一上来就出现超卖。原因就是每台机器上都有一个独立的OrderService实例,锁的是各自不同的this,跨实例之间完全没有互斥效果。

所以用实例方法同步前,先问自己一句:这个共享资源是实例级别的,还是进程级别的?实例级别的用实例方法锁没问题,进程级别的就必须换成静态方法或代码块锁Class对象。

2.2 同步静态方法:锁的是Class对象

静态方法的同步,锁的不是某个实例,而是当前类的Class对象。

public class InventoryService { private static int stock = 100; public static synchronized boolean deductStock() { if (stock <= 0) { return false; } stock--; return true; } }

这种情况下,不管你创建多少个InventoryService实例,锁都是同一个InventoryService.class对象。JVM里每个类有且只有一个Class对象,所以静态同步方法天然是进程级别的互斥。

这里就引出了另一个经典问题:一个类的同步实例方法和同步静态方法,它们之间会互斥吗?答案是不会。因为一个锁在具体实例上,另一个锁在Class对象上,两者是两把不同的锁。这个知识点在面试里经常被包装成“两个线程分别调用同一个类的同步实例方法和同步静态方法,能并发执行吗”来考察,答案是可以。

2.3 同步代码块:锁对象自己定

同步代码块是三种用法里最灵活、也最推荐的日常写法。它的核心就一句话:锁哪个对象,你自己说了算。

public class OrderService { private final Object lock = new Object(); public void createOrder() { // 不需要同步的校验、组装逻辑 synchronized (lock) { // 真正需要保护的关键区 saveOrder(); } // 不需要同步的后续逻辑 } }

用private final Object lock作为锁对象,有几个好处:第一,锁对象对外不可见,别人没法利用你内部的锁去做一些奇怪的联锁操作;第二,final保证锁对象不会被重新赋值,避免出现“同一把锁”被替换导致锁失效的问题;第三,通过代码块可以精确控制临界区的范围,比把整个方法体都锁住要高效得多。

我曾经优化过一个定时任务,原代码是整个方法加上synchronized,每次执行要5秒,其中4秒都在做日志汇总和结果推送。改成代码块之后,只锁真正操作共享缓存的那500毫秒,整个任务耗时从5秒降到了2秒以内。这个案例让我意识到了锁粒度的重要性,后面专门整理成了一个优化清单。

2.4 锁对象选型的三个常见坑

锁对象选错,比不用锁还可怕,因为表面看起来“加了锁”,实际上竞争还是发生,只是隐藏得更深。

第一个坑是用字符串常量作为锁对象。比如synchronized ("lock")。Java的字符串常量存在字符串常量池里,只要字面量相同,多个地方拿到的就是同一个对象。这听起来好像还能用,但它有一个致命的副作用:字符串常量是整个JVM共享的,如果你依赖的第三方库内部也用了同样的字面量当锁,两边会莫名其妙地互相阻塞。反过来,如果你在同步块里对字符串做拼接、替换等操作,锁对象可能已经不是原来那个字符串了,也会导致锁失效。

第二个坑是锁对象为null。synchronized块内如果你引用了一个null对象,会直接抛NullPointerException。虽然是异常,但并发场景下异常不一定马上暴露,可能隔了很久才在某个特定调用链上炸出来,定位成本很高。

第三个坑是锁对象被重新赋值。比如你把一个普通成员变量当锁对象,某个方法执行到一半给它赋了新的对象,那其他线程拿到的锁对象就变了,相当于原本的锁被“偷换”了。所以锁对象要么用final,要么用不可变对象。

顺带提一句,这让我想起另一个像“关键字”这个说法一样容易踩的坑:MySQL建表时给字段起名叫group、order、desc之类的关系型数据库关键字,使用时要到处加反引号。道理差不多,越是看起来基础的东西,越要先搞清楚它在系统里的真正身份再动手用,不然早晚被反噬。

3. 底层原理:synchronized为什么“重”过,又为什么变快了

3.1 从字节码看monitorenter和monitorexit

很多八股文只告诉你结论,不告诉你结论是怎么来的。想真正搞懂synchronized,最好从字节码入手看一眼。

假设有这样一个类:

public class SyncDemo { private final Object lock = new Object(); public void sayHello() { synchronized (lock) { System.out.println("hello"); } } }

用javap -c反编译SyncDemo.class,核心字节码长这样:

public void sayHello(); Code: 0: aload_0 1: getfield #2 // Field lock:Ljava/lang/Object; 4: dup 5: astore_1 6: monitorenter 7: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 10: ldc #4 // String hello 12: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 15: aload_1 16: monitorexit 17: goto 25 20: astore_2 21: aload_1 22: monitorexit 23: aload_2 24: athrow 25: return

你会看到一对关键指令:进入同步块时执行monitorenter,退出同步块时执行monitorexit。而且要注意,正常路径和异常路径都有monitorexit。正常路径是执行完同步块后释放锁,异常路径是通过异常表捕获异常,在抛出异常之前先把锁释放掉。

这就是synchronized和后面要讲的ReentrantLock在API层面最大的区别之一:synchronized不用你手动释放锁,哪怕同步块里抛了异常,JVM也会保证monitorexit被执行。很多线上事故就是Lock忘记在finally里unlock()导致锁永远不释放,而synchronized天然规避了这个问题。

理解了字节码结构,你就知道“synchronized锁住的是对象,不是代码块”这句话的真正含义了。monitorenter和monitorexit操作的是同一个对象,这个对象就是锁。代码本身没有锁的概念,锁是每个对象都自带的一个监视器锁。

3.2 对象头与Mark Word到底记了啥

每个Java对象在内存里并不只是“字段数据”那么简单,它前面还有一块固定大小的对象头。对象头里最关键的部分叫Mark Word,锁状态就记录在这里。

以64位JVM为例,Mark Word是64位,不同的锁状态下这64位的含义完全不同。知识点比较多,我整理了一张简化对照表:

锁状态Mark Word的部分内容标志位
无锁对象哈希码、偏向锁位、分代年龄01
偏向锁偏向线程ID、偏向时间戳、分代年龄01
轻量级锁指向线程栈中Lock Record的指针00
重量级锁指向ObjectMonitor对象的指针10
GC标记空11

这里需要强调“简化”两个字,64位JVM和32位JVM的位分布不一样,不同JDK版本也可能有细微调整。但对开发来讲,最重要的是形成一个概念模型:锁信息就存在对象头里,线程竞争锁,本质是在竞争这个对象头里的状态位。

为什么轻量级锁是“指向线程栈中Lock Record的指针”?因为轻量级锁的设计思路是:每个线程在进入同步块时,会在自己的栈帧里创建一个Lock Record,里面保存对象头原来的Mark Word,然后通过CAS尝试把对象头的Mark Word替换成指向自己Lock Record的指针。如果替换成功,说明抢到了锁。如果替换失败,说明已经有人持锁了。

而重量级锁的Mark Word指向的是一个ObjectMonitor对象。ObjectMonitor是JVM内部用C++实现的一个监控器结构,里面记录了持有锁的线程、锁的重入次数、等待队列等关键信息。到了这一步,锁的获取和释放就会走操作系统层面的互斥量,涉及用户态和内核态的切换,所以被称为“重量级”。

3.3 锁升级:从无锁到重量级的完整路径

JDK 1.6之前,synchronized的性能被诟病,原因就是它动不动就直接上重量级锁,线程拿不到锁就进入BLOCKED状态,由操作系统调度,开销很大。JDK 1.6之后做了大量优化,引入了一个重要概念:锁升级。

一条完整的升级路径是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。正常情况下锁只能升级,不能降级(GC时可能会有例外,但那是JVM内部细节,普通开发不用纠结)。

偏向锁解决的是“只有同一个线程反复进入同一段同步代码”的场景。它的想法很大胆:既然只有一个线程在用这把锁,那锁直接记下这个线程的ID,之后它再进来,什么都不用做,直接放行。只有当另一个线程也来竞争时,偏向锁才需要撤销,这时锁会升级为轻量级锁。

轻量级锁解决的是“少量线程交替或竞争短暂”的场景。拿不到锁的线程不会立刻阻塞,而是先做一段时间的自旋等待——就是让CPU空转着不断尝试获取锁。自旋不是无限转,JVM有自适应自旋机制,会根据历史竞争情况动态调整次数。如果自旋能等到锁被释放,就避免了用户态和内核态的切换开销。

如果自旋也等不到,说明竞争已经非常激烈了,锁就会升级为重量级锁。此时没拿到锁的线程会真的阻塞住,进入ObjectMonitor的等待队列,等持有锁的线程释放后由操作系统唤醒。这个唤醒过程有性能成本,但面对高强度竞争,阻塞比自旋更节能,是一种防护措施。

这里要提一个很重要的变化:JDK 15开始,JVM默认禁用了偏向锁,JDK 18进一步移除了相关实现。原因是偏向锁在多线程竞争频繁的现代应用里,维护和撤销偏向锁的成本反而超过了收益。所以现在很多人讲synchronized还在大谈偏向锁,面试官问到就得心里有数:这是历史产物,不是当前默认行为。

3.4 锁消除和锁粗化:JIT帮你做的优化

了解完锁升级,再补充两个由JIT编译器在运行时做的优化:锁消除和锁粗化。

锁消除发生在“JVM能判断某段代码根本没有竞争”的情况下。最经典的例子是,在方法内部创建局部变量,并且没有把它逃逸出去,仅仅在当前线程内使用。如果对这个局部变量加锁,JVM可能直接锁落空,也就是消除锁。一个常见的例子是:

public String concat(String a, String b) { StringBuffer sb = new StringBuffer(); sb.append(a); sb.append(b); return sb.toString(); }

StringBuffer的append方法是同步的,但sb是纯局部对象,不会暴露给其他线程。JIT分析后认为竞争不存在,就可能把这里的加锁去掉,这就是锁消除。

锁粗化则相反。JVM发现你在一个循环里反复地加锁、解锁,比如每执行一次循环体就进入一次同步代码块,这种频繁的同步开销很大,它会自动把锁的范围扩大,把整个循环都纳入临界区,减少获取锁和释放锁的次数。这其实是在“锁粒度”和“同步次数”之间做一个权衡。

但我要提醒一句:JIT的优化是我们控制不了的,不能依赖它来掩盖糟糕的锁设计。你能做的还是在业务代码里合理控制锁范围。把锁加在一个无谓的循环里,即使JIT帮你优化了,也只是“侥幸”,可读性也差,没必要赌。

4. 实操:三个案例搞懂锁粒度与性能

4.1 场景一:计数器累加的锁粒度

回到第1节那个UnsafeCounter,最简单的修复就是给increment()加上synchronized:

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

这样写完全能解决问题,却能看出来对性能的考虑不够细。因为increment()只有一行代码,方法级同步和代码块同步的差别其实不大。但现实中同步方法往往包含其他耗时操作,这时候方法级同步就会把很多没必要互斥的代码也锁住。

改造方案是这样的思路:把耗时操作移出临界区,只保留共享变量操作在锁内。

public void increment() { // 一些不需要同步的本地计算 synchronized (this) { count++; } }

你可能会说“这优化了个寂寞,一行有什么区别?”那是因为例子太简单。换成真实场景,比如扣减库存时还要写日志、推送消息、做后续补偿,把耗时操作从锁内移到锁外,效果立竿见影。我做过一个压测,同样的并发量下,锁范围从整个方法缩小到只保护库存变更后,TPS直接翻倍。

锁粒度控制的铁律就是:临界区越小越好,但共享资源的原子性保护不能丢。不要为了性能把该保护的代码漏出锁外,否则会出现更严重的数据问题。

4.2 场景二:读多写少时怎么选

读多写少在并发开发里是绝对的热词,几乎每个业务系统都能遇到。如果直接用synchronized保护读写方法,读和读之间也会互斥,这在读频率远高于写频率的场景里是一个巨大瓶颈。

读操作本来就该允许并发,只有写的时候才需要互斥。ReentrantReadWriteLock就是为解决这个问题设计的:共享锁读、排他锁写。

private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); public Object readFromCache() { rwLock.readLock().lock(); try { // 读缓存逻辑 } finally { rwLock.readLock().unlock(); } } public void writeToCache(Object value) { rwLock.writeLock().lock(); try { // 写缓存逻辑 } finally { rwLock.writeLock().unlock(); } }

简单说,如果是读多写少且数据量不是特别大的场景,ReentrantReadWriteLock比synchronized有明显优势。但如果写操作也很频繁,读写锁的维护成本会让自己变成一个折腾的“大锁”,反而不如synchronized简单直接。

还有一个更轻的替代方案:CopyOnWriteArrayList。它是“写时复制”思路,写操作直接复制整个数组,读操作不加锁。适合读极多、写极少、且写时允许短暂不一致的场景。我见过有人拿它当并发List的万能药,结果写频繁的场景下频繁复制数组,内存和CPU都崩了。工具选型一定要对着场景来,不能光靠印象。

在读多写少场景里还有一个细节:如果缓存数据本身是原子可见的不可变对象,很多情况下连锁都可以不用,直接以volatile引用指向新对象就行。这种方案性能最好,代价是需要把共享数据设计成不可变对象,每次更新直接替换整个引用。

4.3 场景三:需要超时抢锁和响应中断时

有一个问题很多面试官会问:synchronized和ReentrantLock到底怎么选?我的答案是:能用synchronized优先用synchronized,除非你明确需要下面这几个能力。

第一,需要尝试获取锁,拿不到就立刻离开,而不是无限期等待。synchronized做不到,ReentrantLock有tryLock()。

第二,需要等待锁时可中断。synchronized的等待无法响应线程中断,而ReentrantLock的lockInterruptibly()支持。

第三,需要实现公平锁。synchronized默认是非公平的,ReentrantLock可以指定为公平锁。

代码上直观感受一下:

ReentrantLock lock = new ReentrantLock(); if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 执行临界区逻辑 } finally { lock.unlock(); } } else { // 拿不到锁的兜底逻辑 }

这是synchronized很难优雅表达的场景。如果业务上允许“抢不到锁就直接走降级逻辑”,用tryLock就是很合适的写法。

两者的选择我总结成一个经验:优先synchronized,它的代码更简洁、不需要手动释放锁、JVM做了大量优化,绝大多数业务场景足够。只有当业务需要中断、超时、多条件等待时,才切换到ReentrantLock,并且必须配合try/finally保证unlock()。

下面一张表把关键差异列出来,方便面试前翻一翻:

对比维度synchronizedReentrantLock
获取和释放方式JVM内置,自动释放需要手动lock/unlock
是否可重入是是
锁中断不支持支持lockInterruptibly
超时尝试不支持支持tryLock(timeout)
公平性非公平默认非公平,可切换公平
条件变量只配合wait/notify使用支持多个Condition条件队列
异常释放同步块抛异常自动释放必须在finally中unlock

4.4 synchronized和wait/notify的配合

前面聊了很多synchronized的用法,但Thread学习系列里还有一个关键知识点:wait()和notify()必须和synchronized配合使用,否则直接抛IllegalMonitorStateException。

究其原因,wait()的意思是“我暂时不竞争锁了,请你把我放到这个对象的等待集里,等别人notify()再来唤醒我”。它释放当前持有的锁,所以调用前必须先持有锁。notify()则是从等待集里挑一个线程唤醒,被唤醒的线程会重新去竞争锁。sleep()不会释放锁,wait()会释放锁——这个差异在面试里几乎必考。

有一个典型的基于synchronized的生产者消费者代码结构:

public synchronized void produce() { while (isFull) { wait(); } // 生产逻辑 notifyAll(); } public synchronized void consume() { while (isEmpty) { wait(); } // 消费逻辑 notifyAll(); }

这里的while不是多余的,因为线程被唤醒后重新抢到锁,需要再次检查条件是否已经被其他线程改变,这叫“循环等待”,在实际项目里极其重要。如果你用if判断,一旦出现多个消费者等待同一个条件,就可能出现“唤醒一个线程后,另一个线程已经把资源拿走了,被唤醒的线程却直接继续执行”的问题。

5. 常见问题排查实录

5.1 锁失效的几种表现和根因

我总结了几个实际工作中容易遇到的“锁失效”场景,做成一个速查表,遇到类似表现可以直接对号入座:

现象可能原因解决方向
加了synchronized还是有多线程问题锁的是不同对象检查是实例方法还是静态方法,统一锁对象
同步代码块不互斥锁对象被重新赋值锁对象声明为final
多个实例之间互斥失效部署了多个JVM进程JVM层锁无法跨进程,引入分布式锁
使用字符串字面量当锁,莫名死锁字符串常量池共享换用私有final Object锁对象
wait/notify抛异常不在同步块内调用移到synchronized块/方法中
定时任务多机重复执行进程间无共享锁Redis分布式锁、数据库乐观锁

这里重点说下“多个实例之间互斥失效”。很多人会问:synchronized不是进程级的锁吗?为什么多实例部署就失效?答案很简单,synchronized锁的是当前JVM进程内的对象,不同JVM进程各自维护各自的对象,彼此之间根本看不到对方。跨进程的互斥需要借助外部存储,比如Redis锁、Zookeeper锁、数据库乐观锁。这不是synchronized的锅,而是你把它的能力边界用错了。

还有一个很低频但很坑的问题:如果你在synchronized块里调用了外部HTTP接口或数据库操作,锁的持有时间会非常长。一旦外部服务缓慢,整个系统的吞吐量骤降,排查时看起来像“线程卡死”,其实就是线程在等锁。这种“锁内做IO”的写法要尽量避免。

5.2 死锁:现象、复现、用jstack定位

死锁是并发开发里的灾难现场,也是最经典面试题。最简单的死锁长这样:

Object lockA = new Object(); Object lockB = new Object(); // 线程1 new Thread(() -> { synchronized (lockA) { System.out.println("线程1持有A"); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { } synchronized (lockB) { System.out.println("线程1尝试拿B"); } } }).start(); // 线程2 new Thread(() -> { synchronized (lockB) { System.out.println("线程2持有B"); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { } synchronized (lockA) { System.out.println("线程2尝试拿A"); } } }).start();

线程1持有A等B,线程2持有B等A,谁也等不到谁。这个示例能稳定复现死锁,它是理解和排查复杂死锁的基础。

线下定位死锁的流程很简单:

jps -l jstack <pid>

执行jstack后,如果存在死锁,输出最后会直接有一句Found one Java-level deadlock,并且会列出两个线程各自持有和等待的锁对象。看到这个输出,基本就可以确定死锁线程和锁的持有关系了。然后回到代码里,去调整两个线程获取锁的顺序,比如都先拿A再拿B,死锁自然消失。

死锁的根因通常是“锁的顺序不一致”。所以我在实际编码时有一个习惯:如果两个锁之间有关系,尽量让所有线程都按同一个全局顺序获取锁。如果锁的顺序没法统一,就引入超时机制,比如用ReentrantLock.tryLock而不是无限等待。留一条退路,比试图把所有情况都提前想到要现实得多。

5.3 锁竞争导致性能下降怎么定位

之前遇到过一个线上问题:某个接口偶发超时,但CPU和内存都正常。用jstack多次抓线程快照后,发现大量业务线程处于BLOCKED状态,阻塞在一个synchronized方法上。定位思路就三条:先确认线程状态,再找到锁的持有者,最后优化锁粒度。

jstack输出里,BLOCKED状态线程会有一行waiting to lock <0x...>,而持有锁的线程会有locked <0x...>。对比几次快照,就能看出谁长期占着锁不放。常见原因就是这个锁被某个慢操作占了太久,比如在锁里查数据库、调外部接口、做大对象拷贝。

定位到之后,常规优化路径是:把耗时操作移出临界区、用读写锁替换共享锁、或者把synchronized换成JUC组件。这一步如果数据量很大,还要配合压测验证,改完不能只看功能正确,要看吞吐量是否真的提升。

我个人的经验是,性能问题排查时先看锁竞争情况和线程状态,比先怀疑GC更有效。GC导致的性能问题往往伴随着GC日志的增加,而锁竞争问题的特征是线程大量BLOCKED,现象非常明显,一抓一个准。

5.4 Spring事务与synchronized叠加时的坑

这个坑我踩过一次之后记忆深刻。Spring的@Transactional是用代理实现的,默认情况下事务方法会先启动事务,再执行业务逻辑,最后提交事务。如果你给一个事务方法加上synchronized,整个锁的持有时间会覆盖事务提交之前的时间。

问题在于:线程A执行完业务逻辑,释放锁,但事务还没提交;线程B立刻拿到锁进入方法,读到数据库中旧数据,然后线程A才提交事务。这种情况下,线程A的同步块确实互斥了,但线程B读到的还是未提交前的旧数据,业务目的根本没达到。

正确姿势是把锁放到事务外层。常见做法有两种:一是把同步逻辑写在事务方法的外面,用编程式事务把锁内部的事务提交控制在自己手里;二是拆分方法,锁住的是调用事务方法的入口,而不是事务方法本身。

还有一个更容易中招的细节:Spring代理自调用时,this调用方法不会经过代理,@Transactional会失效,synchronized加的锁也可能是同一个this。我见过一个问题:同一个类里,方法A调用内部方法B,B上有@Transactional,结果事务和锁都没生效,线上数据出现不一致。排查下来才知道是代理自调用的问题。解决方式是注入自己的代理,或者把方法拆分到不同Spring Bean里。

如果你在做定时任务的多实例部署,还要注意:定时任务的调度器在不同节点上是独立的,单凭synchronized拦不住多节点重复执行。这时你需要分布式锁,或者让调度框架自带分布式协调能力。把synchronized当成跨进程万能锁,是并发开发里最常见的认知错误。

6. 写在最后:一点个人经验和建议

我用synchronized这些年,踩过不少坑,也一次次刷新对它认知。最后分享几个小习惯给大家参考。

第一个习惯,是动手写同步之前先问“这个共享资源到底属于谁”吗?是单实例的,还是进程级的,还是跨进程的?搞清楚这个问题再选锁,不然很容易做出“表面正确”的代码。

第二个习惯,是坚持最小临界区。宁可多写一个同步代码块,也不要图省事把整个方法锁起来。锁的范围越小,并发能力越强,排查问题也越容易。

第三个习惯,是能用synchronized就别急着上复杂锁。很多场景ReentrantLock能用,但synchronized也够用,那就用简单的。JVM把synchronized优化了这么多年,它仍然是Java并发编程里最可靠的锁基础。

真正的高手不是背得住底层原理,而是能在凌晨两点的线上事故里,通过jstack一眼看出锁导致的问题。synchronized只是起点,后面还有AQS、并发容器、线程池、分布式锁,一个个学起来,都是绕不开的积累。把这一篇的每一个细节都亲自跑一遍,比背十篇八股文都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 12:37:16

基于IEEE33节点的主动配电网优化与粒子群算法实战解析

头一回拿IEEE33节点系统跑主动配电网优化&#xff0c;我犯了个挺基础的错误&#xff1a;直接把分布式电源当成节点上的负的负荷塞进潮流里&#xff0c;然后套了个粒子群就开始迭代。结果出来的电压剖面图反而比原始系统更差&#xff0c;我还以为是算法写崩了&#xff0c;后来才…

作者头像 李华
网站建设 2026/10/7 12:37:03

MiniMax M Plan 全模态额度统一与 Claude Code、Cursor 免密接入实战

1. 从 Token Plan 到 M Plan&#xff1a;这次额度规则到底改了什么如果你最近一直在用 MiniMax 的 API 做开发&#xff0c;大概率已经注意到一个变化&#xff1a;原来那套按 Token 单独计费、按模态分别扣额度的 Token Plan 已经不再是主角了。取而代之的是 M Plan——一个把文…

作者头像 李华
网站建设 2026/10/7 12:35:25

Node.js 双模 MCP 服务实战:同时支持 Stdio 与 Streamable HTTP

1. 为什么我要自己动手写一个双模 MCP 服务 最早接触 MCP 是在给一个内部工具链做 AI 能力接入的时候。当时的需求很朴素&#xff1a;让本地的脚本、数据库查询、文件操作能被大模型直接调用&#xff0c;而不是每次都在对话框里复制粘贴。翻了一圈资料&#xff0c;发现 MCP 这个…

作者头像 李华
网站建设 2026/10/7 12:35:16

Agent-Reach:多Agent协作的轻量触达层实践

最近在做一条跨业务的智能化流程改造&#xff0c;最开始只是让几个独立的Agent各管一段流程&#xff0c;跑着跑着发现不对劲&#xff1a;单看每个Agent都挺聪明&#xff0c;但只要涉及接力协作&#xff0c;就全靠人肉中转——把上一个Agent的输出复制粘贴到下一个Agent的输入框…

作者头像 李华
网站建设 2026/10/7 12:35:08

AI 重构 UI 工作流:从手搓像素到意图生成与 prefab 批量落地

1. 从手搓像素到对话生成&#xff1a;UI 工作流的真实转折点“自从有了 AI&#xff0c;我就再也不想拼 UI 了”——这句话我第一次在团队群里看到时&#xff0c;正对着一个 200 多个 prefab 的 Unity 工程发呆。那天下午的任务是把一套活动弹窗从旧版视觉规范迁移到新版&#x…

作者头像 李华