Java: Pass by Value hay Pass by Reference?
Table of contents
Java chỉ có pass by value. Đây là một trong những nguyên nhân gây ra bug trong Java: developer hiểu sai rằng truyền một object vào hàm là “an toàn”, không ai ngoài hàm đó có thể thay đổi dữ liệu gốc. Bài viết này giải thích cơ chế từ góc độ thiết kế ngôn ngữ, chỉ ra chính xác bug gì xảy ra khi bạn nhầm, và đưa ra các pattern thực tế để tránh chúng.
Tại sao Java không có pass by reference thực sự?
Đây là quyết định thiết kế có chủ đích, không phải giới hạn kỹ thuật.
Trong C++, pass by reference cho phép một hàm thay đổi trực tiếp biến của caller:
void reassign(Dog& d) {
d = Dog("Buddy"); // biến d bên ngoài bị thay đổi hoàn toàn
}
Không có gì trong call site cho thấy biến d sẽ bị thay thế. Khi call stack đủ sâu, điều này trở thành hành vi không thể đoán trước: một hàm ba lớp bên dưới có thể âm thầm gán lại biến ở lớp trên cùng.
Java loại bỏ điều đó để đổi lấy một đảm bảo quan trọng hơn: một hàm không thể làm cho biến cục bộ của caller trỏ sang chỗ khác. Side effect chỉ xảy ra theo một cách duy nhất, có thể kiểm soát được: thông qua mutation của object trên heap.
Điều đó không loại bỏ side effect. Nó chỉ giới hạn cách side effect xảy ra, giúp bạn lý luận về code dễ hơn.
Primitive: Stack frame hoàn toàn độc lập
Primitive types (int, long, double, boolean, char, byte, short, float) được lưu trực tiếp trên stack. Khi truyền vào hàm, toàn bộ giá trị được copy sang một ô nhớ mới.
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, không đổi
Bug thực tế: developer kỳ vọng giá trị được sửa qua tham số thay vì qua return value.
int price = 100_000;
applyDiscount(price); // nghĩ rằng price giờ là 90_000
processPayment(price); // vẫn là 100_000, thanh toán sai giá
Trade-off: primitive pass by value an toàn và không có surprise, nhưng nó buộc mọi kết quả tính toán phải qua return value. Java không có output parameter cho primitive. Nếu một hàm cần trả về nhiều giá trị primitive, bạn phải đóng gói chúng trong một object hoặc dùng array.
Object: Copy địa chỉ, không copy dữ liệu
Biến object trên stack không chứa object. Nó chứa một địa chỉ bộ nhớ trỏ đến object trên heap. Khi truyền vào hàm, địa chỉ đó được copy, không phải object.
Kết quả: hai biến cùng trỏ đến một vùng nhớ. Sửa object qua một biến, biến kia thấy ngay.
class Order {
String status;
double total;
Order(double total) {
this.status = "PENDING";
this.total = total;
}
}
void processPayment(Order order) {
// xử lý payment gateway...
order.status = "PAID"; // sửa trực tiếp object gốc
}
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 │──► (cùng object)
└──────────────────┘
[trong processPayment]
processPayment không trả về gì nhưng vẫn thay đổi được order.status. Cả hai biến đều trỏ đến cùng một object trên heap, nên mutation từ bên trong hàm được thấy ngay từ bên ngoài.
Bug thực tế: unintended mutation qua nhiều lớp xử lý
Pattern nguy hiểm này xuất hiện khi một object được truyền qua nhiều service mà không ai kiểm soát ai được phép sửa nó.
// 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 nhận reference đến catalog và gọi removeIf trực tiếp trên nó. Nếu productRepository.findAll() trả về internal list thay vì bản sao mới (một lỗi phổ biến trong in-memory repository), hoặc nếu có caching ở tầng trên, catalog thực tế đã bị sửa vĩnh viễn. Lần gọi tiếp theo nhận về danh sách đã bị filter, không phải toàn bộ catalog.
Bug này không throw exception và không có stack trace. Behavior chỉ sai dần theo thời gian.
Trade-off: Sharing reference giữa các hàm hiệu quả về bộ nhớ (không cần copy object lớn), nhưng mất đi sự rõ ràng về ai sở hữu và ai được phép sửa object đó. Trong hệ thống lớn, thiếu quy ước rõ ràng về ownership dẫn đến loại bug này.
final Parameter: Bảo vệ được Gì?
final trên tham số chỉ ngăn reassignment của chính biến tham số đó. Nó không làm object trở thành immutable và không ngăn mutation.
void applyVoucher(final Order order, final String code) {
order = new Order(); // compile error
order.setDiscount(0.2); // hoàn toàn hợp lệ
order.getItems().clear(); // cũng hợp lệ
}
Tại sao final tồn tại ở đây? Để ngăn một lớp lỗi lập trình cụ thể: developer vô tình reassign tham số thay vì tạo biến mới, rồi tự hỏi tại sao thay đổi không có tác dụng. final bắt lỗi này ở compile time. Một số team dùng final trên tất cả tham số như một coding convention.
Bug thực tế: tin tưởng final để bảo vệ object
// Code reviewer thấy `final`, nghĩ order không thể bị sửa
void auditOrder(final Order order) {
logger.info("Auditing order {}", order.getId());
enrichWithMetadata(order); // hàm này gọi order.setAuditedAt(now())
// order đã bị mutate, caller không biết
}
Reviewer thấy final và cho rằng hàm chỉ đọc. Thực tế enrichWithMetadata vẫn sửa được object. final không cung cấp bất kỳ bảo đảm nào về nội dung object.
Nếu muốn ngăn mutation thực sự, bạn cần immutable object hoặc defensive copy, không phải final.
Defensive Copy: Khi nào cần, Chi phí là gì?
Defensive copy là kỹ thuật tạo bản sao của một mutable object trước khi lưu vào field hoặc trả ra ngoài, để caller và internal state không share cùng reference.
Khi nhận vào constructor:
// Không an toàn
class ReportConfig {
private final Date generatedAt;
ReportConfig(Date generatedAt) {
this.generatedAt = generatedAt; // lưu reference trực tiếp
}
}
Date ts = new Date();
ReportConfig config = new ReportConfig(ts);
ts.setTime(0); // thay đổi được generatedAt bên trong config
Caller vẫn giữ reference đến ts. Sau khi tạo ReportConfig, họ có thể sửa ts và làm hỏng state bên trong.
// An toàn với defensive copy
ReportConfig(Date generatedAt) {
this.generatedAt = new Date(generatedAt.getTime());
}
Khi trả ra qua getter:
class ProductCatalog {
private final List<Product> products = new ArrayList<>();
// Nguy hiểm: caller gọi catalog.getProducts().clear() làm hỏng cả catalog
public List<Product> getProducts() {
return products;
}
// An toàn: trả về view bất biến
public List<Product> getProducts() {
return Collections.unmodifiableList(products);
}
}
Trade-off: Defensive copy có chi phí allocation thực sự. Trên hot path hoặc với object lớn (deep copy của nested structure), tạo bản sao mỗi lần tạo áp lực GC đáng kể.
Thay vì defensive copy, ưu tiên dùng immutable object ngay từ đầu:
// Java 16+: record là immutable by default
record DateRange(LocalDate start, LocalDate end) {}
// LocalDate đã là immutable, không cần defensive copy
DateRange range = new DateRange(LocalDate.now(), LocalDate.now().plusDays(7));
java.time.LocalDate, LocalDateTime, Instant đều là immutable. Ưu tiên dùng chúng thay vì java.util.Date cũng chính là để tránh bài toán defensive copy này.
Tổng kết
| Primitive | Object | |
|---|---|---|
| Thứ được truyền | Giá trị thực | Bản sao của địa chỉ (reference) |
| Sửa field qua tham số | Không thể | Có thể, caller thấy ngay |
| Reassign tham số | Không ảnh hưởng caller | Không ảnh hưởng caller |
final ngăn được | Reassign | Chỉ reassign, mutation vẫn xảy ra |
Mental model: Java copy everything. Với primitive, copy giá trị. Với object, copy địa chỉ. Địa chỉ là một con số trên stack, object là dữ liệu thực trên heap. Khi bạn nắm rõ điều đó, mọi behavior đều trở nên có thể dự đoán được.