Статьи

Отладка для понимания финализатора

Этот пост охватывает одну из встроенных концепций Java, называемую  Finalizer . Эта концепция на самом деле хорошо скрыта и хорошо известна, в зависимости от того, удосужились ли вы взглянуть на   класс java.lang.Object достаточно подробно. Прямо в самом  объекте java.lang.Object  есть метод  finalize () . Реализация метода пуста, но и сила, и опасность связаны с внутренним поведением JVM в зависимости от наличия такого метода.

Когда JVM обнаруживает, что у класса есть   метод finalize () , волшебство начинает происходить. Итак, давайте продолжим и создадим класс с нетривиальным  методом finalize (),  чтобы мы могли увидеть, как по-разному JVM обрабатывает объекты в этом случае. Для этого давайте начнем с создания примера программы:

Пример класса Finalizable

import java.util.concurrent.atomic.AtomicInteger;

class Finalizable {
	static AtomicInteger aliveCount = new AtomicInteger(0);

	Finalizable() {
		aliveCount.incrementAndGet();
	}

	@Override
	protected void finalize() throws Throwable {
		Finalizable.aliveCount.decrementAndGet();
	}

	public static void main(String args[]) {
		for (int i = 0;; i++) {
			Finalizable f = new Finalizable();
			if ((i % 100_000) == 0) {
				System.out.format("After creating %d objects, %d are still alive.%n", new Object[] {i, Finalizable.aliveCount.get() });
			}
		}
	}
}

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

Так что вы ожидаете от такого простого фрагмента кода? Поскольку на вновь созданные объекты нигде нет ссылок, они должны быть немедленно допущены к GC. Таким образом, вы можете ожидать, что код будет работать вечно, а выходные данные программы будут выглядеть примерно так:

After creating 345,000,000 objects, 0 are still alive.
After creating 345,100,000 objects, 0 are still alive.
After creating 345,200,000 objects, 0 are still alive.
After creating 345,300,000 objects, 0 are still alive.

Видимо, это не так. Реальность совершенно иная, например, в моей Mac OS X на JDK 1.7.0_51 я вижу, что программа завершается с ошибкой с  java.lang.OutOfMemoryError: лимит накладных расходов GC превышен  примерно после того, как было создано ~ 1,2M объектов:

After creating 900,000 objects, 791,361 are still alive.
After creating 1,000,000 objects, 875,624 are still alive.
After creating 1,100,000 objects, 959,024 are still alive.
After creating 1,200,000 objects, 1,040,909 are still alive.
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
	at java.lang.ref.Finalizer.register(Finalizer.java:90)
	at java.lang.Object.(Object.java:37)
	at eu.plumbr.demo.Finalizable.(Finalizable.java:8)
	at eu.plumbr.demo.Finalizable.main(Finalizable.java:19)

Поведение сбора мусора

Чтобы понять, что происходит, нам нужно взглянуть на наш пример кода во время выполнения. Для этого давайте запустим наш пример с  включенным  флагом -XX: + PrintGCDetails :

[GC [PSYoungGen: 16896K->2544K(19456K)] 16896K->16832K(62976K), 0.0857640 secs] [Times: user=0.22 sys=0.02, real=0.09 secs] 
[GC [PSYoungGen: 19440K->2560K(19456K)] 33728K->31392K(62976K), 0.0489700 secs] [Times: user=0.14 sys=0.01, real=0.05 secs] 
[GC-- [PSYoungGen: 19456K->19456K(19456K)] 48288K->62976K(62976K), 0.0601190 secs] [Times: user=0.16 sys=0.01, real=0.06 secs] 
[Full GC [PSYoungGen: 16896K->14845K(19456K)] [ParOldGen: 43182K->43363K(43520K)] 60078K->58209K(62976K) [PSPermGen: 2567K->2567K(21504K)], 0.4954480 secs] [Times: user=1.76 sys=0.01, real=0.50 secs] 
[Full GC [PSYoungGen: 16896K->16820K(19456K)] [ParOldGen: 43361K->43361K(43520K)] 60257K->60181K(62976K) [PSPermGen: 2567K->2567K(21504K)], 0.1379550 secs] [Times: user=0.47 sys=0.01, real=0.14 secs] 
--- cut for brevity---
[Full GC [PSYoungGen: 16896K->16893K(19456K)] [ParOldGen: 43351K->43351K(43520K)] 60247K->60244K(62976K) [PSPermGen: 2567K->2567K(21504K)], 0.1231240 secs] [Times: user=0.45 sys=0.00, real=0.13 secs] 
[Full GCException in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
 [PSYoungGen: 16896K->16866K(19456K)] [ParOldGen: 43351K->43351K(43520K)] 60247K->60218K(62976K) [PSPermGen: 2591K->2591K(21504K)], 0.1301790 secs] [Times: user=0.44 sys=0.00, real=0.13 secs] 
	at eu.plumbr.demo.Finalizable.main(Finalizable.java:19)

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

Чтобы понять, почему GC ведет себя так, как он это делает, давайте внесем незначительные изменения в код и удалим тело метода  finalize ()  . Теперь JVM обнаруживает, что наш класс не нужно завершать, и меняет поведение обратно на «нормальное». Глядя на журналы GC, мы увидим только дешевые второстепенные GC, которые будут работать вечно.

Ява памяти Eden Permgen занимал должность молодой
Поскольку в этом модифицированном примере ничто действительно не относится к объектам в Эдеме (где все объекты рождаются), ГХ может выполнять очень эффективную работу и сразу отбрасывать весь Эдем. Итак, мы немедленно очистили весь Эдем, и бесконечный цикл может продолжаться вечно.

