主線程異常會導致 JVM 退出?

大家好,我是坤哥

上週線程崩潰爲什麼不會導致 JVM 崩潰在其他平臺發出後,有一位小夥伴留言說有個地方不嚴謹

他認爲如果 JVM 中的主線程異常沒有被捕獲,JVM 還是會崩潰,那麼這個說法是否正確呢,我們做個試驗看看結果是否是他說的這樣

public class Test {
    public static void main(String[] args) {
        TestThread testThread = new TestThread();
        TestThread.start();
        Integer p = null;
          // 這裏會導致空指針異常
        if (p.equals(2)) {
            System.out.println("hahaha");
        }
    }
}

class TestThread extends Thread {
    @Override
    public void run()  {
        while (true) {
            System.out.println("test");
        }
    }
}

試驗很簡單,首先啓動一個線程,在這個線程裏搞一個 while true 不斷打印, 然後在主線程中製造一個空指針異常,不捕獲,然後看是否會一直打印 test

結果是會不斷打印 test,說明主線程崩潰,JVM 並沒有崩潰,這是怎麼回事, JVM 又會在什麼情況下完全退出呢?

其實在 Java 中並沒有所謂主線程的概念,只是我們習慣把啓動的線程作爲主線程而已,所有線程其實都是平等的,不管什麼線程崩潰都不會影響到其它線程的執行,注意我們這裏說的線程崩潰是指由於未 catch 住 JVM 拋出的虛擬機錯誤(VirtualMachineError)而導致的崩潰,虛擬機錯誤包括 InternalError,OutOfMemoryError,StackOverflowError,UnknownError 這四大子類

JVM 拋出這些錯誤其實是一種防止整個進程崩潰的自我防護機制,這些錯誤其實是 JVM 內部定義了信號處理函數處理後拋出的,JVM 認爲這些錯誤 "罪不致死",所以選擇恢復線程再給這些線程拋錯誤(就算線程不 catch 這些錯誤也不會崩潰)的方式來避免自身崩潰,但如果線程觸發了一些其他的非法訪問內存的錯誤,JVM 則會認爲這些錯誤很嚴重,從而選擇退出,比如下面這種非法訪問內存的錯誤就會被認爲是致命錯誤,JVM 就不會向上層拋錯誤,而會直接選擇退出

Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
unsafe.putAddress(0, 0);

回過頭來看,除了這些致命性錯誤導致的 JVM 崩潰,還有哪些情況會導致 JVM 退出呢,在 javadoc 上說得很清楚

The Java Virtual Machine exits when the only threads running are all daemon threads

也就是說只有在 JVM 的所有線程都是守護線程(daemon thread)的時候纔會完全退出,什麼是守護線程?守護線程其實是爲其他線程服務的線程,比如垃圾回收線程就是典型的守護線程,既然是爲其他線程服務的,那麼一旦其他線程都不存在了,守護線程也沒有存在的意義了,於是 JVM 也就退出了,守護線程通常是 JVM 運行時幫我們創建好的,當然我們也可以自己設置,以開頭的代碼爲例,在創建完 TestThread 後,調用 testThread.setDaemon(true) 方法即可將線程轉爲守護線程,然後再啓動,這樣在主線程退出後,JVM 就會退出了,大家可以試試

Java 線程模型簡介

我們可以看看 Java 的線程模型,這樣大家對 JVM 的線程調度也會有一個更全面的認識,我們可以先從源碼角度看看,啓動一個 Thread 到底在 JVM 內部發生了什麼,啓動源碼代碼在 Thread#start 方法中

public class Thread {

  public synchronized void start() {
        ...
    start0();
    ...
  }
  private native void start0();
}

