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作为一门风靡全球的语言,应该有细致且周全的检查空引用的逻辑,但实际却和大家想的恰恰相反。

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。这里的代码注释已经足够直白,告诉我们如果空引用的情况下,访问内存的时候会触发操作系统层面的异常。

java 复制代码
 ==================== 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等,在不同的架构下有不同的实现,所以这里要抽象出统一的函数模型。

java 复制代码
==================== 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,系统也并不会崩溃。

java 复制代码
==================== 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环境尝试:

java 复制代码
// 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脚本来执行:

java 复制代码
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的发生:

java 复制代码
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**)。

java 复制代码
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虚拟机在设计时需要考虑到多种因素,例如如何迅速反馈给开发者,同时又不影响程序的整体性能和稳定性。通过深入分析这一过程,我们能够更好地理解异常处理机制的内在原理,这不仅提升了我们的编程技能,也为我们在开发过程中处理类似问题提供了更深刻的视角和解决方案。希望本文能够为你提供一些有价值的见解与帮助,激发你的进一步探索和思考。

文 / 财神

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

相关推荐
AAA.建材批发刘哥1 小时前
Linux快速入门-Linux文件系统管理
linux·运维·服务器·c语言·学习方法
LKAI.2 小时前
搭建Elastic search群集
linux·运维·elasticsearch·搜索引擎
dlnu20152506222 小时前
ssr实现方案
前端·javascript·ssr
gywl3 小时前
openEuler VM虚拟机操作(期末考试)
linux·服务器·网络·windows·http·centos
轻口味4 小时前
命名空间与模块化概述
开发语言·前端·javascript
前端小小王4 小时前
React Hooks
前端·javascript·react.js
迷途小码农零零发4 小时前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
日记跟新中5 小时前
Ubuntu20.04 修改root密码
linux·运维·服务器
码农君莫笑5 小时前
信管通低代码信息管理系统应用平台
linux·数据库·windows·低代码·c#·.net·visual studio
BUG 4045 小时前
Linux——Shell
linux·运维·服务器