Bộ nhớ
2/26
Bài 2 / 26~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ỉ:

Plain Text
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:

C
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.

Bốn ô nhớ tại địa chỉ 1000 tới 1003 chứa các byte 2A, 00, 00, 00 là biến int x bằng 42 theo little-endian; bên dưới, ô tại địa chỉ 2000 là con trỏ p và giá trị bên trong nó chính là số 1000, kèm mũi tên chỉ lên ô 1000 — đọc sao p cho 42, ghi sao p bằng 99 làm x thành 99

Thử ngẫmbạn debug hàm C nhận int **pp. Cần bao nhiêu lần dereference để chạm số nguyên thật, và vì sao chữ ký đó khác hẳn nhận thẳng int *p?

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).

Java
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.

Hai phép gán đặt cạnh nhau: int b bằng a copy thẳng giá trị 42 nên sửa b không đụng tới a; còn mảng arr2 bằng arr1 chỉ copy địa chỉ nên cả hai biến cùng trỏ tới một mảng duy nhất trên heap, đổi arr2 phần tử đầu thành 99 làm arr1 phần tử đầu cũng thành 99

Đâ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".

Thử ngẫmmột hàm Java nhận items (kiểu List) làm tham số rồi gọi items.clear() ngay dòng đầu. Cái gì bị xoá — bản copy cục bộ hay dữ liệu nơi gọi đang giữ?

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:

Java
// 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:

Java
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 cơ chế 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
0/6 câu đã trả lời
  1. 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.
  2. 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?
  3. 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ỉ.
  4. 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.
  5. 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?
  6. 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?

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

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Bài tiếp theo

Stack và stack frame — gọi hàm sống và chết thế nào