可以看到最終會調用 start0 這個 native 方法,我們去下載一下 openJDK(地址:https://github.com/AdoptOpenJDK/openjdk-jdk8u) 來看看這個方法對應的邏輯

image-20220622073357619

可以看到 start0 對應的是 JVM_startThread 這個方法,我們主要觀察在 Linux 下的線程啓動情況,一路追蹤下去

// jvm.cpp
JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
  native_thread = new JavaThread(&thread_entry, sz);

// thread.cpp
JavaThread::JavaThread(ThreadFunction entry_point, size_t stack_sz)
{
  os::create_thread(this, thr_type, stack_sz);
}

// os_linux.cpp
bool os::create_thread(Thread* thread, ThreadType thr_type, size_t stack_size) {
  int ret = pthread_create(&tid, &attr, (void* (*)(void*)) java_start, thread);
}

可以看到最終是通過調用 pthread_create 來啓動線程的,這個方法是一個 C 函數庫實現的創建 native thread 的接口,是一個系統調用,由此可見 pthread_create 最終會創建一個 native thread,這個線程也叫內核線程,操作系統只能調度內核線程,於是我們知道了在 Java 中,Java 線程和內核線程是一對一的關係,Java 線程調度實際上是通過操作系統調度實現的,這種一對一的線程也叫 NPTL(Native POSIX Thread Library) 模型,如下

NPTL 線程模型

那麼這個內核線程在內核中又是怎麼表示的呢, 其實在 Linux 中不管是進程還是線程都是通過一個 task_struct 的結構體來表示的, 這個結構體定義了進程需要的虛擬地址,文件描述符,寄存器,信號等資源

早期沒有線程的概念,所以每次啓動一個進程都需要調用 fork 創建進程,這個 fork 乾的事其實就是 copy 父進程對應的 task_struct 的多數字段(pid 等除外),這在性能上顯然是無法接受的。於是線程的概念被提出來了,線程除了有自己的棧和寄存器外,其他像虛擬地址,文件描述符等資源都可以共享

線程共享代碼段,數據段,地址空間,文件等資源

於是針對線程,我們就可以指定在創建 task_struct 時,採用共享而不是複製字段的方式。其實不管是創建進程(fork)還是創建線程(pthread_create)最終都會通過調用 clone() 的形式來創建 task_struct,只不過 pthread_create 在調用 clone 時,指定了如下幾個共享參數

clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

畫外音:CLONE_VM 共享頁表,CLONE_FS 共享文件系統信息,CLONE_FILES 共享文件句柄,CLONE_SIGHAND 共享信號

通過共享而不是複製資源的形式極大地加快了線程的創建,另外線程的調度開銷也會更小,比如在(同一進程內)線程間切換的時候由於共享了虛擬地址空間,TLB 不會被刷新從而導致內存訪問低效的問題

提到這相信你已經明白了教科書上的一句話:進程是資源分配的最小單元,而線程是程序執行和調度的最小單位。在 Linux 中進程分配資源後,線程通過共享資源的方式來被調度得以提升線程的執行效率

由此可見,在 Linux 中所有的進程 / 線程都是用的 task_struct,它們之間其實是平等的,那怎麼表示這些線程屬於同一個進程的概念呢,畢竟線程之間也是要通信的,一組線程以及它們所共同引用的一組資源就是一個進程。, 它們還必須被視爲一個整體。

task_struct 中引入了線程組的概念,如果線程都是由同一個進程(即我們說的主線程)產生的, 那麼它們的 tgid(線程組 id) 是一樣的,如果是主線程,則 pid = tgid,如果是主線程創建的線程,則這些線程的 tgid 會與主線程的 tgid 一致,

那麼在 LInux 中進程,進程內的線程之間是如何通信或者管理的呢,其實 NPTL 是一種實現了 POSIX Thread 的標準 ,所以我們只需要看 POSIX Thread 的標準即可,以下列出了 POSIX Thread 的主要標準:

  1. 查看進程列表的時候, 相關的一組 task_struct 應當被展現爲列表中的一個節點(即進程內如果有多個線程,展示進程列表 ps -ef 時只會展示主線程,如果要查看線程的話可以用 ps -T

  2. 發送給這個進程的信號 (對應 kill 系統調用), 將被對應的這一組 task_struct 所共享, 並且被其中的任意一個” 線程”處理

  3. 發送給某個線程的信號 (對應 pthread_kill), 將只被對應的一個 task_struct 接收, 並且由它自己來處理

  4. 當進程被停止或繼續時 (對應 SIGSTOP/SIGCONT 信號), 對應的這一組 task_struct 狀態將改變

  5. 當進程收到一個致命信號 (比如由於段錯誤收到 SIGSEGV 信號), 對應的這一組 task_struct 將全部退出

畫外音: POSIX 即可移植操作系統接口(Portable Operating System Interface of UNIX,縮寫爲 POSIX ),是一種接口規範,如果系統都遵循這個標準,可以做到源碼級的遷移,這就類似 Java 中的針對接口編程

這樣就能很好地滿足進程退出線程也退出,或者線程間通信等要求了

NPTL 模型的缺點

NPTL 是一種非常高效的模型,研究表明 NPTL 能夠成功地在 IA-32 平臺上在兩秒內生成 100,000 個線程,而 2.6 之前未採用 NPTL 的內核則需耗費 15 分鐘左右,看起來 NPTL 確實很好地滿足了我們的需求,但針對內核線程來調度其實還是有以下問題

  1. 不管是進程還是線程,每次阻塞、切換都需要陷入系統調用 (system call),系統調用開銷其實挺大的,包括上下文切換(寄存器切換),特權模式切換等,而且還得先讓 CPU 跑操作系統的調度程序,然後再由調度程序決定該跑哪一個進程 (線程)

  2. 不管是進程還是線程,都屬於搶佔式調度(高優先級線進程優先被調度),由於搶佔式調度執行順序無法確定的特點,使用線程時需要非常小心地處理同步問題

  3. 線程雖然更輕量級,但這只是相對於進程而言,實際上使用線程所消耗的資源依然很大,比如在 linux 上,一個線程默認的棧大小是 1M,創建幾萬個線程就喫不消了

協程

NPTL 模型其實已經足夠優秀了,上述問題本質上其實還是因爲線程還是太 “重” 所致,那能否再在線程上抽出一個更輕量級的執行單元(可被 CPU 調度和分派的基本單位)呢,答案是肯定的,在線程之上我們可以再抽象出一個協程(coroutine)的概念, 就像進程是由線程來調度的,同樣線程也可以細化成一個個的協程來調度

針對以上問題,協程都做了非常好的處理

  1. 協程的調度處於用戶態,也就沒有了系統調用這些開銷

  2. 協程不屬於搶佔式調度,而是協作式調度,如何調度,在什麼時間讓出執行權給其它協程是由用戶自己決定的,這樣的話同步的問題也基本不存在,可以認爲協程是無鎖的,所以性能很高

  3. 我們可以認爲線程的執行是由一個個協程組成的,協程是更輕量的存在,內存使用大約只有線程的十分之一甚至是幾十分之一,它是使用棧內存按需使用的,所以創建百萬級的協程是非常輕鬆的事

協程是怎麼做到上述這些的呢

協程(coroutine)可以分爲兩個角度來看,一個是 routine 即執行單元,一個是 co 即 cooperative 協作,也就是說線程可以依次順序執行各個協程,但協程與線程不同之處在於,如果某個協程(假設爲 A)內碰到了 IO 等阻塞事件,可以主動讓出自己的調度權,即掛起(suspend),轉而執行其他協程,等 IO 事件準備好了,再來調度協程 A

這就好比我在看電視的時候碰到廣告,那我可以先去倒杯水,等廣告播完了再回來繼續看電視。而如果是函數,那你必須看完廣告再去倒水,顯然協程的效率更高。那麼協程之間是怎麼協作的呢,我們可以在兩個協程之間碰到 IO 等阻塞事件時隨時將自己掛起(yield),然後喚醒(resume)對方以讓對方執行,想象一下如果協程中有挺多 IO 等阻塞事件時,那這種協作調度是非常方便的

兩個協程之間的 “協作”

不像函數必須執行完才能返回,協程可以在執行流中的任意位置由用戶決定掛起和喚醒,無疑協程是更方便的

函數與協程的區別

更重要的一點是不像線程的掛起和喚醒等調度必須通過系統調用來讓內核調度器來調度,協程的掛起和喚醒完全是由用戶決定的,而且這個調度是在用戶態,幾乎沒有開銷!

前面我們一直提到一般我們在協程中碰到 IO 等阻塞事件時纔會掛起並喚醒其他協程,所以可知協程非常適合 IO 密集型的應用,如果是計算密集型其實用線程反而更加合適

爲什麼 Go 語言這麼最近這麼火,一個很重要的原因就是因爲因爲它天生支持協程,可以輕而易舉地創建成千上萬個協程,而如果是創建線程的話,創建幾百個估計就夠嗆了,不過比較遺憾的是 Java 原生並不支持協程,只能通過一些第三方庫如 Quasar 來實現,2018 年 OpenJDK 官方創建了一個  loom 項目來推進協程的官方支持工作

總結

從進程,到線程再到協程,可知我們一直在想辦法讓執行單元變得更輕量級,一開始只有進程的概念,但是進程的創建在 Linux 下需要調用 fork 全部複製一遍資源,雖然後來引入了寫時複製的概念,但進程的創建開銷依然很大,於是提出了更輕量級的線程,在 Linux 中線程與進程其實都是用 task_struct 表示的,只是線程採用了共享資源的方式來創建,極大了提升了 task_struct 的創建與調度效率,但人們發現,線程的阻塞,喚醒都要通過系統調用陷入內核態才能被調度程度調度,如果線程頻繁切換,開銷無疑是很大的,於是人們提出了協程的概念,協程是根據棧內存按需求分配的,所需開銷是線程的幾十分之一,非常的輕量,而且調度是在用戶態,並且它是協作式調度,可以很方便的掛起恢復其他協程的執行,在此期間,線程是不會被掛起的,所以無論是創建還是調度開銷都很小,目前 Java 官方還不支持,不過支持協程應該是大勢所趨,未來我們可以期待一下

你好,我是坤哥,前獨角獸技術專家,現創業者,持續分享個人的成長收穫,歡迎大家加我微信,圍觀朋友圈,關注我一定能提升你的視野,讓我們一起進階吧!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/7buwp_N7P3OkVBvvYNNo_A