Введение: «Сломать — не построить»
Каждый Java-разработчик хоть раз проклинал утечку памяти (memory leak) в своём production-приложении. Приложение падает с OutOfMemoryError, сервер уходит в ребут, пользователи пишут гневные письма. Но есть и обратная сторона медали: как создать утечку памяти в Java намеренно? Звучит безумно, но именно этот вопрос (оригинал с Stack Overflow) собрал тысячи просмотров.
Зачем это знать? Чтобы понимать механику сборщика мусора (GC) и антипаттерны, которые превращают ваш код в решето. В 2025 году, когда Java 21 с виртуальными потоками и Loom уже стандарт, старые утечки никуда не делись — они просто маскируются под новые фичи.
В этой статье мы не только покажем, как «испортить» Java-приложение, но и дадим лучшие практики обнаружения утечек в 2025 году. Готовы? Поехали!
Немного теории: почему Java не «вечная»
Многие новички свято верят, что сборщик мусора (GC) автоматически удаляет всё, что не нужно. Реальность жестока: утечка памяти в Java возможна, если объект остаётся достижимым (reachable) из корневых ссылок (GC roots), но при этом больше не нужен приложению.
В 2025 году, с появлением Value Objects (Project Valhalla) и улучшенных ZGC (сборщик с временем паузы < 1 мс), ситуация с производительностью улучшилась, но логика утечек осталась прежней. GC не телепат — он не знает, что вы забыли обнулить ссылку в HashMap.
Способ №1: Классика — «забывчивый» HashMap
Самый популярный антипаттерн, который кочует из статьи в статью. Но без него — никуда.
Как работает утечка
Вы кладёте объекты в HashMap (или HashSet) в качестве ключей, но забываете их удалить. Если ключи — mutable-объекты, которые меняют хеш-код после вставки, то вы гарантированно получите утечку памяти Java.
import java.util.HashMap;
import java.util.Map;
public class LeakyMapExample {
public static void main(String[] args) {
Map<StringBuilder, String> map = new HashMap<>();
for (int i = 0; i < 100_000; i++) {
StringBuilder key = new StringBuilder("key_" + i);
map.put(key, "value_" + i);
// "Забыли" удалить — ключи висят вечно
}
System.out.println("Размер карты: " + map.size());
// Утечка: 100_000 объектов StringBuilder + String в куче
}
}
Почему это утечка? Пока жива ссылка на map, все ключи и значения остаются в heap. Даже если логика приложения больше не использует эти данные, GC их не тронет.
Лучшая практика 2025
- Используйте
WeakHashMap, если ключи — временные объекты. - Для кэширования —
CaffeineилиGuava Cacheс лимитами по размеру/времени. - Никогда не используйте
StringBuilderкак ключ — только immutable-типы.
Способ №2: Статические коллекции — тихий убийца
Статические поля живут до конца работы JVM. Если вы складываете данные в static List или static Map и не чистите их, вы получаете утечку памяти Java на уровне класса.
import java.util.ArrayList;
import java.util.List;
public class StaticLeak {
private static final List<byte[]> CACHE = new ArrayList<>();
public static void addData() {
// Каждый вызов добавляет 10 МБ в статический список
CACHE.add(new byte[10 * 1024 * 1024]);
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < 1_000; i++) {
addData();
Thread.sleep(100);
}
// OutOfMemoryError неизбежен
}
}
В 2025 году эта проблема особенно актуальна для виртуальных потоков (Virtual Threads). Если вы используете ThreadLocal с виртуальными потоками, а пул потоков бесконечен, каждый новый поток может создать новый объект в статической карте — привет, утечка!
Способ №3: Callback-ад и слушатели
Вы подписались на событие, но забыли отписаться. Классика жанра.
import java.util.ArrayList;
import java.util.List;
public class CallbackLeak {
interface Listener {
void onEvent();
}
private static final List<Listener> listeners = new ArrayList<>();
public static void register(Listener l) {
listeners.add(l);
}
public static void main(String[] args) {
for (int i = 0; i < 1_000_000; i++) {
final int id = i;
register(() -> System.out.println("Event " + id));
// Никто не вызывает listeners.clear()
}
// Каждый лямбда-объект захватывает id — утечка!
}
}
Нюанс: лямбды в Java захватывают переменные. Каждая лямбда — это отдельный объект в куче. Если слушателей миллион, а вы не отписываетесь, GC будет держать их вечно.
Способ №4: Внутренние классы и неявные ссылки
Нестатический внутренний класс (inner class) всегда содержит неявную ссылку на внешний класс. Если вы создаёте экземпляр внутреннего класса и сохраняете его, внешний объект никогда не будет собран GC.
public class OuterLeak {
private byte[] bigData = new byte[100 * 1024 * 1024]; // 100 МБ
class Inner {
void doSomething() {
System.out.println("I'm leaking!");
}
}
public static void main(String[] args) {
OuterLeak outer = new OuterLeak();
Inner inner = outer.new Inner();
// outer = null; // Закомментировано — утечка!
// inner всё ещё держит ссылку на outer
System.gc(); // Не поможет — inner жива
}
}
Решение: делайте внутренние классы статическими (static class Inner), если они не должны иметь доступ к полям внешнего класса. Или используйте record (Java 16+), которые по умолчанию immutable.
Инструменты поиска утечек в 2025 году
Ручной поиск утечек — прошлый век. Вот лучшие практики для современного Java-разработчика:
1. JProfiler и YourKit
Классические профилировщи