以下是 Java 应用在运行时常见的一些问题总结,主要侧重于运行时黑盒方式的排查方法。希望各位读者能给予补充,无论是遇到的新问题还是更多的解决思路。

类装载问题

开发过 Java 应用的工程师通常都遇到过 ClassNotFoundExceptionNoClassDefFoundErrorNoSuchMethodException(常见的 ClassCastException 此处不再赘述)。

ClassNotFoundException / NoClassDefFoundError

当碰到 ClassNotFoundExceptionNoClassDefFoundError 时,可按照以下步骤排查:

  1. 确认类路径:如果确定该 class 应该从哪个路径装载,可以去相应路径查找是否存在对应的 .class 文件。

    • 例如 Web 应用通常位于 *.war (或 ear)/WEB-INF/libclasses 目录下。
    • 对于 lib 下的 jar 包,可通过脚本 jar -tvf 查找。
  2. 检查日志与 ClassLoader:如不确定 class 从哪装载,先查看日志中是否有堆栈信息。

    • 如果有堆栈,可以看到具体是哪个 ClassLoader 实现在装载 class。
    • 之后可以通过 www.grepcode.com 或 jar 包反编译工具(推荐 JD-GUI)查看具体是从哪装载的 class。
  3. 使用 BTrace 跟踪:如日志中没有相关信息,可以用 BTrace 来跟踪抛出以上两个异常的堆栈信息。BTrace 脚本示例如下:

    import static com.sun.btrace.BTraceUtils.*;
    import com.sun.btrace.annotations.*;
    
    @BTrace public class Trace{
       @OnMethod(
           clazz="java.lang.ClassNotFoundException",
           method="<init>"
       )
       public static void traceExecute(){
            jstack();
       }
    }

    拿到堆栈信息后,可继续使用上述方法进行排查。在确认了 class 装载的位置后,将相应的 class 或 jar 包加上即可。

NoSuchMethodException

当碰到 NoSuchMethodException 时,通常是由于不存在需要的 class 版本或 class 版本冲突造成的。

  1. 定位加载源:在启动参数上增加 -XX:+TraceClassLoading,重启后在日志里查看此 class 是在哪 load 的。
  2. 检查 Jar 包版本:在对应的路径下用 jar -tvf 查找是否存在正确版本的 jar。
  3. 解决冲突

    • 通常可能会发现是版本冲突造成的,需要删掉有冲突版本的 jar。
    • 对于没有正确版本的,则需要用正确版本的 jar 替换(注意:这种场景通常还会出现一些复杂问题,例如和容器/框架的 jar 冲突等)。

CPU 问题

CPU Usages (us) 偏高

当出现 CPU us 消耗高时,通常的排查方法如下:

  1. 检查 GC 情况

    • 从经验上来说,有些时候是由于频繁 CMS GC 或 Full GC 造成的(频繁指每次 GC 结束后又立刻继续)。
    • 在开启 GC 日志的情况下( -Xloggc:),可通过 GC log 查看。
    • 如果没打开 GC 日志,可通过 jstat -gcutil 来查看。
    • 如是 GC 频繁造成的,可参考后文「内存问题 | GC 频繁」部分。
  2. 定位高耗线程

    • 如不是 GC 原因,可使用 top -H 查看线程的 CPU 消耗状况。
    • 有可能会看到有个别线程是 CPU 消耗的主体,这种情况通常比较好解决。
    • 根据 top 看到的线程 ID 进行十六进制转换,用转换出来的值和 jstack 出来的 Java 线程堆栈的 nid=0x[十六进制的线程 id] 进行关联,即可看到此线程到底在做什么动作。
    • 需要进一步排查原因,例如有可能是正则计算、很深的递归或循环,也有可能是 错误的在并发场景使用 HashMap 等。
    • 参考案例:一段耗 CPU 的随机生成字符串的代码 case
  3. 线程变化频繁的情况

    • top -H 看到的消耗 CPU 的线程是不断变化的,就比较麻烦了。
    • 有个同学写了个 脚本 自动地去通过 top -H 看到的消耗 CPU 的线程找到对应的 Java 线程堆栈,在这种情况下可以用这个脚本去试试。
    • 如果看到的线程堆栈确实是比较耗 CPU 的动作,则基本可以定位到。
  4. 多次 jstack 对比

    • 如仍然看不出,则可以尝试多次 jstack,看看是否经常有一些耗 CPU 的动作在不同的线程不断出现。
  5. 使用 Perf 工具

