是否有可能追踪到哪个表达式导致了 NPE?

Is it possible to track down which expression caused an NPE?

当我得到一个 NPE 时,我将得到一个带有行号的堆栈跟踪。 这很有用,但如果行非常密集 and/or 包含嵌套表达式,仍然无法找出哪个引用为空。

当然,此信息一定在某处可用。 有办法解决这个问题吗? (如果不是 java 表达式,那么至少导致 NPE 的字节码指令也会有所帮助)

编辑#1:我看到一些评论建议打断这条线等等,这没有冒犯,实际上是非建设性和无关紧要的。如果我能那样做,我会的!只是说这种修改源代码是不可能的。

编辑 #2:apangin 在下面发布了一个很好的答案,我接受了。但是对于不想自己尝试的人来说,我不得不在这里包含输出太酷了! ;)

假设我有这个驱动程序TestNPE.java

 1  public class TestNPE {
 2      public static void main(String[] args) {
 3          int n = 0;
 4          String st = null;
 5  
 6          System.out.println("about to throw NPE");
 7          if (n >= 0 && st.isEmpty()){
 8              System.out.println("empty");
 9          }
10          else {
11              System.out.println("othereise");
12          }
13      }
14      
15  }

字节码如下所示(仅显示 main() 方法并省略其他不相关的部分)

Code:
  stack=2, locals=3, args_size=1
     0: iconst_0
     1: istore_1
     2: aconst_null
     3: astore_2
     4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;                                              
     7: ldc           #3                  // String about to throw NPE                                                                     
     9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V                                      
    12: iload_1
    13: iflt          34
    16: aload_2
    17: invokevirtual #5                  // Method java/lang/String.isEmpty:()Z                                                           
    20: ifeq          34
    23: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;                                              
    26: ldc           #6                  // String empty                                                                                  
    28: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V                                      
    31: goto          42
    34: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;                                              
    37: ldc           #7                  // String othereise                                                                              
    39: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V                                      
    42: return

现在当你 运行 带有代理的 TestNPE 驱动程序时,你会得到这个

$ java -agentpath:libRichNPE.o TestNPE
about to throw NPE
Exception in thread "main" java.lang.NullPointerException: location=17
    at TestNPE.main(TestNPE.java:7)

因此指向偏移量 17 处的 invokevirtual #5!只是 那有多酷?

异常本身没有足够的信息来提供超过行号。

我看到的一个选择是使用字节码调试器(如字节码可视化工具)来更接近地定位导致 npe 的字节码指令。一直往前走,直到出现异常,或者给npe加个断点。

堆栈跟踪机制依赖于选择性编译到每个 class 中的调试元数据(即 SourceFile 和 LineNumberTable 属性)。据我所知,字节码偏移量不会在任何地方保留。但是,这些对于典型的 Java 程序没有用,因为您仍然知道每个字节码指令对应的代码。

但是,有一个明显的解决方法 - 只需将有问题的代码分成多行并重新编译!您几乎可以在 Java.

中的任何位置插入空格

当异常发生时,JVM 知道导致异常的原始字节码。但是,StackTraceElement 不跟踪字节码索引。

解决方案是每当发生异常时使用JVMTI捕获字节码索引。

以下示例 JVMTI 代理将拦截所有异常,如果异常类型为 NullPointerException,代理将用字节码位置信息替换其 detailMessage

#include <jvmti.h>
#include <stdio.h>

static jclass NullPointerException;
static jfieldID detailMessage;

void JNICALL VMInit(jvmtiEnv* jvmti, JNIEnv* env, jthread thread) {
    jclass localNPE = env->FindClass("java/lang/NullPointerException");
    NullPointerException = (jclass) env->NewGlobalRef(localNPE);

    jclass Throwable = env->FindClass("java/lang/Throwable");
    detailMessage = env->GetFieldID(Throwable, "detailMessage", "Ljava/lang/String;");
}

void JNICALL ExceptionCallback(jvmtiEnv* jvmti, JNIEnv* env, jthread thread,
                               jmethodID method, jlocation location, jobject exception,
                               jmethodID catch_method, jlocation catch_location) {
    if (env->IsInstanceOf(exception, NullPointerException)) {
        char buf[32];
        sprintf(buf, "location=%ld", (long)location);
        env->SetObjectField(exception, detailMessage, env->NewStringUTF(buf));
    }
}

JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM* vm, char* options, void* reserved) {
    jvmtiEnv* jvmti;
    vm->GetEnv((void**)&jvmti, JVMTI_VERSION_1_0);

    jvmtiCapabilities capabilities = {0};
    capabilities.can_generate_exception_events = 1;
    jvmti->AddCapabilities(&capabilities);

    jvmtiEventCallbacks callbacks = {0};
    callbacks.VMInit = VMInit;
    callbacks.Exception = ExceptionCallback;
    jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks));
    jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_VM_INIT, NULL);
    jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_EXCEPTION, NULL);

    return 0;
}

将其编译到共享库中并 运行 java 使用 -agentpath 选项:

java -agentpath:/pato/to/libRichNPE.so Main

您可以将复杂的行分解成许多您可以跟踪的更小的行,或者使用调试器查看异常发生时 null 的值。

虽然您可以尝试查看发生这种情况的字节码,但这只是复杂旅程的开始。我建议让你的代码更容易理解,你可能会计算出哪些值可以是 null (注意:它可能是 null 除非你知道这是不可能的)

JEP 358: Helpful NullPointerExceptions在OpenJDK 14中加入了这样的特性,默认是禁用的;您必须指定 -XX:+ShowCodeDetailsInExceptionMessages 才能启用它。有了它,您的示例结果为:

Exception in thread "main"
java.lang.NullPointerException: Cannot invoke "String.isEmpty()" because "st" is null
    at TestNPE.main(TestNPE.java:7)

类 无需重新编译即可利用此功能。它最初是为 SAP JVM.

开发的