Create your own
Lesson illustration

Tracing Java Object References and Preventing NullPointerExceptions

Hello again. In the previous lesson, you organized classes into packages and used access modifiers to control which code may depend on which members. Those boundaries make code easier to reason about—but a public method can still fail at runtime if it tries to use an object reference that is null.

This final lesson in the Java foundations module focuses on that failure mode. You will learn to distinguish an object from a variable that refers to it, trace what happens when references are assigned or passed to methods, read a NullPointerException productively, and choose a null-handling strategy that matches the intended contract.


Objects, references, and null

A variable of a primitive type contains its value directly:

int requestLimit = 10;
boolean enabled = true;

Primitive values such as int, double, and boolean cannot be null.

A variable whose type is a class, array, interface, or enum is a reference variable. It does not contain the whole object. It contains a reference value that identifies an object, or it contains null.

Subscription plan = new Subscription("Starter", 3);

Here:

  • plan is a reference variable.
  • new Subscription("Starter", 3) creates a Subscription object.
  • The value stored in plan refers to that object.

null means that a reference variable currently refers to no object. It is not an “empty Subscription,” an object with blank fields, or a placeholder object. There is simply no object available through that reference.

A useful mental model is that reference variables hold directions to objects, rather than the objects themselves. The JVM’s actual memory management is more sophisticated than a hand-drawn map, so do not rely on specific memory addresses. But the distinction between object and reference is essential.

This short visual explanation shows the key contrast between copying primitives and copying references.

Primitive and Reference (Object) Types in Memory (Java Tutorial)

Watch “Primitive and Reference (Object) Types in Memory (Java Tutorial)” by Bill Barnum to visualize why assigning one object variable to another does not duplicate the object.

Watch primitive copying first: changing the copied primitive does not affect the original. Then watch reference aliasing, focusing on the fact that two variables can refer to one mutable object, while reassigning one variable changes only that variable.

Assignment copies the reference, not the object

Use the Subscription class from earlier lessons:

Subscription firstPlan = new Subscription("Starter", 3);
Subscription secondPlan = firstPlan;

secondPlan.tryConsumeRequest();

System.out.println(firstPlan.getRequestsRemaining());

This prints:

2

firstPlan and secondPlan refer to the same Subscription. Calling tryConsumeRequest() changes that one shared object, so the changed state is visible through either variable.

This is often called aliasing: multiple references are aliases for one object.

Now consider reassignment:

secondPlan = null;

System.out.println(firstPlan.getRequestsRemaining());

firstPlan still refers to the Subscription, so this is safe. Only secondPlan lost its reference.

However, this fails:

secondPlan.tryConsumeRequest();

At that point Java must call an instance method on the object referred to by secondPlan. Since there is no such object, Java throws a NullPointerException, often abbreviated NPE.

Fields may begin as null

Java prevents you from using an uninitialized local variable:

Subscription plan;
System.out.println(plan.getPlanName()); // Does not compile

But instance fields are initialized automatically. Reference fields default to null unless your constructor or field initializer gives them a value.

public class Account {
    private Subscription subscription;

    public boolean canMakeRequest() {
        return subscription.tryConsumeRequest();
    }
}

If no code assigns subscription, canMakeRequest() throws an NPE. This is one reason constructors are so important: they establish the valid initial state of an object.


Trace references through assignments and method calls

When debugging, read each reference variable as a separate value. The most common mistake is to assume that two variables of the same type necessarily refer to different objects.

Java is also strictly pass-by-value. When you pass an object variable to a method, Java copies the reference value into the method parameter. It does not copy the object, and Java does not pass the caller’s variable itself into the method.

static void consumeOneRequest(Subscription plan) {
    plan.tryConsumeRequest();
}

Subscription starter = new Subscription("Starter", 3);
consumeOneRequest(starter);

System.out.println(starter.getRequestsRemaining());

The method parameter plan and the caller variable starter hold copied reference values, but both references identify the same Subscription. Therefore, mutating the object inside the method is visible to the caller.

Reassigning the parameter is different:

static void replacePlan(Subscription plan) {
    plan = new Subscription("Enterprise", 1_000);
}

Subscription starter = new Subscription("Starter", 3);
replacePlan(starter);

System.out.println(starter.getPlanName());

This still prints:

Starter

Inside replacePlan, only the local parameter variable was reassigned. The caller’s starter variable was untouched.

Keep this distinction in mind:

OperationWhat changes?
plan.tryConsumeRequest()The shared Subscription object changes
plan = new Subscription(...)Only that particular reference variable changes
plan = nullOnly that particular reference variable loses its object
Passing starter to a methodThe method receives a copied reference to the same object

This behavior is especially relevant in backend code. A service method may receive a reference to a request object, entity, configuration object, or collection. Before deciding whether a null check belongs somewhere, first determine whether the variable should refer to an object at all.


What actually causes a NullPointerException?

An NPE happens when Java needs an object but the reference value is null. Common examples include:

String name = null;
int length = name.length();
Subscription plan = null;
boolean accepted = plan.tryConsumeRequest();
int[] requestCounts = null;
int firstCount = requestCounts[0];

It can also occur through unboxing, when Java tries to turn a wrapper object into a primitive:

Boolean enabled = null;

if (enabled) {
    System.out.println("Enabled");
}

Boolean is an object type and can be null; boolean is a primitive and cannot. Java tries to unbox enabled into a boolean for the if condition, but cannot do so because the reference is null.

When a value cannot meaningfully be absent, prefer a primitive:

private boolean enabled;
private int monthlyRequestLimit;

Use wrapper types such as Boolean and Integer only when a third state—“not provided” or “unknown”—is meaningful.

Read the entire error, not only the failing line

Modern Java usually provides a helpful NPE message. For a chain such as:

String city = order.getCustomer().getAddress().getCity();

several things might be absent:

  • order
  • the Customer returned by order.getCustomer()
  • the Address returned by getAddress()
  • the String returned by getCity()—if you later call city.length()

The source line alone tells you that something in the chain failed. A modern message may identify the specific variable or method result that was null. The stack trace then tells you the class, method, and line where the failure surfaced.

This segment from Coding with John demonstrates how modern Java makes that diagnosis more precise.

Null Pointer Exceptions In Java - What EXACTLY They Are and How to Fix Them

Watch “Null Pointer Exceptions In Java - What EXACTLY They Are and How to Fix Them” by Coding with John for practical NPE diagnosis and prevention patterns.

Start with helpful messages to see why a chained expression needs careful tracing. Continue with fixing the cause, particularly the distinction between initializing a required value and handling a genuinely optional one. Finish with safe equality for a useful pattern when comparing a possibly null string to a fixed literal.

A disciplined NPE debugging process

Avoid immediately adding if (value != null) around the line that failed. That may hide the symptom while leaving a broken state or silently skipping important work.

Instead, use this process:

  1. Read the NPE message and stack trace. Identify the exact expression Java tried to dereference.
  2. Work backward to the source of that reference. Was it a field never initialized, a method allowed to return null, a collection returned by another component, or an external response?
  3. State the intended contract. Should this value always exist, or is absence a valid business outcome?
  4. Fix the correct location. Establish required values near object creation; deliberately handle valid absence at the point where it matters.
  5. Make the next failure easier to understand. Use meaningful variable names and clear validation messages.

For a long expression, temporarily split it into named steps:

Customer customer = order.getCustomer();
Address address = customer.getAddress();
String city = address.getCity();

return city.length();

This does not itself make the code safe. It makes the data flow visible, which helps you identify the failed contract. Once you understand which value may be absent, choose a design rather than accumulating defensive checks.


Choose the right null-safety response

The correct response to null depends on whether absence violates a contract or represents an expected state.

1. Required input: fail fast

Suppose every Subscription must have a plan name. Allowing a null name into the object would create invalid state and postpone the eventual failure.

Use Objects.requireNonNull() in the constructor:

import java.util.Objects;

public class Subscription {
    private final String planName;
    private final int monthlyRequestLimit;
    private int requestsUsed;

    public Subscription(String planName, int monthlyRequestLimit) {
        this.planName = Objects.requireNonNull(
            planName,
            "planName must not be null"
        );

        if (planName.isBlank()) {
            throw new IllegalArgumentException(
                "planName must not be blank"
            );
        }

        if (monthlyRequestLimit <= 0) {
            throw new IllegalArgumentException(
                "monthlyRequestLimit must be positive"
            );
        }

        this.monthlyRequestLimit = monthlyRequestLimit;
    }

    // methods omitted
}

requireNonNull() does two useful things:

  • It rejects null immediately with a clear message.
  • It returns the non-null object, allowing direct assignment to the field.

After the assignment succeeds, planName.isBlank() is safe. This is a compact way to preserve the class invariant established in the previous lesson: a Subscription cannot exist without a plan name.

Read the following parts of Baeldung’s guide for the standard-library method and its fail-fast rationale.

Guide to Objects.requireNonNull() in Java | Baeldung

Read “Guide to Objects.requireNonNull() in Java” from Baeldung for a focused explanation of Java’s built-in precondition check and useful error messages.

In Section 3, “Advantages of Objects.requireNonNull(),” read the explanation beginning with the method's purpose. Then read Section 4.1, “Single Parameter Validation in Methods and Constructors,” and Section 4.2, “Multiple Parameter Validation With Custom Error Messages.” Notice that the method returns the same non-null reference, and that a message identifies the bad parameter. Finally, in Section 5, “Best Practices,” read the fail fast discussion.

Use Objects.requireNonNull() when null is a programming error at a method or constructor boundary. Do not use it merely to move an unclear NPE closer to the caller. Its message should explain the violated requirement.

Assertions are not a substitute for this validation:

assert planName != null;

Java assertions are normally disabled in production unless explicitly enabled. Runtime contracts should be enforced with ordinary validation, not with assertions.

2. Valid absence: branch deliberately

Sometimes absence is normal. For example, a customer might not have added a display name yet. The application can choose a fallback:

public String displayName(Customer customer) {
    if (customer == null) {
        return "Guest";
    }

    return customer.getDisplayName();
}

The important question is not “How can I suppress the NPE?” It is “What should the application mean when this value is absent?”

In future Spring MVC lessons, you will validate incoming API data at the HTTP boundary. For now, remember that client-provided data may be absent or invalid, while internal required collaborators and domain invariants should generally be non-null.

3. A missing collection is usually an empty collection

A collection with no elements is a real object; a null collection is no collection object at all.

List<Subscription> subscriptions = List.of();

for (Subscription subscription : subscriptions) {
    System.out.println(subscription.getPlanName());
}

The loop safely does nothing.

By contrast:

List<Subscription> subscriptions = null;

for (Subscription subscription : subscriptions) {
    System.out.println(subscription.getPlanName());
}

The loop throws an NPE before its body runs.

When a method has “zero results,” return an empty collection rather than null:

public List<Subscription> findByCustomerId(long customerId) {
    return List.of();
}

This gives callers a simpler contract: they can always iterate, ask for the size, or process the result. You will use List, Set, and Map in the next module.

4. Safe equality for a fixed string

This code fails if status is null:

if (status.equals("ACTIVE")) {
    // ...
}

If you are comparing with a known literal, place the known non-null value on the left:

if ("ACTIVE".equals(status)) {
    // ...
}

"ACTIVE" is a real String object, and its equals method safely returns false when its argument is null.

For comparisons where either side may be null, Java also provides:

if (Objects.equals(expectedStatus, actualStatus)) {
    // ...
}

This treats two null references as equal and one null reference as unequal to a non-null one. Use it when that equality behavior matches the domain meaning.

Short-circuit checks protect later conditions

Java evaluates && and || from left to right and stops when it already knows the result. This lets you write a safe combined check:

if (planName != null && !planName.isBlank()) {
    System.out.println("Valid name");
}

If planName is null, Java does not evaluate planName.isBlank().

The reversed order is unsafe:

if (!planName.isBlank() && planName != null) {
    // NPE if planName is null
}

Even so, do not turn every piece of code into a long chain of checks. Repeated null checks often signal an unclear API contract or an object that has not been properly initialized. Prefer to establish strong invariants early, then write normal business logic under those guarantees.


Using IDE feedback productively

IntelliJ IDEA highlights `body.length()` because the result assigned to `body` may be null, then offers fixes such as an explicit check, `Objects.requireNonNull(body)`, or a conditional expression.

An IDE warning is evidence to investigate, not a command to choose the first quick fix. For the code in the image, first determine the contract of body():

  • If a response body must be present for the operation to succeed, use a clear fail-fast check or fix the producing method so its contract is explicit.
  • If an absent body is meaningful, use an explicit branch and decide how the application should respond.
  • If the method should provide a default representation, make that choice deliberately and document it.

Quick fixes can generate valid Java that is poor backend behavior. For example, replacing an unexpected absent response with an empty string may prevent an NPE while causing later code to persist incomplete data. Correct null-safety is about preserving meaning, not merely preventing a crash.

A practical habit while debugging in IntelliJ is to place a breakpoint before the failing dereference, run the program, and inspect each reference in the chain. Check whether it is null, where it acquired that value, and whether that value violates its intended contract.


Key takeaways

A Java object and a reference to that object are different things. Assigning one reference variable to another copies the reference, so both variables can access and mutate one shared object. Reassigning one variable, including assigning it null, does not change the other reference.

A NullPointerException occurs when Java needs an object—such as to call an instance method, access a field, index an array, or unbox a wrapper—but the reference is null. Read the modern error message and stack trace, trace the reference back to its origin, and decide whether absence is invalid or expected.

For required values, establish invariants in constructors and validate preconditions with Objects.requireNonNull(). For valid absence, branch deliberately. Prefer primitives when a value cannot be absent, and return empty collections rather than null collections. Treat IDE nullability warnings as a prompt to clarify the contract.

Next, you will begin object-oriented Java and core collections by implementing interface-based polymorphism—a central technique for designing Spring services around stable abstractions rather than concrete implementations.

Can't find a good explanation? Sign up and we'll make it for you

Sign up