JAVA 后端 CPU 飙高到 100%?快速定位线程阻塞与死循环问题的方法

Java 后端 CPU 飙高到 100%?快速定位线程阻塞与死循环问题的方法

各位朋友,大家好!今天我们来聊聊一个让 Java 后端工程师头疼的问题:CPU 飙高到 100%。 这个问题往往意味着我们的服务出现了性能瓶颈,严重时会导致服务崩溃。 面对这种情况,我们需要冷静分析,快速定位问题根源。 今天我将分享一些常用的方法,帮助大家快速诊断线程阻塞和死循环导致的 CPU 飙高问题。

一、问题现象与初步判断

首先,我们需要确认 CPU 的确飙高了。 可以通过以下方式观察:

  • Linux 系统: 使用 tophtop 命令,观察 CPU 使用率最高的进程。
  • Windows 系统: 使用任务管理器,查看 CPU 占用率最高的进程。
  • 监控系统: 如果使用了监控系统(如 Prometheus + Grafana),可以查看 CPU 使用率的监控指标。

如果确认是 Java 进程 CPU 占用率过高,那么接下来需要判断是所有线程都在高负荷运行,还是少数线程导致的 CPU 飙高。 这将影响我们后续的排查方向。

  • 所有线程高负荷: 这通常意味着整体系统负载过高,或者代码存在普遍的性能问题,例如大量的计算密集型操作、频繁的 GC 等。
  • 少数线程高负荷: 这很可能是由于线程阻塞或死循环导致的。

二、定位高 CPU 占用线程

定位到高 CPU 占用线程是关键的第一步。 我们需要借助一些工具来完成这个任务。

  1. jstack 命令: jstack 是 JDK 自带的线程堆栈分析工具,它可以打印出 Java 进程中所有线程的堆栈信息。 通过分析堆栈信息,我们可以找到正在执行的线程,以及它们正在执行的代码。

    • 获取进程 ID (PID): 使用 jps 命令或者 ps -ef | grep java 命令找到 Java 进程的 PID。

      jps -l

      输出类似:

      12345 com.example.MyApplication

      这里 12345 就是进程 ID。

    • 使用 jstack 命令: 使用以下命令打印线程堆栈信息。

      jstack 12345 > thread_dump.txt

      这将把线程堆栈信息输出到 thread_dump.txt 文件中。

  2. VisualVM: VisualVM 是一个功能强大的 Java 虚拟机监控工具,它提供了图形化的界面,可以方便地查看线程信息、内存信息、CPU 使用率等。VisualVM 默认包含在 JDK 中。

    • 启动 VisualVM (jvisualvm 命令)。
    • 在 VisualVM 中连接到正在运行的 Java 进程。
    • 切换到 "Threads" (线程) 选项卡。
    • 按照 CPU 时间排序,找到 CPU 时间最高的线程。
  3. Arthas: Arthas 是阿里巴巴开源的一款 Java 诊断工具,它提供了丰富的命令,可以方便地进行各种诊断操作,包括查看线程信息、方法调用堆栈、类加载信息等。

    • 下载并安装 Arthas。
    • 启动 Arthas 并连接到 Java 进程。
    • 使用 thread -n 3 命令,查看 CPU 使用率最高的 3 个线程。

      thread -n 3

      Arthas 会输出类似如下信息:

      "pool-1-thread-1" Id=10 cpuUsage=98% deltaCpuUsage=97% RUNNABLE
          at com.example.MyTask.run(MyTask.java:20)
          at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
          at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
          at java.lang.Thread.run(Thread.java:748)

      从 Arthas 的输出中,我们可以看到线程 ID、线程名称、CPU 使用率,以及线程的堆栈信息。

三、分析线程堆栈信息

