Skip to content

Java NullPointerException: Cannot invoke ... because ... is null

Decode the helpful NullPointerException message — Cannot invoke "String.length()" because "s" is null — find the exact null reference, and fix it for good.

Java java-errors nullpointerexception, core-java jvm java-14 java-17 java-21
Gopi Gorantala
Reading Progress

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 null

This 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.NullPointerException and 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 1

Environment-specific triggers

  • <local2> instead of a name: your build is not passing -g. Maven's maven-compiler-plugin defaults debug to true, but Gradle's options.debug can be flipped off in release profiles, and ProGuard/shading strips LocalVariableTable.
  • 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 is boolean/int rather than Boolean/Integer, so they only show up after someone changes a field type or a JSON mapping.

3. Version Behaviour Matrix

JDKHelpful NPE messageDefaultNotes
8Nojava.lang.NullPointerException with getMessage() == null. Debug by line number only.
11NoNot backported upstream. An 8 → 11 migration does not buy you better messages.
14YesOffJEP 358. Enable with -XX:+ShowCodeDetailsInExceptionMessages.
15YesOnJDK-8233014 flipped the default.
17YesOnUnchanged. Records (JEP 395) make null-in-value-object bugs easier to localise.
21YesOnPattern matching for switch (JEP 441): a switch over a reference throws NPE for a null selector unless you write case null.
25YesOnMessage 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 LocalVariableTable attribute, which javac only 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 present

This 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:

  • Optional is a return type. Not a field, not a parameter, not a collection element. Optional fields serialize badly and add a second null to check.
  • Never return null from a method that returns a collection. Return List.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.of throw NPE on null arguments, which surprises people migrating from Arrays.asList.
  • case null in pattern switches (Java 21+). A switch over a sealed hierarchy throws NPE for a null selector unless you write case null -> explicitly. Make that decision deliberately rather than discovering it in prod.
  • Annotate nullability and enforce it. JSpecify @Nullable/@NonNull plus 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.yourorg and treat it as an error only for new code.
  • SpotBugs NP_* detectors catch Map.get unboxing, redundant null checks, and null returns from methods annotated @NonNull.
  • -Xlint:all -Werror in maven-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 a requireNonNull.

In production:

  • Ship with -XX:-OmitStackTraceInFastThrow on 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 debuglevel includes vars in the release build — grep a shipped jar with javap -l -p -c YourClass.class | grep LocalVariableTable. No table means every future NPE says <local2>.
  • JFR: jcmd <pid> JFR.start name=npe settings=profile — the jdk.JavaExceptionThrow event 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 null appearing 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, and Collectors.toMap has changed across releases — you want that failing in CI, not during the upgrade window.

9. Key Takeaways

  • because "x" is null names the null; the stack frame names the crash site. They are frequently different objects — read the because clause 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.get into an int is an unboxing NPE, and the message will name Integer.intValue() — a method you never wrote. getOrDefault or computeIfAbsent removes the whole class of bug.
  • Push null checks to the boundary with Objects.requireNonNull in constructors and compact record constructors; use Optional only as a return type.
Javajava-errorsnullpointerexception,core-javajvmjava-14java-17java-21

I'm Gopi — 15+ years in Java, building Kafka and Flink platforms for banks, where one lost event is a financial discrepancy. I write javahandbook.com because the guides I needed didn't exist. Everything here is tested against a real cluster first.

Comments