Java 優雅地退出程序


本文轉載自Java 優雅地退出程序

導語

很多情況下,我們的程序需要在操作系統 后台 一直運行,這在程序代碼里的實現就是用死循環 ( while (true) ) 來實現的。但是,這樣會出現一個問題,就是我們想要關閉程序怎么辦?如果用暴力結束進程方式,那程序的內存中若還有未輸出的數據,這部分數據將會遺失。因此,我們要對程序實現 退出收尾 操作,這就需要我們完善我們的程序,實現 “優雅” 地退出。

后台進程

首先,我們需要知道什么是后台進程。眾所周知,我們與服務器進行交互都需要通過終端進行實現,而在終端上執行的程序都會默認將輸出打印在終端界面里,而這中方式就 交互式進程,並且當前終端只能運行一個交互進程的,所以如果我們想在一個終端里運行多個任務,我們就需要將某些進程丟到 后台 ,而這些進程不影響當前終端的交互執行,就被稱為 “后台進程”

所有的 交互式進程 都是可以轉為 后台進程 的,因為進程的操作任務是一定的,只不過是它們的顯示方式不同罷了,通常我們在一個終端里在任務后面加上 & 操作符就可以讓交互式進程變為后台執行進程了。如:

前台進程

git clone https://gitee.com/jiyiren/linuxfile

如果按 ctrl + c 將會結束 clone 操作。

轉為 后台進程

git clone https://gitee.com/jiyiren/linuxfile &
[1] 70235

我們可以看到此時該命令輸出一個編號 70235,這個就是后台 job 的 ID,此時你按 ctrl + c 並不會結束改任務。如果要 查看 job 列表,可以使用 jobs -l, 如下:

jobs -l
[1]+ 70235 運行中               git clone https://gitee.com/jiyiren/linuxfile &

可以看到該任務在運行中,此時若想將該任務再 調到前台,可以使用 fg % jobid ( 注意百分號前后都有空格 ), 如下:

fg % 70235
git clone https://gitee.com/jiyiren/linuxfile
remote: Total 15 (delta 3), reused 0 (delta 0)
Unpacking objects: 100% (15/15), done.

此時,顯示的就是正在進程的任務,如果此時按 ctrl + c 則將取消 clone 操作。

上面是基本的 Linux 前后台任務轉換命令,我們可以看到我們結束進程都是將任務調到前台,然后用 ctrl + c, 來結束進程的。然而,將任務從后台調到前台的方式只能在同一個終端里操作的,如果用戶在將任務掉入后台后關閉了終端窗口,那么該任務是永遠無法通過 fg % jobid 調到前台了。這時如果要結束該進程怎么辦?

KILL 命令


還好我們有終極殺器 – kill 命令,但 kill 命令操作的是 進程 ID 而非 job ID。也就是說 job ID 只能是同一個終端下的操作,相當於終端局域性的,而脫離了該終端后,該局域的 job ID 就不再有效。而 進程 ID 則是全局性的,任意終端都可以操作的,並且局域的 job ID 都會有與之對應的全局 進程 ID 的,因此如果關閉了那個 job ID 所在的終端,我們可以通過 kill job ID 對應的進程 ID 來結束此任務進程。

在我們平常的開發中,我們不可能一直維持着一個服務器的終端的,因此通過 ctrl + c 的方式結束 job ID 的方式對正式部署應用很不適合的,它只能適合個人的簡單測試,因此 kill 命令方式才是 統一而確實有效 結束進程的方式。

假如,我們上面執行下面命令之后,就關閉掉了終端 ( 也不用管 job ID 了 ):

git clone https://gitee.com/jiyiren/linuxfile &

我們可以先通過 ps 命令來拿到我們的 進程 ID

