Java: Pass by Value or Pass by Reference?
Table of contents
Java is pass-by-value only. This is one of the more persistent sources of bugs in Java: developers mistakenly believe that passing an object into a function is “safe” and that nothing outside that function can modify the original data. This post explains the mechanism from a language design perspective, pinpoints exactly what breaks when you get it wrong, and provides practical patterns to avoid those bugs.
Why Java Does Not Have True Pass by Reference
This is a deliberate design decision, not a technical limitation.
In C++, pass by reference allows a function to directly modify the caller’s variable:
void reassign(Dog& d) {
d = Dog("Buddy"); // the variable d outside is completely replaced
}
Nothing at the call site signals that d will be replaced. When the call stack is deep enough, this becomes unpredictable: a function three layers down can silently reassign a variable at the top level.
Java eliminates this in exchange for a more important guarantee: a function cannot make the caller’s local variable point somewhere else. Side effects happen in only one way, and it is a controllable way: through mutation of an object on the heap.
This does not eliminate side effects. It only constrains how side effects happen, making code easier to reason about.
Primitives: Completely Independent Stack Frames
Primitive types (int, long, double, boolean, char, byte, short, float) are stored directly on the stack. When passed to a function, the entire value is copied into a new memory slot.
void applyDiscount(int price) {
price = price - (price * 10 / 100);
System.out.println("Discounted: " + price); // 90_000
}
int originalPrice = 100_000;
applyDiscount(originalPrice);
System.out.println(originalPrice); // 100_000, unchanged
A real bug: the developer expects the value to be modified through the parameter instead of through a return value.
int price = 100_000;
applyDiscount(price); // assumes price is now 90_000
processPayment(price); // still 100_000, payment charged at the wrong amount
Trade-off: Pass by value for primitives is safe and predictable, but it forces every computed result to go through a return value. Java has no output parameters for primitives. If a function needs to return multiple primitive values, they must be wrapped in an object or placed in an array.
Objects: Copy the Address, Not the Data
An object variable on the stack does not contain the object. It contains a memory address pointing to the object on the heap. When passed to a function, that address is copied, not the object.
Result: two variables point to the same region of memory. Modify the object through one variable and the other sees it immediately.
class Order {
String status;
double total;
Order(double total) {
this.status = "PENDING";
this.total = total;
}
}
void processPayment(Order order) {
// process payment gateway...
order.status = "PAID"; // modifies the original object directly
}
Order order = new Order(250_000);
processPayment(order);
System.out.println(order.status); // "PAID"
Stack Heap
┌──────────────────┐ ┌───────────────────────────┐
│ order = 0x4F2A │──►│ Order { status="PENDING", │
└──────────────────┘ │ total=250000 } │
┌──────────────────┐ └───────────────────────────┘
│ order = 0x4F2A │──► (same object)
└──────────────────┘
[inside processPayment]
processPayment returns nothing but still changes order.status. Both variables point to the same object on the heap, so mutations from inside the function are immediately visible from outside.
A real bug: unintended mutation across multiple processing layers
This dangerous pattern appears when an object is passed through multiple services with no clear ownership over who is allowed to modify it.
// ProductService.java
public List<Product> getRecommendations(User user) {
List<Product> catalog = productRepository.findAll();
filterByPreference(user, catalog);
return catalog;
}
// RecommendationEngine.java
private void filterByPreference(User user, List<Product> products) {
products.removeIf(p -> !user.getPreferences().contains(p.getCategory()));
}
filterByPreference receives a reference to catalog and calls removeIf directly on it. If productRepository.findAll() returns an internal list instead of a fresh copy (a common bug in in-memory repositories), or if there is caching at a higher layer, catalog is permanently modified. The next call returns a filtered list, not the full catalog.
This bug throws no exception and produces no stack trace. Behavior simply drifts wrong over time.
Trade-off: Sharing references between functions is memory-efficient (no need to copy large objects), but it removes clarity about who owns and who is allowed to modify the object. In large systems, the absence of clear ownership conventions leads to exactly this class of bug.
final Parameter: What It Actually Protects
final on a parameter only prevents reassignment of that parameter variable itself. It does not make the object immutable and does not prevent mutation.
void applyVoucher(final Order order, final String code) {
order = new Order(); // compile error
order.setDiscount(0.2); // perfectly legal
order.getItems().clear(); // also legal
}
Why final exists here: To prevent a specific programmer error: accidentally reassigning the parameter instead of creating a new variable, then wondering why the change had no effect. final catches this at compile time. Some teams use final on all parameters as a coding convention.
A real bug: trusting final to protect the object
// A reviewer sees `final` and assumes order cannot be modified
void auditOrder(final Order order) {
logger.info("Auditing order {}", order.getId());
enrichWithMetadata(order); // this function calls order.setAuditedAt(now())
// order has been mutated, but the caller does not know
}
The reviewer sees final and concludes the function is read-only. In reality, enrichWithMetadata can still modify the object. final provides no guarantee whatsoever about the object’s contents.
To truly prevent mutation, you need an immutable object or a defensive copy, not final.
Defensive Copy: When to Use It and What It Costs
A defensive copy is the technique of creating a copy of a mutable object before storing it in a field or returning it to a caller, so that caller and internal state do not share the same reference.
When receiving in a constructor:
// Unsafe
class ReportConfig {
private final Date generatedAt;
ReportConfig(Date generatedAt) {
this.generatedAt = generatedAt; // stores the reference directly
}
}
Date ts = new Date();
ReportConfig config = new ReportConfig(ts);
ts.setTime(0); // changes generatedAt inside config
The caller still holds a reference to ts. After creating ReportConfig, they can modify ts and corrupt the internal state.
// Safe with a defensive copy
ReportConfig(Date generatedAt) {
this.generatedAt = new Date(generatedAt.getTime());
}
When returning through a getter:
class ProductCatalog {
private final List<Product> products = new ArrayList<>();
// Dangerous: caller can call catalog.getProducts().clear() and corrupt the catalog
public List<Product> getProducts() {
return products;
}
// Safe: return an unmodifiable view
public List<Product> getProducts() {
return Collections.unmodifiableList(products);
}
}
Trade-off: Defensive copies have a real allocation cost. On a hot path or with large objects (deep copies of nested structures), creating a copy on every call adds meaningful GC pressure.
Prefer using immutable objects from the start instead of defensive copies:
// Java 16+: record is immutable by default
record DateRange(LocalDate start, LocalDate end) {}
// LocalDate is already immutable, no defensive copy needed
DateRange range = new DateRange(LocalDate.now(), LocalDate.now().plusDays(7));
java.time.LocalDate, LocalDateTime, and Instant are all immutable. Preferring them over java.util.Date is precisely how you avoid the defensive copy problem in the first place.
Summary
| Primitive | Object | |
|---|---|---|
| What is passed | The actual value | A copy of the address (reference) |
| Modify a field through the parameter | Not possible | Possible; caller sees it immediately |
| Reassign the parameter | Does not affect the caller | Does not affect the caller |
final prevents | Reassignment | Only reassignment; mutation still happens |
Mental model: Java copies everything. For primitives, it copies the value. For objects, it copies the address. The address is a number on the stack; the object is the actual data on the heap. Once this is clear, every behavior becomes predictable.