JAVA synchronized 偏向锁失败频繁导致 Full GC 的排查方式
大家好,今天我们来聊聊一个比较棘手的问题:JAVA synchronized 偏向锁失败频繁导致 Full GC。这个问题在并发量较高的系统中比较常见,排查起来也比较复杂。我会从偏向锁的基本原理开始,逐步深入到问题排查的思路和方法,并通过代码示例来帮助大家更好地理解。
1. 偏向锁:锁优化的第一步
在了解偏向锁失败导致 Full GC 之前,我们先要搞清楚什么是偏向锁,以及它存在的意义。synchronized 关键字在 JDK 6 之后引入了锁升级的概念,旨在减少锁竞争带来的性能损耗。锁升级的路径大致如下:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
偏向锁,顾名思义,就是偏向于第一个获取锁的线程。它的假设是:在大多数情况下,锁不存在多线程竞争,总是由同一个线程反复获取。在这种情况下,每次线程获取和释放锁都进行 CAS 操作是很浪费的。
偏向锁的实现机制是:当一个线程第一次获取锁时,会在对象头(mark word)中记录该线程的 ID。以后该线程再次进入同步块时,只需要判断对象头中的线程 ID 是否与自己的线程 ID 相同,如果相同,则认为该线程获得了锁,不需要进行任何同步操作。
1.1 偏向锁的优势
- 减少 CAS 操作: 在单线程环境下,避免了频繁的 CAS 操作,提高了性能。
- 降低上下文切换: 减少了线程阻塞和唤醒的开销,降低了上下文切换的频率。
1.2 偏向锁的劣势
偏向锁也有其固有的缺点,主要体现在以下两个方面:
- 不适用于高并发场景: 如果多个线程竞争同一个锁,偏向锁会频繁撤销和重偏向,反而会降低性能。
- 撤销开销: 当有其他线程尝试获取偏向锁时,JVM 需要暂停持有偏向锁的线程,并将对象头重置为无锁状态或升级为轻量级锁,这个过程称为偏向锁撤销,开销较大。
2. 偏向锁失效的常见原因
偏向锁失效,也称为偏向锁撤销,是指原本持有偏向锁的线程,由于某些原因,失去了对锁的偏向。常见的失效原因包括:
- 其他线程竞争锁: 这是最常见的原因。当有其他线程尝试获取已被偏向的锁时,JVM 会撤销偏向锁。
- 调用
hashCode()方法: 如果对象已经计算过hashCode(),并且存储在对象头中,那么偏向锁会被撤销。因为偏向锁的信息也存储在对象头中,两者会冲突。 - 进入安全点(Safepoint): 在 GC 期间,JVM 会进入安全点,此时所有线程都必须暂停。如果持有偏向锁的线程在安全点暂停,JVM 会撤销偏向锁。
- 显式禁用或延迟启用偏向锁: JVM 参数可以控制偏向锁的启用和延迟启用。
3. Full GC 的诱因:锁竞争与对象分配
Full GC 的触发条件有很多,但在这里,我们关注的是与偏向锁失效相关的 Full GC。当偏向锁失效频繁发生时,会带来以下问题:
- 锁升级: 偏向锁失效会导致锁升级为轻量级锁甚至重量级锁,增加了锁竞争的开销。
- 对象头修改: 偏向锁的撤销和重偏向需要修改对象头,增加了 CPU 消耗。
- 内存碎片: 频繁的对象分配和回收,尤其是在锁竞争激烈的场景下,容易导致内存碎片。
- 年轻代晋升: 锁竞争可能导致对象在年轻代中存活时间变长,更容易晋升到老年代。
这些因素综合作用,会导致老年代空间快速增长,最终触发 Full GC。
4. 排查思路与步骤
当怀疑偏向锁失效频繁导致 Full GC 时,可以按照以下步骤进行排查:
-
监控 GC 日志: 首先,我们需要开启 GC 日志,观察 Full GC 的频率和持续时间。可以使用以下 JVM 参数:
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:gc.log分析 GC 日志,重点关注以下信息:
- Full GC 的频率:如果 Full GC 频率很高,例如几分钟一次,说明系统存在内存问题。
- Full GC 的持续时间:如果 Full GC 持续时间很长,例如几秒甚至几十秒,说明 GC 的效率很低。
- 老年代空间使用情况:观察老年代空间的使用情况,如果老年代空间增长很快,说明有大量对象晋升到老年代。
-
分析线程 Dump: 使用
jstack命令获取线程 Dump,分析线程的锁竞争情况。jstack <pid> > thread_dump.txt分析线程 Dump 文件,查找以下信息:
- BLOCKED 线程: 查找处于
BLOCKED状态的线程,这些线程正在等待获取锁。 - 持有锁的线程: 确定持有锁的线程,以及它正在执行的任务。
- 锁竞争的热点代码: 找出锁竞争最激烈的代码区域。
- BLOCKED 线程: 查找处于
-
使用 Profiling 工具: 使用专业的 Profiling 工具,例如 JProfiler、YourKit 或 Arthas,可以更详细地分析锁竞争和对象分配情况。
- CPU Profiling: 找出 CPU 消耗最高的代码区域,重点关注
synchronized关键字相关的代码。 - Memory Profiling: 找出对象分配最多的类,以及对象的生命周期。
- Lock Profiling: 分析锁的竞争情况,包括锁的持有者、等待者和竞争次数。
- CPU Profiling: 找出 CPU 消耗最高的代码区域,重点关注
-
分析代码: 根据线程 Dump 和 Profiling 工具的分析结果,定位到锁竞争的热点代码。重点关注以下代码:
- 频繁使用
synchronized关键字的代码: 检查这些代码是否真的需要同步,是否可以使用更细粒度的锁,或者使用无锁数据结构。 - 大量对象分配的代码: 检查这些代码是否可以减少对象分配,例如使用对象池、字符串常量池等。
- 调用
hashCode()方法的代码: 如果对象被用作锁,并且调用了hashCode()方法,考虑避免这种情况,或者使用其他方式来实现同步。
- 频繁使用
-
调整 JVM 参数: 根据分析结果,可以尝试调整 JVM 参数来优化偏向锁的行为。
-
禁用或延迟启用偏向锁: 如果偏向锁失效过于频繁,可以尝试禁用偏向锁,或者延迟启用偏向锁。
-XX:-UseBiasedLocking # 禁用偏向锁 -XX:BiasedLockingStartupDelay=60000 # 延迟启用偏向锁,单位毫秒 -
调整 GC 参数: 根据 Full GC 的情况,可以调整 GC 参数来优化 GC 性能。例如,增加堆大小、调整年轻代和老年代的比例、选择合适的 GC 算法等。
-
-
代码优化:
- 减少锁竞争: 尽可能减少锁的竞争,例如使用更细粒度的锁、读写分离锁、无锁数据结构等。
- 避免不必要的同步: 检查代码,确定是否真的需要同步,如果不需要,则移除
synchronized关键字。 - 减少对象分配: 尽可能减少对象的分配,例如使用对象池、字符串常量池等。
- 避免在锁中使用耗时操作: 避免在
synchronized代码块中执行耗时操作,例如 IO 操作、网络请求等。可以将这些操作移到锁外面执行。
5. 代码示例
下面我们通过一个简单的代码示例来说明偏向锁失效导致 Full GC 的情况。
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class BiasedLockingExample {
private static final int LOOP_COUNT = 1000000;
private static final List<Object> dataList = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
// 预热,让偏向锁生效
for (int i = 0; i < 10000; i++) {
Object obj = new Object();
synchronized (obj) {
// do nothing
}
}
Thread thread1 = new Thread(() -> {
Random random = new Random();
for (int i = 0; i < LOOP_COUNT; i++) {
Object obj = new Object();
synchronized (obj) {
dataList.add(obj);
}
// 模拟一些耗时操作,增加锁竞争
try {
Thread.sleep(random.nextInt(5));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread thread2 = new Thread(() -> {
Random random = new Random();
for (int i = 0; i < LOOP_COUNT; i++) {
Object obj = new Object();
synchronized (obj) {
dataList.add(obj);
}
// 模拟一些耗时操作,增加锁竞争
try {
Thread.sleep(random.nextInt(5));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
long startTime = System.currentTimeMillis();
thread1.start();
thread2.start();
thread1.join();
thread2.join();
long endTime = System.currentTimeMillis();
System.out.println("Time taken: " + (endTime - startTime) + " ms");
System.out.println("Data list size: " + dataList.size());
}
}
在这个示例中,两个线程同时创建对象,并使用 synchronized 关键字进行同步。由于两个线程频繁竞争锁,导致偏向锁失效,锁升级为轻量级锁甚至重量级锁,增加了锁竞争的开销。同时,大量的对象分配也增加了 GC 的压力,最终可能导致 Full GC。
可以通过以下步骤验证:
- 运行代码: 运行上述代码,观察程序的执行时间和内存占用。
- 开启 GC 日志: 使用
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:gc.log参数运行代码,观察 GC 日志。 - 禁用偏向锁: 使用
-XX:-UseBiasedLocking参数重新运行代码,观察程序的执行时间和内存占用,以及 GC 日志。
你会发现,禁用偏向锁后,程序的执行时间可能会缩短,Full GC 的频率也会降低。
6. 总结与建议:理解锁机制,优化代码结构
通过以上分析,我们可以看到,偏向锁失效频繁会导致 Full GC,影响系统的性能。要解决这个问题,需要深入理解锁的机制,分析锁竞争的原因,并采取相应的优化措施。
- 选择合适的锁策略: 根据实际情况选择合适的锁策略,例如使用更细粒度的锁、读写分离锁、无锁数据结构等。
- 减少锁竞争: 尽可能减少锁的竞争,避免不必要的同步。
- 优化代码结构: 优化代码结构,减少对象分配,避免在锁中使用耗时操作。
- 监控与调优: 持续监控系统的性能,并根据实际情况进行调优。
希望今天的分享对大家有所帮助。谢谢!