1. Các lỗi thường gặp khi làm việc với bộ nhớ
Đã đến lúc nhìn vào mặt trái của “ma thuật” quản lý bộ nhớ tự động. Ngay cả khi bạn không viết C, nơi bạn phải tự mình để ý từng byte, trong Java bạn vẫn có thể “gây chuyện” đến mức ứng dụng sẽ ngốn bộ nhớ như một chú mèo phàm ăn ngốn xúc xích. Hãy cùng xem những lỗi phổ biến nhất và cách tránh chúng.
Các listener bị bỏ quên (listeners)
Trong Java thường dùng mẫu “listener” — đối tượng đăng ký nhận sự kiện từ đối tượng khác. Ví dụ, bạn tạo một nút và thêm trình xử lý nhấn:
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// xử lý click
}
});
Vấn đề: nếu bạn quên gỡ listener này (removeActionListener) khi nút hoặc cửa sổ không còn cần thiết, listener sẽ tiếp tục treo trong bộ nhớ. Ngay cả khi bạn đã đóng cửa sổ và đặt tất cả tham chiếu tới nó về null, đối tượng listener vẫn giữ tham chiếu tới cửa sổ (hoặc ngược lại), không cho GC giải phóng bộ nhớ.
Ví von: Hãy tưởng tượng bạn đã chuyển nhà nhưng quên hủy đăng ký nhận thư quảng cáo của tiệm pizza — họ vẫn gửi quảng cáo đến địa chỉ cũ của bạn.
Các collection tĩnh không được dọn dẹp
Trường static sống lâu như class (đôi khi — đến hết vòng đời của ứng dụng). Nếu bạn có một collection static:
public class Cache {
public static final List<String> globalList = new ArrayList<>();
}
và bạn thêm đối tượng vào đó nhưng không xóa chúng, chúng sẽ treo trong bộ nhớ mãi. Ngay cả khi không còn tham chiếu nào khác đến các đối tượng đó, tham chiếu từ collection static sẽ không cho GC dọn chúng.
Ví dụ thực tế: Cache ảnh trong ứng dụng desktop không bao giờ được dọn. Sau vài giờ chạy — OutOfMemoryError.
Không giải phóng tài nguyên (file, stream, kết nối)
Java quản lý bộ nhớ, nhưng không tự động đóng file descriptor, kết nối mạng và các tài nguyên bên ngoài khác. Nếu quên đóng file hoặc stream, tài nguyên sẽ bị giữ lại, và đến một lúc nào đó hệ thống sẽ nói: “Đủ rồi, không cấp thêm file nữa!” (IOException: Too many open files).
Mẹo: Luôn dùng try-with-resources:
try (FileInputStream in = new FileInputStream("data.txt")) {
// đọc file
} // in.close() sẽ được gọi tự động!
Đối tượng lớn bị giữ lâu trong bộ nhớ
Đôi khi bạn tạo một mảng hay collection lớn, dùng xong rồi “quên thả”. Ví dụ:
List<byte[]> bigList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
bigList.add(new byte[1024 * 1024]); // 1 MB mỗi cái
}
// ... quên clear bigList
Nếu collection này sống trong một trường static hoặc trong một đối tượng sống lâu, toàn bộ bộ nhớ đó sẽ bị chiếm giữ.
Lớp nội bộ và ẩn danh: bắt giữ tham chiếu bên ngoài
Các lớp ẩn danh (và nội bộ) trong Java giữ một tham chiếu ngầm đến đối tượng bên ngoài:
public class Outer {
void doSomething() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello from inner!");
}
};
// r được lưu đâu đó
}
}
Nếu đối tượng r đi vào một collection static hoặc cache, nó sẽ “giữ” tham chiếu đến thể hiện Outer, ngay cả khi đối tượng đó đã không còn cần thiết. Kết quả — rò rỉ bộ nhớ. Với lambda, tình hình có phần tốt hơn, nhưng nếu lambda sử dụng các trường của lớp bên ngoài, tham chiếu vẫn được giữ lại.
2. Lỗi khi làm việc với bộ gom rác (GC)
Gọi cưỡng bức System.gc()
Nhiều người mới nghĩ: “Hết bộ nhớ — gọi System.gc() là xong!”. Thực tế đó chỉ là yêu cầu gửi đến JVM, không phải đảm bảo dọn rác ngay lập tức. Dùng thường xuyên có thể làm giảm hiệu năng rõ rệt, gây ra pause dài và đơ (freeze). Trong ứng dụng thực tế, tốt hơn nên tin JVM — nó sẽ tự quyết khi nào dọn rác. Nhân tiện, một số JVM có thể bỏ qua lệnh gọi GC tường minh (ví dụ với tùy chọn -XX:+DisableExplicitGC).
Bỏ qua log GC
Trong log GC có thể thấy khi nào diễn ra các lần dọn, chúng mất bao lâu và giải phóng bao nhiêu bộ nhớ. Nếu không xem log này, bạn có thể bỏ lỡ tín hiệu vấn đề: pause dài, Full GC thường xuyên, rò rỉ bộ nhớ.
Cách bật log GC:
java -Xlog:gc* -jar MyApp.jar
hoặc cho JVM cũ:
java -XX:+PrintGCDetails -XX:+PrintGCDateStamps -jar MyApp.jar
Chọn sai GC cho bài toán
Việc chọn bộ gom rác ảnh hưởng đến độ trễ và sự ổn định. Với yêu cầu độ trễ thấp (sàn giao dịch, trò chơi trực tuyến), Parallel GC kiểu stop-the-world song song — là ý tưởng tồi: nó có thể “đóng băng” mọi thread trong thời gian dọn. Hãy cân nhắc G1 GC, ZGC hoặc Shenandoah.
3. Lỗi với collection
Dùng HashMap thay vì WeakHashMap cho cache.
Nếu bạn xây cache nơi đối tượng cần tự động bị loại khi không còn tham chiếu “sống”, hãy dùng WeakHashMap:
Map<Key, Value> cache = new WeakHashMap<>();
Với HashMap thường, các đối tượng sẽ sống cho đến khi cache được dọn thủ công, dẫn đến rò rỉ bộ nhớ.
Quên gọi remove() cho phần tử.
Nếu bạn thêm đối tượng vào collection (ví dụ danh sách listener) nhưng không xóa khi chúng không còn cần thiết, các đối tượng này sẽ sống mãi, đặc biệt trong các collection sống lâu (ví dụ static).
4. Best practices: cách tránh vấn đề
Luôn gỡ listener.
Nếu đối tượng đã đăng ký nhận sự kiện, hãy chắc chắn hủy đăng ký khi nó không còn cần thiết. Thuận tiện nhất là làm việc này trong phương thức dispose() hoặc khi đóng cửa sổ/màn hình.
button.removeActionListener(myListener);
Dùng weak reference cho cache.
Nếu trong cache có thể không cần đảm bảo giữ đối tượng, dùng WeakReference hoặc các collection dựa trên nó (WeakHashMap). Như vậy GC có thể giải phóng bộ nhớ khi cần.
Theo dõi bộ nhớ trong production.
Sử dụng jvisualvm, jconsole hoặc các hệ thống APM. Điều này giúp bắt rò rỉ trước khi người dùng than phiền.
Phân tích heap dump khi nghi ngờ rò rỉ
Nếu ứng dụng bắt đầu “ngốn” nhiều bộ nhớ hơn bình thường, hãy chụp heap dump (ví dụ qua jmap hoặc jvisualvm) và xem đối tượng nào chiếm nhiều dung lượng nhất. Thủ phạm thường được tìm ra chỉ trong vài phút.
Cấu hình tham số JVM.
- -Xmx — kích thước heap tối đa
- -Xms — kích thước heap khởi tạo
Đặt giới hạn hợp lý giúp tránh OutOfMemoryError và tăng tốc chẩn đoán.
5. Thực hành: ví dụ code với rò rỉ bộ nhớ và cách sửa
Ví dụ 1: Rò rỉ qua collection static
public class MemoryLeakDemo {
// Collection static — sống mãi
private static final List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
leakyList.add(new byte[1024 * 1024]); // 1 MB mỗi lần
System.out.println("Đã thêm " + (i + 1) + " MB");
}
// OutOfMemoryError!
}
}
Cách sửa: Dùng biến cục bộ hoặc dọn collection khi không còn cần thiết.
public class MemoryLeakFixed {
public static void main(String[] args) {
List<byte[]> tempList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
tempList.add(new byte[1024 * 1024]);
System.out.println("Đã thêm " + (i + 1) + " MB");
}
// tempList = null; // Có thể đặt null tường minh
// Bây giờ các đối tượng khả dụng cho GC sau khi thoát khỏi phương thức
}
}
Ví dụ 2: Rò rỉ qua listener
public class Window {
private final List<EventListener> listeners = new ArrayList<>();
public void addListener(EventListener l) {
listeners.add(l);
}
// Không có phương thức removeListener!
}
Cách sửa: Thêm phương thức để gỡ listener và gọi nó khi đóng cửa sổ.
public void removeListener(EventListener l) {
listeners.remove(l);
}
Ví dụ 3: Cache dùng HashMap thay vì WeakHashMap
Map<Object, Object> cache = new HashMap<>();
// ... thêm đối tượng
Cách sửa: Chuyển sang WeakHashMap:
Map<Object, Object> cache = new WeakHashMap<>();
Mẹo cấu hình JVM để giám sát bộ nhớ
- Bật log GC: -Xlog:gc* hoặc -XX:+PrintGCDetails
- Giới hạn kích thước heap tối đa: -Xmx512m
- Nếu dùng cache — theo dõi kích thước của chúng và dùng weak reference nếu phù hợp
- Thử nghiệm với GC: -XX:+UseG1GC, -XX:+UseZGC, -XX:+UseShenandoahGC
7. Các lỗi thường gặp khi làm việc với bộ nhớ
Lỗi số 1: Listener và subscription bị quên. Nếu bạn đã thêm listener vào đối tượng nhưng quên gỡ nó, đối tượng listener (và mọi thứ nó tham chiếu) sẽ ở lại trong bộ nhớ. Kinh điển với GUI và hệ thống sự kiện. Hãy dùng removeListener/removeActionListener.
Lỗi số 2: Collection static không được dọn. Trường static sống rất lâu. Nếu bạn nhét đối tượng vào đó và không dọn collection, các đối tượng sẽ ở lại mãi. Đặc biệt nguy hiểm với cache “không đáy”.
Lỗi số 3: Không giải phóng tài nguyên bên ngoài. Để mở stream, file hoặc kết nối mà không đóng? Bạn vừa mất bộ nhớ và đụng giới hạn của hệ điều hành. Hãy dùng try-with-resources và đóng tài nguyên.
Lỗi số 4: Gọi cưỡng bức System.gc(). Đây không phải thần dược, chỉ là yêu cầu gửi tới JVM. Thường dẫn tới pause và suy giảm hiệu năng.
Lỗi số 5: Dùng collection thường cho cache. Nếu đối tượng trong cache nên tự bị loại bỏ, hãy dùng weak/soft reference (WeakHashMap, SoftReference). Nếu không bạn sẽ gặp rò rỉ.
Lỗi số 6: Lớp nội bộ và ẩn danh giữ tham chiếu bên ngoài. Lớp nội bộ và lambda có thể ngầm giữ tham chiếu đến đối tượng bên ngoài. Nếu lưu chúng trong collection sống lâu — đó là rò rỉ.
Lỗi số 7: Bỏ qua log GC. Nếu không xem log GC, bạn sẽ không biết về pause dài hoặc Full GC thường xuyên — còn người dùng sẽ biết qua việc giật lag và đơ. Hãy bật -Xlog:gc* hoặc -XX:+PrintGCDetails.
GO TO FULL VERSION