如何优化循环中的频繁锁操作,JVM如何实现锁粗化以减少锁粒度?
- 内容介绍
- 相关推荐
本文共计957个文字,预计阅读时间需要4分钟。
锁粗化不是开发者手动调整的策略,而是+JVM的+JIT编译器(主要是HotSpot的C2编译器)在运行时识别特定代码模式后,自动将多个细粒度加/解锁操作合并成一次更大范围同步的过程。它针对的是同一把锁、同一线程、连续执行、中间无干扰的场景,常见于循环体内部对共享对象的重复同步调用。
锁粗化在循环中的典型触发条件
当循环体内部每次迭代都对同一个锁对象执行 synchronized 块,且满足以下全部条件时,JIT 才可能将其粗化:
- 锁对象是局部变量或逃逸分析判定为未逃逸的对象(如
new Object()或final字段) - 循环内没有方法调用可能引发锁语义变化(例如
obj.wait()、obj.notify(),或任何可能被重写的同步方法) - 循环中没有条件分支(
if、switch)、异常处理(try-catch)或跳出逻辑(break、return)打断同步流 - 循环体内的操作不涉及将锁对象暴露给其他线程(比如存入 static 集合、传给 logger、放入 ThreadLocal 以外的容器)
为什么 StringBuffer.append() 在循环里常被粗化
StringBuffer 的 append() 是同步方法,下面这段代码是锁粗化的经典示例:
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 100; i++) {
sb.append("a");
}
return sb.toString();
}
JIT 会发现:100 次 sb.append() 实际上是对同一个 sb 对象重复加锁释放。由于 sb 是局部变量、未逃逸、循环中无分支无调用(append 内部被内联),C2 编译器就可能把整个循环包裹进一个大的 synchronized(sb) 块——等效于只加锁一次、执行完所有 append、再解锁一次。
实际效果与注意事项
粗化后性能提升明显,但代价是锁持有时间变长,可能加剧其他线程的等待。因此 JIT 对循环内的粗化非常谨慎:
- 它通常不会把「循环体本身」直接粗化,而是把循环内多个相邻的独立
synchronized块合并(例如三个分开写的synchronized(sb) { sb.append(...); }) - 一旦循环中出现
sb.toString()这类可能触发 monitor 退出语义的方法,或插入日志、计数器更新等非纯操作,粗化就会失效 - 冷启动、短命程序、低频调用方法根本等不到 C2 编译,所以本地简单 for 循环测试往往看不到效果;需确保方法被调用超 10000 次(默认阈值),并启用
-XX:+PrintCompilation观察编译日志中是否出现coarsened
怎么确认你的代码被粗化了
不能靠肉眼或单次执行耗时判断。有效方式是:
- 添加 JVM 参数:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining - 让程序稳定运行足够久(比如压测几分钟),观察输出日志中对应方法名是否带
coarsened或lock coarsening字样 - 配合 JMH 做微基准测试,关闭预热干扰,对比开启/关闭
-XX:-EliminateLocks(也控制粗化)前后的吞吐量差异
本文共计957个文字,预计阅读时间需要4分钟。
锁粗化不是开发者手动调整的策略,而是+JVM的+JIT编译器(主要是HotSpot的C2编译器)在运行时识别特定代码模式后,自动将多个细粒度加/解锁操作合并成一次更大范围同步的过程。它针对的是同一把锁、同一线程、连续执行、中间无干扰的场景,常见于循环体内部对共享对象的重复同步调用。
锁粗化在循环中的典型触发条件
当循环体内部每次迭代都对同一个锁对象执行 synchronized 块,且满足以下全部条件时,JIT 才可能将其粗化:
- 锁对象是局部变量或逃逸分析判定为未逃逸的对象(如
new Object()或final字段) - 循环内没有方法调用可能引发锁语义变化(例如
obj.wait()、obj.notify(),或任何可能被重写的同步方法) - 循环中没有条件分支(
if、switch)、异常处理(try-catch)或跳出逻辑(break、return)打断同步流 - 循环体内的操作不涉及将锁对象暴露给其他线程(比如存入 static 集合、传给 logger、放入 ThreadLocal 以外的容器)
为什么 StringBuffer.append() 在循环里常被粗化
StringBuffer 的 append() 是同步方法,下面这段代码是锁粗化的经典示例:
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 100; i++) {
sb.append("a");
}
return sb.toString();
}
JIT 会发现:100 次 sb.append() 实际上是对同一个 sb 对象重复加锁释放。由于 sb 是局部变量、未逃逸、循环中无分支无调用(append 内部被内联),C2 编译器就可能把整个循环包裹进一个大的 synchronized(sb) 块——等效于只加锁一次、执行完所有 append、再解锁一次。
实际效果与注意事项
粗化后性能提升明显,但代价是锁持有时间变长,可能加剧其他线程的等待。因此 JIT 对循环内的粗化非常谨慎:
- 它通常不会把「循环体本身」直接粗化,而是把循环内多个相邻的独立
synchronized块合并(例如三个分开写的synchronized(sb) { sb.append(...); }) - 一旦循环中出现
sb.toString()这类可能触发 monitor 退出语义的方法,或插入日志、计数器更新等非纯操作,粗化就会失效 - 冷启动、短命程序、低频调用方法根本等不到 C2 编译,所以本地简单 for 循环测试往往看不到效果;需确保方法被调用超 10000 次(默认阈值),并启用
-XX:+PrintCompilation观察编译日志中是否出现coarsened
怎么确认你的代码被粗化了
不能靠肉眼或单次执行耗时判断。有效方式是:
- 添加 JVM 参数:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining - 让程序稳定运行足够久(比如压测几分钟),观察输出日志中对应方法名是否带
coarsened或lock coarsening字样 - 配合 JMH 做微基准测试,关闭预热干扰,对比开启/关闭
-XX:-EliminateLocks(也控制粗化)前后的吞吐量差异