ps -aux | grep linuxfile | grep -v grep
jiyi  70376  0.0  0.0 116676  1536 pts/1    S    01:06   0:00 git clone https://gitee.com/jiyiren/linuxfile
jiyi  70377  5.7  0.4 174908  7952 pts/1    S    01:06   0:01 git-remote-https origin https://gitee.com/jiyiren/linuxfile
jiyi  70379  3.3  0.0 124632  1136 pts/1    Sl   01:06   0:00 git fetch-pack --stateless-rpc --stdin --lock-pack --thin https://gitee.com/jiyiren/linuxfile/

上面第一個 grep 后面就是自己要搜索的進程中包含的 關鍵詞,這個自己根據自己的命令選擇命令中的關鍵詞,這樣便於更好地過濾。第二個 grep 則是去除本身這個查找命令的意思。

我們從上面命令結果可以看到有三個進程與此任務對應,其中第二列是 進程的 ID, 我們可以用下面命令殺死該任務的所有進程:

kill -9 70376 70377 70379

這樣在終端里通過 jobs -l 可以看到已經沒有任務在運行了。

KILL 信號


通過上面的敘述,我們知道 kill 命令的作用。那么,上面的結束進程的命令 kill -99 是什么意思呢?實際上 kill -9kill -s 9 的縮寫,-s 后面接信號名稱或者信號序號。而 9 代表的信號名為 SIGKILL, 也就是說 kill -9 也可以寫成 kill -s SIGKILL. 此外,如果用信號名,字符的大小寫是不敏感的,因此大家也可以寫成 kill -s sigkill. 最后,由於所有的信號名都是以 SIG 打頭的,因此,通常在我們自己寫的程序中都是去掉 SIG 作為信號名的,因此,此命令還可以寫成 kill -s kill. 這里我整理出 信號 9 所有相同功能的命令操作:

kill -9 [PID]
kill -s 9 [PID]
kill -s SIGKILL [PID]
kill -s sigkill [PID]
kill -s KILL [PID]
kill -s kill [PID]

大家可以把 SIGKILL 這個信號換成其他的也適用,但由於信號名稱有點長,不太好記,因此,通常我們在操作命令的時候使用序號來執行 kill 命令。

那我們怎么知道有哪些信號?以及這些信號對應的序號呢?實際上 kill 命令還有一個參數 -l, 可以列出所有支持的 信號序號 以及 信號名

kill -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX

大家也看到了,信號太多了,這里我挑選出最長用的信號進行說明:

信號名	 信號序號	含義
SIGHUP     1    終端斷線
SIGINT     2    中斷(同 Ctrl + C)
SIGQUIT    3    退出(同 Ctrl + \)
SIGTERM   15    正常終止
SIGKILL    9    強制終止
SIGCONT   18    繼續(與STOP相反, fg/bg命令)
SIGSTOP   19    暫停(同 Ctrl + Z)
SIGUSR1   10    用戶自定義信號1
SIGUSR2   12    用戶自定義信號2

這里我們只取其中的 結束進程的信號 來講:

SIGINT     2    中斷(同 Ctrl + C)
SIGTERM   15    正常終止
SIGKILL    9    強制終止

其中大家經常使用的 ctrl + c 快捷鍵就是發送了 SIGINT(2) 信號給進程的。另外,整個信號中,最特殊的命令就是 SIGKILL(9), 它代表 無條件結束進程,也就是通常說的強制結束進程,這種方式結束進程有可能會導致進程內存中 數據丟失。而另外兩個信號對於進程來說是可以選擇性忽略的,但目前的絕大部分的進程都是可以通過這三個信號進行結束的。

那這三個結束命令到底有啥區別?對比如下表:

信號 快捷鍵 正常結束 無條件結束 應用場景
SIGINT(2) ctrl + c 前台進程快捷終止
SIGTERM(15) 后台進程正常終止
SIGKILL(9) 后台進程強制終止

大家主要關注下各個信號的 應用場景 即可。

