天天看點

這個Bug的排查之路,真的太有趣了。

這是why哥的第 92 篇原創文章

這個Bug的排查之路,真的太有趣了。

在《深入了解Java虛拟機》一書中有這樣一段代碼:

public class VolatileTest {

    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREADS_COUNT=20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for(int i = 0; i < THREADS_COUNT; i++){
           new Thread(new Runnable() {
               @Override
               public void run() {
                   for (int i = 0; i < 10000; i++) {
                       increase();
                   }
               }
           }).start();
        }

        //等待所有累加線程都結束
        while(Thread.activeCount()>1)
            Thread.yield();

        System.out.println(race);
    }
}
           

你看到這段代碼的第一反應是什麼?

是不是關注點都在 volatile 關鍵字上。

甚至馬上就要開始脫口而出:volatile 隻保證可見性,不保證原子性。而代碼中的

race++

不是原子性的操作,巴拉巴拉巴拉...

反正我就是這樣的:

這個Bug的排查之路,真的太有趣了。

當他把代碼發給我,我在 idea 裡面一粘貼,然後把 main 方法運作起來後,神奇的事情出現了。

這個代碼真的沒有執行到輸出語句,也沒有任何報錯。

看起來就像是死循環了一樣。

不信的話,你也可以放到你的 idea 裡面去執行一下。

等等......

死循環?

代碼裡面不是就有一個死循環嗎?

//等待所有累加線程都結束
while(Thread.activeCount()>1)
    Thread.yield();
           

這段代碼能有什麼小心思呢?看起來人畜無害啊。

但是程式員的直覺告訴我,這個地方就是有問題的。

活躍線程一直是大于 1 的,是以導緻 while 一直在死循環。

算了,不想了,先 Debug 看一眼吧。

Debug 了兩遍之後,我才發現,這個事情,有點意思了。

因為 Debug 的情況下,程式竟然正常結束了。

這個Bug的排查之路,真的太有趣了。

啥情況啊?

分析一波走起。

為啥停不下來?

我是怎麼分析這個問題的呢。

我就把程式又 Run 了起來,控制台還是啥輸出都沒有。

我就盯着這個控制台想啊,會是啥原因呢?

這樣幹看着也不是辦法啊。

反正我現在就是咬死這個 while 循環是有問題的,是以為了排除其他的幹擾項。

我把程式簡化到了這個樣子:

public class VolatileTest {

    public static volatile int race = 0;

    public static void main(String[] args) {
        while(Thread.activeCount()>1)
            Thread.yield();
        System.out.println("race = " + race);
    }
}
           

運作起來之後,還是沒有執行到輸出語句,也就側面證明了我的想法:while 循環有問題。

而 while 循環的條件就是

Thread.activeCount()>1

朝着這個方向繼續想下去,就是看看目前活躍線程到底有幾個。

于是程式又可以簡化成這樣:

這個Bug的排查之路,真的太有趣了。

直接運作看到輸出結果是 2。

這個Bug的排查之路,真的太有趣了。

用 Debug 模式運作時傳回的是 1。

對比這運作結果,我心裡基本上就有數了。

先看一下這個 activeCount 方法是幹啥的:

這個Bug的排查之路,真的太有趣了。

注意看畫着下劃線的地方:

傳回的值是一個 estimate。

estimate 是啥?

這個Bug的排查之路,真的太有趣了。

你看,又在我這裡學一個進階詞彙。真是 very good。

傳回的是一個預估值。

為什麼呢?

因為我們調用這個方法的一刻擷取到值之後,線程數還是在動态變化的。

也就是說傳回的值隻代表你調用的那一刻有幾個活躍線程,也許當你調用完成後,有一個線程就立馬嗝屁了。

是以,這個值是個預估值。

這一瞬間,我突然想到了量子力學中的測不準原理。

這個Bug的排查之路,真的太有趣了。

