×

Уничтожение объектов Java

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

Здравствуйте. Сейчас читаю Tinking in java и наткнулся на вопрос, который не даёт покоя. Экель пишет, что ненужные объекты удаляет garbage collector, но есть случаи, когда это нужно делать вручную. Приведу отрывок из книги:

Если при создании нового класса используется композиция и наследование, обычно вам не приходится беспокоиться о проведении завершающих действий — подобъекты уничтожаются сборщиком мусора. Но если вам необходимо провести завершающие действия, создайте в своем классе метод dispose() (в данном разделе я решил использовать такое имя; возможно, вы придумаете более удачное название). Переопределяя метод dispose() в производном классе, важно помнить о вызове версии этого метода из базового класса, поскольку иначе не будут выполнены завершающие действия базового класса. Следующий пример доказывает справедливость этого утверждения:
//: polymorphism/Frog.java
// Наследование и завершающие действия.
package polymorphism;
import static net.mindview.util.Print.*;
 
class Characteristic {
  private String s;
  Characteristic(String s) {
    this.s = s;
    print("Creating Characteristic " + s);
  }
  protected void dispose() {
    print("disposing Characteristic " + s);
  }
}
 
class Description {
  private String s;
  Description(String s) {
    this.s = s;
    print("Creating Description " + s);
  }
  protected void dispose() {
    print("disposing Description " + s);
  }
}
// живое существо
class LivingCreature {
  private Characteristic p =
    new Characteristic("is alive");
  private Description t =
    new Description("Basic Living Creature");
  LivingCreature() {
    print("LivingCreature()");
  }
  protected void dispose() {
    print("LivingCreature dispose");
    t.dispose();
    p.dispose();
  }
}
// животное
class Animal extends LivingCreature {
  private Characteristic p =
    new Characteristic("has heart");
  private Description t =
    new Description("Animal not Vegetable");
  Animal() { print("Animal()"); }
  protected void dispose() {
    print("Animal dispose");
    t.dispose();
    p.dispose();
    super.dispose();
  }
}
// земноводное
class Amphibian extends Animal {
  private Characteristic p =
    new Characteristic("can live in water");
  private Description t =
    new Description("Both water and land");
  Amphibian() {
    print("Amphibian()");
  }
  protected void dispose() {
    print("Amphibian dispose");
    t.dispose();
    p.dispose();
    super.dispose();
  }
}
// лягушка
public class Frog extends Amphibian {
  private Characteristic p = new Characteristic("Croaks");
  private Description t = new Description("Eats Bugs");
  public Frog() { print("Frog()"); }
  protected void dispose() {
    print("Frog dispose");
    t.dispose();
    p.dispose();
    super.dispose();
  }
  public static void main(String[] args) {
    Frog frog = new Frog();
    print("Bye!");
    frog.dispose();
  }
}

(Наследование и завершающие действия)
я не пойму следующее:
1. когда возникают такие случаи.
2. каким образом создание нового метода поможет избавится от нежелательных ссылок.

p.s.: Простите за, возможно глупый вопрос, но так и не нашел в интернете на него ответ((

👍ПодобаєтьсяСподобалось0
До обраногоВ обраному0
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

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

Нормальний приклад, там явно написано

Но если вам необходимо провести завершающие действия,
Якщо у вашій практиці не було такої необхідності — дуже добре, бо це треш ще той, але факт є факт, що іноді така необхідність є.

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

там є приклади і з finalize(), але з допомогою ДЩУ, наче, трохи розібрався))

Украинские экстремисты планируют диверсии по уничтожению объектов.

try{think(this);
}catch(TiredException e){Coffee.newInstance().dispose();}

В Java не нужно уничтожать объекты, главное следите, чтобы не накапливалось слишком много hard-reference-ов, например у вас есть мапа, в ней каждый объект тоже мапа и туда добавляются ссылки на объекты и не удаляются........готовимся к OutOfMemory на любом количестве памяти.

