ИТ Блог. Администрирование серверов на основе Linux (Ubuntu, Debian, CentOS, openSUSE)

Пять основных причин утечки памяти в Java и рекомендаций по кодированию

Пять основных причин утечки памяти в Java и рекомендаций по кодированию

Автоматический процесс сборки мусора в Java является фундаментальной особенностью, которая отличает его от предшественников, таких как C ++ и C. Виртуальная машина Java (JVM) использует этот процесс для управления памятью, поэтому вы можете сосредоточиться на разработке основной бизнес-логики. Несмотря на то, что это эффективный инструмент, он не лишен недостатков, приводящих к значительным проблемам с загрузкой памяти.

Сборщик мусора автоматически удаляет неиспользуемые объекты или объекты без ссылок в стеке и кучной памяти, освобождая место для последующих операций. Однако в некоторых ситуациях сборщику мусора не удается удалить объекты, которые больше не используются, из памяти кучи, тем самым поддерживая их без необходимости. Это известно как утечка памяти. Это может затруднить выделение ресурсов памяти программам, которые в них нуждаются, и в конечном итоге привести к java.lang.OutOfMemoryError. Со временем это снижает производительность вашей системы.

Причин переполнения памяти может быть множество, но утечки памяти являются наиболее распространенными. В этой статье объясняются наиболее распространенные ситуации, которые приводят к утечкам памяти, а также приводятся некоторые рекомендации по кодированию, позволяющие смягчить их или избежать.

 

Пять основных причин утечки памяти в Java

В этой статье рассматриваются следующие пять причин утечек памяти:

 

В каждом разделе сначала объясняется проблема, а затем предлагаются некоторые рекомендации по кодированию для ее решения.

 

Чрезмерное использование статических полей

В любом приложении Java объявленные статические переменные будут оставаться в оперативной памяти на протяжении всего жизненного цикла этого приложения, то есть пока оно все еще работает. Чем больше статических полей вы объявляете, тем больше кучи памяти вы потребляете в жизненном цикле приложения. Чрезмерное использование статических полей может подвергнуть вашу Java-программу потенциальной утечке памяти. Рассмотрим следующий код:

import java.util.ArrayList; 
import java.util.List; 
import java.util.Random; 

public class StaticFieldMemoryLeak { 
        public static List list = new ArrayList<>(); 
       public void generateRandomValues() { 
            Random random = new Random(); 
            for (int i = 0; i < 10000000; i++) { 
                list.add(random.nextLong()); 
            } 
        } 
        public static void main(String[] args) { 
            new StaticFieldMemoryLeak().generateRandomValues(); 

        // Дальнейшие реализации
        } 
}

 

При запуске этого кода статическая переменная list увеличивается в пределах for цикла в методе generateRandomValues(). Каждый элемент в списке будет занимать некоторую часть памяти кучи. Обычно сборщик мусора должен удалять список после выхода приложения из метода generateRandomValues(), но здесь это не так.

Поскольку статические поля будут оставаться в памяти на протяжении всего жизненного цикла приложения, список и все его элементы сохранят свои соответствующие места в памяти, даже если они не понадобятся в последующих операциях приложения. Любым другим программным реализациям придется использовать все оставшееся пространство.

 

Лучшие практики для статических полей

Сведите использование статических полей к минимуму и объявляйте статические переменные только тогда, когда это необходимо в контексте приложения, особенно с коллекциями или большими объектами — например, когда вам нужна только одна копия этих значений.

Выбирайте ленивую загрузку объектов, а не быструю. Ленивая загрузка объектов, которые не нужны для начальных операций программы, может ограничить использование статического поля.

 

Неправильные реализации equals() и hashCode()

Объекты обычно отличаются друг от друга по значениям equals() и hashCode(). При создании пользовательского класса важно переопределить и определить методы equals() и hashCode(). Это связано с тем, что коллекции, содержащие уникальные элементы, зависят от реализации этих методов.

Когда эти методы переопределены неправильно, а класс объявлен как ключ в HashMap, коллекция, вероятно, будет содержать дубликаты ключей. Аналогично, если объекты этого класса вставлены в a HashSet, в коллекции будут дублирующиеся элементы. Это не только противоречит принципам этих коллекций, но и эти повторяющиеся элементы также будут занимать ненужное место в кучной памяти и могут повлиять на производительность приложения в долгосрочной перспективе.

Вот пример класса, который демонстрирует описанный выше сценарий:

public class Product { 
    private String productName; 

