Java问题定位与深度调试技术(一)——线程堆栈分析

一、Java线程堆栈分析

Java线程堆栈是虚拟机中线程(包括锁)状态的一个瞬间快照,即系统在某个时刻所有线程的运行状态,包括每一个线程的调用堆栈、锁的持有情况等信息。每一种Java虚拟机(SUN JVM、IBM JVM、JRockit、GNU JVM等)都提供了线程转储的后门,通过这个后门可以将那个时刻的线程堆栈打印出来。虽然各种Java虚拟机在线程堆栈的输出格式上有一些不同,但是线程堆栈的信息都包含了一下内容

  1. 线程的名字、ID、数量等
  2. 线程的运行状态,锁的状态(锁被哪个线程持有,哪个线程在等待锁等)
  3. 调用堆栈(函数的调用层次关系)。调用堆栈包含完整的类名、执行的方法,以及源代码的行号。

线程堆栈不擅长的事情

由于线程堆栈是系统某个时刻的线程运行状况(瞬间快照),对于已经消失且没留痕迹的信息,线程堆栈是无法进行历史追踪的。这种情况只能结合日志进行分析,如连接池中的连接被哪些线程使用且没有被释放。

  • 数据混乱问题,代码逻辑问题
  • 数据库锁表问题。表被锁往往是由于某个事务没有提交/回滚,但这些信息是无法在堆栈中表现出来的。
  • 其他无法在线程堆栈上留有痕迹的问题,得依赖日志设计。

小技巧(new Throwable()).printStackTrace()可以在运行期打印并调用该函数的线程堆栈信息,借助这个信息就可以知道调用流程。

线程池堆栈优点和擅长的事情

  • CPU的使用率无缘故过高
  • 系统挂起,无响应
  • 系统运行速度越来越慢
  • 性能瓶颈(如无法充分利用CPU等我的项目也经常遇到)。
  • 线程死锁、死循环等。
  • 由于线程数量太多导致系统运行失败(如无法创建线程等)

1.1 打印线程堆栈

Java虚拟机提供了线程转储的后门,通过这个后门就可以将线程堆栈打印出来。向Java进程发送一个QUIT信号,Java虚拟机收到信号之后,可将系统当前的Java线程调用堆栈打印出来。 有的虚拟机(SUN JDK)能将堆栈信息打印在屏幕上,而(IBM JDK)可直接将线程堆栈打印到一个文件中,从当前的运行目录下可以找到该文件。当JDK把线程堆栈打印到屏幕上可能由于信息太多造成丢失。

  1. Windows:在运行Java的控制台窗口按ctrl+break组合键
  2. UNIX/Linux:使用 kill -3 <java pid>
ruby 复制代码
myrun.sh > run.log 2>&1

# 解释如下
> 将屏幕输出写入文件 
>> 将屏幕输出添加到文件末尾

在操作系统中,0代表输入流,1(标准输出)、2(错误输出)代表输出流 。
在这里2>&1表示将错误输出"2"重定向到标准输出流"1"中,即将标准输出和错误输出都重定向到一个文件中

不过有很多公司会禁用你直接kill -3你会发现没效果,一般来说都是用jstack

perl 复制代码
ps -ef | grep java # 先获取java的pid
jstack pid > run.log 2>&1 # 堆栈快照打印,注意用户权限必须得是你java程序的创建用户 root有时候都不行
  1. 在AIX上使用IBM的JVM时,需要设置以下的环境变量,kill -3才可以生效
ini 复制代码
export IBM_HEAPDUMP = true
export IBM_HEAP_DUMP = true
export IBM_HEAPDUMP_OUTOFMEMORY = true
export IBM_HEAPDUMPDIR=<directory path>

同时应确保Java命令行中没有DISABLE_JAVADUMP运行选项

1.2 解读线程堆栈

从这段堆栈输出中可以看出,当前系统共有8个线程:Low Memory Detector.CompilerThreadO、Signal Dispatcher、Finalizer 、Reference Handler、main.VM Thread、VM Periodic Task Thread,具体说明如下。

  1. Low Memory Detector:低内存检测线程,用于检测当前可用堆内存(HEAP)是否到达下限,以启动垃圾回收工作。
  2. CompilerThread0:编译线程,即将频繁调用的热字节码编译成本地代码(JIT),以提高运行效率。
  3. Signal Dispatcher:信号分发线程,如从终端控制台接收的各种 kill 信号(QUIT信号)等。
  4. Finalizer:善后线程。
  5. Reference Handler :引用计数线程。
  6. main:主线程,即执行用户main ()函数的线程。
  7. VM Thread: VM线程。
  8. VM Periodic Task Thread: VM周期任务处理线程,如Timer线程等。

其中只有main 线程属于Java用户线程,其他七个线程都是由虚拟机自动创建的,如果是 Java 界面程序,虚拟机还会自动创建事件分发线程awt-eventqueue等。在实际分析过程中,大多数情况下只要关注Java 用户线程即可。

从 main 线程中可以看出,线程堆栈中最直观的信息是当前线程调用的上下文(context),即从哪个函数调用到哪个函数中(从下往上看),正执行到哪个类的哪一行等,借助这些信息,就可以对当前系统的运行情况一目了然了。线程堆栈在分析问题中的作用将在后续章节进行详细介绍,其中一个线程的某层调用含义如下。

另外,在main线程的堆栈中,有"- locked <Oxc8c1a090> (a java.lang.Object)"语句,这表示该线程( main线程)已经占有了锁<Oxc8c1a090>,其中Oxc8c1a090表示锁的ID,它是系统自动产生的,在每次打印的堆栈时,只要知道同一个ID表示同一个锁即可。

每个线程堆栈的第一行含义如下。

1.2.1 打印本地线程

perl 复制代码
ps -ef | grep java
pstack <javapid>

跟java堆栈快照里面的线程是一一对应的。

java堆栈里面的nid(16进制)就是本地线程里面的LWP ID(10进制)

1.2.2 线程的状态

其中"runnable"表示当前线程处于运行状态。这个runnable状态从虚拟机角度来看的,表示这个想线程正在运行。但是处于runnable状态的线程不一定真的消耗cpu只能说明该线程没有阻塞在Java的wait或者sleep方法上,同时也没在锁上面等待。但如果该线程调用了本地方法(有两种情况会调用本地方法,一种是调用到用户手工写的JNI本地代码中,另一种是将Java自身提供的API调用到本地代码中,如at java.net,SocketInputStream.socketRead0(Native Method)中的"Native Method"就表示当前正在调用本地方法),而本地方法又处于等待状态,这是虚拟机并不知道本地代码发生了什么,此时尽管当前线程实际上也是阻塞的状态,但显示出来的还是runnable状态,这种情况下是不消耗CPU的。

1.2.3 init和clinit

1.2.4 锁的解读

线程信号类型 作用
wait() 当线程执行wait()时,当前线程会释放监视锁,此时其他线程可以占有该锁。一旦wait()执行完成,当前线程又会继续持有该锁,直到执行完该锁的作用域。可以说wait()是多线程场合中使用得最多的一个方法。它结合notify(),可以实现两个线程之间的通信,即一个线程可以通过这种方式通知另一个线程继续执行,完成线程之间的配合,实现线程间的特定执行时序。
sleep() sleep()与锁无关,如果它恰好在一个锁的保护范围之内,当前线程即使在执行sleep(),仍能继续保持监视锁。不会释放锁。
堆栈打印的类型 作用
locked<0x22bffb60> 当一个线程占有一个锁的时候,线程堆栈会打印locked<0x22bffb60>
waiting to lock<0x22bffb60> 当一个线程正在等待其他线程释放该锁,线程堆栈会打印waiting to lock<0x22bffb60>
-waiting on<0x22bffb60> 当一个线程占有一个锁,但又执行到该锁的wait()上,线程堆栈先打印locked,然后再打印-waiting on<0x22bffb60>。

一般情况下,当一个(些)线程再等待一个锁时,就应该有一个线程占用了这个锁,也就是说,从打印的堆栈中如果能看到waiting to lock<0x22bffb60>,也应该能找到一个线程locked<0x22bffb60>。某些情况下好不到:

  1. 在一个线程释放锁和另一个线程被唤醒之间有一个时间窗,在这期间,如果恰巧进行了堆栈转储,就会找不到locked的线程。
  2. 另外通过kill -3向虚拟机发信号,请求输出线程堆栈时,有的虚拟机会有不同的实现策略,并不一定立即响应该请求,也许会等待正在执行的线程完成后,才打印堆栈。在实际应用中,看IBM的JDK打印的堆栈,经常能找到一个锁的wainting to线程,但找不到locked该锁的线程,而SUN JDK中绝大多数都是同时出现的。

