Java – Tràn bộ nhớ

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ì ArrayListkhô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ớ PermGennế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()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()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.

You May Also Like

About the Author: Nguyen Dinh Thuc

Leave a Reply

Your email address will not be published.