Rò rỉ bộ nhớ trong Java - Memory Leak

Người dịch: Trần Ngọc Quân - Học viên lớp Java08
Email liên hệ: quan.tranngoc317@gmail.com
Bài viết gốc: https://www.baeldung.com/java-memory-leaks

1. Giới thiệu

Một trong những ưu điểm cốt lõi của Java là tính năng tự động hóa quản lý bộ nhớ với sự trợ giúp của Garbage Collector (GC). GC có chức năng cấp phát và thu hồi bộ nhớ, nhờ đó có khả năng kiểm soát phần lớn các vấn đề liên quan đến rò rỉ bộ nhớ (memory leak).
Trong khi GC có thể xử lý hiệu quả một phần của bộ nhớ, nó không đảm bảo một giải pháp tường minh hoàn toàn cho rò rỉ bộ nhớ. Hiện tượng rò rỉ vẫn có thể xảy ra kể cả khi các lập trình viên đã rất cẩn thận trong quá trình phát triển ứng dụng.
Có những tình huống khi ứng dụng tạo ra một số lượng lớn các đối tượng (object) không cần thiết, làm cạn kiệt các nguồn bộ nhớ quan trọng và gây ra lỗi cho toàn bộ ứng dụng.
Rò rỉ bộ nhớ thực sự là một thách thức trong Java. Trong bài viết này, ta sẽ tìm hiểu những nguyên nhân phổ biến gây ra rò rỉ bộ nhớ, làm thế nào để nhận biết chúng trong quá trình chạy ứng dụng và cách xử lý hiệu quả.

2. Memory leak - Rò rỉ bộ nhớ là gì?

Hiện tượng rò rỉ bộ nhớ là tình huống khi các đối tượng (object) không còn được sử dụng vẫn xuất hiện trong bộ nhớ heap tuy nhiên Garbage Collector không thể xóa chúng khỏi bộ nhớ, do vậy, chúng vẫn tồn tại một cách không cần thiết.
Rò rỉ bộ nhớ là một tín hiệu xấu do nó làm hạn chế bộ nhớ và giảm hiệu năng hệ thống theo thời gian. Nếu ta không xử lý, ứng dụng sẽ dần dần làm cạn kiệt tài nguyên (resources), cuối cùng dẫn đến lỗi nghiêm trọng java.Lang.OutOfMemoryError.
Có 2 kiểu đối tượng tồn tại trong bộ nhớ Heap, đối tượng được tham chiếu (referenced) và không được tham chiếu (unreferenced). Referenced objects là các đối tượng đang được tham chiếu trong ứng dụng (được các biến trong stack trỏ vào), trong khi unreferenced objects là các đối tượng không có bất cứ tham chiếu nào trỏ đến.
Garbage Collector sẽ xóa bỏ các unreferenced object theo chu kì, và sẽ không bao giờ tác động đến các đối tượng đang được tham chiếu. Đây là khi rò rỉ bộ nhớ có thể xảy ra:
leak 1

Triệu chứng của rò rỉ bộ nhớ (memory leak)

  • Hiệu năng giảm nghiêm trọng khi ứng dụng chạy liên tục trong thời gian dài
  • Lỗi OutOfMenmoryError của bộ nhớ heap
  • Xuất hiện những xung đột tự phát và không rõ nguyên nhân
  • Ứng dụng đôi khi mất liên kết giữa các đối tượng
    Hãy cùng tìm hiểu sâu hơn về một số tình huống xảy ra và cách để xử lý chúng

3. Các kiểu rò rỉ bộ nhớ trong Java

Trong bất kì ứng dụng nào, rò rỉ bộ nhớ có thể xuất hiện do vô số nguyên nhân. Trong phần này, ta sẽ thảo luận những nguyên nhân phổ biến nhất.

3.1. Rò rỉ bộ nhớ qua trường static

Tình huống đầu tiên dễ gây ra memory leak là do sử dụng quá nhiều biến static
Trong Java, trường static thông thường có tuổi thọ kéo dài xuyên suốt quá trình khởi chạy của ứng dụng ( trừ khi ClassLoader cho phép Garbage Collection hoạt động).
Thử tạo 1 chương trình Java đơn giản sử dụng static List

public class StaticTest {
    public static List<Double> list = new ArrayList<>();

    public void populateList() {
        for (int i = 0; i < 10000000; i++) {
            list.add(Math.random());
        }
        Log.info("Debug Point 2");
    }

    public static void main(String[] args) {
        Log.info("Debug Point 1");
        new StaticTest().populateList();
        Log.info("Debug Point 3");
    }
}

Nếu ta phân tích bộ nhớ Heap trong quá trình chạy ứng dụng, ta sẽ thấy rằng giữa debug points 1 và 2, bộ nhớ heap tăng lên nhưng mong đợi.
Nhưng khi kết thúc phương thức populateList() tại debug point 3, bộ nhớ heap chưa được Garbage Collector thu thập như ta thấy trong sơ đồ VisualVM response bên dưới:

leak 2

Tuy nhiên, chỉ cần bỏ từ khóa static ở dòng số 2 của chương trình trên, sẽ dẫn đến sự khác biệt đáng kể cho việc sử dụng bộ nhớ như sơ đồ sau:
leak 3

Từ phần đầu cho đến điểm debug point gần giống như sơ đồ khi sử dụng static. Tuy nhiên lần này, sau khi phương thức popuplateList() chạy xong, tất cả bộ nhớ của list sẽ được Garbage Collector dọn dẹp vì chúng không còn được tham chiếu đến.

Bởi vậy, ta cần đặc biệt chú ý đến việc sử dụng các biến static. Nếu 1 collection hoặc 1 số lượng lớn đối tượng được khai báo static, chúng sẽ tồn tại trong bộ nhớ suốt quá trình ứng dụng chạy, và chiếm hữu 1 phần bộ nhớ quan trọng mà ta cần để sử dụng vào mục đích khác.
Cách ngăn chặn

  • Giảm thiểu tối đa việc áp dụng biến static.
  • Khi làm việc với singleton, ưu tiên triển khai theo hướng lazy loading các đối tượng thay vì eager loading.

3.2 Thông qua các nguồn hở

Mỗi khi ta tạo 1 liên kết mới hoặc mở 1 stream, JVM sẽ cấp phát bộ nhớ cho các tài nguyên đó. Một vài ví dụ có thể kể đến như liên kết với database, input streams, và session objects.
Bỏ qua việc kết thúc hay đóng lại các tài nguyên này có thể làm phong tỏa bộ nhớ và ngăn chặn hoạt động của GC. Tình trạng này thậm chí có thể xảy ra trong trường hợp một exception ngăn chặn chương trình khởi chạy các lệnh để xử lý đóng lại các tài nguyên đó.
Dù trong trường hợp nào, liên kết để mở từ các tài nguyên đều chiếm dụng bộ nhớ, và nếu ta không kiểm soát, chúng sẽ làm giảm hiệu năng và có thể dẫn đến lỗi OutOfMemoryError.
Cách ngăn chặn

  • Luôn sử dụng block finally để đóng các nguồn liên kết.
  • Phần code xử lý đóng tài nguyên không được throw bất cứ exception nào.
  • Khi sử dụng Java 7+, ta có thể sử dụng try-with-resources block.

3.3. Triển khai phương thức equals() và hashCode() không chính xác

Khi định nghĩa các class mới, một lỗi phổ biến hay xảy ra là do ghi đè phương thức equals() và hashCode() không chính xác.
HashSet và HashMap sử dụng những phương thức này trong rất nhiều thuật toán, và nếu chúng không được ghi đè chính xác, chúng rất dễ trở thành nguồn tạo ra rò rỉ bộ nhớ.
Cùng xem thử một ví dụ của class Person, và sử dụng như 1 key trong HashMap

public class Person {
    public String name;
    
    public Person(String name) {
        this.name = name;
    }
}

Tiếp theo ta sẽ thêm 2 đối tượng Person giống nhau vào Map để xem điều gì xảy ra:

@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map<Person, Integer> map = new HashMap<>();
    for(int i=0; i<100; i++) {
        map.put(new Person("jon"), 1);
    }
    Assert.assertFalse(map.size() == 1);
}

Ở đây, ta sử dụng Person như 1 key. Do Map không cho phép key trùng nhau, cho dù ta thêm bao nhiêu đối tượng Person giống nhau vào Map như 1 key đều sẽ không tăng thêm bộ nhớ.
Nhưng do ta không định nghĩa phương thức equals() chính xác, các đối tượng giống nhau sẽ xếp chồng lên nhau và làm tăng bộ nhớ, đó là lý do tại sao ta thấy nhiều hơn một object tồn tại trong bộ nhớ như sơ đồ dưới đây:
leak 4

Tuy nhiên, nếu ta ghi đè phương thức equals() và hashCode() phù hợp, sẽ chỉ có một đối tượng Person tồn tại trong Map.
Cùng xem thử cách triển khai phương thức equals() và hashCode() cho class Person:

public class Person {
    public String name;
    
    public Person(String name) {
        this.name = name;
    }
    
    @Override
    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Person)) {
            return false;
        }
        Person person = (Person) o;
        return person.name.equals(name);
    }
    
    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + name.hashCode();
        return result;
    }
}

@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map<Person, Integer> map = new HashMap<>();
    for(int i=0; i<2; i++) {
        map.put(new Person("jon"), 1);
    }
    Assert.assertTrue(map.size() == 1);
}

Sau khi ghi đè phương thức equals() và hashCode() là sẽ được sơ đồ như sau:
leak 5

Một trường hợp khác là khi sử dụng một công cụ của ORM như Hibernate, sử dụng equals() và hashCode() để phân tích đối tượng và lưu chúng trong cache.
Khả năng bộ nhớ bị rò rỉ là khá cao nếu những phương thức trên không được ghi đè vì Hibernate không có khả năng so sánh các đối tượng và sẽ lưu chồng các đối tượng vào cache

Cách ngăn chặn:

  • Theo kinh nghiệm, khi định nghĩa một entity mới, luôn ghi đè phương thức equals() và hashCode().
  • Chỉ ghi đè là chưa đủ, những phương thức này cần được ghi đè theo cách tối ưu nhất.

3.4. Inner class tham chiếu đến Outer Class

Trường hợp này xảy ra với inner class (non-static) – class vô danh. Khi khởi tạo, những inner class này luôn yêu cầu một instance của class chứa nó.
Tất cả non-static Inner Class đều tham chiếu đến Class chứa nó. Nếu ta sử dụng đối tượng của inner class đó trong ứng dụng, thì sau đó, khi đối tượng của class chứa nó không được sử dụng nữa, chúng vẫn không bị Garbage Collector dọn dẹp.
Xét 1 class có nhiều đối tượng tham chiếu và có một non-static inner class. Khi ta tạo 1 đối tượng của lớp inner class, cấu trúc bộ nhớ sẽ như sơ đồ bên dưới:
leak 6
Tuy nhiên, nếu ta chỉ khai báo lớp inner class là static, cấu trúc bộ nhớ sẽ như sau:
leak 7
Hiện tượng này xảy ra do đối tượng của lớp inner class gián tiếp tham chiếu đến đối tượng của lớp outer class và khiến chúng không bị Garbage Collection dọn dẹp. Điều tương tự cũng sẽ xảy ra với trường hợp của anonymous class.
Cách ngăn chặn:

  • Nếu lớp inner class không cần truy cập đến các thành phần của lớp chứa nó, ta nên cân nhắc khai báo nó như 1 static class.

3.5. Thông qua phương thức finalize()

Việc sử dụng finalizers cũng là một nguyên nhân dễ gây ra các vấn đề rò rỉ bộ nhớ. Mỗi khi phương thức finalize() của một class được ghi đè, các đối tượng của class đó sẽ không được dọn dẹp ngay. Thay vào đó GC sẽ xếp chúng vào giai đoạn finalization và sẽ được xử lý sau.
Ngoài ra, nếu code trong phương thức finalize() không được tối ưu và nếu ngăn xếp finalizer không bắt kịp Java Garbage Collector, không sớm thì muộn, ứng dụng sẽ gặp lỗi OutOfMemoryError
Để minh họa, hãy tưởng tượng ta có 1 class trong đó được ghi đè phương thức finalize(), và phương thức này cần thời gian lâu 1 chút để chạy. Khi 1 số lượng lớn các đối tượng của class được dọn dẹp, ta sẽ có sơ đồ VisualVm tương tự như sau:
leak 8
Tuy nhiên, nếu ta bỏ phần ghi đè phương thức finalize(), sơ đồ sẽ thay đổi:

leak 9

Cách ngăn chặn

  • Luôn luôn hạn chế việc sử dụng finalizers

3.6. Interned String

Java String pool có sự thay đổi đáng kể từ Java 7 khi nó được chuyển từ PermGen sang Heap space. Tuy nhiên, đối với những ứng dụng hoạt động trên phiên bản Java 6 hoặc thấp hơn, ta cần cẩn trọng khi làm việc với các String kích thước lớn.
Nếu ta đọc một đối tượng String kích thước lớn, và gọi phương thức intern() cho đối tượng đó, nó sẽ được lưu trữ trong string pool thuộc PermGen và sẽ tồn tại trong suốt thời gian chạy ứng dụng. Nó sẽ phong tỏa bộ nhớ và gây ra tình trạng rò rỉ bộ nhớ nghiêm trọng trong ứng dụng
The PermGen for this case in JVM 1.6 looks like this in VisualVM: PermGen trong trường hợp này sẽ có sơ đồ JVM 1.6 như sau:

leak 10

Ngược lại, nếu ta chỉ đọc string từ file trong 1 phương thức và không sử dụng intern, PermGen sẽ có sơ đồ như sau
leak 11
Cách ngăn chặn:

  • Cách đơn giản nhất để giải quyết vấn đề này là sử dụng phiên bản Java mới nhất, do String pool đã được chuyển sang HeapSpace kể từ Java 7.
  • Khi làm việc với String có kích thước lớn, ta có thể tăng kích thước của PermGen để tránh gặp lỗi OutOfMemoryErrors
  -XX:MaxPermSize=512m
  

3.7. Sử dụng ThreadLocals

ThreadLocal là một khởi tạo có khả năng tách biệt trạng thái vào một thread cụ thể, nhờ đó giúp người dung đảm bảo threadsafe.

Khi sử dụng kiểu khởi tạo này, mỗi thread sẽ được tham chiếu đến bản sao của một biến ThreadLocal và sẽ lưu giữ bản sao của chính nó, thay vì chia sẻ tài nguyên giữa nhiều thread trong giai đoạn thread đó đang chạy.

Mặc dù có những ưu điểm, việc sử dụng biến ThreadLocal vẫn gây nhiều tranh cãi, vì chúng dễ gây ra rò rỉ bộ nhớ nếu không được sử dụng đúng cách. Joshua Bloch từng nhận định về việc sử dụng thread local:
“Việc tùy tiện sử dụng thread pools kết hợp với thread local có thể dẫn đến các đối tượng được duy trì một cách thừa thãi, như đã được lưu ý ở nhiều nơi. Tuy nhiên nếu đổ lỗi hoàn toàn cho thread local cũng không hoàn toàn xác đáng. ”
Memory Leaks with ThreadLocals

ThreadLocals sẽ được Garbage Collector dọn dẹp khi thread kết thúc. Nhưng vấn đề sẽ phát sinh khi ta sử dụng ThreadLocals với máy chủ ứng dụng (application server).

Các application server ngày nay sử dụng một chuỗi các thread để triển khai request, thay vì tạo mới. Hơn nữa, chúng còn sử dụng các classloader riêng biệt.

Do Thread Pool trong application server hoạt động dựa trên quan niệm của tận dụng thread, chúng sẽ không được GC dọn dẹp, thay vào đó, chúng sẽ được sử dụng lại để phục vụ request khác
Nếu bất cứ class nào tạo ra một biến ThreadLocal, nhưng không có tác vụ xóa bỏ nó, thì 1 bản copy của đối tượng đó sẽ tồn tại với thread ngay cả sau khi ứng dụng web ngừng chạy, từ đó ngăn chặn GC dọn dẹp.
Cách ngăn chặn

  • Một giải pháp hay được sử dụng là clean-up dọn dẹp ThreadLocals nếu ta không còn sử dụng chúng. ThreadLocals cung cấp phương thức remove() giúp ta xóa giá trị của thread hiện tại cho biến đó.
  • Không sử dụng ThreadLocal.set(null) to để xóa giá trị. Cách này thực tế không có tác dụng xóa, thay vào đó, nó tìm trong Map liên kết đến thread hiện tại và đặt giá trị key-value của thread hiện tại thành null
  • Nên coi ThreadLocal như một nguồn mà ta cần đóng lại trong block finally
  try {
    threadLocal.set(System.nanoTime());
    //... further processing
}
finally {
    threadLocal.remove();
}

4. Những biện pháp khác để ngăn chặn Memory Leak

Mặc dù không có 1 giải pháp nào có thể sử lý toàn bộ các rủi ro dẫn đến memory leak, ta vẫn có thể áp dụng 1 số cách để hạn chế tối đa chúng.

4.1. Kích hoạt Profiling

Java profilers là các công cụ theo dõi và phát hiện rò rỉ bộ nhớ thông qua ứng dụng. Chúng phân tích các tác vụ nội bộ bên trong ứng dụng như cách cấp phát bộ nhớ,…
Sử dụng profilers, ta có thể so sánh các cách tiếp cận khác nhau và tìm ra những vị trí nơi mà ta có thể tối ưu việc sử dụng resources.

4.2. Verbose Garbage Collection

Bằng cách kích hoạt verbose garbage collection, ta có thể dò được đường đi chi tiết của GC. Để kích hoạt, ta cần thiết lập trong JVM configuration:

-verbose:gc

Khi thêm công cụ này, ta có thể theo dõi được chi tiết hoạt động bên trong của GC

leak 12

4.3. Sử dụng reference object để tránh memory leak

Ta cũng có thể tận dụng đối tượng tham chiếu trong Java được tích hợp sẵn trong package java.lang.ref để tránh rò rỉ bộ nhớ. Khi sử dụng java.lang.ref, thay vì trực tiếp tham chiếu đến đối tượng, ta sử dụng các tham chiếu đặc biệt đến các đối tượng cho phép GC dọn dẹp 1 cách dễ dàng

4.4. Cảnh báo memory leak trong Eclipse

Với các dự án sử dụng JDK 1.5 trở lên, Eclipse hiển thị cảnh báo và lỗi mỗi khi xuất hiện những tình huống chắn chắn xảy ra rò rỉ bộ nhớ. Khi sử Eclipse, ta có thể kiểm tra tab “Problems” để có thể kịp thời phát hiện các cảnh báo rò rỉ nếu có.
leak 13

4.5. Benchmarking

Ta có thể đo lường và phân tích hiệu năng của code bằng cách sử dụng benchmarks. Theo cách này, ta có thể so sánh hiệu năng của nhiều phương án khác nhau khi thực hiện 1 nhiệm vụ. Điều này sẽ giúp ta chọn được cách tiếp cận tốt nhất cũng như tối ưu bộ nhớ

4.6. Review code

Cuối cùng, review lại code luôn là bước vô cùng quan trọng để kiểm soát bất cứ rủi ro nào. Trong một số trường hợp, review code có thể giúp ta loại bỏ một số lỗi phổ biến gây ra rò rỉ bộ nhớ.

5. Tổng kết

Đơn giản hóa, ta có thể coi rò rỉ bộ nhớ như một căn bệnh làm giảm hiệu năng của ứng dụng bằng cách phong tỏa các tài nguyên quan trọng của bộ nhớ. Và giống như các căn bệnh khác, nếu không được xử lý, nó sẽ dần dần gây ra các xung đột trầm trọng trong ứng dụng
Rò rỉ bộ nhớ thực sự khó để giải quyết triệt để, và để tìm ra được chúng đòi hỏi người dùng phải nắm vững kiến thức về Java. Khi xử lý rò rỉ bộ nhớ, không có giải pháp nào xử lý được tất cả các trường hợp, do rò rỉ có thể xảy ra từ vô số nguyên nhân.
Tuy nhiên, nếu áp dụng những phương pháp tối ưu và thường xuyên review code và profiling, ta có thể giảm thiếu tối đa rủi ro rò rỉ bộ nhớ trong ứng dụng của mình