获取到线程堆栈信息后,我们需要仔细分析它,才能找到问题的根源。 线程堆栈信息包含了线程的执行路径,以及线程当前的状态。

  1. 线程状态: 线程堆栈信息中会包含线程的状态,常见的状态包括:

    • RUNNABLE: 线程正在执行,或者可以被执行。如果一个线程长时间处于 RUNNABLE 状态,并且 CPU 使用率很高,那么很可能存在死循环。
    • BLOCKED: 线程被阻塞,等待获取锁。 如果多个线程同时竞争同一个锁,并且其中一个线程长时间持有锁不释放,那么其他线程就会被阻塞。
    • WAITING: 线程正在等待其他线程的通知。 例如,线程调用了 Object.wait() 方法,或者 Thread.join() 方法。
    • TIMED_WAITING: 线程正在等待一段时间后自动唤醒。 例如,线程调用了 Thread.sleep() 方法,或者 Object.wait(long timeout) 方法。
  2. 代码位置: 线程堆栈信息中会包含代码位置,也就是线程正在执行的代码行号。 通过代码位置,我们可以找到导致 CPU 飙高的具体代码。

  3. 锁信息: 如果线程处于 BLOCKED 状态,线程堆栈信息中会包含锁的信息,包括锁的持有者线程 ID,以及等待获取锁的线程 ID。

四、常见问题与解决方案

  1. 死循环: 死循环是最常见的导致 CPU 飙高的问题之一。 如果一个线程长时间处于 RUNNABLE 状态,并且 CPU 使用率很高,那么很可能存在死循环。

    • 识别死循环: 查看线程堆栈信息,找到长时间执行的代码块。 检查循环条件是否正确,以及循环体内部是否存在逻辑错误。

    • 示例代码:

      public class DeadLoop {
          public static void main(String[] args) {
              int i = 0;
              while (true) { // 死循环
                  i++;
              }
          }
      }
    • 解决方法: 修改循环条件,确保循环能够正常结束。 或者使用 break 语句跳出循环。

  2. 锁竞争: 如果多个线程同时竞争同一个锁,并且其中一个线程长时间持有锁不释放,那么其他线程就会被阻塞,导致 CPU 飙高。

    • 识别锁竞争: 查看线程堆栈信息,找到处于 BLOCKED 状态的线程。 确定这些线程正在等待获取哪个锁,以及哪个线程持有这个锁。

    • 示例代码:

      public class LockContention {
          private static final Object lock = new Object();
      
          public static void main(String[] args) throws InterruptedException {
              Thread thread1 = new Thread(() -> {
                  synchronized (lock) {
                      System.out.println("Thread 1 acquired the lock");
                      try {
                          Thread.sleep(5000); // 模拟长时间持有锁
                      } catch (InterruptedException e) {
                          e.printStackTrace();
                      }
                      System.out.println("Thread 1 released the lock");
                  }
              });
      
              Thread thread2 = new Thread(() -> {
                  synchronized (lock) {
                      System.out.println("Thread 2 acquired the lock");
                  }
              });
      
              thread1.start();
              Thread.sleep(100); // 确保 thread1 先获取锁
              thread2.start();
          }
      }
    • 解决方法:

      • 减少锁的持有时间: 尽可能减少锁的持有时间,避免长时间阻塞其他线程。
      • 使用更细粒度的锁: 将一个大的锁拆分成多个小的锁,降低锁竞争的概率。
      • 使用读写锁: 如果读操作远多于写操作,可以使用读写锁,允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。
      • 使用无锁数据结构: 使用无锁数据结构(例如 ConcurrentHashMap、AtomicInteger)可以避免锁竞争,提高并发性能。
  3. 线程池耗尽: 如果线程池中的线程全部被占用,新的任务将无法执行,导致请求堆积,CPU 飙高。

    • 识别线程池耗尽: 查看线程池的监控指标,例如活跃线程数、队列长度、拒绝任务数等。 如果活跃线程数达到最大线程数,并且队列长度持续增加,那么很可能存在线程池耗尽的问题。

    • 示例代码:

      import java.util.concurrent.ExecutorService;
      import java.util.concurrent.Executors;
      
      public class ThreadPoolExhaustion {
          public static void main(String[] args) {
              ExecutorService executor = Executors.newFixedThreadPool(2); // 线程池大小为 2
      
              for (int i = 0; i < 10; i++) {
                  final int taskNumber = i;
                  executor.submit(() -> {
                      System.out.println("Task " + taskNumber + " started");
                      try {
                          Thread.sleep(2000); // 模拟耗时任务
                      } catch (InterruptedException e) {
                          e.printStackTrace();
                      }
                      System.out.println("Task " + taskNumber + " finished");
                  });
              }
      
              executor.shutdown();
          }
      }
    • 解决方法:

      • 增加线程池大小: 根据实际需要,增加线程池的最大线程数。
      • 使用有界队列: 使用有界队列可以防止任务无限堆积,避免 OOM 错误。
      • 优化任务执行时间: 缩短单个任务的执行时间,提高线程池的吞吐量。
      • 使用拒绝策略: 当线程池中的线程全部被占用,并且队列已满时,可以使用拒绝策略来处理新的任务。 常见的拒绝策略包括:
        • AbortPolicy: 直接抛出 RejectedExecutionException 异常。
        • CallerRunsPolicy: 由提交任务的线程执行该任务。
        • DiscardPolicy: 直接丢弃该任务。
        • DiscardOldestPolicy: 丢弃队列中最老的任务,然后尝试执行该任务。
  4. 频繁的 GC: 频繁的 GC 会导致 JVM 暂停所有线程,影响系统性能,甚至导致 CPU 飙高。

    • 识别频繁的 GC: 使用 jstat -gcutil <pid> 1000 命令,观察 GC 的频率和耗时。 如果 GC 的频率很高,并且耗时很长,那么很可能存在频繁的 GC 问题。
    • 解决方法:
      • 优化代码,减少对象创建: 避免创建不必要的对象,减少内存占用。
      • 调整 JVM 参数: 调整 JVM 的堆大小、新生代大小、老年代大小等参数,优化 GC 性能。
      • 使用合适的 GC 算法: 根据应用场景选择合适的 GC 算法。 例如,CMS 算法适合于对停顿时间要求较高的应用,G1 算法适合于大堆内存的应用。

