Một trong những lợi ích cốt lõi của Java là JVM, một công cụ giúp quản lý bộ nhớ. Chúng ta chủ yếu tạo các Object và Java Garbage Collector sẽ lo việc cấp phát và giải phóng bộ nhớ
Tuy nhiên, việc tràn bộ nhớ vẫn có thể xảy ra trong các ứng dụng Java.
Trong bài viết này, chúng ta sẽ cùng nhau tìm hiểu về việc tràn bộ nhớ, hiểu được nguyên nhân, và xem một vài kỹ thuật để phát hiện, phòng tránh. Chúng ta sẽ sử dụng Java YourKit Profiler trong bài viết này, để phân tích trạng thái của bộ nhớ khi chạy.
Tràn bộ nhớ trong Java là gì?
Định nghĩa chuẩn của tràn bộ nhớ là kịch bản xảy ra khi các Object không còn được sử dụng nữa bởi ứng dụng, nhưng Garbage Collector không thể xoá chúng khỏi bộ nhớ đang hoạt động – Bởi vì chúng vẫn đang được tham chiếu. Kết quả là, ứng dụng tiêu thụ nhiều và nhiều hơn các tài nguyên – Cuối cùng dẫn đến một lỗi tai hại OutOfMemoryError
Để hiểu hơn về khái niệm, hãy xem hình miêu tả đơn giản sau đây
Như bạn nhìn thấy, có 2 kiểu Object: Tham chiếu (Referenced) và không tham chiếu (Unreferenced), Garbage Collector có thể xoá bỏ các Object không được tham chiếu. Các Object được tham chiếu sẽ không bị thu thập, thậm trí chúng không thật sự còn được sử dụng bởi ứng dụng.
Việc phát hiện tràn bộ nhớ là rất khó. Một số các công cụ thực hiện phân tích tĩnh để xác định việc tràn bộ nhớ tiềm ẩn, nhưng những kỹ thuật này là không hoàn hảo bởi vì phương diện quan trọng nhất là cách xử lý thực sự khi chạy của hệ thống
Nên chúng ta hãy tập trung tìm hiểu một vài thực hành cơ bản giúp ngăn chặn việc tràn bộ nhớ, bằng cách phân tích một số kịch bản hay xảy ra.
Tràn bộ nhớ Heap
Trong phần đầu tiên này, chúng ta sẽ tập trung vào kịch bản tràn bộ nhớ class – Nơi các Object Java được tạo liên tục không được giải phóng.
Để hiểu rõ hơn về những trường hợp này, chúng ta sẽ sử dụng một vài kỹ thuật đơn giản để gây ra tràn bộ nhớ bằng cách cấu hình kích thước thấp hơn cho Heap. Đó là nguyên nhân tại sao khi chạy ứng dụng cần điều chỉnh JVM cho phù hợp với bộ nhớ cần thiết
-Xms<size>
-Xmx<size
Những tham số này xác định kích thước Java Heap ban đầu cũng như kích thước Heap tối đa.
Các trường static duy trì tham chiếu Object
Kịch bản đầu tiên có thể gây ra lỗi tràn bộ nhớ là đang tham chiếu đến Object nặng với một trường static.
Hãy xem ví dụ đơn giản sau
private Random random = new Random(); public static final ArrayList<Double> list = new ArrayList<Double>(1000000); @Test public void givenStaticField_whenLotsOfOperations_thenMemoryLeak() throws InterruptedException { for (int i = 0; i < 1000000; i++) { list.add(random.nextDouble()); } System.gc(); Thread.sleep(10000); // to allow GC do its job }
Chúng ta tạo một ArrayList
như một trường static mà sẽ không bao giờ bị thu thập bởi JVM Garbage Collector trong suốt quá trình JVM tồn tại, ngay cả sau khi việc tính toán được sử dụng đã hoàn thành. Chúng ta cũng gọi Thread.sleep(10000)
để cho phép GC thực hiện thu thập toàn bộ và thử thu thập lại tất cả mọi thứ có thể thu thập.
Hãy chạy test và phân tích JVM với Profiler
Lưu ý rằng, tất cả bộ nhớ tất nhiên là trống
Sau đấy chỉ sau 2 giây, quá trình vòng lặp chạy và kết thúc – tải toàn bộ vào trong danh sách (thông thường, cái này sẽ phụ thuộc vào máy bạn đang sử dụng để chạy)
Sau đó, một chu kỳ thu gom rác (Garbage Collector) đầy đủ được kích hoạt, và test tiếp tục thực hiện, để cho phép thời gian chu kỳ này chạy và kết thúc. Như bạn có thể thấy, danh sách sẽ không bị thu gom lại, tiêu thụ bộ nhớ sẽ không giảm.
Bây giờ hãy cùng xem một ví dụ tương tự chính xác và ở đây thì ArrayList
không bị biến static tham chiếu. Thay vào đó là một biến local được tạo, sử dụng và sau đó loại bỏ đi:
@Test public void givenNormalField_whenLotsOfOperations_thenGCWorksFine() throws InterruptedException { addElementsToTheList(); System.gc(); Thread.sleep(10000); // to allow GC do its job } private void addElementsToTheList(){ ArrayList<Double> list = new ArrayList<Double>(1000000); for (int i = 0; i < 1000000; i++) { list.add(random.nextDouble()); } }
Khi phương thức hoàn thành công việc của nó, chúng ta sẽ quan sát phần thu thập của GC, nó vào khoảng giây thứ 50 như hình vẽ
Lưu ý cách GC bây giờ có thể lấy lại một lượng bộ nhớ được sử dụng bởi JVM
Làm thế nào để ngăn chặn
Bây giờ thì chúng ta đều hiểu kịch bản, tất nhiên là có một vài cách để ngăn chặn nó xảy ra
Đầu tiên chúng ta cần chú ý kỹ hơn khi sử dụng static: Khai báo bất kỳ collection hay object nặng nào là static, gắn vòng đời nó với vòng đời của JVM, và làm cho toàn bộ biểu đồ Object không thể thu thập được.
Nói chung, chúng ta cần phải biết về các thu thập, nó thường là cách phổ biến để không chủ ý nắm giữ các tham chiếu lâu hơn chúng ta mong muốn
Gọi String.intern() trên String Long
Nhóm kịch bản thứ 2 thường là nguyên nhân gây tràn bộ nhớ là gọi các toán tử String – cụ thể API String.intern()
Hãy cùng xem nhanh ví dụ
@Test public void givenLengthString_whenIntern_thenOutOfMemory() throws IOException, InterruptedException { Thread.sleep(15000); String str = new Scanner(new File("src/test/resources/large.txt"), "UTF-8") .useDelimiter("\\A").next(); str.intern(); System.gc(); Thread.sleep(15000); }
Ở đây đơn giản chúng ta chỉ tải một file text lớn vào bộ nhớ đang chạy, và sau đấy trả về một định dạng chuẩn sử dụng .intern()
intern
API sẽ đặt string str
vào trong vùng nhớ JVM – nơi nó không thể thu thập và một lần nữa đây là nguyên nhân GC không thể giải phóng đủ bộ nhớ.
Chúng ta có thể thấy 15 giây đầu tiên JVM ổn định, sau đó chúng ta tải file và JVM thực hiện dọn dẹp rác ở giây thứ 20
Cuối cùng, str.intern()
được gọi, dẫn đến việc bị tràn bộ nhớ, đường ổn định chỉ ra rằng bộ nhớ Heap đang sử dụng cao, và sẽ không bao giờ được giải phóng.
Làm thế nào để ngăn chặn
Hãy nhớ rằng các Object String giữ lại được lưu trữ trong trong bộ nhớ PermGen, nếu ứng dụng của chúng ta dự định thực hiện nhiều hoạt động trên các chuỗi lớn, chúng ta có lẽ cần tăng kích thước của PermGen
-XX:MaxPermSize=<size>
Giải pháp thứ hai là sử dụng Java 8, PermGen đã được thay thế bởi Metaspace – sẽ không dẫn đến một lỗi OutOfMemoryError
khi sử dụng intern trong String.
Cuối cùng, cũng có một vài lựa chọn khác thay vì sử dụng API .intern()
Không đóng Stream
Quên đóng Stream là kịch bản rất phổ biến, và chắc chắn hầu hết các lập trình viên đều có thể gặp phải. Vấn đề đã được loại bỏ một phần trong Java 7 với khả năng tự động đóng tất cả các kiểu Stream được đưa ra trong mệnh đề try-with-resource
Tại sao chỉ một phần thôi? Bởi vì cú pháp try-with-resource không bắt buộc.
@Test(expected = OutOfMemoryError.class) public void givenURL_whenUnclosedStream_thenOutOfMemory() throws IOException, URISyntaxException { String str = ""; URLConnection conn = new URL("https://norvig.com/big.txt").openConnection(); BufferedReader br = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)); while (br.readLine() != null) { str += br.readLine(); } // }
Hãy cùng xem bộ nhớ của ứng dụng trông như thế nào khi tải về một file lớn từ URL:
Như bạn đã nhìn thấy, việc sử dụng Heap tăng liên tục theo thời gian – tác động trực tiếp gây lên việc tràn bộ nhớ do không đóng Stream
Làm thế nào để ngăn chặn
Chúng ta luôn luôn cần nhớ đóng Stream bằng tay, hoặc sử dụng tính năng tự động đóng trong Java 8
try (BufferedReader br = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { // further implementation } catch (IOException e) { e.printStackTrace(); }
Trong trường hợp này, BufferedReader
sẽ tự động đóng ở cuối câu lệnh try
, không cần phải thực hiện đóng rõ ràng khi kết thúc
Không đóng các kết nối
Kịch bản này hơi giống với kịch bản trước, khác biệt cơ bản là đối phó với việc không đóng các kết nối (cơ sở dữ liêu, FTP…). Hơn nữa, thực thi sai có thể gây hại rất nhiều, dẫn đến tràn bộ nhớ.
Hãy cùng xem nhanh ví dụ
@Test(expected = OutOfMemoryError.class) public void givenConnection_whenUnclosed_thenOutOfMemory() throws IOException, URISyntaxException { URL url = new URL("ftp://speedtest.tele2.net"); URLConnection urlc = url.openConnection(); InputStream is = urlc.getInputStream(); String str = ""; // }
URLConnection
vẫn đang mở, và kết quả là, như dự đoán bộ nhớ bị tràn
Lưu ý, cách Garbage Collector không thể làm bất cứ điều gì để giải phóng bộ nhớ không sử dụng, nhưng được tham chiếu bộ nhớ. Tình trạng này ngay lập tức rõ ràng sau một phút, số hoạt động của GC nhanh chóng giảm, nguyên nhân bởi bộ nhớ Heap bị tăng sử dụng, dẫn đến OutOfMemoryError
Làm thế nào để ngăn chặn
Câu trả lời đơn giản – Chúng ta luôn luôn cần đóng các kết nối như một thói quen được quy định.
Thêm Object không có hashCode() và equals() vào HashSet
Một ví dụ đơn giản nhưng rất phổ biến có thể dẫn đến tràn bộ nhớ là sử dụng HashSet
với các Object thiếu phần thực thi cho hashCode()
và equals()
Cụ thể, khi chúng ta bắt đầu thêm các Object trùng lặp vào trong Set
– Điều này sẽ chỉ phát sinh, thay vì bỏ qua các trùng lặp như bình thường. Chúng ta cũng sẽ không thể loại bỏ những Object này, sau khi thêm vào.
Hãy thử tạo một class đơn giản không có hasCode
hoặc equals
public class Key { public String key; public Key(String key) { Key.key = key; } }
Bây giờ hãy cùng xem kịch bản
@Test(expected = OutOfMemoryError.class) public void givenMap_whenNoEqualsNoHashCodeMethods_thenOutOfMemory() throws IOException, URISyntaxException { Map < Object, Object > map = System.getProperties(); while (true) { map.put(new Key("key"), "value"); } }
Phần thực thi đơn giản này sẽ dẫn đến một kịch bản như sau
Chú ý cách garbage collector dừng khả năng thu thập xung quanh thời điểm 1 phút 40 giây, và để ý rằng bộ nhớ bị tràn; số lượng thu thập GC ngay lập tức giảm gần 4 lần sau đó.
Làm thế nào để ngăn chặn
Trong những trường hợp này, giải pháp đơn giản – Yếu tố quyết định của nó là cung cấp thực thi cho hashCode()
và equals()
Các tìm đoạn code gây tràn bộ nhớ
Phán đoán việc tràn bộ nhớ là một quá trình loằng ngoằng, đòi hỏi phải có nhiều kinh nghiệm trong thực tế và những hiểu biết chi tiết về ứng dụng
Verbose Garbage Collection
Một trong những cách nhanh nhất để xác định việc tràn bộ nhớ là cho phép verbose garbage collection
Bằng cách thêm tham số -verbose:gc
vào cấu hình JVM của ứng dụng, chúng ta đang cho phép theo dõi rất chi tiết về GC. Báo cáo tóm tắt được hiển thị trong file đầu ra lỗi mặc định, sẽ giúp bạn hiểu cách quản lý bộ nhớ của ứng dụng
Thực hiện chép hình
Kỹ thuật thứ 2 mà chúng ta đang sử dụng trong toàn bộ bài viết này – đó là chép hình (profiling). Profiler phổ biến nhất là Visual VM
Trong bài viết này chúng ta sử dụng Profiler khác – YourKit – có thêm một vài bổ sung, nhiều tính năng nâng cao khi so sánh với Visual VM
Kiểm tra code
Cuối cùng, đây là cách thông thường và phổ biến nhất hơn là sử dụng một kỹ thuật cụ thể để đối phó với việc tràn bộ nhớ
Đơn giản chỉ cần kiểm tra code một cách kỹ lưỡng, việc review code thường xuyên và sử dụng tốt các công cụ phân tích sẽ giúp bạn hiểu code và hệ thống của mình.