Understanding Java Object Parameter Behavior
Java passes object references by value. Learn why mutating an object inside a method affects the caller, why reassigning the parameter does not, and when defensive copies protect your APIs.
In Java, arguments are always passed by value. For an object parameter, the value being passed is a reference to the object, not the object itself. This is why objects appear to be passed by reference: the method receives a copy of that reference, so both the caller and the method point to the same object. That distinction explains why mutating an object inside a method affects the caller, while reassigning the parameter does not. Knowing these semantics 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 to 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 at the top level. For example, when storing a list passed as a parameter:
import java.util.ArrayList; import java.util.List; 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 list. The getter also returns a copy, preventing callers from modifying the internal list. Keep in mind that this is a shallow copy: if the list contains mutable elements, those element references are still shared. This pattern is common when you want a container's internal collection to be protected from external changes.
Performance and Memory Considerations
Passing an object reference is cheap: the method receives a copy of the reference, not a copy of the object. No deep copy occurs automatically, so method calls remain efficient even when the object is large.
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 defensive copy isolates the collection from that external change, but you still need to consider whether the elements themselves are mutable.
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 and Integer, or if you use records, make sure their components are immutable. 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 example uses the Java 16+ Stream.toList() method, which returns an unmodifiable list. 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.
These semantics help you decide when to mutate an object, when to copy it, and when to return a new object. The choice affects correctness, maintainability, and performance. By respecting pass-by-value behavior and using defensive copies where needed, you can write APIs that are both safe and efficient.