java中WAITING狀態的執行緒為啥還會消耗CPU

NorthWard發表於2019-11-20

背景

剛剛過去的雙十一, 公司訂單量又翻了一倍. 就在老闆坐在辦公室裡面偷偷笑的同時,坐在工位上的我們卻是一直瑟瑟發抖. 面對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.
     *
     * <p>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

image

被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

image

發生死鎖的執行緒

再來看看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

image

image

也是可以看到雖然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<Thread> list = new ArrayList<>(threadCount);

        // 啟動threadCount個執行緒, 不斷地park/unpark, 來表示執行緒間的切換
        for(int i =0; i<threadCount; i++){
            Thread thread = new Thread(()->{
                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

每個執行緒的佔用cpu情況:

image

分析和總結

再回到我們文章開頭的執行緒堆疊(佔用了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/

相關文章