Tản mạn đôi điều về cách Java quản lý bộ nhớ. Khi lập trình với Java, ta cần biết gì về cách thức hoạt động của bộ nhớ của nó? Java có quản lý bộ nhớ tự động, một trình thu gom rác hoạt động ở chế độ nền để dọn sạch các đối tượng không sử dụng và giải phóng một số bộ nhớ.
Do đó, hầu hết khi lập trình chúng ta không cần phải bận tâm đến các vấn đề như dọn dẹp các đối tượng khi chúng không còn được sử dụng nữa. Tuy nhiên, dù quy trình này là tự động, nó cũng không đảm bảo mọi đối tượng sẽ được dọn dẹp.
Vì vậy, việc biết bộ nhớ thực sự hoạt động như thế nào trong Java rất quan trọng, vì nó mang lại cho ta lợi thế của việc viết các ứng dụng được tối ưu hóa và hiệu suất cao sẽ không bao giờ gặp sự cố với OutOfMemoryError. Mặt khác, khi ta gặp một tình huống lỗi, ta sẽ có thể nhanh chóng tìm thấy nguyên nhân.
Để bắt đầu, chúng ta cùng xem cách bộ nhớ thường được tổ chức trong Java:
Nói chung, bộ nhớ được chia thành hai phần lớn: stack và heap. Heap chiếm giữ lượng lớn bộ nhớ so với stack.

Stack

Bộ nhớ stack có trách nhiệm giữ các tham chiếu đến các đối tượng heap và lưu trữ các loại giá trị kiểu nguyên thủy, chứa chính giá trị đó chứ không phải là một tham chiếu đến một đối tượng từ heap. Ngoài ra, các biến trên stack có khả năng hiển thị nhất định, còn được gọi là scope - phạm vi. Chỉ các đối tượng từ phạm vi hoạt động được sử dụng. Ví dụ, giả sử rằng chúng ta không có bất kỳ biến global nào (biến toàn cục) và chỉ có các biến local (biến cục bộ), nếu trình biên dịch thực thi một thân phương thức, nó chỉ có thể truy cập các đối tượng từ ngăn xếp trong thân phương thức. Nó không thể truy cập các biến cục bộ khác, vì các biến đó nằm ngoài phạm vi. Có thể bạn nhận thấy rằng trong hình trên, có nhiều bộ nhớ ngăn xếp được hiển thị. Điều này là do thực tế là bộ nhớ ngăn xếp trong Java được phân bổ cho mỗi luồng xử lý. Do đó, mỗi khi một luồng xử lý được tạo và bắt đầu, nó có bộ nhớ ngăn xếp riêng - và không thể truy cập vào một bộ nhớ ngăn xếp ở luồng xử lý khác.

Heap

Heap là bộ nhớ lưu trữ đối tượng thực tế trong bộ nhớ. Ví dụ: hãy để phân tích những gì xảy ra trong dòng mã sau:

StringBuilder builder = new StringBuilder();

Từ khóa new chịu trách nhiệm đảm bảo khai báo không gian trống trên heap, tạo một đối tượng của kiểu StringBuilder trong bộ nhớ và tham chiếu đến nó thông qua tham chiếu của trình xây dựng trên máy tính, có trong ngăn xếp. Chỉ tồn tại một bộ nhớ heap cho mỗi tiến trình JVM đang chạy. Do đó, đây là một phần được chia sẻ của bộ nhớ bất kể có bao nhiêu luồng đang chạy. Trên thực tế, cấu trúc heap khác một chút so với nó được hiển thị trong hình trên. Bản thân heap được chia thành một vài phần, tạo điều kiện cho quá trình thu gom rác. Ngăn xếp tối đa và kích thước heap không được xác định trước - điều này phụ thuộc vào máy đang chạy. Ở cuối bài viết này, ta sẽ xem xét một số cách cấu hình JVM cho phép chỉ định rõ ràng kích thước của chúng cho một ứng dụng đang chạy.

Reference Types