    public Product(String productName) { 
        this.productName = productName; 
    } 
}

 

В приведенном выше классе  equals()нет переопределенных реализаций методов hashCode() и Product. Итак, на этом этапе Java-программа распознает разные экземпляры этого класса как отдельные объекты, даже если они имеют одинаковое значение productName.

Вот пример, иллюстрирующий эту проблему:

import org.junit.jupiter.api.Assertions; 
import org.junit.jupiter.api.Test; 

import java.util.HashMap; 

public class ProductUnitTests { 

    @Test 
    void testThatMemoryLeakThroughHashMapWhenHashCodeAndEqualsNotOverridden(){ 
        Map<product, long=""> map = new HashMap<>(); 
        for (long i = 0; i < 10; i++) { 
            map.put(new Product("Shirt"), i); 
        } 

        Assertions.assertNotEquals(1, map.size()); 
    } 
}</product,>

 

Приведенный выше тест показывает, что ключ класса HashMap with a Product содержит дубликаты ключей с тем же значением productName, что и оператор assert. Дубликаты ключей в приведенном выше примере возникли из-за отсутствия пользовательского equals() метода для различения объектов Product с тем жеproductName, что и дубликаты. В результате эти повторяющиеся объекты вызовут утечку памяти в куче памяти.

 

Лучшие практики для реализаций equals() и hashCode()

Правильно переопределяя методы equals() и hashCode(), вы предотвращаете появление дублирующихся объектов в коллекции, обеспечивая эффективное использование пространства в памяти.

Для этого добавьте в класс Product следующие реализации:

@Override 
    public boolean equals(Object o) { 
        if (o == this) return true; 
        if (!(o instanceof Product product)) { 
            return false; 
        } 
        return product.productName.equals(productName); 
    } 

    @Override 
    public int hashCode() { 
        int hash = 17; 
        hash = productName != null ? 31 * hash + productName.hashCode() : hash; 

        return hash; 
    }

 

Используйте следующий тест, чтобы подтвердить, что вы успешно переопределили методы equals() и hashCode():

HashMap<product, long=""> map = new HashMap<>(); 
for (long i = 0; i < 10; i++) { 
    map.put(new Product("Shirt"), i); 
} 
Assertions.assertEquals(1, map.size());</product,>

 

Незакрытые ресурсы

Когда вы открываете новое соединение или поток, память выделяется для этого ресурса. Пока он открыт, этому ресурсу будет по-прежнему выделяться память, даже если вы его не используете. Если эти ресурсы не реализованы должным образом — например, если перед закрытием ресурса возникает исключение — сборщик мусора может потерять к ним доступ, что приведет к утечке памяти, как вы можете видеть в следующем примере:

import java.io.FileWriter; 
import java.io.IOException; 
import java.io.PrintWriter; 

public class Resources { 

    public void writeToFile() { 
        FileWriter fileWriter; 
        PrintWriter printWriter; 

        try { 
            fileWriter = new FileWriter("test.txt"); 
            printWriter = new PrintWriter(fileWriter); 
            printWriter.println("test"); 

       } catch (IOException e) { 
            e.printStackTrace(); 
        } 

    } 
}

 

Метод writeToFile(), описанный выше, открывает соединение для записи в test.txt файл, и JVM выделяет память для этого соединения. Этот метод подвержен утечкам памяти, потому что соединение не закрывается после записи в файл. JVM предполагает, что соединение все еще необходимо, поэтому поддерживает выделение памяти.

 

Лучшие практики для незакрытых ресурсов

Убедитесь, что все открытые соединения и потоки закрыты после завершения их операций. Вы должны закрыть соединение в приведенном выше примере, вызвав метод  close() для printWriter объекта, как показано ниже:

        try { 
            fileWriter = new FileWriter("test.txt"); 
            printWriter = new PrintWriter(fileWriter); 
            printWriter.println("test"); 

        } catch (IOException e) { 
            e.printStackTrace(); 
        } finally { 
           if(printWriter != null){ 
	printWriter.close(); 
         } 
      }

 

Иногда исключение может быть выдано после открытия соединения, но до того, как оно попадет в close() инструкцию. Закрытие соединения внутри блока finally гарантирует, что соединение будет закрыто, независимо от того, выдает try блок неожиданное исключение или нет.

