Trang chủ » Blog » và ẢnBản Chất Bộ Nhớ Java: Giải Mã Cơ Chế “Strictly Pass-by-Value”h Hưởng Tới Object Mutation

và ẢnBản Chất Bộ Nhớ Java: Giải Mã Cơ Chế “Strictly Pass-by-Value”h Hưởng Tới Object Mutation

| Blog

Trong lập trình Java, một trong những chủ đề gây tranh cãi và dễ gây nhầm lẫn nhất chính là cơ chế truyền tham số (parameter passing mechanism). Nhiều lập trình viên thường lầm tưởng rằng Java truyền đối tượng bằng tham chiếu (pass-by-reference). Tuy nhiên, trên thực tế, Java hoàn toàn và luôn luôn là truyền bằng giá trị (strictly pass-by-value).

Để hiểu rõ tại sao lại như vậy, chúng ta cần phải bóc tách kiến trúc phân bổ bộ nhớ logic của JVM (Java Virtual Machine) và cách các biến tương tác giữa vùng nhớ Stack và Heap. Bài viết chuyên môn này sẽ phân tích chi tiết cơ chế này, tác động của nó đến việc thay đổi trạng thái đối tượng (mutation) và cấu trúc bộ nhớ dưới góc nhìn của Java Memory Model (JMM).

1. Phân Biệt Lý Thuyết: Pass-by-Value vs. Pass-by-Reference

Để tránh các hiểu lầm về thuật ngữ, trước hết hãy định nghĩa rõ ràng hai cơ chế truyền tham số phổ biến nhất trong khoa học máy tính:

  • Pass-by-Value (Truyền bằng giá trị): Phương thức gọi (caller) và phương thức được gọi (callee) hoạt động trên hai biến khác nhau, vốn là các bản sao (clones) độc lập của nhau. Bất kỳ sự thay đổi nào đối với biến bản sao ở callee đều không gây ảnh hưởng đến biến gốc ở caller.
  • Pass-by-Reference (Truyền bằng tham chiếu): Cả phương thức gọi và phương thức được gọi cùng hoạt động trên một đối tượng/vùng nhớ duy nhất. Định danh độc nhất (unique identifier) hoặc địa chỉ trực tiếp của đối tượng gốc được gửi tới phương thức. Do đó, bất kỳ sự thay đổi nào trên tham số trong phương thức callee đều lập tức tác động và thay đổi giá trị gốc ở caller.

2. Kiến Trúc Bộ Nhớ JVM: Nền Tảng Của Tham Số

Để hiểu cách Java truyền tham số, ta phải làm rõ cách JVM phân bổ dữ liệu trên hai phân vùng logic quan trọng: Thread Stack và Heap.

bản chất bộ nhớ java 1

2.1 Thread Stack (Ngăn xếp luồng)

Mỗi luồng (thread) chạy trong JVM có một không gian ngăn xếp riêng gọi là Thread Stack (hoặc call stack). Thread Stack chứa thông tin về các phương thức mà luồng đó đang thực thi.

  • Tính cô lập (Isolation): Thread Stack hoàn toàn riêng tư; một luồng không thể truy cập trực tiếp vào Stack của luồng khác. Các biến cục bộ được tạo ra trong một luồng sẽ hoàn toàn ẩn đối với các luồng khác.
  • Dữ liệu lưu trữ:
    • Kiểu dữ liệu nguyên thủy (Primitive Types): Tám kiểu nguyên thủy (boolean, byte, short, char, int, long, float, double) được lưu trữ toàn bộ và trực tiếp trên Thread Stack.
    • Biến tham chiếu đối tượng (Object Reference Variables): Bản thân giá trị dữ liệu của đối tượng không nằm ở đây, mà chỉ có biến tham chiếu (chứa địa chỉ bộ nhớ trỏ đến Heap) là được lưu trữ trên Thread Stack.

2.2 Heap Space (Vùng nhớ Heap)

Vùng nhớ Heap là vùng nhớ dùng chung (global) cho toàn bộ ứng dụng Java, có thể được truy cập bởi tất cả các luồng nếu chúng có tham chiếu hợp lệ.

  • Dữ liệu lưu trữ: Tất cả các đối tượng được khởi tạo thông qua từ khóa new (bao gồm cả các đối tượng bao bọc kiểu nguyên thủy như Integer, Long hay các Class do người dùng định nghĩa) đều được cấp phát động trên Heap.
  • Biến thành viên (Member Variables): Các biến thành viên của đối tượng (dù là kiểu nguyên thủy hay kiểu tham chiếu) đều được lưu trữ trực tiếp trên Heap cùng với chính đối tượng đó.

3. Tại Sao Java Luôn Luôn Là “Pass-by-Value”?

Bản chất của mọi tham số trong Java đều tuân theo quy tắc: Khi gọi một phương thức, JVM sẽ tạo ra một bản sao (copy) của biến đối số trong bộ nhớ Stack và truyền bản sao này vào phương thức được gọi.

Điểm mấu chốt tạo nên sự khác biệt giữa Kiểu nguyên thủy và Kiểu đối tượng nằm ở giá trị (value) thực sự được lưu trên Stack là gì.

3.1. Đối Với Kiểu Dữ Liệu Nguyên Thủy (Primitive Types)

Với các kiểu dữ liệu nguyên thủy, “giá trị” lưu trữ trực tiếp trên Stack chính là dữ liệu thực tế (ví dụ: số 1, số 2).

Khi truyền biến nguyên thủy vào phương thức, JVM thực hiện:

  1. Sao chép giá trị thực tế của biến gốc.
  2. Cấp phát một ô nhớ mới trên Stack dành cho tham số của phương thức callee và đặt giá trị sao chép vào đó.
  3. Mọi thay đổi bên trong phương thức chỉ tác động trực tiếp lên ô nhớ mới này. Khi phương thức kết thúc, tham số đó bị giải phóng khỏi Stack, biến gốc ở caller hoàn toàn không bị ảnh hưởng.

3.2. Đối Với Kiểu Đối Tượng (Object References)

Đây chính là nguồn cơn của sự nhầm lẫn. Khi chúng ta làm việc với một đối tượng (non-primitive), biến mà chúng ta thao tác trên mã nguồn thực chất không phải là đối tượng, mà là một biến tham chiếu (reference variable) đang nắm giữ địa chỉ vùng nhớ Heap của đối tượng đó.

Khi truyền một đối tượng vào phương thức:

  1. “Giá trị” của biến lúc này chính là địa chỉ bộ nhớ (đại diện cho tham chiếu).
  2. JVM tạo ra một bản sao chính xác của biến tham chiếu này trên Stack.
  3. Bản sao tham chiếu này ( formal parameter) được truyền vào phương thức và vẫn trỏ đến cùng một địa chỉ bộ nhớ của đối tượng trên Heap giống như biến tham chiếu gốc.

Do đó, Java truyền giá trị của tham chiếu (value of reference) chứ không phải truyền bản thân đối tượng bằng tham chiếu.

4. Tác Động Đến Sự Biến Đổi Đối Tượng (Mutation) và Gán Lại (Reassignment)

Hành vi sao chép địa chỉ bộ nhớ trên tạo ra hai kịch bản hoàn toàn khác nhau khi ta thao tác với đối tượng bên trong phương thức. Chúng ta hãy phân tích dựa trên ví dụ kinh điển sau:

public class ParameterPassingDemo {

    public static void main(String[] args) {

        Foo a = new Foo(1);

        Foo b = new Foo(1);

        modify(a, b);

        System.out.println(“a.num = ” + a.num); // Kết quả: 2 (Bị thay đổi/Mutated)

        System.out.println(“b.num = ” + b.num); // Kết quả: 1 (Không thay đổi/Not changed)

    }

    public static void modify(Foo a1, Foo b1) {

        // Kịch bản 1: Mutation (Biến đổi thuộc tính)

        a1.num++;

        // Kịch bản 2: Reassignment (Gán lại tham chiếu mới)

        b1 = new Foo(1);

        b1.num++;

    }

}

class Foo {

    public int num;

    public Foo(int num) {

        this.num = num;

    }

}

4.1 Kịch bản 1: Object Mutation (Biến đổi trạng thái đối tượng)

