One line of code, two JDKs. On Java 8 it produced Exception in thread "main" java.lang.NullPointerException and a line number, and that was the entire report. On Java 21 the same line produces Cannot invoke "java.lang.String.strip()" because the return value of "java.util.Map.get(Object)" is null. The first version told me a line had a null in it somewhere. The second told me the map lookup missed.
That difference is JEP 358, "Helpful NullPointerExceptions". It landed in JDK 14 switched off, and has been on by default since JDK 15, so on Java 21 you already have it whether you asked for it or not. Most of the value is locked behind knowing how to read the sentence, and the sentence has a grammar that nobody ever explains.
So this post is mostly about that grammar. Three realistic ways to get a null in production, the exact message each one throws, and how each clause maps back to a character position in the source.
Table of Contents
- What the Error Actually Means
- Why It Happens: What the JVM Is Doing
- The Grammar of the Message
- Cause 1: A Map Lookup That Missed
- Cause 2: Unboxing a Null Integer
- Cause 3: A Field That Wasn't Injected Yet
- The General Fix
- Where the Message Still Won't Save You
- How to Prevent It
- Frequently Asked Questions
What the Error Actually Means
A NullPointerException means your program tried to do something to an object and there was no object there. Not "a variable was null" — nulls sit in variables quite happily forever. The exception fires at the moment you use one: calling a method on it, reading or writing a field, indexing into it as an array, asking for its length, unboxing it, throwing it, or synchronizing on it.
It's unchecked, which means the compiler never asks you about it, and it's an exception rather than an error, so it's catchable — though catching one is almost always a mistake, because the thing you'd be catching is a bug in your own code rather than a condition in the world. That distinction between a bug and a condition is the line that separates unchecked exceptions from checked ones, and it's worth keeping straight.
What Java 21 adds is not a new failure mode. It's a description of the failure you already had.
Why It Happens: What the JVM Is Doing
Every one of those operations compiles down to a bytecode instruction that expects a reference on the operand stack: invokevirtual, getfield, arraylength, aaload, athrow, monitorenter. Each one checks for null before it does anything else, and if the reference is null it throws instead. That check is why null dereferences fail immediately and predictably rather than corrupting memory.
Here's the part that makes the helpful message possible. When the JVM throws, it knows exactly which instruction failed — it knows the method, and it knows the bytecode index inside that method. That's enough to walk backwards through the bytecode and reconstruct the expression that pushed the null reference onto the stack in the first place. Was it a local variable load? A field read? The return of a call? The JVM can answer that from the bytecode alone, without any source file.
It does not do this work at throw time. The message is computed lazily, the first time somebody calls getMessage(). So an exception you catch and swallow costs you nothing extra, and the analysis only runs when a stack trace gets printed or a logger formats the throwable.
One consequence follows directly from all of this: the JVM can only describe NPEs it threw itself. If your code runs throw new NullPointerException(), or calls Objects.requireNonNull(x), that exception was constructed by ordinary Java code. There is no failing bytecode to analyse, so you get whatever message the constructor was handed — frequently none at all.
The Grammar of the Message
Every helpful message is two clauses joined by "because". The first says what the JVM was trying to do. The second says which expression handed it the null.
Cannot <the operation that failed> because "<the expression that was null>" is null
The first clause is drawn from a small fixed set, and knowing the set means you can tell what kind of operation blew up before you even look at the code:
Cannot invoke "com.acme.Foo.bar()"— a method call on a null receiverCannot read field "count"/Cannot assign field "count"— a field accessCannot read the array length—arr.lengthon a null arrayCannot load from object array/Cannot store to int array— indexing a null array, with the element type namedCannot throw exception—throwon a null referenceCannot enter synchronized block—synchronizedon a null monitor
The second clause is the one that actually finds your bug, and it also comes in fixed shapes. "this.config" is an instance field. "com.acme.Registry.INSTANCE" is a static field. "a.b.c" is a chain of field reads, and the message names the deepest one that was non-null, so you know precisely where the chain broke. "orders[3]" is an array element. And the return value of "java.util.Map.get(Object)" means a method handed you a null — that's the shape that saves the most time, because it's the one a line number can never disambiguate.
Then there's "<local4>", which is the shape everyone asks about. It means a local variable in slot 4, and you're seeing a slot number instead of a name because the class was compiled without local variable debug information. Names live in the LocalVariableTable attribute, which javac writes only when you pass -g or -g:vars, and plenty of release builds strip it to shave bytes. Fields, static fields, array accesses and method returns are named from the constant pool, which is never stripped, so those parts stay readable regardless. If your messages look worse than the ones in this post, that's why.
Class and method names are printed fully qualified, which makes the line long but removes any doubt about which Order or which get the JVM meant.
Cause 1: A Map Lookup That Missed
Configuration lookups are where I hit this most. A map that's populated from a properties file, a chained call because the value is always there, and then one environment where it isn't.
package com.acme.billing;
import java.util.Map;
public final class RegionRouter {
private final Map<String, String> endpoints;
public RegionRouter(Map<String, String> endpoints) {
this.endpoints = endpoints;
}
public String endpointFor(String region) {
return endpoints.get(region).strip();
}
public static void main(String[] args) {
var router = new RegionRouter(Map.of("eu-west-1", " billing-eu.internal "));
System.out.println(router.endpointFor("eu-west-2"));
}
}
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.String.strip()" because the return value of "java.util.Map.get(Object)" is null
at com.acme.billing.RegionRouter.endpointFor(RegionRouter.java:14)
at com.acme.billing.RegionRouter.main(RegionRouter.java:19)
Read it clause by clause. Cannot invoke java.lang.String.strip() tells you the failing operation is the .strip() call — so the JVM got as far as the second call in the chain. because the return value of java.util.Map.get(Object) is null tells you what fed it: not the map, the map's answer. The map itself is fine. Line 14 has two dereferences on it and the message points at exactly one of them.
Notice what you'd have had on Java 8: a bare NPE at line 14, and no way to tell whether endpoints was null or the lookup missed. Those two bugs have completely different causes and completely different fixes.
The fix is to stop treating a missing key as impossible:
public String endpointFor(String region) {
String endpoint = endpoints.get(region);
if (endpoint == null) {
throw new IllegalArgumentException("No endpoint configured for region: " + region);
}
return endpoint.strip();
}
getOrDefault is the shorter option and it's the right one when a default is genuinely correct — a display name, a retry count, a feature flag that's off unless configured. For routing config it's the wrong tool, because quietly routing to a fallback endpoint turns a loud startup failure into a silent data problem three days later. Pick based on whether the missing value has a sane default, not on which line is shorter.
Cause 2: Unboxing a Null Integer
This is the nastiest of the three, because the method that throws doesn't appear anywhere in your source code. A record built from a JSON payload, a nullable column, arithmetic:
package com.acme.billing;
public record InvoiceLine(String sku, Integer quantity, Long unitPriceMinor) {
public long totalMinor() {
return unitPriceMinor() * quantity();
}
public static void main(String[] args) {
// quantity arrives as JSON null on metered lines
var line = new InvoiceLine("SEAT-STD", null, 1299L);
System.out.println(line.totalMinor());
}
}
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "com.acme.billing.InvoiceLine.quantity()" is null
at com.acme.billing.InvoiceLine.totalMinor(InvoiceLine.java:6)
at com.acme.billing.InvoiceLine.main(InvoiceLine.java:12)
You never wrote intValue(). That's the signal. When the failing operation is intValue(), longValue(), booleanValue() or doubleValue() and you can't find that call in your source, you are looking at an automatic unboxing conversion the compiler inserted, and the fix is upstream of the arithmetic.
The second clause names quantity() specifically, not unitPriceMinor(), and that's not luck. Both operands get unboxed, but the compiler emits each unbox immediately after its operand is loaded, so the left one fails first if it's the null one. Had the price been null instead you'd have seen Cannot invoke "java.lang.Long.longValue()". The message names the operand, which on a line with two of them is the whole answer.
The fix is to stop letting a nullable box reach arithmetic at all:
public record InvoiceLine(String sku, int quantity, long unitPriceMinor) {
public InvoiceLine {
Objects.requireNonNull(sku, "sku");
}
public long totalMinor() {
return unitPriceMinor * quantity;
}
}
Primitives where null has no meaning. A quantity of null isn't a quantity, it's a parsing failure, and it should be rejected by whatever deserialized the payload rather than surviving to the multiplication. If null genuinely means something in your domain — "not yet metered" is different from zero — then keep the box, but branch on it explicitly at the boundary instead of letting arithmetic decide.
Cause 3: A Field That Wasn't Injected Yet
Field injection plus a constructor that does real work. The framework can't inject a field into an object that doesn't exist yet, so the constructor runs first, against a field that is still null.
package com.acme.billing;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class PricingService {
@Autowired
private TaxTable taxTable;
private final Map<String, Long> warmRates = new HashMap<>();
public PricingService() {
warmRates.put("default", taxTable.rateFor("default"));
}
}
Caused by: java.lang.NullPointerException: Cannot invoke "com.acme.billing.TaxTable.rateFor(String)" because "this.taxTable" is null
at com.acme.billing.PricingService.<init>(PricingService.java:18)
because "this.taxTable" is null — an instance field, named, on this. No <local> placeholder, because field names survive stripping. And the frame is <init>, the constructor, which is the tell: the object is still being built. Put those two facts together and the diagnosis writes itself. A dependency that will be injected has not been injected yet.
The same shape shows up with deserialization. Jackson creates the object, then fills fields, so anything that runs during construction sees nulls. Records don't have this problem, because every component is set before the constructor returns — which is a decent argument for records as your wire types.
The fix is constructor injection, which makes the problem impossible rather than merely unlikely:
@Service
public class PricingService {
private final TaxTable taxTable;
private final Map<String, Long> warmRates = new HashMap<>();
public PricingService(TaxTable taxTable) {
this.taxTable = Objects.requireNonNull(taxTable, "taxTable");
warmRates.put("default", taxTable.rateFor("default"));
}
}
The field can be final now, which means the compiler enforces that it's assigned exactly once before the constructor returns. That's a stronger guarantee than any annotation.
The General Fix
Three causes, one shape: a value that was allowed to be null travelled some distance from where it was created to where it was used, and the code at the far end assumed it couldn't be. The distance is the bug. Everything worth doing shortens it.
If I get one move, it's failing at construction. Make the object impossible to build in a broken state — final fields, constructor injection, Objects.requireNonNull on the arguments, validation in a record's compact constructor. Then a null shows up in a stack trace that points at the code that produced it, which is worth more than any message pointing at the code that consumed it.
Objects.requireNonNull belongs at boundaries: public constructors, public API entry points, anything crossing from framework code into yours. Always pass the second argument. requireNonNull(taxTable) throws an NPE with no message and gains you nothing over what the JVM would have said; requireNonNull(taxTable, "taxTable") names the thing. Sprinkling it through private methods, where you already control every caller, is noise.
Optional is the one I think is overused. As a return type it's excellent — it makes "there might be no answer" part of the signature, and it's exactly right for a lookup. As a field type or a parameter type it's a mistake: it costs an allocation, it isn't serializable, it gives you a variable that can be null and empty, and a method taking Optional<Customer> is a method with two overloads pretending to be one. Return it, unwrap it at the call site, and don't store it.
Where the Message Still Won't Save You
Stripped debug info is the big one, and it's a build setting rather than a JVM setting. Locals become <local1>, and in a method with a long chain of intermediate values that's close to useless.
Lambdas and streams are the second. The failing frame is a synthetic method like lambda$process$3, and the intermediate values inside a stream pipeline are locals, so a null element halfway through a map gives you a slot number in a method you didn't name. The message tells you which operation failed, not which element.
Reflective calls give you a message about the code inside the invoked method, which is correct but often several frames away from the code that actually made the mistake. And framework boundaries are worse: an NPE thrown inside a proxied method may get caught, wrapped and rethrown, and if the wrapper builds a new exception with its own text then the helpful message is only visible on the Caused by line — if it survives at all.
Then there's the failure mode that catches people in production and has nothing to do with JEP 358. After the same NPE site throws often enough, the JIT compiler stops building exception objects there and throws a preallocated one instead — no stack trace, no message, just java.lang.NullPointerException on its own in the log, exactly like Java 8. If you're staring at a bare NPE on Java 21 and everything in this post says you shouldn't be, that's the reason. Restart with -XX:-OmitStackTraceInFastThrow and the detail comes back.
How to Prevent It
Keep -g, or at minimum -g:vars, in any build you might have to debug. The size cost is trivial next to an hour spent guessing which of five locals was <local3>. If you strip production builds deliberately, at least keep the debug info in the artifact you can reproduce with.
Static analysis catches what testing doesn't. Error Prone with the NullAway plugin runs as a compiler plugin and fails the build on a dereference it can't prove safe, which turns most of this class of bug into a compile error. It needs annotations to work from, and JSpecify is the one to standardise on now — it exists specifically because @Nullable came in a dozen incompatible flavours. Annotate the package as null-marked by default, mark the genuine exceptions, and let the tool do the reasoning.
And write the null path as a test. Not a test that a null throws NPE — a test that a missing config key produces the error you intended, that a JSON payload without quantity is rejected at the boundary with a message someone can act on. The reason bugs like cause 1 reach production is that the happy path was the only path anyone exercised.
Author's note: The message shapes described here are from JEP 358 as implemented in current HotSpot builds, and the code above is written to produce exactly the messages shown. Exact formatting of a class name or an argument list can vary slightly between JDK releases and between HotSpot and other VMs, so treat the clause structure as the thing to learn rather than the punctuation.
Frequently Asked Questions
Why does my NullPointerException say <local1> instead of a variable name?
Because the class was compiled without local variable debug information. Names live in the LocalVariableTable attribute, which javac only writes when you compile with -g or -g:vars, and many release builds strip it. Without that table the JVM falls back to the slot number and prints a placeholder. Fields, static fields, array accesses and method return values are named from the constant pool instead, so those parts of the message stay readable even in a stripped build.
How do I turn helpful NullPointerException messages off?
Run the JVM with -XX:-ShowCodeDetailsInExceptionMessages. The feature shipped in JDK 14 switched off, and has been on by default since JDK 15, so on Java 21 you get it without asking. The usual reason to disable it is that the message quotes source-level expressions such as field and method names, which then land in logs and support tickets, and some teams would rather not publish that.
Do helpful NullPointerException messages slow the JVM down?
Not measurably, because the message is computed lazily. Throwing costs what it always cost. The bytecode analysis that produces the text only runs when something calls getMessage(), which in practice means a stack trace being printed or a logger formatting the throwable. An exception that gets caught and swallowed never pays for a message at all.
Does the helpful message work for a NullPointerException I throw myself?
No. The JVM only describes null dereferences it detected itself. If your code runs new NullPointerException() or calls Objects.requireNonNull, the exception was built by ordinary Java code with whatever message the constructor was given, and the JVM leaves it alone. That's a good reason to always pass a description to requireNonNull rather than relying on the no-message default.
Why does the message name a method I never called, like intValue()?
Because it's an unboxing conversion the compiler inserted for you. intValue(), longValue(), booleanValue() and doubleValue() appearing in a message you can't find in your source all mean the same thing: a boxed wrapper was null where a primitive was needed. The second clause of the message names the expression that produced the box, which is where the fix goes.