Если ваше приложение работает на Java 8 или выше, вы можете использовать оператор try-with-resources, чтобы гарантировать, что каждый AutoCloseable очищается после того, как try блок завершит свою работу:

    public void writeToFile() { 

        try (PrintWriter printWriter = new PrintWriter(new FileWriter("test.txt"))){ 
            printWriter.println("test"); 
        } catch (IOException e) { 
            e.printStackTrace(); 
        } 

    }

 

Не удаление объектов ThreadLocal

ThreadLocal – это объект Java, который позволяет вам оборачивать другие объекты, доступные только определенным потокам. Каждый поток имеет ссылку на ThreadLocal экземпляр на протяжении всего своего жизненного цикла.

Сегодня большинство веб-серверов поддерживают пул потоков или набор из нескольких потоков, каждый из которых обрабатывает отдельные веб-запросы. Потоки в пуле потоков не удаляются после завершения своей работы; вместо этого они повторно используются для следующего веб-запроса. Когда веб-запрос создает ThreadLocal экземпляр и не может удалить его после завершения своей работы, объект остается в потоке даже во время последующих запросов. В конечном итоге это блокирует сборщик мусора от очистки неиспользуемого объекта.

 

Лучшие практики для ThreadLocal

У каждого ThreadLocal объекта есть метод remove(), который вы можете вызвать для явной очистки объекта после завершения его работы:

ThreadLocal threadLocal = new ThreadLocal(); 
        threadLocal.set(10); 
        threadLocal.remove();

 

Вы также можете поместить threadLocal.remove() в finally область видимости, чтобы гарантировать удаление объекта  ThreadLocal в ситуации, когда непредвиденное исключение могло предотвратить удаление объекта.

 

Переопределение методаfinalize()

Всякий раз, когда сборщик мусора решает, что объект следует очистить, он вызывает для объекта метод finalize(). Этот метод освобождает ресурсы, выделенные объекту, прежде чем окончательно удалить его из памяти.

Иногда вы можете переопределить метод finalize() для выполнения пользовательских операций перед сбором мусора с объекта. Однако объекты с переопределенными финализаторами не собираются немедленно, даже если вы вызываете System.gc(). Они отправляются в очередь и очищаются каждый раз, когда сборщик мусора автоматически проверяет наличие объектов, на которые нет ссылок.

Когда скорость, с которой объекты с переопределенными finalize() методами отправляются в очередь, выше, чем может вместить очередь, память истощается, что в конечном итоге приводит к OutOfMemoryError.

 

Лучшие практики для  методаfinalize()

Чтобы предотвратить ненужное накопление объектов для сборки мусора, вам следует избегать переопределения finalize() метода.

В качестве альтернативы вы можете реализовать ранее упомянутые интерфейсы Java Closeable или AutoCloseable. Оба этих интерфейса ожидают, что вы предоставите конкретную реализацию close() метода, который запускается, когда экземпляр класса выходит за пределы области видимости, как показано ниже:

public class Product implements AutoCloseable{ 

    private String productName; 

    public Product(String productName) { 
        this.productName = productName; 
    } 

    @Override 
    public void close() throws Exception { 
        // Perform any custom operation 
    } 
}

 

Это также позволяет вам использовать объекты вашего класса в инструкции try-with-resources:

  try(Product product = new Product("Shirt")){ 

        }

 

Почему вы должны отслеживать использование памяти в приложениях Java

Мониторинг вашего Java-приложения является важной частью современного процесса разработки программного обеспечения. Он помогает выявлять и устранять утечки памяти и неэффективный код, оптимизировать приложение и поддерживать оптимальную производительность приложения. Утечки памяти могут снизить производительность приложений, что приведет к ухудшению взаимодействия с пользователем. Они также могут создавать потенциальные угрозы безопасности, такие как отказ в обслуживании (DOS). Если злоумышленник может контролировать скорость, с которой происходят утечки памяти в программе, он потенциально может исчерпать доступную память и в конечном итоге привести к аварийному завершению работы программы.

Улучшая использование памяти приложением, разработчики могут гарантировать, что приложение работает максимально эффективно. Это может уменьшить объем памяти, используемой приложением, и повысить общую производительность. Итак, важно отслеживать использование памяти и выявлять проблемы до того, как они станут серьезной проблемой.

 

Заключение

В этой статье представлен метод сборки мусора JVM для обработки выделения памяти. В нем также рассмотрены пять основных причин утечек памяти, которые вызывают перегрузку памяти, и некоторые рекомендации по предотвращению каждой из них.

Примеры, проиллюстрированные в этой статье, доступны на GitHub.

Exit mobile version