Layers of Logic

7.3

Wrapper Types, Autoboxing, and POJOs

See every hidden primitive conversion, avoid identity and null traps, and separate Java objects from framework rules.

Core26 min read4 exercises
01

Previously on

Primitive variables hold values directly. Reference variables hold references to objects. Java APIs sometimes require an object even when the data is only a number or boolean.

Section 7.2 already completed abstract classes. This lesson keeps that foundation and focuses on wrapper types, boxing, and plain data objects.

02

The problem

Java has eight primitive types, and none is a class:

int count = 5;
// count.toString(); // int has no methods

Some language features and APIs operate on reference types. A generic type argument is one example:

// List<int> values; // invalid type argument
List<Integer> values;

Java therefore provides an object type corresponding to every primitive. Automatic conversions make those wrapper objects convenient.

The convenience creates a second problem. Source code can look like primitive arithmetic while performing object conversion, identity comparison, or null dereferencing.

03

The idea

Every primitive has a wrapper class

Primitive Wrapper Primitive Wrapper
byte Byte float Float
short Short double Double
int Integer char Character
long Long boolean Boolean

Wrapper objects are immutable. An Integer object represents one int value for its lifetime.

You can convert explicitly:

Integer boxed = Integer.valueOf(5);
int plain = boxed.intValue();

Java can insert both conversions:

Integer boxed = 5; // boxing conversion
int plain = boxed; // unboxing conversion

Autoboxing is the source-level convenience of converting a primitive value to its wrapper type. Unboxing converts a wrapper reference to its primitive value.

Mentally expand the convenient form when behaviour is unclear:

Integer total = 10;
total++;

Its effect is similar to:

Integer total = Integer.valueOf(10);
total = Integer.valueOf(total.intValue() + 1);

The original wrapper is not changed. The variable receives a reference representing the new value.

Parse text separately from boxing

Parsing and boxing solve different problems:

int number = Integer.parseInt("42");
Integer object = Integer.valueOf("42");

parseInt returns a primitive int. valueOf returns an Integer reference.

Invalid numeric text throws NumberFormatException in both cases.

04

Under the hood

Going deeper

Trap 1: wrapper identity is not numeric equality

Integer a = 127;
Integer b = 127;
System.out.println(a == b);

This commonly prints true because boxing uses Integer.valueOf, which reuses objects for a required range of small constant values.

Changing both values to 128 commonly prints false on a standard runtime. The implementation may return separate wrapper objects outside its cache.

Do not build logic around either result. The rule that matters is stable:

  • == between two references asks whether they refer to the same object.
  • equals asks whether wrapper values are equal according to that wrapper class.
Integer left = 1000;
Integer right = 1000;

System.out.println(left.equals(right)); // true

The language guarantees certain boxing identities for small constant values. Implementations may cache additional values. Neither fact turns reference identity into value comparison.

Wrapper equals is also type-sensitive:

Integer i = 5;
Long l = 5L;
System.out.println(i.equals(l)); // false

They represent numerically similar values but are objects of different wrapper classes.

Mixed primitive and wrapper comparison may unbox

Integer boxed = 1000;
int primitive = 1000;
System.out.println(boxed == primitive); // true

Because one operand is primitive, Java unboxes boxed and performs primitive numeric comparison.

This is another reason to inspect operand types before interpreting ==.

Trap 2: null unboxing

A primitive cannot be null. A wrapper reference can:

Integer count = null;
int value = count; // NullPointerException

The assignment compiles because Integer can normally unbox to int. At run time it behaves like a call to count.intValue().

The same conversion hides inside arithmetic, comparisons, conditions, and method calls:

Integer count = null;

// count + 1
// count < 10
// Math.max(count, 5)

Each operation needs an int, so each attempts to unbox.

Decide what absence means at the boundary. Reject null, supply a documented default, or preserve absence explicitly. Do not let an accidental unboxing choose the behaviour.

Trap 3: overloading sees primitive and wrapper types

static void show(int value) {
    System.out.println("primitive");
}

static void show(Integer value) {
    System.out.println("wrapper");
}

show(5);                  // primitive
show(Integer.valueOf(5)); // wrapper

The compiler selects an exact fixed-arity match for each declared argument type.

The collections API contains a well-known case:

List<Integer> values = new ArrayList<>(List.of(10, 20, 30));