Метод finalize если и вызывается, то в отдельном потоке GC, вы этот поток не контролируете и не увидите. Правильно пишет Джошуа Блох — ничего не пишите в финализаторах, это значительно замедляет работу, а пользы — пшик.

Вывод: код написан скверно, я бы сказал отвратительно и он запутывает новичка гораздо больше, чем приносит ощутимой пользы. Почитайте Joshua Bloch Effective Java, Java Puzzlers, Head First Java, а про это забудьте. На первых порах вам нужно понять как это работает в целом и не лезть в подобные дебри.

Вот как раз ошибкой новичка являются всякие «главное следите». Если сам понимаешь что пишешь сложную структуру данных — то пишешь и финализатор. Он как правило короткий, 1-2 строчки.

В конце концов, разве не к этому сводится предложение «главное следите»? Так какая разница, где это слежение будет вызвано — руками или в финализаторе? Лично я бы написал и там и там. Потому что вылетит какой-нить глупый Throwable из-за несоответствия кодировки — и зависнет в памяти фиговина. «Удовольствие» ловить это на боевом сервере — редкостное. А если это приложение у тысяч юзеров стоит — тогда песец.

Так что финализацию объектов знать НУЖНО. Без понимания финализации — к программированию боевого кода не допускать.
Примерно так же как нельзя давать соответствующую роль к SQL-серверу человеку, не знающего синтаксис команды DELETE.

На форуме есть Java Senoir’ы? Объясните плиз на русском языке гуманитариям, что именно и зачем этот товарищ предлагает чудить в финализаторах.

Это робот Вертер, он из будущего, там какая то другая версия джавы.

RBBG.
Но лучше всего что такое грабли — обьясняют сами грабли. Я предлагаю:
Новичку — читать книги, пробовать на личном опыте. Без вариантов.
Остальным — не выёжываться и не рекомендовать что-либо другое.

Я и пытаюсь понять, вы предлагаете наступать на грабли или раскладывать их по земле.

Я думаю, что муть в финализаторах идет от богатого опыта автора на С/С++ где надо использовать деструкторы, чтобы избежать утечек памяти, поэтому он и в Java по привычке и пишет так сказать «на всякий случай». Уничтожать объекты в Java не нужно, надо следить за hard-referencе-ами и понимать когда ссылка уничтожается, тогда уже GC спокойно соберет объект. По-моему это настолько базовые прописные истины. что любой джун должен знать.

Можете дать маленький кусочек кода для примера(создание и уничтожение

hard-referencе
) или ссылку на ресурс, где почитать. Спасибо.
public class SomeContainer {
    //1 - mind static final containers, they're populated once the class is loaded
    private static final List<Map<String, Object>> someContainer;
    //2 - destroyed when the SomeContainer is destroyed
    private List<Map<String, Object>> secondContainer;
    
    //initializing static container #1 destroyed only when the class is unloaded by classloader ~ never
    static {
        someContainer = new ArrayList<Map<String, Object>>();
        for (int i = 0; i < Integer.MAX_VALUE; ) {
            Map<String, Object> entry = new HashMap<String, Object>();
            entry.put("key1", "probably very large object");
            entry.put("key2", "probably very large object");
            
            someContainer.add(entry);
        }
    }
    
    //non static container is populated the same way as the static one, and destroyed once the object is destroyed

    //3 - containers that are safe enough and do not cause out of memory, everything is destroyed
    //once the method is pop-ed from stack
    private void populateInnerContainer() {
        List<Map<String, Object>> secondContainer = new LinkedList<Map<String, Object>>();
        for (int i = 0; i < Integer.MAX_VALUE; ) {
            Map<String, Object> entry = new HashMap<String, Object>();
            entry.put("key1", "probably very large object");
            entry.put("key2", "probably very large object");

            secondContainer.add(entry);
        }
    }
    
}

