Java程序中的潛在危機(jī): 深入探討NullPointerException
一、前言
在Java語(yǔ)言的世界里,處理錯(cuò)誤和異常是每位開(kāi)發(fā)者必須面對(duì)的重要課題。其中,NullPointerException無(wú)疑是最常見(jiàn)且令人頭痛的錯(cuò)誤之一。它的出現(xiàn)往往讓我們措手不及,同時(shí)大概率會(huì)導(dǎo)致程序行為異常。盡管從最早的版本這個(gè)異常就貫穿在我們的編碼世界里,但它背后卻隱藏著深刻的歷史和設(shè)計(jì)哲學(xué)。
二、一則趣聞
在討論今天的主題之前,讓我們先介紹一位計(jì)算機(jī)科學(xué)界的杰出人物:Tony Hoare。他在業(yè)界享有極高的聲譽(yù),成就斐然,重要事跡和頭銜足以讓人頂禮膜拜:
- 發(fā)明了廣為人知的快速排序算法
- 1980年榮獲圖靈獎(jiǎng)
- 被選為美國(guó)國(guó)家工程院外籍院士、英國(guó)皇家工程院院士、牛津大學(xué)名譽(yù)教授
然而,Tony Hoare被大多數(shù)人所熟知的,還是他與空引用的故事。
1965年,Tony Hoare在設(shè)計(jì)ALGOL W語(yǔ)言時(shí),引入了空引用Null Reference這一概念。他認(rèn)為,空引用可以方便地表示無(wú)值或未知值。其設(shè)計(jì)初衷是借助編譯器的自動(dòng)檢測(cè)機(jī)制,確保所有引用的使用都是絕對(duì)安全的。此外,這種設(shè)計(jì)思路在實(shí)現(xiàn)上相對(duì)簡(jiǎn)單,大大減少了開(kāi)發(fā)者的工作量。因此,受到Tony Hoare的影響,隨后幾十年中,許多編程語(yǔ)言,包括1991年誕生的Java(前身為Oak語(yǔ)言),也紛紛被這一設(shè)計(jì)思路所影響。
然而,隨著時(shí)間的推移,Hoare對(duì)自己當(dāng)年引入空引用的決策進(jìn)行了深刻的反思。在2009年,他坦言:
“我將我之前發(fā)明的空引用的處理稱為十億美元的錯(cuò)誤。1965年,我在為一種面向?qū)ο蟮恼Z(yǔ)言(ALGOL W)設(shè)計(jì)第一個(gè)全面的引用類(lèi)型系統(tǒng)時(shí),目標(biāo)是確保所有引用的使用都應(yīng)該是絕對(duì)安全的,由編譯器自動(dòng)進(jìn)行檢查。但我無(wú)法抵擋引入空引用的誘惑,因?yàn)檫@實(shí)在是太容易實(shí)現(xiàn)了。這導(dǎo)致了無(wú)數(shù)錯(cuò)誤、漏洞和系統(tǒng)崩潰,可能在過(guò)去四十年里造成了十億美元的損失和痛苦。”
但從今天的軟件系統(tǒng)發(fā)展來(lái)看,空引用對(duì)業(yè)界的影響遠(yuǎn)不止這一數(shù)字。它不僅改變了程序設(shè)計(jì)的方式,也引發(fā)了對(duì)異常處理、內(nèi)存管理等眾多領(lǐng)域的深入思考。
三、空引用檢查
空引用識(shí)別
我們先來(lái)想一個(gè)問(wèn)題:虛擬機(jī)是如何識(shí)別到空引用的呢?
- JDK底層封裝識(shí)別
- 字節(jié)碼層面識(shí)別
- 機(jī)器碼層面識(shí)別
- 類(lèi)型檢查
- 內(nèi)存數(shù)據(jù)分析
在不考慮實(shí)現(xiàn)復(fù)雜度的情況下,我們很快可以列舉出上述可能的識(shí)別方向,但Java虛擬機(jī)這邊給出了一種意料之外的解決方案:不主動(dòng)識(shí)別。
這可能會(huì)讓很多研發(fā)人大跌眼鏡。大家可能會(huì)想,Java作為一門(mén)風(fēng)靡全球的語(yǔ)言,應(yīng)該有細(xì)致且周全的檢查空引用的邏輯,但實(shí)際卻和大家想的恰恰相反。
public static int getSize(List first, List second, List third, List fourth) {
return first.size() + second.size() + third.size() + fourth.size();
}
上述代碼累加了多個(gè)列表的大小,理論上每個(gè)列表對(duì)象都可能是個(gè)空值。如果按照我們預(yù)想的對(duì)于每個(gè)對(duì)象引用做空是否為空的檢查,那么對(duì)于每個(gè)列表對(duì)象都會(huì)做一次檢查,這次檢查會(huì)至少涉及到一條機(jī)器碼比較指令。這個(gè)成本對(duì)于當(dāng)下的Java應(yīng)用程序來(lái)說(shuō)是巨大且不可接受的。
所以權(quán)衡之后虛擬機(jī)的開(kāi)發(fā)者們采用了一種類(lèi)似于Try-Catch的解決方案,白話一點(diǎn)的意思就是:我們并不實(shí)時(shí)去檢查是否可能有空的引用,因?yàn)榻^大多數(shù)情況下空引用都是少數(shù)情況,但是如果真的發(fā)生了我們保證一定會(huì)處理(拋出NullPointerException)。
檢查細(xì)節(jié)
下面代碼是JDK8的虛擬機(jī)內(nèi)部判別是否需要檢查空引用的實(shí)現(xiàn),調(diào)用鏈路依次如圖中所示。入口處的注釋This platform only uses signal-based null checks. The Label is not needed就已經(jīng)告訴我們了足夠多的信息,意思是在x86環(huán)境下,使用了基于signal的方式來(lái)完成了空的檢查,至于什么是signal我們先按下不表。
進(jìn)一步的由于offset使用默認(rèn)值,needs_explicit_null_check函數(shù)(是否需要顯式的進(jìn)行空引用檢查)會(huì)返回false。這會(huì)導(dǎo)致最終函數(shù)null_check里什么也不做,僅有一行注釋nothing to do, (later) access of M[reg + offset] will provoke OS NULL exception if reg = NULL。這里的代碼注釋已經(jīng)足夠直白,告訴我們?nèi)绻找玫那闆r下,訪問(wèn)內(nèi)存的時(shí)候會(huì)觸發(fā)操作系統(tǒng)層面的異常。
==================== 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;
}
四、空引用操作系統(tǒng)處理
我們回過(guò)頭再看上面代碼中的注釋?zhuān)?/p>
nothing to do, (later) access of M[reg + offset] will provoke OS NULL exception if reg = NULL
它明確的告訴了我們觸發(fā)的細(xì)節(jié),也就是當(dāng)真的碰到了空引用,此時(shí)的流程應(yīng)該是這樣:
- 空引用時(shí)寄存器里的地址也為空
- 基于寄存器內(nèi)的空地址從內(nèi)存讀取會(huì)觸發(fā)操作系統(tǒng)層面的Exception
那這個(gè)操作系統(tǒng)的層面到底是什么呢?
初見(jiàn)SIGSEGV
Linux下把信號(hào)分為了兩大類(lèi):可靠信號(hào)與不可靠信號(hào)。不可靠信號(hào)有可能丟失、順序問(wèn)題等特點(diǎn)。其中我們?nèi)粘S鲆?jiàn)的信號(hào)基本都在不可靠信號(hào)這個(gè)區(qū)間內(nèi)。這里列舉一些場(chǎng)景的信號(hào):
圖片
而尤以SIGSEGV這個(gè)信號(hào)尤為重要和常見(jiàn)。它意味著此時(shí)發(fā)生了無(wú)效的內(nèi)存訪問(wèn),而虛擬機(jī)對(duì)于NullPointerException的識(shí)別便是依靠著SIGSEGV才能完成。
SIGSEGV捕獲
操作系統(tǒng)對(duì)于所有的信號(hào)都有其默認(rèn)行為。對(duì)于大部分不可靠信號(hào)來(lái)說(shuō),它的默認(rèn)行為都是終止當(dāng)前進(jìn)程,有些場(chǎng)景下會(huì)同時(shí)生成核心轉(zhuǎn)儲(chǔ)文件。這意味著如果進(jìn)程收到SIGSEGV信號(hào)其實(shí)是一件非常嚴(yán)重的事情,但操作系統(tǒng)層面同時(shí)也考慮到了擴(kuò)展性: 雖然默認(rèn)行為是終止進(jìn)程,但是如果開(kāi)發(fā)者確認(rèn)這是個(gè)正常行為,那么可以嘗試攔截這樣的情況別忽略。所以操作系統(tǒng)在這里提供了回調(diào)方法的注冊(cè),開(kāi)發(fā)可以自行注冊(cè)回調(diào)來(lái)識(shí)別正常行為的信號(hào)。
如下是OpenJDK9中虛擬機(jī)的代碼,3個(gè)方法主要做了三件事情:
- install_signal_handlers(): 虛擬機(jī)啟動(dòng)時(shí)注冊(cè)信號(hào),這里完成了SIGSEGV的捕獲注冊(cè)
- set_signal_handler(): 設(shè)置回調(diào)函數(shù)為signalHandler
- signalHandler(): 進(jìn)一步調(diào)用抽象的JNI函數(shù)JVM_handle_linux_signal
這里需要說(shuō)明的是函數(shù)JVM_handle_linux_signal,它定義在os_linux.cpp下,但由于Linux平臺(tái)下還有更細(xì)的架構(gòu)劃分,如x86、aarch64、arm、ppc、s390、sparc等,在不同的架構(gòu)下有不同的實(shí)現(xiàn),所以這里要抽象出統(tǒng)一的函數(shù)模型。
==================== 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捕獲后的行為
由于我們當(dāng)前生產(chǎn)環(huán)境多為x86架構(gòu),所以這里我們只用關(guān)注os_linux_x86.cpp下的實(shí)現(xiàn)即可。這里可以看到一下的細(xì)節(jié):
- NullPointerException下的SIGSEGV處理:設(shè)置攔截后的跳轉(zhuǎn)代碼,這里是SharedRuntime::continuation_for_implicit_exception,該函數(shù)負(fù)責(zé)拋出Java層面的NullPointerException。
- ucontext_set_pc: 重置PC寄存器,更改代碼執(zhí)行行為,直接執(zhí)行continuation_for_implicit_exception,這樣接下來(lái)就會(huì)拋出NullPointerException
- VMError::report_and_die等同于信號(hào)的默認(rèn)語(yǔ)義,直接終止進(jìn)程。
到此,NullPointerException從產(chǎn)生到拋出的全過(guò)程我們都有了了解。如下方注釋所說(shuō),當(dāng)虛擬機(jī)收到操作系統(tǒng)回調(diào)時(shí),如果發(fā)現(xiàn)是SIGSEGV信號(hào)且對(duì)應(yīng)的內(nèi)存offset為0,會(huì)主動(dòng)返回并拋出NullPointerException,系統(tǒng)也并不會(huì)崩潰。
==================== 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會(huì)主動(dòng)返回并被記錄, 系統(tǒng)不掛
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;
}
...
// 虛擬機(jī)不主動(dòng)處理的信號(hào)到達(dá)這里會(huì)觸發(fā)系統(tǒng)掛掉
VMError::report_and_die(t, sig, pc, info, ucVoid);
ShouldNotReachHere();
return true; // Mute compiler
}
五、使用信號(hào)量的隱含風(fēng)險(xiǎn)
頻繁的空引用
JVM規(guī)范只是規(guī)定了當(dāng)遇見(jiàn)空引用需要拋出空指針異常,但在具體實(shí)現(xiàn)的細(xì)節(jié)上,NullPointerException的監(jiān)測(cè)和拋出多少有點(diǎn)超出了我們的想象,但從結(jié)果看它確實(shí)是符合JVM規(guī)范的行為。同時(shí)當(dāng)前方案的好處也顯而易見(jiàn),它將本來(lái)需要顯式的檢查一個(gè)引用是否為空的代碼轉(zhuǎn)換為了隱式的檢查(可以理解為和虛擬機(jī)核心邏輯處理流程解耦了),算是很精妙的設(shè)計(jì)。
那么到這里可能就有人會(huì)問(wèn)了,如果我們代碼寫(xiě)的很爛到處都是空引用呢?這樣的話NullPointerException要通過(guò)發(fā)信號(hào)、信號(hào)處理、跳轉(zhuǎn)到空指針檢查的后續(xù)處理代碼的路徑,比起直接生成顯式檢查的路徑要長(zhǎng)得多也慢得多,豈不是得不償失?實(shí)際上也確實(shí)是這樣,但虛擬機(jī)的開(kāi)發(fā)者就是在做一種假設(shè):一個(gè)正常健康運(yùn)行的系統(tǒng)就不應(yīng)該會(huì)有這么多的空指針異常,如果真出現(xiàn)大量異常,開(kāi)發(fā)者應(yīng)該先去檢查自身代碼的健壯性。
信號(hào)量資源共享
在程序開(kāi)發(fā)里一個(gè)非常重要的細(xì)節(jié)就是,你一定要管控好你的程序的作用域。如果在管控域之外的行為需要多加留意?;氐竭@個(gè)問(wèn)題本身,由于JVM采用了操作系統(tǒng)級(jí)別的信號(hào)量來(lái)同步NullPointerException信息,這在JVM本身內(nèi)部并無(wú)問(wèn)題,但由于JVM可以加載JNI代碼,如果加載的第三方JNI中也捕獲了SIGSEGV信號(hào),這便會(huì)導(dǎo)致虛擬機(jī)自身的捕獲失效,屆時(shí)面對(duì)一個(gè)普通的NullPointerException都會(huì)導(dǎo)致系統(tǒng)崩潰。
下面是一個(gè)簡(jiǎn)單的例子,大家可以在Linux環(huán)境嘗試:
// 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;
}
我們可以將這個(gè)例子打包成一個(gè)shell腳本來(lái)執(zhí)行:
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
如上是一個(gè)簡(jiǎn)單的例子,當(dāng)加載的JNI代碼中存在手工捕獲了SIGSEGV之后,面對(duì)NullPointerException虛擬機(jī)只能無(wú)奈以崩潰告終,并生成堆轉(zhuǎn)儲(chǔ)文件。
圖片
如果我們將JNI中的信號(hào)量捕獲代碼signal(SIGSEGV, SIG_DFL);注釋掉,即可看到正常的異常拋出:
圖片
六、JDK的改進(jìn)
Optional
Optional是JDK8引入的一個(gè)容器類(lèi),旨在提供一種更安全且清晰的方式來(lái)處理可能為空的值,從而減少 NullPointerException的發(fā)生。通過(guò)使用Optional,開(kāi)發(fā)者可以明確地表示某個(gè)值可能缺失,這種設(shè)計(jì)促使開(kāi)發(fā)者在代碼中顯式處理缺失值的情況,增強(qiáng)了代碼的健壯性和可讀性。Optional類(lèi)提供了一系列便捷的方法,如isPresent()來(lái)檢查值是否存在、ifPresent()以避免空值的直接處理、orElse()用于提供默認(rèn)值,以及map()和flatMap()方法以支持函數(shù)式編程風(fēng)格的鏈?zhǔn)讲僮?。這些特性不僅使代碼更簡(jiǎn)潔,而且?guī)椭_(kāi)發(fā)者以更直觀的方式處理空值,提高了代碼的可維護(hù)性和可理解性。
需要指出的是,Optional最早是由Google Guava庫(kù)開(kāi)發(fā)的。這一設(shè)計(jì)旨在提供一種更安全的方式來(lái)處理可能為空的值,減少空指針異常的發(fā)生。2014年發(fā)布的JDK8 中引入的Optional類(lèi),實(shí)際上是基于Guava的設(shè)計(jì)思想進(jìn)行了改進(jìn)和擴(kuò)展。JDK8的Optional不僅保持了Guava的核心理念,還增加了一些新的方法和特性,使得開(kāi)發(fā)者能夠以更簡(jiǎn)潔和直觀的方式處理缺失值,從而提高代碼的可讀性和可維護(hù)性。
異常提示細(xì)化
隨著時(shí)間的推移,越來(lái)越多的開(kāi)發(fā)者對(duì)于NullPointerException提出了更高的要求:
- 開(kāi)發(fā)者在調(diào)試時(shí)花費(fèi)大量時(shí)間尋找導(dǎo)致NullPointerException的原因(特別是鏈?zhǔn)秸{(diào)用的場(chǎng)景)
- 隨著編程語(yǔ)言的發(fā)展,許多現(xiàn)代語(yǔ)言已經(jīng)提供了更好的空值處理和更有用的異常信息。但Java 作為一個(gè)成熟且廣泛使用的語(yǔ)言,卻沒(méi)有跟上這種趨勢(shì)
以下面代碼為例,研發(fā)就較難在第一時(shí)間決策出到底是代碼中的哪個(gè)返回是空才導(dǎo)致了NPE的發(fā)生:
System.out.println(earth.getAsian().getCountryList().size()); // NullPointerException
于是基于以上的訴求,Goetz Lindenmaier(在SAP負(fù)責(zé)JIT編譯器技術(shù)相關(guān)工作,是SAP的IA64移植的作者之一)發(fā)起了提案JEP 358: Helpful NullPointerExceptions, 核心主旨在于:通過(guò)準(zhǔn)確指明哪個(gè)變量為 null,增強(qiáng)JVM生成的NullPointerExceptions的可用性。
對(duì)應(yīng)該提案的內(nèi)容在JDK14上正式生效。從這個(gè)版本開(kāi)始,如果產(chǎn)生了NullPointerException,JVM可以給出詳細(xì)的信息告訴我們空對(duì)象到底是誰(shuí)(需開(kāi)啟-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)
七、結(jié)語(yǔ)
在深入了解虛擬機(jī)如何處理NullPointerException之后,我們可以發(fā)現(xiàn),表面上看似簡(jiǎn)單的異常處理背后,實(shí)際上蘊(yùn)藏著大量復(fù)雜的邏輯思考和設(shè)計(jì)上的平衡。這不僅涉及到如何有效捕獲和報(bào)告錯(cuò)誤,還包括在性能、內(nèi)存管理和用戶體驗(yàn)之間進(jìn)行權(quán)衡。Java虛擬機(jī)在設(shè)計(jì)時(shí)需要考慮到多種因素,例如如何迅速反饋給開(kāi)發(fā)者,同時(shí)又不影響程序的整體性能和穩(wěn)定性。通過(guò)深入分析這一過(guò)程,我們能夠更好地理解異常處理機(jī)制的內(nèi)在原理,這不僅提升了我們的編程技能,也為我們?cè)陂_(kāi)發(fā)過(guò)程中處理類(lèi)似問(wèn)題提供了更深刻的視角和解決方案。希望本文能夠?yàn)槟闾峁┮恍┯袃r(jià)值的見(jiàn)解與幫助,激發(fā)你的進(jìn)一步探索和思考。