Як влаштована робота з памʼяттю в Java

Привіт, мене звуть Валентин Вівчарик, я вже доволі давно працюю як Senior Java Developer. У цій статті хотів би поглибитися у доволі складну та важливу тему, а саме як виглядає модель памʼяті в Java, її взаємодія зі збирачами сміття, та їхня конфігурація.

Модель памʼяті в Java

З яких частин складається памʼять у JVM

Загалом можна поділити памʼять у JVM на такі секції:

  • Stack;
  • Heap;
  • MetaSpace;
  • Code Cache.

Які ж функції вони виконують? Якщо коротко, то на кожен потік в Java використовується окремий стек, що зберігає примітивні значення: int, float, double, char тощо, а також посилання на обʼєкти, що створюються в кучі.

Що ж в цьому випадку за посилання такі? Коли ми створюємо обʼєкт в Java, то прописуємо це наступним чином: Object object = new Object();. Тож Object object створює посилання з назвою object, new Object() створює сам обʼєкт в Heap, куди буде вести посилання, з використанням конструктора за замовчуванням, а = просто присвоює цьому посиланню адресу в кучі, де знаходиться цей обʼєкт.

В такому випадку створюється Strong Reference, так зване міцне посилання, яке JVM використовує за замовчуванням. Про нього та інші типи посилань трохи згодом.

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

Також я згадав MetaSpace i Cache. Cache зберігає кеш який використовує JIT (Just-in-time compiler), Metaspace зберігає метадані необхідні для JVM, наприклад: модифікатори доступу, метадані класу (інформація про поля, методи), анотації та статичні дані. MetaSpace було запроваджено в Java 8, до цього було PermGen, але про це все згодом.

До Java 8 String Pool зберігався саме в PermGen, але після заміни на MetaSpace він мігрував у Heap.

Heap

Ну ніби все звучить не так складно, правда? Я трошки спростив опис, аби не перевантажувати тебе, читачу. Зараз ми послідовно будемо поглиблюватися в роботу з памʼяттю і буде трошки складніше.

Взагалі коли ми говоримо про кучу, здебільшого розмови закінчуються на тому, що там зберігаються обʼєкти і все, але насправді Heap має складнішу структуру, щоб легко взаємодіяти з різними збирачами сміття.

По-перше, куча має дві секції: Молоде покоління (Young Generation) та Старе покоління (Old Generation або Tenured). Молоде покоління також ділиться на три окремі секції памʼяті: Eden, S0, S1.

Тож під час створення обʼєкта, як ми це робили через new Object(), новостворений обʼєкт потрапляє в Eden-секцію кучі, тут зберігаються всі молоді обʼєкти.

Eden Space

  • Призначення. Це область, де нові об’єкти спочатку створюються. Коли застосунок створює новий об’єкт, JVM в першу чергу розміщує його в Eden-просторі.
  • Збір сміття. Коли Eden заповнюється, запускається процес збору сміття, відомий як Minor GC. Цей процес очищає Eden, переміщуючи вцілілі об’єкти до однієї з Survivor-зон.

Survivor Spaces (S0 і S1)

  • Призначення. Survivor-зони використовуються для зберігання об’єктів, які пережили збірку сміття в Eden. Ці зони допомагають фільтрувати ті об’єкти, які часто використовуються, але ще не готові для переміщення в старе покоління (Old Generation).
  • Ротація. Об’єкти переміщуються між двома Survivor-зонами для кількох циклів збору сміття. Наприклад, після збору сміття в Eden, вцілілі об’єкти переміщуються в S0. Під час наступного Minor GC об’єкти з Eden і вцілілі з S0 переміщаються в S1. Процес чергується між S0 і S1 після кожного Minor GC.
  • Промоція. Якщо об’єкт залишається достатньо довго в Survivor-зоні і не «вмирає», він може бути «просунутий» (promoted) в старе покоління. Часто існує порогове значення для кількості циклів, яке об’єкт повинен пережити, перш ніж бути просунутим.

Старе покоління (Tenured)

Старе покоління містить об’єкти, які пережили декілька циклів збору сміття в молодому поколінні. Це покоління оптимізоване для зберігання об’єктів, що живуть довго. В старому поколінні збір сміття відбувається рідше, але цей процес є більш обтяжливим і може викликати значні паузи в роботі програми.

Volatile-змінні

Volatile-змінні зберігаються в купі для того, щоб усі потоки могли мати до них доступ та їх змінювати. JVM забезпечує прямий доступ до цих змінних в памʼяті, уникаючи локальних кешів.

Типи збирань сміття

Якщо не заглиблюватись (поки) в роботу збирання сміття, то можна виділити три його типи: Minor GC, Mixed GC, Major GC. Minor GC запускається, коли памʼять в Eden закінчується, і опрацьовує обʼєкти в молодому поколінні, Mixed GC очищує вибрані регіони як старого, так і молодого покоління, а Major (Full) GC запускається у випадку, якщо старе покоління заповнюється і працює набагато повільніше, обробляючи обʼєкти як з молодого, так і зі старого покоління.

Потрібно зауважити, що коли запускається збір сміття, це може зупинити всі потоки і змусити їх очікувати її закінчення.

Тож основними властивостями купи є:

  1. Доступ до неї здійснюється за допомогою складних методів керування пам’яттю, які включають молоде покоління, старе покоління та метапростір.
  2. Якщо простір купи заповнено, Java видає java.lang.OutOfMemoryError.
  3. Доступ до цієї пам’яті порівняно повільніший, ніж до стекової пам’яті.
  4. Ця пам’ять, на відміну від стека, не звільняється автоматично. Їй потрібен збирач сміття, щоб звільнити невикористані об’єкти, щоб зберегти ефективність використання пам’яті.
  5. На відміну від стека, купа не є потокобезпечною, і її потрібно захистити правильною синхронізацією коду.

Heap зберігає обʼєкти та volatile-змінні.

Stack, як структура даних

Загалом стек це не тільки секція в памʼяті, це окрема структура даних. Стек реалізовує моделі LIFO (Last In First Out). Що ж воно значить? Простіше зрозуміти на прикладі.

Уявімо: в нас є ящик для книг. Ми спочатку кладемо, наприклад, книгу з алгоритмів, потім книгу з Java, далі книгу з патернів. Тепер нам потрібно дістати книги одна за одною, і зверху стоїть книга з патернів, ми її поставили останньою, а витягнемо першою і так далі.

Тобто стек — це структура даних, де ми зберігаємо список даних в одному порядку, а дістаємо в протилежному. Якщо говорити про памʼять, то хорошим прикладом роботи стека буде рекурсія. Не буду зупинятись на цьому, бо це геть затягнеться :)

Основні властивості стека, як моделі памʼяті:

  1. Він зростає та зменшується, коли нові методи викликаються та повертаються відповідно.
  2. Змінні всередині стека існують лише доти, поки працює метод, який їх створив.
  3. Він автоматично розподіляється та звільняється, коли метод завершує виконання.
  4. Якщо ця пам’ять заповнена, Java видає java.lang.StackOverFlowError.
  5. Доступ до цієї пам’яті є швидким у порівнянні з пам’яттю купи.
  6. Ця пам’ять є потокобезпечною, оскільки кожен потік працює у власному стеку.

JVM створює окремий Stack на кожен потік, що зберігає примітивні типи даних та посилання на обʼєкти в Heap.

Metaspace та PermGen

PermGen

PermGen (Permanent Generation) була частиною Heap до Java 8. JVM відстежувала завантажені метадані класу в PermGen. Крім того, JVM зберігала весь статичний вміст у цьому розділі пам’яті. Це включає всі статичні методи, примітивні змінні та посилання на статичні об’єкти. Крім того, він містив дані про байт-код, імена та інформацію JIT. String Pool також був частиною цієї пам’яті.