Ở dòng lệnh a1.num++:

  • Biến a (ở main) và biến a1 (tham số của modify) là hai biến tham chiếu riêng biệt trên Stack, nhưng chúng cùng lưu chung một địa chỉ bộ nhớ và cùng trỏ tới đối tượng Foo(1) đầu tiên trên Heap.
  • Khi ta thực hiện thay đổi thuộc tính num thông qua a1, JVM sẽ tìm đến địa chỉ Heap chung đó và tăng giá trị num lên thành 2.
  • Vì biến a ở phương thức main vẫn trỏ tới đối tượng Heap này, sự thay đổi trạng thái (mutation) được phản ánh trực tiếp và ta nhận được kết quả a.num = 2.

4.2 Kịch bản 2: Reassignment (Gán lại tham chiếu mới)

Ở dòng lệnh b1 = new Foo(1); b1.num++:

  • Ban đầu, b (ở main) và b1 (trong modify) đều trỏ chung đến đối tượng Foo(1) thứ hai trên Heap.
  • Khi lệnh b1 = new Foo(1) được thực thi, JVM sẽ khởi tạo một đối tượng Foo hoàn toàn mới tại một địa chỉ khác trên Heap và gán địa chỉ mới này cho biến tham chiếu bản sao b1.
  • Kể từ thời điểm này, b1 đã bị bẻ hướng sang đối tượng mới. Biến gốc b ở main vẫn giữ nguyên địa chỉ cũ và vẫn tiếp tục trỏ tới đối tượng Foo(1) ban đầu.
  • Lệnh b1.num++ tiếp theo chỉ làm tăng thuộc tính num của đối tượng mới trên Heap (lên 2). Đối tượng ban đầu mà b trỏ tới hoàn toàn giữ nguyên giá trị num = 1.

5. Memory Layout Trong Quá Trình Thực Thi: Stack & Heap Visualized

Để khắc họa rõ nét, hãy xem sơ đồ chuyển đổi trạng thái bộ nhớ dưới đây trong suốt vòng đời của phương thức modify(a, b):

Giai đoạn 1: Trước khi gọi phương thức modify: Hai đối tượng riêng biệt được tạo trên Heap, hai biến tham chiếu nằm trên Stack của luồng (main):

Giai đoạn 2: Khi vừa bước vào phương thức modify(a, b): JVM sao chép hai biến tham chiếu a và b thành a1 và b1 trên Stack, trỏ tới cùng vùng nhớ cũ

Giai đoạn 3: Sau khi thực hiện a1.num++ và gán lại b1 = new Foo(1): a1 thay đổi đối tượng tại địa chỉ 0x100. b1 được hướng sang địa chỉ mới 0x300 trên Heap.

bản chất bộ nhớ java 2

6. Tổng Kết

Hiểu rõ bản chất Pass-by-Value giúp lập trình viên tránh được những lỗi logic nghiêm trọng khi viết code, đặc biệt là khi truyền các cấu trúc dữ liệu phức tạp qua các lớp nghiệp vụ.

  • Java luôn truyền bằng giá trị (Pass-by-Value).
  • Đối với kiểu dữ liệu nguyên thủy, giá trị được truyền là dữ liệu thực tế.
  • Đối với kiểu đối tượng, giá trị được truyền là bản sao của địa chỉ tham chiếu. Điều này giải thích tại sao ta có thể thay đổi trạng thái bên trong của đối tượng (mutation) nhưng không thể thay đổi đối tượng mà biến tham chiếu gốc đang trỏ tới (reassignment).
  • Cơ chế phân tách Stack (cho các biến cục bộ cục bộ, tách biệt giữa các luồng) và Heap (cho các đối tượng chia sẻ chung) là nền tảng cốt lõi định hình nên cơ chế này cũng như toàn bộ mô hình concurrency của Java Memory Model.

Tags:

0 Lời bình

Gửi Lời bình

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *

BÀI VIẾT LIÊN QUAN

BẠN MUỐN HỌC LẬP TRÌNH?

GỌI NGAY

098 953 44 58

Đăng ký tư vấn lộ trình học lập trình

Đăng ký tư vấn, định hướng lộ trình học và giải đáp các thắc mắc về ngành nghề – Miễn phí – Online.

8 + 11 =