likes
comments
collection
share

从 thread dump 代码停止的位置到 JVM 的安全点Safepoint

作者站长头像
站长
· 阅读数 8

有一天, 生产环境有个Java应用的CPU的使用率突然升高了, 并且一旦升上去之后, 就再也下不来了, 看上去像爬台阶, 直到重启. 如下图:

从 thread dump 代码停止的位置到 JVM 的安全点Safepoint

预感某个特定的bug被激活了. 这样的bug有时候是遇到特定的请求数据才会被触发, 一旦触发就会占用一个线程, 并且根据它消耗CPU的能力, 要燃烧一个CPU.

初步诊断

既然消耗CPU, 那么就先通过线程栈看看它在干嘛, 发现这些消耗CPU的线程们都在执行这些方法:

java.lang.ClassLoader.getPackage(ClassLoader.java:1607)
java.lang.Package.getPackage(Package.java:334)
java.lang.Class.getPackage(Class.java:796)
com.tianxiaohui.logger.Logger.getInstance(Logger.java:77) 
com.tianxiaohui.helper.client.MyOrderSvcClient.<init>(MyOrderSvcClient.java:25) 
com.tianxiaohui.helper.client.MyOrderSvcClient.getInstance(MyOrderSvcClient.java:68) 
com.tianxiaohui.helper.client.MyOrderSvcClient.getInstance(MyOrderSvcClient.java:73) 
com.tianxiaohui.biz.MyOrdersvcCosmosHelper.findContract(MyOrdersvcCosmosHelper.java:91)
... 省略其它不相关线程栈 ...

如果看 thread dump, 能够很明显看到有一个线程在 getPackage(ClassLoader.java:1607) 的时候, 获得了一把锁, 而其它也在执行这个 getPackage(ClassLoader.java:1607) 方法的线程都在等这把锁. 而所有停在这一个方法的线程都是停在了 1607 这一行.

我们不禁要问, 这一行到底干嘛, 翻开JDK 8 的对应版本的源代码, 看到如下源代码:

    protected Package getPackage(String name) {
        Package pkg;
        synchronized (packages) {
            pkg = packages.get(name);
        }
        //省略其它不相关代码
    }

1607行正是 synchronized (packages) { 这段代码(上面的第3行). 可以看到, 这里在争抢一把同步锁.

虽然停在这里没什么大事情, 但是我们却不明白:

  1. 为什么这么消耗CPU的线程总是停在这个地方?
  2. 为什么已经获得这把锁的线程也停在这个地方, 而没有走到后面的步骤?

找到消耗CPU的原因

为了找到原因, 我们多做了几次 thread dump, 发现每次都是那么几个相同的线程停在 getPackage(ClassLoader.java:1607) 行, 稍有不同的是, 有时候A线程获得了锁, 其它线程等待, 有时候是B线程获得了锁, 其它线程等待, 反正呢, 就是只有一个线程获得了那把锁, 其它线程都等在这行. 那个获得锁的线程也一直很奇怪地停在这行.

于是我们又通过通过 profiling 工具, 看到这几个线程都在 MyOrdersvcCosmosHelper.findContract() 方法内, 其中大约2%在这个方法另外一行代码, 98% 都在MyOrderSvcClient.getInstance()方法上, 同时, 我们发现 MyOrdersvcCosmosHelper.findContract()内部的一个死循环正是造成这次问题的核心. 这个死循环正是特定的请求数据造成的. 解决了这个死循环问题, 这个问题就消失了.

但是, 我们不禁要问, 为什么在这个死循环里, 绝大多数的thread dump 里相关的线程都停在了 ClassLoader.getPackage(ClassLoader.java:1607) 这行代码, 它的魔力在哪?

破解 thread dump 里最后方法停止处的魔法 - Safepoint

经常读取 thread dump 的读者可能会对下面的某些代码栈很熟悉, 因为它们最经常出现在 thread dump 里面. 比如:

java.lang.Object.wait(Native Method)

sun.misc.Unsafe.park(Native Method)

java.lang.Thread.sleep(Native Method)

sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)

为什么是这些代码? 这些代码可能比较明显, 因为它们都在待某些事情的发生, 所以有很大概率在做 thread dump 的时候正好遇到它们. 可是如果你正在处理一个死循环的代码, 类似我们上面真实发生的, 也会发现每次大概率你的线程都停在特定的位置. 即便你每次间隔不同的时间, 还是停在某些特定的位置上, 而不是平均的概率停在所有可能执行的代码之上.

当我们执行 thread dump 的时候, 当前线程停的位置并不是随机的, 而是由代码安全点(Safe point)决定的, 当所有的代码都停在安全点上时, 才能安全的做heap dump. 然而只有部分代码适合做安全点. 上面列出的这些代码点以及我们一开始看到的争抢锁的位置, 都是合适做代码安全点的.

安全点 Safepoint

在 Java 中,安全点(Safepoint)是一种程序执行的状态,在这种状态下,JVM(Java虚拟机)能够停止正在运行的线程以进行特定操作。这些操作可能包括垃圾回收、线程挂起、代码优化等,这些操作需要所有线程都暂停在一个确定的状态,以确保不会出现数据不一致或不可预测的行为。

为何需要安全点?

当JVM处理下面的事情的时候, 它需要安全点:

  1. 垃圾回收(GC): JVM 中的垃圾回收器需要所有线程暂停才能安全地执行,以便清理不再使用的内存对象。
  2. 线程挂起: 某些情况下,需要暂停线程来执行特定的操作,比如线程调优、安全性检查等。
  3. 代码优化: JVM 可能会在安全点处执行代码优化,以提高性能或执行某些特定的操作。

哪些地方适合做代码安全点?

并非所有的代码处都适合成为安全点,但有一些通用的情况适合在此处执行安全点操作:

  1. 方法调用的返回点: 方法调用结束时,返回到调用者之前,这是一个适合执行安全点操作的常见地点。
  2. 循环末尾: 循环结束时是另一个适合执行安全点操作的地点。当循环完成或被中断时,可以在循环体的最后执行安全点操作。
  3. 异常处理块的末尾: 当异常处理块完成处理异常时,通常在异常处理代码块的末尾进行安全点操作,确保异常处理后的状态正确。
  4. 同步块的末尾: 在使用同步块或关键字(synchronized)时,当同步块执行完毕,退出临界区时,可以在同步块末尾执行安全点操作。
  5. 线程调用结束点: 当线程任务完成、线程结束或线程主动终止时,在这些地方执行安全点操作。

我们文中一开始的例子就属于上面的第4种情况, 虽然标注的位置是同步锁的开始处, 但是我们注意到它是一个代码块, 而这个同步块的结尾处是非常适合的安全点.