Стандартний максимальний розмір пам’яті для 32-розрядної JVM становить 64 МБ і 82 МБ для 64-розрядної версії, але їх можна змінити:

  • ​​-XX:PermSize=[size] базовий або мінімальний розмір памʼяті для PermGen;
  • -XX:MaxPermSize=[size] максимальний розмір памʼяті для PermGen.

Ці команди недоступні в Java 8+, консоль просто видасть сповіщення, що їх підтримка завершилась до 8 версії Java. Через обмежений розмір пам’яті PermGen часто призводила до відомої помилки OutOfMemoryError. Простіше кажучи, у завантажувачів класів не збиралося сміття належним чином і, як наслідок, це спричиняло витік пам’яті.

Metaspace

Metaspace — це новий простір пам’яті. Починаючи з версії Java 8 він замінив старий простір пам’яті PermGen. Найсуттєвіша відмінність полягає в тому, як він обробляє розподіл пам’яті. Зокрема, ця область рідної пам’яті автоматично збільшується за замовчуванням.

У нас також є нові прапорці для налаштування пам’яті:

  • MetaspaceSize і MaxMetaspaceSize — ми можемо встановити верхню межу метапростору;
  • MinMetaspaceFreeRatio — мінімальний відсоток вільної місткості метаданих класу після збирання сміття;
  • MaxMetaspaceFreeRatio — максимальний відсоток вільної місткості метаданих класу після збирання сміття, щоб уникнути зменшення обсягу простору

Крім того, процес збирання сміття також отримує деякі переваги від цієї зміни. Збирач сміття тепер автоматично запускає очищення мертвих класів, коли використання метаданих класу досягає максимального розміру метапростору.

Тому за допомогою цього вдосконалення JVM зменшує ймовірність отримання помилки OutOfMemory. Попри всі ці вдосконалення, нам все одно потрібно контролювати та налаштовувати метапростір, щоб уникнути витоку пам’яті.

Метапростір зберігає мета дані про класи, методи, поля, модифікатори доступу тощо.

Code Cache

Кеш коду — це простір у JVM, де зберігається скомпільований байт-код. Очевидно, що найбільшим споживачем цієї пам’яті є компілятор JIT, тому ви також можете почути, що цю пам’ять називають кеш-кодом JIT.

Посилання в Java

Типи посилань в Java

Загалом у джаві налічується чотири типи посилань:

  • Strong Reference;
  • Weak Reference;
  • Soft Reference;
  • Phantom Reference.

Міцне посилання

Strong reference — це тип посилань, які Java використовує за замовчуванням. Тобто будь-який створений обʼєкт буде використовувати міцне посилання, якщо явно не вказати інше. Збирач сміття буде вважати обʼєкт живим доти, допоки цей обʼєкт має хоча б одне міцне посилання, що веде на нього.

До прикладу повернемося до Object object = new Object();. Тут створюється міцне посилання на новостворений обʼєкт в Eden, що в Heap. При object = null; ми кажемо, що це посилання більше нікуди не веде, тож оскільки більше посилань на цей обʼєкт немає, збирач сміття може його очистити з памʼяті під час наступного запуску.

Слабке посилання

Weak reference — це не стандартне посилання в Java, тому щоб його застосувати потрібно це явно вказати:

WeakReference<Object> weakObject = new WeakReference<>(new Object());

У цьому випадку ми створили слабке посилання, і щойно Eden заповниться, Minor GC видалить цей обʼєкт. Такі посилання часто застосовуються для кешування тимчасових даних або під час реалізації складних структур даних.

Звичайно, найвідомішим використанням цих посилань є клас WeakHashMap. Це реалізація інтерфейсу мапи, де кожен ключ зберігається як слабке посилання на цей ключ. Коли збирач сміття видаляє ключ, сутність, пов’язана з цим ключем, також видаляється.

Мʼяке посилання

Soft reference — посилання, що використовуються для реалізації кешів, чутливих до пам’яті. Буде очищено тільки при нестачі памʼяті (наприклад, перед OutOfMemoryError). Тобто здебільшого саме Major GC буде видаляти такі посилання. Ну і як з Weak reference, його потрібно вручну створювати:

SoftReference<Object> softObject = new SoftReference<>(new Object());

Оскільки програмне посилання діє як кеш, воно може залишатися доступним, навіть якщо сам обʼєкт таким не є. По суті, м’яке посилання придатне для збору тоді, і тільки якщо:

  • обʼєкт не сильно досяжний;
  • доступ до програмного посилання останнім часом не здійснювався.

Таким чином, програмне посилання може бути доступним протягом хвилин або навіть годин після того, як обʼєкт стає недоступним.

Фантомне посилання

Фантомні посилання в Java використовуються для визначення моменту, коли об’єкт буде видалено з пам’яті, дозволяючи виконати додаткові дії з очищення ресурсів.

Основні характеристики:

  1. Неотримувані посилання. Метод get() фантомного посилання завжди повертає null. Це означає, що ви не можете отримати доступ до об’єкта через фантомне посилання.
  2. Черга посилань (ReferenceQueue). Фантомні посилання створюються з прив’язкою до черги посилань. Після того, як об’єкт стає недосяжним за будь-якими іншими посиланнями, і GC визначає, що його можна зібрати, фантомне посилання додається до цієї черги.
  3. Очищення ресурсів. Фантомні посилання дозволяють виконати ресурсні очищення, такі як закриття файлів або звільнення пам’яті, після того, як об’єкт буде зібраний GC, але перед тим, як його пам’ять буде фактично звільнена.

Переваги використання:

  1. Контроль над звільненням ресурсів. Дозволяє точно контролювати момент звільнення ресурсів.
  2. Альтернатива finalize(). Забезпечує більш надійний та передбачуваний механізм очищення, ніж метод finalize().

Важливо знати, що System.gc() не запускає збирання сміття негайно — це просто підказка для JVM, щоб запустити процес.

Strong reference — стандартне посилання в Java, збирач сміття не видалить обʼєкт, доки він має хоча б одне міцне посилання.

Weak reference — слабке посилання, буде видалено першим збиранням сміття.

Soft reference — буде видалено збірником сміття тільки якщо є нестача памʼяті.

Phantom reference — дозволяє виконувати маніпуляції з очищення ресурсів перед збіркою сміття, але не дозволяють напряму впливати на обʼєкт.

Garbage Collectors

Загальний огляд збирачів сміття

Загалом у Java протягом оновлення версій змінювався збирач сміття за замовчуванням, а також додавались нові, які можна застосувати.

Найдавнішим збірником сміття можна назвати Serial GC. Потім зʼявився Parallel GC, який став збирачем сміття за замовчуванням, у Java 7 зʼявився G1GC, який став дефолтним у Java 9, але давайте по черзі.

Щоб побачити базові налаштування памʼяті та GC, використовуйте java -XX:+PrintCommandLineFlags -version.

Serial GC

Serial Garbage Collector — це найпростіша реалізація GC, оскільки вона в основному працює з одним потоком. У результаті ця реалізація GC заморожує всі потоки програми під час її запуску. Тому не варто використовувати його в багатопоточних програмах, наприклад у серверних середовищах. Використовує модель памʼяті Heap: Young generation, Old generation, PermGen.

Щоб застосувати його необхідно скористатися командою java -XX:+UseSerialGC -jar Application.java.

Фази збирання сміття:

Young Generation Collection (Minor GC):

  • Mark: Маркування живих об’єктів у молодому поколінні;
  • Copy: Копіювання живих об’єктів до Survivor Space.

