java中WAITING状态的线程为啥还会消耗CPU

public static enum Thread.Stateextends Enum线程状态。线程可以处于下列状态之一:

线程的生命周期包括哪几个阶段

面试官:您知道线程的生命周期包括哪几个阶段?

应聘者:

线程的生命周期包含 5 个阶段,包括:新建、就绪、运行、阻塞、销毁。

  • 新建:就是刚使用 new 方法,new 出来的线程;

  • 就绪:就是调用的线程的 start() 方法后,这时候线程处于等待 CPU 分配资源阶段,谁先抢的 CPU 资源,谁开始执行;

  • 运行:当就绪的线程被调度并获得 CPU 资源时,便进入运行状态,run 方法定义了线程的操作和功能;

  • 阻塞:在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态,比如 sleep()、wait() 之后线程就处于了阻塞状态,这个时候需要其他机制将处于阻塞状态的线程唤醒,比如调用 notify 或者 notifyAll() 方法。唤醒的线程不会立刻执行 run 方法,它们要再次等待 CPU 分配资源进入运行状态;

  • 销毁:如果线程正常执行完毕后或线程被提前强制性的终止或出现异常导致结束,那么线程就要被销毁,释放资源;

完整的生命周期图如下:

image

新建状态

我们来看下面一段代码:

Thread t1 = new Thread();

这里的创建,仅仅是在 JAVA 的这种编程语言层面被创建,而在操作系统层面,真正的线程还没有被创建。只有当我们调用了 start() 方法之后,该线程才会被创建出来,进入 Runnable 状态。只有当我们调用了 start() 方法之后,该线程才会被创建出来

image

就绪状态

调用 start() 方法后,JVM 进程会去创建一个新的线程,而此线程不会马上被 CPU 调度运行,进入 Running 状态,这里会有一个中间状态,就是 Runnable 状态,你可以理解为等待被 CPU 调度的状态

t1.start()

用一张图表示如下:

image

那么处于 Runnable 状态的线程能发生哪些状态转变?

image

Runnable 状态的线程无法直接进入 Blocked 状态和 Terminated 状态的。只有处在 Running 状态的线程,换句话说,只有获得 CPU 调度执行权的线程才有资格进入 Blocked 状态和 Terminated 状态,Runnable 状态的线程要么能被转换成 Running 状态,要么被意外终止。

运行状态

当 CPU 调度发生,并从任务队列中选中了某个 Runnable 线程时,该线程会进入 Running 执行状态,并且开始调用 run() 方法中逻辑代码。

那么处于 Running 状态的线程能发生哪些状态转变?

image
  • 被转换成 Terminated 状态,比如调用 stop() 方法;

  • 被转换成 Blocked 状态,比如调用了 sleep, wait 方法被加入 waitSet 中;

  • 被转换成 Blocked 状态,如进行 IO 阻塞操作,如查询数据库进入阻塞状态;

  • 被转换成 Blocked 状态,比如获取某个锁的释放,而被加入该锁的阻塞队列中;

  • 该线程的时间片用完,CPU 再次调度,进入 Runnable 状态;

  • 线程主动调用 yield 方法,让出 CPU 资源,进入 Runnable 状态

阻塞状态

Blocked 状态的线程能够发生哪些状态改变?

image
  • 被转换成 Terminated 状态,比如调用 stop() 方法,或者是 JVM 意外 Crash;

  • 被转换成 Runnable 状态,阻塞时间结束,比如读取到了数据库的数据后;

  • 完成了指定时间的休眠,进入到 Runnable 状态;

  • 正在 wait 中的线程,被其他线程调用 notify/notifyAll 方法唤醒,进入到 Runnable 状态;

  • 线程获取到了想要的锁资源,进入 Runnable 状态;

  • 线程在阻塞状态下被打断,如其他线程调用了 interrupt 方法,进入到 Runnable 状态;

终止状态

一旦线程进入了 Terminated 状态,就意味着这个线程生命的终结,哪些情况下,线程会进入到 Terminated 状态呢?

  • 线程正常运行结束,生命周期结束;

  • 线程运行过程中出现意外错误;

  • JVM 异常结束,所有的线程生命周期均被结束。

synchronized底层实现

参考原文

synchronized不论是修饰方法还是代码块,都是通过持有修饰对象的锁来实现同步,那么synchronized锁对象是存在哪里的呢?答案是存在锁对象的对象头Mark Word,来看一下Mark Word存储了哪些内容?

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下 (32位虚拟机):

其中轻量级锁和偏向锁是Java 6 对 synchronized 锁进行优化后新增加的,稍后我们会简要分析。这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的),省略部分属性

ObjectMonitor() {
_count = 0; //记录数
_recursions = 0; //锁的重入次数
_owner = NULL; //指向持有ObjectMonitor对象的线程
_WaitSet = NULL; //调用wait后,线程会被加入到_WaitSet
_EntryList = NULL ; //等待获取锁的线程,会被加入到该列表
}

结合线程状态解释一下执行过程。(状态装换参考自《深入理解Java虚拟机》)

新建(New),新建后尚未启动的线程
运行(Runable),Runnable包括了操作系统线程状态中的Running和Ready
无限期等待(Waiting),不会被分配CPU执行时间,要等待被其他线程显式的唤醒。例如调用没有设置Timeout参数的Object.wait()方法
限期等待(Timed Waiting),不会被分配CPU执行时间,不过无需等待其他线程显示的唤醒,在一定时间之后会由系统自动唤醒。例如调用Thread.sleep()方法
阻塞(Blocked),线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待获取着一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生,而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态
结束(Terminated):线程结束执行

对于一个synchronized修饰的方法(代码块)来说:

当多个线程同时访问该方法,那么这些线程会先被放进_EntryList队列,此时线程处于blocked状态
当一个线程获取到了对象的monitor后,那么就可以进入running状态,执行方法,此时,ObjectMonitor对象的/_owner指向当前线程,_count加1表示当前对象锁被一个线程获取
当running状态的线程调用wait()方法,那么当前线程释放monitor对象,进入waiting状态,ObjectMonitor对象的/_owner变为null,_count减1,同时线程进入_WaitSet队列,直到有线程调用notify()方法唤醒该线程,则该线程进入_EntryList队列,竞争到锁再进入_Owner区
如果当前线程执行完毕,那么也释放monitor对象,ObjectMonitor对象的/_owner变为null,_count减1
由此看来,monitor对象存在于每个Java对象的对象头中(存储的是指针),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因

Java.Thread.State状态

1.NEW
至今尚未启动的线程的状态。

2.RUNNABLE
可运行线程的线程状态。处于可运行状态的某一线程正在 Java 虚拟机中运行,但它可能正在等待操作系统中的其他资源,比如处理器。

3.BLOCKED
受阻塞并且正在等待监视器锁的某一线程的线程状态。处于受阻塞状态的某一线程正在等待监视器锁,以便进入一个同步的块/方法,或者在调用 Object.wait 之后再次进入同步的块/方法。

4.WAITING
某一等待线程的线程状态。某一线程因为调用下列方法之一而处于等待状态:

不带超时值的 Object.wait
不带超时值的 Thread.join
LockSupport.park

处于等待状态的线程正等待另一个线程,以执行特定操作。 例如,已经在某一对象上调用了 Object.wait() 的线程正等待另一个线程,以便在该对象上调用 Object.notify() 或 Object.notifyAll()。已经调用了 Thread.join() 的线程正在等待指定线程终止。

5.TIMED_WAITING具有指定等待时间的某一等待线程的线程状态。某一线程因为调用以下带有指定正等待时间的方法之一而处于定时等待状态:

Thread.sleep
带有超时值的 Object.wait
带有超时值的 Thread.join
LockSupport.parkNanos
LockSupport.parkUntil

6.TERMINATED
已终止线程的线程状态。线程已经结束执行。

线程状态与CPU占用的关系

参考原文
原文

背景

刚刚过去的双十一, 公司订单量又翻了一倍. 就在老板坐在办公室里面偷偷笑的同时,坐在工位上的我们却是一直瑟瑟发抖. 面对zabbix里面时不时蹦出来的一条条CPU告警,默默地祈祷着不要出问题.

当然, 祈祷是解决不了问题的, 即使是开过光的服务器也不行. CPU告警了, 还得老老实实地去看为啥CPU飚起来了.

接下来就是CPU排查三部曲

1. top -Hp $pid 找到最耗CPU的线程.  2. 将最耗CPU的线程ID转成16进制 3. 打印jstack, 到jstack里面查这个线程在干嘛 复制代码

当然 如果你线上环境有装arthas等工具的话, 直接thread -n就可以打印出最耗cpu的n个线程的堆栈,三个步骤一起帮你做了.

最后找到最耗cpu的线程堆栈如下:

"operate-api-1-thread-6" #1522 prio=5 os\_prio=0 tid=0x00007f4b7006f800 nid=0x1b67c waiting on condition \[0x00007f4ac8c4a000\]    java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for  <0x00000006c10828c8> (a java.util.concurrent.locks.ReentrantLock$NonfairSync) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:836) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:870) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1199) at java.util.concurrent.locks.ReentrantLock$NonfairSync.lock(ReentrantLock.java:209) at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:285) at ch.qos.logback.core.OutputStreamAppender.subAppend(OutputStreamAppender.java:210) at ch.qos.logback.core.rolling.RollingFileAppender.subAppend(RollingFileAppender.java:235) at ch.qos.logback.core.OutputStreamAppender.append(OutputStreamAppender.java:100) at ch.qos.logback.core.UnsynchronizedAppenderBase.doAppend(UnsynchronizedAppenderBase.java:84) at ch.qos.logback.core.spi.AppenderAttachableImpl.appendLoopOnAppenders(AppenderAttachableImpl.java:51) at ch.qos.logback.classic.Logger.appendLoopOnAppenders(Logger.java:270) at ch.qos.logback.classic.Logger.callAppenders(Logger.java:257) at ch.qos.logback.classic.Logger.buildLoggingEventAndAppend(Logger.java:421) at ch.qos.logback.classic.Logger.filterAndLog\_0_Or3Plus(Logger.java:383) at ch.qos.logback.classic.Logger.info(Logger.java:579) ... 复制代码

值得一提的是, 类似的线程还有800多个... 只是部分没有消耗CPU而已

问题

很明显, 这是因为logback打印日志太多了造成的(此时应有一个尴尬而不失礼貌的假笑).

当大家都纷纷转向讨论接下来如何优化logback和打日志的时候. 我却眉头一皱, 觉得事情并没有那么简单:

这个线程不是被LockSupport.park挂起了, 处于WAITING状态吗? 被挂起即代表放弃占用CPU了, 那为啥还会消耗CPU呢?

来看一下LockSupport.park的注释, 明确提到park的线程不会再被CPU调度了的:

   /**      * Disables the current thread for thread scheduling purposes unless the      * permit is available.      *      * 

If the permit is available then it is consumed and the call * returns immediately; otherwise the current thread becomes disabled * for thread scheduling purposes and lies dormant until one of three * things happens: * */ public static void park() { UNSAFE.park(false, 0L); } 复制代码

实验见真知

带着这个疑问, 我在stackoverflow搜索了一波, 发现还有不少人有这个疑问

  1. stackoverflow.com/questions/1…

  2. stackoverflow.com/questions/5…

  3. stackoverflow.com/questions/1…

  4. stackoverflow.com/questions/3…

  5. stackoverflow.com/questions/5…

上面好几个问题内容有点多, 我也懒得翻译了, 直接总结结论:

1. 处于waittig和blocked状态的线程都不会消耗CPU 2. 线程频繁地挂起和唤醒需要消耗CPU, 而且代价颇大 复制代码

但这是别人的结论, 到底是不是这样的呢. 下面我们结合visualvm来做一下实验.

有问题的代码

首先来看一段肯定会消耗100%CPU的代码:

package com.test; public class TestCpu {     public static void main(String\[\] args) {          while(true){          }     } } 复制代码

visualvm显示CPU确实消耗了1个核, main线程也是占用了100%的CPU:

[图片上传失败...(image-897af1-1636004498399)]

[图片上传失败...(image-6a087b-1636004498400)]

被park的线程

然后来看一下park的线程是否会消耗cpu

代码:

import java.util.concurrent.locks.LockSupport; public class TestCpu {     public static void main(String\[\] args) {         while(true){             LockSupport.park();         }     } } 复制代码

visualvm显示一切波澜不惊,CPU毫无压力 :

[图片上传失败...(image-56c905-1636004498400)]

[图片上传失败...(image-30afdc-1636004498400)]

发生死锁的线程

再来看看blocked的线程是否消耗CPU. 而且我们这次玩大一点, 看看出现了死锁的话,会不会造成CPU飙高.(死锁就是两个线程互相block对方)

死锁代码如下:

package com.test; public class DeadLock {     static Object lock1 = new Object();     static Object lock2 = new Object();     public static class Task1 implements Runnable {         @Override         public void run() {             synchronized (lock1) {                 System.out.println(Thread.currentThread().getName() + " 获得了第一把锁!!");                 try {                     Thread.sleep(50);                 } catch (InterruptedException e) {                     e.printStackTrace();                 }                 synchronized (lock2) {                     System.out.println(Thread.currentThread().getName() + " 获得了第二把锁!!");                 }             }         }     }     public static class Task2 implements Runnable {         @Override         public void run() {             synchronized (lock2) {                 System.out.println(Thread.currentThread().getName() + " 获得了第二把锁!!");                 synchronized (lock1) {                     System.out.println(Thread.currentThread().getName() + " 获得了第一把锁!!");                 }             }         }     }     public static void main(String\[\] args) throws InterruptedException {         Thread thread1 = new Thread(new Task1(), "task-1");         Thread thread2 = new Thread(new Task2(), "task-2");         thread1.start();         thread2.start();         thread1.join();         thread2.join();         System.out.println(Thread.currentThread().getName() + " 执行结束!");     } } 复制代码

[图片上传失败...(image-46a180-1636004498400)]

[图片上传失败...(image-7c9652-1636004498400)]

[图片上传失败...(image-170b8e-1636004498400)]

也是可以看到虽然visualVm能检测到了死锁, 但是整个JVM消耗的CPU并没有什么大的起伏的. 也就是说就算是出现了死锁,理论上也不会影响到系统CPU.

当然,虽然死锁不会影响到CPU, 但是一个系统的资源并不只有CPU这一种, 死锁的出现还是有可能导致某种资源的耗尽,而最终导致服务不可用, 所以死锁还是要避免的.

频繁切换线程上下文的场景

最后, 来看看大量线程切换是否会影响到JVM的CPU.

我们先生成数2000个线程, 利用jdk提供的LockSupport.park()不断挂起这些线程. 再使用LockSupport.unpark(t)不断地唤醒这些线程. 唤醒之后又立马挂起. 以此达到不断切换线程的目的.

代码如下:

package com.test; import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.concurrent.locks.LockSupport; public class TestCpu {     public static void main(String\[\] args) {         int  threadCount = 2000;         if(args.length > 0){             threadCount = Integer.parseInt(args\[0\].trim());         }         final List list = new ArrayList<>(threadCount);         // 启动threadCount个线程, 不断地park/unpark, 来表示线程间的切换         for(int i =0; i{                 while(true){                     LockSupport.park();                     System.out.println(Thread.currentThread() +" was unpark");                 }             });             thread.setName("cpuThread" + i);             list.add(thread);             thread.start();         }         // 随机地unpark某个线程         while(true){             int i = new Random().nextInt(threadCount);             Thread t = list.get(i);             if(t != null){                 LockSupport.unpark(t);             }             try {                 Thread.sleep(1000);             } catch (InterruptedException e) {                 e.printStackTrace();             }finally {             }         }     } } 复制代码

再观察visualVm, 发现整个JVM的CPU的确开始升高了, 但是具体到线程级别, 会发现每个线程都基本不耗CPU. 说明CPU不是这些线程本身消耗的. 而是系统在进行线程上下文切换时消耗的:

jvm的cpu情况:

[图片上传失败...(image-eba20d-1636004498400)]

每个线程的占用cpu情况:

[图片上传失败...(image-f38e54-1636004498400)]

分析和总结

再回到我们文章开头的线程堆栈(占用了15%的CPU):

"operate-api-1-thread-6" #1522 prio=5 os\_prio=0 tid=0x00007f4b7006f800 nid=0x1b67c waiting on condition \[0x00007f4ac8c4a000\]    java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for  <0x00000006c10828c8> (a java.util.concurrent.locks.ReentrantLock$NonfairSync) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:836) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:870) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1199) at java.util.concurrent.locks.ReentrantLock$NonfairSync.lock(ReentrantLock.java:209) at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:285) at ch.qos.logback.core.OutputStreamAppender.subAppend(OutputStreamAppender.java:210) at ch.qos.logback.core.rolling.RollingFileAppender.subAppend(RollingFileAppender.java:235) at ch.qos.logback.core.OutputStreamAppender.append(OutputStreamAppender.java:100) at ch.qos.logback.core.UnsynchronizedAppenderBase.doAppend(UnsynchronizedAppenderBase.java:84) at ch.qos.logback.core.spi.AppenderAttachableImpl.appendLoopOnAppenders(AppenderAttachableImpl.java:51) at ch.qos.logback.classic.Logger.appendLoopOnAppenders(Logger.java:270) at ch.qos.logback.classic.Logger.callAppenders(Logger.java:257) at ch.qos.logback.classic.Logger.buildLoggingEventAndAppend(Logger.java:421) at ch.qos.logback.classic.Logger.filterAndLog\_0_Or3Plus(Logger.java:383) at ch.qos.logback.classic.Logger.info(Logger.java:579) ... 复制代码

上面论证过了,WAITING状态的线程是不会消耗CPU的, 所以这里的CPU肯定不是挂起后消耗的, 而是挂起前消耗的.

那是哪段代码消耗的呢? 答案就在堆栈中的这段代码:

at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1199) 复制代码

众所周知, ReentrantLock的底层是使用AQS框架实现的. AQS大家可能都比较熟悉, 如果不熟悉的话这里可以大概描述一下AQS:

1. AQS有个临界变量state,当一个线程获取到state==0时, 表示这个线程进入了临界代码(获取到锁), 并原子地把这个变量值+1 2. 没能进入临界区(获取锁失败)的线程, 会利用CAS的方式添加到到CLH队列尾去, 并被LockSupport.park挂起. 3. 当线程释放锁的时候, 会唤醒head节点的下一个需要唤醒的线程(有些线程cancel了就不需要唤醒了) 4. 被唤醒的线程检查一下自己的前置节点是不是head节点(CLH队列的head节点就是之前拿到锁的线程节点)的下一个节点, 如果不是则继续挂起, 如果是的话, 与其他线程重新争夺临界变量,即重复第1步 复制代码

CAS

在AQS的第2步中, 如果竞争锁失败的话, 是会使用CAS乐观锁的方式添加到队列尾的, 核心代码如下:

   /**      * Inserts node into queue, initializing if necessary. See picture above.      * @param node the node to insert      * @return node's predecessor      */     private Node enq(final Node node) {         for (;;) {             Node t = tail;             if (t == null) { // Must initialize                 if (compareAndSetHead(new Node()))                     tail = head;             } else {                 node.prev = t;                 if (compareAndSetTail(t, node)) {                     t.next = node;                     return t;                 }             }         }     } 复制代码

看上面的这段代码, 设想在极端情况下(并发量非常高的情况下), 每一次执行compareAndSetTail都失败(即返回false)的话,那么这段代码就相当是一个while(true)死循环.

在我们的实际案例中, 虽然不是极端情况, 但是并发量也是极高的(每一个线程每时每刻都在调用logback打日志), 所以在某些情况下, 个别线程会在这段代码自旋过久而长期占用CPU, 最终导致CPU告警

CAS也是一种乐观锁, 所谓乐观就是认为竞争情况比较少出现. 所以CAS是不适合用于锁竞争严重的场景下的,锁竞争严重的场景更适合使用悲观锁, 那样线程被挂起了,会更加节省CPU

AQS中线程上下文切换

在实际的环境中, 如果临界区的代码执行时间比较短的话(logback写日志够短了吧), 上面AQS的第3,第4步也是会导致CLH队列的线程被频繁唤醒,而又由于抢占锁失败频繁地被挂起. 因此也会带来大量的上下文切换, 消耗系统的cpu资源.

从实验结果来看, 我觉得这个原因的可能性更高.

延伸思考

所谓cpu偏高就是指"cpu使用率"过高. 举例说1个核的机器,CPU使用100%, 8个核使用了800%,都表示cpu被用满了.那么1核的90%, 8核的700%都可以认为cpu使用率过高了.

cpu被用满的后果就是操作系统的其他任务无法抢占到CPU资源. 在window上的体现就是卡顿,鼠标移动非常不流畅.在服务器端的体现就是整个JVM无法接受新的请求, 当前的处理逻辑也无法进行而导致超时,对外的表现就是整个系统不可用.

CPU% = (1 - idleTime / sysTime) * 100 idleTime: CPU空闲时间 sysTime: CPU在用户态和内核态的使用时间之和 复制代码

cpu是基于时间片调度的. 理论上不管一个线程处理时间有多长, 它能运行的时间也就是一个时间片的时间, 处理完后就得释放cpu. 然而它释放了CPU后, 还是会立马又去抢占cpu,而且抢到的概率是一样的. 所以从应用层面看, 有时还是可以看到这个线程是占用100%的

最后,从经验来看, 一个JVM系统的CPU偏高一般就是以下几个原因:

  1. 代码中存在死循环

  2. JVM频繁GC

  3. 加密和解密的逻辑

  4. 正则表达式的处理

  5. 频繁地线程上下文切换

如果真的遇到了线上环境cpu偏高的问题, 不妨先从这几个角度进行分析.

最最最后, 给大家推荐一个工具, 可以线上分析jstack的一个网站, 非常的有用.

网站地址: fastthread.io/

作者:NorthWard
链接:https://juejin.cn/post/6844904001067040781
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

你可能感兴趣的:(java中WAITING状态的线程为啥还会消耗CPU)