一、Java线程堆栈分析
Java线程堆栈是虚拟机中线程(包括锁)状态的一个瞬间快照,即系统在某个时刻所有线程的运行状态,包括每一个线程的调用堆栈、锁的持有情况等信息。每一种Java虚拟机(SUN JVM、IBM JVM、JRockit、GNU JVM等)都提供了线程转储的后门,通过这个后门可以将那个时刻的线程堆栈打印出来。虽然各种Java虚拟机在线程堆栈的输出格式上有一些不同,但是线程堆栈的信息都包含了一下内容
- 线程的名字、ID、数量等
- 线程的运行状态,锁的状态(锁被哪个线程持有,哪个线程在等待锁等)
- 调用堆栈(函数的调用层次关系)。调用堆栈包含完整的类名、执行的方法,以及源代码的行号。
线程堆栈不擅长的事情
由于线程堆栈是系统某个时刻的线程运行状况(瞬间快照),对于已经消失且没留痕迹的信息,线程堆栈是无法进行历史追踪的
。这种情况只能结合日志进行分析,如连接池中的连接被哪些线程使用且没有被释放。
- 数据混乱问题,代码逻辑问题
- 数据库锁表问题。表被锁往往是由于某个事务没有提交/回滚,但这些信息是无法在堆栈中表现出来的。
- 其他无法在线程堆栈上留有痕迹的问题,得依赖日志设计。
小技巧(new Throwable()).printStackTrace()可以在运行期打印并调用该函数的线程堆栈信息,借助这个信息就可以知道调用流程。
线程池堆栈优点和擅长的事情
- CPU的使用率无缘故过高
- 系统挂起,无响应
- 系统运行速度越来越慢
- 性能瓶颈(
如无法充分利用CPU等
我的项目也经常遇到)。 - 线程死锁、死循环等。
- 由于线程数量太多导致系统运行失败(如无法创建线程等)
1.1 打印线程堆栈
Java虚拟机提供了线程转储的后门,通过这个后门就可以将线程堆栈打印出来。向Java进程发送一个QUIT信号,Java虚拟机收到信号之后,可将系统当前的Java线程调用堆栈打印出来。 有的虚拟机(SUN JDK)能将堆栈信息打印在屏幕上,而(IBM JDK)可直接将线程堆栈打印到一个文件中,从当前的运行目录下可以找到该文件。当JDK把线程堆栈打印到屏幕上可能由于信息太多造成丢失。
- Windows:在运行Java的控制台窗口按
ctrl+break
组合键 - 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有时候都不行
- 在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,具体说明如下。
- Low Memory Detector:低内存检测线程,用于检测当前可用堆内存(HEAP)是否到达下限,以启动垃圾回收工作。
- CompilerThread0:编译线程,即将频繁调用的热字节码编译成本地代码(JIT),以提高运行效率。
- Signal Dispatcher:信号分发线程,如从终端控制台接收的各种 kill 信号(QUIT信号)等。
- Finalizer:善后线程。
- Reference Handler :引用计数线程。
- main:主线程,即执行用户main ()函数的线程。
- VM Thread: VM线程。
- 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>。某些情况下好不到:
- 在一个线程释放锁和另一个线程被唤醒之间有一个时间窗,在这期间,如果恰巧进行了堆栈转储,就会找不到locked的线程。
- 另外通过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 视角二 一次堆栈的统计信息(全局信息)
从一次堆栈的信息中,通过统计可以获得相关的影响性能的嫌疑代码,其符合典型的"笨贼原理"。例如,一个贼到西瓜地里偷西瓜,他待得越久被发现的几率就越大。
- 当前锁的争用情况:是不是很多线程在等待同一个锁呢?如果很多正在执行用户代码的线程在等待同一个锁,那么这个系统就已经出现了性能瓶颈,并导致了锁竞争。还可能是某个线程长时间持有一个锁不释放(如这个线程陷入了死循环的代码,或者正在请求一个资源,很长时间得不到唤醒)。
用户代码是指正在执行用户逻辑的代码。在Java应用中,很多情况下会使用线程池等技术,如果线程池中的线程仍在池中,那么这个线程则不在执行用户代码,在实际分析过程中,我们只关注正在执行用户代码的线程。具体哪些线程在执行用户代码,要根据调用上下文确认,如在下面的线程中,从上下文可以看出他们是线程中的待调度线程,这种线程并不需要关注。
- 当前大多数线程在干什么:在一次堆栈中,如果有很多线程在集中执行某段代码,那么突破这个点就能提升系统的性能。
- 当前线程的总数量:通过该信息可以知道系统创建线程的状况(太多、太少,或者实现机制不合理)。
1.3.3 视角三 多个堆栈的前后对比信息
从多次(前后打印多次堆栈进行对比)的堆栈信息中,还可以获得如下针对某个线程的信息。这个线程是不是长期在执行,是不是长期在等一个锁。跟二结合起来看。
1.4 借助线程堆栈进行问题分析(典型场景)
1.4.1 线程死锁分析
从打印的线程堆栈中可以看到"Found one Java-level deadlock",即如果存在线程死锁
的情况,虚拟机在打印堆栈中会直接给出死锁的分析结果。唯一的办法就是重启
。避免死锁唯一的办法就是修改代码。死锁的两个或多个线程是不消耗CPU的
。
1.4.2 Java代码死循环导致CPU过高
多次打印堆栈,还在RUNNABLE执行的线程看在干什么。也有可能是不合理的内存设置导致的GC。常见的死循环原因如下:
-
HashMap等线程不安全的容器,用在多线程读/写的场合,导致HashMap的方法调用形成死循环。在多线程中使用JDK提供的容器类作为共享变量的时候,千万不能使用线程不安全的容器类。
-
多线程场合对共享变量没有进行保护,导致数据混乱,从而使循环退出的条件永远不满足,导致死循环的发生。
- 在for、while循环中,由于退出条件永远不满足而导致死循环。
- 链表等数据结构收尾相接,导致遍历永远无法停止。
-
其他错误的编码。如果是CPU密集型的。可以
bash
top
# 按1 查看每一个核的CPU使用率
1.4.3 高消耗CPU代码的常用分析方法
造成CPU使用率异常的原因如下
- Java代码中存在死循环,导致CPU使用率过高。
- 系统存在不恰当的设计,尽管没有死循环,但CPU的使用率仍然过高。如不断的轮询。
- JNI中有死循环代码
- 堆内存设置太小造成的频繁GC。
- 在32位的JDK中,由于堆内存设置太大造成的频繁GC。
- JDK自身存在死循环的bug。
1.操作系统提供的性能分析工具
css
top -p <javapid>
# 按H查看该进程所有线程的统计情况(CPU等)
这个PID就是tid=... nid=...里面的nid,其中nid就是本地线程号,只不过线程堆栈里面的nid中用十六进制来表示。 然后再去java堆栈快照里面找这个线程id。看在干嘛。
如果在Java线程堆栈中找不到对应的线程ID,则有如下两种可能
- JNI调用中重新创建新线程来执行,那么在Java线程堆栈中就不存在对应的线程信息。
- 虚拟机自身代码导致的CPU使用率过高,如堆内存枯竭导致的频繁Full GC(完全垃圾回收),或者虚拟机的BUg等。
这种方式对系统的消耗最小,非常适合在生产环境中使用。
2.Xrunprof协助分析
虚拟机自身也提供了一些CPU剖析工具,借助这些工具可以获知哪些代码段消耗了更多的CPU,从而找到可疑的性能点。
3.JProftler和Optimizelt等工具
一些商业化分析CPU的工具。
4.多次打印堆栈。
第一种和第四种相比二三对系统的消耗最小。
1.4.4 资源不足等导致性能下降的分析
这里所说的资源包括数据库连接等。大多数时候资源不足和性能瓶颈是同一类问题。如果资源不足,就会导致资源争用,请求该资源的线程会被阻塞或者挂起(wait),自然就导致性能下降。
系统对于资源一般的设计模式是:当需要资源的时候,就获取资源;当不需要的时候,就把资源释放;如果暂时没有可用资源,那么就等待(阻塞)在那里(等在一个资源锁上面);如果有别的线程释放资源,那么等待的线程被notify()后获得资源继续运营(一般资源的设计都是遵循wait/notify模式)
资源不足的时候,就会有大量的线程在等待资源,停在同样的调用上下文上。
- 资源数量配置太少
- 获得资源的线程把持资源太久。
- 如SQL语句没有索引导致数据库访问过慢。
- 资源用完后,在某种情况下,没有关闭或回池,导致可用资源泄漏或者减少,从而导致资源竞争。
1.4.5 线程不退出导致系统挂死的分析
系统挂死的特征是线程一直都在同一个调用上下文上。间隔Nmin多打印几次一直在执行任务的线程。 可能原因
- 线程正在执行死循环代码
- 资源不足或资源泄漏,造成当前线程阻塞在锁对象上(wait在锁对象上,长时间得不到唤醒notify)
- 如果Java程序和外部应用程序通信,外部应用程序阻塞时,也会导致当前Java线程挂起。
1.4.6 多个锁导致的锁链分析
A1线程等S1锁,找到S1锁的持有者A2,发现A2在等S2锁,又继续找S2锁的持有者A3,发现这个线程在执行一个很慢的任务。