Old Generation Collection (Major GC):

  • Mark: Маркування живих об’єктів у старому поколінні;
  • Sweep: Видалення непомічених об’єктів;
  • Compact: Ущільнення живих об’єктів для зменшення фрагментації.

Parallel GC

Parallel Garbage Collector також відомий як Throughput Garbage Collector.

Працює як і Serial GC, тільки використовує N потоків для очищення сміття в молодому поколінні та один потік для очищення сміття в старому поколінні. Застосовувався як для моделі памʼяті з PermGen (до Java 8), так і з моделлю памʼяті з metaspace (з Java 8).

-XX:ParallelGCThreads=n



java -XX:+UseParallelGC -jar Application.java

Garbage-First(G1)

Опис роботи

Цей збирач сміття доволі схожий на Parallel GC, але вже використовує поділ зон памʼяті (Eden, S0, S1, Tenured) на регіони до 32 МБ включно (може містити до 2048 регіонів), що забезпечує більшу гнучкість роботи.

Він є збирачем сміття за замовчуванням починаючи з Java 9, тому про нього поговоримо детальніше (був доданий у Java 7).

Можна увімкнути за допомогою java -XX:+UseG1GC -jar Application.java.

Збирач сміття Garbage-First (G1) призначений для багатопроцесорних машин з великим обсягом пам’яті. Він намагається з високою ймовірністю досягти цілей щодо часу паузи під час збирання сміття, забезпечуючи при цьому високу пропускну здатність з мінімальною потребою в конфігурації.

G1 має на меті забезпечити найкращий баланс між затримкою та пропускною здатністю, використовуючи поточні цільові програми та середовища, які мають такі особливості:

  1. Розміри купи до десяти гігабайтів і більше, причому понад 50% купи Java займають живі дані.
  2. Швидкість виділення та просування об’єктів, яка може суттєво змінюватися з часом.
  3. Значна кількість фрагментації в купі.
  4. Передбачувані цільові значення часу пауз, які не перевищують декількох сотень мілісекунд, що дозволяє уникнути довгих пауз при збиранні сміття.

Деякі операції завжди виконуються під час пауз для покращення пропускної здатності. Інші операції, які займають більше часу при зупиненому застосунку, такі як глобальне маркування, виконуються паралельно та одночасно з застосунком. Щоб скоротити паузи для рекультивації простору, G1 виконує рекультивацію простору поетапно, паралельно і крок за кроком.

G1 досягає передбачуваності, відстежуючи інформацію про попередню поведінку програми та паузи у збиранні сміття, щоб побудувати модель пов’язаних з цим витрат. Він використовує цю інформацію для визначення обсягу роботи, що виконується під час пауз. Наприклад, G1 в першу чергу очищає простір у найбільш ефективних зонах (тобто в зонах, які найбільше заповнені сміттям, звідси й назва).

G1 звільняє простір переважно за допомогою евакуації: живі об’єкти, знайдені у вибраних для збору ділянках пам’яті, копіюються до нових ділянок пам’яті, ущільнюючи їх у процесі. Після завершення евакуації простір, раніше зайнятий живими об’єктами, повторно використовується для розподілу застосунком.

G1 розділяє купу на множину рівних за розміром областей купи, кожна з яких є суміжною областю віртуальної пам’яті, як показано на малюнку нижче. Регіон є одиницею виділення та звільнення пам’яті. У будь-який момент часу кожен з цих регіонів може бути порожнім (світло-сірий) або призначеним певному поколінню, молодому чи старому. З надходженням запитів на пам’ять, менеджер пам’яті розподіляє вільні регіони. Менеджер пам’яті призначає їх поколінню, а потім повертає їх програмі як вільний простір, в якому вона може розміститися.

Молоде покоління містить Eden-області (червоні) і Survival-області (червоні з літерою «S»). Ці області виконують ту саму функцію, що й відповідні суміжні ділянки в інших колекторах, з тією різницею, що в G1 ці області, як правило, розташовані в пам’яті у вигляді шаблону, що не перетинається.

Tenured-області (світло-блакитні) складають старе покоління. Для об’єктів, які охоплюють кілька областей, області старого покоління можуть бути громіздкими (блакитний колір з літерою «H»).

Програма завжди розподіляє об’єкти до молодого покоління, тобто до Eden-областей, за винятком об’ємних об’єктів, які безпосередньо розподілено як такі, що належать до старого покоління.

Паузи збору сміття G1 можуть повернути простір у молодому поколінні в цілому, а також будь-який додатковий набір регіонів старого покоління під час будь-якої паузи збору. Під час паузи G1 копіює об’єкти з цього набору в один або декілька різних регіонів у купі. Регіон призначення для об’єкта залежить від регіону-джерела цього об’єкта: все молоде покоління копіюється в уцілілі або старі регіони, а об’єкти зі старих регіонів — в інші, інші старі регіони за допомогою старіння.

Якщо говорити в загальному, то на високому рівні присутні два види збирання сміття: Minor GC — збирає сміття тільки в зонах молодого покоління, та Major GC — збирає сміття спочатку в старому поколінні, а потім в молодому.

Фази циклу збирання сміття G1

  1. Young-only GC. Відбувається збірка тільки молодого покоління. Коли купа стає достатньо заповненою, G1 ініціює цю збірку та паралельно починає конкурентне маркування для ідентифікації живих об’єктів.
  2. Паралельний старт. Збірка молодого покоління починає процес маркування, який допомагає ідентифікувати живі об’єкти в старому поколінні.
  3. Перемаркування. Важлива пауза, яка завершує маркування, включаючи обробку посилань і видалення класів.
  4. Очищення. Визначає, чи слід проводити змішану збірку сміття. Якщо так, збірка молодого покоління завершується однією підготовчою змішаною збіркою.
  5. Фаза рекультивації простору. Серія змішаних зборів сміття, які очищають не тільки молоді, але й вибрані старі регіони. Фаза закінчується, коли подальше очищення старих регіонів вже не виправдане.

Після рекультивації простору цикл збору сміття перезапускається з іншою Minor GC-збіркою. Як підстраховка, у випадку нестачі памʼяті під час збирання інформації про живі обʼєкти може запуститися Major GC.

Цей цикл допомагає оптимізувати використання пам’яті, зменшувати паузи та підтримувати продуктивність застосунків. Коли JVM розпочинає роботу, вона спільно з вибраним збирачем сміття визначає структуру купи, включаючи поділ на молоде покоління, старе покоління, та в межах молодого покоління — області Eden і Survivor.

Цей поділ на зони зумовлений вибором конфігурації збирача сміття, але саме JVM відповідає за виділення пам’яті для цих зон при старті віртуальної машини.

Розподіл памʼяті

Під час запуску JVM:

  • JVM ініціалізує купу та, на основі обраного збирача сміття, поділяє її на необхідні зони: молоде покоління (Eden та Survivor) та старе покоління;
  • розмір та кількість кожної зони визначаються параметрами конфігурації JVM, які можуть бути задані користувачем через опції запуску (наприклад, -Xmx для максимального розміру купи).

Роль збирача сміття:

  • збирач сміття визначає, як оброблятимуться об’єкти у цих зонах під час роботи JVM, зокрема як вони будуть переміщені між зонами Eden, Survivor, та Old залежно від алгоритмів збирання сміття;
  • збирач сміття відповідає за оптимізацію збору сміття, просування об’єктів між зонами та збереження ефективності роботи купи.

Практичний приклад: якщо ви використовуєте G1 Garbage Collector, JVM розділяє купу на багато дрібних регіонів, які динамічно можуть використовуватися як Eden, Survivor, або Old залежно від поточних потреб управління пам’яттю.