Уничтожение здесь не приведено, но когда вы создаете SomeContainer вам нужно рано или поздно обнулить ссылку на него. Не используйте статические контейнеры с большими объектами и коннекшинами к БД.

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

Видимо ты не очень много работал с не типичными Java EE проектами

Нетипичный — это синоним говн*кода?

Нет. В типичных финализаторы не нужны, но есть определенные предметные области, где тюнинг гц и использование финализаторов просто необходимы

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

Кстати профайлер кучу раз спасал меня от подобного тюнинга и мои улучшения были не столь красивы для true-программистов, но эффективные. Узкие места — там где предполагалось, соединения с БД, многочисленные соединения с БД, повисшие потоки, неиспользуемые тяжелые депенденси. Например 40м jar-ник ради 1 метода тянуть имхо глупо.

но уж никак не зеленые стажеры.
А какие у них задачи? Подай принеси, уйди не мешай ?
неиспользуемые тяжелые депенденси. Например 40м jar-ник ради 1 метода тянуть имхо глупо.
А что сейчас подключают жарники? Я думал все минимум на мейвене уже, а то и gradle используют в большей части
А что сейчас подключают жарники? Я думал все минимум на мейвене уже, а то и gradle используют в большей части
А что мавен/гредл не тянут джарники?
Ручками не надо тянуть
И че?
Проблема же не в том чтобы стянуть либы.
Проблема в том что размер артифактов вырастает на 40м (доп нагрузка при деплое).
Вторая проблема — это зависимости. Увеличивается вероятность конфликта.
№ 3 ИДЕ больше индексировать.
Ну и “копієчка до копієчки” и “пустое” приложение весит пару сотен метров, а с кодом уже несколько Гб. Кстати, попробуйте собрать пустое приложение на Play2.

У меня ощущение, что вы не адепт, а зеленый новичок как ТС в соседней теме.

думал все минимум на мейвене уже, а то и gradle используют в большей части
В том то и дело, что с мавеном — велик соблазн накачать еще 100-200-дофига метров левых jar-ников в итоговый артефакт, ведь каждый jar-ник тянет еще свои jar-ники и т.д., когда они появляются разных версий — отут начинается веселье.

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

Коментар порушує правила спільноти і видалений модераторами.

Memory leaks in garbage-collected languages
(more properly known as unintentional object retentions) are insidious.
If an object reference is unintentionally
retained, not only is that object excluded from garbage collection, but so too are
any objects referenced by that object, and so on.
Even if only a few object references
are unintentionally retained, many, many objects may be prevented from
being garbage collected, with potentially large effects on performance.
The fix for this sort of problem is simple: null out references once they
become obsolete.
Bloch, “Effective Java”, ITEM 6: ELIMINATE OBSOLETE OBJECT REFERENCES

И удвоить размер кода? А больше ничего не посоветуешь? Философия java — это «создал и забудь». И едва ли в одном классе из ста придётся писать финализатор новичку. Профи — едва ли в одном из тысячи.

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

К примеру, в Java EE вообще многие вещи делаются через инъекции. А теперь угадай с одного раза, если декомпилить код — будет ли там финализатор? Так что не надо тут расказывать про «устаревшее» — мир всё так же держится на трёх слонах, а те — на огромной черепахе. И уж поверь, если не уметь делать финализаторов — «черепаха» GC о себе напомнит.

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

При чем тут финализатор к наллингу референсов ?

Глупейший ответ: не dispose(), а finalize().
Метод dispose не вызывается JVM вообще. Это не зарезервинованное слово, и ты можешь пользовать его в своих объектах под свои цели.

С финализацией это выглядит так: Если ты переопределяешь финализцию, то первой строкой пиши
super.finalize();
а далее уже всё что тебе требуется.