你不可能同時知道一個粒子的位置和它的速度,就像在多線程高并發的情況下你不可能同時知道調用 activeCount 方法得到的值和你要用這個值的時刻,這個值的真實值是多少。

你看,剛學完英語又學量子力學。

這個Bug的排查之路,真的太有趣了。

好了,回到程式裡面。

雖然注釋裡面說了傳回值是 estimate 的,但是在我們的程式中,并不存在這樣的問題。

看到 activeCount 方法的實作之後:

public static int activeCount() {
    return currentThread().getThreadGroup().activeCount();
}
           

我又想到,既然在直接 Run 的情況下,程式傳回的數是 2,那我看看到底有那些線程呢?

其實最開始我想着去 Debug 一下的,但是 Debug 的情況下,傳回的數是 1。我意識到,這個問題肯定和 idea 有關,而且必須得用日志調試大法才能知道原因。

于是,我把程式改成了這樣:

這個Bug的排查之路,真的太有趣了。

直接 Run 起來,可以看到,确實有兩個線程。

一個是 main 線程,我們熟悉。

一個是 Monitor Ctrl-Break 線程,我不認識。

但是當我用 Debug 的方式運作的時候,有意思的事情就發生了:

這個Bug的排查之路,真的太有趣了。

Monitor Ctrl-Break 線程不見了!?

于是,我問他:

這個Bug的排查之路,真的太有趣了。

是啊,問題解決了,但是啥原因啊?

為什麼 Run 不可以運作,而 Debug 可以運作呢?

這個Bug的排查之路,真的太有趣了。

目前線程有哪些?

我們先梳理一下目前線程有哪些吧。

可以使用下面的代碼擷取目前所有的線程:

public  static Thread[] findAllThread(){
    ThreadGroup currentGroup =Thread.currentThread().getThreadGroup();

    while (currentGroup.getParent()!=null){
        // 傳回此線程組的父線程組
        currentGroup=currentGroup.getParent();
    }
    //此線程組中活動線程的估計數
    int noThreads = currentGroup.activeCount();

    Thread[] lstThreads = new Thread[noThreads];
    //把對此線程組中的所有活動子組的引用複制到指定數組中。
    currentGroup.enumerate(lstThreads);

    for (Thread thread : lstThreads) {
        System.out.println("線程數量:"+noThreads+" " +
                "線程id:" + thread.getId() + 
                " 線程名稱:" + thread.getName() + 
                " 線程狀态:" + thread.getState());
    }
    return lstThreads;
}
           

運作之後可以看到有 6 個線程:

這個Bug的排查之路,真的太有趣了。

也就是說,在 idea 裡面,一個 main 方法 Run 起來之後,即使什麼都不幹,也會有 6 個線程運作。

這 6 個線程分别是幹啥的呢?

我們一個個的說。

Reference Handler 線程:

JVM 在建立 main 線程後就建立 Reference Handler 線程,其優先級最高,為 10,它主要用于處理引用對象本身(軟引用、弱引用、虛引用)的垃圾回收問題。

Finalizer 線程:

這個線程也是在 main 線程之後建立的,其優先級為10,主要用于在垃圾收集前,調用對象的 finalize() 方法。

關于 Finalizer 線程的幾點:

1)隻有當開始一輪垃圾收集時,才會開始調用 finalize() 方法;是以并不是所有對象的 finalize() 方法都會被執行;

2)該線程也是 daemon 線程,是以如果虛拟機中沒有其他非 daemon 線程,不管該線程有沒有執行完 finalize() 方法,JVM 也會退出;

3) JVM在垃圾收集時會将失去引用的對象包裝成 Finalizer 對象(Reference的實作),并放入 ReferenceQueue,由 Finalizer 線程來處理;最後将該 Finalizer 對象的引用置為 null,由垃圾收集器來回收;

4) JVM 為什麼要單獨用一個線程來執行 finalize() 方法呢?如果 JVM 的垃圾收集線程自己來做,很有可能由于在 finalize() 方法中誤操作導緻 GC 線程停止或不可控,這對 GC 線程來說是一種災難。