Отже, саме JVM визначає, як купа буде розділена на старті, включаючи створення зони Eden, на основі конфігурації та вибраного збирача сміття, але збирач сміття визначає, як ці зони будуть використовуватися в процесі виконання програми.

Особливості

Основні особливості G1 GC:

  1. Регіональний підхід. Пам’ять поділена на множину регіонів, які можуть містити дані молодого або старого покоління. Це дозволяє G1 вибірково збирати регіони, що містять більше сміття, мінімізуючи паузи.
  2. Змішані збори сміття. G1 виконує не тільки молоді збори сміття, а й змішані, під час яких обробляються як молоді, так і вибрані старі регіони. Це допомагає уникнути великих пауз, асоційованих із повною збіркою сміття старого покоління.
  3. Паузи на збір сміття. G1 намагається контролювати тривалість пауз, прагнучи досягти заданого користувачем цільового часу паузи.
  4. Видалення фрагментації. Під час зборів сміття G1 переміщує об’єкти, щоб ущільнити пам’ять і зменшити фрагментацію, що підвищує ефективність використання пам’яті.

Робочі фази G1 GC:

  1. Початкове маркування (Initial Mark). Позначає живі об’єкти, які безпосередньо досяжні з коренів (наприклад, локальні змінні та активні потоки).
  2. Конкурентне маркування (Concurrent Marking). Продовжує процес маркування живих об’єктів під час роботи програми.
  3. Завершення маркування (Final Mark). Перевіряє та коригує будь-які зміни, що відбулись після конкурентного маркування.
  4. Очищення (Cleanup). Визначає регіони, повністю вільні від живих об’єктів і підготовлює їх до повторного використання.

Remembered Sets (RSet)

Remembered Sets — це структури даних, які використовує G1 Garbage Collector для відстеження посилань на об’єкти, розташовані в інших регіонах. Кожен регіон має свій власний Remembered Set, який зберігає інформацію про всі посилання з об’єктів з інших регіонів на об’єкти в цьому регіоні.

Приклад: якщо об’єкт у регіоні A має посилання на об’єкт у регіоні B, це посилання буде збережено в Remembered Set регіону B. Це дозволяє GC збирати регіон B без необхідності сканувати всю купу.

Write Barriers

Write Barriers — це механізми, що затримують запис даних. Вони використовуються для оновлення Remembered Sets під час зміни посилань на об’єкти. Коли програма змінює посилання на об’єкт, write barrier виконується, щоб відповідний RSet був оновлений.

Приклад: якщо об’єкт у регіоні A змінює своє посилання з об’єкта у регіоні B на об’єкт у регіоні C, Write Barrier забезпечить, що RSet для регіону B видалить це посилання, а RSet для регіону C додасть нове посилання. Ці механізми допомагають збирачу сміття ефективно відстежувати та управляти збором сміття, мінімізуючи необхідність великих зупинок програми для аналізу всієї купи.

Внутрішні об’єкти або поля класу в Java зберігаються у регіонах купи залежно від місця розташування об’єкта-контейнера (тобто об’єкта, який містить ці поля). Якщо поле класу є посиланням на інший об’єкт, який розташований в іншому регіоні, це посилання зазначатиметься в Remembered Set регіону, де знаходиться посиланий об’єкт.

Це важливо для збирачів сміття, таких як G1 GC, оскільки дозволяє ефективно керувати зборами сміття без необхідності сканувати всю купу. Завдяки Write Barriers, які оновлюють Remembered Sets під час зміни посилань, JVM може точно відстежувати та управляти живими об’єктами у різних регіонах, що допомагає оптимізувати процес збору сміття і зменшити зупинки виконання програми.

Налаштування G1GC

Базова оптимізація:

  1. Початковий та максимальний розмір купи. Використовуйте параметри -Xms та -Xmx для налаштування розміру купи.
  2. Налаштування молодого покоління. Параметр -XX:NewSize встановлює початковий розмір молодого покоління.
  3. Переміщення об’єктів. Використовуйте параметри -XX:SurvivorRatio для налаштування відношення Eden до Survivor Space.

Визначення початкового заповнення купи

Відсоток заповнення початкової купи (Initiating Heap Occupancy Percent, IHOP) — це поріг, за якого ініціюється збір початкових міток, і він визначається у відсотках від розміру старого покоління.

G1 за замовчуванням автоматично визначає оптимальний IHOP, спостерігаючи за тим, скільки часу займає маркування і скільки пам’яті зазвичай виділяється у старому поколінні під час циклів маркування. Ця функція називається адаптивним IHOP.

Якщо цю можливість увімкнено, параметр -XX:InitiatingHeapOccupancyPercent визначає початкове значення у відсотках від розміру поточного старого покоління доти, доки не буде достатньо спостережень для точного передбачення порога заповнення купи для ініціювання. Вимкніть таку поведінку G1 за допомогою опції -XX:-G1UseAdaptiveIHOP. У цьому випадку значення -XX:InitiatingHeapOccupancyPercent завжди визначатиме цей поріг.

Внутрішньо Adaptive IHOP намагається встановити Initiating Heap Occupancy таким чином, щоб перше збирання змішаного сміття у фазі рекультивації простору почалося, коли заповнення старого покоління досягне поточного максимального розміру старого покоління мінус значення -XX:G1HeapReservePercent як додаткового буфера.

Маркування

Маркування G1 використовує алгоритм під назвою Snapshot-At-The-Beginning (SATB). Він робить віртуальний знімок купи під час паузи початкового маркування, коли всі об’єкти, які були живими на початку маркування, вважаються живими до кінця маркування. Це означає, що об’єкти, які стали мертвими (недосяжними) під час розмітки, все ще вважаються живими для цілей рекультивації простору (за деякими винятками).

Це може призвести до помилкового збереження додаткової пам’яті порівняно з іншими збирачами. Однак SATB потенційно забезпечує кращу затримку під час паузи Перемаркування. Занадто консервативно розглянуті живі об’єкти під час цього маркування будуть відновлені під час наступного маркування

Поведінка у ситуаціях дуже щільної купи

Коли програма використовує так багато пам’яті, що під час евакуації не вистачає місця для копіювання, виникає помилка евакуації. Відмова евакуації означає, що G1 намагається завершити поточне збирання сміття, зберігаючи всі об’єкти, які вже було переміщено, на новому місці, і не копіюючи ще не переміщені об’єкти, а лише коригуючи посилання між об’єктами.

Збій евакуації може спричинити деякі додаткові накладні витрати, але загалом має відбуватися так само швидко, як і у випадку з іншими молодими колекціями. Після цього збору сміття з помилкою евакуації G1 відновить роботу програми у звичайному режимі без жодних інших заходів.

G1 припускає, що збій евакуації стався наприкінці збору сміття, тобто більшість об’єктів вже переміщено і залишилося достатньо місця для продовження роботи програми, поки не завершиться маркування і не почнеться рекультивація простору.

Якщо це припущення не справджується, то G1 врешті-решт запланує повне збирання сміття. Цей тип збору виконує ущільнення всієї купи на місці. Це може бути дуже повільно.

Величезні об’єкти

Громіздкі об’єкти — це об’єкти, розмір яких більший або дорівнює розміру половини регіону. Поточний розмір регіону визначається ергономічно, якщо його не задано за допомогою опції -XX:G1HeapRegionSize.

