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 StackOverflowError và 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

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]:

text
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ướcLời gọi đang chạySố khung trên stackĐiều xảy ra
1sum(3)2main gọi, khung n bằng 3 được đặt lên
2sum(2)3sum(3) gọi tiếp
3sum(1)4sum(2) gọi tiếp
4sum(0)5Điều kiện dừng đúng, sum(0) trả 0
5 đến 7trả dần4, 3, 1Mỗ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ì o là bản sao nên a không đổi.
  • B. null, vì khung của rename đã bị gỡ.
  • C. "Mocha", vì o là 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

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]:

text
5
99

Đọc code từng dòng:

Dòng codeMá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 mainKiể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 ô traysMả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 changeGhi 99 vào ô cups của khung changeChỉ 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ử đầuSửa chính mảng chung, nên main thấy
System.out.println(cups);In ô cups của mainKhung 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ạycups của maincups của changeMảng trên heapKhung đang có
Hai dòng đầu của main5chưa có1, 2, 3main
change(cups, trays)551, 2, 3main và change
cups = 99;5991, 2, 3main và change
trays[0] = 99;59999, 2, 3main và change
Trả về, rồi hai dòng in5đã gỡ99, 2, 3main

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 trays lẫn mảng đều nằm trên stack, vì khai báo trong main.
  • B. Cả biến trays lẫn mảng đều nằm trên heap, vì mảng là đối tượng.
  • C. Biến trays nằm trên heap, mảng nằm trên stack của main.
  • D. Biến trays nằ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ố.

java
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ó:

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

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

java
        // 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.

java
    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:

text
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:

text
caught: Java heap space
Code lỗiThông báo lỗiNguyên nhân bằng lời thườngCách sửa
static int forever(int n) { return forever(n + 1) + 1; }StackOverflowErrorHàm gọi chính nó mà không có điều kiện dừng, khung chồng tới khi stack đầyThê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 nullBiến không giữ phiếu nào, nên không có mảng để ghiTạ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ạnOutOfMemoryError: Java heap spaceDanh sách giữ phiếu tới mọi mảng nên không món nào bị dọn, heap đầyGiữ í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 MochaGán chỉ chép phiếu, không tạo đối tượng mớiTạ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:

text
Mocha
true
false
Hình: mô hình sai và mô hình đúng về Order b = a rồi sửa b

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:

java
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àmViệ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:

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

text
[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ì?

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

java
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 new nằ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).
  • StackOverflowError là stack đầy, thường do đệ quy không dừng; OutOfMemoryError: Java heap space là 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 ở đâuAi giữGhi chú
int cups = 5Stack, trong khungChính khungMất khi phương thức trả về
new Order(...)HeapBiến giữ phiếuCòn miễn là có phiếu dẫn tới
int[] traysBiến trên stack, mảng trên heapBiến giữ phiếutrays == other so hai phiếu
Tham số số nguyênBản sao trong khung mớiKhung của hàm được gọiSửa không ảnh hưởng nơi gọi
Tham số đối tượngBản sao của phiếuKhung của hàm được gọiSửa trường thì nơi gọi thấy
StackOverflowErrorStack đầyĐệ quy không dừngSửa điều kiện dừng
OutOfMemoryError: Java heap spaceHeap đầyGiữ quá nhiềuGiữ í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]:

text
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):

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]:

java
// 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 codeMáy làm gìLý do chọn cách này
cups = cups + 1; trong bumpTăng bản sao trong khung của bumpKhung 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 chungMả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 trayssame 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ử sangMuố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]

java
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 codeMá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ũ sangMả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ọiMả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 chungSử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.

  1. Biến trays nằm trên stack (trong khung của main) và giữ một phiếu; mảng {1, 2, 3} nằm trên heap. Kiểm tra mục tiêu 1.
  2. In ra 1 3 [Đã chạy]. n++ chỉ tăng bản sao nên n của main vẫn là 1, còn a[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.
  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.
  4. Lỗi: count không có điều kiện dừng nên khung chồng tới khi stack đầy. Sửa: thêm if (n == 0) { return 0; } ở đầu, khi đó count(5) trả 5 [Suy luận]. Kiểm tra mục tiêu 4.
  5. 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.