资讯动态

深入理解Java synchronized:用法、原理与锁优化

发布时间:2026/10/16 11:14:04 来源:尧图企业网站定制
1. 内容整体设计与思路拆解1.1 为什么synchronized是并发编程绕不开的坎做Java开发但凡涉及到多线程操作共享数据synchronized就是第一个跳进脑子里的关键字。我在项目里写过不下百个带锁的方法但真正敢说自己“懂”这个关键字的开发者其实不多。很多人只是知道“加锁能让线程安全”至于它底层怎么实现的、锁的粒度该怎么控制、为什么会死锁、什么时候该换成Lock这些细节往往是一知半解。作为一个Java进阶系列的第六篇这篇文章的思路很明确从使用语法到底层原理从锁的优化机制到实战排查彻底把synchronized这块硬骨头啃下来。适合的人群包括已经掌握Java基础语法、开始接触多线程编程的开发者写过一些并发代码但遇到莫名奇妙的线程安全问题、正在排查的朋友以及准备面试Java中高级岗位、需要系统梳理并发知识的求职者。这篇文章我想达到的效果是你看完之后不仅能写出正确的同步代码还能说出“为什么这么写是安全的”“锁在什么情况下会失效”“JDK层面又对锁做了哪些优化”。这些背后逻辑才是区分熟练工和资深开发者的关键。1.2 synchronized的定位与整体学习路径先给synchronized做个定位。它是Java内置的、基于监视器锁Monitor实现的同步机制是JVM层面提供的语言级锁。跟它形成对比的是java.util.concurrent包下的显式锁比如ReentrantLock那是在API层面实现的锁。两者的核心目的是一样的保证多个线程在访问共享资源时的互斥性、可见性和有序性。整套学习路径我建议按下面的节奏走先掌握三种基本用法同步实例方法、同步静态方法、同步代码块搞清楚它们各自锁的是什么东西再深入理解锁的底层原理包括对象头、Monitor、锁升级机制这部分是区分“会用”和“懂原理”的分水岭然后理解它声明的三大特性原子性、可见性、有序性以及和volatile之间的分工差异接着通过实战案例强化学以致用的能力特别是锁粒度怎么控制才不影响性能最后整理我这些年踩过的坑和排查经验帮你提前避开那些容易忽视的问题。这整套内容走下来你对synchronized的理解就能形成体系遇到并发相关的场景也知道从哪个角度去分析。2. 三种使用方式详解锁的对象完全不同2.1 同步实例方法与同步代码块的基础用法先看最基础的代码示例直观感受一下三种写法的区别public class SynchronizedDemo { private int count 0; // 方式一同步实例方法锁的是当前实例对象 this public synchronized void increment() { count; } // 方式二同步静态方法锁的是当前类的 Class 对象 public static synchronized void staticIncrement() { // 静态方法逻辑 } // 方式三同步代码块锁的是括号里指定的对象 public void blockIncrement() { synchronized (this) { count; } } }方式一和方式三在这里效果是等价的锁的都是当前实例。区别在于方式一只需要加一个关键字但锁的范围是整个方法体方式三可以精确控制需要同步的代码区间锁的范围更小并发度更高。方式二锁的是Class对象这一点很多初学者容易忽略。用同一个类的两个不同实例去并发调用这个静态同步方法依然是互斥的因为Class对象在JVM里是唯一的。但如果一个线程调用静态同步方法、另一个线程调用非静态同步方法它们之间不会互斥因为一个锁的是Class对象、另一个锁的是实例对象两把锁互不相干。public class LockObjectDemo { public synchronized void instanceMethod() { System.out.println(实例方法); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } public static synchronized void staticMethod() { System.out.println(静态方法); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }执行上面的代码两个线程分别调用instanceMethod()和staticMethod()结果是它们会同时执行、互不阻塞。这是所有synchronized面试题里最高频的考点之一你可以在本地跑一下加深印象。2.2 锁对象选择的三个原则与常见误区锁对象的选择直接决定着同步逻辑是否正确。这里分享我总结的三个基本原则锁对象必须是多个线程共享的同一个对象。也就是说线程A和线程B必须拿到同一把钥匙才能实现互斥如果各拿各的钥匙锁就形同虚设锁对象不能是null。synchronized (null) 会在运行时抛出NullPointerException这也是一个容易踩的小坑锁对象的引用在同步期间不能被重新赋值。如果锁对象的引用在中途被替换了其他线程拿到的就是另一把锁互斥关系会被破坏。最常见的坑是“new出一个新对象当锁”。我见过同事写过这样的代码public class WrongLockDemo { private Integer lock 128; public void doSomething() { synchronized (lock) { // 业务逻辑 } } // 错误示例某处对 lock 重新赋值 public void resetLock() { lock new Integer(200); } }这段代码的问题是一旦resetLock()被调用后续线程拿到的锁对象就变了之前持锁的线程和之后的线程之间不再互斥可能引发线程安全问题。另一个更隐蔽的问题是如果锁对象是Integer这种包装类型它内部有缓存机制-128到127之间的整数会复用缓存对象如果两个不同的逻辑恰好锁了同一个缓存的Integer值就发生了意外串锁。因为这些问题我推荐你在项目里专门定义锁对象或者用Object实例、String常量要特别小心常量池问题等明确且不会被修改引用的对象。团队规范里可以加上一条锁对象使用私有final修饰避免外部修改引用。2.3 字符串常量池锁引发的“幽灵互斥”说到String做锁这里有个特别值得警惕的场景。Java的字符串字面量会驻留到常量池也就是说内容相同的两个字符串变量实际上指向的是同一个String对象。这意味着两个逻辑上完全不相关的线程如果恰好用了一样的字符串内容当锁它们会莫名奇妙地互相阻塞。public class StringLockDemo { public void methodA() { synchronized (lock) { // 业务逻辑A } } public void methodB() { synchronized (new String(lock).intern()) { // 业务逻辑B } } }methodA和methodB看起来用的是不同的锁表达式但字符串lock是常量池里的同一个对象intern()方法返回的也是常量池里的同一个对象。所以这两个方法之间的线程会互相阻塞而与它们业务毫无关联的第三方代码如果也用了lock这个字符串做锁同样会受影响。这类问题在生产环境极其难排查因为代码review阶段几乎不可能通过肉眼看出来。排查手段一般是抓线程dump发现有大量线程阻塞在某个同步块上而这个同步块看起来不应该争抢这么激烈。解决方法是锁对象统一使用私有final的Object实例完全把锁和业务字符串隔离。3. 底层原理对象头、Monitor与锁升级机制3.1 对象头里的Mark Word如何记录锁状态要理解synchronized的底层原理先得认识Java对象在内存里的布局。一个Java对象由三部分组成对象头Header、实例数据Instance Data和对齐填充Padding。对象头又包含两部分Mark Word和类型指针Klass Pointer。Mark Word是理解锁机制的关键。它是一个可变长度的数据结构会根据对象的状态是否被锁定、是否处于GC流程等复用存储空间。在64位JVM的默认开启压缩指针情况下Mark Word占8个字节64位。它的不同状态如下锁状态存储内容锁标志位无锁对象的hashCode、分代年龄001偏向锁线程ID、偏向时间戳、分代年龄101轻量级锁指向栈中锁记录的指针000重量级锁指向Monitor对象的指针010GC标记空011这些标志位决定了JVM能否直接判断出对象当前的锁状态进而决定什么时候可以直接进入临界区、什么时候需要阻塞等待。对象头不是固定不变的它的状态会随着锁竞争情况在不同状态之间切换这就引出了锁升级的机制。3.2 从偏向锁到重量级锁的四级升级之路JDK 6之前synchronized一旦被线程竞争就直接依赖操作系统底层的互斥量来实现阻塞和唤醒这个操作涉及用户态和内核态的切换开销非常大所以一直被叫做“重量级锁”。JDK 6之后引入了锁升级机制核心目标是尽量在无竞争或低竞争的场景下避免真正动用操作系统级别的互斥操作。锁升级的完整路径是偏向锁 → 轻量级锁 → 重量级锁。每一种锁的应用场景不同偏向锁是针对“这个锁始终被同一个线程持有”的场景设计的优化。当线程A第一次访问同步块时JVM会把锁对象头的Mark Word设为偏向模式记录线程A的ID。之后A再次进入同步块时只需检查线程ID是否匹配不需要做任何CAS操作和互斥操作开销极其小。如果另一个线程B试图获取这把偏向锁偏向锁会立刻撤销并膨胀为轻量级锁。轻量级锁的核心是CAS自旋。线程B会尝试通过CAS操作把对象头的Mark Word替换成指向自己栈中锁记录的指针。如果成功B就持有了锁。如果失败说明锁被其他线程持有B会进入自旋状态反复尝试CAS而不是立刻阻塞挂起。自旋会消耗CPU资源因此JDK之后引入了自适应自旋如果上一次自旋成功拿到锁JVM认为你很快还能拿到就多旋几次反之就减少自旋次数甚至不自旋。如果自旋次数超过阈值或者等待线程数太多轻量级锁就会膨胀为重量级锁。膨胀之后未抢到锁的线程进入操作系统的阻塞队列由CPU调度这时才真正涉及用户态到内核态的切换。整个过程我用一个生活场景类比可能更好理解一个洗手间临界区通常只有一个人固定使用门都不需要锁这是偏向锁突然有另一个人也想用于是门上装了把简易锁来人在门口快速敲两下门CAS自旋里面没人回应就把门打开进去了如果门口的人越来越多干脆换一把需要排队叫号的电子锁大家老老实实排队等着这就是重量级锁。这里需要补充的是偏向锁在JDK 15开始被默认禁用JDK 18已经被彻底移除。原因是现代应用里绝大多数锁对象都经历过至少两个线程的竞争偏向锁带来的优化收益已经不明显反而增加了复杂的撤销逻辑。换句话说你在较新版JDK上跑代码锁的起始状态很可能直接是轻量级锁这是JVM团队基于实际业务场景统计数据做出的取舍。3.3 轻量级锁和重量级锁的获取流程对比轻量级锁的获取流程可以细分为四步线程在栈帧中创建锁记录Lock Record用于存放对象当前Mark Word的副本通过CAS操作尝试把对象头Mark Word更新为指向锁记录的指针更新成功则当前线程持锁更新失败说明存在竞争先检查对象的Mark Word是否已经指向当前线程的锁记录。如果是说明是重入直接进入临界区如果不是说明锁被其他线程持有进行自旋等待自旋达到阈值后锁膨胀为重量级锁。重量级锁获取的核心是Monitor机制。每个Java对象都关联一个Monitor对象在HotSpot虚拟机中叫ObjectMonitor重量级锁的Mark Word就指向这个Monitor。Monitor内部最重要的结构包括Owner当前持有锁的线程标识、EntryList等待获取锁的线程队列和WaitSet调用了wait方法的线程队列。当一个线程尝试获取重量级锁时JVM会把线程包装成ObjectWaiter放入EntryList然后通过操作系统互斥量mutex阻塞该线程。当Owner线程释放锁时JVM会从EntryList里挑选一个线程唤醒让它重新竞争锁。这些都是操作系统级别的操作所以开销远大于用户态的CAS。4. synchronized锁带来的三大特性原子性、可见性与有序性4.1 原子性代码块内的操作不再被线程调度拆散原子性是指一系列操作要么全部执行、要么全部不执行中间不能被线程调度机制打断。synchronized通过锁的互斥性质来保证原子性同一时刻只有一个线程能进入临界区所以在临界区内执行的代码序列对其它线程来说就是一个不可分割的整体。这里需要注意一个常见的理解误区synchronized并不保证临界区内代码的执行时间变短也不保证这条线程一定一口气执行完所有逻辑。它的保证是“这一系列操作期间其他线程无法介入操作同一份数据”。经典的i问题可以说明问题。i在字节码层面其实包含三条指令读取i的值、将值加一、写回i。在多线程场景下三个线程同时执行i会出现竞争条件。但把i放进synchronized块之后三条指令就被“锁”成了一个逻辑整体同一时刻只有一条线程能执行它们。public class AtomicityDemo { private int count 0; public synchronized void safeIncrement() { count; } }我在项目里遇到过一个真实场景某结算系统里同一个用户的余额被多个充值渠道并发更新因为代码只在部分方法上加了同步、部分没加导致余额出现偏差。排查之后发现对同一条数据的读-改-写链路只要有一个环节没同步保护整个链路就从原子性上被打破了。4.2 可见性加锁解锁是天然的“内存屏障”可见性是指当一个线程修改了共享变量的值后其他线程能够立刻看到这个最新值。Java内存模型JMM规定了每个线程都有自己独立的工作内存可以理解为CPU高速缓存线程对共享变量的读写都发生在工作内存中这就可能导致一个线程的修改对另一个线程不可见。synchronized通过两条规则保证可见性第一条线程释放锁时会把当前工作内存中的共享变量强制刷新回主内存第二条线程获取锁时会把主内存中对应的共享变量重新加载到工作内存使本地副本失效。这两条规则合在一起的效果是持有锁的线程所做的所有修改在它释放锁之后对所有来获取同一把锁的线程都是可见的。有个容易忽略但实际很关键的点这里说的“释放锁”和“获取锁”必须是同一把锁。如果线程A修改完数据后释放的是锁X线程B接下来想要看到A的修改它获取的也必须锁X才行。如果B获取的是锁Y那锁X的释放和锁Y的获取之间没有直接关系可见性无从谈起。4.3 有序性与重排序的陷阱Happens-Before规则有序性是指代码的执行顺序应当符合程序的逻辑顺序。但在底层编译器和CPU为了优化性能会对指令进行重排序。重排序本身没问题只要最终结果在单线程视角下是一致的。可问题在于多线程场景下重排序可能导致一个线程看到的操作顺序和代码顺序不一致。synchronized是如何防止这种问题的JMM有一条与锁相关的Happens-Before规则一个锁的解锁操作先行发生于后面对同一把锁的加锁操作。这条规则意味着线程A在synchronized块里做的所有操作包括写变量、调用方法、分支跳转都不会被重排序到锁释放之后线程B获取同一把锁的所有操作也不会被重排序到锁获取之前。在实际编写并发代码时有序性问题往往发生在双重检查锁Double-Checked Locking模式里。经典的例子如下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; } }这里的singleton变量必须加volatile因为new Singleton()在底层包含三步分配内存、初始化对象、把引用赋值给变量。如果不加volatile第二步和第三步可能被重排序导致其他线程拿到一个尚未初始化完成的半成品对象。如果你用synchronized替换volatile是无法解决问题的因为volatile在这里负责的是“禁止重排序”而synchronized负责的是互斥两者职能不同。4.4 可重入性同一线程反复获取同一把锁不会把自己锁死synchronized的一个重要特性是同一线程可以重复获取同一把锁。比如一个同步方法内部调用了另一个同步方法而两个方法用的都是同一把锁如果锁不可重入当前线程会把自己阻塞住形成死锁。synchronized作为内置锁天然支持重入底层通过Lock Record或Monitor中的计数器来实现每次获取锁状态计数加一每次释放锁计数减一计数归零才算真正释放。public class ReentrantDemo { private final Object lock new Object(); public void methodA() { synchronized (lock) { System.out.println(进入方法A可以重入获取同一把锁); methodB(); } } public void methodB() { synchronized (lock) { System.out.println(进入方法B此时用的是同一把锁); } } }可重入性带来的实际便利是递归调用同一个同步方法不会出问题同步方法内调用同一个类的其他同步方法不会出问题。这也是synchronized和很多自定义锁的核心区别。如果你自己去实现一把锁一定要把重入性考虑进去否则在复杂的调用链里很容易出现自我阻塞。5. 实战案例改造一个线程不安全的计数器系统5.1 从问题代码出发为什么结果总是小于预期下面我模拟一个实战场景。某电商系统的库存扣减模块初始库存是500件多个线程同时发起扣减请求。第一版代码如下public class StockService { private int stock 500; public void deduct() { if (stock 0) { try { // 模拟业务处理耗时 Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } stock--; System.out.println(扣减成功剩余库存 stock); } else { System.out.println(库存不足); } } }跑500个线程同时调用deduct()你会发现剩余库存和预期完全不同。原因有两个层面第一check-then-act先检查再操作这个动作本身不是原子的多个线程可能同时通过if判断都认为库存大于0然后一起执行stock--导致结果偏差第二stock--不是原子操作在字节码层面包含读、改、写三步并发执行时会发生互相覆盖。这里我测试过用500个线程并发调500次扣减接口最终库存往往只剩一两百甚至会出现负数。这说明线程安全的bug不像编译错误那样直接报错而是结果不可复现测试里的“偶发问题”到生产环境就变成“经常问题”。5.2 用synchronized实现三种不同粒度的正确方案改造方案之一是在方法上直接加synchronizedpublic class StockService { private int stock 500; private final Object lock new Object(); public void deduct() { synchronized (lock) { if (stock 0) { try { Thread.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } stock--; System.out.println(扣减成功剩余库存 stock); } else { System.out.println(库存不足); } } } }方案二的锁粒度更小只同步需要保护的共享变量操作部分把耗时业务逻辑移出锁外public void deductV2() { // 模拟耗时校验逻辑不需要加锁 boolean needDeduct checkSomething(); if (!needDeduct) { return; } synchronized (lock) { if (stock 0) { stock--; } else { System.out.println(库存不足); return; } } // 记录日志、发送通知等操作不需要加锁 notifySystem(); }方案三在锁竞争相对激烈的场景下匿名引入一个专门的锁对象避免直接用this或StockService.classpublic class StockService { private int stock 500; private final byte[] lock new byte[0]; public void deductV3() { synchronized (lock) { if (stock 0) { stock--; } } } }方案一最简单但锁范围太大导致本可以并发的业务操作全部串行化方案二把真正的临界区压缩到最小并发度更高方案三的设计思路是锁对象专一、不跟业务对象绑死防止外部代码意外拿这个实例做锁造成串锁。在实际项目中我推荐记住一个原则锁的粒度越大代码越不容易出错但性能越差锁的粒度越小并发性能越好但对临界区边界的判断要格外小心。我的做法是先保证正确性、用偏大的粒度上线压测发现确实存在锁竞争瓶颈之后再逐步缩小粒度。5.3 用wait/notify配合synchronized实现线程协作synchronized在实现互斥的同时还经常和Object类的wait()、notify()方法配合实现线程间协作。wait/notify有一个硬性要求必须在拥有对象锁的线程中调用否则抛出IllegalMonitorStateException。经典的消费者-生产者模式是这样实现的public class ProducerConsumerDemo { private final QueueInteger queue new LinkedList(); private final int capacity 5; private final Object lock new Object(); public void produce(int item) throws InterruptedException { synchronized (lock) { while (queue.size() capacity) { // 队列已满生产者等待 lock.wait(); } queue.offer(item); System.out.println(生产 item); // 唤醒等待中的消费者 lock.notifyAll(); } } public void consume() throws InterruptedException { synchronized (lock) { while (queue.isEmpty()) { // 队列为空消费者等待 lock.wait(); } int item queue.poll(); System.out.println(消费 item); lock.notifyAll(); } } }这段代码里有两个细节值得注意。第一判断条件用while而不是if防止线程被唤醒后发现条件又不满足了比如多个消费者被同时唤醒必须重新校验第二使用notifyAll而不是notify避免只唤醒了一个线程而唤醒的恰好是同类线程导致互相等待的局面典型场景生产者唤醒的是另一个生产者但队列已满对方又继续等待消费者就永远排不到。5.4 锁的选择什么时候用synchronized什么时候换ReentrantLock接触并发一段时间后必然会遇到synchronized和ReentrantLock的选型问题。我给一个简洁的对照表能力项synchronizedReentrantLock锁的获取简单自动加锁解锁需要手动lock/unlock通常配try-finally可中断性不支持支持lockInterruptibly()超时等待不支持支持tryLock(timeout, TimeUnit)公平性非公平可在构造器指定公平模式绑定多个条件不支持支持newCondition()创建多个Condition异常释放同步块结束自动释放即使抛异常必须手动unlock否则容易死锁synchronized在异常场景下的表现是它的一个显著优势不管同步块内抛出什么异常JVM都会保证锁被释放。ReentrantLock必须写在finally块中unlock一旦遗漏就会造成其他线程永久阻塞。我见过生产事故就是因为某位同事在ReentrantLock的lock和unlock之间写了一段可能抛异常的代码又忘了包try-finally导致锁无法释放请求全部挂起。从性能角度看现代JDK里synchronized经过锁升级优化后和ReentrantLock的差距已经很小。真正决定选型的应该是功能需求你需要超时获取锁、可中断获取锁、公平排队或者多个条件变量时选ReentrantLock其他常规互斥场景优先用synchronized代码更简洁也更不容易出错。6. 常见问题与排查技巧实录6.1 高频问题速查表以下这些问题是我在实际代码审查和线上排查中反复遇到的整理成速查表方便你对照定位现象可能原因解决方案加了synchronized仍然线程不安全锁的不是同一个对象比如不同实例调用同步实例方法或者部分方法加了锁、部分没加统一锁对象建议使用私有final锁对象两个无关模块之间出现锁互斥锁对象用了字符串字面量或Integer缓存值换用私有Object实例做锁线上的请求偶尔大量卡死锁粒度过大或者某线程持锁时间过长比如持锁执行网络IO缩小临界区范围把耗时操作移出锁外死锁无法恢复多个线程以不同顺序获取多把锁统一加锁顺序或者使用tryLock超时机制线程永远不释放锁ReentrantLock忘记unlock或同步方法中死循环检查加解锁配对排查业务死循环6.2 死锁的经典场景与排查方式死锁的经典场景是“两个线程各自持有一把锁并等待对方手里的锁”。下面用最典型的代码来说明public class DeadlockDemo { private final Object lockA new Object(); private final Object lockB new Object(); public void methodOne() { synchronized (lockA) { System.out.println(线程1持有锁A等待锁B); synchronized (lockB) { System.out.println(线程1持有锁A和锁B); } } } public void methodTwo() { synchronized (lockB) { System.out.println(线程2持有锁B等待锁A); synchronized (lockA) { System.out.println(线程2持有锁B和锁A); } } } }如果线程1执行methodOne线程2执行methodTwo就有很大的概率死锁线程1等B线程2等A谁也不让谁。排查死锁最快的方式是执行测试代码之后用jstack抓取线程dump。我建议记住一个标准操作流程先jps找出目标Java进程的PID再执行jstack PID在dump文件里搜索“deadlock”或者“Found one Java-level deadlock”关键词JVM会直接列出死锁的线程栈以及它们各自持有哪些锁、正在等待哪些锁。这个信息非常清晰基本不需要人工推断。对付死锁最好的办法还是预防。我的经验是两条第一全局约定加锁顺序完全一致比如所有需要同时拿多把锁的地方都严格按照同样的顺序比如先A再B、再C彻底消除循环等待的条件第二培养用带超时的加锁方式的意识如果一定时间内拿不到锁就直接放弃并重试而不是无限等下去。6.3 锁的“假失效”为什么加了synchronized还是有问题“加了synchronized还是有问题”是论坛里最常见的求助帖标题。大部分情况下不是synchronized没用而是锁的对象选错了。看下面这段经典错误代码public class WrongDemo { private Integer count 0; public void add() { synchronized (count) { count; } } }这段代码的问题有两层。第一层每次执行countInteger对象都会变成一个新的Integer实例从128开始Integer是不复用缓存的0会复用缓存但从语义上讲对象引用被重新赋值了意味着每次加锁锁的都是一个全新的对象锁自然失效第二层由于Integer对象引用在变化不同线程各自可能拿着不同的对象当锁完全谈不上互斥。正确的做法是用单独的Object锁对象或者用AtomicInteger替换。另一种“假失效”的情况是多个方法分散在不同类中各自用了自己的锁对象表面上都加了synchronized实际上互不干涉。比如订单服务和库存服务都定义了自己的锁对象但订单和库存之间存在联动逻辑必须用同一个锁来控制时两把锁就聊不到一起去。这种问题在分布式微服务里尤其容易混淆。单体应用靠synchronized共享同一把锁就能解决一旦拆成多个服务进程synchronized就无能为力了因为每个JVM各自拥有一套锁机制这时就需要引入分布式的锁方案如基于数据库、Redis或ZooKeeper的实现。6.4 锁与第三方框架的隐性问题事务、缓存与分布式场景最后说一个相对冷门但很重要的场景synchronized遇到Spring事务管理。Spring的Transactional默认基于AOP代理实现事务控制事务的提交发生在方法返回之前、代理方法收尾阶段。如果在事务方法内部用synchronized保护一段代码锁的释放时机同步块结束和事务提交时机方法返回并不是同一个时刻。换句话说先释放了锁事务还没提交另一个线程拿到锁进来读取到的还是旧数据读的是未提交事务的数据或者隔离级别导致的不一致数据。这种问题的触发条件比较苛刻需要事务隔离级别设置得比较低如读未提交或者事务里执行了复杂的多表更新。但既然存在这个风险我的建议是尽量不要在事务方法里用线程锁来控制业务数据的一致性。事务本身在更高层面上保证了数据库操作的一致性线程锁要管的是JVM内存中的共享数据。两者关注的对象不同混在一起用反而容易产生隐性bug。另一个相关场景是synchronized只对单个JVM实例内的线程生效如果应用部署了多个实例集群部署同一个库存扣减请求被负载均衡分发到不同实例上每个实例各自有锁全局来看互斥性完全失效。这时就必须升级到分布式锁方案。这也能解释为什么很多中小团队在单实例部署时一切正常一上容器化、一扩机器并发正确性就崩了。7. 写在最后的几个经验每次写完synchronized相关的内容我都会想到自己刚入门时踩的那些坑。那时候理解不深只知道“加锁就能安全”结果在不同实例上分别加锁线程问题照样出现查了两天才明白是锁对象选错了。从那次开始我养成了一个习惯写并发代码前先花三十秒想清楚“锁的是谁、谁跟谁互斥、锁的范围到底该多大”。这个习惯救了我很多次。如果你刚接触synchronized不用急着把所有细节都背下来先把三种用法和锁对象一致性这两点彻底吃透日常开发就够用了。等到项目里真正遇到性能瓶颈或诡异并发问题时再回过头看锁升级机制、锁粒度优化、与显式锁的对比你会有完全不同的感悟。这套从浅入深的学习路径也适用于Java并发领域其他知识点的学习。希望这篇文章能帮你少走一些弯路睡觉前多留出一点思考时间给“为什么”下一次遇到并发bug时会轻松很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