Java 14 Helpful NullPointerExceptions Explained

Introduction The NullPointerException is the most common exception in Java. Before Java 14, its stack trace told you only the line where it happened. On a line such as order.customer().address().city(), any of four references could have been null, and you had to guess which - often by adding logging and running the code again. Java 14 (JEP 358) added helpful NullPointerExceptions: the exception's message now says precisely which expression was null, and what the code was trying to do with it. This tutorial explains helpful NullPointerExceptions with examples. We will see the new messages for different kinds of null dereference, how the messages are computed, why local variable names sometimes appear as <local0>, and when the messages are turned on.

(Note - This feature is part of Java 14Click to read tutorial: Overview of New Features in Java 14.) The new messages The program below triggers a null dereference in several different ways, catches each exception, and prints its message -
Different kinds of null dereference
//HelpfulNpeDemo.java
public class HelpfulNpeDemo {
  static class Address { String city; }
  static class Customer { String name; Address address; Address address() { return address; } }

  static void attempt(String label, Runnable action) {
    try {
      action.run();
    } catch (NullPointerException e) {
      System.out.println(label + ":\n   " + e.getMessage());
    }
  }

  static Customer customer = new Customer();   // name and address are null
  static int[] scores;                          // null array

  public static void main(String[] args) {
    attempt("Method call on a null field", () -> customer.name.length());
    attempt("Chained calls", () -> System.out.println(customer.address().city.toUpperCase()));
    attempt("Writing a field of a null object", () -> customer.address.city = "Pune");
    attempt("Array access", () -> System.out.println(scores[0]));
    attempt("Unboxing null", () -> { Integer count = null; int c = count; });
    attempt("Throwing null", () -> { RuntimeException ex = null; throw ex; });
  }
}
 OUTPUT of the above code
Method call on a null field:
   Cannot invoke "String.length()" because "HelpfulNpeDemo.customer.name" is null
Chained calls:
   Cannot read field "city" because the return value of "HelpfulNpeDemo$Customer.address()" is null
Writing a field of a null object:
   Cannot assign field "city" because "HelpfulNpeDemo.customer.address" is null
Array access:
   Cannot load from int array because "HelpfulNpeDemo.scores" is null
Unboxing null:
   Cannot invoke "java.lang.Integer.intValue()" because "<local0>" is null
Throwing null:
   Cannot throw exception because "<local0>" is null
Explanation of the code
  • Each message has two parts: the action which failed ("Cannot invoke String.length()", "Cannot load from int array", "Cannot assign field city") and the reason - the exact expression which was null, such as "HelpfulNpeDemo.customer.name".
  • For the chained call, the message pinpoints the return value of ...address() - the method call in the middle of the chain which returned null. Before Java 14 this line would have produced a bare NullPointerException.
  • Unboxing a null Integer into an int is reported as a failed call to Integer.intValue(), which is what the compiler generates for unboxing.
  • Two of the messages show "<local0>" instead of a variable name. This is explained next.
Why local variables appear as <local0> The JVM computes the message only when the exception is thrown, by analyzing the bytecode of the method which failed. Field and method names are always in the bytecode, but the names of local variables are only kept if the class was compiled with debug information for variables, using javac -g. Without it, the JVM can only refer to the variable by its slot number in the method, such as <local0> for the first one (here each lambda body is compiled into its own method, so the variable is in slot 0). Compiling the same program with -g -
javac -g -d classes-g HelpfulNpeDemo.java
java -cp classes-g HelpfulNpeDemo
 OUTPUT of the above command
Method call on a null field:
   Cannot invoke "String.length()" because "HelpfulNpeDemo.customer.name" is null
Chained calls:
   Cannot read field "city" because the return value of "HelpfulNpeDemo$Customer.address()" is null
Writing a field of a null object:
   Cannot assign field "city" because "HelpfulNpeDemo.customer.address" is null
Array access:
   Cannot load from int array because "HelpfulNpeDemo.scores" is null
Unboxing null:
   Cannot invoke "java.lang.Integer.intValue()" because "count" is null
Throwing null:
   Cannot throw exception because "ex" is null
Now the unboxing and throw messages name the variables count and ex. Build tools such as Maven and Gradle compile with full debug information by default, so in most real projects you will see the names. When are the messages shown?
  • Java 14: the messages were off by default, and enabled with the JVM option -XX:+ShowCodeDetailsInExceptionMessages.
  • Java 15 and later: they are on by default. The option -XX:-ShowCodeDetailsInExceptionMessages turns them off.
  • They apply only to exceptions thrown by the JVM itself. A NullPointerException created in code, such as by Objects.requireNonNull(x, "x must not be null"), keeps its own message.
  • The message is computed lazily, only when getMessage() is called, so there is no cost for exceptions which are caught and never inspected.
Is it safe to show these messages? The messages contain names of fields, methods and local variables, but never the values of variables. The JEP noted that the names could still reveal details of the code to someone reading error output, which was one reason the feature was off by default in Java 14. Since Java 15 the messages are on by default, and the usual rule applies: do not show raw exception messages and stack traces to end users of an application; log them instead. Summary In this tutorial we saw how Java 14's helpful NullPointerExceptions name the exact expression which was null - a field, an array, a method's return value, or a local variable - and the action which failed. We saw that local variable names need classes compiled with javac -g, and otherwise appear as <local0>. Lastly, we saw that the messages are opt-in in Java 14, on by default from Java 15, and only computed when they are needed. Next, read the JFR event streaming tutorialClick to read tutorial: Java 14 JFR Event Streaming Tutorial with Examples.