Quản lý bộ nhớ: Stack và Heap
Xem thửTình huống mở đầu
Quán Cơm Bác Tám mở cửa giờ cao điểm. Bạn viết một hàm change để điều chỉnh khay món và gọi nó với hai thứ: số khay đang mở (5) và danh sách món trên khay (một mảng ba phần tử). Hàm đặt số khay thành 99 và đổi món đầu tiên thành 99. Về lại nơi gọi, bạn in ra hai giá trị. Mong đợi: cả hai là 99. Thực tế: số khay vẫn là 5, chỉ món đầu tiên đổi thành 99.
Cùng một dòng = 99 trong cùng một hàm, nhưng một dòng có tác dụng ra ngoài, một dòng thì không. Máy đã làm điều gì mà bạn không nhìn thấy? Hãy giữ câu hỏi này trong đầu, mình sẽ trả lời bằng hình vẽ.
Ôn nhanh bài trước: bài trước nói về regex. Muốn đọc nhóm số điện thoại sau khi tìm thấy, bạn phải gọi find() trước hay group(1) trước?
Đáp án: find() trước. Gọi group khi chưa khớp sẽ ném IllegalStateException.
Mục tiêu bài học
Sau bài này, bạn có thể:
- Chỉ ra được một biến cục bộ, một tham số và một đối tượng nằm ở stack hay heap.
- Mô tả được điều máy làm khi gọi và trả về một phương thức: đặt khung lên stack, chép giá trị tham số, gỡ khung.
- Dự đoán được kết quả khi truyền số, mảng và đối tượng vào phương thức rồi sửa chúng.
- Nhận ra và sửa được
StackOverflowErrorvàOutOfMemoryError: Java heap space.
Bài này đo được bằng năm câu trong phần Tự kiểm tra, mỗi câu ghi rõ nó kiểm tra mục tiêu nào.
Hãy xem video dưới đây để có cái nhìn đầu tiên, sau đó mình đi chậm hơn từng bước ở các phần sau.
Bức tranh lớn (Big Picture)
Trước khi vào chi tiết, hãy xem hai vùng nhớ chính của một chương trình Java và quan hệ giữa chúng.
Cách đọc hình: hàng trên đọc từ trái sang phải. Một Thread (luồng chạy chương trình) có một Stack riêng (ô xanh nhạt) và dùng chung một Heap (ô vàng). Hàng dưới là một ví dụ: khung của main (ô xanh dương) ghi cups = 5 ngay trên giấy nhớ, còn trays chỉ là một phiếu có mũi tên tới mảng int[3] (ô lục) nằm trong heap. Mỗi ô đều có nhãn chữ, màu chỉ giúp bạn tìm nhanh.
Xuyên suốt bài, mình dùng một hình ảnh: stack là quầy bếp, mỗi lần gọi phương thức là một khay giấy nhớ mới đặt lên chồng khay. Heap là kho lạnh có kệ đánh số, và đối tượng là món để trên kệ. Biến chứa đối tượng không giữ món, nó giữ phiếu ghi kệ nào. Dự án xuyên suốt là bếp của Quán Cơm Bác Tám: số khay và danh sách món trên khay. Có chỗ hình ảnh này không còn đúng, và mình sẽ chỉ rõ khi tới.
Khái niệm cốt lõi
Bài này có bốn khái niệm. Mỗi khái niệm đi kèm một hình.
Khái niệm 1: stack và khung gọi
Stack là vùng nhớ xếp chồng dành cho các lời gọi phương thức. Mỗi lần gọi, máy đặt lên đỉnh một khung (frame) chứa tham số và biến cục bộ của lời gọi đó. Khi phương thức trả về, khung trên đỉnh bị gỡ. Ở nhà bếp, đó là chồng khay: khay mới đặt lên trên, xong việc thì lấy khay đó đi.
Cách đọc hình: năm ô nằm ngang đi từ main đến sum(0), mỗi mũi tên "calls" là một lời gọi và đặt thêm một khung. Mỗi khung có n riêng: sum(3) có n bằng 3, sum(2) có n bằng 2. Chú thích dưới hình nói chiều ngược lại: khi trả về, mỗi khung bị gỡ và giá trị đi về khung bên trái.
Chương trình đi kèm hình này rất ngắn:
Countdown.java
public class Countdown {
static int sum(int n) {
// Điều kiện dừng: không có nó thì khung chồng mãi
if (n == 0) {
return 0;
}
// Mỗi lời gọi này đặt thêm một khung mới lên stack
return n + sum(n - 1);
}
public static void main(String[] args) {
System.out.println(sum(3));
}
}
Kết quả khi chạy [Đã chạy, JDK 17.0.15]:
6
Hãy xem stack lớn lên rồi nhỏ lại từng bước:
Đang tải Animation…
Bản chữ tương đương của animation:
| Bước | Lời gọi đang chạy | Số khung trên stack | Điều xảy ra |
|---|---|---|---|
| 1 | sum(3) | 2 | main gọi, khung n bằng 3 được đặt lên |
| 2 | sum(2) | 3 | sum(3) gọi tiếp |
| 3 | sum(1) | 4 | sum(2) gọi tiếp |
| 4 | sum(0) | 5 | Điều kiện dừng đúng, sum(0) trả 0 |
| 5 đến 7 | trả dần | 4, 3, 1 | Mỗi khung bị gỡ: 1, 3 rồi 6 được trả về |
Khái niệm 2: heap, new và phiếu
Heap là vùng nhớ chung chứa mọi đối tượng và mảng được tạo bằng new. Một biến kiểu đối tượng hay mảng không chứa món, nó chứa một tham chiếu (reference), tức là cái phiếu ghi món nằm ở đâu. Một biến số như int cups thì khác: số nằm ngay trong khung.
Cách đọc hình: ô trên là cups (nằm trên stack), nó giữ chính số 5. Ô dưới bên trái là trays (nằm trên stack), nó giữ phiếu. Mũi tên từ trays sang ô vàng int[3] (nằm trên heap) nghĩa là: muốn đọc trays[0] thì cầm phiếu, đi tới kệ, đọc phần tử đầu.
Chỗ hình ảnh không còn đúng: ở kho thật, món lấy ra khỏi kho thì kho hết món đó. Trong Java, phiếu chỉ là bản chỉ đường, bạn chép được bao nhiêu phiếu cũng được và mọi phiếu cùng chỉ vào một món.
Khái niệm 3: truyền tham số là chép nội dung biến
Khi gọi change(cups, trays), Java chép nội dung của từng biến sang tham số tương ứng trong khung mới. Với số, nội dung là chính giá trị. Với mảng hay đối tượng, nội dung là cái phiếu. Cho nên có một bản sao riêng của số, nhưng chỉ có một mảng thật mà hai phiếu cùng chỉ tới.
Cách đọc hình: hàng trên: main: cups = 5 chép giá trị sang change: cups (ô đỏ nhạt), việc sửa bản sao là việc riêng của change. Hàng dưới: main: trays chép phiếu sang change: trays (ô lục), và phiếu đó cùng dẫn tới một mảng int[3] (ô vàng). Cả hai phiếu cùng chỉ vào một mảng, nên sửa phần tử từ bên nào thì bên kia cũng thấy.
Đây là khái niệm quan trọng nhất của bài, và nó trả lời câu hỏi ở tình huống mở đầu. Chương trình KitchenCall ở mục Ví dụ đầu tiên bên dưới cho bạn thấy điều đó từng dòng.
Concept Check 1. Có class Order { String drink; }. Hàm static void rename(Order o) { o.drink = "Mocha"; } được gọi với rename(a), trong đó a.drink đang là "Latte". Sau lời gọi, a.drink là gì?
- A.
"Latte", vìolà bản sao nênakhông đổi. - B.
null, vì khung củarenameđã bị gỡ. - C.
"Mocha", vìolà bản sao của phiếu tới cùng một đối tượng. - D. Lỗi biên dịch, vì không được sửa đối tượng trong hàm.
Đáp án: C. o là bản sao của phiếu, nhưng phiếu dẫn tới cùng một Order trên heap, nên sửa o.drink là sửa đối tượng của a. A là hiểu lầm hay gặp nhất: nó đúng với số nguyên thủy nhưng sai với đối tượng. B sai vì chỉ khung bị gỡ, đối tượng trên heap vẫn còn. D sai vì sửa trường của đối tượng được truyền vào là việc hợp lệ.
Khái niệm 4: hai vùng nhớ đều có giới hạn
Stack có giới hạn độ sâu. Đệ quy không có điều kiện dừng làm khung chồng mãi tới khi máy ném StackOverflowError. Heap có giới hạn dung lượng (đặt bằng tuỳ chọn -Xmx). Giữ quá nhiều đối tượng làm heap đầy và máy ném OutOfMemoryError: Java heap space. Hai lỗi này khác tên, khác vùng, khác cách sửa.
Cách đọc hình: hàng trên: đệ quy không dừng làm stack đầy rồi máy ném StackOverflowError. Hàng dưới: một danh sách chỉ lớn thêm làm heap đầy rồi máy ném OutOfMemoryError với thông báo Java heap space. Cả sáu ô đỏ nhạt hay trắng đều có nhãn chữ nói rõ chuyện gì xảy ra.
Ví dụ đầu tiên (Worked Example) và "Xem từng bước"
Đây là chương trình đầu tiên của dự án: bếp Quán Cơm Bác Tám gọi hàm change với số khay đang mở và mảng món trên khay.
KitchenCall.java
public class KitchenCall {
static void change(int cups, int[] trays) {
// Chỉ sửa bản sao nằm trong khung của change
cups = 99;
// Đi theo phiếu tới mảng thật nên main cũng thấy thay đổi
trays[0] = 99;
}
public static void main(String[] args) {
int cups = 5;
int[] trays = {1, 2, 3};
// Chép giá trị 5 và chép phiếu tới mảng
change(cups, trays);
System.out.println(cups);
System.out.println(trays[0]);
}
}
Predict: hãy dừng lại, lấy giấy nháp và đoán: bạn đoán nó in ra gì? Và trạng thái bên trong ra sao, tức là cups của main, cups của change và phần tử đầu của mảng mang giá trị nào sau từng dòng? Đoán xong rồi hãy đọc tiếp.
Kết quả mong đợi [Đã chạy, JDK 17.0.15]:
5
99
Đọc code từng dòng:
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
int cups = 5; | Ghi số 5 thẳng vào ô cups trong khung của main | Kiểu số nguyên thủy nằm trực tiếp trong khung, không cần phiếu |
int[] trays = {1, 2, 3}; | Tạo mảng trên heap, ghi phiếu vào ô trays | Mảng là đối tượng nên nằm trên heap, khung chỉ giữ phiếu |
change(cups, trays); | Tạo khung mới, chép 5 và chép phiếu vào hai tham số | Đây là bước chép nội dung của khái niệm 3 |
cups = 99; trong change | Ghi 99 vào ô cups của khung change | Chỉ sửa bản sao, nên main không thấy |
trays[0] = 99; trong change | Đi theo phiếu tới mảng và ghi 99 vào phần tử đầu | Sửa chính mảng chung, nên main thấy |
System.out.println(cups); | In ô cups của main | Khung change đã bị gỡ, ô này vẫn là 5 |
Bây giờ xem từng bước. Mỗi bước nêu dòng code đang chạy, khung nào đang tồn tại và lý do:
Đang tải Animation…
Bảng trace (bản chữ tương đương của animation, [Suy luận] bằng cách đi từng dòng và khớp với kết quả chạy thật):
| Dòng chạy | cups của main | cups của change | Mảng trên heap | Khung đang có |
|---|---|---|---|---|
Hai dòng đầu của main | 5 | chưa có | 1, 2, 3 | main |
change(cups, trays) | 5 | 5 | 1, 2, 3 | main và change |
cups = 99; | 5 | 99 | 1, 2, 3 | main và change |
trays[0] = 99; | 5 | 99 | 99, 2, 3 | main và change |
| Trả về, rồi hai dòng in | 5 | đã gỡ | 99, 2, 3 | main |
Bạn vừa làm được: nói đúng khung nào có gì sau từng dòng và vì sao số không đổi còn mảng đã đổi.
Concept Check xen kẽ
Chọn đáp án của bạn trước, rồi đọc phần đáp án ngay bên dưới mỗi câu.
Concept Check 2. Trong int[] trays = {1, 2, 3}; viết trong main, cái gì nằm ở đâu?
- A. Cả biến
trayslẫn mảng đều nằm trên stack, vì khai báo trongmain. - B. Cả biến
trayslẫn mảng đều nằm trên heap, vì mảng là đối tượng. - C. Biến
traysnằm trên heap, mảng nằm trên stack củamain. - D. Biến
traysnằm trên stack, mảng nằm trên heap.
Đáp án: D. Biến cục bộ nằm trong khung của main trên stack và giữ phiếu, còn mảng tạo bằng khởi tạo nằm trên heap. A và B đều gộp hai thứ khác nhau vào một chỗ. C đảo ngược hai vùng. [Mô hình này là cách hiểu đúng ngữ nghĩa; máy ảo có thể tối ưu cách đặt thật trong vài trường hợp, xem phần Đào sâu.]
Concept Check 3. Chương trình ném StackOverflowError trong một hàm đệ quy. Cách sửa nào đúng hướng?
- A. Kiểm tra điều kiện dừng và bảo đảm mỗi lời gọi tiến gần tới điều kiện đó.
- B. Tăng giới hạn heap bằng
-Xmx, vì heap lớn hơn thì có thêm chỗ. - C. Gọi
System.gc()trước mỗi lời gọi đệ quy để dọn khung cũ. - D. Đổi biến số nguyên thành
Integer, vì đối tượng nằm trên heap nên stack nhẹ đi.
Đáp án: A. Khung chồng lên nhau vì các lời gọi không bao giờ dừng, nên phải sửa điều kiện dừng. B nhầm vùng: -Xmx đổi heap, còn lỗi này nằm ở stack. C sai vì khung đang chờ kết quả thì còn cần, không dọn được. D sai vì mỗi lời gọi vẫn có khung, chỉ thêm đối tượng không giúp được gì.
Làm cùng mình (Guided Practice)
Bạn sẽ dựng dần class ShelfNotes qua năm bước nhỏ. Mỗi bước thêm vào chương trình của bước trước, và hỗ trợ giảm dần: bước 1 gần như làm sẵn, bước 5 chỉ còn đề. Gõ vào một file ShelfNotes.java rồi chạy bằng lệnh java ShelfNotes.java.
Bước 1: sửa bản sao của số.
public class ShelfNotes {
static void bump(int cups) {
// TODO 1: tăng cups lên 1 bằng cups = cups + 1
}
public static void main(String[] args) {
int cups = 5;
bump(cups);
System.out.println("step 1: " + cups);
}
}
Chạy xong bạn sẽ thấy: step 1: 5. Tăng cups trong bump mà kết quả vẫn là 5: đó là bản sao.
Bước 2: sửa mảng qua phiếu. Thêm hàm fill và dòng gọi nó:
static void fill(int[] trays) {
// TODO 2: ghi 7 vào phần tử đầu của trays
}
Trong main, thêm int[] trays = {1, 2, 3}; rồi gọi fill(trays); và in Arrays.toString(trays) với tiền tố step 2: (nhớ import java.util.Arrays;). Chạy xong bạn sẽ thấy: step 2: [7, 2, 3].
Vẽ ra giấy: vẽ khung của main với hai ô cups và trays, vẽ mảng bên ngoài, rồi vẽ khung của fill với ô trays thứ hai có mũi tên tới cùng mảng đó. Đối chiếu: hai mũi tên cùng chỉ vào một mảng, nên ghi qua mũi tên nào thì mảng thật cũng đổi. Hình ở khái niệm 3 là cùng ý.
Bước 3: hai phiếu cho một mảng.
int[] same = trays;
// TODO 3: ghi 8 vào phần tử số 1 của same, rồi in "step 3: " cộng trays, một dấu cách, rồi kết quả trays == same
Chạy xong bạn sẽ thấy: step 3: [7, 8, 3] true. Gợi ý: sửa qua same nhưng in trays. Dòng đầu chỉ chép phiếu, không tạo mảng mới.
Bước 4: chép cả mảng. Bạn tự quyết cách viết.
// TODO 4: tạo copy là một mảng mới cùng nội dung với trays, ghi 9 vào phần tử số 2 của copy, rồi in "step 4: " cộng trays, một dấu cách, rồi copy
Chạy xong bạn sẽ thấy: step 4: [7, 8, 3] [7, 8, 9]. Gợi ý: Arrays.copyOf(trays, trays.length) tạo một mảng mới trên heap.
Bước 5: tự vẽ stack cho đệ quy.
static int total(int n) {
// TODO 5: trả 0 khi n bằng 0, ngược lại trả n cộng total(n - 1)
return -1;
}
Trong main, in "step 5: " + total(4). Chạy xong bạn sẽ thấy: step 5: 10. Gợi ý: hình khung ở khái niệm 1 là cùng ý với sum. Lời giải đầy đủ nằm ở cuối bài.
Bạn vừa làm được: tự dựng chương trình qua năm bước, và vẽ được số, phiếu, mảng và khung trước khi chạy. Toàn bộ mã nguồn của bài nằm trong thư mục 10-bo-nho-jvm/01-stack-heap của kho GitHub, nhưng bạn không cần mở nó để học.
Thử phá nó đi (Lỗi thường gặp)
Bốn tình huống dưới đây là những lỗi người mới gặp nhiều nhất về bộ nhớ. Hai chương trình đã chạy thật [Đã chạy, JDK 17.0.15; chữ trong thông báo có thể khác ở phiên bản Java khác].
Chương trình Breakages chạy hai lỗi đầu:
recursion: StackOverflowError
null array: Cannot store to int array because "<local1>" is null
Chương trình Oom giữ mỗi lần một mảng 1 MB cho tới khi hết chỗ, chạy với giới hạn nhỏ java -Xmx32m Oom.java để lỗi hiện nhanh:
caught: Java heap space
| Code lỗi | Thông báo lỗi | Nguyên nhân bằng lời thường | Cách sửa |
|---|---|---|---|
static int forever(int n) { return forever(n + 1) + 1; } | StackOverflowError | Hàm gọi chính nó mà không có điều kiện dừng, khung chồng tới khi stack đầy | Thêm điều kiện dừng và bảo đảm mỗi lời gọi tiến gần tới nó |
int[] trays = null; rồi trays[0] = 1; | NullPointerException: Cannot store to int array because "<local1>" is null | Biến không giữ phiếu nào, nên không có mảng để ghi | Tạo mảng bằng new hoặc khởi tạo trước khi dùng |
kept.add(new byte[1024 * 1024]); trong vòng lặp vô hạn | OutOfMemoryError: Java heap space | Danh sách giữ phiếu tới mọi mảng nên không món nào bị dọn, heap đầy | Giữ ít hơn, xử lý từng phần, bỏ phiếu khi xong |
Order b = a; rồi b.drink = "Mocha"; | Không báo lỗi, a.drink cũng thành Mocha | Gán chỉ chép phiếu, không tạo đối tượng mới | Tạo đối tượng mới bằng new Order(...) khi cần bản riêng |
Dòng thứ hai: tên biến chỉ hiện ra khi biên dịch kèm tuỳ chọn -g, còn chương trình này biên dịch mặc định nên thấy <local1> [Đã chạy cả hai cách; kèm -g thông báo ghi "trays"]. Dòng thứ tư là lỗi hiểu sai cơ chế nên hình dưới gom mô hình sai và mô hình đúng lại. Chương trình Tickets đã chạy dòng đó cùng hai phép so sánh:
Mocha
true
false
Cách đọc hình: hàng trên màu đỏ nhạt là mô hình sai: nhiều người tưởng b = a tạo thêm một đơn riêng nên a.drink vẫn là Latte. Hàng dưới màu lục là mô hình đúng: b = a chỉ chép phiếu, hai biến cùng chỉ một đơn, nên a.drink là Mocha và a == b là true. Dòng thứ ba của chương trình Tickets cho thấy a == c là false: hai đơn khác nhau dù cùng ghi Mocha, vì == so hai phiếu chứ không so nội dung.
Mô hình đúng này giải thích luôn tình huống mở đầu: bản sao của số thì riêng, bản sao của phiếu thì dẫn tới món chung.
Bạn vừa làm được: đọc bốn tình huống lỗi về bộ nhớ và nói được nguyên nhân bằng lời thường.
Tự tay làm (Bài tập)
Làm lần lượt từng mức. Mỗi bài có đề, dữ liệu vào, kết quả mong đợi, tiêu chí nghiệm thu và gợi ý. Lời giải nằm riêng ở cuối bài, hãy thử trước rồi mới so.
Mức Cơ bản
Bài 1. Vẽ trạng thái. Dữ liệu vào là đoạn code sau, chạy theo từng dòng:
int x = 10;
int[] box = {1, 2};
// Chỉ chép phiếu, không tạo mảng mới
int[] alias = box;
alias[0] = 5;
x = x + 1;
int[] other = new int[] {1, 2};
Vẽ khung của main với bốn ô x, box, alias, other và các mảng trên heap. Rồi điền: giá trị của x, box == alias, box == other, box[0], other[0]. Kết quả mong đợi: một hình vẽ và năm giá trị. Tiêu chí nghiệm thu: bạn đếm được hai mảng trên heap và nói được ba biến nào trỏ vào mảng nào. Gợi ý: new mới tạo mảng, phép gán int[] alias = box thì không.
Bài 2. Đoán kết quả. Dữ liệu vào: static void reset(int[] a) { a = new int[] {0, 0}; }, rồi trong main có int[] data = {1, 2}; và gọi reset(data);, rồi in Arrays.toString(data). Kết quả mong đợi: một dự đoán bằng chữ trước khi chạy thật. Tiêu chí nghiệm thu: bạn vẽ được hai phiếu và giải thích vì sao data không đổi. Gợi ý: gán a = ... chỉ đổi phiếu trong khung của reset.
Bài 3. Đếm khung. Dữ liệu vào: hàm sum ở khái niệm 1 và lời gọi sum(4). Kết quả mong đợi: một con số. Tiêu chí nghiệm thu: bạn nêu được số khung trên stack, tính cả main, tại lúc sum(0) đang chạy, và giá trị main in ra. Gợi ý: đếm từng lời gọi sum(4), sum(3) cho tới sum(0).
Mức Vận dụng
Bài 4. Class TrayBoard cho bếp Quán Cơm Bác Tám. Viết hai hàm tĩnh trên mảng int[] các khay:
| Hàm | Việc phải làm |
|---|---|
static int[] withExtra(int[] old, int extra) | Trả một mảng mới dài hơn một ô, có thêm extra ở cuối, không đổi mảng cũ |
static void clearAll(int[] trays) | Ghi 0 vào mọi phần tử của mảng được truyền vào |
Dữ liệu vào là hàm main sau:
// trays giữ phiếu tới mảng thứ nhất, more giữ phiếu tới mảng mới
int[] trays = {4, 5, 6};
int[] more = withExtra(trays, 7);
System.out.println(Arrays.toString(trays) + " " + Arrays.toString(more));
clearAll(trays);
System.out.println(Arrays.toString(trays) + " " + Arrays.toString(more));
System.out.println(trays == more);
Kết quả mong đợi:
[4, 5, 6] [4, 5, 6, 7]
[0, 0, 0] [4, 5, 6, 7]
false
Tiêu chí nghiệm thu: ba dòng in ra khớp từng ký tự, và bạn vẽ được ba mảng trên heap, nói được mảng nào bị clearAll đụng tới. Gợi ý: Arrays.copyOf và Arrays.fill có sẵn trong java.util.Arrays.
Mức Thử thách (tuỳ chọn)
Bài 5. Đo độ sâu đệ quy. Viết một hàm đệ quy đếm độ sâu bằng một biến static int depth cho tới khi gặp StackOverflowError, rồi in độ sâu đạt được. Chạy hai lần, lần thứ hai thêm -Xss256k vào lệnh java. Kết quả mong đợi: hai con số của riêng máy bạn [Có thể khác nhau]. Tiêu chí nghiệm thu: bạn nêu được lần nào sâu hơn và giải thích rằng -Xss đổi kích thước stack của mỗi luồng. Gợi ý: bắt StackOverflowError ở main rồi in depth.
Tự kiểm tra (Retrieval Practice)
Trả lời bằng trí nhớ, không xem lại bài. Mỗi câu ghi rõ nó kiểm tra mục tiêu nào trong bốn mục tiêu ở đầu bài.
Câu 1 (nhớ lại, mục tiêu 1). Nêu một thứ nằm trên stack và một thứ nằm trên heap trong int[] trays = {1, 2, 3};, và nói biến trays giữ cái gì.
Câu 2 (dự đoán kết quả code, mục tiêu 3). Đoạn sau in ra gì?
static void bump(int n, int[] a) {
n++;
a[0]++;
}
Trong main: int n = 1; int[] a = {1}; bump(n, a); bump(n, a); System.out.println(n + " " + a[0]);.
Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 2). Hàm static int f(int n) { return n == 0 ? 0 : f(n - 1) + 2; } được gọi bằng f(3). Viết số khung lớn nhất trên stack (tính cả main) và giá trị trả về.
Câu 4 (tìm lỗi, mục tiêu 4). Hàm sau ném StackOverflowError khi gọi count(5). Chỉ ra lỗi và sửa.
static int count(int n) {
// Hàm tự gọi mà không có điều kiện dừng
return 1 + count(n - 1);
}
Câu 5 (giải thích cho bạn chưa học, mục tiêu 2). Bạn của bạn chưa biết lập trình hỏi: "Vì sao tôi đưa số vào hàm, hàm đổi nó mà chỗ tôi vẫn như cũ, còn đưa danh sách vào thì lại đổi theo?" Hãy giải thích bằng hình ảnh khay giấy nhớ và phiếu.
Điều cần nhớ
- Biến cục bộ và tham số nằm trong khung trên stack, còn đối tượng và mảng tạo bằng
newnằm trên heap, và biến chỉ giữ phiếu tới chúng (mục tiêu 1). - Mỗi lời gọi đặt một khung mới lên stack và khung bị gỡ khi trả về, mỗi khung có biến riêng (mục tiêu 2).
- Truyền tham số chép nội dung biến: số thì chép giá trị, đối tượng thì chép phiếu nên sửa qua phiếu thấy ở nơi gọi (mục tiêu 3).
StackOverflowErrorlà stack đầy, thường do đệ quy không dừng;OutOfMemoryError: Java heap spacelà heap đầy do giữ quá nhiều (mục tiêu 4).
Cheat sheet một trang.
| Thứ cần xếp chỗ | Nằm ở đâu | Ai giữ | Ghi chú |
|---|---|---|---|
int cups = 5 | Stack, trong khung | Chính khung | Mất khi phương thức trả về |
new Order(...) | Heap | Biến giữ phiếu | Còn miễn là có phiếu dẫn tới |
int[] trays | Biến trên stack, mảng trên heap | Biến giữ phiếu | trays == other so hai phiếu |
| Tham số số nguyên | Bản sao trong khung mới | Khung của hàm được gọi | Sửa không ảnh hưởng nơi gọi |
| Tham số đối tượng | Bản sao của phiếu | Khung của hàm được gọi | Sửa trường thì nơi gọi thấy |
StackOverflowError | Stack đầy | Đệ quy không dừng | Sửa điều kiện dừng |
OutOfMemoryError: Java heap space | Heap đầy | Giữ quá nhiều | Giữ ít hơn hoặc xử lý từng phần |
Cách đọc hình: mỗi hàng là một câu hỏi bên trái và câu trả lời bên phải. Số cục bộ thì sống ở stack trong khung của nó, đối tượng tạo bằng new thì sống ở heap sau một phiếu, phương thức luôn nhận bản sao nội dung của biến.
Câu hỏi để dành cho ngày mai: khi khung của main bị gỡ và phiếu tới một đối tượng biến mất, đối tượng trên heap còn nằm đó không, và ai dọn nó? Hãy nghĩ trước khi sang bài sau.
Đào sâu
Phần này dành cho bạn tò mò. Bỏ qua cũng không thiếu gì ở trên.
Bên trong một đối tượng. Mỗi đối tượng trên heap có phần đầu (header) do máy ảo dùng, rồi các trường, rồi đệm cho kích thước chia hết cho 8. Một lần đo của người biên soạn bằng cách đếm số byte luồng hiện tại cấp phát khi tạo 200000 đối tượng Pair có hai trường int [Đã chạy, JDK 17.0.15, cấu hình mặc định, Có thể khác nhau ở máy ảo hoặc cấu hình khác]:
Pair: 24 bytes
Với cấu hình mặc định, phần đầu thường là 12 byte, hai int là 8 byte, và 20 được đệm lên 24. Con số này không nằm trong đặc tả Java, nên đừng viết code dựa vào nó. Điều đáng nhớ là mỗi đối tượng trả thêm một phần phí, nên hàng triệu đối tượng nhỏ tốn nhiều hơn tổng các trường của chúng.
So với lựa chọn khác. Một số ngôn ngữ cho bạn chọn đặt cả đối tượng lên stack hoặc truyền theo tham chiếu thật (sửa chính biến của người gọi). Java không có cả hai ở mức ngôn ngữ: kiểu nguyên thủy ở trong khung, đối tượng ở heap, và tham số luôn là bản sao nội dung. Thực tế máy ảo có thể tối ưu và không cấp phát một số đối tượng trên heap nếu chứng minh được nó không thoát khỏi phương thức. Mô hình stack và heap trong bài vẫn là cách hiểu đúng về ngữ nghĩa của chương trình.
Hiểu lầm nâng cao. Nhiều người nói "Java truyền đối tượng theo tham chiếu". Chính xác hơn, Java truyền giá trị, và giá trị của biến đối tượng là một tham chiếu. Bài tập 2 là phép thử: gán lại tham số không đổi biến của nơi gọi, điều sẽ xảy ra nếu Java truyền tham chiếu thật.
Giới hạn của bài. Bài này chưa nói về việc heap được dọn thế nào (bài sau), về nhiều luồng dùng chung heap, về Metaspace và vùng nhớ ngoài heap, và về các tuỳ chọn như -Xmx, -Xss ngoài hai chỗ đã nhắc. Số -Xmx32m trong bài chỉ để lỗi hiện nhanh, không phải giá trị khuyến nghị.
Đọc thêm (tài liệu chính thống, Java SE 17):
- Java Virtual Machine Specification, mục 2.5: Run-Time Data Areas
- Java SE 17 Troubleshooting Guide: Troubleshoot Memory Leaks
- Javadoc: java.lang.OutOfMemoryError
- Javadoc: java.lang.StackOverflowError
Bài tiếp theo
Bài sau trả lời câu hỏi "ai dọn heap": bộ gom rác, điều kiện để một đối tượng bị dọn, và cách quan sát nó bằng một tham chiếu yếu.
Mini-dự án nối tiếp: ở bài sau, bạn sẽ dùng mảng trays của bếp Quán Cơm Bác Tám và xem khi nào mảng không còn phiếu nào dẫn tới thì bị dọn. Hãy giữ lại class TrayBoard bạn vừa viết.
Lời giải và đáp án
Hãy thử xong rồi mới đọc phần này.
Lời giải Làm cùng mình. Thay các dòng // TODO bằng những dòng dưới đây, ghép lại chạy ra đúng năm dòng step ở trên [Đã chạy, JDK 17.0.15]:
// Chỉ tăng bản sao trong khung của bump
cups = cups + 1;
// Ghi qua phiếu vào mảng chung
trays[0] = 7;
// same chỉ là phiếu thứ hai tới cùng mảng
same[1] = 8;
System.out.println("step 3: " + Arrays.toString(trays) + " " + (trays == same));
// Mảng mới trên heap, độc lập với trays
int[] copy = Arrays.copyOf(trays, trays.length);
copy[2] = 9;
System.out.println("step 4: " + Arrays.toString(trays) + " " + Arrays.toString(copy));
// Điều kiện dừng của đệ quy
if (n == 0) {
return 0;
}
return n + total(n - 1);
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
cups = cups + 1; trong bump | Tăng bản sao trong khung của bump | Khung bị gỡ khi trả về, nên cups của main vẫn 5 |
trays[0] = 7; trong fill | Đi theo phiếu và ghi vào mảng chung | Mảng chỉ có một bản, nên main thấy [7, 2, 3] |
same[1] = 8; | Ghi vào cùng mảng với trays | same chỉ là phiếu thứ hai, nên trays == same là true |
Arrays.copyOf(trays, trays.length) | Tạo mảng mới trên heap và chép phần tử sang | Muốn bản riêng thì phải tạo mảng mới, không chỉ chép phiếu |
n == 0 rồi n + total(n - 1) | Dừng ở 0, còn lại cộng dồn qua lời gọi sau | Điều kiện dừng bảo đảm khung ngừng chồng lên |
Lời giải Bài 1. [Suy luận, khớp với kết quả chạy thật 11 true false 5 1]
Trong khung của main có bốn ô. x giữ số 11 sau dòng cộng. box và alias giữ hai phiếu cùng dẫn tới mảng thứ nhất, trong đó phần tử đầu thành 5 vì ghi qua alias. other giữ phiếu tới mảng thứ hai (do new tạo ra) vẫn là 1, 2. Nên box == alias là true, box == other là false, box[0] là 5, other[0] là 1. Heap có hai mảng.
Lời giải Bài 2. [Đã chạy, in ra [1, 2]]
reset nhận bản sao của phiếu data. Dòng a = new int[] {0, 0}; tạo mảng mới và ghi phiếu mới vào ô a trong khung của reset. Phiếu data ở main không đổi, nên mảng 1, 2 vẫn còn nguyên. Nếu bạn viết a[0] = 0; thay vì gán lại, mảng chung mới đổi.
Lời giải Bài 3. [Đã chạy, sum(4) in 10]
Tại lúc sum(0) chạy có 6 khung: main và các khung sum(4), sum(3), sum(2), sum(1), sum(0). Giá trị main in ra là 4 cộng 3 cộng 2 cộng 1 cộng 0, bằng 10.
Lời giải Bài 4. [Đã chạy, ba dòng khớp với kết quả mong đợi]
static int[] withExtra(int[] old, int extra) {
// Mảng có độ dài cố định nên phải tạo mảng mới
int[] bigger = Arrays.copyOf(old, old.length + 1);
bigger[old.length] = extra;
return bigger;
}
static void clearAll(int[] trays) {
// Sửa qua phiếu nên mảng của nơi gọi đổi theo
Arrays.fill(trays, 0);
}
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
Arrays.copyOf(old, old.length + 1) | Tạo mảng mới dài hơn một ô, chép phần tử cũ sang | Mảng Java có độ dài cố định, nên muốn thêm phải tạo mảng mới |
bigger[old.length] = extra; | Ghi giá trị mới vào ô cuối | Ô cuối có chỉ số bằng độ dài cũ |
return bigger; | Trả phiếu tới mảng mới về nơi gọi | Mảng nằm trên heap nên sống sau khi khung bị gỡ |
Arrays.fill(trays, 0); | Ghi 0 vào mọi ô của mảng chung | Sửa qua phiếu nên mảng ở nơi gọi đổi, còn more là mảng khác nên không đổi |
Gợi ý lời giải Bài 5. Dùng một biến static int depth và hàm static void dive() { depth++; dive(); }. Trong main, bọc dive() trong try rồi catch (StackOverflowError e) và in depth. Chạy hai lần: lần thứ hai thêm -Xss256k sẽ cho độ sâu nhỏ hơn vì stack nhỏ hơn. Hai con số của máy bạn sẽ khác con số của máy khác [Có thể khác nhau], nên đừng ghi thành một con số cố định.
Đáp án Tự kiểm tra.
- Biến
traysnằm trên stack (trong khung củamain) và giữ một phiếu; mảng{1, 2, 3}nằm trên heap. Kiểm tra mục tiêu 1. - In ra
1 3[Đã chạy].n++chỉ tăng bản sao nênncủa main vẫn là 1, còna[0]++chạy hai lần trên mảng chung nên từ 1 thành 3. Kiểm tra mục tiêu 3. - Số khung lớn nhất là 5:
main,f(3),f(2),f(1),f(0). Giá trị trả về là 6 [Suy luận]. Mỗi tầng cộng thêm 2, ba tầng nên 6. Kiểm tra mục tiêu 2. - Lỗi:
countkhông có điều kiện dừng nên khung chồng tới khi stack đầy. Sửa: thêmif (n == 0) { return 0; }ở đầu, khi đócount(5)trả 5 [Suy luận]. Kiểm tra mục tiêu 4. - Số được chép ra một tờ giấy nhớ mới trong khay của hàm, nên đổi tờ đó không đổi tờ gốc. Danh sách thì nằm trong kho, và hàm nhận một bản sao của phiếu tới kho, nên sửa qua phiếu là sửa món thật mà bạn cũng thấy. Kiểm tra mục tiêu 2.