От нежелательных ссылок избавляются в случае, если у тебя запутанные структуры данных. Например, замыкания или что-то подобное. И хотя современные JVM весьма искусны в их распутывании, всё же нельзя надеяться что это сработает всегда. Если за обьектом стоит сложная циклическая структура данных в отличие от типичной «древовидной», особенно если есть кеширование родительских обьектов — всё-таки разрушай связи которые насоздавал за пределами своего обьекта. Тогда ты существенно облегчишь работу (и затрачиваемое время) garbage collector.

Сам ты финализатор вызывать не должен ни при каких обстоятельствах.

Вместо print используй system.out.println
а ещё лучше system.err.println
Тогда оно красивше будет раскрашено при вызове из твоей IDE. В будущем изучишь уровни логирования, чтобы тебе подобный дебаг-хлам не сыпался в боевом коде.

ЗЫ: финализаторы ДРУГИХ классов ты тоже вызывать не должен! Этот метод вызывается ТОЛЬКО сборщиком мусора в той последовательности в которой он это спланировал. Всё что ты делаешь — это присваиваешь null ссылкам, закрываешь потоки, освобождаешь сторонние ресурсы.

От нежелательных ссылок избавляются в случае, если у тебя запутанные структуры данных. Например, замыкания или что-то подобное. И хотя современные JVM весьма искусны в их распутывании, всё же нельзя надеяться что это сработает всегда. Если за обьектом стоит сложная циклическая структура данных в отличие от типичной «древовидной», особенно если есть кеширование родительских обьектов — всё-таки разрушай связи которые насоздавал за пределами своего обьекта. Тогда ты существенно облегчишь работу (и затрачиваемое время) garbage collector.
Кто-нибудь может рассказать на русском языке каким образом добавление метода finalize и манипуляция в нем управляемыми ссылками облегчает и ускоряет сборку мусора?

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

И хватит какого-нить вполне кошерного WeakReference снаружи, чтобы этот клубок удерживался до полноценного прохода Garbage Collector. В то время как есть «быстрые» алгоритмы сборки мусора, которые пролетают без полной остановки java-машины. И на них приходитя львиная доля уборки.

Типичнейший случай — когда в каждом элементе дерева ты держишь полный набор его родителей. В результате хватит «отдать» хоть один листик этого дерева живой ссылкой — и за этот листик будет держать в памяти всё дерево. Хотя по логике программиста этого дерева уже не должно быть и в помине.
А ведь объекты зачастую очень удобно держать древовидной структурой. При этом листик — банально реализует какой-то интерфейс, то класс есть являтся структурой данных. Ты и не вспомнишь так сразу, что где-то в дебрях его наследования реализовано дерево. А вот написал бы в тех же дебрях финализатор — листику для отвязки от дерева, а дереву — для сброса листьев, и дерево бы жило своей жизнью листья своей.

Притом один класс может реализовывать сразу несколько структур данных. И в конечной реализации программисту о них знать и вовсе необязательно. Пример — GUI-компоненты. Чтобы с ними работать, в большинстве случаев не нужно знать как они друг друга удерживают и реализуют события.

И хватит какого-нить вполне кошерного WeakReference снаружи, чтобы этот клубок удерживался до полноценного прохода Garbage Collector.

Я щось не зрозумів — Ваша JVM рахує посилання на об’єкт? І чому в ній слабке посилання має таку силу?

В то время как есть «быстрые» алгоритмы сборки мусора, которые пролетают без полной остановки java-машины. И на них приходитя львиная доля уборки.

Для повноцінного GC не треба повної зупинки машини, це ортогональні речі. Наприклад, «трикольоровий» метод з двома кроками по списку на кожне створення об’єкта або розіменування посилання, що викликано юзерським кодом. Він не найкращий, я чув, у випадку великої кількості процесорів, але забезпечує гладку роботу GC. А у комбінації з врахуванням поколінь — ще й ефективну.