Nếu nhìn kỹ vào hình ảnh Cấu trúc bộ nhớ phía trên, ta sẽ nhận thấy rằng các mũi tên biểu thị các tham chiếu đến các đối tượng trong heap là các loại khác nhau. Đó là bởi vì, trong ngôn ngữ lập trình Java, chúng ta có các loại tham chiếu khác nhau: tham chiếu mạnh, yếu, mềm và ảo. Sự khác biệt giữa các loại này là các đối tượng trong heap mà chúng tham chiếu sẽ được thu gom rác theo các tiêu chí khác nhau. Hãy cùng xem từng loại này được xử lý ntn.

Strong Reference

Đây là loại tham chiếu phổ biến nhất mà tất cả chúng ta đều quen sử dụng. Trong ví dụ trên với StringBuilder, ta giữ một tham chiếu mạnh đến một đối tượng trong heap.

Weak Reference

Nói một cách đơn giản, một tham chiếu yếu đến một đối tượng từ heap rất có thể không tồn tại sau quá trình thu gom rác tiếp theo. Một tham chiếu yếu được tạo như sau:

WeakReference<StringBuilder> reference = new WeakReference<>(new StringBuilder());

Một trường hợp phù hợp sử dụng tốt cho loại tham chiếu yếu là các tình huống cần lưu trong bộ đệm. Hãy tưởng tượng rằng bạn truy xuất một số dữ liệu và bạn cũng muốn nó được lưu trữ trong bộ nhớ và có thể dùng lại ở một thời điểm nào đó. Mặt khác, bạn không chắc chắn khi nào sẽ sử dụng lại dữ liệu này. Vì vậy, bạn có thể tạo một tham chiếu yếu đến nó và trong trường hợp trình thu gom rác chạy, có thể nó sẽ phá hủy đối tượng này của bạn trên heap. Do đó, sau một thời gian, nếu bạn muốn truy xuất đối tượng bạn tham chiếu, bạn có thể đột nhiên lấy lại giá trị null. Một triển khai tốt cho các kịch bản bộ đệm là bộ sưu tập WeakHashMap <K, V>. Nếu chúng ta mở lớp WeakHashMap trong API Java, chúng ta sẽ thấy rằng các mục nhập của nó thực sự mở rộng lớp WeakReference và sử dụng trường ref của nó làm khóa bản đồ:

/**
    * The entries in this hash table extend WeakReference, using its main ref
    * field as the key.
*/
private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
    ...

Khi một khóa từ WeakHashMap là rác được thu thập, toàn bộ mục nhập sẽ bị xóa khỏi bản đồ.

Soft Reference

Các loại tham chiếu này được sử dụng cho các tình huống nhạy cảm với bộ nhớ hơn, vì các trường hợp này sẽ chỉ bị bộ dọn rác dọn dẹp khi ứng dụng của ta sắp hết bộ nhớ. Do đó, miễn là không có nhu cầu quan trọng để giải phóng không gian, bộ thu gom rác sẽ không động vào các đối tượng loại này. Java đảm bảo rằng tất cả các đối tượng tham chiếu mềm được dọn sạch trước khi ném OutOfMemoryError.

SoftReference<StringBuilder> reference = new SoftReference<>(new StringBuilder());

Tips and Tricks

  • Cấu hình JVM của bạn dựa trên các yêu cầu ứng dụng của bạn. Chỉ định rõ ràng kích thước heap cho JVM khi chạy ứng dụng. Quá trình phân bổ bộ nhớ cũng tốn kém, vì vậy hãy phân bổ số lượng bộ nhớ ban đầu và tối đa hợp lý cho heap. Chỉ định các tùy chọn bộ nhớ với các tùy chọn sau:
  • Kích thước heap ban đầu -Xms512m - đặt kích thước heap ban đầu là 512 megabyte.
  • Kích thước heap tối đa -Xmx1024m - đặt kích thước heap tối đa thành 1024 megabyte.
  • Kích thước ngăn xếp luồng -Xss128m - đặt kích thước ngăn xếp luồng thành 128 megabyte.
  • Kích thước generation -Xmn256m - đặt kích thước generation thành 256 megabyte.
  • Nếu một ứng dụng Java gặp sự cố với OutOfMemoryError và bạn cần thêm một số thông tin để phát hiện lỗi, hãy chạy quy trình với tham số -XX: HeapDumpOnOutOfMemory, khi đó sẽ xảy ra lỗi heap.
  • Sử dụng tùy chọn -verbose: gc để nhận đầu ra của bộ sưu tập rác.