ARTICLE DETAIL

资讯详情

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

synchronized 原理与实践:锁对象、锁升级与性能排查

synchronized 原理与实践:锁对象、锁升级与性能排查 synchronized 这个关键字Java 开发者应该都见过但说句实话很多人对它是一知半解的状态会用能写但真要问一句“synchronized 到底锁的是什么”“偏向锁和轻量级锁什么关系”“为什么有时候加了锁还是出问题”能答清楚的人真不多。这篇文章我就以一线编码的视角把 synchronized 从头到尾捋一遍。不搞那种教科书式的长篇大论而是把关键的原理、实际的写法、容易踩的坑、排查问题的思路全部揉碎了讲清楚。不管你是刚接触并发的初学者还是写了几年 Java 想查漏补缺的老手这篇文章都应该能让你有些收获。1. 先搞懂 synchronized 到底锁住了什么1.1 从一次并发事故说起我先讲一个我实际遇到过的案例。之前做一个库存扣减接口代码很简单就是从数据库查出库存减一再写回去。单机跑的时候一点问题没有结果一压测库存直接变成负数了。问题出在哪两个线程同时读到库存是 10都做了减一操作再同时写回 9。等于说卖了两件商品库存只减了一件。这就是典型的并发问题——多个线程同时读写共享数据导致结果不符合预期。当时同事的解决方案就是在减库存的方法上加上 synchronized问题瞬间消失了。这也引出了 synchronized 最核心的作用保证同一时刻只有一个线程可以执行被锁住的代码块从而保证共享数据的线程安全。实际上 synchronized 不只是 Java 特有的它几乎是所有编程语言在并发领域都会涉及的基础概念。在操作系统层面这叫互斥锁在数据库层面这叫行锁或表锁。Java 在语法层面直接内置了这个关键字让开发者不需要借助任何外部库就能实现基本的线程同步。1.2 synchronized 是锁但锁的是“对象”而不是“代码”这是新手最容易搞混的地方。很多人以为 synchronized 锁住的是后面跟的那段代码其实不是。synchronized 锁住的是一个对象或者更准确地说是通过一个对象的监视器锁Monitor Lock来实现互斥。这里可以打一个比方。想象一个公共厕所只有一个坑位。synchronized 锁住的不是“上厕所”这个动作而是那个坑位本身。谁先进去就把门锁上其他人只能在门口排队。等里面的人出来了下一个人才能进去。在 JVM 层面每个对象都有一个监视器锁Monitor这个锁的信息就存在对象头里。当一个线程进入 synchronized 代码块时它必须先拿到这个对象的监视器锁拿不到就阻塞等待。拿到锁的线程执行完后释放锁其他线程才有机会竞争。所以你在写 synchronized 的时候一定要想清楚一个问题你锁的对象是谁多个线程竞争的是不是同一个对象的锁如果两个线程锁的不是同一个对象那 synchronized 就形同虚设该出问题还是出问题。1.3 一个经典的反面教材我见过不少人这样写public void decreaseStock() { synchronized (new Object()) { // 扣减库存逻辑 } }每次方法调用了都 new 一个新对象然后锁这个新对象这等于每次都是拿一把新锁压根起不到互斥效果。正确的做法是锁一个大家共享的对象比如把锁对象定义成成员变量或静态变量或者直接锁 this或者用 Class 对象。还有更隐蔽的错误是锁字符串常量或 Integer 对象。字符串常量在 JVM 里是有常量池的内容相同的字符串其实指向同一个对象锁它可能造成无意义的全局锁Integer 在 -128 到 127 之间有缓存如果拿它当锁多个不相关的模块可能因为用了同一个 Integer 对象而互相阻塞。这些坑我在后面的章节会详细讲。2. synchronized 的三种用法与真实区别2.1 实例方法锁的是 this这是最简单的用法直接在方法声明上加 synchronizedpublic class StockService { private int stock 10; public synchronized void decrease() { if (stock 0) { stock--; } } }这种写法锁的是当前实例对象也就是 this。多个线程同时调用同一个 StockService 实例的 decrease 方法时会互斥执行。但有一个问题如果系统里存在多个 StockService 实例比如多例模式或者每个请求都 new 一个那锁就失效了因为不同实例对应不同的锁对象。实际开发中Spring 管理的 Bean 默认是单例的所以把 synchronized 加在 Service 方法上锁的是这个单例 Bean 的实例能正常工作。但如果你手贱把 Bean 配成了多例那就等着出事故吧。2.2 静态方法锁的是 Class 对象静态方法的 synchronized 锁的不是实例而是当前类的 Class 对象public class StockService { private static int stock 10; public static synchronized void decrease() { if (stock 0) { stock--; } } }Class 对象在 JVM 里是全局唯一的所以静态 synchronized 方法天然就是全局锁不管你有多少个实例它们都会竞争同一把锁。这种方式适合操作静态变量或全局共享资源的场景。需要特别注意的是同一个类里如果既有 synchronized 实例方法又有 synchronized 静态方法它们之间不会互斥。因为一个锁的是 this一个锁的是 Class 对象两把不同的锁各管各的。很多人在这里会踩坑以为类里所有 synchronized 方法都是互斥的其实不是。2.3 同步代码块精细控制锁的范围同步代码块是最灵活的写法它可以指定锁对象也可以控制锁的范围public class StockService { private final Object lock new Object(); private int stock 10; public void decrease() { // 这里可以放不需要同步的逻辑 System.out.println(before lock); synchronized (lock) { if (stock 0) { stock--; } } // 这里也可以放不需要同步的逻辑 System.out.println(after lock); } }这种写法的好处有两个。第一锁对象可以自己指定想锁谁就锁谁第二锁的范围可以控制得尽可能小只包住真正需要原子操作的代码。锁的范围越小线程竞争的概率越低并发性能越好。有一种常见的优化手段叫“锁粒度细化”。比如一个方法里有读操作也有写操作读操作其实不需要每次都加锁那就可以把锁只放在写操作周围读操作完全不加锁或者用读写锁。synchronized 不支持读写锁但你可以通过控制代码块范围来减少锁持有时间。2.4 synchronized 是可重入锁这里讲一个容易被忽略但很重要的特性synchronized 是可重入的。意思是一个线程已经持有了某把锁它可以再次进入任何由这把锁保护的代码块。public synchronized void methodA() { // 做一些事情 methodB(); } public synchronized void methodB() { // 做另一些事情 }假设 methodA 和 methodB 都是 synchronized 方法锁的都是 this。线程 t1 进入 methodA 时拿到了 this 的锁然后调用 methodB此时它还要再拿一次 this 的锁。如果 synchronized 不可重入这里就直接死锁了。正是因为可重入同一个线程可以反复进入被同一把锁保护的代码段。JVM 在实现可重入时会在锁对象上记录持有线程和重入次数。每次进入重入次数加一每次退出重入次数减一减到零才真正释放锁。理解这一点对后面分析锁升级机制很重要。3. 锁升级机制从偏向锁到重量级锁的完整链路3.1 why为什么会有多种锁状态早期的 synchronized 是纯重量级锁每次加锁解锁都要通过操作系统层面的互斥原语来实现线程阻塞和唤醒涉及用户态与内核态的切换开销非常大。这也是为什么很多并发编程的书都说“synchronized 性能差能用 Lock 就别用 synchronized”。但那是老黄历了。JDK 1.6 之后HotSpot 虚拟机对 synchronized 做了一波大优化引入了锁升级机制。根据锁的竞争激烈程度锁会在四种状态之间晋升无锁、偏向锁、轻量级锁、重量级锁。这四种状态的本质目的就是尽量减少加锁解锁的开销。很多场景下锁其实是被同一个线程反复获取的或者多个线程是交替执行的根本不存在真正的竞争。如果每次都走重量级锁那就是杀鸡用牛刀。3.2 偏向锁同一个线程反复获取锁的场景偏向锁针对的场景是锁总是被同一个线程获取很少甚至从来没有其他线程来竞争。偏向锁的思想是“偏心”——锁会偏向于第一个获取它的线程。当线程第一次获取锁时JVM 会在对象头中记录这个线程的 ID。之后这个线程再次进入同步块时只需要检查对象头里的线程 ID 是不是自己如果是直接进入不需要任何 CAS 操作开销几乎为零。如果另一个线程来竞争偏向锁就会被撤销revoke升级为轻量级锁。注意偏向锁的撤销需要等待一个安全点Safe Point在这个时间点上没有线程在执行字节码所以撤销操作要额外停顿一下这也是偏向锁的一个开销来源。所以不要以为开启了偏向锁就一定快在高竞争场景下偏向锁的撤销反而会增加性能负担。JDK 15 之后偏向锁被默认禁用JDK 17 中已经被彻底标记为废弃。原因就是现代应用大多是多线程高竞争的偏向锁带来的收益不明显反而增加了复杂的撤销逻辑。不过理解偏向锁的原理对理解整个锁升级模型还是有帮助的。3.3 轻量级锁多线程交替执行的场景当偏向锁被撤销后锁会升级为轻量级锁。轻量级锁面向的场景是多个线程交替进入临界区不存在长时间的竞争基本能做到“你执行完了我再来”。轻量级锁的实现依赖于 CASCompare And Swap操作。线程进入同步块时先在栈帧中创建一份锁记录Lock Record然后把对象头中的 Mark Word 拷贝到锁记录中再用 CAS 尝试把对象头中的 Mark Word 替换为指向锁记录的指针。如果 CAS 成功说明拿到了锁如果失败说明有其他线程正在持有锁当前线程就进入自旋等待。自旋就是让线程“原地打转”反复检查锁是否被释放。自旋不是无限进行的JDK 1.6 引入了自适应自旋JVM 会根据上一次自旋等待的结果动态调整自旋次数避免 CPU 空转太久。3.4 重量级锁真正竞争激烈的场景如果自旋等待也解决不了问题锁就会升级为重量级锁。这是最传统的实现方式线程获取不到锁时会进入阻塞状态不再消耗 CPU由操作系统来调度唤醒。重量级锁的底层依赖于操作系统的互斥量Mutex。线程阻塞和唤醒时会发生用户态与内核态的切换每次切换的开销都不小。这也是重量级锁性能差的根本原因——不是锁本身慢而是“阻塞唤醒”这个过程慢。这里有一个关键点需要理解锁升级是单向的只能从低到高不能降级。也就是说一旦锁升级为重量级锁它就一直是重量级锁不会因为竞争变小而降回轻量级锁。所以在设计高并发系统时要尽量避免长时间持锁或频繁竞争同一把锁否则锁一旦升级性能就会持续受损。3.5 锁状态与对象头的关系关于锁状态这里需要补充一点字节层面的知识。在 HotSpot 虚拟机中对象在内存中的布局分为三部分对象头Header、实例数据Instance Data和对齐填充Padding。锁信息就存储在对象头的 Mark Word 中。Mark Word 是一块可变长度的数据它会根据对象状态复用存储空间。在 64 位 JVM 中Mark Word 是 64 位其中最后两位用于表示锁状态01 表示无锁或偏向锁00 表示轻量级锁10 表示重量级锁11 表示 GC 标记。这就是为什么锁升级后对象头里的信息会发生变化。这里我不打算把位布局展开讲得太细因为对于绝大多数开发者来说知道“锁状态存在对象头里不同锁状态对应不同的存储结构”就够用了。如果你在排查性能问题时需要深入 JVM 底层再去翻 HotSpot 源码或《深入理解 Java 虚拟机》相关章节也不迟。4. synchronized 在实际开发中的进阶用法与对比4.1 锁对象的经典选择与避坑前面提到过锁对象的选择直接决定了 synchronized 能不能生效。这里整理一份清单覆盖常见的锁对象选择情况和注意事项。第一锁this。适用于实例方法锁当前对象。如果当前类是单例的没问题如果类被多次实例化锁就会失效。第二锁 Class 对象。适用于静态方法或静态变量场景锁全局唯一一定生效但粒度可能过大。第三锁私有 final 对象。这是比较推荐的写法因为私有对象不会被外部持有不会因为外部直接 synchronized(你暴露的对象) 而与你产生意外的锁竞争。还有一种做法是锁Integer或Long这类包装类型这里面坑最大。举例来说public class Counter { private Integer count 0; public void increment() { synchronized (count) { count; } } }这段代码看起来没问题但你仔细分析会发现count 这个操作会创建一个新的 Integer 对象赋给 count。也就是说第二次进入 increment 时synchronized (count) 锁住的对象已经是新的 Integer 对象了跟第一次锁的对象完全不一样。这把锁没有任何互斥效果。正确的写法应该是用一个专门的 Object 对象做锁或者直接锁 this。锁对象不能是会被修改的对象这个原则一定要记住。还有一个常见的误区是锁字符串private final String LOCK LOCK;虽然这里 LOCK 是 final 的不会重新赋值但问题是字符串字面量在 JVM 中会被驻留intern常量池中“LOCK”这个字符串是唯一的一份。如果代码其他模块也有一个相同的字符串常量并把它当锁用两个完全不相关的业务就会互相阻塞。为了避免这种无意义的耦合锁对象一定要用互不共享的独立对象。4.2 synchronized 与 volatile 的分工提到 synchronized就绕不开 volatile。volatile 是另一个并发关键字但它做的事情和 synchronized 完全不一样。volatile 有两个核心语义可见性和禁止指令重排序。可见性指的一个线程修改了 volatile 变量的值其他线程能立刻看到最新的值。禁止指令重排序说的是编译器和 CPU 不会为了优化而把 volatile 变量相关的读写操作乱序执行。synchronized 则同时保证了原子性、可见性和有序性。原子性是最关键的差异volatile 不保证原子性比如 count 这个操作用 volatile 修饰 count 也解决不了并发问题因为 count 本身不是一个原子操作它包含“读取-加一-写回”三步volatile 只能保证这三步的可见性但不能保证三步不可分割。所以 volatile 适合的场景是一个变量被多个线程读写但写操作不依赖当前值。典型例子是状态开关public class Server { private volatile boolean running true; public void stop() { running false; } public void run() { while (running) { // 处理请求 } } }这里 running 只在 stop 方法中被改写不依赖旧值用 volatile 就够了不需要 synchronized 的重量级保证。但如果你要做“点一下按钮计数器加一”这种操作就必须用 synchronized 或 AtomicInteger。4.3 synchronized 与 ReentrantLock 的取舍很多人会问有了 synchronized为什么还要有 ReentrantLock它们到底怎么选先说结论在大多数场景下synchronized 都够用而且它还在不断优化。只有在需要高级功能时才需要考虑 ReentrantLock。ReentrantLock 相比 synchronized 的几个差异点第一可中断。ReentrantLock 的 lockInterruptibly 方法可以让等待锁的线程响应中断而 synchronized 一旦进入等待只能等锁释放无法主动中断。第二可超时。tryLock(timeout) 可以在指定时间内尝试获取锁拿不到就放弃避免因为死锁导致无限等待。synchronized 做不到。第三公平锁。ReentrantLock 可以指定公平模式让等待时间最长的线程先获取锁synchronized 是非公平的。第四多个条件变量。ReentrantLock 可以创建多个 Condition实现更精细的等待通知机制synchronized 只能配合 wait/notify而且只有一个条件队列。但也要看到ReentrantLock 需要手动加锁和解锁如果忘记在 finally 中解锁就会出现难以排查的死锁问题。synchronized 由 JVM 自动管理锁的释放即使代码抛异常也能保证锁被释放这一点对开发体验来说非常友好。所以我的建议是业务代码中优先考虑 synchronized简洁且不易出错只有明确需要可中断、可超时或公平锁等功能时才换 ReentrantLock。同步工具包 java.util.concurrent 里还有很多其他同步器比如 CountDownLatch、Semaphore、CyclicBarrier它们解决的问题各不相同后面有机会再单独开篇讲。4.4 锁的粒度控制粗化与细化关于锁的性能有一个核心原则锁的粒度要尽可能小锁的持有时间要尽可能短。锁的粒度指的是锁保护的数据范围。比如你有一个 Map 存了很多商品信息如果对整个 Map 加锁所有对 Map 的读写都会排队这显然不合理。更好的做法是用 ConcurrentHashMap它内部通过分段锁或 CAS 来实现细粒度的并发控制读操作基本无锁写操作只锁对应桶。锁的持有时间指的是从加锁到解锁的这段耗时。要避免在持锁状态下做耗时操作比如网络请求、数据库查询、磁盘 IO。这些操作的耗时是毫秒甚至秒级的一旦在锁内执行其他线程就都得干等着。举个反例public synchronized void process(Order order) { // 1. 校验订单耗时少 // 2. 远程调用库存服务耗时多 // 3. 写数据库耗时多 }这个 synchronized 方法把远程调用和数据库操作都包在了锁里假设这两个操作平均耗时 500ms那这个同步方法的吞吐量就只有每秒 2 次性能惨不忍睹。改进的方法是把锁的范围缩小public void process(Order order) { // 1. 校验订单 // 2. 远程调用库存服务不加锁 // 3. 同步扣减本地库存加锁 synchronized (lock) { stock--; } // 4. 写数据库不加锁 }只把真正涉及共享变量操作的代码放到锁里其他耗时操作全部放到锁外。优化后的吞吐量可能提升几十倍不止。所以在设计同步方案时先问自己三个问题哪些代码必须原子执行共享变量是谁能不能把不相关的操作移出锁块5. 线程等待与通知机制wait、notify 的正确打开方式5.1 为什么需要 wait 和 notifysynchronized 解决的是互斥问题但实际业务中还有一个更复杂的场景线程之间需要相互协作。比如生产者-消费者模型生产者生产数据消费者消费数据。当缓冲区满了生产者要停下来等待当缓冲区空了消费者要停下来等待。如果只用 synchronized线程只能互斥访问缓冲区但没办法在条件不满足时主动让出 CPU也没办法在条件满足时通知对方。这时候就需要 wait 和 notify。这两个方法都是 Object 类的方法它们的调用前提是当前线程必须持有调用对象的监视器锁。也就是说wait 和 notify 必须放在 synchronized 代码块中调用否则会抛出 IllegalMonitorStateException。5.2 一个标准的 wait/notify 模板来看一个经典的生产者消费者实现public class MessageQueue { private final LinkedListString queue new LinkedList(); private final int capacity 10; public synchronized void put(String message) throws InterruptedException { while (queue.size() capacity) { wait(); } queue.add(message); notifyAll(); } public synchronized String take() throws InterruptedException { while (queue.isEmpty()) { wait(); } String message queue.removeFirst(); notifyAll(); return message; } }这里有两个关键点需要特别注意。第一等待条件必须用 while 而不是 if。这是因为线程被 notify 唤醒后它会重新尝试获取锁但此时条件可能已经不满足了比如另一个线程抢先消费了消息队列又空了。如果用 if线程被唤醒后会直接往下执行读到的数据可能是空的或者写入时队列已满。用 while 可以在唤醒后重新检查条件不满足就继续等待这是标准的“线程安全”写法。第二使用 notifyAll 而不是 notify。notify 只会唤醒一个等待线程如果被唤醒的线程发现条件还是不满足它会继续等待而其他可能满足条件的线程却没有被唤醒就会造成“信号丢失”或“线程饿死”。notifyAll 唤醒所有等待线程让它们重新竞争锁并检查条件虽然会有一些不必要的竞争但换来的是安全性。这里的 wait/notify 跟 ReentrantLock 的 Condition 是一一对应的。Condition 的 await 对应 waitsignal 对应 notifysignalAll 对应 notifyAll。Condition 的优势是可以创建多个条件队列比如生产者等待“不满”条件消费者等待“不空”条件两者互不干扰比单一的 wait/notify 更精细。5.3 为什么 wait 会释放锁这是个经常被问到的面试题线程在 wait 的时候为什么必须释放锁答案很直接如果不释放锁那消费者线程永远没有机会获取锁去消费数据生产者等待的条件永远不可能满足。这就会造成死锁。wait 方法的语义就是当前线程让出监视器锁进入等待集合Wait Set直到其他线程调用 notify 或 notifyAll 将其唤醒。唤醒后它并不是立即执行而是重新与其他线程竞争锁竞争成功后从 wait 之后的代码继续执行。理解这一点你就能明白为什么 wait 和 sleep 是两种完全不同的东西。sleep 是不会释放锁的它只是让线程暂停执行一段时间锁还捏在自己手里。所以你在 synchronized 块里调 sleep其他线程依然进不来只能干等。wait 则会释放锁让其他线程有机会执行。5.4 千万不要在循环外调用 wait我在代码评审中见过不止一次的错误写法public synchronized void put(String message) throws InterruptedException { if (queue.size() capacity) { wait(); } queue.add(message); notifyAll(); }看起来逻辑差不多但用 if 代替 while 在并发环境下有很大隐患。假设队列满了线程 A 和线程 B 同时进入 put 方法。A 先拿到锁执行 wait 释放锁然后 B 拿到锁也执行 wait 释放锁。此时消费者 C 消费了一条消息调用 notifyAllA 和 B 同时被唤醒。A 抢到锁往队列里加了一条队列又满了。B 抢到锁此时如果用 if 判断它不会再检查队列状态直接往已经满了的队列里塞数据就出错了。用 while 重写就能彻底解决这个问题所以别在这上面省代码。这也是 Java 官方文档里明确推荐的标准模式。6. 实际开发中常见的与 synchronized 相关的坑6.1 锁字符串常量引发的“串锁”问题我线上遇到过一件非常诡异的事情某两个完全无关的模块在压测时出现大量线程阻塞。查了堆栈发现两个线程卡在了不同类的 synchronized 块中但这两个类没有任何关联。排查到最后发现两个模块都用了一个叫LOCK的字符串常量作为锁对象。由于字符串驻留机制JVM 中这两个字符串其实是同一个对象所以两个模块的锁实际上是一把锁导致互相阻塞。解决办法很简单用new Object()创建锁对象每个模块各持有一把互不干扰。如果一定要用字符串也要确保字符串内容完全不同但这种做法隐患很大不建议用。6.2 synchronized 在 Spring Bean 上的误用Spring 的 Bean 默认是单例的所以把 synchronized 加在 Service 方法上通常是有效的。但有几个配置会让锁失效需要留意。第一把 Bean 的作用域改成了 prototype每次注入都会创建新实例这时候 synchronized 方法锁的 this 每个对象都不一样锁就失效了。第二在分布式环境下部署了多个应用实例每个实例有各自的 JVMsynchronized 只能锁住当前 JVM 内的方法执行跨实例的并发控制完全没有效果。这种场景需要引入分布式锁比如基于 Redis 的 Redisson 锁或者 ZooKeeper 锁。我遇到过同事把减库存的接口加上了 synchronized但因为负载均衡把请求分发到了不同节点库存还是被扣成负数了。后面不得不把本地锁换成 Redis 分布式锁才解决。所以使用 synchronized 之前要想清楚一个前提你的并发控制范围是整个 JVM还是跨 JVM 的如果是后者synchronized 解决不了问题。6.3 synchronized 与事务的微妙关系在 Spring 项目中这个问题非常隐蔽。如果你的 synchronized 方法加上 Transactional 注解执行顺序是这样的先进入 synchronized 方法拿到锁然后 Spring 通过 AOP 开启事务执行数据库操作提交事务最后退出 synchronized 方法释放锁。问题出在哪如果事务还没提交锁就释放了另一个线程进入方法它读到的是事务提交前的数据也就是“脏读”。因为前一个事务虽然执行完了数据库操作但事务提交发生在释放锁之前还是之后取决于代码的具体位置。更准确地说Spring 事务的提交时机是在代理方法返回之后、锁释放之后。标准的事务拦截器会在目标方法执行完成后提交事务而 synchronized 锁的释放是在方法 return 时。所以锁的释放要早于事务的提交这就导致下一个线程在锁释放后立刻进入读到的可能还是旧数据。这个问题没有万能的解决方案常见思路包括把 synchronized 和 Transactional 放在不同的类中让锁的范围比事务更大或者使用编程式事务把事务提交放到 synchronized 块内再或者干脆用悲观锁 SELECT FOR UPDATE让数据库层面保证一致性。每种方案都有取舍需要结合业务场景来选择。6.4 synchronized 中调用外部接口导致的长锁阻塞这个问题在微服务架构中尤其常见。假设你在 synchronized 块中调了远程 HTTP 接口如果这个接口响应很慢或者直接超时那持有锁的线程就会一直被卡住其他所有等待该锁的线程全部阻塞。解决思路有几种第一把外部调用移到 synchronized 块之外只把更新共享变量的部分加锁。第二给远程调用设置超时时间避免无限等待。第三不需要强一致性的场景可以考虑用异步化或消息队列削峰填谷避免同步调用阻塞。第四如果必须同步调用且必须持锁可以考虑用 ReentrantLock 的 tryLock 加上超时时间获取不到锁就直接返回失败而不是无限等待。很多人觉得 synchronized 导致性能问题的本质是锁本身慢其实更多的是锁内做了什么操作。锁内的代码越短锁的竞争越小性能自然就上来了。7. 锁相关的性能排查与问题定位经验7.1 CPU 飙高且线程阻塞的排查思路如果在生产环境发现 CPU 使用率很高同时业务响应变慢怀疑是锁竞争的问题可以按以下步骤排查。先执行jps找到 Java 进程 ID然后执行top -Hp pid看进程里哪个线程占用的 CPU 高。记下这个线程的十进制线程 ID转换成十六进制然后执行jstack pid thread.log在日志中搜索这个十六进制线程 ID看它的堆栈停在哪里。如果堆栈中出现了parking to wait for 0x...或者waiting for monitor entry说明线程正在等待锁。再搜索locked 0x...可以找到持有锁的线程在做什么。如果持有锁的线程在做耗时操作外部调用、数据库慢查询那问题基本就定位了。这个过程说起来简单实际操作时要注意 jstack 里线程 ID 的格式是十六进制的而 top 命令显示的是十进制的 pid中间需要转换。比如线程 ID 是 31456转换成十六进制就是 0x7ae0。7.2 死锁的诊断与预防死锁发生的条件有四个互斥、持有并等待、不可剥夺、循环等待。synchronized 本身满足“互斥”和“不可剥夺”所以如果设计不当是可能发生死锁的。典型的死锁场景是线程 A 持有了锁 X正在等待锁 Y线程 B 持有了锁 Y正在等待锁 X。两个线程互相等待谁也不让谁。遇到死锁不要慌JVM 自带的 jstack 就能检测出来。在 jstack 输出的最后如果线程状态中包含Found one Java-level deadlock那就说明检测到了死锁日志中会明确指出哪些线程持有哪些锁、在等待哪些锁。预防死锁的关键是锁的顺序。如果多个线程需要多个锁保证所有线程都按相同的顺序获取锁就能打破循环等待条件。比如 A 总是先锁 X 再锁 YB 也总是先锁 X 再锁 Y就不会出现 A 等 Y 而 B 等 X 的情况。另外使用 ReentrantLock 的 tryLock 可以在尝试获取锁失败后释放已持有的锁并重试这种策略也能有效避免死锁但要注意重试可能带来的活锁问题。7.3 使用 JFR 分析锁竞争JDK 自带的 Java Flight RecorderJFR是一个非常好用的诊断工具它可以在不重启应用的情况下记录锁竞争相关的事件。启动时加上参数-XX:StartFlightRecordingduration60s,filenamerecording.jfr然后在启动器或 JConsole 里把 JFR 文件 dump 出来用 JDK Mission Control 打开在“Lock Instances”或“Thread Contention”标签页可以看到哪些锁竞争最激烈、哪些线程持有锁的时间最长。JFR 的好处是它基于事件记录对应用性能的影响非常小一般小于 1%可以长期开启在问题发生时刚好捕获到现场的锁竞争数据。相比之下jstack 属于采样只能在某个时间点抓一次如果锁竞争是间歇性的可能抓不到。所以在排查偶发的性能问题时JFR 比 jstack 更可靠。7.4 锁竞争压力的压测验证除了事后排查我更推荐在发布前做充分的锁竞争压测。用 JMHJava Microbenchmark Harness可以精确地测试单个方法的锁竞争性能用 JMeter 或 Gatling 可以模拟多线程并发请求看整体吞吐量和响应时间。压测时重点关注几个指标TPS每秒事务数、平均响应时间、P99 响应时间。如果加锁之后 P99 明显变高说明锁竞争比较激烈需要优化锁粒度或换用无锁数据结构。还可以通过调整 JVM 参数观察效果比如-XX:-UseBiasedLocking可以关闭偏向锁-XX:UseSpinning可以调节自旋策略但这些参数在 JDK 高版本中很多已经被调整或废弃了实际作用有限核心还是从代码层面优化。8. 聊聊我对 synchronized 的使用心得与建议做了这么多年 Java 开发我对 synchronized 的感情还是有点复杂的。一方面它在业务代码里出现的频率很高是解决并发问题最直接的工具另一方面很多人只停留在“会用”的层面对它的底层原理和边界限制了解不够踩了不少坑。几个心得分享给大家也是我在项目评审中反复强调的几点。第一能用组合就用组合别什么都上锁。很多场景其实不需要 synchronized用 java.util.concurrent 包下的原子类或并发容器就能解决。比如统计计数用 AtomicInteger缓存用 ConcurrentHashMap队列用 ConcurrentLinkedQueue 或 BlockingQueue。这些工具类内部已经做了很好的并发优化比你手动加锁要高效得多。第二锁的范围能小就小锁的时间能短就短。锁住不必要的代码不仅浪费时间还会放大锁竞争的影响范围。写代码之前先想想这个锁保护的最小范围是多少是否可以把耗时操作移出锁块第三锁对象一定要用私有、final、独立的 Object 实例。这是最安全的做法能避免字符串驻留、Integer 缓存、外部引用等一系列潜在的锁竞争问题。第四面试和实际开发中synchronized 的锁升级过程、wait/notify 的条件循环、synchronized 与 ReentrantLock 的选择这几个点是高频考点。理解原理比背结论重要得多理解了原理你才能灵活应对各种变体问题。最后再分享一个小技巧。当你对一个并发方案没有把握时不要凭感觉设计先用 jstack 或者 JFR 记录一段时间的锁状态用数据说话。很多性能问题不是肉眼能看出来的实际测试下来往往会发现瓶颈点跟你预想的完全不同。这种习惯能帮你避免很多“我以为”的假优化真正把精力花在刀刃上。
返回列表