Ці об’єкти іноді обробляються по-особливому:

  1. Кожен об’ємний об’єкт виділяється як послідовність суміжних областей у старому поколінні. Початок самого об’єкта завжди знаходиться на початку першої області у цій послідовності. Будь-який залишок простору в останній області послідовності буде втрачено для розподілу, поки весь об’єкт не буде звільнено.
  2. Як правило, великі об’єкти можуть бути повернуті тільки в кінці розмітки під час фази Очищення або під час Full GC, якщо вони стали недосяжними. Однак, існує спеціальне положення для великих об’єктів для масивів примітивних типів, наприклад, bool, всіх видів цілих чисел і значень з плавучою комою. G1 намагається повернути великі об’єкти, якщо на них не посилається багато об’єктів під час будь-якої паузи у збиранні сміття. Таку поведінку увімкнено за замовчуванням, але ви можете вимкнути її за допомогою параметра -XX:G1EagerReclaimHumongousObjects.
  3. Виділення громіздких об’єктів може спричинити передчасне зупинення збирання сміття. G1 перевіряє поріг заповнення купи під час кожного виділення великих об’єктів і може негайно розпочати збирання молодих об’єктів, якщо поточне заповнення перевищує цей поріг.
  4. Великі об’єкти ніколи не переміщуються, навіть під час Full GC. Це може призвести до передчасних повільних Full GC або несподіваних станів поза пам’яттю з великою кількістю вільного простору, що залишився через фрагментацію простору регіону.

Розмір покоління на фазі освоєння простору

Під час фази відновлення простору G1 намагається максимізувати кількість простору, що відновлюється у старому поколінні за одну паузу збирання сміття. Розмір молодого покоління встановлюється мінімально допустимим, як правило, визначеним за допомогою -XX:G1NewSizePercent, і будь-які регіони старого покоління для звільнення простору додаються до тих пір, поки G1 не визначить, що додавання подальших регіонів перевищує цільовий час паузи. Під час певної паузи у збиранні сміття G1 додає регіони старого покоління у порядку їхньої ефективності очищення, спочатку найвищої, а потім за часом, що залишився до отримання остаточного набору для збору.

Кількість регіонів старої генерації, яку потрібно взяти за один збір сміття, обмежена в нижньому кінці кількістю потенційних регіонів-кандидатів старої генерації (регіонів-кандидатів набору збору), які потрібно зібрати, поділеною на тривалість фази рекультивації простору, визначену за допомогою -XX:G1MixedGCCountTarget. Регіонами-кандидатами на збір є всі регіони старого покоління, які мають заповненість, меншу за -XX:G1MixedGCLiveThresholdPercent на початку фази.

Фаза завершується, коли залишок простору, який може бути звільнений у регіонах-кандидатах на збірку, стає меншим за відсоток, заданий значенням -XX:G1HeapWastePercent.

Додаткові методи конфігурації G1GC

-XX:G1HeapRegionSize=n 

Задає розмір області G1. Значення буде степенем двійки та може варіюватися від 1 МБ до 32 МБ. Мета полягає у тому, щоб мати близько 2048 регіонів, виходячи з мінімального розміру купи Java.

-XX:MaxGCPauseMillis=200 

Задає цільове значення для бажаного максимального часу паузи. Значення за замовчуванням — 200 мілісекунд. Вказане значення не адаптується до розміру купи.

-XX:G1NewSizePercent=5

Встановлює відсоток від купи, який буде використовуватися як мінімальний для розміру молодого покоління. Значення за замовчуванням дорівнює 5 відсоткам від вашої купи Java. Це експериментальний прапорець.

-XX:G1MaxNewSizePercent=60

Встановлює відсоток від розміру купи, який буде використовуватися як максимальний для розміру молодого покоління. Значення за замовчуванням дорівнює 60 відсоткам вашої купи Java. Це експериментальний прапорець.

-XX:ConcGCThreads=n

Задає кількість паралельних потоків розмітки. Встановлює n приблизно рівним 1/4 від кількості паралельних потоків збирання сміття (ParallelGCThreads).

-XX:InitiatingHeapOccupancyPercent=45

Встановлює поріг заповнення купи Java, при досягненні якого запускається цикл розмітки. За замовчуванням використовується 45 відсотків від усієї купи Java.

-XX:G1MixedGCLiveThresholdPercent=65

Встановлює поріг заповнення старого регіону для включення його до циклу змішаного збирання сміття. Значення за замовчуванням — 65 відсотків. Це експериментальний прапорець.

-XX:G1HeapWastePercent=10

Встановлює відсоток купи, який ви бажаєте відкинути. Java HotSpot VM не ініціює цикл змішаного збору сміття, коли відсоток повторно використовуваного сміття менший за відсоток відходів купи. За замовчуванням це значення дорівнює 10 відсоткам.

-XX:G1MixedGCCountTarget=8

Встановлює цільову кількість збірок змішаного сміття після циклу розмітки для збору старих регіонів з не більше ніж G1MixedGCLIveThresholdPercent живих даних. За замовчуванням встановлено 8 змішаних збірок сміття. Мета для змішаних збірок полягає у тому, щоб бути у межах цієї цільової кількості.

Як розблокувати експериментальні прапори VM

Щоб змінити значення експериментальних прапорів, ви повинні спочатку їх розблокувати. Це можна зробити, явно вказавши -XX:+UnlockExperimentalVMOptions у командному рядку перед будь-якими експериментальними прапорами. Наприклад:

java -XX:+UnlockExperimentalVMOptions 



-XX:G1NewSizePercent=10 



-XX:G1MaxNewSizePercent=75 G1test.jar

Рекомендації

Коли ви оцінюєте та налаштовуєте G1 GC, пам’ятайте про наступні рекомендації:

  • Розмір молодого покоління. Уникайте явного встановлення розміру молодого покоління за допомогою опції -Xmn або будь-якої іншої пов’язаної з нею опції, наприклад, -XX:NewRatio. Встановлення розміру молодого покоління перевизначає цільовий час паузи.
  • Цільові значення часу паузи. Коли ви оцінюєте або налаштовуєте будь-яке збирання сміття, завжди існує компроміс між затримкою та пропускною здатністю. G1 GC — це інкрементний збирач сміття з рівномірними паузами, але з більшими накладними витратами для потоків програми. Мета пропускної здатності для G1 GC — 90 відсотків часу роботи програми та 10 відсотків часу збору сміття. Якщо порівняти його з колектором пропускної здатності Java HotSpot VM, то його мета — 99% часу роботи програми та 1% часу збору сміття. Тому, коли ви оцінюєте G1 GC за пропускною здатністю, розслабте цільовий час паузи. Встановлення занадто агресивної мети вказує на те, що ви готові нести збільшення накладних витрат на збір сміття, що має прямий вплив на пропускну здатність. Коли ви оцінюєте G1 GC на затримку, ви встановлюєте бажану (м’яку) мету в реальному часі, і G1 GC намагатиметься її досягти. Як побічний ефект, пропускна здатність може постраждати.

Оптимізація роботи з памʼяттю та збірниками сміття


Опція та значення за замовчуванням


Опис


-XX:+G1UseAdaptiveConcRefinement

-XX:G1ConcRefinementGreenZone=<ergo>

-XX:G1ConcRefinementYellowZone=<ergo>

-XX:G1ConcRefinementRedZone=<ergo>

-XX:G1ConcRefinementThreads=<ergo>


Одночасне оновлення (уточнення) запам’ятовуваної множини використовує ці опції для керування розподілом роботи між паралельними потоками уточнення. G1 вибирає ергономічні значення для цих параметрів таким чином, щоб -XX:G1RSetUpdatingPauseTimePercent час витрачався на паузу збирання сміття для обробки будь-якої роботи, що залишилася, адаптивно підлаштовуючи їх за потреби. Змінюйте з обережністю, оскільки це може призвести до надто довгих пауз.