总结:CPU us 消耗高的问题排查有一定复杂性。例如之前碰到过反序列化的对象比较大,请求又非常频繁,导致 CPU us 消耗增高了很多,但当时的机器内核版本不够,不支持 perf,从 jstack 等等上都看不出什么,后来是由于从业务监控的变化上才排查出问题。

CPU IOWait 高

CPU System (sy) 高

内存问题

尽管 JVM 是自动管理内存的分配和回收的,但 Java 程序员们还是会经常碰到各种各样的内存问题。

OutOfMemoryError: Unable to create new native thread

在日志中可能会看到 java.lang.OutOfMemoryError: Unable to create new native thread

  1. 统计线程数:先统计目前的线程数(例如 ps -eLf | grep java -c)。
  2. 检查限制值:看看 ulimit -u 的限制值是多少。

    • 如线程数已经达到限制值,如限制值可调整,则可通过调整限制值来解决。
    • 如不能调限制值,或者创建的线程已经很多了,那就需要看看线程都是哪里创建出来的。同样可通过 BTrace 来查出是哪里创建的,脚本类似如下:
    import static com.sun.btrace.BTraceUtils.*;
    import com.sun.btrace.annotations.*;
    
    @BTrace public class Trace{
       @OnMethod(
           clazz="java.lang.Thread",
           method="start"
       )
       public static void traceExecute(){
            jstack();
       }
    }
  3. 解决方案

    • 在找到是哪里创建造成后,之后就可以想办法解决了。例如这种情况下常见的有可能是用了 Executors.newCachedThreadPool 这种来创建了一个没限制大小的线程池。
    • 还有一种可能是 ulimit -u 的限制还没到,内存也空闲,但仍然创建不了。这有可能是由于在 2.6.18/32 内核上 kernel.pid_max 默认的 32768 造成的,这个值其实直接限制了最多能创建的线程数就是 32768(即使 ulimit -u 的值比这大也没用)。

OutOfMemoryError: Heap Space / GC overhead limit exceeded

java.lang.OutOfMemoryError: Heap SpaceGC overhead limit exceeded 也是常见的现象。

  1. 获取 Dump 文件:在出现了这两种现象的情况下,最重要的是 dump 出内存。

    • 方法一:在启动参数上增加 -XX:+HeapDumpOnOutOfMemoryError
    • 方法二:在当出现 OOM 时,通过 jmap -dump 获取到内存 dump。
  2. 分析 Dump 文件:获取到内存 dump 文件后,可通过 MAT 进行分析。

    • 通常来说仅仅靠 MAT 可能还不能直接定位到具体应用代码中哪个部分造成的问题。例如 MAT 有可能看到是某个线程创建了很大的 ArrayList,但这样是不足以解决问题的。
    • 所以通常还需要借助 BTrace 来定位到具体的代码。
    • 参考案例:两个 OOM 排查的 Case

OutOfMemoryError: PermGen Space

注意:PermGen 空间在 Java 8 及以后版本已被 Metaspace 取代,此问题主要存在于旧版本 JVM。

当碰到这个现象时,可以通过调整 PermGen size 来试试。如果放大了一点后还是不断的消耗,则可以通过 BTrace 来跟踪下装载 class 的现象,脚本类似如下:

import static com.sun.btrace.BTraceUtils.*;
import com.sun.btrace.annotations.*;

@BTrace public class Trace{
   @OnMethod(
       clazz="java.lang.ClassLoader",
       method="defineClass"
   )
   public static void traceExecute(){
        jstack();
   }
}

Native OOM

还有一种 OOM 是 Native OOM,就是物理内存被耗光,对于这种现象,解决起来会麻烦一些。

  1. 检查 Deflater/Inflater:从经验上来说,Native OOM 有很大概率是由于错误使用 Deflater/Inflater 造成的。所以在碰到这类现象时,可以先用 BTrace 跟进下看看使用了 Deflater/Inflater 的有没有显式去调用 end 方法。
  2. 检查 Direct ByteBuffer:另外一种常见的原因是使用 Direct ByteBuffer 的场景(例如 NIO 框架等)。

    • 如使用了 Direct ByteBuffer 的对象是比较长存活的,当其被转到旧生代后,在 Full GC 没触发前,其实其占用的 JVM 堆外内存是不会被释放的。
    • 在这种情况下,可以做的一个尝试是先强制执行几次 Full GC(jmap -histo:live),然后看看堆外内存的使用是不是下降了。如果下降了则说明是这个问题。
    • 对于这类问题,可以用的一个解决方案是增加一个启动参数:-XX:MaxDirectMemorySize=500m 来实现当 Direct ByteBuffer 使用到 500m 后主动触发 Full GC 来回收(到底设置成多大应用可以自己调整)。
  3. 使用 Perf Tools 跟踪:如上面两招都没用,则需要挂上 google perf-tools 来跟踪下看看到底是哪里在 malloc。不过这里看到的是 C 堆栈上的东西,因此需要自己想办法根据这个对应到 Java 的代码上去。

