
做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 三大特性原子性、可见性、有序性聊并发编程绕不开JMMJava内存模型里的三个概念原子性、可见性、有序性。很多文章把它们摆在一起讲结果读者越看越糊涂。我换个方式用大白话拆开。原子性意思是操作不可分割。对应到我们的案例就是“读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 同步实例方法默认锁的是thissynchronized修饰实例方法是最常见的用法也是最初级的入门姿势。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对象的指针10GC标记空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()加上synchronizedpublic 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中unlock4.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调用内部方法BB上有Transactional结果事务和锁都没生效线上数据出现不一致。排查下来才知道是代理自调用的问题。解决方式是注入自己的代理或者把方法拆分到不同Spring Bean里。如果你在做定时任务的多实例部署还要注意定时任务的调度器在不同节点上是独立的单凭synchronized拦不住多节点重复执行。这时你需要分布式锁或者让调度框架自带分布式协调能力。把synchronized当成跨进程万能锁是并发开发里最常见的认知错误。6. 写在最后一点个人经验和建议我用synchronized这些年踩过不少坑也一次次刷新对它认知。最后分享几个小习惯给大家参考。第一个习惯是动手写同步之前先问“这个共享资源到底属于谁”吗是单实例的还是进程级的还是跨进程的搞清楚这个问题再选锁不然很容易做出“表面正确”的代码。第二个习惯是坚持最小临界区。宁可多写一个同步代码块也不要图省事把整个方法锁起来。锁的范围越小并发能力越强排查问题也越容易。第三个习惯是能用synchronized就别急着上复杂锁。很多场景ReentrantLock能用但synchronized也够用那就用简单的。JVM把synchronized优化了这么多年它仍然是Java并发编程里最可靠的锁基础。真正的高手不是背得住底层原理而是能在凌晨两点的线上事故里通过jstack一眼看出锁导致的问题。synchronized只是起点后面还有AQS、并发容器、线程池、分布式锁一个个学起来都是绕不开的积累。把这一篇的每一个细节都亲自跑一遍比背十篇八股文都管用。