Fork me on GitHub

Java 优雅地退出程序

show me code? No, show me elegant code!

很多情况下,我们的程序需要在操作系统 后台 一直运行,这在程序代码里的实现就是用死循环 ( while (true) ) 来实现的。但是,这样会出现一个问题,就是我们想要关闭程序怎么办?如果用暴力结束进程方式,那程序的内存中若还有未输出的数据,这部分数据将会遗失。因此,我们要对程序实现 退出收尾 操作,这就需要我们完善我们的程序,实现 “优雅” 地退出。

后台进程

首先,我们需要知道什么是后台进程。众所周知,我们与服务器进行交互都需要通过终端进行实现,而在终端上执行的程序都会默认将输出打印在终端界面里,而这中方式就 交互式进程,并且当前终端只能运行一个交互进程的,所以如果我们想在一个终端里运行多个任务,我们就需要将某些进程丢到 后台 ,而这些进程不影响当前终端的交互执行,就被称为 “后台进程”

所有的 交互式进程 都是可以转为 后台进程 的,因为进程的操作任务是一定的,只不过是它们的显示方式不同罢了,通常我们在一个终端里在任务后面加上 & 操作符就可以让交互式进程变为后台执行进程了。如:

前台进程

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

如果按 ctrl + c 将会结束 clone 操作。

转为 后台进程

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

我们可以看到此时该命令输出一个编号 70235,这个就是后台 job 的 ID,此时你按 ctrl + c 并不会结束改任务。如果要 查看 job 列表,可以使用 jobs -l, 如下:

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

可以看到该任务在运行中,此时若想将该任务再 调到前台,可以使用 fg % jobid ( 注意百分号前后都有空格 ), 如下:

1
2
3
4
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 了 ):

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

我们可以先通过 ps 命令来拿到我们的 进程 ID

1
2
3
4
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, 我们可以用下面命令杀死该任务的所有进程:

1
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 所有相同功能的命令操作:

1
2
3
4
5
6
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, 可以列出所有支持的 信号序号 以及 信号名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

大家也看到了,信号太多了,这里我挑选出最长用的信号进行说明:

1
2
3
4
5
6
7
8
9
10
信号名	 信号序号	含义
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

这里我们只取其中的 结束进程的信号 来讲:

1
2
3
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 工作线程,每秒钟输出一个递增的数字
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 收尾线程,没 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 函数中先注册收尾线程,然后再启动工作线程:

1
2
3
4
5
6
7
8
public class Main {

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

然后打包成 Jar 包 ( 假设名字为 jvmexit-example.jar ),我们通过下面命令启动程序:

1
2
3
4
5
6
7
java -jar jvmexit-example.jar
0
1
2
3
.
.

我们可以看到工作线程每隔 1 秒输出一个数字,此时如果我们来通过正常和强制执行看看他们相应的输出。

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

强制结束 kill -9 [PID] :

从中我们可以看出 正常结束 方式,会 先调用收尾线程,然后再结束,而 强制结束 则直接 杀死所有线程。因此,这里给出优雅结束进程说明:

  • 先定义自己的 收尾线程 要完成的任务,比如:清理内存,将未完成的 IO 操作完成,删除缓存文件等等;
  • Main 函数里,在主任务启动之前注册 收尾线程 即可完成收尾任务的注册;
  • 使用 killSIGIN(2)SIGTERM(15) 两个信号进行进程结束,则 收尾线程 会被调用;

自定义 kill 信号处理


我们前面也讲过,除了信号 SIGKILL(9) 外,其他信号对于进程来说都是可忽略的。而这个忽略就是自己在自己的任务进程里实现这些信号的监听。

Java 中有提供一个接口 SignalHandler,完整名 sun.misc.SignalHandler,我们只要实现该接口,就可以在接收到信号后进行一些相应处理了。

我们定义类 SignalHandlerImp 其实现接口 SignalHandler

1
2
3
4
5
6
7
public class SignalHandlerImp implements SignalHandler {

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

}

类内部只有一个要实现的方法 public void handle(Signal signal), 而我们在方法里仅仅是打印了信号的名称和序号。然后在 Main 函数里注册一下

1
2
3
4
5
6
7
8
9
10
11
12
13
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 ( 注意这里没有用到上节的扫尾进程 ), 让我们来重新打包并启动任务 。

1
2
3
4
5
6
7
java -jar jvmexit-example.jar
0
1
2
3
.
.

执行结果是一样的,每秒输出一个数字,那我们来分别执行:

1
2
3
4
5
// pid 换成自己的进程 ID
kill -2 [PID]
kill -15 [PID]
kill -12 [PID]
kill -9 [PID]

得到的结果如下:

从中我们可以看出自定义的信号处理方式,正常结束的信号 ( SIGINT(2)SIGTERM(15) ) 都不会结束进程,而只是执行自己自定义的方法,然而 强制结束信号 ( SIGKILL(9) ) 则不会被自定义监控,大家自己可以尝试下在 Main 函数中注册 KILL 信号,如下:

1
Signal.handle(new Signal("KILL"), signalHandlerImp);    // 9 : 强制终止

这个在运行的时候就会报错,因此 SIGKILL(9) 信号是唯一不能够被自定义的信号。

那既然我们自己可以自定义信号,那我们通过自定义的信号来处理我们的收尾操作也是可行的。因此我们只要在 SignalHandler 接口的实现类中 handle 方法中处理自己的收尾操作就可以了。这里也整理下自定义信号处理进行收尾的说明:

  • 实现 SignalHandler 接口,在 handle 方法中实现自己的收尾操作;
  • Main 函数里,在主任务启动之前注册 自定义信号名 即可完成收尾任务的注册,只需要注册一个就行了;
  • 使用 kill 的 对应 自定义信号名 进行任务进程的结束,就可以正常收尾了。

另外,在实际操作中使用自定义信号的方式通常是直接让 工作线程 实现 SignalHandler 接口的,我们上面是为了举例,以不至于发送对应信号后进程就停止了,而实际情况下是需要我们发送信号工作线程就应该停止,因此可以将上面的工作线程修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 工作线程,每秒钟输出一个递增的数字
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 关闭的不止有 正常关闭强制关闭 还有一种 异常关闭 如下图:

这种方式还是会调用以 Runtime.getRuntime().addShutdownHook(new ShudownHookThread()); 此方法注册的 收尾线程 的,而不会触发自定义的信号通信的。因此,还是第一种默认信号处理机制,通过 Hook 线程方式适应性更广泛。

参考

苟且一下