Lesson 4. null and Where It Comes From
Mission link: A NullPointerException in production tells you where the program noticed, not where the null came from. Closing the distance between those two is a design skill, and it starts with knowing which constructs produce null at all.
Primary source: The Java Language Specification, 4.12.5 Initial Values of Variables
Prerequisites: Lesson 1, Lesson 3
Warm-up
- ▢ What must be true of a field that
hashCodereads, if the object is going to be a map key?
Check
It must not change while the object is in the map. In practice that means final, because hashing happens once at insertion.
- ▢ Which fails first when
nameisnull:name.equals("x")or"x".equals(name)?
Check
The first throws NullPointerException. The second returns false, because the constant is the receiver.
Know this
null is a reference value that refers to no object. It has its own type, the null type, which has no name and is assignable to every reference type. It is not an object, so it has no methods, and it is not zero, so it is not a number.
Three sources put null into a program:
- Defaults. Every field and array element of a reference type starts as
null, before any constructor body runs. - Returns. A method that has nothing to return and no better answer returns
null.Map.geton a missing key is the most-called example in the language. - Inputs. Callers pass it, deserialisation produces it, and a database column allows it.
What actually throws
A NullPointerException comes from using a null reference, which means one of:
name.length(); // calling a method
config.retries; // reading or writing a field
array[0]; // indexing, or reading array.length
throw problem; // throwing it
int n = boxedInteger; // unboxing, which is a hidden method call
for (String s : list) // iterating, which calls iterator()
The unboxing case is worth its own look, because nothing in the line mentions a method:
Map<String, Integer> counts = new HashMap<>();
int n = counts.get("missing"); // NullPointerException, not 0
get returned null, and the assignment to int had to call intValue() on it. Use counts.getOrDefault("missing", 0) when zero is the right answer.
Since JDK 15, the message names the expression that was null, which turns most of these from a hunt into a read. That is JEP 358, and it is on by default.
Two things that do not throw: "" + name produces the string "null" when name is null, and System.out.println(name) prints those same four characters. That is how null ends up written to logs and stored in databases as text.
A curiosity worth knowing because it confuses people once: the literal System.out.println(null) does not compile at all, since the compiler cannot choose between the String and char[] overloads. A null-valued variable is unambiguous, which is why the version above is fine.
null and the collections
The rules differ per implementation, and they are not arbitrary:
| Collection | null key | null value |
|---|---|---|
HashMap | one allowed | allowed |
TreeMap | rejected under natural ordering | allowed |
ConcurrentHashMap | rejected | rejected |
Map.of(...) | rejected | rejected |
ArrayList | not applicable | allowed as an element |
List.of(...) | not applicable | rejected as an element |
The immutable factories reject null deliberately, so List.of(a, b) throws immediately if either is null rather than storing a hole for someone to trip on later. Treat that as guidance rather than an inconvenience.
Rejecting instead of checking
Scattering if (x != null) through a codebase treats null as a value with meaning. The alternative is to decide, once, where a null is allowed and to stop it there:
public Order(String id, List<Item> items) {
this.id = Objects.requireNonNull(id, "id");
this.items = List.copyOf(items); // throws on null, and on null elements
}
requireNonNull throws at the boundary, with the parameter name, before the object exists in an invalid state. List.copyOf does the same for a collection and gives you an unmodifiable copy, which also settles lesson 1's question about whether the caller can still mutate it.
Three habits that follow, all of them cheap:
- Never return
nullfor a collection. ReturnList.of(). Callers can iterate it without a guard, and an empty list means exactly what a caller wants to know. - Validate at the edge, meaning constructors, public entry points and deserialisation, and trust the interior.
- Say so in the signature's documentation when
nullis genuinely permitted, because otherwise every reader has to guess.
Stage 3 adds Optional, which is the right tool for "this method may have no answer". It is not a replacement for the three habits above, and using it as a field type or a parameter type is a known mistake this workspace will come back to.
Practice
-
▢ Which line throws, and what is the exception?
Map<String, Integer> counts = new HashMap<>(); Integer a = counts.get("missing"); int b = counts.get("missing"); System.out.println(a);
Hint
Two of these lines involve a conversion the source code does not mention.
Check
The third line throws NullPointerException.
get returns null for both lines. Assigning null to an Integer is fine. Assigning it to an int requires unboxing, which calls intValue() on null. The fourth line would have printed null quite happily.
The fix is counts.getOrDefault("missing", 0), and the general rule is that unboxing is a method call wearing an assignment's clothes.
-
▢ For each, say whether it throws, and when.
List<String> a = new ArrayList<>(); a.add(null); List<String> b = List.of("x", null); Map<String, String> c = new HashMap<>(); c.put(null, "v"); Map<String, String> d = Map.of("k", null);
Check
a is fine: ArrayList accepts null elements. b throws NullPointerException at construction. c is fine: HashMap allows one null key. d throws NullPointerException at construction.
The pattern: the mutable legacy collections tolerate null, and the immutable factories added later refuse it. The refusal is the better behaviour, because it fails where the mistake is.
-
▢ A method returns
nullwhen it finds no matching users. Rewrite the contract, and say what the caller stops having to write.List<User> findByTenant(String tenant); // returns null when none found
Check
Return List.of() when there are none, and document that the result is never null.
The caller stops writing if (result != null && !result.isEmpty()) and writes if (!result.isEmpty()), or just iterates, since a loop over an empty list runs zero times. The signature is unchanged; what changed is that there is now one fewer state for every caller to handle.
-
▢ Where should this validation go, and which spelling would you use?
class Report { private final String title; private final List<Row> rows; Report(String title, List<Row> rows) { this.title = title; this.rows = rows; } }
Check
In the constructor, because that is the boundary where an invalid Report would otherwise come into existence:
Report(String title, List<Row> rows) {
this.title = Objects.requireNonNull(title, "title");
this.rows = List.copyOf(rows);
}
requireNonNull names the parameter in the message. List.copyOf rejects a null list and null elements, and returns an unmodifiable copy, so the caller cannot mutate the report's rows afterwards. Two problems, one line.
- ▢ A log line reads
user=null tenant=acme. The field is aString. Name two different things that could have produced that text.
Check
Either the reference was null and string concatenation rendered it as the four characters null, or the string genuinely contained the text "null", which happens when a caller upstream concatenated a null reference into a value and stored it.
That ambiguity is the practical cost of null being printable. It is a real argument for rejecting null at the boundary rather than letting it travel: by the time it reaches a log, the two cases are indistinguishable.
Real-world reps
- [ ] Trigger the unboxing
NullPointerExceptionfrom practice 1 and read the message. Note that it names the expression, which is what makes JDK 15 and later much less painful than what came before. - [ ] Take one constructor in code you know and add
Objects.requireNonNullto each reference parameter. Run the tests. Anything that now fails was passingnulland getting away with it. - [ ] Tomorrow: find a method that returns
nullfor "nothing found". Count the call sites that guard for it, and the ones that do not.
Going further
- JLS 4.12.5, Initial Values of Variables: why a field is
nullbefore any code runs Objects:requireNonNull,requireNonNullElse, and the null-safe helpersList: whatofandcopyOfguarantee, including their treatment ofnull- JEP index: JEP 358 is the helpful
NullPointerExceptionmessages - Resources
Not landing? Reread the primary source at the top, since this lesson compresses it and compression is where understanding leaks. Check the glossary for any term that felt slippery.
If the lesson itself is unclear rather than the material, that is a defect: open an issue.