Вниз по кроличьей норе и в переулок утечки памяти

Dec 05 2022
Вы инженер-программист и только что присоединились к новой команде, задачей которой является увеличение и улучшение мониторинга. Работая над этой новой задачей, вы обнаруживаете забытую службу, которая обрабатывает все фоновые задачи, такие как восстановление, отчеты и другие длительные задачи.
«Лучший способ объяснить это — сделать это». - Приключения Алисы в стране чудес Льюиса Кэрролла

Вы инженер-программист и только что присоединились к новой команде, задачей которой является увеличение и улучшение мониторинга. Работая над этой новой задачей, вы обнаруживаете забытую службу, которая обрабатывает все фоновые задачи, такие как восстановление, отчеты и другие длительные задачи. Но поскольку это «задний двор», никто не думал, что его стоит измерять… до сих пор. Однако, как только вы начнете, вы обнаружите, что эта служба постоянно работает с использованием памяти более 90% в любое время.

Скриншот утечки памяти

«Как мы сюда попали, — думаете вы про себя, — и что теперь?»

Ваша команда считает, что лучший способ войти в команду и завоевать их доверие — решить эту проблему.

Я Шей, инженер-программист с более чем 10-летним опытом, и я сталкивался с некоторыми подобными проблемами. Если вы думаете, что у вас есть похожая проблема или вы можете столкнуться с ней в будущем, вы найдете здесь несколько полезных советов. Итак, давайте начнем, не так ли?

Основы

Прежде чем решать какую-либо проблему, важно установить некоторые основные правила. Я не вижу быстрого пути к этому шагу, но есть две книги, которые могут помочь и которые должен прочитать каждый разработчик — « Чистый код» и «Чистый кодер», написанные Робертом Сесилом Мартином, также известным как «Дядя Боб». делает хороший код и что может привести к плохому коду. Bealdung также предоставляет передовой опыт и дополнительную информацию.

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

Известные проблемы и легкие плоды

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

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