然而,我們的上線程序絕大部分都是后台進程在跑的,本篇內容也是討論后台進程,因此我們主要看 后台進程的正常結束( SIGINT(2)、SIGTERM(15) ) 與 后台進程的強制結束 ( SIGKILL(9) ) 的區別。

正常與強制結束方式


本篇討論 Java 程序的后台程序 正常強制結束 方式對比。在 Java 中,強制結束代表 直接立即結束 進程中的 Main 線程和其他所有線程,這里強調 直接和立即,也就是說通過強制方式,進程不會做任何收尾工作。而 正常結束 則非立即結束進程,而是先調用程序的 收尾線程,等收尾線程結束后再結束所有線程。

這里出現了 收尾線程,實際上這個就是 Java 程序中通過 Runtime.getRuntime().addShutdownHook() 方式注冊的線程就是收尾線程。為了更詳細地說明正常結束與強制結束的區別我們先定義一個工作線程 JobThread

// 工作線程,每秒鍾輸出一個遞增的數字
public class JobThread extends Thread {

    int count = 0;

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Work Thread: " + count++);
        }
    }
}

另外我們再定義一個收尾線程 ShudownHookThread

// 收尾線程,沒 0.5 秒輸出一個遞減的數字
public class ShudownHookThread extends Thread {

    int count = 10;

    @Override
    public void run() {
        while (count>0){
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Shutdown Thread: "+count--);
        }
    }
}

現在在 Main 函數中先注冊收尾線程,然后再啟動工作線程:

public class Main {

    public static void main(String[] args) {
        Runtime.getRuntime().addShutdownHook(new ShudownHookThread());
        JobThread jobThread = new JobThread();
        jobThread.start();
    }
}

然后打包成 Jar 包 ( 假設名字為 jvmexit-example.jar ),我們通過下面命令啟動程序:

java -jar jvmexit-example.jar
0
1
2
3
.
.

我們可以看到工作線程每隔 1 秒輸出一個數字,此時如果我們來通過正常和強制執行看看他們相應的輸出。

正常結束 kill -2 [PID] 或者 kill -15 [PID]

img

強制結束 kill -9 [PID] :

img

從中我們可以看出 正常結束 方式,會 先調用收尾線程,然后再結束,而 強制結束 則直接 殺死所有線程。因此,這里給出優雅結束進程說明:

  • 先定義自己的 收尾線程 要完成的任務,比如:清理內存,將未完成的 IO 操作完成,刪除緩存文件等等;
  • Main 函數里,在主任務啟動之前注冊 收尾線程 即可完成收尾任務的注冊;
  • 使用 killSIGIN(2)SIGTERM(15) 兩個信號進行進程結束,則 收尾線程 會被調用;

自定義 kill 信號處理


我們前面也講過,除了信號 SIGKILL(9) 外,其他信號對於進程來說都是可忽略的。而這個忽略就是自己在自己的任務進程里實現這些信號的監聽。

Java 中有提供一個接口 SignalHandler,完整名 sun.misc.SignalHandler,我們只要實現該接口,就可以在接收到信號后進行一些相應處理了。

我們定義類 SignalHandlerImp 其實現接口 SignalHandler

public class SignalHandlerImp implements SignalHandler {

    public void handle(Signal signal) {
        System.out.println(signal.getName()+":"+signal.getNumber());
    }

}

類內部只有一個要實現的方法 public void handle(Signal signal), 而我們在方法里僅僅是打印了信號的名稱和序號。然后在 Main 函數里注冊一下

public class Main {

    public static void main(String[] args) {
    	// 注冊要監聽的信號
        SignalHandlerImp signalHandlerImp = new SignalHandlerImp();
        Signal.handle(new Signal("INT"), signalHandlerImp);     // 2  : 中斷(同 ctrl + c )
        Signal.handle(new Signal("TERM"), signalHandlerImp);    // 15 : 正常終止
        Signal.handle(new Signal("USR2"), signalHandlerImp);    // 12 : 用戶自定義信號
        
        JobThread jobThread = new JobThread();
        jobThread.start();
    }
}