-XX:+ReduceInitialCardMarks


Це об’єднує одночасну роботу з оновлення (уточнення) запам’ятовуваного набору для початкових розподілів об’єктів.


-XX:+ParallelRefProcEnabled

-XX:ReferencesPerThread=1000

-XX:ReferencesPerThread визначає ступінь розпаралелювання: для кожних N об’єктів посилань один потік братиме участь у підфазах обробки посилань, обмежений значенням -XX:ParallelGCThreads. Значення 0 вказує на те, що завжди буде використовуватися максимальна кількість потоків, визначена значенням -XX:ParallelGCThreads.

Це визначає, чи повинна обробка екземплярів java.lang.Ref.* виконуватися паралельно кількома потоками.


-XX:G1RSetUpdatingPauseTimePercent=10


Цей параметр визначає відсоток загального часу збирання сміття, який G1 має витратити на етапі оновлення RS, щоб оновити всі запам’ятовувані набори, що залишилися. За допомогою цього параметра G1 контролює кількість одночасних оновлень запам’ятовуваних наборів.


-XX:G1SummarizeRSetStatsPeriod=0


Це період у ряді GC, за який G1 генерує звіти про запам’ятовані набори. Встановіть нуль, щоб вимкнути цю функцію. Створення звітів про запам’ятовані набори є дороговартісною операцією, тому її слід використовувати лише у разі необхідності і при досить високих значеннях. Для друку будь-чого використовуйте gc+remset=trace.


-XX:GCTimeRatio=12


Це дільник для цільового співвідношення часу, який слід витрачати на прибирання сміття, а не на програму. Фактична формула для визначення цільової частки часу, яку можна витратити на збирання сміття перед збільшенням купи, має вигляд 1 / (1 + GCTimeRatio). Це значення за замовчуванням призводить до того, що цільова частка часу, яку можна витрачати на збір сміття, становить близько 8%.

ZGC

Загальний опис

Збирач сміття Z (ZGC) є масштабованим збирачем сміття з низькою затримкою. ZGC виконує всі дорогі операції одночасно, не зупиняючи виконання потоків застосунку більше ніж на мілісекунду. Він підходить для застосунків, які вимагають низької затримки. Час пауз не залежить від розміру купи, яка використовується.

ZGC добре працює з розмірами купи від кількох сотень мегабайт до 16 Тб. ZGC був спочатку представлений як експериментальна функція в JDK 11 і був оголошений готовим до використання в JDK 15. У JDK 21 був реімплементований для підтримки поколінь.

Коротко про ZGC:

  • паралельний;
  • заснований на регіонах;
  • компактуючий;
  • підтримує NUMA;
  • використовує кольорові вказівники;
  • використовує бар’єри завантаження;
  • використовує бар’єри запису (в режимі поколінь).

В основі, ZGC є паралельним збирачем сміття, тобто вся важка робота виконується, поки потоки Java продовжують виконуватись. Це значно обмежує вплив збору сміття на час відгуку вашого застосунку.


Опції General GC


Опції ZGC


Діагностичні опції ZGC

(-XX:+UnlockDiagnosticVMOptions)


-XX:MinHeapSize, -Xms

-XX:InitialHeapSize, -Xms

-XX:MaxHeapSize, -Xmx

-XX:SoftMaxHeapSize

-XX:ConcGCThreads

-XX:ParallelGCThreads

-XX:UseDynamicNumberOfGCThreads

-XX:UseLargePages

-XX:UseTransparentHugePages

-XX:UseNUMA

-XX:SoftRefLRUPolicyMSPerMB

-XX:AllocateHeapAt


-XX:ZAllocationSpikeTolerance

-XX:ZCollectionInterval

-XX:ZFragmentationLimit

-XX:ZMarkStackSpaceLimit

-XX:ZProactive

-XX:ZUncommit

-XX:ZUncommitDelay


-XX:ZStatisticsInterval

-XX:ZVerifyForwarding

-XX:ZVerifyMarking

-XX:ZVerifyObjects

-XX:ZVerifyRoots

-XX:ZVerifyViews

-XX:ZYoungGCThreads

-XX:ZOldGCThreads

-XX:ZBufferStoreBarriers

Також при ввімкненні «Generation»-режиму стають доступними інші прапорці, для увімкнення використовуйте: -XX:+UseZGC -XX:+ZGenerational:


Опції General GC


Опції ZGC


Діагностичні опції ZGC (-XX:+UnlockDiagnosticVMOptions)


-XX:ZCollectionIntervalMinor

-XX:ZCollectionIntervalMajor

-XX:ZYoungCompactionLimit


-XX:ZVerifyRemembered

-XX:ZYoungGCThreads

-XX:ZOldGCThreads

-XX:ZBufferStoreBarriers

Налаштування розміру купи

Найважливішим параметром налаштування для ZGC є встановлення максимального розміру купи, який можна встановити за допомогою опції командного рядка -Xmx. Оскільки ZGC є паралельним збирачем, необхідно вибрати максимальний розмір купи таким чином, щоб купа могла вмістити живий набір вашого застосунку і щоб було достатньо місця для виділення пам’яті під час роботи GC.

Скільки додаткового місця потрібно, дуже залежить від швидкості виділення пам’яті та розміру живого набору застосунку. Загалом, чим більше пам’яті ви виділяєте для ZGC, тим краще. Але водночас, марнування пам’яті є небажаним, тому все зводиться до знаходження балансу між використанням пам’яті та частотою запуску GC.

ZGC має іншу опцію командного рядка, пов’язану з розміром купи, названу -XX. Вона може бути використана для встановлення м’якої межі на те, наскільки може збільшуватися Java-купа. ZGC буде прагнути не перевищувати цю межу, але все ж може збільшуватися до максимального розміру купи. ZGC використовуватиме більше за м’яку межу, лише якщо це потрібно для запобігання зависання Java-застосунку та очікування на GC для звільнення пам’яті.

Наприклад, з параметрами командного рядка -Xmx5g -XX =4g ZGC буде використовувати 4 ГБ як межу для своїх евристик, але якщо він не зможе утримати розмір купи менш як 4 ГБ, йому буде дозволено тимчасово використовувати до 5 ГБ.

Налаштування кількості одночасних потоків GC

Зверніть увагу! Цей розділ стосується негенераційної версії ZGC. Генераційний ZGC має більш адаптивну реалізацію, і вам менш імовірно потрібно буде налаштовувати потоки GC.

Другим параметром налаштування, на який слід звернути увагу, є встановлення кількості одночасних потоків GC (-XX =<кількість>). ZGC має евристики для автоматичного вибору цієї кількості. Ця евристика зазвичай працює добре, але залежно від характеристик застосунку її може знадобитися скоригувати.

Цей параметр фактично визначає, скільки часу процесора слід виділити для GC. Якщо виділити занадто багато, GC буде забирати занадто багато часу процесора у застосунку. Якщо виділити занадто мало, додаток може виділяти сміття швидше, ніж GC зможе його збирати.

Примітка! Починаючи з JDK 17, ZGC динамічно масштабує кількість одночасних потоків GC вгору та вниз. Це робить ще менш імовірним, що вам потрібно буде коригувати кількість одночасних потоків GC.

Примітка!! Загалом, якщо низька затримка (тобто низький час відгуку застосунку) є важливою для вашого застосунку, ніколи не перевантажуйте свою систему. Ідеально, щоб ваша система ніколи не мала більше ніж 70% завантаження процесора.

Повернення невикористаної пам’яті операційній системі