values.remove(1);                  // removes index 1, the value 20
values.remove(Integer.valueOf(10)); // removes the value 10

List has both remove(int index) and remove(Object value). The argument type selects the overload.

Collections arrive in detail later. Keep this example as proof that hidden boxing can change which operation runs.

Trap 4: boxed arithmetic can create allocation pressure

Long total = 0L;
for (long i = 0; i < 1_000_000; i++) {
    total += i;
}

Each iteration conceptually unboxes total, adds two long values, and boxes the result. Long is immutable, so the old object cannot store the new sum.

A modern JIT may eliminate some temporary allocations after analysis. Source code should not depend on that optimisation.

Use a primitive accumulator when null and object identity have no meaning:

long total = 0L;

Wrappers are appropriate at object-only boundaries, generic APIs, and models where absence is meaningful. Primitives are the normal choice for tight arithmetic.

Boxing conversions are not arbitrary casts

Autoboxing converts a primitive to its corresponding wrapper. It does not freely combine primitive widening and boxing into any wrapper type.

Long a = 5L; // long boxes to Long
// Long b = 5; // int does not box directly to Long

The second line is invalid. int corresponds to Integer, not Long.

Method-invocation conversions have detailed phases, as Section 5.1 described. Do not replace those rules with “Java converts numbers as needed.”

POJO is a reaction to invasive frameworks

POJO means Plain Old Java Object. The term distinguishes an ordinary Java class from a component forced to extend framework base classes or implement infrastructure-heavy contracts.

public class Customer {
    private final long id;
    private String email;

    public Customer(long id, String email) {
        this.id = id;
        changeEmail(email);
    }

    public long getId() {
        return id;
    }

    public String getEmail() {
        return email;
    }

    public void changeEmail(String email) {
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("invalid email");
        }
        this.email = email;
    }
}

This is a POJO. It uses ordinary language features and has domain behaviour.

POJO is not a formal Java language category. The compiler has no POJO rule. A team or library may place additional conventions on its objects.

Do not confuse POJO with JavaBean. JavaBeans traditionally follow conventions such as property accessors and often a public no-argument constructor. A POJO does not automatically promise those conventions.

Records are concise data carriers, not universal POJO replacements

For immutable data whose main job is carrying values, a record can remove routine code:

public record Coordinate(int x, int y) {
}

Java supplies component accessors, a constructor, equals, hashCode, and toString based on the components.

A record can validate and define methods. Its component state is final, and it cannot extend another class.

Use a record when those semantics match the model. Use a class when identity, mutable lifecycle, or class inheritance matters.

One source file may contain several top-level types

This is valid in Demo.java:

public class Demo {
    public static void main(String[] args) {
        Helper.work();
    }
}

class Helper {
    static void work() {
        System.out.println("working");
    }
}

The precise everyday rule is:

  1. A source file may declare several top-level classes or interfaces.
  2. At most one top-level type in that file may be public.
  3. A public top-level type must match the source file name in the normal file-based compiler environment.

The rule lets tools find public types predictably. import registry.Customer can map to registry/Customer.java during source compilation.

Non-public helper types may share a file, but separate files are usually clearer once each type has independent importance.

05

What it costs

Autoboxing reduces conversion noise, but it can hide null dereferences, identity comparisons, overload choices, and allocation work.

Do not ban wrappers. Use them when an API needs an object or absence is part of the model. Use primitives when every value is present and arithmetic dominates.

POJO is useful language for architectural independence. It is not a substitute for designing invariants, behaviour, and useful boundaries.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. Why must value code not use == to compare two Integer variables?

    Show the answer

    == compares reference identity when both operands are wrappers. Some boxed values may reuse cached objects, so identity can appear to match value for one input and fail for another. Use equals for non-null wrappers or unbox deliberately.

  2. Why can "Integer count = null; int next = count + 1;" compile but fail at run time?

    Show the answer

    Arithmetic requires primitive values, so Java inserts an unboxing conversion equivalent to count.intValue(). Invoking that method through null throws NullPointerException.

  3. What does POJO guarantee about a class?

    Show the answer

    Very little by itself. POJO is an informal label for an ordinary Java object that is not forced to inherit from a special framework base class or obey an invasive component model. It does not require public fields, empty constructors, or getters and setters for every field.

  4. May one source file contain several top-level classes?

    Show the answer

    Yes. A source file may contain several top-level types, but at most one can be public in the ordinary file-based arrangement. If a public top-level class is named Invoice, its file must be named Invoice.java.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises85 pointsabout 90 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