public List<Item> matchedItems(List<Item> liOne, List<Item> liTwo){
List<Item> outcome = new ArrayList<Item>();
for(Item one : liOne){
   for(Item two : liTwo){
      if (one.value().equals(two.value()){
            outcome.add(one);
            break;
         }
      }
   }
return outcome
}

Также в коде обычно «прячутся» вложенные циклы. Можно было бы выглядеть примерно так:

public List<Item> matchedItems(List<Item> liOne, List<Item> liTwo){
List<Item> outcome = new ArrayList<Item>();
for(Item one : liOne){
   if(existsInList(liTwo , one.value)){
      outcome.add(one);
      break;
   }
return outcome
}

private boolean existsInList(List<Item> items , String value){
  for(Item item : items){
    if (item.value().equals(value){
      return true;
    }
  }
  return false;
}

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

public List<Item> matchedItems (List<Item> liOne, List<Item> liTwo){
Map<String value,Item item> mappedItems = new HashMap();
for(Item one : liOne){
      mappedItems.put(liOne.value(), one);
   }
List<Item> outcome = new ArrayList<Item>();
for(Item two: liTwo) {
      if(mappedItems.containsKey(two.value()) {
        outcome.put(two);
      }
   }
return outcome;
}

Планировщики Почти каждый современный фреймворк Java использует планировщик, будь то Quartz или Spring Boot. Таким образом, вы можете столкнуться с проблемой, если вам нужно использовать большую полезную нагрузку .

@Scheduled(fixedDelayString = "1000")
public void collectMessageFromSQS() {
   ReceiveMessageRequest receiveMessageRequest = new ReceiveMessageRequest(sqsUrl);
   receiveMessageRequest.setMaxNumberOfMessages(config.getMaxMessageToPull());
   ReceiveMessageResult receiveMessageResult = config.amazonSQSAsync().receiveMessage(receiveMessageRequest);
   //handle SQS message
}

Так что же можно сделать ? Во-первых, асинхронная работа заставит фреймворки создать новый поток для достижения процесса, создав новый поток с использованием пула потоков. Таким образом, вы используете возможности платформы Java для нескольких потоков.
Возможно, мы можем использовать то же соединение или определить пул соединений (для SQS это не имеет значения).

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

Все вышеперечисленное является хорошей практикой для отладки, но работа с анонимными функциями сложнее, например:

List<Prop> goodProps = new ArrayList();
list.stream().forEach(item -> {
   for(prop p : item.props){
      if (p instanceof GoodValue){
         goodProps.add(p);
      }
   }
});
return goodProps;

List<Prop> goodPropes = list
                            .stream()
                            .map(Prop :: item.getEnrichedProp)
                            .filter(p -> p instanceOf GoodValue)
                            .collect(toList());
return goodProps;

Поиск проблем Статические методы и статические объекты
Сборщик мусора не собирает статические переменные, поэтому, как только вы их создадите, они останутся до конца жизни JVM. Итак, что нужно сделать? Давайте посмотрим на пример:

public static Optional<Object> convertToJson(final String jsonString) {
   ObjectMapper mapper = new ObjectMapper();
   try {
       return Optional.of(mapper.readValue(jsonString, Object.class));
   } catch (Exception e) {
       return Optional.empty();
   }
}

состояние гонки

Допустим, у нас есть два потока, 1 и 2. Оба они обращаются к одной и той же статической функции, но поскольку блок кода не синхронизирован и не является одноэлементным, в некоторых случаях могут быть созданы два экземпляра кода. Кроме того, если вы внимательно посмотрите на сам код, мы создаем новый ObjectMapper для каждого запроса, что означает, что он будет выделять новый объект каждый раз, поскольку метод является статическим.

Итак, что мы можем сделать по-другому?

public Optional<Object> convertToJson(final String jsonString) {
   ObjectMapper mapper = new ObjectMapper();
   try {
       return Optional.of(mapper.readValue(jsonString, Object.class));
   } catch (Exception e) {
       return Optional.empty();
   }
}

public class jsonUtils { 
  private final ObjectMapper mapper = new ObjectMapper();
  public Optional<Object> convertToJson(final String jsonString) {
    try {
       return Optional.of(mapper.readValue(jsonString, Object.class));
    } catch (Exception e) {
       return Optional.empty();
    }
  }
}

То же самое касается констант:

public class MyConstants{
     public static String MY_STRING = "this is my String";
}

Правильный путь следующий

public final class MyConstants{
    private MyConstants()
    public final static String MY_STRING = "this is my String";
}

Сторонние SDK, декораторы и очереди

При работе с пакетами SDK, обычно для облачных сервисов, таких как SQS или управляемые сервисы Kafka, некоторые умеют создавать подключения, но поиск проблем с распределением ресурсов затруднен и требует дополнительной работы. Возможно, лучше всего выполнить поиск в Google, чтобы узнать, не зарегистрировал ли кто-нибудь проблему на GITHUB или не предоставил обходной путь для такой проблемы.

C потерять все оставшиеся соединения

Неважно, создаете ли вы соединения с базой данных, открываете веб-сокет или даже старый обычный HTTP-клиент. если вы получили соединение, то вам следует подумать о том, как закрыть соединение и разблокировать его.

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

Форматы регистраторов и MDC
Большинство форматов журналов и шаблонов форматов основаны на объекте, который называется Mapped Diagnostic Context (MDC). Этот объект очень старый (из Java 1.1) и не изменился с момента его создания. Это не потокобезопасно, но большинство шаблонов используют его.

Если вы используете его, освободите значение, когда закончите, выполнив следующие действия:

mdc.clear();

Дамп памяти

Для этого нет ярлыков. Вам нужно будет войти в систему, которая содержит эту JVM. Это может быть машина, виртуальная машина или модуль kubernetes для каждого из них. Я рекомендую вам использовать два типа выборки — дамп потока и дамп кучи.

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

Скорость потребления памяти

Насколько я понимаю, есть три типа утечек:

«Ударь стену», что означает, что что-то в приложении сразу истощает всю память.

«Лестница», где вы видите постепенное увеличение в течение относительно короткого периода времени.

«Утечка воды», когда вы видите постоянное увеличение в течение длительного периода времени.

"Ударить стену"
Лестница
Утечка воды

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

Чтение, оценка, цикл печати

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

Пробный прогон

Итак, вы нашли все утечки и думаете, что все готово… но подождите! Теперь пришло время

выполните пробный запуск в непроизводственной среде, развернув ее в QA. Затем запустите сквозной тест, чтобы измерить производительность выделения памяти для небольших объемов данных.

Я считаю, что это самый ответственный этап. Это может быть самый сложный шаг, но его пропуск может привести к серьезным проблемам.

Заключительные слова

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

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

Что касается меня, вот мой прогресс:

Финальный этап печати экрана

Вы можете видеть, что это потребовало большого количества проб и ошибок, но в конце концов мне удалось уменьшить использование памяти моим приложением с 97% до примерно 75%. Это много, и это повлияет на стабильность моего приложения.

Удачного кодирования и продолжайте учиться!