За замовчуванням ZGC відключає невикористану пам’ять, повертаючи її операційній системі. Це корисно для застосунків та середовищ, де важливий обсяг пам’яті, але може негативно впливати на затримку потоків Java. Ви можете відключити цю функцію за допомогою опції командного рядка -XX:-ZUncommit.

Крім того, пам’ять не буде відключена таким чином, щоб розмір купи зменшився нижче мінімального розміру купи (-Xms). Це означає, що ця функція буде неявно відключена, якщо мінімальний розмір купи (-Xms) налаштований рівним максимальному розміру купи (-Xmx).

Ви можете налаштувати затримку відключення за допомогою -XX =<секунд> (за замовчуванням 300 секунд). Ця затримка вказує, як довго пам’ять повинна залишатися невикористаною, перш ніж вона стане придатною для відключення.

Примітка! Дозвіл GC комітити та декомітити пам’ять під час роботи застосунку може негативно вплинути на затримку потоків Java. Якщо надзвичайно низька затримка є основною причиною використання ZGC, розгляньте можливість запуску з однаковими значеннями для -Xmx та -Xms і використовуйте -XX:+AlwaysPreTouch, щоб заповнити пам’ять перед запуском застосунку.

Примітка!! На Linux відключення невикористаної пам’яті вимагає підтримки fallocate(2) з FALLOC_FL_PUNCH_HOLE, яка вперше з’явилася у версії ядра 3.5 (для tmpfs) та 4.3 (для hugetlbfs).

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось34
До обраногоВ обраному26
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Трошки дивно, що іноді використується термінологія англійською (Metaspace чи Heap), а іноді — укроїнською (купа та ін.)

Доречи, існуючих GC вже 7 а не 4 :)

Я не намагався описати роботу всіх GC, а просто показати як працює дефолтний і коротко розказати про декілька інших. Якщо описувати детально всі GC їхню роботу, специфіку і ще більше заглиблюватись в роботу з памʼяттю, то це вже буде не стаття, а ціла книжка)
А на рахунок термінології гарне зауваження, але я не думаю, що це критично;)

Доречи, існуючих GC вже 7 а не 4 :)

Насправді усього два, G1 та ZGC. Усі інші давно історія. Жодна з JVM не підримувала усі GC, а якщо опції командної строки залишались — то тільки для зворотної сумісності. Коли ви приєднуєстесь профайлером — то бачите, що не дивлячись на опції командної строки типу Serial GC працює GC за замовченням, зараз це G1.

Потрібна стаття. Але дуже мало про роботу стеку. Зараз багато джунів з профільною освітою не розуміють, як працює стек, яким чином передаються аргументи як хіпових об’єктів, так і простих типів. А Value Types вже не за горами, принаймні я на це сподіваюсь (вони вже 10 років це розроблюють, коли воно вже вийде у реліз?).

Дуже дякую за коментар, дійсно про стек малувато інформації, думаю, доповню цей розділ з часом.

Зараз багато джунів з профільною освітою не розуміють, як працює стек, яким чином передаються аргументи як хіпових об’єктів, так і простих типів.

Разом з поліморфізмом, це такий біч. Людям в цілому туго це доходить як рекурсія так і просто FIFO та FILO черги, хоча ніби то і дуже прості абстракції. Т
і же кеши — LRU, SLRU, MRU, LFU, MQCA — це ніби набагато більш складні абстракації, але чомусь доходять до людей значно легше.

Хороша стаття, було б цікаво ще побачити приклади, бо особисто я в житті (10 років джави) не бачив weak reference

Я колись кеш писав який юзав в основі weak reference.
Все працювало супер і дуже швидко.
Через рік взнав що інша тімка, якій передали сервіс на супорт, переписала на caffeine (чи що там spring boot пропонує) бо не змогли розібратись.

Так що нікому цього і не треба) хіба на співбесіді повимахуватись)

Ну чому не треба, це просто штука, яку ти не часто будеш використовувати. А на рахунок переписали, бо не розібрались, щось мені підказує, що просто було лінь розбиратись, бо це не якийсь rocket science) Загалом штука прикольна і часом корисна. Так само в JDK є WeakHashMap, яка якраз використовує WeakReference як ключі, не всі за нього чули і ще менше використовували, а вона в JDK присутня, я думаю, що Oracle за стільки років розвитку джави вже би давно деприкейтнули те, що нікому не потрібно))

Загалом штука прикольна і часом корисна

А де вона ще корисна, окрім реалізаціїї власного кешу?

Дуже дякую за питання, це мені дозволяє краще розуміти аудиторію і те що їм незрозуміло/не вистачає інформації. Коротко про випадки, де WeakReference може бути корисним:
1. Мапи з слабкими ключами, які можуть бути зібрані сміттям
Іноді буває корисно створювати мапи, де ключі або значення можуть бути зібрані сміттям, якщо на них більше немає сильних посилань. Для цього існують спеціальні класи WeakHashMap, які використовують слабкі посилання на ключі. Це особливо корисно для зберігання метаданих об’єктів і це є одним з прикладів кешування.

2. Реалізація ідентичних об’єктів (Canonicalizing Maps)
Коли у вас є багато об’єктів, які мають однаковий стан (наприклад, рядки), слабкі посилання можуть допомогти уникнути дублювання, дозволяючи створювати єдині екземпляри (канонічні представники). Це може зекономити пам’ять і зменшити навантаження на систему. Таким чином їх зручно використовувати у парі з патерном Flyweight.

3. Інтеграція з бібліотеками та фреймворками
Деякі фреймворки використовують слабкі посилання для збереження внутрішніх об’єктів. Наприклад, Hibernate може використовувати слабкі посилання для кешування ентіті, щоб вони могли бути зібрані GC, коли вони більше не потрібні.

4. Реалізація слабо зв’язаних компонентів (Weakly Referenced Components)
Коли компоненти не повинні перешкоджати звільненню пам’яті, слабкі посилання можуть бути використані для уникнення утримання зайвої пам’яті. Це може бути корисним у складних системах, де багато компонентів взаємодіють один з одним.

5. Використання у великих системах зі складною пам’яттю
У великих і складних системах, де є багато взаємозв’язків між об’єктами, слабкі посилання можуть допомогти уникнути циклічних посилань, які можуть заважати збирачу сміття.
Якщо щось забув, то сподіваюсь, що хтось доповнить)

1. Кеш
2. Також кеш
3. Приклад поганий, Hibernate не може використовувати слабкі посилання, і GC під час транзаціі нічого збирати не буде.
github.com/...​m WeakReference&type=code
github.com/...​orm WeakHashMap&type=code
І те що ви описали — це знову таки кеш.
4. 5. так, у складних системах можуть бути кейси для WeakReference, я саме про ці кейси і питав).
Але це точно не «уникнути циклічних посилань, які можуть заважати збирачу сміття», бо це ж GC, а не ARC.

Ну тоді не підкажу, не зустрічав використання слабких посилань на практиці деінде окрім кешів

Глянув на цей

caffeine