Attach Listener 線程:

Attach Listener 線程是負責接收到外部的指令,而對該指令進行執行的并且把結果傳回給發送者。通常我們會用一些指令去要求 jvm 給我們一些回報資訊。

如:java -version、jmap、jstack 等等。如果該線程在 jvm 啟動的時候沒有初始化,那麼,則會在使用者第一次執行 jvm 指令時,得到啟動。

Signal Dispatcher 線程:

前面我們提到第一個 Attach Listener 線程的職責是接收外部 jvm 指令,當指令接收成功後,會交給 signal dispather 線程去進行分發到各個不同的子產品處理指令,并且傳回處理結果。signal dispather 線程也是在第一次接收外部 jvm 指令時,進行初始化工作。

main 線程:

呃,這個不說了吧。大家都知道。

Monitor Ctrl-Break 線程:

先買個關子,下一小節專門聊聊這個線程。

上面線程的作用,我是從這個網頁搬運過來的,還有很多其他的線程,大家可以去看看:

http://ifeve.com/jvm-thread/

我好事做到底,直接給你來個長截圖,一網打盡。

你先把圖檔儲存起來,後面慢慢看:

這個Bug的排查之路,真的太有趣了。

現在跟着我去探尋 Monitor Ctrl-Break 線程的秘密。

繼續挖掘

問題解決了,但是問題背後的問題,還沒有得到解決:

Monitor Ctrl-Break 線程是啥?它是怎麼來的?

我們先 jstack 一把看看線程堆棧呗。

而在 idea 裡面,這裡的“照相機”圖示,就是 jstack 一樣的功能。

這個Bug的排查之路,真的太有趣了。

我把程式恢複為最初的樣子,然後把“照相機”就這麼輕輕的一點:

這個Bug的排查之路,真的太有趣了。

從線程堆棧裡面可以看到 Monitor Ctrl-Break 線程來自于這個地方:

com.intellij.rt.execution.application.AppMainV2$1.run(AppMainV2.java:64)

而這個地方,一看名稱,是 idea 的源碼了啊?

不屬于我們的項目裡面了,這咋個搞呢?

思考了一下,想到了一種可能,于是我決定用 jps 指令驗證一下:

這個Bug的排查之路,真的太有趣了。

看到執行結果的時候我笑了,一切就說的通了。

果然,是用了 -javaagent 啊。

那麼 javaagent 是什麼?

好的,要問答好這個問題,就得另起一篇文章了,本文不讨論,先欠着。

隻是簡單的提一下。

你在指令行執行

java

指令,會輸出一大串東西,其中就包含這個:

這個Bug的排查之路,真的太有趣了。

什麼語言代理的,看不懂。

叫我們參閱 java.lang.instrument。

那它又是拿來幹啥的?

簡單的一句話解釋就是:

使用 instrument 可以更加友善的使用位元組碼增強的技術,可以認為是一種 jvm 層面的截面。不需要對程式源代碼進行任何侵入,就可以對其進行增強或者修改。總之,有點 AOP 内味。

-javaagent

指令後面需要緊跟一個 jar 包。

-javaagent:<jar 路徑>[=<選項>]

instrument 機制要求,這個 jar 包必須有 MANIFEST.MF 檔案,而 MANIFEST.MF 檔案裡面必須有 Premain-Class 這個東西。

是以,回到我們的程式中,看一下 javaagent 後面跟的包是什麼。

在哪看呢?

就這個地方:

這個Bug的排查之路,真的太有趣了。

你把它點開,指令非常的長。但是我們關心的

-javaagent

就在最開始的地方:

這個Bug的排查之路,真的太有趣了。

-javaagent:D:\Program Files\JetBrains\IntelliJ IDEA 2019.3.4\lib\idea_rt.jar=61960