С другой стороны, в нашем оригинальном примере ситуация иная. Вместо объектов без каких-либо ссылок  JVM создает персональный сторожевой таймер для каждого  экземпляра Finalizable . Этот сторожевой таймер является экземпляром  Finalizer . И на все эти экземпляры в свою очередь ссылается   класс Finalizer . Так что благодаря этой ссылочной цепочке вся банда остается в живых.

Теперь, когда Eden заполнен и все объекты ссылаются, у GC нет других альтернатив, кроме как скопировать все в пространство Survivor. Или, что еще хуже, если свободное пространство в Survivor также ограничено, расширьте его до пространства с постоянными правами. Как вы, возможно, помните, GC в пространстве Tenured — это совершенно другой зверь, и он намного дороже, чем метод «давайте выбросим все», используемый для очистки Эдема.

Очередь финализатора

Только после завершения GC JVM понимает, что, кроме финализаторов, ничто не относится к нашим экземплярам, ​​поэтому он может пометить все финализаторы, указывающие на эти экземпляры, как готовые к обработке. Таким образом, внутреннее устройство GC добавляет все объекты Finalizer в специальную очередь по адресу java.lang.ref.Finalizer.ReferenceQueue .

Только когда все эти хлопоты будут завершены, наши потоки приложений смогут приступить к реальной работе. Один из этих потоков сейчас особенно интересен для нас —   поток демонов «Финализатор» . Вы можете увидеть этот поток в действии, взяв дамп потока через jstack:

My Precious:~ demo$ jps
1703 Jps
1702 Finalizable
My Precious:~ demo$ jstack 1702

--- cut for brevity ---
"Finalizer" daemon prio=5 tid=0x00007fe33b029000 nid=0x3103 runnable [0x0000000111fd4000]
   java.lang.Thread.State: RUNNABLE
	at java.lang.ref.Finalizer.invokeFinalizeMethod(Native Method)
	at java.lang.ref.Finalizer.runFinalizer(Finalizer.java:101)
	at java.lang.ref.Finalizer.access$100(Finalizer.java:32)
	at java.lang.ref.Finalizer$FinalizerThread.run(Finalizer.java:190)
--- cut for brevity ---

Из вышесказанного мы видим   работающий поток демона «Финализатор»Тема «Финализатор»  — это тема с единственной ответственностью. Поток выполняет заблокированный неопределенный цикл, ожидая появления новых экземпляров в   очереди java.lang.ref.Finalizer.ReferenceQueue . Всякий раз, когда   потоки «Finalizer» обнаруживают новые объекты в очереди, он извлекает объект, вызывает метод  finalize ()  и удаляет ссылку из   класса Finalizer , поэтому в следующий раз, когда GC запустит  Finalizer,  и объект, на который ссылаются, теперь может быть GCd.

Таким образом, у нас есть два незавершенных цикла, работающих в двух разных потоках. Наш главный поток занят созданием новых объектов. Все эти объекты имеют свои собственные сторожевые таймеры под названием  Finalizer,  которые добавляются в  java.lang.ref.Finalizer.ReferenceQueue  GC. И поток « Финализатор » обрабатывает эту очередь, извлекая все экземпляры из этой очереди и вызывая   методы finalize () для экземпляров.

Большую часть времени вам это сойдет с рук. Вызов   метода finalize () должен завершиться быстрее, чем мы фактически создаем новые экземпляры. Поэтому во многих случаях   поток «Финализатор» сможет догнать и очистить очередь до того, как следующий GC добавит  в нее больше  Финализаторов . В нашем случае это, видимо, не происходит.

Почему так? Поток  «Финализатор»  запускается с более низким приоритетом, чем основной поток. Это означает, что он получает меньше процессорного времени и, таким образом, не может догнать темп создания объектов. И вот что у нас получилось — объекты создаются быстрее, чем  поток  «Финализатор» может их  завершить ()  , что приводит к использованию всей доступной кучи. Результат — разные вкусы нашего дорогого друга java.lang.OutOfMemoryError .

Если ты все еще не веришь мне, возьми кучу свалок и загляни внутрь. Например, когда наш отрыванный код запускается с  параметром -XX: + HeapDumpOnOutOfMemoryError  , я вижу следующую картинку в дереве доминантных объектов Eclipse MAT:
Eclipse MAT дерево-доминатор
Как видно из скриншота, моя куча на 64 м полностью заполнена  финализаторами .

Выводы

Напомним, что жизненный цикл   объектов Finalizable полностью отличается от стандартного поведения, а именно:

  • JVM создаст экземпляр   объекта Finalizable
  • JVM создаст экземпляр  java.lang.ref.Finalizer , указывая на наш вновь созданный экземпляр объекта.
  •  Класс java.lang.ref.Finalizer поддерживает   только что созданный экземпляр java.lang.ref.Finalizer . Это блокирует следующий незначительный сборщик мусора от сбора наших объектов и поддерживает их в живых.
  • Незначительный сборщик мусора не может очистить Эдем и расширяется до Выживших и / или Арендованных пространств.
  • GC обнаруживает, что объекты могут быть завершены, и добавляет эти объекты в java.lang.ref.Finalizer.ReferenceQueue.
  • Очередь будет обрабатываться потоком « Finalizer », выталкивая объекты один за другим и вызывая их   методы finalize () .
  • После  вызова finalize () поток  « Finalizer » удаляет ссылку из класса Finalizer, поэтому во время следующего GC объекты могут быть GCd.
  • Поток « Финализатор » конкурирует с нашим « основным » потоком, но из-за более низкого приоритета получает меньше процессорного времени и, следовательно, никогда не сможет его догнать.
  • Программа исчерпывает все доступные ресурсы и выдает  OutOfMemoryError .

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