主函數里我們監聽了三個信號:SIGINT(2), SIGTERM(15), SIGUSR2(12), 同時我們也用到了上一節使用的工作線程 JobThread ( 注意這里沒有用到上節的掃尾進程 ), 讓我們來重新打包並啟動任務 。

java -jar jvmexit-example.jar
0
1
2
3
.
.

執行結果是一樣的,每秒輸出一個數字,那我們來分別執行:

// pid 換成自己的進程 ID
kill -2 [PID]
kill -15 [PID]
kill -12 [PID]
kill -9 [PID]

得到的結果如下:

img

從中我們可以看出自定義的信號處理方式,正常結束的信號 ( SIGINT(2)SIGTERM(15) ) 都不會結束進程,而只是執行自己自定義的方法,然而 強制結束信號 ( SIGKILL(9) ) 則不會被自定義監控,大家自己可以嘗試下在 Main 函數中注冊 KILL 信號,如下:

Signal.handle(new Signal("KILL"), signalHandlerImp);    // 9 : 強制終止

這個在運行的時候就會報錯,因此 SIGKILL(9) 信號是唯一不能夠被自定義的信號。

那既然我們自己可以自定義信號,那我們通過自定義的信號來處理我們的收尾操作也是可行的。因此我們只要在 SignalHandler 接口的實現類中 handle 方法中處理自己的收尾操作就可以了。這里也整理下自定義信號處理進行收尾的說明:

  • 實現 SignalHandler 接口,在 handle 方法中實現自己的收尾操作;
  • Main 函數里,在主任務啟動之前注冊 自定義信號名 即可完成收尾任務的注冊,只需要注冊一個就行了;
  • 使用 kill 的 對應 自定義信號名 進行任務進程的結束,就可以正常收尾了。

另外,在實際操作中使用自定義信號的方式通常是直接讓 工作線程 實現 SignalHandler 接口的,我們上面是為了舉例,以不至於發送對應信號后進程就停止了,而實際情況下是需要我們發送信號工作線程就應該停止,因此可以將上面的工作線程修改如下:

// 工作線程,每秒鍾輸出一個遞增的數字
public class JobThread extends Thread implements SignalHandler{

    boolean isStop = fals;
    int count = 0;

    @Override
    public void run() {
        while (!isStop) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Work Thread: " + count++);
        }
    }
    
    public void handle(Signal signal) {
    	  isStop = true;
        // do other something;
    }
}

如上所示,加一個運行 標識,並在收到信號后進行 標識 的反賦值,這樣工作線程就會自動停止,當然還可以進行其他相關操作。

兩種方式對比


本文接收兩種優雅 ( 而非暴力 kill -9 ) 結束進程方式:

  1. 采用默認信號處理機制,通過 Runtime.getRuntime().addShutdownHook(new ShudownHookThread()); 實現收尾進程的注冊,這樣在收到默認正常結束信號 ( SIGINT(2)SIGTERM(15) ) 就可優雅退出;
  2. 采用自定義信號處理機制,通過 Signal.handle(new Signal("USR2"), new SignalHandlerImp()); 注冊 自定義信號 以及 信號處理實現類,這樣使用 kill -自定義信號 ( 如: SIGUSR2(12) ) [PID] 就可以達到收尾操作在 信號處理實現類 里實現,從而也可實現優雅退出。

那這兩種方式哪個更好點?或者說適應性更廣泛一點?

這里我參考了 JVM 安全退出 這篇文章,它給出了 JVM 關閉的不止有 正常關閉強制關閉 還有一種 異常關閉 如下圖:

img

這種方式還是會調用以 Runtime.getRuntime().addShutdownHook(new ShudownHookThread()); 此方法注冊的 收尾線程 的,而不會觸發自定義的信號通信的。因此,還是第一種默認信號處理機制,通過 Hook 線程方式適應性更廣泛。

參考


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM