Zrozumienie wycieków pamięci w Javie

1. Wstęp

Jedną z głównych zalet Javy jest zautomatyzowane zarządzanie pamięcią za pomocą wbudowanego Garbage Collectora ( w skrócie GC ). GC niejawnie zajmuje się przydzielaniem i zwalnianiem pamięci, dzięki czemu jest w stanie obsłużyć większość problemów z wyciekiem pamięci.

Chociaż GC skutecznie obsługuje dużą część pamięci, nie gwarantuje niezawodnego rozwiązania wycieku pamięci. GC jest całkiem sprytny, ale nie bezbłędny. Wycieki pamięci wciąż mogą się przedostać nawet w aplikacjach świadomego programisty.

Nadal mogą wystąpić sytuacje, w których aplikacja generuje znaczną liczbę zbędnych obiektów, wyczerpując w ten sposób kluczowe zasoby pamięci, co czasami prowadzi do awarii całej aplikacji.

Wycieki pamięci to prawdziwy problem w Javie. W tym samouczku zobaczymy, jakie są potencjalne przyczyny wycieków pamięci, jak je rozpoznać w czasie wykonywania i jak sobie z nimi radzić w naszej aplikacji .

2. Co to jest wyciek pamięci

Wyciek pamięci to sytuacja, w której w stercie znajdują się obiekty, które nie są już używane, ale moduł odśmiecania pamięci nie może ich usunąć z pamięci i przez to są niepotrzebnie utrzymywane.

Wyciek pamięci jest zły, ponieważ blokuje zasoby pamięci i pogarsza wydajność systemu w czasie . A jeśli nie zostanie rozwiązane, aplikacja w końcu wyczerpie swoje zasoby, ostatecznie kończąc się błędnym błędem java.lang.OutOfMemoryError .

Istnieją dwa różne typy obiektów, które znajdują się w pamięci sterty - do których istnieją odwołania i do których nie ma odwołań. Obiekty, do których istnieją odniesienia, to te, które mają nadal aktywne odwołania w aplikacji, podczas gdy obiekty, do których nie istnieją odniesienia, nie mają żadnych aktywnych odniesień.

Moduł odśmiecania pamięci okresowo usuwa obiekty, do których nie istnieją odniesienia, ale nigdy nie zbiera obiektów, do których nadal istnieją odwołania. W tym miejscu mogą wystąpić wycieki pamięci:

Objawy wycieku pamięci

  • Poważne obniżenie wydajności, gdy aplikacja działa nieprzerwanie przez długi czas
  • Błąd sterty OutOfMemoryError w aplikacji
  • Spontaniczne i dziwne awarie aplikacji
  • W aplikacji czasami kończą się obiekty połączeń

Przyjrzyjmy się bliżej niektórym z tych scenariuszy i sposobom radzenia sobie z nimi.

3. Rodzaje wycieków pamięci w Javie

W każdej aplikacji wycieki pamięci mogą wystąpić z wielu powodów. W tej sekcji omówimy najpopularniejsze.

3.1. Wyciek pamięci przez pola statyczne

Pierwszy scenariusz, który może spowodować potencjalny wyciek pamięci, to intensywne użycie zmiennych statycznych .

W Javie pola statyczne mają żywotność, która zwykle odpowiada całemu okresowi życia uruchomionej aplikacji (chyba że ClassLoader kwalifikuje się do czyszczenia pamięci).

Utwórzmy prosty program w języku Java, który zapełni statyczną listę:

public class StaticTest { public static List 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"); } }

Jeśli teraz przeanalizujemy pamięć sterty podczas wykonywania tego programu, zobaczymy, że między punktami debugowania 1 i 2, zgodnie z oczekiwaniami, zwiększyła się pamięć sterty.

Ale kiedy zostawimy metodę populateList () w punkcie debugowania 3, pamięć sterty nie jest jeszcze zbierana jako śmieci, jak widać w tej odpowiedzi VisualVM:

Jednak w powyższym programie, w linii numer 2, jeśli po prostu upuścimy słowo kluczowe static , spowoduje to drastyczną zmianę w użyciu pamięci, ta odpowiedź Visual VM pokazuje:

Pierwsza część do punktu debugowania jest prawie taka sama jak ta, którą otrzymaliśmy w przypadku statycznego. Ale tym razem po opuszczamy populateList () metodę, cała pamięć liście zbierane są śmieci, bo nie mamy żadnego odniesienia do niego .

Dlatego musimy bardzo uważać na użycie zmiennych statycznych . Jeśli kolekcje lub duże obiekty są zadeklarowane jako statyczne , pozostają w pamięci przez cały okres istnienia aplikacji, blokując w ten sposób pamięć życiową, która mogłaby zostać wykorzystana w innym miejscu.

Jak temu zapobiec?

  • Zminimalizuj użycie zmiennych statycznych
  • Korzystając z singletonów, polegaj na implementacji, która leniwie ładuje obiekt, zamiast pobieżnie ładować

3.2. Poprzez niezamknięte zasoby

Za każdym razem, gdy tworzymy nowe połączenie lub otwieramy strumień, JVM przydziela pamięć dla tych zasobów. Kilka przykładów obejmuje połączenia z bazami danych, strumienie wejściowe i obiekty sesji.

Zapomnienie o zamknięciu tych zasobów może zablokować pamięć, a tym samym utrzymać je poza zasięgiem GC. Może się to zdarzyć nawet w przypadku wyjątku, który uniemożliwia wykonanie programu dotarcie do instrukcji obsługującej kod w celu zamknięcia tych zasobów.

W obu przypadkach otwarte połączenie pozostawione z zasobów zużywa pamięć , a jeśli nie zajmiemy się nimi, mogą one pogorszyć wydajność, a nawet spowodować OutOfMemoryError .

Jak temu zapobiec?

  • Zawsze używaj final block do zamykania zasobów
  • Kod (nawet w ostatnim bloku), który zamyka zasoby, sam nie powinien mieć żadnych wyjątków
  • Korzystając z Javy 7+, możemy skorzystać z bloku try -with-resources

3.3. Nieprawidłowe implementacje equals () i hashCode ()

Podczas definiowania nowych klas, bardzo częstym przeoczeniem jest niepisanie odpowiednich nadpisanych metod dla metod equals () i hashCode () .

HashSet i HashMap używają tych metod w wielu operacjach i jeśli nie zostaną one poprawnie zastąpione, mogą stać się źródłem potencjalnych problemów z wyciekiem pamięci.

Weźmy przykład trywialnej klasy Person i użyjmy jej jako klucza w HashMap :

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

Teraz wstawimy zduplikowane obiekty Person do mapy używającej tego klucza.

Pamiętaj, że mapa nie może zawierać zduplikowanych kluczy:

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

Here we're using Person as a key. Since Map doesn't allow duplicate keys, the numerous duplicate Person objects that we've inserted as a key shouldn't increase the memory.

But since we haven't defined proper equals() method, the duplicate objects pile up and increase the memory, that's why we see more than one object in the memory. The Heap Memory in VisualVM for this looks like:

However, if we had overridden the equals() and hashCode() methods properly, then there would only exist one Person object in this Map.

Let's take a look at proper implementations of equals() and hashCode() for our Person class:

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; } }

And in this case, the following assertions would be true:

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

After properly overriding equals() and hashCode(), the Heap Memory for the same program looks like:

Another example is of using an ORM tool like Hibernate, which uses equals() and hashCode() methods to analyze the objects and saves them in the cache.

The chances of memory leak are quite high if these methods are not overridden because Hibernate then wouldn't be able to compare objects and would fill its cache with duplicate objects.

How to Prevent It?

  • As a rule of thumb, when defining new entities, always override equals() and hashCode() methods
  • It's not just enough to override, but these methods must be overridden in an optimal way as well

For more information, visit our tutorials Generate equals() and hashCode() with Eclipse and Guide to hashCode() in Java.

3.4. Inner Classes That Reference Outer Classes

This happens in the case of non-static inner classes (anonymous classes). For initialization, these inner classes always require an instance of the enclosing class.

Every non-static Inner Class has, by default, an implicit reference to its containing class. If we use this inner class' object in our application, then even after our containing class' object goes out of scope, it will not be garbage collected.

Consider a class that holds the reference to lots of bulky objects and has a non-static inner class. Now when we create an object of just the inner class, the memory model looks like:

However, if we just declare the inner class as static, then the same memory model looks like this:

This happens because the inner class object implicitly holds a reference to the outer class object, thereby making it an invalid candidate for garbage collection. The same happens in the case of anonymous classes.

How to Prevent It?

  • If the inner class doesn't need access to the containing class members, consider turning it into a static class

3.5. Through finalize() Methods

Use of finalizers is yet another source of potential memory leak issues. Whenever a class' finalize() method is overridden, then objects of that class aren't instantly garbage collected. Instead, the GC queues them for finalization, which occurs at a later point in time.

Additionally, if the code written in finalize() method is not optimal and if the finalizer queue cannot keep up with the Java garbage collector, then sooner or later, our application is destined to meet an OutOfMemoryError.

To demonstrate this, let's consider that we have a class for which we have overridden the finalize() method and that the method takes a little bit of time to execute. When a large number of objects of this class gets garbage collected, then in VisualVM, it looks like:

However, if we just remove the overridden finalize() method, then the same program gives the following response:

How to Prevent It?

  • We should always avoid finalizers

For more detail about finalize(), read section 3 (Avoiding Finalizers) in our Guide to the finalize Method in Java.

3.6. Interned Strings

The Java String pool had gone through a major change in Java 7 when it was transferred from PermGen to HeapSpace. But for applications operating on version 6 and below, we should be more attentive when working with large Strings.

If we read a huge massive String object, and call intern() on that object, then it goes to the string pool, which is located in PermGen (permanent memory) and will stay there as long as our application runs. This blocks the memory and creates a major memory leak in our application.

The PermGen for this case in JVM 1.6 looks like this in VisualVM:

In contrast to this, in a method, if we just read a string from a file and do not intern it, then the PermGen looks like:

How to Prevent It?

  • The simplest way to resolve this issue is by upgrading to latest Java version as String pool is moved to HeapSpace from Java version 7 onwards
  • If working on large Strings, increase the size of the PermGen space to avoid any potential OutOfMemoryErrors:
    -XX:MaxPermSize=512m

3.7. Using ThreadLocals

ThreadLocal (discussed in detail in Introduction to ThreadLocal in Java tutorial) is a construct that gives us the ability to isolate state to a particular thread and thus allows us to achieve thread safety.

When using this construct, each thread will hold an implicit reference to its copy of a ThreadLocal variable and will maintain its own copy, instead of sharing the resource across multiple threads, as long as the thread is alive.

Despite its advantages, the use of ThreadLocal variables is controversial, as they are infamous for introducing memory leaks if not used properly. Joshua Bloch once commented on thread local usage:

“Sloppy use of thread pools in combination with sloppy use of thread locals can cause unintended object retention, as has been noted in many places. But placing the blame on thread locals is unwarranted.”

Memory leaks with ThreadLocals

ThreadLocals are supposed to be garbage collected once the holding thread is no longer alive. But the problem arises when ThreadLocals are used along with modern application servers.

Modern application servers use a pool of threads to process requests instead of creating new ones (for example the Executor in case of Apache Tomcat). Moreover, they also use a separate classloader.

Since Thread Pools in application servers work on the concept of thread reuse, they are never garbage collected — instead, they're reused to serve another request.

Now, if any class creates a ThreadLocal variable but doesn't explicitly remove it, then a copy of that object will remain with the worker Thread even after the web application is stopped, thus preventing the object from being garbage collected.

How to Prevent It?

  • It's a good practice to clean-up ThreadLocals when they're no longer used — ThreadLocals provide the remove() method, which removes the current thread's value for this variable
  • Do not use ThreadLocal.set(null) to clear the value — it doesn't actually clear the value but will instead look up the Map associated with the current thread and set the key-value pair as the current thread and null respectively
  • It's even better to consider ThreadLocal as a resource that needs to be closed in a finally block just to make sure that it is always closed, even in the case of an exception:
    try { threadLocal.set(System.nanoTime()); //... further processing } finally { threadLocal.remove(); }

4. Other Strategies for Dealing With Memory Leaks

Although there is no one-size-fits-all solution when dealing with memory leaks, there are some ways by which we can minimize these leaks.

4.1. Enable Profiling

Java profilers are tools that monitor and diagnose the memory leaks through the application. They analyze what's going on internally in our application — for example, how memory is allocated.

Using profilers, we can compare different approaches and find areas where we can optimally use our resources.

We have used Java VisualVM throughout section 3 of this tutorial. Please check out our Guide to Java Profilers to learn about different types of profilers, like Mission Control, JProfiler, YourKit, Java VisualVM, and the Netbeans Profiler.

4.2. Verbose Garbage Collection

By enabling verbose garbage collection, we're tracking detailed trace of the GC. To enable this, we need to add the following to our JVM configuration:

-verbose:gc

By adding this parameter, we can see the details of what's happening inside GC:

4.3. Use Reference Objects to Avoid Memory Leaks

We can also resort to reference objects in Java that comes in-built with java.lang.ref package to deal with memory leaks. Using java.lang.ref package, instead of directly referencing objects, we use special references to objects that allow them to be easily garbage collected.

Reference queues are designed for making us aware of actions performed by the Garbage Collector. For more information, read Soft References in Java Baeldung tutorial, specifically section 4.

4.4. Eclipse Memory Leak Warnings

For projects on JDK 1.5 and above, Eclipse shows warnings and errors whenever it encounters obvious cases of memory leaks. So when developing in Eclipse, we can regularly visit the “Problems” tab and be more vigilant about memory leak warnings (if any):

4.5. Benchmarking

We can measure and analyze the Java code's performance by executing benchmarks. This way, we can compare the performance of alternative approaches to do the same task. This can help us choose a better approach and may help us to conserve memory.

For more information about benchmarking, please head over to our Microbenchmarking with Java tutorial.

4.6. Code Reviews

Finally, we always have the classic, old-school way of doing a simple code walk-through.

In some cases, even this trivial looking method can help in eliminating some common memory leak problems.

5. Conclusion

In layman's terms, we can think of memory leak as a disease that degrades our application's performance by blocking vital memory resources. And like all other diseases, if not cured, it can result in fatal application crashes over time.

Wycieki pamięci są trudne do rozwiązania, a ich znalezienie wymaga skomplikowanej biegłości i znajomości języka Java. W przypadku wycieków pamięci nie ma jednego rozwiązania, które byłoby odpowiednie dla wszystkich, ponieważ przecieki mogą wystąpić w wyniku wielu różnych zdarzeń.

Jeśli jednak uciekamy się do najlepszych praktyk i regularnie przeprowadzamy rygorystyczne przechodzenie przez kod i profilowanie, możemy zminimalizować ryzyko wycieków pamięci w naszej aplikacji.

Jak zawsze fragmenty kodu używane do generowania odpowiedzi VisualVM przedstawione w tym samouczku są dostępne w witrynie GitHub.