什么是线程堆栈
Java线程堆栈是虚拟机中线程(包括锁)状态的一个瞬间快照,即系统在某个时刻所有线程的运行状态,包括每一个线程的调用堆栈,锁的持有情况等信息。每一种Java虚拟机都提供了线程转储(thread dump)的后门, 通过这个后门可以将那个时刻的线程堆栈打印出来。借助线程堆栈,可以分析许多问题,如线程死锁、锁争用、死循环、识别耗时操作等等。在多线程场合下的稳定性问题分析和性能问题分析,线程堆栈分析是最有效的方法,在多数情况下甚至无需对系统了解就可以进行相应的分析。
线程堆栈的信息主要包含: 1. 线程的名字,ID,线程的数量等。 2. 线程的运行状态,锁的状态(锁被哪个线程持有,哪个线程再等待锁等)。 3. 调用堆栈(即函数的调用层次关系)。调用堆栈包含完整的类名,所执行的方法,源代码的行数。
怎么获取线程堆栈信息
Java虚拟机提供了线程转储(Thread dump)的后门。通过如下的命令行方式向Java进程请求堆栈输出:
window:在运行Java的控制窗口上按ctrl + break组合键
linux:先使用 ps -ef|grep java 查看java进程,再选择一个进程pid,使用kill -3 pid 输出堆栈信息
(在Java程序中可以通过Thread.getStackTrace()控制堆栈自动打印堆栈信息)
如何解读线程堆栈信息
Java线程和本地线程基础信息
比如运行如下代码:
public class MyTest {
Object obj1 = new Object();
Object obj2 = new Object();
public void fun1() {
synchronized (obj1) {
fun2();
}
}
public void fun2() {
synchronized (obj2) {
while (true) {
}
}
}
public static void main(String[] args) {
MyTest mt = new MyTest();
mt.fun1();
}
}
编译运行,通过命令 kill -3 pid 得到如下堆栈信息(省略了某些无关的线程地址信息):
首先我们可以看到系统当前共有如下线程:Low Memory Detector、 CompilerThread0、Signal Dispatcher、Finalizer、Reference Handler、main、VM Thread、VM Periodic Task Thread共八个,其中只有第6个main线程属于Java用户线程,其它七个都是由虚拟机自动创建的,我们在实际分析的过程中,只关心Java用户线程即可。
具体分析第6个main线程,我们从下往上可以看到函数的调用关系,即从 哪个函数中调用到哪个函数中,正执行到哪个类的哪一行。另外,从main线程的堆栈中,有"- locked (a java.lang.Object)"语句,这表示 该线程(即main线程)已经占有了锁,其中0xc8c1a090表示锁ID,这个锁的ID是系统自动产生的,我们只需要知道每次打印的堆栈,同一个ID表示是同一个锁即可。
每一个线程堆栈的第一行含义如下图所示,其中"线程对应的本地线程id号"所指的"本地线程"是指该Java线程所对应的虚拟机中的本地线程。(Java执行的实体是Java虚拟机,因此Java语言中的线程是依附于Java虚拟机中的本地线程来运行的,实际上是本地线程在执行Java线程代码。Java代码 中创建一个thread,虚拟机在运行期就会创建一个对应的本地线程,而这个本地线程才是真正 的线程实体。)
在linux系统中可以通过ps -ef | grep java 获得Java进程ID,再通过用 pstack <java pid> 获得Java虚拟机的本地线程的堆栈(打印出来有8个本地线程,与Java线程堆栈中的线程数量相同,并一一对应,这里只展示其中一个与Java中main线程对应的本地线程信息):
Java线程中的nid就是native thread id,也就是指的本地线程中的LWPID,二者是相同的,只不过java线程中的nid中用16进制来表示,而本地线程中的id用十进制表示。
另外,我们常在线程堆栈中发现".<init>"或者".<clinit>"字样的函数,比如下面两个堆栈信息,其中".<init>"表示当前正在执行类的初始化。".<clinit>"表正在执行对象的构造函数。
还有堆栈信息里面包含"Native Method",或者"Compiled Code"。
锁的信息
Java堆栈中与锁相关的三个重要信息如下:
- 当一个线程占有一个锁的时候,线程堆栈中会打印—locked
- 当一个线程正在等待其它线程释放该锁,线程堆栈中会打印—waiting to lock
- 当一个线程占有一个锁,但又执行到该锁的wait()上,线程堆栈中首先打印locked,然后又会打印—waiting on
例如执行下面代码:
public class ThreadTest {
public static void main(String[] args) {
Object share = new Object();
TestThreadLocked thread1 = new TestThreadLocked(share);
thread1.start();
TestThreadWaitingTo thread2 = new TestThreadWaitingTo(share);
thread2.start();
TestThreadWaitingOn thread3 = new TestThreadWaitingOn();
thread3.start();
}
}
class TestThreadLocked extends Thread {
Object lock = null;
public TestThreadLocked(Object lock) {
this.lock = lock;
this.setName(this.getClass().getName());
}
@Override
public void run() {
fun();
}
public void fun() {
synchronized (lock) {
funLongTime();
}
}
public void funLongTime() {
try {
Thread.sleep(20000);
} catch (Exception e) {
e.printStackTrace();
}
}
}
class TestThreadWaitingOn extends Thread {
Object lock = new Object();
public TestThreadWaitingOn() {
this.setName(this.getClass().getName());
}
@Override
public void run() {
fun();
}
public void fun() {
synchronized (lock) {
funWait();
}
}
public void funWait() {
try {
lock.wait(100000);
} catch (Exception e) {
e.printStackTrace();
}
}
}
class TestThreadWaitingTo extends Thread {
Object lock = null;
public TestThreadWaitingTo(Object lock) {
this.lock = lock;
this.setName(this.getClass().getName());
}
@Override
public void run() {
fun();
}
public void fun() {
synchronized (lock) {
funLongTime();
}
}
public void funLongTime() {
try {
Thread.sleep(20000);
} catch (Exception e) {
e.printStackTrace();
}
}
}
(sleep方法是Thread线程类的方法,调用的时候没有释放锁,时间到了会自动退出阻塞,继续拿着锁执行任务;而wait方法是Object类的实例对象的方法,调用的时候释放了对象锁,同时如果不加时间的话不会自动退出阻塞,需要别的线程用同一个对象notify这个线程。)
输出的堆栈信息:
从上面这个例子中,可以很清晰地看出,在线程堆栈中与锁相关的三个最重要的特征字: locked,waiting to lock,waiting on,了解这三个特征字,就能够对锁进行分析了。 一般情况下,当一个(些)线程在等待一个锁时,应该有一个线程占用这个锁,即如果有的线程在等待一个锁,该锁必然被另一个线程占有了,也就是说,从打印的堆栈中如果能看 到waiting to lock ,应该也应该能找到一个线程locked , 大多数情况确实如此,但在有些情况下,你会发现堆栈中可能根本就没有locked ,而只 有wainting to. 这是什么原因呢? 实际上,在一个线程释放锁和另一个线程被唤醒之间有一 个时间窗,在这期间,如果恰巧进行了堆栈转储,那么就会发生上面所介绍的堆栈,只能找到 一个锁的wainting to,但找不到locked该锁的线程。另外,当通过kill -3 (unix/linux)或 者+(windows)向虚拟机进程发送信号,请求输出线程堆栈时,有的虚拟机有不同的实现策略,并不一定立即响应该请求,也许会等待正在执行的线程执行完成,然后才打印堆栈。在实际的应用中看,IBM的JDK打印出的堆栈,经常能找到一个锁的wainting to线程,但找不到locked该锁的线程;而SUN的JDK绝大多数都是配对出现的。
线程的状态信息
我们在Java线程堆栈信息的第一行可以看到线程的运行状态,比如下面的线程就处于runnable状态:
Java堆栈中线程状态主要可以分为以下几类:
- RUNNABLE:从虚拟机的角度看,线程处于正在运行状态
- TIMED_WAITING(on object monitor) :表示当前线程被挂起一段时间,说明该线程正在执行obj.wait(int time)方法
- TIMED_WAITING(sleeping) :表示当前线程被挂起一段时间,即正在执行Thread.sleep(int time)方法
- TIMED_WAITING(parking) :当前线程被挂起一段时间,即正在执行Thread.sleep(int time)方法
- WAINTING(on object monitor) :当前线程被挂起,即正在执行obj.wait()方法(无参数的wait()方法)
RUNNABLE
网络IO型线程
处于Runnable的线程不一定会消耗cpu,只能说明该线程没有阻塞在java的wait或者sleep方法上,同时也没等待在锁上面。如果该线程调用了本地方法,而本地方法处于等待状态,这时实际上是不会消耗cpu的,但Java线程显示出来的还是runnable状态。如下面的线程堆栈:
该线程处于runnable状态,而它正在调用如下的本地方法:
at java.net.SocketInputStream.socketRead0(Native Method)
但实际上像读socket的本地方法大多数时间是阻塞的,除非socket的缓冲区中有数据,底层的TCP/IP协议栈将唤醒阻塞的线程。因此虽然线程处于"runnable"状态但不意味这个线程正在消耗CPU。
纯cpu运算型线程
若线程主要就是在执行java代码指令,那就实实在在是消耗cpu的线程,比如:
执行本地方法型线程
下面的线程正在进行JNI本地方法调用,具体是否消耗CPU,要看TcpRecvExt的实现,如果TcpRecvExt 是纯运算代码,那么是实实在在消耗CPU,如果TcpRecvExt()中存在挂起的代码,那么该线程尽管显示为RUNNABLE,但实际上也是不消耗CPU的。
TIMED_WAITING(on object monitor)
下面的线程堆栈表示当前线程正处于TIMED_WAITING状态,当前正在被挂起,时长为参数中指定的时长,如obj.wait(2000)。因此该线程当前不消耗CPU:
TIMED_WAITING(sleeping)
下面的线程正处于TIMED_WAITING状态,表示当前被挂起一段时间,时长为参数中指定的时长,如Thread.sleep(100000)。因此该线程当前不消耗CPU:
TIMED_WAITING(parking)
下面的线程正处于TIMED_WAITING状态,表示当前被挂起一段时间,时长为参数中指定的时长,如LockSupport.parkNanos(blocker, l10000) 。因此该线程当前不消耗CPU:
WAINTING(on object monitor)
下面的线程正处于WAITING状态,表示当前线程被挂起,如obj.wait()(只能通过notify()唤醒)。因此该线程当前不消耗CPU:
总结
处于TIMED_WAITING、WAINTING状态的线程一定不消耗CPU,处于RUNNABLE的线程,要结合当前线程代码的性质判断,是否消耗CPU:
- 如果是网络IO,很少消耗CPU
- 如果是纯Java运算代码,则消耗CPU
- 如果是本地代码,结合本地代码的性质判断(可以通过pstack/gstack获取本地线程堆栈),如果是纯运算代码,则消耗CPU, 如果被挂起,则不消耗CPU,如果是IO,则不怎么消耗CPU
借助堆栈分析具体问题
线程死锁分析
当两个或多个线程正在等待被对方占有的锁,死锁就会发生。死锁会导致 两个线程无法继续运行,被永远挂起。比如运行如下代码:
public class TestDeadLock {
public static void main(String[] args) {
Object lock1 = new Object();
Object lock2 = new Object();
TestThread1 thread1 = new TestThread1(lock1, lock2);
thread1.start();
TestThread2 thread2 = new TestThread2(lock1, lock2);
thread2.start();
}
static class TestThread1 extends Thread {
Object lock1 = null;
Object lock2 = null;
public TestThread1(Object lock1, Object lock2) {
this.lock1 = lock1;
this.lock2 = lock2;
this.setName(this.getClass().getName());
}
@Override
public void run() {
fun();
}
public void fun() {
synchronized (lock1) {
try {
Thread.sleep(2);
} catch (Exception e) {
e.printStackTrace();
}
new Throwable().printStackTrace();
synchronized (lock2) {
}
}
}
}
static class TestThread2 extends Thread {
Object lock1 = null;
Object lock2 = null;
public TestThread2(Object lock1, Object lock2) {
this.lock1 = lock1;
this.lock2 = lock2;
this.setName(this.getClass().getName());
}
@Override
public void run() {
fun();
}
public void fun() {
synchronized (lock2) {
try {
Thread.sleep(2);
} catch (Exception e) {
e.printStackTrace();
}
synchronized (lock1) {
}
}
}
}
}
获取下面的堆栈信息:
从打印的线程堆栈中我们能看到"Found one Java-level deadlock",即如果存在线程死锁情况, 堆栈中会直接给出死锁的分析结果。另外从线程信息中也可以看到,TestThread2等待的锁被TestThread1占有,TestThread1等待的锁被TestThread2占有,从而形成死锁。
另外,死锁的两个或多个线程是不消耗CPU的,有的人认为CPU 100%的使用率是线程死锁导致的,这个说法是完全错误的。无限循环(即死循环),并且在循环中代码都是CPU密集型,才有可能导致CPU的100%使用率,像socket或者数据库等IO操作是不怎么消耗CPU的。
cpu过高问题分析
首先cpu过高的原因可能有:系统负载太大、存在不恰当或者死循环的Java代码、堆内存大小设置不当导致频繁gc等。我们可以通过线程堆栈信息来分析由于代码问题导致的cpu过高。
想解决问题首先要定位到问题,再去针对性的修改:
1、在linux中输入 top -p<pid> 命令(pid就是Java进程id)
2、输入 H 查看该进程下所有的本地线程的统计情况,并记录消耗cpu最多的本地线程,如:
3、通过kill -3 pid 输出Java堆栈信息,我们在Java堆栈中查找nid=<第1步获得的最耗CPU时间的线程id>的线程(注意:我们之前提到过java线程中的nid是十六进制,本地线程中的id是十进制,需要转化单位后进行对应),这个线程的堆栈信息就可以帮我我们定位到问题具体出现在什么方法的多少行代码。
如果定位到的java线程执行的是纯java代码,那可能就是Java代码导致的cpu过高,比如:
如果定位到的java线程正在执行native代码,那可能导致cpu过高的代码在JNI调用中,比如:
这种定位方式由于能够直接定位到特定的线程ID,因此基本上能够一次命中问题,是最为有效的一种方式。不管什么原因导致的CPU过高,通过这种方式都能查出来。这种方式对系统的消耗最小,非常适合在生产环境使用。
(另外,通过多次打印堆栈,挑选多次打印结果中一直存活的线程,如果多次结果中同一个线程处于同样的调用上下文,则该线程所执行的代码也可能是导致cpu过高的原因)
资源不足等导致的性能下降分析
这里所说的资源包括数据库连接等。大多时候资源不足和性能瓶颈是同一类问题。当资源不足,就会导致资源争用,请求该资源的线程会被阻塞或挂起,自然就导致性能下降。
对于资源不足的导致的性能瓶颈,打印出的线程堆栈有如下特点:大量的线程停在同样的调用上下文。
导致资源不足的原因可能:(1)资源数配置太少(2)获得资源的线程把持资源时间太久,导致资源不足(3)资源用完后,在某种情况下,没有关闭或回池,导致可用资源泄露或减少。
线程不退出导致的系统挂死分析
导致系统挂死的原因有很多,其中有一个最常见的原因是线程挂死。
具体导致线程无法退出的原因有很多:
(1)线程正在执行死循环
(2)资源不足或资源泄露,造成当前线程阻塞在锁对象上(即wait在锁对象上)长期得不到唤醒notify
(3)如果当前程序和外部通信,当外部程序挂起无返回时,也会导致当前线程挂起。