On This Page
1. The Error
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "s" is null
at Npe.main(Npe.java:14)The same JVM produces several message shapes depending on which bytecode dereferenced the null:
Cannot read field "next" because "this.head" is null
Cannot read field "value" because "Field.sHead" is null
Cannot read the array length because "a" is null
Cannot load from object array because "a[0]" is null
Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null
Cannot invoke "Npe$Address.city()" because the return value of "Npe$Person.address()" is nullThis is the helpful NullPointerException message from JEP 358. The wording is version-dependent:
- JDK 8 and 11: no message at all — just
Exception in thread "main" java.lang.NullPointerExceptionand a stack trace. - JDK 14: message exists but is off by default; you must run with
-XX:+ShowCodeDetailsInExceptionMessages. - JDK 15 and later: on by default (JDK-8233014).
If your class files were compiled without local variable debug info (javac -g:none, or many shaded/obfuscated jars), the variable name degrades to a slot number:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local2>" is null
at Npe.main(Unknown Source)<local2> is the local variable slot index in the frame, not the second variable you declared — this occupies slot 0 in an instance method, and long/double take two slots.
2. How to Reproduce It (step-by-step)
Single self-contained file, runs with the source launcher on Java 11+ (java Npe.java), or compile it:
// Npe.java
import java.util.*;
public class Npe {
record Address(String city) {}
record Person(Address address) {}
static String cityUpper(Person p) { return p.address().city().toUpperCase(); }
static Map<String, Integer> counts = new HashMap<>();
public static void main(String[] args) {
int which = Integer.parseInt(args[0]);
switch (which) {
case 1 -> { String s = null; System.out.println(s.length()); }
case 2 -> { int n = counts.get("missing"); System.out.println(n); } // unboxing NPE
case 3 -> { Person p = new Person(null); System.out.println(cityUpper(p)); }
case 4 -> { int[] a = null; System.out.println(a.length); }
case 5 -> { String[][] a = new String[1][]; System.out.println(a[0][0]); }
case 6 -> { Map<String, List<String>> m = new HashMap<>(); m.get("k").add("v"); }
case 7 -> { Boolean b = null; if (b) System.out.println("x"); } // Boolean unboxing
case 8 -> { Integer i = null; int x = true ? i : 0; System.out.println(x); } // ternary
default -> throw new IllegalArgumentException();
}
}
}javac -g Npe.java # -g keeps local variable names in the class file
java Npe 2 # unboxing NPE
java Npe 3 # chained call NPE
# JDK 14 only — the message is off unless you ask for it
java -XX:+ShowCodeDetailsInExceptionMessages Npe 1
# Turn it off on 15+ to see what your JDK 8 logs looked like
java -XX:-ShowCodeDetailsInExceptionMessages Npe 1
# Exception in thread "main" java.lang.NullPointerException
# at Npe.main(Npe.java:14)
# Strip debug info and watch the variable name become a slot number
javac -g:none -d nog Npe.java && java -cp nog Npe 1Environment-specific triggers
<local2>instead of a name: your build is not passing-g. Maven'smaven-compiler-plugindefaultsdebugtotrue, but Gradle'soptions.debugcan be flipped off in release profiles, and ProGuard/shading stripsLocalVariableTable.- No message and no stack trace, only in production: that is the JIT, not the JVM losing information. See section 5.
case 6/case 2: only fires when the key is genuinely absent — a bug that hides in dev where the cache is always warm.case 7/case 8: autoboxing NPEs disappear entirely if the type isboolean/intrather thanBoolean/Integer, so they only show up after someone changes a field type or a JSON mapping.
3. Version Behaviour Matrix
| JDK | Helpful NPE message | Default | Notes |
|---|---|---|---|
| 8 | No | — | java.lang.NullPointerException with getMessage() == null. Debug by line number only. |
| 11 | No | — | Not backported upstream. An 8 → 11 migration does not buy you better messages. |
| 14 | Yes | Off | JEP 358. Enable with -XX:+ShowCodeDetailsInExceptionMessages. |
| 15 | Yes | On | JDK-8233014 flipped the default. |
| 17 | Yes | On | Unchanged. Records (JEP 395) make null-in-value-object bugs easier to localise. |
| 21 | Yes | On | Pattern matching for switch (JEP 441): a switch over a reference throws NPE for a null selector unless you write case null. |
| 25 | Yes | On | Message format unchanged since 15. |
The message text itself has been stable since 15 — you can grep logs for because " across 15/17/21/25 without version-specific patterns.
4. Why It Happens — Surface Level
A NullPointerException is thrown when the JVM executes a bytecode that requires an object reference and finds null on the stack: invokevirtual / invokeinterface (method call), getfield / putfield (instance field), arraylength, aaload / iaload (array element), athrow, monitorenter, and the implicit xxxValue() call the compiler inserts for unboxing.
The reason Map.get() is such a frequent source is that Map<String,Integer>.get(k) returns Integer, and assigning it to an int compiles to Integer.intValue(). There is no null check in your source, so there is nothing to read in your source either — the failing call is synthetic. That is precisely what the helpful message tells you: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null.
5. Why It Happens — Under the Hood
How the message is built. The message is not stored when the exception is constructed. HotSpot records the bytecode index (BCI) of the faulting instruction in the NullPointerException object and computes the text lazily, the first time Throwable::getMessage is called. At that point the VM re-reads the method's bytecode, finds the instruction at that BCI, determines which operand was null, and then walks backwards through the instruction stream to reconstruct a human-readable expression for where that null came from — a local variable slot, a getfield, a getstatic, an aaload, or the return value of an earlier invoke.
Two consequences fall straight out of that design:
- Names come from the class file, not the VM. Local variable names live in the optional
LocalVariableTableattribute, whichjavaconly emits with-g(or-g:vars). Without it the VM can only report the slot:<local2>. Field and method names always work — they are in the constant pool regardless. - The message does not survive serialization. JEP 358 is explicit: an NPE serialized and sent over RMI arrives without the BCI context, so the receiver cannot recompute the message. Same for any framework that reconstructs exceptions across a wire.
Backward walking is also why the message is structural rather than valuational: it prints a[0] and Person.address(), never a[3] with the real index or the actual object identity — the runtime values are long gone by the time getMessage() runs.
The disappearing exception. In a hot method the JIT eventually stops allocating implicit exceptions. C2 compiles the null check as an unconditional load guarded by a signal handler; after a method has thrown an implicit exception often enough, HotSpot recompiles it to throw a pre-allocated, shared exception instance with no stack trace and no message. This is -XX:+OmitStackTraceInFastThrow, on by default. It is directly observable:
// FastThrow.java
public class FastThrow {
static String s = null;
static int len() {
try { return s.length(); }
catch (NullPointerException e) { return e.getMessage() == null ? -1 : -2; }
}
public static void main(String[] a) {
for (int i = 0; i < 200_000; i++) {
if (len() == -1) { System.out.println("first message-less NPE at iteration " + i); return; }
}
System.out.println("message always present");
}
}$ java FastThrow
first message-less NPE at iteration 5507
$ java -XX:-OmitStackTraceInFastThrow FastThrow
message always presentThis is the single most common reason a production log shows java.lang.NullPointerException with no message and no frames while the same code in dev prints a perfect stack trace. The first few thousand occurrences are fully detailed; after the JIT kicks in you get nothing. If your logs show a dense burst of naked NPEs, add -XX:-OmitStackTraceInFastThrow to that service and redeploy — you pay a small allocation cost on the exception path only.
Related mechanics worth linking: autoboxing NPEs ↔ the Integer cache (Integer.valueOf returns cached instances for −128..127, which is why == comparisons on boxed values behave inconsistently); Map.get returning null ↔ Collectors.toMap rejecting null values; NPE inside a lambda ↔ deep stream frames — a null in a map() gives you the helpful message on the lambda frame, but the rest of the trace is ReferencePipeline plumbing:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.indexOf(int)" because the return value of "Stream1$User.email()" is null
at Stream1.lambda$main$0(Stream1.java:6)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
...6. The Fix
Fix A — the immediate one: check where the null enters, not where it explodes
- int hits = counts.get(userId); // NPE: Integer.intValue() on null
+ int hits = counts.getOrDefault(userId, 0);- List<String> tags = index.get(key);
- tags.add(newTag); // NPE: Map.get returned null
+ index.computeIfAbsent(key, k -> new ArrayList<>()).add(newTag);- String city = person.address().city().toUpperCase();
+ String city = Optional.ofNullable(person.address())
+ .map(Address::city)
+ .map(String::toUpperCase)
+ .orElse("UNKNOWN");Fix B — fail at the boundary with a message you wrote
public Order(String id, Customer customer) {
- this.id = id;
- this.customer = customer;
+ this.id = Objects.requireNonNull(id, "id must not be null");
+ this.customer = Objects.requireNonNull(customer, "customer must not be null");
}Exception in thread "main" java.lang.NullPointerException: email must not be null
at java.base/java.util.Objects.requireNonNull(Objects.java:259)
at Rn.main(Rn.java:2)An explicit requireNonNull in a constructor or compact record constructor turns "NPE somewhere three layers down, twenty minutes later" into "NPE at the exact line that accepted bad input". This is worth doing even though the helpful message exists — the helpful message tells you what was null, requireNonNull tells you who allowed it.
Fix C — make the message readable in production
# keep local variable names (Maven does this by default; verify Gradle)
javac -g ...
# stop the JIT from swallowing repeated NPEs
java -XX:-OmitStackTraceInFastThrow -jar app.jar<!-- pom.xml — explicit is better than assumed -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>21</release>
<debug>true</debug>
<debuglevel>lines,vars,source</debuglevel>
</configuration>
</plugin>Which fix when: getOrDefault/computeIfAbsent when absence is legal; requireNonNull when absence is a bug and you want it loud at the boundary; Optional chaining only when the value is genuinely optional and you are returning it — not as a field type and not as a parameter type.
7. Best Practices & The Better Design
The durable fix is to stop nullable references from crossing module boundaries at all.
// The right way: nulls are rejected at construction, absence is modelled explicitly.
import java.util.*;
public record Customer(String id, String email, Optional<String> phone) {
// compact constructor: validation happens before any field is assigned
public Customer {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(email, "email");
Objects.requireNonNull(phone, "phone"); // the Optional itself is never null
if (id.isBlank()) throw new IllegalArgumentException("id must not be blank");
}
public static Customer of(String id, String email, String phoneOrNull) {
return new Customer(id, email, Optional.ofNullable(phoneOrNull));
}
public String phoneOrDefault() {
return phone.orElse("n/a");
}
public static void main(String[] args) {
Customer c = Customer.of("c-1", "g@example.io", null);
System.out.println(c.phoneOrDefault()); // n/a
System.out.println(Customer.of("c-2", null, "+32")); // NPE: "email"
}
}Rules that hold up under production load:
Optionalis a return type. Not a field, not a parameter, not a collection element.Optionalfields serialize badly and add a second null to check.- Never return
nullfrom a method that returns a collection. ReturnList.of(). Empty is not absent. - Records for value types. The compact constructor is the one place every construction path goes through.
- Immutable collections at API edges (
List.copyOf,Map.copyOf) — these also reject null elements up front, turning a late NPE into an early one. Note the trade-off:Map.of/List.ofthrow NPE on null arguments, which surprises people migrating fromArrays.asList. case nullin pattern switches (Java 21+). Aswitchover a sealed hierarchy throws NPE for a null selector unless you writecase null ->explicitly. Make that decision deliberately rather than discovering it in prod.- Annotate nullability and enforce it. JSpecify
@Nullable/@NonNullplus NullAway turns "possible NPE" into a compile error.
8. How to Prevent It Long-Term
In CI:
- NullAway (Error Prone plugin) with JSpecify annotations — fails the build on a dereference the compiler can prove may be null. Start with
-XepOpt:NullAway:AnnotatedPackages=com.yourorgand treat it as an error only for new code. - SpotBugs
NP_*detectors catchMap.getunboxing, redundant null checks, and null returns from methods annotated@NonNull. -Xlint:all -Werrorinmaven-compiler-plugin— it will not find NPEs, but it will find the raw types and unchecked conversions that hide them.- Assert on messages in tests:
assertThatThrownBy(...).hasMessageContaining("customer")breaks if someone removes arequireNonNull.
In production:
- Ship with
-XX:-OmitStackTraceInFastThrowon any service where you have ever seen a message-less NPE. The cost is bounded by your exception rate; the alternative is unfixable log noise. - Verify
debuglevelincludesvarsin the release build — grep a shipped jar withjavap -l -p -c YourClass.class | grep LocalVariableTable. No table means every future NPE says<local2>. - JFR:
jcmd <pid> JFR.start name=npe settings=profile— thejdk.JavaExceptionThrowevent records exception type, message and stack trace with sampling, which catches NPEs that are being caught and swallowed somewhere in a framework. - Alert on exception rate by message, not by type. A new
because "this.config" is nullappearing after a deploy is a config regression; the aggregate NPE count would not have moved. - Add a canary CI job running the suite on the next LTS. Behaviour around null in
switch,Map.of, andCollectors.toMaphas changed across releases — you want that failing in CI, not during the upgrade window.
9. Key Takeaways
because "x" is nullnames the null; the stack frame names the crash site. They are frequently different objects — read thebecauseclause first.<local2>means your build stripped debug info, not that the JVM failed. Fix-g/debuglevel, not your code.- A message-less, frame-less NPE in production is
OmitStackTraceInFastThrow, the JIT reusing a pre-allocated exception. Disable it on that service and the detail comes back. Map.getinto anintis an unboxing NPE, and the message will nameInteger.intValue()— a method you never wrote.getOrDefaultorcomputeIfAbsentremoves the whole class of bug.- Push null checks to the boundary with
Objects.requireNonNullin constructors and compact record constructors; useOptionalonly as a return type.
Gopi Gorantala Newsletter
Join the newsletter to receive the latest updates in your inbox.