Java 14中的 JEP 358: Helpful NullPointerExceptions是如何实现的

Java 14在2020年3月17号正式发布,不久AdoptOpenJDK也跟进发布了新版。

JEP 358: Helpful NullPointerExceptions

其中对使用者比较有用的功能是 JEP 358: Helpful NullPointerExceptions,即在NullPointerException的message中指明为什么产生了这个NPE。

示例代码如下(需要Java14,并配置参数-XX:+ShowCodeDetailsInExceptionMessages):

public class Main {
    static class Class1 {
        Object object;
    }
    static class Class2 {
        Class1 class1;
        public Class1 getClass1() {
            return class1;
        }
    }
    static Class2 class2;
    private static Object get(int i) {
        return null;
    }
    // 打印NPE的message
    private static void printNPE(Runnable runnable) {
        try {
            runnable.run();
        } catch (NullPointerException e) {
            System.err.println(e.getMessage());
        }
    }
    public static void main(String[] args) {
        Class2 class21 = null;
        printNPE(() -> {
            // 1. getstatic为null
            // Cannot invoke "Main$Class2.getClass1()" because "Main.class2" is null
            System.out.println(class2.getClass1().object.toString());
        });
        Class2 class22 = new Class2();
        printNPE(() -> {
            // 2. 调用函数结果为null
            // Cannot read field "object" because the return value of "Main$Class2.getClass1()" is null
            System.out.println(class22.getClass1().object.toString());
        });
        class22.class1 = new Class1();
        printNPE(() -> {
            // 3. 函数调用后,getfield为null
            // Cannot invoke "Object.toString()" because "Main$Class2.getClass1().object" is null
            System.out.println(class22.getClass1().object.toString());
        });
        printNPE(() -> {
            // 4. 链式getfield
            // Cannot invoke "Object.toString()" because "class22.class1.object" is null
            System.out.println(class22.class1.object.toString());
        });
        Integer[] ary1 = null;
        printNPE(() -> {
            // 5. 对null执行aaload
            // Cannot load from object array because "ary1" is null
            System.out.println(ary1[0].intValue());
        });
        Integer[] ary2 = new Integer[1];
        printNPE(() -> {
            // 6. iconst_0+aaload后为null
            // Cannot invoke "java.lang.Integer.intValue()" because "ary2[0]" is null
            System.out.println(ary2[0].intValue());
        });
        printNPE(() -> {
            int i = 0;
            // 7. iconst_0+iload_1后为null
            // Cannot invoke "java.lang.Integer.intValue()" because "ary2[i]" is null
            System.out.println(ary2[i].intValue());
        });
        printNPE(() -> {
            int i = 0;
            // 8. iconst_0+iadd后为null
            // Cannot invoke "java.lang.Integer.intValue()" because "ary2[...]" is null
            System.out.println(ary2[i + 0].intValue());
        });
        printNPE(() -> {
            // 9. invokevirtual结果为null
            // Cannot invoke "Object.toString()" because the return value of "Main.get(int)" is null
            System.out.println(Main.get(1).toString());
        });
    }
}

令我比较意外的是情况4和情况7,Java能够追溯链式回调,而且连变量名都带上了!

JEP 358实现细节

那我们就看下这么神奇的特性是如何实现的吧。

首先,这个功能需要添加参数-XX:+ShowCodeDetailsInExceptionMessages才能开启,那我们只要在JDK的代码中搜索这个字符串就行。于是找到给NPE填充Message的代码:

可以看到,比较重要函数是 get_NPE_message_at,我们继续跟进:

这儿的逻辑很简单,首先打印失败的操作(print_NPE_failed_action)然后再打印失败的原因(print_NPE_cause)。

先看 print_NPE_failed_action ,主体就是一个大的switch:

void ExceptionMessageBuilder::print_NPE_failed_action(outputStream *os, int bci) {

  // Get the bytecode.
  address code_base = _method->constMethod()->code_base();
  Bytecodes::Code code = Bytecodes::java_code_at(_method, code_base + bci);
  int pos = bci + 1;
  if (code == Bytecodes::_wide) {
    code = Bytecodes::java_code_at(_method, code_base + bci + 1);
    pos += 1;
  }

  switch (code) {
    case Bytecodes::_iaload:
      os->print("Cannot load from int array"); break;
    case Bytecodes::_faload:
      os->print("Cannot load from float array"); break;
    case Bytecodes::_aaload:
      os->print("Cannot load from object array"); break;
    case Bytecodes::_baload:
      os->print("Cannot load from byte/boolean array"); break;
    case Bytecodes::_caload:
      os->print("Cannot load from char array"); break;
    case Bytecodes::_saload:
      os->print("Cannot load from short array"); break;
    case Bytecodes::_laload:
      os->print("Cannot load from long array"); break;
    case Bytecodes::_daload:
      os->print("Cannot load from double array"); break;

    case Bytecodes::_iastore:
      os->print("Cannot store to int array"); break;
    case Bytecodes::_fastore:
      os->print("Cannot store to float array"); break;
    case Bytecodes::_aastore:
      os->print("Cannot store to object array"); break;
    case Bytecodes::_bastore:
      os->print("Cannot store to byte/boolean array"); break;
    case Bytecodes::_castore:
      os->print("Cannot store to char array"); break;
    case Bytecodes::_sastore:
      os->print("Cannot store to short array"); break;
    case Bytecodes::_lastore:
      os->print("Cannot store to long array"); break;
    case Bytecodes::_dastore:
      os->print("Cannot store to double array"); break;

    case Bytecodes::_arraylength:
      os->print("Cannot read the array length"); break;
    case Bytecodes::_athrow:
      os->print("Cannot throw exception"); break;
    case Bytecodes::_monitorenter:
      os->print("Cannot enter synchronized block"); break;
    case Bytecodes::_monitorexit:
      os->print("Cannot exit synchronized block"); break;
    case Bytecodes::_getfield: {
        int cp_index = Bytes::get_native_u2(code_base + pos) DEBUG_ONLY(+ ConstantPool::CPCACHE_INDEX_TAG);
        ConstantPool* cp = _method->constants();
        int name_and_type_index = cp->name_and_type_ref_index_at(cp_index);
        int name_index = cp->name_ref_index_at(name_and_type_index);
        Symbol* name = cp->symbol_at(name_index);
        os->print("Cannot read field \"%s\"", name->as_C_string());
      } break;
    case Bytecodes::_putfield: {
        int cp_index = Bytes::get_native_u2(code_base + pos) DEBUG_ONLY(+ ConstantPool::CPCACHE_INDEX_TAG);
        os->print("Cannot assign field \"%s\"", get_field_name(_method, cp_index));
      } break;
    case Bytecodes::_invokevirtual:
    case Bytecodes::_invokespecial:
    case Bytecodes::_invokeinterface: {
        int cp_index = Bytes::get_native_u2(code_base+ pos) DEBUG_ONLY(+ ConstantPool::CPCACHE_INDEX_TAG);
        os->print("Cannot invoke \"");
        print_method_name(os, _method, cp_index);
        os->print("\"");
      } break;

    default:
      assert(0, "We should have checked this bytecode in get_NPE_null_slot().");
      break;
  }
}

可以看到,这一步就是针对不同的操作码,输入不同的信息。比如例子中,情况2走的就是_getfield,情况7走的就是_invokevirtual分支,当然还有一些打印方法名之类的操作,这儿就不多说了。

接下来看第二部分, print_NPE_cause ,失败的原因是怎么拼出来的:

bool ExceptionMessageBuilder::print_NPE_cause(outputStream* os, int bci, int slot) {
  if (print_NPE_cause0(os, bci, slot, _max_cause_detail, false, " because \"")) {
    os->print("\" is null");
    return true;
  }
  return false;
}

print_NPE_cause调用print_NPE_cause0输出原因,并在前面添加because ",在其后添加" is null

对于这个功能来说, print_NPE_cause0 的实现比较重要。

print_NPE_cause0 方法定义如下:

os接收字符串;bci表示操作数栈的的偏移;slot表示操作目标在第几个slot上;max_detail表示目前还能再追溯几级,inner_expr表示是否在追溯表达式,如果是的话,省略默写字符串;prefix表示前缀。

print_NPE_cause0 的内部是一个大的switch,根据不同的字节码做不同分析行为。我们分类来看下:

1. 对于 _aload、_iload、_aload<n>、_iload<n>、const相关指令来说,只需要输出立即数即可:

2. 对于 _iaload、_aaload来说,我们需要打印数组名和下标

首先递归调用,打印数组来源(数组名或者方法名),然后再递归调用获得数组的index。

3.对于getstatic,直接输出类名和字段名即可

4.对于getfield,通过递归调用来逐级输出getfield的”链条”,比如例子中的情况4,就能输出class22.class1.object is null

5.对于invoke系列指令,只需要输出方法名就行。

还有一个比较重要的问题是,如何获取局部变量的名字?刚刚有看到调用 print_local_var 的地方,就是它了:

可以看到如果有LocalVariableTable的话,就遍历它,找到变量名字。如果没有的话,走下面的逻辑:

如果没有的话,对于参数,只能输出<paramenter%d>;对于局部变量,输出 <local%d> 。

比如,对于如下情况:

Integer[] ary2 = new Integer[1];
printNPE(() -> {
    int i = 0;
    // 7. iconst_0+iload_1后为null
    System.out.println(ary2[i].intValue());
});

NPE的输出就变为了Cannot invoke "java.lang.Integer.intValue()" because "<parameter1>[<local1>]" is null

在这儿需要解释下,local1就是i,paramenter1是ary2,因为在Java中,Lambda的变量捕获是使用隐式传参的方式实现的。

总结

总而言之,这个特性对于使用者来说是非常好的;但这儿的“回溯”很多逻辑都是Java代码相关的,而JVM作为一个通用的平台,上面也许会有很多其他的语言来跑,它们不仅不能享受到此特性带来的好处,反而可能会有更加混乱的NPE message。

所以希望这个特性能够标准化,并写入到JVMS中,这样其他基于JVM的语言才能更好的利用这个特性。


javac在编译的时候,添加上-parameters就能在class文件中存储参数的名字,加上-g:vars参数就能带上本地变量表(LocalVariableTable),更加方便调试。

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.