1.2.5 线程状态的解读

state 作用
RUNNABLE 从虚拟机的角度来看,线程处于正在运行状态。但是不一定是在消耗CPU。有可能在读取网络数据等。因为只有java代码显示调用了sleep或wait等方法,虚拟机才可以精准的获取线程的真正状态。但调用本地(Native)方法时,虚拟机无法抓取本地代码的内部执行状态。
TIMED_WAITING(on object monitor) 该状态表示当前线程被挂起一段时间,该线程正在执行obj.wait(int time)方法。不消耗CPU。
TIMED_WAITING(sleeping) 该状态表示当前线程被挂起一段时间,正在执行Thread.sleep(int time)的方法。不消耗CPU。
TIMED_WAITING(parking) 该状态表示当前线程被挂起一段时间,正在执行lock()的方法。下面的线程正处于TIMED_WAITING状态,表示当前被挂起一段时间,时长为参数中指定的时长,如LockSupport.parkNanos(blocker,10000)。因此当前该线程并不消耗CPU。
WAITING(on object monitor) 该状态表示当前线程被挂起,正在执行obj.wait()方法(无参数的wait())。不消耗CPU

总结:有时间限制的都是TIMED_开头,没有时间限制的则是直接WAITING开头。RUNNABLE不一定在消耗CPU要看具体的方法。WAITING肯定不消耗CPU。

1.3 线程堆栈分析的三个视角

1.3.1 视角一 堆栈的局部信息

  • 当前每一个线程的调用层次关系(调用上下文),即当前每个线程正在调用哪些函数。
  • 当前每个线程的状态,如持有了哪些锁,在等待哪些锁。

1.3.2 视角二 一次堆栈的统计信息(全局信息)

从一次堆栈的信息中,通过统计可以获得相关的影响性能的嫌疑代码,其符合典型的"笨贼原理"。例如,一个贼到西瓜地里偷西瓜,他待得越久被发现的几率就越大。

  1. 当前锁的争用情况:是不是很多线程在等待同一个锁呢?如果很多正在执行用户代码的线程在等待同一个锁,那么这个系统就已经出现了性能瓶颈,并导致了锁竞争。还可能是某个线程长时间持有一个锁不释放(如这个线程陷入了死循环的代码,或者正在请求一个资源,很长时间得不到唤醒)。

用户代码是指正在执行用户逻辑的代码。在Java应用中,很多情况下会使用线程池等技术,如果线程池中的线程仍在池中,那么这个线程则不在执行用户代码,在实际分析过程中,我们只关注正在执行用户代码的线程。具体哪些线程在执行用户代码,要根据调用上下文确认,如在下面的线程中,从上下文可以看出他们是线程中的待调度线程,这种线程并不需要关注。

  1. 当前大多数线程在干什么:在一次堆栈中,如果有很多线程在集中执行某段代码,那么突破这个点就能提升系统的性能。
  2. 当前线程的总数量:通过该信息可以知道系统创建线程的状况(太多、太少,或者实现机制不合理)。

1.3.3 视角三 多个堆栈的前后对比信息

从多次(前后打印多次堆栈进行对比)的堆栈信息中,还可以获得如下针对某个线程的信息。这个线程是不是长期在执行,是不是长期在等一个锁。跟二结合起来看。

1.4 借助线程堆栈进行问题分析(典型场景)

1.4.1 线程死锁分析

从打印的线程堆栈中可以看到"Found one Java-level deadlock",即如果存在线程死锁的情况,虚拟机在打印堆栈中会直接给出死锁的分析结果。唯一的办法就是重启避免死锁唯一的办法就是修改代码。死锁的两个或多个线程是不消耗CPU的

1.4.2 Java代码死循环导致CPU过高