五、排查步骤总结

为了更清晰地总结排查过程,我将步骤整理成表格:

步骤 操作 工具/命令 说明
1 确认 CPU 飙高 top / htop / 任务管理器 / 监控系统 确认 Java 进程 CPU 占用率过高
2 获取线程堆栈信息 jstack <pid> > thread_dump.txt / VisualVM / Arthas 获取 Java 进程的线程堆栈信息
3 找到高 CPU 占用线程 分析 thread_dump.txt / VisualVM / thread -n <num> (Arthas) 找到 CPU 使用率最高的线程
4 分析线程堆栈信息 文本编辑器 / VisualVM / Arthas 重点关注线程状态 (RUNNABLE, BLOCKED, WAITING, TIMED_WAITING),代码位置,锁信息
5 根据线程堆栈信息,定位问题根源 常见问题包括死循环、锁竞争、线程池耗尽、频繁的 GC 等
6 解决问题 根据具体问题,采取相应的解决方案

六、预防措施与最佳实践

除了解决已经出现的问题,我们更应该注重预防,从源头上避免 CPU 飙高问题的发生。

  • 代码审查: 进行代码审查,检查代码是否存在潜在的性能问题,例如死循环、锁竞争、资源泄漏等。
  • 性能测试: 在上线前进行性能测试,模拟高并发场景,发现潜在的性能瓶颈。
  • 监控系统: 建立完善的监控系统,监控 CPU 使用率、内存使用率、线程池状态、GC 频率等关键指标,及时发现异常情况。
  • 日志记录: 记录详细的日志信息,方便问题排查。
  • 使用成熟的框架和库: 使用经过充分测试和优化的框架和库,可以减少潜在的性能问题。
  • 遵循最佳实践: 遵循 Java 编码最佳实践,编写高质量的代码。

最后,一些提示

  • 不要盲目猜测: 不要盲目猜测问题的原因,要根据实际数据进行分析。
  • 逐步排查: 从最简单的开始排查,逐步缩小问题范围。
  • 善用工具: 善用各种诊断工具,例如 jstack, VisualVM, Arthas 等,可以提高问题排查效率。
  • 持续学习: 不断学习新的技术和工具,提高自己的问题解决能力。

希望今天的分享能帮助大家更好地定位和解决 Java 后端 CPU 飙高问题。 谢谢大家!

问题的解决在于细致的分析和有效的工具使用
要解决CPU飙高问题,需要细致地分析线程堆栈信息,并熟练运用各种诊断工具。

预防措施是避免问题的关键
通过代码审查、性能测试和完善的监控系统,可以有效预防CPU飙高问题的发生。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注