Bộ nhớ/Địa chỉ bộ nhớ và con trỏ — mọi biến sống ở đâu
2/13
Bài 2 / 13~16 phútMô hình bộ nhớ chương trìnhMiễn phí lượt xem

Địa chỉ bộ nhớ và con trỏ — mọi biến sống ở đâu

Địa chỉ là số thứ tự của ô nhớ; con trỏ chỉ là biến lưu địa chỉ. Hiểu điều này giải thích tham chiếu, NullPointerException và pass-by-value trong Java.

TL;DR: Bộ nhớ chính (RAM) là một dãy dài các ô 1 byte, mỗi ô có một địa chỉ — số thứ tự duy nhất, bắt đầu từ 0. Mọi biến trong chương trình sống tại một địa chỉ nào đó. Một con trỏ (pointer) không có gì huyền bí: nó là một biến mà giá trị lại là một địa chỉ — nó "trỏ tới" ô nhớ khác. Java và Python giấu con trỏ sau khái niệm tham chiếu (reference), nhưng cơ chế bên dưới giống hệt: biến đối tượng lưu địa chỉ của object trên heap. Hiểu địa chỉ và con trỏ là chìa khoá để hiểu NullPointerException, pass-by-value vs pass-by-reference, và vì sao hai biến có thể cùng sửa một object.

Bạn viết String a = name; a = a.toUpperCase(); và ngạc nhiên vì name không đổi. Bạn viết list2 = list1; list2.add(x); và ngạc nhiên vì list1 cũng có thêm x. Hai tình huống trái ngược này không mâu thuẫn — chúng là hệ quả trực tiếp của việc biến lưu cái gì: giá trị trực tiếp hay một địa chỉ.

Bài này giải thích bộ nhớ được đánh địa chỉ thế nào, con trỏ là gì, vì sao ngôn ngữ "không có con trỏ" vẫn dùng con trỏ ở bên dưới, và những quyết định code rút ra từ đó.

1. Analogy — dãy hộp thư có đánh số

Tưởng tượng một bức tường gồm hàng triệu hộp thư, mỗi hộp chứa đúng 1 byte và được dán một số thứ tự: hộp 0, hộp 1, hộp 2, ... cho tới hết. Số thứ tự đó là địa chỉ. Nội dung bên trong hộp là giá trị.

Khi bạn khai báo int x = 42, hệ thống chọn một số hộp liên tiếp (4 hộp cho một int 32-bit), ghi giá trị 42 vào đó, và ghi nhớ rằng tên x ứng với địa chỉ hộp đầu tiên. Một con trỏ tới x là một tờ giấy nhỏ ghi số hộp của x — không phải giá trị 42, mà là vị trí.

Dãy hộp thưBộ nhớ máy tính
Một hộp 1 byteMột ô nhớ (1 byte)
Số dán trên hộpĐịa chỉ (address)
Nội dung trong hộpGiá trị (value)
Tờ giấy ghi số hộpCon trỏ / tham chiếu
"Mở hộp số ghi trên giấy"Dereference (*ptr)
Hộp số 0 đặc biệt "không có hộp"Con trỏ null
💡 Cách nhớ

Giá trị là cái gì ở trong hộp. Địa chỉ là hộp nào. Con trỏ là một biến mà giá trị của nó tình cờ lại là một địa chỉ — một tờ giấy ghi "hãy xem hộp số 5000".

2. Cơ chế — bộ nhớ được đánh địa chỉ theo byte

CPU nhìn bộ nhớ như một mảng phẳng khổng lồ, đánh chỉ số theo byte (byte-addressable). Trên máy 64-bit, địa chỉ là số 64-bit, nên về lý thuyết CPU có thể đánh số tới 2^64 byte (16 exabyte) — thực tế OS chỉ dùng một phần.

Mỗi biến chiếm một hoặc nhiều byte liên tiếp, bắt đầu tại một địa chỉ:

Dia chi:  1000  1001  1002  1003  1004  1005  1006  1007
         +-----+-----+-----+-----+-----+-----+-----+-----+
Noi dung |  2A |  00 |  00 |  00 |  ... |    |     |     |
         +-----+-----+-----+-----+-----+-----+-----+-----+
          \_______________________/
           int x = 42 (0x0000002A), 4 byte tu dia chi 1000