多次打印堆栈,还在RUNNABLE执行的线程看在干什么。也有可能是不合理的内存设置导致的GC。常见的死循环原因如下:

  1. HashMap等线程不安全的容器,用在多线程读/写的场合,导致HashMap的方法调用形成死循环。在多线程中使用JDK提供的容器类作为共享变量的时候,千万不能使用线程不安全的容器类。

  2. 多线程场合对共享变量没有进行保护,导致数据混乱,从而使循环退出的条件永远不满足,导致死循环的发生。

    • 在for、while循环中,由于退出条件永远不满足而导致死循环。
    • 链表等数据结构收尾相接,导致遍历永远无法停止。
  3. 其他错误的编码。如果是CPU密集型的。可以

bash 复制代码
top
# 按1 查看每一个核的CPU使用率

1.4.3 高消耗CPU代码的常用分析方法

造成CPU使用率异常的原因如下

  1. Java代码中存在死循环,导致CPU使用率过高。
  2. 系统存在不恰当的设计,尽管没有死循环,但CPU的使用率仍然过高。如不断的轮询。
  3. JNI中有死循环代码
  4. 堆内存设置太小造成的频繁GC。
  5. 在32位的JDK中,由于堆内存设置太大造成的频繁GC。
  6. JDK自身存在死循环的bug。

1.操作系统提供的性能分析工具

css 复制代码
top -p <javapid>
# 按H查看该进程所有线程的统计情况(CPU等)

这个PID就是tid=... nid=...里面的nid,其中nid就是本地线程号,只不过线程堆栈里面的nid中用十六进制来表示。 然后再去java堆栈快照里面找这个线程id。看在干嘛。

如果在Java线程堆栈中找不到对应的线程ID,则有如下两种可能

  1. JNI调用中重新创建新线程来执行,那么在Java线程堆栈中就不存在对应的线程信息。
  2. 虚拟机自身代码导致的CPU使用率过高,如堆内存枯竭导致的频繁Full GC(完全垃圾回收),或者虚拟机的BUg等。

这种方式对系统的消耗最小,非常适合在生产环境中使用。

2.Xrunprof协助分析

虚拟机自身也提供了一些CPU剖析工具,借助这些工具可以获知哪些代码段消耗了更多的CPU,从而找到可疑的性能点。

3.JProftler和Optimizelt等工具

一些商业化分析CPU的工具。

4.多次打印堆栈。

第一种和第四种相比二三对系统的消耗最小。

1.4.4 资源不足等导致性能下降的分析

这里所说的资源包括数据库连接等。大多数时候资源不足和性能瓶颈是同一类问题。如果资源不足,就会导致资源争用,请求该资源的线程会被阻塞或者挂起(wait),自然就导致性能下降。

系统对于资源一般的设计模式是:当需要资源的时候,就获取资源;当不需要的时候,就把资源释放;如果暂时没有可用资源,那么就等待(阻塞)在那里(等在一个资源锁上面);如果有别的线程释放资源,那么等待的线程被notify()后获得资源继续运营(一般资源的设计都是遵循wait/notify模式)

资源不足的时候,就会有大量的线程在等待资源,停在同样的调用上下文上。

  1. 资源数量配置太少
  2. 获得资源的线程把持资源太久。
  3. 如SQL语句没有索引导致数据库访问过慢。
  4. 资源用完后,在某种情况下,没有关闭或回池,导致可用资源泄漏或者减少,从而导致资源竞争。

1.4.5 线程不退出导致系统挂死的分析

系统挂死的特征是线程一直都在同一个调用上下文上。间隔Nmin多打印几次一直在执行任务的线程。 可能原因

  1. 线程正在执行死循环代码
  2. 资源不足或资源泄漏,造成当前线程阻塞在锁对象上(wait在锁对象上,长时间得不到唤醒notify)
  3. 如果Java程序和外部应用程序通信,外部应用程序阻塞时,也会导致当前Java线程挂起。

1.4.6 多个锁导致的锁链分析

A1线程等S1锁,找到S1锁的持有者A2,发现A2在等S2锁,又继续找S2锁的持有者A3,发现这个线程在执行一个很慢的任务。

相关推荐
NiNg_1_2343 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
Chrikk4 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*4 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue5 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man5 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
customer086 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
Yaml47 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
小码编匠8 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#