GC 频繁

除了 OOM 外,还有可能会碰到 GC 频繁的问题(有很多同学会问我,到底什么算频繁,我觉得基本上如果每隔 10s 或更短时间就来一次 CMS GC 或 Full GC 才算得上吧)。

  1. 存活对象多:GC 频繁的现象出现时,如果发现 CMS GC 或 Full GC 后,存活的对象始终很多,这种情况下可以通过 jmap -dump 来获取下内存 dump 文件,然后通过 MAT/BTrace 来定位到具体的原因。
  2. 悲观策略:如 CMS GC 或 Full GC 频繁,但触发时 Old 区还有空闲空间,这种情况下有可能会是由于悲观策略造成。

    • 具体可以看看这篇文章里的几个 cases
    • 这种情况下通常的解决方法可以是调大 Old 区或减小 Young 区。
  3. 碎片问题:如不是悲观策略造成的,对于采用 CMS GC 的情况,还有可能是 CMS GC 的碎片问题造成的。

    • 这种情况下可以通过强制执行下 jmap -histo:live 来触发 Full GC。
    • 不过悲催的是 CMS GC 的碎片问题是无解的,暂时只能靠强制触发 Full GC 等来避免在高峰期时出现问题。
  4. 其他失败模式:对于 CMS GC 而言,还有可能会出现 promotion failedconcurrent mode failure 问题,具体也可以看看上面那篇文章的 cases。

进程 Crash 或异常退出

Java 进程 Crash 或无故退出也是会碰到的现象。

  1. 检查日志文件:对于进程 Crash,默认情况下 JDK 会生成 hs_err[pid].log 的文件,Core Dump 打开的话也会生成 Core Dump 文件。
  2. 分析 hs_err 或 Core Dump

    • 当进程 Crash 发生时,可以先看看 hs_err[pid].log
    • 如没找到此文件,但有 Core Dump 文件,有可能的原因是代码中出现了无限递归或死循环,可通过 jstack [core dump 文件] 来提取出 Java 的线程堆栈,从而具体定位到具体的代码。
    • 如有 hs_err[pid].log 以及 Core Dump 文件,则需要具体原因具体排查,这个比较麻烦。常见的可能会有上面的 Native OOM(还有可能是 32 bit 机器,但 Java 进程已经申请了超过 3G 的地址空间),某些代码 JIT 编译出问题了(可通过指定某些代码不让 JIT 编译来避免,但会影响性能:-XX:CompileCommand=exclude, 类名/方法名)等。
  3. 系统与应用日志

    • 在上面的招还无效时,可以尝试 dmesg 看看是不是系统出了什么问题或系统主动杀掉过进程(例如内存超出限制等)。
    • 仍然没找到原因的话需要去翻翻应用的日志,看看是不是能找到什么线索,因为有些时候是应用上主动退出了(对于应用主动退出的问题可通过 BTrace 来排查是不是有主动调用过 System.exit)。

硬件资源未达瓶颈但吞吐量不足

如在压测时,出现这个现象时,首先可以看看施加压力的一端是否真的压力传递到了服务端。

  1. 检查线程池:如确认压力已传递,则可以看看从 Server 接到请求的地方开始,是不是处理线程池满了(例如假设是 Tomcat,最大的线程数大小是不是已经到了)。

    • 如处理线程池满了,可考虑扩大线程数大小。
    • 这个地方的排查其实有点麻烦,需要从接收请求的部分一直到纯粹的业务处理部分,看看每步的瓶颈状况,例如有些时候新建连接这种还有可能是由于系统参数的问题。
  2. 检查锁竞争

    • 另外,需要看的就是锁的状况,可通过 jstack -l 来查看,也许是由于锁竞争激烈造成。
    • 在锁竞争激烈出现时,需要考虑使用 j.u.c 里的数据结构或使用无锁算法等来优化。

说明

  • 适用版本:文中部分排查方法(如 PermGen 空间、32 位内核限制、特定 GC 策略)主要适用于 Java 8 及之前的版本或特定历史环境。Java 8 及以上版本在内存模型和 GC 机制上有所变化(如 Metaspace 取代 PermGen),请结合实际运行环境调整。
  • 工具时效:文中提及的部分外部链接(如 grepcode、code.google.com 等)可能已失效或迁移,建议结合当前可用的替代工具(如 GitHub 上的开源项目、新一代反编译工具等)进行排查。