(com.github.ben-manes.caffeine) З того що я побачив, це просто реалізація кешів для різних потреб/фраємворків за допомогою різнотипних мап, і саме WeakReference i WeakHashMap я там не побачив) Тобто це просто більш формалізований спосіб використання кешів, але так як WeakHashMap він не використовує, я підозрюю, що працює він нічим не краще ніж реалізація за допомогою WeakReference i WeakHashMap, просто зручніше, але щей додаткову залежність варто додавати в проект і GC не підчищатиме при кожній збірці сміття, бо є міцні посилання(

Мабуть пропустив, дякую

Я у цьом році використовував WeakReference у розробці прозорої зміни креденшелсів у рантаймі для MongoDB драйверу. Тобто якщо у вас зараз нема таких задач, це не говорить, що вони не з’являться у майбутньому.

я вже доволі давно працюю як Senior Java Developer.

Якщо ви дуже давно працюєте як Senior Developer, то було б логічно припустити, що у своєму пості ви наведете приклади зі свого власного досвіду, з тих проектів, у яких ви працювали.
А такий пост без практичних прикладів має дуже низьку цінність, оскільки це просто переклад/переказ офіційної документації. І якщо хтось напише ще 50 подібних постів, то всі вони будуть, як брати-близнюки.
Тому я б не здивувався, якби такий пост був би результатом роботи наприклад Junior Developer, так як у нього немає особливого практичного досвіду, але не Senior Developer.

Якщо простір купи заповнено, Java видає java.lang.OutOfMemoryError.

Java не може нічого видавати, оскільки Java — це просто мова програмування зі своєю специфікацією. А ось JVM справді може викидати OutOfMemoryError

Якщо коротко, то на кожен потік в Java використовується окремий стек, що зберігає примітивні значення: int, float, double, char тощо, а також посилання на обʼєкти, що створюються в кучі.

Це не зовсім правильне твердження.
Якщо об’єкт містить поля типу int або char, вони разом з іншими даними зберігатимуться у купі, а чи не в стеку.
А стек зберігає локальні змінні та значення аргументів методів.

Дякую за уточнення. Виправлю

По-перше, куча має дві секції: Молоде покоління (Young Generation) та Старе покоління (Old Generation або Tenured). Молоде покоління також ділиться на три окремі секції памʼяті: Eden, S0, S1.

Інформація в цьому пості застаріла років на 10, тому що нові збирачі сміття (Z, Shenandoah) вже не поділяють пам’ять на «генерації», тому їх називають Single-generational.
Тільки в Java 21 в ZGC з’явилася опція знову використовувати generations.

Мова йде здебільшого про секції так як за замовчуванням з 9 версії джави JVM використовує G1 GC. Якщо це не є очевидним, тоді я підправлю і допишу більше інформації в розділ ZGC.

Він є збирачем сміття за замовчуванням починаючи з Java 9

Тільки якщо більше 1 CPU, інакше serial

docker run —cpus=1 —rm -it eclipse-temurin:21 java “-Xlog:gc*” -version | grep Using
[0.001s][info][gc ] Using Serial

Дякую за уточнення, виправлю.

Класика жанру. Спочатку ти рекламуєш GC як засіб взагалі не турбуватися про пам’ять. Але з часом тобі треба знати усе більше як воно працює під капотом, щоб цим керувати, бо треба турбуватися.

Більшість проектів функціонують без додаткових конфігурацій памʼяті чи GC, але знати і розуміти як вони працюють варто. Навіть дуже часто на співбесідах не знаючи якихось нюансів можна догадатися розуміючи як працює JVM і як можна самому розробити той чи інший функціонал. Тому я вважаю цю тему дуже важливою для будь-якого джава розробника рівня Senior. Також в деяких випадках таким чином можна оптимізувати високонавантажені додатки, якщо розуміти як воно все працює під капотом.

Більшість проектів функціонують без додаткових конфігурацій памʼяті чи GC, але знати і розуміти як вони працюють

Це виглядає як розумний вибір.

Також в деяких випадках таким чином можна оптимізувати високонавантажені додатки, якщо розуміти як воно все працює під капотом.

Ну... як на мене це більше свідчить про те, що вибір GC був не дуже вдалим. Бо це нагадує анекдот, як гінеколог зібрав мотоцикл через вихлопну трубу. Коли на треба знати нюанси роботи GC, то ми втрачаємо більшість переваг GC, тут виникає питання чи не простіше зробити крок назад, відмовитися від GC та реалізувати той механізм керування пам’яттю, який більше відповідає потребам застосунку?

G1GC цілком добре виконує свою роботу. Інші збирачі сміття мають як переваги так і недоліки, для збирача сміття за замовчуванням він підходить, все залежить від специфіки роботи, не кожен застосунок треба тюнити або використовувати інший GC, Oracle старається зробити найбільш підходящий для більшості застосунків GC основним, але також і додає/оптимізовує інші, щоб дати можливість застосункам які потребують більш особливих налаштувань все натюнити під себе, але для цього вже важливе глибоке розуміння їх роботи. Ця стаття покликана показати як працює G1GC, модель памʼяті JVM та трохи розповісти про інші GC, цього мало аби ідеально володіти знанням та розумінням всіх збирачів сміття. Можливо в майбутньому я допишу цю статтю або створю нову, яка вже буде стосуватися суто збирачів сміття.

Головна перевага GC це memory safety, а не можливість для розробників не знати, як працює виділення пам’яті.

Memory safety це суттєвий аспект, якому зараз приділяється все більше уваги, дивіться наприклад www.hpcwire.com/...​re-should-be-memory-safe

Rust теж має memory safety але без GC. Знову ж таки, при наявності санитайзерів актуальність цього втрачається.

Ну в rust це концептуально навпаки набагато складніше, а не простіше ніж gc.

Ну... але GC тебе не захистить від race conditions, а це знову призводить до unsafe коду. А так, невже концепція, що існує або один вказівник, за яким можна записувати дані, або багато для читання настільки складна?

А що, джава ще жива?

Живіша всіх живих

Ще забули додати велику секцію про керування off-heap або native памʼяттю. Project Panama додала такий функціонал в JDK22 через арени які привʼязані до стеку та контролюються GC.

Відносно GC. ZGC це вже де-факто стандарт для рантайму в незалежності від розмірів heap. Ну і ідея generational полягає в тому, що GC сам може себе ефективно налаштувати, тюнінг як такий йде у минуле.

Поділюся цікавим досвідом чому саме треба обирати ZGC. Я працюю над проєктом де є високочастотні UDP клієнти які генерують приблизно по 35 мегабайт датаграм в секунду. Таких клієнтів за одну сесію може бути до 22 одночасно упродовж двох годин. Нажаль, G1 давав великі паузи за які втрачалися приблизно 10-14% датаграм, у той же час generational zgc зміг допомогти нам знизити цей показник до 1% при правильному налаштуванні операційної системи.

Ви продаєте ваш юзкейс за стандарт. Не всі апки повинні тримати великий стейт в пам’яті протягом довгого часу

Причому тут стейт? Наш UDP сервер взагалі був stateless. Я ж кажу про застосунки, які дуже чутливі до stop the world пауз. У цьому ж незрівненна перевага generational zgc з його sub-millisecond паузами.

Повністю згоден. ZGC був дуже потрібний для застосунків чутливих до stop the world, але їх меньшість, і ви самі розумієте що в грошовому виражені це коштує дорожче. Тому він і не дефолтний, і не про стандарт)

Ще забули додати

В чому проблема написати свою статтю?😉

вона і так написана тут.

Я бачив вашу статтю по Project Panama, думаю немає сенсу переписувати те, що вже написане, тай моя стаття і так вийшла доволі обʼємною. На рахунок ZGC я подумаю на рахунок того, щоб дописати цей розділ, дуже дякую.

Дякую за статтю. Варто підкорегувати

Heap зберігає обʼєкти та volatile-змінні.

, тому що трошки конф’юзить. Heap зберігає об’єкти та усі не константні змінні які ці об’єкти мають. Різниця в тому, що volatile зміні не завантажуються в стек

Дякую за зауваження. Обовʼязково підправлю!

Воу, класна стаття. Дякую.

Я старався) Там можна було ще писати мегабагато всього, але це стаття, а не книжка😅

Підписатись на коментарі