Understanding Java Object Parameter Behavior
java object parameter behavior: Understand how Java passes object references by value, why mutation affects the caller, and when reassignment is safe. Includes defensi...
In Java, the behavior of object parameters often confuses developers because the language uses pass-by-value for all arguments, yet objects appear to be passed by reference. When you pass an object to a method, Java copies the reference value, not the object itself. This distinction explains why mutating an object inside a method affects the caller, while reassigning the parameter does not. Understanding java object parameter behavior is essential for writing predictable APIs and avoiding subtle bugs.
What Pass-by-Value Means for Object Parameters
Java always passes arguments by value. For primitive types, the value is the actual data. For reference types, the value is the reference (the memory address) of the object. The method receives a copy of that reference, so both the caller and the method point to the same object.
Consider this example:
public class PassByValueDemo { public static void main(String[] args) { int number = 10; changePrimitive(number); System.out.println(number); // 10 StringBuilder sb = new StringBuilder("Hello"); changeReference(sb); System.out.println(sb); // Hello World } static void changePrimitive(int value) { value = 20; } static void changeReference(StringBuilder builder) { builder.append(" World"); } }
The primitive number remains 10 because the method modifies only its local copy. The StringBuilder is mutated because builder and sb reference the same object. The reference itself is copied, but the object it points to is shared.
How Mutation Works Through a Reference
When a method receives an object reference, it can modify the object's internal state. This is true for any mutable object, whether it is a standard library class or a custom class.
class MutableBox { private String value; public String getValue() { return value; } public void setValue(String value) { this.value = value; } } public class MutationDemo { public static void main(String[] args) { MutableBox box = new MutableBox(); box.setValue("original"); updateBox(box); System.out.println(box.getValue()); // updated } static void updateBox(MutableBox box) { box.setValue("updated"); } }
The method updateBox changes the field of the object that the caller passed. Because the reference copy still points to the same MutableBox instance, the caller observes the change. This is the basis of many APIs that modify collections or builder objects.
Reassigning a Parameter Does Not Affect the Caller
A common mistake is to think that assigning a new object to a parameter will update the caller's variable. It does not. The parameter is a local variable that holds a copy of the reference. Reassigning it only changes that local copy.
public class ReassignmentDemo { public static void main(String[] args) { StringBuilder sb = new StringBuilder("original"); replaceBuilder(sb); System.out.println(sb); // original } static void replaceBuilder(StringBuilder builder) { builder = new StringBuilder("replacement"); } }
After replaceBuilder returns, sb still points to the original object. The new StringBuilder created inside the method becomes eligible for garbage collection once the method exits. To change what the caller references, the method must return the new object and the caller must assign it.
Common Mistakes and Misunderstandings
One frequent misunderstanding is that final parameters prevent mutation. In Java, final on a parameter only prevents reassignment within the method. It does not make the referenced object immutable.
static void attemptMutation(final StringBuilder builder) { builder.append(" still allowed"); // legal // builder = new StringBuilder(); // compile error }
Another mistake involves null. If you pass null to a method that expects an object, the parameter receives a copy of the null reference. Assigning a new object to that parameter does not affect the caller's variable, just like any other reassignment.
Arrays are also objects. Passing an array passes a reference to the array, so modifying elements inside the method affects the caller. Reassigning the array variable does not.
When to Use Defensive Copies
Because object parameters allow the callee to mutate the object, you may need to protect your own internal state. If a method stores a reference to a caller-provided object, the caller can later change that object and break the method's invariants. Similarly, returning an internal reference gives the caller direct access to your data.
A defensive copy creates a new object that is independent of the original. For example, when storing a list passed as a parameter:
public class SafeStore { private final List<String> items; public SafeStore(List<String> input) { this.items = new ArrayList<>(input); } public List<String> getItems() { return new ArrayList<>(items); } }
The constructor copies the input list, so later changes to the caller's list do not affect the stored data. The getter also returns a copy, preventing callers from modifying the internal list. This pattern is common when implementing immutable or thread-safe containers.
Performance and Memory Considerations
Passing an object reference is cheap. On a 64-bit JVM, a reference is typically 8 bytes, regardless of the object's size. No deep copy occurs automatically. This makes method calls efficient even when passing large objects.
Defensive copies, however, add allocation and copying overhead. If you copy a large collection on every call, the cost can become significant. You should balance the need for safety against performance. In performance-sensitive code, consider using read-only views or immutable data structures instead of copying.
For example, Collections.unmodifiableList provides a read-only view without copying the underlying data. But it only prevents modification through the view; the original list can still change if the caller holds a reference. A true defensive copy is the only way to guarantee isolation.
Designing Method Signatures to Avoid Unintended Mutation
You can reduce the risk of unintended mutation by designing your APIs carefully. Use immutable types like String, Integer, or Java records when possible. If you must accept a mutable object, document whether the method mutates it. Consider accepting an interface that exposes only read-only operations, or use a builder pattern that returns a new object.
When a method needs to modify a collection, it can return a new collection instead of changing the input. This makes the behavior explicit and avoids side effects.
public static List<String> addPrefix(List<String> names, String prefix) { return names.stream() .map(name -> prefix + name) .toList(); }
The original list remains unchanged. This style is common in functional programming and makes code easier to reason about, especially in concurrent environments where shared mutable state is a hazard.
Understanding java object parameter behavior helps you decide when to mutate, when to copy, and when to return a new object. The choice affects correctness, maintainability, and performance. By respecting the pass-by-value semantics and using defensive copying where needed, you can write APIs that are both safe and efficient.