Polyglot
Languages Java reference types
Java § reference-types

References and primitives

Java distinguishes primitive typesboolean, 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:

PrimitiveWidth (bits)Range / Values
boolean(logically 1)true, false
byte8−128 to 127
short16−32 768 to 32 767
int32−2³¹ to 2³¹ − 1
long64−2⁶³ to 2⁶³ − 1
float32IEEE 754 binary32
double64IEEE 754 binary64
char16UTF-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 kindb = a
Primitiveb is an independent copy of a
Referencea 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:

PrimitiveWrapper
booleanBoolean
byteByte
shortShort
intInteger
longLong
floatFloat
doubleDouble
charCharacter

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 Integer is approximately 16 bytes on a 64-bit JVM, versus 4 bytes for a primitive int.
  • Auto-unboxing of null throws NullPointerException.
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 always true.
  • It is symmetric: x.equals(y) == y.equals(x).
  • It is transitive: if x.equals(y) and y.equals(z), then x.equals(z).
  • It is consistent: repeated calls produce the same result if no observable state changes.
  • x.equals(null) is always false.

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

DefectDescription
Comparing wrappers with ==Outside the cached range, distinct boxes have distinct references. Always use equals.
Auto-unboxing nullThrows NPE at the unbox point. Use Optional<T> or explicit null checks.
Returning a mutable internal collectionThe caller may modify it, breaking class invariants. Defensive copy or return immutable.
Storing a reference to a mutable parameterThe caller may mutate it after the call. Defensive copy on the way in.
Reassigning a parameter and expecting the caller to seeJava 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.