The Boxed-Identity Trap at 127 and 128

Warm up·20 min·15 points

checkedex-7-3-a

Two lines that look identical can give opposite answers on the course JDK. Confirm the required small-value cache range.

Test values around it, then compare other wrapper types. Record required behaviour separately from what one runtime happens to cache beyond the requirement.

The cache itself is not the comparison rule. What matters is why it fooled you: == on two wrapper references compares identity.

Write down the one line rule you will follow from now on. It should be short and it should have no exceptions.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Cache {
    static int cacheLowBound()
    static int cacheHighBound()
    static boolean longCaches()
    static boolean shortCaches()
    static boolean booleanCaches()
    static int characterCacheHighBound()
    static boolean doubleCaches()
    static boolean valueOfUsesTheCache()
    static boolean newIntegerUsesTheCache()
    static String whatEqualsEqualsComparesOnBoxed()
    static boolean sameValue(Integer a, Integer b)
}

The bounds name the range that Integer.valueOf is required to cache and that the course JDK uses by default. whatEqualsEqualsComparesOnBoxed is one word, case and spaces ignored. sameValue is the value rule written as code: same value gives true on both sides of the boundary, two nulls are the same, and a null against a number is not.

What your program must do

  • Confirm the required Integer cache range on the course JDK
  • Test the other wrapper types and record which cache and which do not
  • Say what == is actually comparing on a boxed type
  • Write sameValue so it is correct everywhere, including for null
Cache.java
public class Cache {

    // ---- where the boundary is ----
    // Record the range Integer.valueOf is required to cache.

    // TODO
    static int cacheLowBound()  { return 0; }

    // TODO
    static int cacheHighBound() { return 0; }

    // ---- which types cache ----
    // Test each one. Do not guess.

    // TODO
    static boolean longCaches()      { return false; }

    // TODO
    static boolean shortCaches()     { return false; }

    // TODO
    static boolean booleanCaches()   { return false; }

    // TODO: char has no negative values, so where does its cache stop?
    static int characterCacheHighBound() { return 0; }

    // TODO: one of the wrappers caches nothing at all. Work out which, and why.
    static boolean doubleCaches()    { return true; }

    // ---- what is actually going on ----

    // TODO: does Integer.valueOf consult a cache for values such as 127?
    static boolean valueOfUsesTheCache() { return false; }

    // TODO: new Integer(128) == new Integer(128)   (deprecated, and revealing)
    static boolean newIntegerUsesTheCache() { return true; }

    // TODO: one word. What does == compare when both sides are references?
    static String whatEqualsEqualsComparesOnBoxed() { return "?"; }

    // ---- the rule, in code ----

    // TODO: true when a and b hold the same value, on both sides of the boundary,
    // and with no exception for null.
    static boolean sameValue(Integer a, Integer b) {
        return a == b;
    }

    public static void main(String[] args) {
        Integer a = 127, b = 127;
        Integer c = 128, d = 128;

        System.out.println("127 == 127        : " + (a == b));
        System.out.println("128 == 128        : " + (c == d));
        System.out.println("128 .equals 128   : " + c.equals(d));

        // TODO: try -128 and -129
        // TODO: try Long, Short, Character, Boolean and Double
        // TODO: try Integer.valueOf(128) == Integer.valueOf(128)
        // TODO: try new Integer(128) == new Integer(128)
    }
}
Hint 1
Test 127, 128, -128 and -129. Integer.valueOf must cache -128 through 127 and may cache more. The unmodified course JDK uses the required range.
Hint 2
Boolean reuses both values. Character must reuse 0 through 127. Double does not have the same required cache. Wrapper rules are not one universal range.
Hint 3almost the answer
The rule: use .equals() for boxed types, always, exactly as you do for Strings. For the null case, Objects.equals(a, b) does the check for you. The cache itself is trivia. What matters is that == on two references compares identity, and a boxed Integer is an object.
What this is really testing

Whether you know that == on boxed types compares reference identity. The Integer cache makes it appear to work for small numbers, which is worse than always failing.

B

remove(1) Does Not Do What You Think

Real work·20 min·20 points

checkedex-7-3-b

Two calls that look almost identical. One removes an element by position, the other tries to remove a value.

Predict both results, then explain the choice using the three resolution passes from Section 5.1. This is not a special rule for List. It is the rule you already learned, applied to a method that happens to have two overloads an int could reach.

Then do both operations correctly and deliberately, and note how different the two calls have to look.

Finish with the failure case. Removing an index that does not exist throws. Removing a value that is not present does not throw at all, it quietly returns false. Two different kinds of “not found”, and knowing which is which matters.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Remove {
    static String predictedRemoveOneByIndex()
    static String predictedRemoveOneAsObject()
    static String overloadChosenForPlainInt()
    static int resolutionPassThatMatched()
    static List<Integer> removeValue(List<Integer> list, int value)
    static List<Integer> removeAt(List<Integer> list, int index)
    static String exceptionForBadIndex()
    static boolean removingAbsentValueThrows()
    static boolean tryRemoveValue(List<Integer> list, int value)
}

The two prediction strings are lists printed the way a list prints itself, and spaces are ignored. exceptionForBadIndex is the class name without its package. removeValue and removeAt both change the list they are given and return the same list, and removeValue on a value that is not there must change nothing and throw nothing.

What your program must do

  • Predict both printed lines before running
  • Name the overload each call resolved to, and which of the three passes from Section 5.1 found it
  • Write removeValue and removeAt so each does exactly one of the two jobs
  • Show that a bad index throws and an absent value does not
Remove.java
import java.util.*;

public class Remove {

    // ---- predictions, before you run anything ----
    // Write what each line prints, exactly as the list prints itself.

    // TODO: byIndex after byIndex.remove(1)
    static String predictedRemoveOneByIndex()  { return "?"; }

    // TODO: byValue after byValue.remove(Integer.valueOf(1))
    static String predictedRemoveOneAsObject() { return "?"; }

    // ---- why ----

    // TODO: which overload did the plain int call go to? "remove(int)" or "remove(Object)"
    static String overloadChosenForPlainInt() { return "?"; }

    // TODO: which of the three resolution passes from Section 5.1 found it? 1, 2 or 3
    static int resolutionPassThatMatched() { return 0; }

    // ---- doing it on purpose ----

    // TODO: remove the VALUE, wherever in the list it happens to be
    static List<Integer> removeValue(List<Integer> list, int value) {
        return list;
    }

    // TODO: remove the element at that POSITION
    static List<Integer> removeAt(List<Integer> list, int index) {
        return list;
    }

    // ---- the two kinds of not found ----

    // TODO: name the exception class for an index that does not exist
    static String exceptionForBadIndex() { return "?"; }

    // TODO: does removing a value that is not in the list throw?
    static boolean removingAbsentValueThrows() { return true; }

    // TODO: remove the value and report whether anything was actually removed
    static boolean tryRemoveValue(List<Integer> list, int value) {
        return false;
    }

    public static void main(String[] args) {
        List<Integer> byIndex = new ArrayList<>(List.of(10, 20, 30, 40));
        List<Integer> byValue = new ArrayList<>(List.of(10, 20, 30, 40));

        byIndex.remove(1);
        byValue.remove(Integer.valueOf(1));

        System.out.println("remove(1)                  -> " + byIndex);
        System.out.println("remove(Integer.valueOf(1)) -> " + byValue);

        // TODO: remove the value 20 from a list, correctly
        // TODO: remove position 2 from a list, correctly
        // TODO: try removing index 5 from a four element list and read the exception
    }
}
Hint 1
List has remove(int index) and remove(Object o). Both exist, and an int argument can reach either one.
Hint 2
Apply the resolution order. The first pass allows widening only, and remove(int) matches with no conversion at all, so the search stops before boxing is ever considered.
Hint 3almost the answer
To remove by value you have to force the object overload with list.remove(Integer.valueOf(value)). To remove by position, pass a plain int. Note the two different failures: a bad index throws IndexOutOfBoundsException, and an absent value returns false and says nothing.
What this is really testing

Whether you can apply the Phase V resolution order to a real library method. This is the most famous autoboxing trap in Java and it is a direct consequence of a rule you already know.

C

Measure the Boxing Tax

Real work·25 min·25 points

checkedex-7-3-c

Two methods that differ by one character. Time them both.

Predict the ratio before running. Then work out how many objects the slow version creates, and remember from Section 6.3 how many bytes each one costs.

The fix is changing Long to long. That is the entire difference between a loop that does arithmetic and a loop that spends most of its time allocating memory and then throwing it away.

Note the warm-up loop and make sure you can explain why it is there. Every Java benchmark without one is measuring the wrong thing, and that mistake is published constantly.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Tax {
    static long recordedBoxedMillis()
    static long recordedPrimitiveMillis()
    static long objectsCreatedByBoxed(int n)
    static long objectsCreatedByPrimitive(int n)
    static boolean longIsImmutable()
    static boolean warmUpIsForTheJit()
    static boolean withoutWarmUpYouTimeTheInterpreter()
}

boxed, primitive, boxedSum and primitiveSum are given and must stay exactly as they are, because the questions are about those loops. The recorded timings are your own numbers in milliseconds from a warmed up run. The two object counts are formulas in n, and one of them does not depend on n at all. The tests also time both loops live, so they measure the machine they run on rather than trusting what you wrote down.

What your program must do

  • Run it and record both timings
  • Find the one character that differs between the two methods
  • Say how many objects each loop creates for n iterations, and why
  • Say why the warm-up loop is there, referring to Section 1.2
Tax.java
public class Tax {

    // ---- the two methods. One character apart. Leave both as they are. ----

    static long boxed(int n) {
        long start = System.nanoTime();
        Long sum = 0L;
        for (int i = 0; i < n; i++) sum += i;
        if (sum == -1) System.out.print("");
        return (System.nanoTime() - start) / 1_000_000;
    }

    static long primitive(int n) {
        long start = System.nanoTime();
        long sum = 0L;
        for (int i = 0; i < n; i++) sum += i;
        if (sum == -1) System.out.print("");
        return (System.nanoTime() - start) / 1_000_000;
    }

    static long boxedSum(int n) {
        Long sum = 0L;
        for (int i = 0; i < n; i++) sum += i;
        return sum;
    }

    static long primitiveSum(int n) {
        long sum = 0L;
        for (int i = 0; i < n; i++) sum += i;
        return sum;
    }

    // ---- what you measured ----
    // Run main, then write the two numbers you actually saw.

    // TODO
    static long recordedBoxedMillis()     { return 0; }

    // TODO
    static long recordedPrimitiveMillis() { return 0; }

    // ---- what is actually happening ----

    // TODO: how many objects does the boxed loop create for n iterations?
    static long objectsCreatedByBoxed(int n) { return 0; }

    // TODO: and the primitive one?
    static long objectsCreatedByPrimitive(int n) { return n; }

    // TODO: why does every addition need a new object rather than changing the old one?
    static boolean longIsImmutable() { return false; }

    // ---- the warm-up ----

    // TODO: what is the loop at the top of main for?
    static boolean warmUpIsForTheJit() { return false; }

    // TODO
    static boolean withoutWarmUpYouTimeTheInterpreter() { return false; }

    public static void main(String[] args) {
        int n = 20_000_000;
        for (int warm = 0; warm < 3; warm++) { boxed(n); primitive(n); }

        System.out.println("boxed Long     : " + boxed(n) + " ms");
        System.out.println("primitive long : " + primitive(n) + " ms");
    }
}
Hint 1
Compare the two methods line by line. There is exactly one difference and it is the type of the accumulator.
Hint 2
Long is immutable, so sum += i cannot change the object it already has. It unboxes, adds, and boxes the answer into a new object. Every pass. Twenty million passes, twenty million objects, all immediately garbage.
Hint 3almost the answer
The warm-up is the JIT compiler from Section 1.2. Without it you would be timing the interpreter rather than the compiled loop, and every benchmark written without one is reporting the wrong thing.
What this is really testing

Whether boxing overhead is a warning you read or a number you produced. The fix is one character, and seeing the gap is what makes you remember to apply it.

D

The NullPointerException With No Dot

Real work·25 min·25 points·The Registry

checkedex-7-3-d

A NullPointerException on a line with no dot in it.

Run it, read the stack trace, and find the line it blames. Then explain how a line containing no method call can possibly throw from a method call.

Fix the map lookup three different ways, and compare them honestly. They are not equivalent: one of them cannot tell a missing key from a key stored with a null value, and in a real Registry that difference matters.

Then find the second one, in the comparison at the bottom. It is the same mechanism wearing different clothes, and once you can spot both shapes you will spot the rest.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class NullBox {
    static int readingOf(Map<String,Integer> readings, String key)
    static int readingOrDefault(Map<String,Integer> readings, String key, int fallback)
    static Integer readingOrNull(Map<String,Integer> readings, String key)
    static boolean hasReading(Map<String,Integer> readings, String key)
    static int safeReading(Map<String,Integer> readings, String key, int fallback)
    static boolean isPositive(Integer count)
    static String hiddenCallOnTheFailingLine()
    static boolean comparisonCanUnbox()
    static boolean getOrDefaultHandlesStoredNull()
    static boolean containsKeyTellsMissingFromStoredNull()
}

readingOf must keep throwing, because it is the failure being explained. The test map holds a key called Halted that is present and stores null, which is what separates the three fixes from each other. readingOrDefault uses getOrDefault and is allowed to throw on that key, because that is what getOrDefault does. safeReading must survive both. hiddenCallOnTheFailingLine is a method name without brackets, case ignored.

What your program must do

  • Run it and read the stack trace, noting which line it blames and which call it names
  • Say why a line with no dot in it can throw a NullPointerException
  • Write the three fixes and find the case where they stop being equivalent
  • Fix the second one, in the comparison, without changing what it means
NullBox.java
import java.util.*;

public class NullBox {

    // Leave this one exactly as it is. It is the thing being explained.
    static int readingOf(Map<String, Integer> readings, String key) {
        return readings.get(key);
    }

    // TODO: fix one. Use getOrDefault. Then find out what it does NOT cover.
    static int readingOrDefault(Map<String, Integer> readings, String key, int fallback) {
        return readings.get(key);
    }

    // TODO: fix two. Hand the caller the box and let them decide.
    static Integer readingOrNull(Map<String, Integer> readings, String key) {
        return null;
    }

    // TODO: fix three. Is the key there at all?
    static boolean hasReading(Map<String, Integer> readings, String key) {
        return false;
    }

    // TODO: all of it. A missing key or a stored null must both give the fallback.
    static int safeReading(Map<String, Integer> readings, String key, int fallback) {
        return readings.get(key);
    }

    // TODO: true when count is a number above zero. It must survive null.
    static boolean isPositive(Integer count) {
        return count > 0;
    }

    // ---- what you worked out ----

    // TODO: read the stack trace. Which method call does it name?
    static String hiddenCallOnTheFailingLine() { return "?"; }

    // TODO: can a comparison like count > 0 unbox?
    static boolean comparisonCanUnbox() { return false; }

    // TODO: put a key in the map holding null, then call getOrDefault on it.
    static boolean getOrDefaultHandlesStoredNull() { return true; }

    // TODO: and containsKey?
    static boolean containsKeyTellsMissingFromStoredNull() { return false; }

    public static void main(String[] args) {
        Map<String, Integer> readiness = new HashMap<>();
        readiness.put("Atlas", 88);
        readiness.put("Beacon", 42);
        readiness.put("Halted", null);        // present, and holding nothing

        System.out.println("Atlas: " + readingOf(readiness, "Atlas"));
        System.out.println("Ghost: " + readingOf(readiness, "Ghost"));   // read the stack trace

        // TODO: compare the three fixes on "Ghost" and on "Halted"
        // TODO: find the second unboxing NPE, in the comparison below
        Integer count = null;
        if (count > 0) System.out.println("has units");
    }
}
Hint 1
int atlas = readings.get(key) compiles to readings.get(key).intValue(). The dot is there, the compiler added it, and that is what throws. The message in Java 21 names it for you.
Hint 2
The three fixes look interchangeable until you put a key in the map holding null. getOrDefault gives the default only when the key is absent, so a stored null comes straight back and still throws. Only containsKey tells the two apart.
Hint 3almost the answer
count > 0 also unboxes. A comparison needs a number, so Java calls intValue() on null. Any arithmetic or comparison on a boxed type can do this, and the fix is to check for null before you compare, not after.
What this is really testing

Whether you can recognise unboxing hiding in an innocent looking line. The stack trace points at code with no visible method call, which makes this one of the more confusing failures a beginner meets.

08

After the credits

When wrapper code behaves strangely, expand the hidden operation:

boxing:   Integer.valueOf(primitive)
unboxing: reference.intValue()

Then ask whether the code is comparing identities, accepting null, selecting an overload, or repeating conversions inside a loop.

Threads you opened in this section

POJO will return in 8.2 - Immutable Classes