7.3
Wrapper Types, Autoboxing, and POJOs
See every hidden primitive conversion, avoid identity and null traps, and separate Java objects from framework rules.
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.
The problem
Java has eight primitive types, and none is a class:
int count = 5;
// count.toString(); // int has no methodsSome 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.
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 conversionAutoboxing 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.
Under the hood
Going deeperTrap 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.equalsasks whether wrapper values are equal according to that wrapper class.
Integer left = 1000;
Integer right = 1000;
System.out.println(left.equals(right)); // trueThe 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)); // falseThey 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); // trueBecause 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; // NullPointerExceptionThe 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)); // wrapperThe 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 10List 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 LongThe 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:
- A source file may declare several top-level classes or interfaces.
- At most one top-level type in that file may be
public. - 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.
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.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
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. Useequalsfor non-null wrappers or unbox deliberately.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 throughnullthrowsNullPointerException.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.
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 namedInvoice.java.
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 Boxed-Identity Trap at 127 and 128
ex-7-3-aTwo 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
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
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
.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.remove(1) Does Not Do What You Think
ex-7-3-bTwo 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
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
remove(int) matches with no conversion at all, so the search stops before boxing is ever considered.Hint 3almost the answer
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.Measure the Boxing Tax
ex-7-3-cTwo 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
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
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 NullPointerException With No Dot
ex-7-3-dA 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
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
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.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
- AutoboxingInteger 127 == 127 is true. Integer 128 == 128 is false. A cache is why.8.3 - The Object Class: `equals`, `hashCode`, `toString`
- AutoboxingEvery int you put in a List is boxed. In a tight loop that costs real time.Phase X. The Collections Framework
- POJOA POJO without equals and hashCode cannot be found in any collection.8.3 - The Object Class: `equals`, `hashCode`, `toString`
POJO will return in 8.2 - Immutable Classes