Parameters and Pass-by-Value
Understand that Java always copies arguments, why a method can change an object you pass but never your variable, and when to copy defensively.
void method(int count, List<String> items) {
count = 5; // caller's int unchanged
items.add("x"); // caller's list changed
items = List.of(); // caller's variable unchanged
}Java has exactly one way of passing arguments: by value. When you call a method, each parameter gets a copy of the value of the argument. What that copy means depends on the type.
Primitives: the number is copied
For int, long, boolean and the other primitives, the parameter is a fresh copy of the number. Changing it inside the method has no effect on the caller's variable.
Objects: the reference is copied
A variable of a class type such as List<String> holds a reference to an object, not the object itself. Passing it copies the reference. Now the caller and the method both point at the same object, which leads to two different outcomes:
- Calling a mutating method through the parameter (
tags.add("x")) changes the shared object, so the caller sees it. - Assigning a new object to the parameter (
tags = new ArrayList<>()) only changes the method's copy of the reference. The caller still points at the old object.
People often say "objects are passed by reference" in Java. They are not: if they were, reassigning the parameter would change the caller's variable, and it never does. This is why a classic swap(a, b) method cannot be written in Java.
Why this matters in services
The bug that bites back-end code is the second-hand mutation. A service receives a list from a controller, sorts or filters it in place, and the controller's copy, or a cached copy shared between requests, changes underneath it. Two habits prevent it:
- Do not mutate inputs. Return a new list instead of editing the one you were given, unless the method's name makes the mutation obvious.
- Copy on the way in. When an object keeps a collection it was given, store
List.copyOf(items). Later changes by the caller cannot leak in, and the stored list is unmodifiable.
String, records with immutable components, and the boxed types like Integer are immutable, so passing them around is always safe: there is no method that could change them.
final parameters
Marking a parameter final stops the method reassigning it. It does not make the object immutable; final List<String> tags can still have tags.add called on it. Many teams skip final on parameters and instead treat reassigning a parameter as a code smell caught in review.
Example
When to use it
- Store List.copyOf(roles) in a user record so a caller's later edits cannot change who is an admin.
- Return a filtered copy from a service instead of removing items from a list the controller still uses.
- Explain to a teammate why a helper that reassigns its String parameter never changed the request's value.
More examples
Why swap cannot work, and what to do instead
A method cannot reassign the caller's variables, so return a new value and let the caller assign it. Returning a small record is the usual way to hand back more than one value.
void swap(String a, String b) {
String tmp = a;
a = b;
b = tmp; // only the local copies are swapped
}
record Pair(String first, String second) {
Pair swapped() {
return new Pair(second, first);
}
}
void main() {
String from = "eu-west";
String to = "us-east";
swap(from, to);
IO.println(from + " -> " + to);
Pair route = new Pair(from, to).swapped();
IO.println(route.first() + " -> " + route.second());
}
// Output:
// eu-west -> us-east
// us-east -> eu-westDefensive copies in a record
Without the copy, the order would hold the very same list as the cart and change when the cart changes. List.copyOf also rejects null elements, which catches bad input early.
record Order(String id, List<String> items) {
Order {
items = List.copyOf(items); // copy in, and make it unmodifiable
}
}
void main() {
List<String> cart = new ArrayList<>(List.of("book", "pen"));
Order order = new Order("o-1", cart);
cart.add("laptop"); // the caller keeps editing its own list
IO.println("cart: " + cart);
IO.println("order: " + order.items());
try {
order.items().add("mug");
} catch (UnsupportedOperationException e) {
IO.println("order items are read-only");
}
}
// Output:
// cart: [book, pen, laptop]
// order: [book, pen]
// order items are read-onlyReturning a new list instead of mutating the input
The second style is safer in services because the caller's data, which may also be logged or cached, stays exactly as it arrived. When a method must mutate its argument, say so in the name.
// Mutates the caller's list: easy to miss at the call site.
void normaliseInPlace(List<String> emails) {
emails.replaceAll(e -> e.strip().toLowerCase(Locale.ROOT));
}
// Leaves the input alone and returns a new, unmodifiable list.
List<String> normalised(List<String> emails) {
return emails.stream().map(e -> e.strip().toLowerCase(Locale.ROOT)).toList();
}
void main() {
List<String> submitted = new ArrayList<>(List.of(" [email protected]", "[email protected] "));
List<String> clean = normalised(submitted);
IO.println("submitted: " + submitted);
IO.println("clean: " + clean);
normaliseInPlace(submitted);
IO.println("submitted: " + submitted);
}
// Output:
// submitted: [ [email protected], [email protected] ]
// clean: [[email protected], [email protected]]
// submitted: [[email protected], [email protected]]
Discussion