可以看到,後面跟着的 jar 包是 idea_rt,按照檔案目錄找過去,也就是在這裡:

這個Bug的排查之路,真的太有趣了。

我們解壓這個 jar 包,打開它的 MANIFEST.MF 檔案:

這個Bug的排查之路,真的太有趣了。

而這個類,不就是我們要找的它嗎:

這個Bug的排查之路,真的太有趣了。

此時此刻,我們距離真相,隻有一步之遙了。

進到對應的包裡,發現有三個 class 類:

這個Bug的排查之路,真的太有趣了。

主要關注 AppMainV2.class 檔案:

這個Bug的排查之路,真的太有趣了。

在這個檔案裡面,就有一個 startMonitor 方法:

這個Bug的排查之路,真的太有趣了。

我說過什麼來着?

來,大聲的跟我念一遍:源碼之下無秘密。

Monitor Ctrl-Break 線程就是這裡來的。

而仔細看一眼這裡的代碼,這個線程在幹啥事呢?

Socket client = new Socket("127.0.0.1", portNumber);

啊,我的天呐,來看看這個可愛的小東西,socket 程式設計,太熟悉了,簡直是夢回大學實驗課的時候。

它是連結到 127.0.0.1 的某個端口上,然後 while(true) 死循環等待接收指令。

那麼這個端口是哪個端口呢?

就是這裡的 62325:

這個Bug的排查之路,真的太有趣了。

需要注意的是,這個端口并不是固定的,每次啟動這個端口都會變化。

玩玩它

既然它是 Socket 程式設計,那麼我就玩玩它呗。

先搞個程式:

public class SocketTest{

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(12345);
        System.out.println("等待用戶端連接配接.");
        Socket socket = serverSocket.accept();
        System.out.println("有用戶端連接配接上了 "+ socket.getInetAddress() + ":" + socket.getPort() +"");
 
        OutputStream outputStream = socket.getOutputStream();
        Scanner scanner = new Scanner(System.in);
        while (true)
        {
            System.out.println("請輸入指令: ");
            String s = scanner.nextLine();
            String message = s + "\n";
            outputStream.write(message.getBytes("US-ASCII"));
        }
    }
}
           

我們把服務端的端口指定為了 12345。

用戶端這邊的端口也得指定為 12345,那怎麼指定呢?

别想複雜了,簡單的一比。

把這行日志粘貼出來:

這個Bug的排查之路,真的太有趣了。

需要說明的是,我這邊為了示範效果,在程式裡面加了一個 for 循環。

然後我們在這裡把端口改為 12345:

這個Bug的排查之路,真的太有趣了。

把檔案儲存為 start.bat 檔案,随便放一個地方。

萬事俱備。

我們先把服務端運作起來:

這個Bug的排查之路,真的太有趣了。

然後,執行 bat 檔案:

這個Bug的排查之路,真的太有趣了。

在 cmd 視窗裡面輸出了我們的日志,說明程式正常運作。

而在服務端這邊,顯示有用戶端連接配接成功。

叫我們輸入指令。

輸入啥指令呢?

看一下用戶端支援哪些指令呗:

這個Bug的排查之路,真的太有趣了。

可以看到,支援 STOP 指令。

接受到該指令後,會退出程式。

來,搞一波,動圖走起:

這個Bug的排查之路,真的太有趣了。

搞定。

好了,本文技術部分就到這裡了,恭喜你知道了 idea 中的 Monitor Ctrl-Break 線程,這個學了沒啥卵用的知識 。

如果要深挖的話,往

-javaagent

方向挖一挖。

應用很多的,比如耳熟能詳的 Java 診斷工具 Arthas 就是基于 JavaAgent 做的。

有點意思。

最後說一句

才疏學淺,難免會有纰漏,如果你發現了錯誤的地方,可以在背景提出來,我對其加以修改。

感謝您的閱讀,我堅持原創,十分歡迎并感謝您的關注。