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 应用程序来说是巨大且不可接受的。
所以权衡之后虚拟机的开发者们采用了一种类似于 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。这里的代码注释已经足够直白,告诉我们如果空引用的情况下,访问内存的时候会触发操作系统层面的异常。
四、空引用操作系统处理
我们回过头再看上面代码中的注释:
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 等,在不同的架构下有不同的实现,所以这里要抽象出统一的函数模型。
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,系统也并不会崩溃。
五、使用信号量的隐含风险
频繁的空引用
JVM 规范只是规定了当遇见空引用需要抛出空指针异常,但在具体实现的细节上,NullPointerException 的监测和抛出多少有点超出了我们的想象,但从结果看它确实是符合 JVM 规范的行为。同时当前方案的好处也显而易见,它将本来需要显式的检查一个引用是否为空的代码转换为了隐式的检查(可以理解为和虚拟机核心逻辑处理流程解耦了),算是很精妙的设计。
那么到这里可能就有人会问了,如果我们代码写的很烂到处都是空引用呢?这样的话 NullPointerException 要通过发信号、信号处理、跳转到空指针检查的后续处理代码的路径,比起直接生成显式检查的路径要长得多也慢得多,岂不是得不偿失?实际上也确实是这样,但虚拟机的开发者就是在做一种假设:一个正常健康运行的系统就不应该会有这么多的空指针异常,如果真出现大量异常,开发者应该先去检查自身代码的健壮性。
信号量资源共享
在程序开发里一个非常重要的细节就是,你一定要管控好你的程序的作用域。如果在管控域之外的行为需要多加留意。回到这个问题本身,由于 JVM 采用了操作系统级别的信号量来同步 NullPointerException 信息,这在 JVM 本身内部并无问题,但由于 JVM 可以加载 JNI 代码,如果加载的第三方 JNI 中也捕获了 SIGSEGV 信号,这便会导致虚拟机自身的捕获失效,届时面对一个普通的 NullPointerException 都会导致系统崩溃。
下面是一个简单的例子,大家可以在 Linux 环境尝试:
我们可以将这个例子打包成一个 shell 脚本来执行:
如上是一个简单的例子,当加载的 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 的发生:
于是基于以上的诉求,Goetz Lindenmaier(在 SAP 负责 JIT 编译器技术相关工作,是 SAP 的 IA64 移植的作者之一)发起了提案 JEP 358: Helpful NullPointerExceptions, 核心主旨在于:通过准确指明哪个变量为 null,增强 JVM 生成的 NullPointerExceptions 的可用性。
对应该提案的内容在 JDK14 上正式生效。从这个版本开始,如果产生了 NullPointerException,JVM 可以给出详细的信息告诉我们空对象到底是谁(需开启**-XX:+ShowCodeDetailsInExceptionMessages**)。
七、结语
在深入了解虚拟机如何处理 NullPointerException 之后,我们可以发现,表面上看似简单的异常处理背后,实际上蕴藏着大量复杂的逻辑思考和设计上的平衡。这不仅涉及到如何有效捕获和报告错误,还包括在性能、内存管理和用户体验之间进行权衡。Java 虚拟机在设计时需要考虑到多种因素,例如如何迅速反馈给开发者,同时又不影响程序的整体性能和稳定性。通过深入分析这一过程,我们能够更好地理解异常处理机制的内在原理,这不仅提升了我们的编程技能,也为我们在开发过程中处理类似问题提供了更深刻的视角和解决方案。希望本文能够为你提供一些有价值的见解与帮助,激发你的进一步探索和思考。
文 / 财神
关注得物技术,每周更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
版权声明: 本文为 InfoQ 作者【得物技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/3f387c62f7f492c3aacb79c6a】。文章转载请联系作者。
评论