Sơ đồ trên dùng quy ước little-endian của x86: byte thấp nhất (0x2A) nằm ở địa chỉ thấp nhất (1000). Nếu bạn thắc mắc vì sao 42 lại nằm ở byte đầu thay vì byte cuối, đó là byte order — Module 1 Course 1 (bài endianness) giải thích kỹ nếu cần ôn.

Biến x "ở địa chỉ 1000" nghĩa là 4 byte của nó bắt đầu tại hộp số 1000. Khi CPU cần đọc x, nó nói với bộ nhớ: "đưa tôi 4 byte bắt đầu từ 1000".

Một con trỏ tới x là một biến khác, lưu chính con số 1000:

int x = 42;        // x nam tai dia chi nao do, vi du 1000
int *p = &x;       // p luu gia tri 1000 (dia chi cua x)
                   // &x doc la "address-of x"
int y = *p;        // *p doc la "value-at p" -> doc hop 1000 -> 42
*p = 99;           // ghi 99 vao hop 1000 -> x bay gio la 99!

Hai phép toán cốt lõi:

  • &x (address-of): lấy địa chỉ của biến x. Trả về một con trỏ.
  • *p (dereference): đi tới địa chỉ mà p lưu, đọc (hoặc ghi) giá trị ở đó.

Bản thân con trỏ p cũng là một biến nằm trong bộ nhớ, cũng có địa chỉ riêng. Bạn có thể có con trỏ trỏ tới con trỏ (int **pp = &p) — vẫn cùng một cơ chế, chỉ thêm một tầng.

flowchart LR
  P["p (tai dia chi 2000)<br/>gia tri = 1000"] -->|tro toi| X["x (tai dia chi 1000)<br/>gia tri = 42"]

3. Java và Python — con trỏ ẩn dưới tên "tham chiếu"

Java và Python quảng cáo "không có con trỏ". Điều đó chỉ đúng một nửa: bạn không làm số học con trỏ (cộng/trừ địa chỉ) và không truy cập địa chỉ thô, nhưng biến đối tượng vẫn lưu địa chỉ — gọi là tham chiếu (reference).

Phân biệt cốt lõi: biến kiểu nguyên thuỷ (primitive) lưu giá trị trực tiếp; biến kiểu đối tượng lưu một tham chiếu (địa chỉ tới object trên heap).

int a = 42;            // a luu truc tiep gia tri 42 (tren stack)
int b = a;             // copy gia tri: b = 42, doc lap voi a

int[] arr1 = {1, 2, 3}; // arr1 luu DIA CHI cua mang tren heap
int[] arr2 = arr1;      // copy DIA CHI: arr2 tro cung mang voi arr1
arr2[0] = 99;           // sua qua arr2 -> arr1[0] cung thanh 99!

Ở dòng int b = a, Java copy giá trị 42 — ab độc lập. Ở dòng int[] arr2 = arr1, Java copy tham chiếu (địa chỉ) — cả hai biến cùng trỏ tới một mảng duy nhất trên heap, nên sửa qua biến này thấy ở biến kia.

flowchart LR
  subgraph stack["Stack"]
    A1["arr1"]
    A2["arr2"]
  end
  subgraph heap["Heap"]
    M["[99, 2, 3]"]
  end
  A1 -->|tro toi| M
  A2 -->|tro toi| M

Đây cũng là lý do null tồn tại: một tham chiếu chưa trỏ tới object nào lưu giá trị đặc biệt "không địa chỉ" (thường là 0). Khi bạn obj.method() trên một tham chiếu null, CPU được yêu cầu đi tới địa chỉ 0 và đọc — bị chặn, sinh NullPointerException (Java) hoặc AttributeError: NoneType (Python).

Pass-by-value, luôn luôn

Java và Python luôn pass-by-value — nhưng "value" của biến đối tượng là một tham chiếu. Khi bạn truyền một List vào hàm, Java copy tham chiếu (địa chỉ) vào tham số. Hàm có thể sửa nội dung list (cùng object trên heap) nhưng không thể làm biến gốc trỏ sang list khác. Nhầm lẫn "pass-by-reference" thực ra là "pass một bản copy của reference".

4. Áp dụng vào code của bạn

Hiểu biến-lưu-địa-chỉ giúp bạn tránh hai lớp bug phổ biến: chia sẻ ngoài ý muốn và sửa tham số.

Bug 1 — chia sẻ ngoài ý muốn (aliasing). Gán một collection cho biến khác không tạo bản sao:

// SAI: tuong da copy, thuc ra hai bien cung tro mot list
List<String> original = new ArrayList<>(List.of("a", "b"));
List<String> backup = original;     // copy dia chi, KHONG copy noi dung
original.clear();                   // backup cung rong!

// DUNG: copy noi dung neu can ban doc lap
List<String> backup2 = new ArrayList<>(original); // defensive copy

Quy tắc: khi bạn muốn một bản sao độc lập, tạo object mới (new ArrayList<>(src), list.copyOf(), arr.clone()), đừng chỉ gán biến.

Bug 2 — tưởng sửa tham số ảnh hưởng biến gốc. Gán lại tham số bên trong hàm không đổi biến ở nơi gọi:

void rename(String name) {
    name = name.toUpperCase();   // chi doi tham chieu cuc bo "name"
}                                // bien goc o noi goi KHONG doi

void addItem(List<String> items) {
    items.add("new");            // sua NOI DUNG object chung -> THAY o noi goi
}

Phân biệt: gán lại tham số (name = ...) chỉ đổi con trỏ cục bộ; sửa nội dung object qua tham chiếu (items.add) thì thấy ở mọi nơi cùng trỏ tới nó.

Quy tắc null-safety. Vì tham chiếu có thể null, mọi dereference (obj.field, obj.method()) là một điểm có thể nổ NullPointerException. Dùng Optional, kiểm tra null sớm, hoặc annotation @Nullable/@NonNull để biên dịch viên cảnh báo.

5. Đào sâu (tuỳ chọn)

📚 Đào sâu (tuỳ chọn)

Số học con trỏ và mảng trong C: trong C, arr[i] thực chất là *(arr + i) — tên mảng là địa chỉ phần tử đầu, + i cộng i * sizeof(phần tử) byte. Đây là lý do mảng C đánh chỉ số từ 0: phần tử đầu nằm ngay tại địa chỉ gốc, offset 0. Số học con trỏ mạnh nhưng nguy hiểm — arr[1000000] không kiểm tra biên, đọc bừa bộ nhớ ngoài mảng (undefined behavior, gốc của nhiều lỗ hổng bảo mật như buffer overflow).

Kích thước con trỏ: trên máy 64-bit, mọi con trỏ là 8 byte bất kể nó trỏ tới char hay struct lớn — vì nó chỉ lưu một địa chỉ. Đây là lý do một mảng con trỏ tới object nhỏ tốn thêm bộ nhớ đáng kể so với lưu object trực tiếp (liên quan tới AoS vs SoA ở Module 2).

Con trỏ trong JVM — compressed oops: JVM 64-bit mặc định nén tham chiếu object xuống 32-bit (compressed ordinary object pointers) cho heap dưới 32 GB, tiết kiệm bộ nhớ và tăng hiệu quả cache. Đây là một tối ưu liên quan trực tiếp tới cách JVM bố trí object — bạn sẽ gặp lại khi học GC ở java-internals.

6. Liên hệ các bài khác

7. Tóm tắt

  • Bộ nhớ là dãy ô 1 byte, mỗi ô có một địa chỉ (số thứ tự từ 0). Mọi biến sống tại một địa chỉ.
  • Con trỏ là biến mà giá trị là một địa chỉ — nó "trỏ tới" ô nhớ khác. &x lấy địa chỉ, *p đi tới địa chỉ đó để đọc/ghi.
  • Java và Python không cho làm số học con trỏ, nhưng biến đối tượng vẫn lưu địa chỉ dưới tên tham chiếu. Primitive lưu giá trị trực tiếp; object lưu reference.
  • Gán biến đối tượng copy địa chỉ, không copy nội dung → hai biến cùng sửa một object (aliasing). Muốn bản sao độc lập phải tạo object mới.
  • null là tham chiếu "không địa chỉ"; dereference null sinh NullPointerException.
  • Java/Python luôn pass-by-value, nhưng value của biến đối tượng là một tham chiếu — nên hàm sửa được nội dung object chung, không đổi được biến gốc trỏ đi đâu.

8. Tự kiểm tra

