Java程序中的潛在危機: 深入探討NullPointerException
一、前言
在Java語言的世界里,處理錯誤和異常是每位開發者必須面對的重要課題。其中,NullPointerException無疑是最常見且令人頭痛的錯誤之一。它的出現往往讓我們措手不及,同時大概率會導致程序行為異常。盡管從最早的版本這個異常就貫穿在我們的編碼世界里,但它背后卻隱藏著深刻的歷史和設計哲學。
二、一則趣聞
在討論今天的主題之前,讓我們先介紹一位計算機科學界的杰出人物:Tony Hoare。他在業界享有極高的聲譽,成就斐然,重要事跡和頭銜足以讓人頂禮膜拜:
- 發明了廣為人知的快速排序算法
- 1980年榮獲圖靈獎
- 被選為美國國家工程院外籍院士、英國皇家工程院院士、牛津大學名譽教授
然而,Tony Hoare被大多數人所熟知的,還是他與空引用的故事。
1965年,Tony Hoare在設計ALGOL W語言時,引入了空引用Null Reference這一概念。他認為,空引用可以方便地表示無值或未知值。其設計初衷是借助編譯器的自動檢測機制,確保所有引用的使用都是絕對安全的。此外,這種設計思路在實現上相對簡單,大大減少了開發者的工作量。因此,受到Tony Hoare的影響,隨后幾十年中,許多編程語言,包括1991年誕生的Java(前身為Oak語言),也紛紛被這一設計思路所影響。
然而,隨著時間的推移,Hoare對自己當年引入空引用的決策進行了深刻的反思。在2009年,他坦言:
“我將我之前發明的空引用的處理稱為十億美元的錯誤。1965年,我在為一種面向對象的語言(ALGOL W)設計第一個全面的引用類型系統時,目標是確保所有引用的使用都應該是絕對安全的,由編譯器自動進行檢查。但我無法抵擋引入空引用的誘惑,因為這實在是太容易實現了。這導致了無數錯誤、漏洞和系統崩潰,可能在過去四十年里造成了十億美元的損失和痛苦。”
但從今天的軟件系統發展來看,空引用對業界的影響遠不止這一數字。它不僅改變了程序設計的方式,也引發了對異常處理、內存管理等眾多領域的深入思考。
三、空引用檢查
空引用識別
我們先來想一個問題:虛擬機是如何識別到空引用的呢?
- JDK底層封裝識別
- 字節碼層面識別
- 機器碼層面識別
- 類型檢查
- 內存數據分析
在不考慮實現復雜度的情況下,我們很快可以列舉出上述可能的識別方向,但Java虛擬機這邊給出了一種意料之外的解決方案:不主動識別。
這可能會讓很多研發人大跌眼鏡。大家可能會想,Java作為一門風靡全球的語言,應該有細致且周全的檢查空引用的邏輯,但實際卻和大家想的恰恰相反。
public static int getSize(List first, List second, List third, List fourth) {
return first.size() + second.size() + third.size() + fourth.size();
}
上述代碼累加了多個列表的大小,理論上每個列表對象都可能是個空值。如果按照我們預想的對于每個對象引用做空是否為空的檢查,那么對于每個列表對象都會做一次檢查,這次檢查會至少涉及到一條機器碼比較指令。這個成本對于當下的Java應用程序來說是巨大且不可接受的。
所以權衡之后虛擬機的開發者們采用了一種類似于Try-Catch的解決方案,白話一點的意思就是:我們并不實時去檢查是否可能有空的引用,因為絕大多數情況下空引用都是少數情況,但是如果真的發生了我們保證一定會處理(拋出NullPointerException)。
檢查細節
下面代碼是JDK8的虛擬機內部判別是否需要檢查空引用的實現,調用鏈路依次如圖中所示。入口處的注釋This platform only uses signal-based null checks. The Label is not needed就已經告訴我們了足夠多的信息,意思是在x86環境下,使用了基于signal的方式來完成了空的檢查,至于什么是signal我們先按下不表。
進一步的由于offset使用默認值,needs_explicit_null_check函數(是否需要顯式的進行空引用檢查)會返回false。這會導致最終函數null_check里什么也不做,僅有一行注釋nothing to do, (later) access of M[reg + offset] will provoke OS NULL exception if reg = NULL。這里的代碼注釋已經足夠直白,告訴我們如果空引用的情況下,訪問內存的時候會觸發操作系統層面的異常。
==================== c1_MacroAssembler_x86.hpp ====================
// This platform only uses signal-based null checks. The Label is not needed.
void null_check(Register r, Label *Lnull = NULL) { MacroAssembler::null_check(r); }
==================== macroAssembler_x86.cpp ====================
void MacroAssembler::null_check(Register reg, int offset) {
if (needs_explicit_null_check(offset)) {
// provoke OS NULL exception if reg = NULL by
// accessing M[reg] w/o changing any (non-CC) registers
// NOTE: cmpl is plenty here to provoke a segv
cmpptr(rax, Address(reg, 0));
// Note: should probably use testl(rax, Address(reg, 0));
// may be shorter code (however, this version of
// testl needs to be implemented first)
} else {
// nothing to do, (later) access of M[reg + offset]
// will provoke OS NULL exception if reg = NULL
}
}
==================== assembler.cpp ====================
bool MacroAssembler::needs_explicit_null_check(intptr_t offset) {
// Exception handler checks the nmethod's implicit null checks table
// only when this method returns false.
#ifdef _LP64
if (UseCompressedOops && Universe::narrow_oop_base() != NULL) {
assert (Universe::heap() != NULL, "java heap should be initialized");
// The first page after heap_base is unmapped and
// the 'offset' is equal to [heap_base + offset] for
// narrow oop implicit null checks.
uintptr_t base = (uintptr_t)Universe::narrow_oop_base();
if ((uintptr_t)offset >= base) {
// Normalize offset for the next check.
offset = (intptr_t)(pointer_delta((void*)offset, (void*)base, 1));
}
}
#endif
return offset < 0 || os::vm_page_size() <= offset;
}
四、空引用操作系統處理
我們回過頭再看上面代碼中的注釋:
nothing to do, (later) access of M[reg + offset] will provoke OS NULL exception if reg = NULL
它明確的告訴了我們觸發的細節,也就是當真的碰到了空引用,此時的流程應該是這樣:
- 空引用時寄存器里的地址也為空
- 基于寄存器內的空地址從內存讀取會觸發操作系統層面的Exception
那這個操作系統的層面到底是什么呢?
初見SIGSEGV
Linux下把信號分為了兩大類:可靠信號與不可靠信號。不可靠信號有可能丟失、順序問題等特點。其中我們日常遇見的信號基本都在不可靠信號這個區間內。這里列舉一些場景的信號:
圖片
而尤以SIGSEGV這個信號尤為重要和常見。它意味著此時發生了無效的內存訪問,而虛擬機對于NullPointerException的識別便是依靠著SIGSEGV才能完成。
SIGSEGV捕獲
操作系統對于所有的信號都有其默認行為。對于大部分不可靠信號來說,它的默認行為都是終止當前進程,有些場景下會同時生成核心轉儲文件。這意味著如果進程收到SIGSEGV信號其實是一件非常嚴重的事情,但操作系統層面同時也考慮到了擴展性: 雖然默認行為是終止進程,但是如果開發者確認這是個正常行為,那么可以嘗試攔截這樣的情況別忽略。所以操作系統在這里提供了回調方法的注冊,開發可以自行注冊回調來識別正常行為的信號。
如下是OpenJDK9中虛擬機的代碼,3個方法主要做了三件事情:
- install_signal_handlers(): 虛擬機啟動時注冊信號,這里完成了SIGSEGV的捕獲注冊
- set_signal_handler(): 設置回調函數為signalHandler
- signalHandler(): 進一步調用抽象的JNI函數JVM_handle_linux_signal
這里需要說明的是函數JVM_handle_linux_signal,它定義在os_linux.cpp下,但由于Linux平臺下還有更細的架構劃分,如x86、aarch64、arm、ppc、s390、sparc等,在不同的架構下有不同的實現,所以這里要抽象出統一的函數模型。
==================== os_linux.cpp ====================
void os::Linux::install_signal_handlers() {
...
set_signal_handler(SIGSEGV, true);
set_signal_handler(SIGPIPE, true);
set_signal_handler(SIGBUS, true);
set_signal_handler(SIGILL, true);
set_signal_handler(SIGFPE, true);
...
}
void os::Linux::set_signal_handler(int sig, bool set_installed) {
...
if (!set_installed) {
sigAct.sa_flags = SA_SIGINFO|SA_RESTART;
} else {
sigAct.sa_sigaction = signalHandler;
sigAct.sa_flags = SA_SIGINFO|SA_RESTART;
}
...
}
void signalHandler(int sig, siginfo_t* info, void* uc) {
assert(info != NULL && uc != NULL, "it must be old kernel");
int orig_errno = errno; // Preserve errno value over signal handler.
JVM_handle_linux_signal(sig, info, uc, true);
errno = orig_errno;
}
==================== os_linux_x86.cpp ====================
extern "C" JNIEXPORT int
JVM_handle_linux_signal(int sig,
siginfo_t* info,
void* ucVoid,
int abort_if_unrecognized) {
...
}
SIGSEGV捕獲后的行為
由于我們當前生產環境多為x86架構,所以這里我們只用關注os_linux_x86.cpp下的實現即可。這里可以看到一下的細節:
- NullPointerException下的SIGSEGV處理:設置攔截后的跳轉代碼,這里是SharedRuntime::continuation_for_implicit_exception,該函數負責拋出Java層面的NullPointerException。
- ucontext_set_pc: 重置PC寄存器,更改代碼執行行為,直接執行continuation_for_implicit_exception,這樣接下來就會拋出NullPointerException
- VMError::report_and_die等同于信號的默認語義,直接終止進程。
到此,NullPointerException從產生到拋出的全過程我們都有了了解。如下方注釋所說,當虛擬機收到操作系統回調時,如果發現是SIGSEGV信號且對應的內存offset為0,會主動返回并拋出NullPointerException,系統也并不會崩潰。
==================== os_linux_x86.cpp ====================
extern "C" JNIEXPORT int
JVM_handle_linux_signal(int sig,
siginfo_t* info,
void* ucVoid,
int abort_if_unrecognized) {
......
// 這里處理NullPointerException的情況
if (sig == SIGSEGV &&
!MacroAssembler::needs_explicit_null_check((intptr_t)info->si_addr)) {
// Determination of interpreter/vtable stub/compiled code null exception
stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::IMPLICIT_NULL);
}
}
}
...
// StackoverflowError和NullPointerException會主動返回并被記錄, 系統不掛
if (stub != NULL) {
// save all thread context in case we need to restore it
if (thread != NULL) thread->set_saved_exception_pc(pc);
os::Linux::ucontext_set_pc(uc, stub);
return true;
}
...
// 虛擬機不主動處理的信號到達這里會觸發系統掛掉
VMError::report_and_die(t, sig, pc, info, ucVoid);
ShouldNotReachHere();
return true; // Mute compiler
}
五、使用信號量的隱含風險
頻繁的空引用
JVM規范只是規定了當遇見空引用需要拋出空指針異常,但在具體實現的細節上,NullPointerException的監測和拋出多少有點超出了我們的想象,但從結果看它確實是符合JVM規范的行為。同時當前方案的好處也顯而易見,它將本來需要顯式的檢查一個引用是否為空的代碼轉換為了隱式的檢查(可以理解為和虛擬機核心邏輯處理流程解耦了),算是很精妙的設計。
那么到這里可能就有人會問了,如果我們代碼寫的很爛到處都是空引用呢?這樣的話NullPointerException要通過發信號、信號處理、跳轉到空指針檢查的后續處理代碼的路徑,比起直接生成顯式檢查的路徑要長得多也慢得多,豈不是得不償失?實際上也確實是這樣,但虛擬機的開發者就是在做一種假設:一個正常健康運行的系統就不應該會有這么多的空指針異常,如果真出現大量異常,開發者應該先去檢查自身代碼的健壯性。
信號量資源共享
在程序開發里一個非常重要的細節就是,你一定要管控好你的程序的作用域。如果在管控域之外的行為需要多加留意。回到這個問題本身,由于JVM采用了操作系統級別的信號量來同步NullPointerException信息,這在JVM本身內部并無問題,但由于JVM可以加載JNI代碼,如果加載的第三方JNI中也捕獲了SIGSEGV信號,這便會導致虛擬機自身的捕獲失效,屆時面對一個普通的NullPointerException都會導致系統崩潰。
下面是一個簡單的例子,大家可以在Linux環境嘗試:
// NPETest.java
import java.util.UUID;
public class NPETest {
public static void main(String[] args) throws Exception {
System.loadLibrary("NPETest");
UUID.fromString(null);
}
}
// NPETest.c
#include <signal.h>
#include <jni.h>
JNIEXPORT
jint JNICALL JNI_OnLoad(JavaVM *jvm, void *reserved) {
signal(SIGSEGV, SIG_DFL);
return JNI_VERSION_1_8;
}
我們可以將這個例子打包成一個shell腳本來執行:
gcc -Wall -shared -fPIC NPETest.c -o libNPETest.so -I$JAVA_HOME/include -I$JAVA_HOME/include/linux
javac NPETest.java
java -Xcomp -Djava.library.path=. -cp . NPETest
如上是一個簡單的例子,當加載的JNI代碼中存在手工捕獲了SIGSEGV之后,面對NullPointerException虛擬機只能無奈以崩潰告終,并生成堆轉儲文件。
圖片
如果我們將JNI中的信號量捕獲代碼signal(SIGSEGV, SIG_DFL);注釋掉,即可看到正常的異常拋出:
圖片
六、JDK的改進
Optional
Optional是JDK8引入的一個容器類,旨在提供一種更安全且清晰的方式來處理可能為空的值,從而減少 NullPointerException的發生。通過使用Optional,開發者可以明確地表示某個值可能缺失,這種設計促使開發者在代碼中顯式處理缺失值的情況,增強了代碼的健壯性和可讀性。Optional類提供了一系列便捷的方法,如isPresent()來檢查值是否存在、ifPresent()以避免空值的直接處理、orElse()用于提供默認值,以及map()和flatMap()方法以支持函數式編程風格的鏈式操作。這些特性不僅使代碼更簡潔,而且幫助開發者以更直觀的方式處理空值,提高了代碼的可維護性和可理解性。
需要指出的是,Optional最早是由Google Guava庫開發的。這一設計旨在提供一種更安全的方式來處理可能為空的值,減少空指針異常的發生。2014年發布的JDK8 中引入的Optional類,實際上是基于Guava的設計思想進行了改進和擴展。JDK8的Optional不僅保持了Guava的核心理念,還增加了一些新的方法和特性,使得開發者能夠以更簡潔和直觀的方式處理缺失值,從而提高代碼的可讀性和可維護性。
異常提示細化
隨著時間的推移,越來越多的開發者對于NullPointerException提出了更高的要求:
- 開發者在調試時花費大量時間尋找導致NullPointerException的原因(特別是鏈式調用的場景)
- 隨著編程語言的發展,許多現代語言已經提供了更好的空值處理和更有用的異常信息。但Java 作為一個成熟且廣泛使用的語言,卻沒有跟上這種趨勢
以下面代碼為例,研發就較難在第一時間決策出到底是代碼中的哪個返回是空才導致了NPE的發生:
System.out.println(earth.getAsian().getCountryList().size()); // NullPointerException
于是基于以上的訴求,Goetz Lindenmaier(在SAP負責JIT編譯器技術相關工作,是SAP的IA64移植的作者之一)發起了提案JEP 358: Helpful NullPointerExceptions, 核心主旨在于:通過準確指明哪個變量為 null,增強JVM生成的NullPointerExceptions的可用性。
對應該提案的內容在JDK14上正式生效。從這個版本開始,如果產生了NullPointerException,JVM可以給出詳細的信息告訴我們空對象到底是誰(需開啟-XX:+ShowCodeDetailsInExceptionMessages)。
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "org.example.Main$Earth.getAsian()" because "earth" is null
at org.example.Main.main(Main.java:8)
七、結語
在深入了解虛擬機如何處理NullPointerException之后,我們可以發現,表面上看似簡單的異常處理背后,實際上蘊藏著大量復雜的邏輯思考和設計上的平衡。這不僅涉及到如何有效捕獲和報告錯誤,還包括在性能、內存管理和用戶體驗之間進行權衡。Java虛擬機在設計時需要考慮到多種因素,例如如何迅速反饋給開發者,同時又不影響程序的整體性能和穩定性。通過深入分析這一過程,我們能夠更好地理解異常處理機制的內在原理,這不僅提升了我們的編程技能,也為我們在開發過程中處理類似問題提供了更深刻的視角和解決方案。希望本文能夠為你提供一些有價值的見解與幫助,激發你的進一步探索和思考。