Факт у тому, що можливо зав′язати клубок посилань, які будуть неявно [для логіки розробника] зберігати за собою гору непотребу. Я так і написав — сучасні алгоритми GC є досить ефективними і можуть набагато більше аніж кілька років тому. Але фундаментальна логіка залишається: чисто не там, де прибирають.

«Повноцінний» прохід GC коли пам′ять каже «Ізя всьо» — це фактично харакірі для навантаженого коду, якщо такий прохід доводиться робити досить часто. І так, коли пам′яті не залишається, то в GC є «тяжкі» алгоритми, які вимагають призупинення JVM. Це майже ніколи не відбувається окрім критичних випадків. Чи відбувається це якщо викликати його примусово — не впевнений, дуже давно не викликав.

Weak References — не настільки weak як це може здатися. Вони є «останнім бастіоном». Тобто допоки діють інші алгоритми — їх не чипатимуть. І якщо на цьому «хвостику» висить купа непотрібного мотлоху — то вона дуже неефективно забиватиме пам′ять і провокуватиме зайві рухи з боку GC.

Якщо коротко — написання деструктора то є нормальне явище. Чому його так бояться в Java — не розумію. Розробник краще за JVM розуміє, що з цієї купи більше не потрібно. І я не рекомендую розбирати по запчастинах весь клас, а лише ті об′єкти які сам розробник творить специфічною, заплутаною логікою, з «надлишковими» зв′язками. Тобто деструктор потрібен десь в 1 з 1000 типових класів навантажених логікою. І там він міститиме 2-3 строчки коду. Складний деструктор доведеться писати можливо декілька разів за життя, можливо ніколи. Але кожен має знати що воно таке, як воно працює, і обов′язково спробувати. Це набагато простіше ніж обгововорювати — тобі так не здається?

Як саме працює GC в JVM — новачкам взагалі розбиратися не варто. Про це мова йтиме коли налаштовуватимуться параметри самої JVM. Новачки цього не роблять. А коли роблять — вони вже не новачки.

«Повноцінний» прохід GC коли пам′ять каже «Ізя всьо» — це фактично харакірі для навантаженого коду, якщо такий прохід доводиться робити досить часто. І так, коли пам′яті не залишається, то в GC є «тяжкі» алгоритми, які вимагають призупинення JVM.

От це і дивно. Для Перла або Пітона лічення посилань — типовий прийом, але я завжди чув, що для Java це ніколи не використовують. Чи вони якось інакше роблять, не лічать посилання на об’єкт?

І так, коли пам′яті не залишається, то в GC є «тяжкі» алгоритми,

Навіщо доводити до момента, коли пам’яті не залишається? Ось це мене завжди дивувало. Це, мабуть, гарна стратегія при відсутності MMU або якщо ядерна VM не Mach стилю. Але в сучасних юніксах все це Mach, і там свопінг почнеться раніше, ніж VM процеса почне підозрювати, що щось не те. Чи мова про упор в ліміт по -Xmx?
Поки що найлогічнішу політику я бачив в Lua. У stop-world варіанті там наступний цикл збору починається, коли процес стане (за замовчуванням) в 2 рази товще за результат останнього збору. У гладкому варіанті, як в останніх версіях, суттєво те ж саме, але розмазане.

Weak References — не настільки weak як це може здатися. Вони є «останнім бастіоном». Тобто допоки діють інші алгоритми — їх не чипатимуть.

Перепрошую, але Ви з soft references не плутаєте? В них принципово різний сенс.

Це набагато простіше ніж обгововорювати — тобі так не здається?

В Пітоні я саме так і роблю, але це там документовано. А такі ж заходи до Яви якось дивні.

Метод dispose не вызывается JVM вообще. Это не зарезервинованное слово, и ты можешь пользовать его в своих объектах под свои цели.

Наскільки я зрозумів ідею, мова йде про випадки _зовнішніх_ ресурсів. Відкриті файли, створені графічні об’єкти і тому подібне.
Їх треба вивільняти щонайскоріше. Тому це робиться не в GC, а в явному try-finally, і тому треба викликати свій метод, а не сподіватись на finalize().
З іншого боку, якщо finalize() так написаний, що його можна викликати багато разів, таки простіше викликати його і в своєму finally.