Tự kiểm tra
Q1
Phân biệt 'giá trị', 'địa chỉ' và 'con trỏ'. Cho ví dụ một biến đóng cả ba vai trò khác nhau.
Giá trị là nội dung lưu trong ô nhớ (ví dụ số 42). Địa chỉ là số thứ tự của ô nhớ (ví dụ ô 1000). Con trỏ là một biến mà giá trị của nó lại là một địa chỉ. Ví dụ: int x = 42 — biến x có giá trị 42, nằm tại địa chỉ (giả sử) 1000. int *p = &x — biến p có giá trị 1000 (địa chỉ của x), bản thân p lại nằm tại một địa chỉ khác (giả sử 2000). Vậy cùng con số 1000 vừa là địa chỉ của x vừa là giá trị của p.
Q2
Trong Java, vì sao int b = a tạo bản độc lập nhưng int[] arr2 = arr1 lại khiến hai biến chia sẻ dữ liệu?
int là kiểu nguyên thuỷ — biến lưu trực tiếp giá trị. int b = a copy con số 42, sau đó ab không liên quan. int[] là kiểu đối tượng — biến lưu tham chiếu (địa chỉ của mảng trên heap). int[] arr2 = arr1 copy địa chỉ, nên cả hai biến trỏ tới cùng một mảng duy nhất. Sửa arr2[0] chính là sửa mảng đó, và arr1 nhìn thấy thay đổi vì nó trỏ cùng chỗ. Khác biệt nằm ở biến lưu cái gì: giá trị trực tiếp hay một địa chỉ.
Q3
Vì sao gọi method trên một biến null lại sinh NullPointerException? Giải thích bằng cơ chế địa chỉ.
Một tham chiếu null lưu giá trị đặc biệt "không trỏ tới đâu" (thường là địa chỉ 0). Khi bạn viết obj.method(), CPU phải dereference tham chiếu — đi tới địa chỉ mà obj lưu để tìm object và bảng method. Với null, địa chỉ đó là 0, một vùng OS cố tình để trống và chặn truy cập. Runtime phát hiện dereference null trước khi chạm phần cứng và ném NullPointerException thay vì để chương trình đọc bừa. Bản chất: bạn yêu cầu "mở hộp tại địa chỉ ghi trên tờ giấy", nhưng tờ giấy ghi "không có hộp nào".
Q4
Một đồng nghiệp viết void rename(String s) { s = s.toUpperCase(); } và phàn nàn biến truyền vào không đổi. Giải thích vì sao, và vì sao items.add(...) trong hàm khác lại đổi được.
Java luôn pass-by-value: tham số s nhận một bản copy của tham chiếu. Dòng s = s.toUpperCase() làm bản copy cục bộ s trỏ sang một String mới — nhưng biến ở nơi gọi vẫn trỏ String cũ, không đổi. Ngược lại, items.add("new") không gán lại tham số; nó dereference tham chiếu để đi tới object List chung trên heap rồi sửa nội dung object đó. Vì biến ở nơi gọi trỏ cùng object, nó thấy phần tử mới. Quy tắc: gán lại tham số chỉ đổi con trỏ cục bộ; sửa nội dung qua tham chiếu thì ảnh hưởng mọi biến cùng trỏ tới.
Q5
Bạn cần lưu một bản 'backup' của một List để so sánh sau khi xử lý. Vì sao List backup = original là sai, và sửa thế nào?
List backup = original chỉ copy tham chiếubackuporiginal trỏ cùng một List. Mọi thay đổi lên original (thêm, xoá, sửa phần tử) lập tức thấy ở backup, nên "backup" hoàn toàn vô dụng để so sánh. Cần một bản sao độc lập: List<String> backup = new ArrayList<>(original) tạo một List mới chứa cùng phần tử. Lưu ý đây là shallow copy — các phần tử bên trong vẫn là tham chiếu chung; nếu phần tử là object mutable và bạn sửa chính chúng, cần deep copy.
Q6
Trên máy 64-bit, một con trỏ trỏ tới một char (1 byte) và một con trỏ trỏ tới một struct 1 KB — cái nào lớn hơn? Vì sao?
Cả hai con trỏ có cùng kích thước: 8 byte (64-bit). Con trỏ chỉ lưu một địa chỉ — số thứ tự của ô nhớ đầu tiên của thứ nó trỏ tới — chứ không lưu nội dung. Địa chỉ trên máy 64-bit luôn là số 64-bit bất kể nó trỏ tới 1 byte hay 1 KB. Kích thước của thứ được trỏ tới khác nhau, nhưng kích thước của con trỏ thì cố định. Đây là lý do một mảng con trỏ tới nhiều object nhỏ có thể tốn nhiều bộ nhớ chỉ cho riêng các con trỏ — một chi tiết quan trọng khi cân nhắc bố cục dữ liệu (Module 2).

Bài tiếp theo: Stack và stack frame

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên