java -jar 關掉終端機就死?決定生死的不是 Java,是你的 shell
週五下午五點半,SSH 進正式機,敲下 nohup java -jar app.jar &,看著 log 開始滾,關掉視窗,下班。
星期一早上收到客服訊息說系統從週末就連不上。SSH 進去 ps -ef | grep java,什麼都沒有。
這件事我踩過,同事也踩過,但我們兩個踩的還不是同一種。他的版本更氣人:他沒加 nohup,只打了 java -jar app.jar &,關掉視窗,服務活得好好的。同一份佈署筆記,兩台一模一樣的機器,兩個相反的結果。
先怪 Java,怪錯了
那時候第一個念頭是 JVM 有沒有裝什麼 shutdown hook 在亂關。翻了一輪 Spring Boot 的 graceful shutdown 設定,server.shutdown=graceful 沒開,spring.lifecycle.timeout-per-shutdown-phase 是預設值。程式碼裡也沒有人自己註冊 Runtime.getRuntime().addShutdownHook。
第二個念頭是 OOM Killer。dmesg | grep -i kill 乾乾淨淨,機器有 16G,這支服務吃不到 2G。
兩個假設都不成立之後,我才意識到一件事:進程死掉的時間點,跟我關掉終端機的時間點對得上。那就不是 Java 的問題。Java 從頭到尾只是一個乖乖收訊號的進程,決定要不要送那個訊號給它的,是外面那層 shell。
訊號是誰送的,送給誰
拆開來看其實只有三個角色。
終端機(或 SSH session)是房東。你的 shell 是二房東。java -jar 開出來的那個進程是房客。房東要收房的時候,只會通知二房東一聲 SIGHUP,也就是 hang up,「線斷了」。房東不會挨家挨戶敲門。
二房東收到這聲通知之後會做一件很盡責的事:在自己搬走之前,照著租客名冊一個一個轉告。
& 這個符號做的事,只是把房客登記進名冊而已。它讓進程在背景跑,但那份名冊還在,門牌還掛著。它沒有解掉任何一件跟 SIGHUP 有關的事。
所以房客要活下來,只有兩條路:從名冊上被劃掉,或者自己戴耳塞裝作沒聽到。後面會看到,這剛好對應兩個不同的指令。
我第一次測,測錯了東西
這件事不用查文件,跑一行就知道。我在自己的 Mac 上跑了兩個 shell 的原廠預設值:
1 | # zsh 的 HUP 選項(-f 代表不讀任何設定檔,看的是原廠值) |
zsh 5.9 原廠是 ON,bash 3.2.57 原廠是 off。看到這兩行,我以為答案出來了:zsh 會殺,bash 不會。
於是我去驗。讓 shell 開一個背景 job、自己 exit,一秒後檢查 PID 還在不在:
1 | bash --norc -c 'sleep 30 & echo $! > b.pid; exit' # → PID 1501 仍存活 |
HUP=ON 的那個也活著。加 disown(17651)、加 nohup(18489)都活著。我猜是非互動 shell 不做 job 管理那一套,於是加 -i 再用 script 包一層 pty 模擬真正的互動終端機,同樣四種組合重跑:36031、36934、37728、38657。八組全活,沒有一組死掉。
測不出來不是因為沒有差別,是因為我測的那條路根本不殺人。
兩條路,只有一條會殺人
man bash 把這件事寫得很清楚,我一開始沒讀到:
1 | The shell exits by default upon receipt of a SIGHUP. Before exiting, |
第一句跟第二句講的是同一條路徑:當 shell 自己收到 SIGHUP,互動 shell 會在離開之前,把這個訊號原封不動轉發給所有 job。這條路沒有開關可以關,不歸 huponexit 管。
huponexit 管的是另一條,你自己在鍵盤上打 exit 的那條。它預設 off,所以 job 活著。
而我前面那八組,測的全是第二條。
關掉終端機視窗、SSH 被網路砍斷,走的是第一條。所以我重測一次,這次不打 exit,直接對 shell 送 SIGHUP:
1 | # 用 script 開一個 pty 互動 bash,在裡面起背景 job,記下 shell 與 job 的 PID |
死了。同一個做法,背景那支改用 nohup 起,PID 11923 仍存活;改成起完補一句 disown,PID 15249 仍存活。
這才是週五下午那件事的解釋。我做的不是打 exit,是把視窗叉掉。終端機消失,shell 收到 SIGHUP,然後它很盡責地把這個訊號轉發給我三分鐘前才起的那支 Java。
順帶一提,我日常用的那個 zsh,HUP 測出來是 OFF,跟原廠的 ON 相反。同一個 zsh 5.9,只因為讀了我的設定檔跟外掛,選項就被改掉了,我 grep .zshrc 跟 .zprofile 還找不到是誰動的手。
所以「& 到底會不會活」這個問題,答案取決於你是怎麼離開那個終端機的、你用哪個 shell、還有那台機器上某個你可能沒讀過的設定檔。佈署腳本建立在這種東西上面,等於在賭。
五種做法,各自解掉哪一步
把常見的幾種寫法放在一起看,差別立刻清楚。
java -jar app.jar 什麼都沒做,前景跑,關掉就死。這個沒爭議。
java -jar app.jar & 只是丟到背景。名冊還在,所以你打 exit 走人它活著,你把視窗叉掉它就跟著陪葬。上一段那個 8292 就是它。
Ctrl+Z 之後 bg 再 exit,跟上一種是同一件事換個姿勢。Ctrl+Z 送的是 SIGTSTP 把它停住,bg 讓它在背景繼續。job 從頭到尾都在名冊上,一步都沒少。網路上很多筆記把這招列成獨立的「第三種方式」,但它跟 & 的結果完全一樣。
disown 是第一個真的有動作的。java -jar app.jar & 之後補一句 disown,等於把這個房客從名冊上劃掉。轉發的前提是它在名冊上,劃掉之後 shell 找不到它,兩條路徑都不會轉。這不是我的推論,man bash 上面那段最後一句就是這樣寫的:要讓 shell 不要送訊號給某個 job,就把它從 jobs table 移除。
它的缺點不在生死,在事後補救這件事:你得記得敲第二句,而且它只解掉訊號,那支 process 的 stdout 還是接在即將消失的那個終端機上。
nohup java -jar app.jar & 換個角度解。它不去動名冊,而是在進程啟動前就把 SIGHUP 設成忽略。訊號照送,房客裝作沒聽到。這是四種裡面最不依賴 shell 行為的一種,也是為什麼各家筆記都推它。
setsid java -jar app.jar 是最徹底的一種:直接開一個新的 session,讓進程變成自己的 session leader,沒有 controlling terminal。房客搬出這棟樓了,房東收房跟它無關。要注意這是 Linux 的 util-linux 提供的指令,macOS 上沒有,我在自己機器上 command -v setsid 是空的。
nohup 的那個檔案,跟你想的不一樣
nohup 有個被講錯很久的細節。很多筆記寫「nohup 會在當前目錄產生 nohup.log」,這句話兩個地方都不對。
檔名是 nohup.out,不是 nohup.log。而且它不是每次都會產生。macOS 的 man page 寫得很清楚:
1 | terminal, the standard output is appended to the file nohup.out in the |
關鍵是開頭那個條件句:只有當 stdout 是終端機的時候,nohup 才會接管並倒進 nohup.out。如果你已經自己重導向過,nohup 就不插手。我實測跑了一次不重導向的 nohup,因為那個環境的 stdout 不是 tty,目錄下什麼檔案都沒生出來,輸出直接走原本的管道。
這個細節在佈署腳本裡會咬人。你在互動式終端機測的時候看到 nohup.out 長出來,於是寫了一段 logrotate 去輪替它;等腳本被 cron 或 CI 呼叫的時候 stdout 不是 tty,nohup.out 根本不存在,你的輪替設定空轉,而真正的輸出跑到你沒預期的地方去了。
所以不要讓 nohup 決定。自己寫死:
1 | nohup java -jar app.jar > /var/log/myapp/app.log 2>&1 & |
或者你根本不想要那份輸出(Spring Boot 通常已經有自己的 logback 在寫檔了),就丟進黑洞:
1 | nohup java -jar app.jar > /dev/null 2>&1 & |
2>&1 那段別漏。只導 stdout 不導 stderr,異常堆疊會繼續往舊管道噴,而那個管道在你關掉終端機之後就沒人接了,運氣不好還會讓進程在寫入時卡住。
我看過一次 60G 的 nohup.out。服務跑了八個月,把整台機器的磁碟塞到 98%,最後是資料庫先寫不進去才爆出來。查了半天以為是資料量成長,其實是一支每秒印兩行 DEBUG 的排程。
上面這些,正式環境一個都不該用
講完五種做法,講個掃興的:這些全部都只是在對抗症狀。
nohup ... & 解決的是「我關掉終端機它不要死」。但正式環境真正需要的是另外幾件事:機器重開之後它要自己起來、它自己 crash 之後要有人把它拉起來、log 要有地方去而且會輪替、要有一個標準的方式查它現在活著沒。這四件事,nohup 一件都不管。
Linux 上這件事早就有標準答案了。寫一份 /etc/systemd/system/myapp.service:
1 | [Unit] |
寫完檔案要 systemctl daemon-reload 讓 systemd 重讀,然後 systemctl enable --now myapp 啟用並啟動,systemctl status myapp 查狀態,journalctl -u myapp -f 看 log。SIGHUP 這整個問題從一開始就不存在,因為訊號不是從你的終端機生出來的,這支 process 也不掛在任何終端機底下。
那個 SuccessExitStatus=143 值得單獨講。JVM 收到 SIGTERM 正常關閉時,離開碼是 128 加 15 等於 143。systemd 預設只把 0 當成功,看到 143 會把 unit 判成 failed。
要講精確一點:這不會導致「你 stop 它、它自己又爬起來」,因為 systemctl stop 這條路本來就不觸發 Restart=。真正的代價是那個 failed 狀態會一直掛在 systemctl status 上、會被你的監控抓去告警、還會觸發你設的 OnFailure=。每次正常重啟服務都留一筆假的失敗紀錄,久了就沒人看告警了。這一行是在告訴 systemd:143 是我自己關的,算成功。
回到週五下午五點半
現在我 SSH 進正式機,只會敲兩種指令的其中一種。
要是這台已經有 systemd unit,就 systemctl restart myapp 然後 journalctl -u myapp -f 看它起來,看完關視窗走人。
要是這是一台臨時的測試機,或是我在別人的環境上急著驗一個東西,那就 nohup java -jar app.jar > /tmp/app.log 2>&1 &,重導向寫死,然後心裡清楚這只是暫時的。
至於 java -jar app.jar & 這種寫法,我現在看到會停下來問一句:這台機器的 shell 是哪一種二房東?問不出答案就不要用。你的服務不該把生死押在一個沒人記得誰改過的設定值上。
還沒解掉的部分我也說清楚:容器裡這整套邏輯又是另一回事。Docker 或 Kubernetes 中 java -jar 常常直接當 PID 1,而 PID 1 對訊號的處理規則跟一般 process 不同,預設不吃那些沒有自訂 handler 的訊號,這會直接影響 graceful shutdown 收不收得到 SIGTERM。同一個「訊號送給誰、誰會轉發」的問題,換一層又要重問一次。
素材來源:Notion 開發筆記「Java-Jar 檔啟用方式」,原始出處為 java -jar 後台啟動的四種方式(Linux)(該文把輸出檔寫成 nohup.log,本文依 man page 更正為 nohup.out)。
驗證範圍:shell 選項值(HUP / huponexit)、nohup.out 的產生條件、八組 exit 路徑存活測試、三組 kill -HUP 路徑測試(裸 job 8292 死亡、nohup 11923 存活、disown 15249 存活),皆為 macOS 26(zsh 5.9 / bash 3.2.57)本機實跑輸出,man bash 引文出自本機 man page。systemd 段落依 systemd.service(5) 的行為描述撰寫,本機無 systemd,未實跑驗證。容器 PID 1 那段未驗證。






































