References and primitives
Java distinguishes primitive types — boolean, byte, short, int, long, float, double, char — from reference types — everything else (classes, interfaces, arrays, enum types, annotation types). The distinction governs assignment semantics, equality, parameter passing, storage location, and the participation in generic types. Most newcomers from object-oriented languages with universal reference semantics (Python, Ruby) and from languages with universal value semantics (C structs without a heap) need to understand the partition explicitly.
The partition is fixed: only the eight primitives have value semantics, and every other type is a reference. Java does not (yet) admit user-defined value types — Project Valhalla is a long-running effort to add them, but until it ships the partition is set by the JLS.
This page covers the reference-vs-primitive partition, the wrapper types and autoboxing, equality semantics, the null problem, Optional<T>, and parameter-passing semantics. The full treatment of memory (the GC, allocation, resource management) is in Memory and the JVM.
The two categories
The eight primitive types are exhaustively enumerated by the JLS:
| Primitive | Width (bits) | Range / Values |
|---|---|---|
boolean | (logically 1) | true, false |
byte | 8 | −128 to 127 |
short | 16 | −32 768 to 32 767 |
int | 32 | −2³¹ to 2³¹ − 1 |
long | 64 | −2⁶³ to 2⁶³ − 1 |
float | 32 | IEEE 754 binary32 |
double | 64 | IEEE 754 binary64 |
char | 16 | UTF-16 code unit |
Every other type is a reference type:
- Classes declared with
class,record. - Interfaces declared with
interface, including annotation types declared with@interface. - Arrays:
int[],String[][]. - Enum types declared with
enum.
Primitives are stored inline
Primitive values are stored directly in the variable, parameter, or field that holds them:
int x = 42;
double y = 3.14;
char c = 'A';
The variable x holds the bit-pattern of 42; assigning to x overwrites the bits. Assignment between primitives copies the value:
int a = 5;
int b = a; // b is an independent copy
b = 99;
System.out.println(a); // 5; a is unaffected
In a method, primitive locals are stored on the stack frame; they are reclaimed when the method returns. Primitive fields are stored inline within the enclosing object (on the heap) or as part of the static area (for static fields).
References are heap-allocated and indirected
A reference variable holds either null or a reference — a value that the JVM uses to designate a heap-allocated object. The reference itself is stored in the variable; the object lives on the heap.
StringBuilder a = new StringBuilder("hello");
StringBuilder b = a; // b refers to the same StringBuilder as a
b.append(", world");
System.out.println(a); // "hello, world"
Assignment between references copies the reference — both a and b now designate the same heap object. Modification through either is visible through the other.
The reference is implicit in the syntax; Java has no explicit dereference operator (no * as in C, no .value as in some languages). Member access (a.append(...)) follows the reference automatically.
Assignment semantics
The principal practical distinction is in assignment:
| Type kind | b = a |
|---|---|
| Primitive | b is an independent copy of a |
| Reference | a and b designate the same object |
The same distinction applies to method arguments — Java is always pass by value, but for references, the value is the reference itself:
public static void modify_int(int n) {
n = n + 1; // affects only the local copy
}
public static void modify_list(List<Integer> list) {
list.add(42); // modifies the heap object the caller's list also designates
}
int n = 5;
modify_int(n);
System.out.println(n); // 5: unchanged
List<Integer> xs = new ArrayList<>();
modify_list(xs);
System.out.println(xs); // [42]: the list was modified
The “Java is pass by reference” claim is a frequent confusion. Java is pass by value. The reference itself is passed by value; modifying the heap object the reference designates is visible to the caller because the caller has a reference to the same object. Reassigning the parameter (list = new ArrayList<>()) does not affect the caller’s variable.
Wrapper types and autoboxing
Each primitive has a corresponding wrapper class in java.lang:
| Primitive | Wrapper |
|---|---|
boolean | Boolean |
byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
char | Character |
The wrapper is a reference type: an immutable class with one instance field (the underlying primitive) and methods for boxing, comparison, and parsing.
Autoboxing (since Java 5) admits implicit conversion between a primitive and its wrapper:
Integer boxed = 42; // autoboxing: int -> Integer
int unboxed = boxed; // auto-unboxing: Integer -> int
List<Integer> ints = new ArrayList<>();
ints.add(1); // autoboxes the int
int first = ints.get(0); // auto-unboxes the Integer
The mechanism is convenient but has costs:
- Each box allocates a new heap object (with caveats; see below).
- The wrapper carries an object header; an
Integeris approximately 16 bytes on a 64-bit JVM, versus 4 bytes for a primitiveint. - Auto-unboxing of
nullthrowsNullPointerException.
Integer maybe = computeMaybe();
int n = maybe; // throws NPE if maybe is null
The conventional discipline is to use primitives where possible (in arithmetic, in tight loops, in fields where null makes no sense) and wrappers only when reference semantics are genuinely required (collections, generic type parameters, optional values).
Wrapper caching and ==
Java caches small wrapper instances. Integer.valueOf(n) (which the autoboxing compiles to) returns a cached instance for n in the range −128 to 127:
Integer a = 100;
Integer b = 100;
boolean eq1 = (a == b); // true: same cached instance
Integer c = 200;
Integer d = 200;
boolean eq2 = (c == d); // false: distinct boxes
boolean eq3 = c.equals(d); // true: value comparison
The cache size is JVM-implementation-defined for Long, Short, etc.; for Integer it is fixed by the spec at −128 to 127. The mechanism is an optimisation; it is invisible at the source level except through the == ambiguity.
The conventional discipline:
- For wrapper comparison: always use
equals, never==. - For wrapper arithmetic: prefer primitives; convert at the boundary.
Reference equality vs value equality
Reference types support two notions of equality:
- Reference equality (
==): the two references designate the same heap object. - Value equality (
equals): the two objects represent the same value, by the contract of the type.
String a = new String("hello");
String b = new String("hello");
boolean ref_eq = (a == b); // false: different heap objects
boolean val_eq = a.equals(b); // true: same content
The default equals (inherited from Object) is reference equality; a class that needs value equality must override it. The contract of equals requires that:
- It is reflexive:
x.equals(x)is alwaystrue. - It is symmetric:
x.equals(y) == y.equals(x). - It is transitive: if
x.equals(y)andy.equals(z), thenx.equals(z). - It is consistent: repeated calls produce the same result if no observable state changes.
x.equals(null)is alwaysfalse.
A class that overrides equals must also override hashCode to maintain the contract that equal objects have equal hash codes. Records (since Java 14) generate both automatically based on the components.
public record Point(double x, double y) { }
Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
boolean eq = p1.equals(p2); // true; auto-generated equals
int h = p1.hashCode(); // consistent with equals
null and NullPointerException
A reference variable may hold null, the absence of a reference. Dereferencing a null reference (member access, method call, indexing into a null array) throws NullPointerException (NPE):
String s = null;
int n = s.length(); // throws NullPointerException
NPEs are historically the most common runtime exception in Java code. Several mechanisms reduce their incidence:
Defensive null checks
public void process(String input) {
if (input == null) {
throw new IllegalArgumentException("input must not be null");
}
// ...
}
The conventional pattern at API boundaries; some libraries (Apache Commons, Guava) provide helper methods (Objects.requireNonNull):
import java.util.Objects;
public void process(String input) {
Objects.requireNonNull(input, "input must not be null");
// ...
}
Optional<T>
Java 8 introduced Optional<T> as an explicit “may or may not have a value” type:
import java.util.Optional;
public Optional<User> findUser(String name) {
User u = repository.find(name);
return Optional.ofNullable(u); // wraps null as empty
}
Optional<User> result = findUser("alice");
if (result.isPresent()) {
User u = result.get();
}
// or, more idiomatic:
result.ifPresent(u -> System.out.println(u.name()));
String displayName = findUser("bob")
.map(User::displayName)
.orElse("unknown");
The conventional uses:
- Return type for methods that may not produce a value (search, parse, lookup).
- Not as a parameter type (overuse leads to clutter).
- Not as a field type (Java’s nullability semantics for fields are clearer than wrapping in
Optional).
Optional<T> admits monadic operations (map, flatMap, filter, ifPresent) that compose the absent-or-present logic without explicit checks.
Null annotations (@Nullable, @NonNull)
Several third-party libraries (JSR-305, JetBrains, the Checker Framework, JSpecify) provide annotations that document and (with appropriate tooling) enforce nullability:
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
public @NonNull String required() { /* ... */ }
public @Nullable User findUser(String name) { /* ... */ }
The annotations are not part of the language; the JVM does not check them at runtime. They are consumed by static analysers and IDE tooling.
NPE messages (Java 14)
Java 14 introduced helpful NPE messages: when an NPE occurs, the runtime explains which expression was null:
String name = person.address.street.name; // NPE: person.address is null
The message identifies which dereference failed. The mechanism is enabled by default since Java 15.
Defensive copying
Mutable reference types — collections, arrays, mutable objects — admit aliasing: a caller can modify a reference’s target after passing it. The conventional defence is defensive copying:
public class Document {
private final List<String> tags;
public Document(List<String> tags) {
this.tags = new ArrayList<>(tags); // copy on the way in
}
public List<String> tags() {
return new ArrayList<>(tags); // copy on the way out
}
}
The pattern admits the constructor’s caller mutating the original tags list without affecting the Document, and admits the tags() caller mutating the returned list without affecting the Document’s state.
For collections, immutable collections (List.of, Map.of, since Java 9) and Collections.unmodifiableList(list) produce read-only views without copying:
public class Document {
private final List<String> tags;
public Document(List<String> tags) {
this.tags = List.copyOf(tags); // immutable copy
}
public List<String> tags() {
return tags; // safe to return: immutable
}
}
List.copyOf (Java 10+) returns an immutable copy; List.of(...) (Java 9+) creates an immutable list directly. The mechanisms are the conventional contemporary form for immutable-collection idioms.
Common defects
| Defect | Description |
|---|---|
Comparing wrappers with == | Outside the cached range, distinct boxes have distinct references. Always use equals. |
Auto-unboxing null | Throws NPE at the unbox point. Use Optional<T> or explicit null checks. |
| Returning a mutable internal collection | The caller may modify it, breaking class invariants. Defensive copy or return immutable. |
| Storing a reference to a mutable parameter | The caller may mutate it after the call. Defensive copy on the way in. |
| Reassigning a parameter and expecting the caller to see | Java passes by value; reassigning a parameter is invisible to the caller. Use a return value. |
Comparing strings with == | Equal literals may share references via interning, but built strings do not. Always use equals. |
The combination of Optional<T> for explicit absence, defensive copying for mutable collaborators, immutable collections for shared data, and consistent equals/hashCode on user-defined classes is the conventional Java idiom for safe reference handling.