Я не про це. Деструктор потрібен для заплутаної логіки. Тоді власне він має розрубати гордієв вузол та звільнити клас від заплутаних зв′язків.

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

// А що до зовнішніх ресурсів — то finally є обов′язковим, це не обговорюється.

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

Тоді потрібно не забувати викликати деструктори, причому дуже бажано, щоб не заплутатися, викликати їх на тому ж рівні, де створюються об’єкти, саме так як в вашому прикладі

Теж не потрібно забувати про трай\кетч\файналайз

Реальний приклад такого випадку — IBM Domino API, Java біблотека надбудована над C ядром і потребує явного виклику .recycle() для Domino об’єктів, для звільнення виділеної пам’яті

www-10.lotus.com/..._in_NotesDomino

Єдине, що б я додав, для повної картинки в ваш приклад — метод boolean isValid().
Тому що після виклику dispose() клас стає не валідним, тобто його поведінка більше не передбачувана.

В SWT, например, контролы, фонты, цвета, и др. UI объекты надо явно уничтожать, т.к. только так они освобождают нативные хэндлы ОС.

Ключевое слово — circular references.

Java’s GC considers objects “garbage” if they aren’t reachable through a chain starting at a garbage collection root, so these objects will be collected. Even though objects may point to each other to form a cycle, they’re still garbage if they’re cut off from the root.

Думаю, автор использовал пустой метод просто для наглядности, при этом закладывая смысл в намёк о необходимости не забывать «чистить за собой» суперкласс при «финализации» дочернего.

могу быть не прав, но автор говорит что если ваш класс Фрог наследует какойто другой класс (анимал) то при создании объекта класса Фрог, вначале вызывется конструктор родительского класса и создаются его переменные (переменные класса Анимал) а уж потом вызывется конструктор класса Фрог и создаются его переменые. Если вы решили удалять объекты для класа Фрог вручную путем создания метода диспосал, но не забудьте туда включить строку для уничтожения родительской части super.dispose(); иначе вы удалите только переменные «детского класса» а переменные созданные для родительского класса остануться в памяти.
Как уже сказанно ниже такие ситуации могут случаться при использовании стримов или когда вам нада вручную удалить объект, например закрыть соединение с базой.

если ваш класс Фрог наследует какойто другой класс (анимал) то при создании объекта класса Фрог, вначале вызывется конструктор родительского класса и создаются его переменные (переменные класса Анимал) а уж потом вызывется конструктор класса Фрог и создаются его переменые.
Если я не ошибаюсь, переменные родительского класса будут инициализироваться уже после вызова конструктора, если вы говорите о инициализаторах типа
private List<object> mObjects = new ArrayList<object>();

И насчет очистки памяти/закрытия системных ресурсов — разве не для этого метод finalize() ?

finalize() вызывается GC. К тому же, GC не сам вызывает finalize(), а передает этот объект в очередь на обработку в отдельный FinalizerThread. В результате, если finalize() проводит длительную операцию, то рискуем вылезть за рамки heap`а и нет определённости в какой именно момент исполнения будет закрыт ресурс. Поэтому освобождение ресурсов всегда должно быть явным.

Эккель говорит о, по сути, деструкторах, которые есть в Си, но нет в Яве. Только в Яве вы его вызываете явно, когда объект вам больше не требуется, посредством указанного метода (dispose, close и т.п.). Это применимо, например, когда в классе у вас используются объекты, требующие явного закрытия (stream и т.п.), или если вам необходимо реализовать какую-то логику в финальном этапе жизненного цикла объекта.
Правда, в Яве есть finalize, но его вызов зависит от gc, поэтому записывать туда какую-то логику не рекомендуется

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