Следует ли действительно избегать финализатора Java также для управления жизненным циклом собственных одноранговых объектов?

По моему опыту разработчика C++/Java/Android, я пришел к выводу, что финализаторы почти всегда являются плохой идеей, единственным исключением является управление нативным одноранговым объектом, необходимым java-объекту для вызова кода C/C++ через JNI. .

Я знаю о JNI: правильно управлять временем жизни объекта Java вопрос, но этот вопрос касается причин, не использовать финализатор в любом случае, ни для нативных одноранговых узлов. Итак, это вопрос/обсуждение опровержения ответов на вышеупомянутый вопрос.

Джошуа Блох в своей Effective Java явно перечисляет этот случай как исключение из его знаменитого совета не использовать финализаторы:

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

(Также см. Почему финализированный метод включен в Java? вопрос по stackexchange)

Затем я посмотрел действительно интересное выступление Как управлять встроенной памятью в Android в Google I /O '17, где Ганс Бём на самом деле выступает против использования финализаторов для управления собственными одноранговыми объектами Java, также ссылаясь на Effective Java в качестве ссылки. После быстрого упоминания о том, почему явное удаление родного однорангового узла или автоматическое закрытие на основе области действия может быть нецелесообразной альтернативой, он советует вместо этого использовать java.lang.ref.PhantomReference.

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

Начиная с этого примера:

class BinaryPoly {

    long mNativeHandle; // holds a c++ raw pointer

    private BinaryPoly(long nativeHandle) {
        mNativeHandle = nativeHandle;
    }

    private static native long nativeMultiply(long xCppPtr, long yCppPtr);

    BinaryPoly multiply(BinaryPoly other) {
        return new BinaryPoly ( nativeMultiply(mNativeHandle, other.mNativeHandler) );
    }
    
    // …

    static native void nativeDelete (long cppPtr);

    protected void finalize() {
        nativeDelete(mNativeHandle);
    }
}

Там, где класс Java содержит ссылку на собственный одноранговый класс, который удаляется в методе финализатора, Блох перечисляет недостатки такого подхода.

Финализаторы могут запускаться в произвольном порядке

Если два объекта становятся недостижимыми, финализаторы фактически запускаются в произвольном порядке, включая случай, когда два объекта, которые указывают друг на друга, становятся недостижимыми одновременно, они могут быть финализированы в неправильном порядке, а это означает, что второй объект, подлежащий финализации, на самом деле пытается получить доступ к уже завершенному объекту. [...] В результате вы можете получить оборванные указатели и увидеть освобожденные объекты c++ [...]

И в качестве примера:

class SomeClass {
    BinaryPoly mMyBinaryPoly:
    …
    // DEFINITELY DON’T DO THIS WITH CURRENT BinaryPoly!
    protected void finalize() {
        Log.v(“BPC”, “Dropped + … + myBinaryPoly.toString());   
    }
}

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

Finalizer может быть вызван, пока работает собственный метод

По правилам Java, но не в настоящее время для Android:
финализатор объекта x может быть вызван, пока один из методов x все еще работает и обращается к нативному объекту.

Псевдокод того, во что компилируется multiply(), показан для объяснения этого:

BinaryPoly multiply(BinaryPoly other) {
    long tmpx = this.mNativeHandle; // last use of “this”
    long tmpy = other.mNativeHandle; // last use of other
    BinaryPoly result = new BinaryPoly();
    // GC happens here. “this” and “other” can be reclaimed and finalized.
    // tmpx and tmpy are still needed. But finalizer can delete tmpx and tmpy here!
    result.mNativeHandle = nativeMultiply(tmpx, tmpy)
    return result;
}

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

Быстрым обходным путем для этого может быть вызов некоторых фиктивных методов как для this, так и для other (уродливо!), или передача их собственному методу (где мы затем можем получить mNativeHandle и работать с ним). И подождите... this уже по умолчанию является одним из аргументов нативного метода!

JNIEXPORT void JNICALL Java_package_BinaryPoly_multiply
(JNIEnv* env, jobject thiz, jlong xPtr, jlong yPtr) {}

Как this может быть сборщиком мусора?

Финализаторы могут откладываться слишком долго

«Чтобы это работало правильно, если вы запускаете приложение, которое выделяет много собственной памяти и относительно мало java-памяти, на самом деле может быть не так, что сборщик мусора запускается достаточно быстро, чтобы фактически вызвать финализаторы [...], так что вы на самом деле можете приходится время от времени вызывать System.gc() и System.runFinalization(), что сложно сделать [...]»

Если собственный одноранговый узел виден только одному объекту Java, к которому он привязан, разве этот факт не прозрачен для остальной части системы, и, следовательно, GC должен просто управлять жизненным циклом объекта Java, поскольку он был чистая java? Я явно чего-то не вижу здесь.

Финализаторы могут продлить время жизни объекта Java

[...] Иногда финализаторы фактически продлевают время жизни java-объекта для другого цикла сборки мусора, что означает, что для сборщиков мусора поколения они могут фактически заставить его выжить в старом поколении, и время жизни может быть значительно увеличено в результате просто наличие финализатора.

Я признаю, что не очень понимаю, в чем здесь проблема и как это связано с наличием родного сверстника, я проведу небольшое исследование и, возможно, обновлю вопрос :)

В заключение

На данный момент я по-прежнему считаю, что использование своего рода подхода RAII, когда собственный одноранговый узел создается в конструкторе объекта java и удаляется в методе finalize, на самом деле не опасно, при условии, что:

  • собственный одноранговый узел не содержит критических ресурсов (в этом случае должен быть отдельный метод для освобождения ресурса, собственный одноранговый узел должен действовать только как аналог объекта Java в родной области)
  • родной одноранговый узел не разделяет потоки и не делает странных одновременных действий в своем деструкторе (кто бы захотел это делать?!?)
  • родной одноранговый указатель никогда не передается за пределы объекта java, принадлежит только одному экземпляру и доступен только внутри методов объекта java. В Android объект java может получить доступ к собственному узлу другого экземпляра того же класса прямо перед вызовом метода jni, принимающего разные собственные узлы, или, что лучше, просто передать объекты java самому собственному методу.
  • финализатор java-объекта удаляет только свой родной одноранговый узел и больше ничего не делает

Есть ли какие-либо другие ограничения, которые следует добавить, или действительно нет способа гарантировать, что финализатор безопасен даже при соблюдении всех ограничений?


person athos    schedule 21.05.2017    source источник
comment
Как правило, финализаторы считаются запасным вариантом для вызова dispose() (или чего-то еще), если управляющий код не может этого сделать - и часто выводит предупреждения журнала.   -  person chrylis -cautiouslyoptimistic-    schedule 21.05.2017
comment
Пожалуйста, прочитайте оба вопроса, прежде чем голосовать за дубликат.   -  person Basilevs    schedule 21.05.2017
comment
Я считаю, что это не должно быть помечено как дубликат. Исходный вопрос спрашивает, как мне автоматически удалить родной одноранговый объект? и ответ использовать финализатор. Мой вопрос конкретно касается выступления, в котором предлагается не использовать финализатор (даже вопреки принятому ответу в исходном вопросе!), и я прошу больше информации и других обоснованных мнений, чтобы понять причины такое предложение.   -  person athos    schedule 21.05.2017
comment
Спасибо, что начали это обсуждение. К сожалению, такие открытые дискуссии не очень подходят для формата SO, но я думаю, что это лучшее место для этой темы. Начнем с того, что в моей книге private BinaryPoly(long nativeHandle) не должно быть, и весь этот пример очень искусственный.   -  person Alex Cohn    schedule 22.05.2017
comment
Это может не касаться Android в обозримом будущем (если вообще когда-либо), но обратите внимание на Object.finalize() устарел в Java 9, хотя в аннотации нет атрибута forRemoval=true, поэтому на данном этапе нет определенного плана по фактическому его удалению. Это не умаляет достоинства аргументов в пользу использования finalize(), но я думаю, что он заслуживает упоминания (в конце концов, вопрос имеет тег java).   -  person Hugues M.    schedule 04.06.2017


Ответы (6)


finalize и другие подходы, использующие сборщик мусора о времени жизни объектов, имеют пару нюансов:

  • видимость: гарантируете ли вы, что все созданные методы записи объекта o будут видны финализатору (т. е. существует отношение происходит до между последним действием над объектом o и кодом, выполняющим финализацию)?
  • Доступность: как вы гарантируете, что объект o не будет уничтожен преждевременно (например, пока работает один из его методов), что разрешено JLS? Это действительно происходит и вызывает сбои.
  • порядок: можете ли вы установить определенный порядок финализации объектов?
  • завершение: нужно ли уничтожать все объекты при завершении работы приложения?
  • Пропускная способность. Подходы на основе GC обеспечивают значительно меньшую пропускную способность при освобождении памяти, чем детерминированный подход.

Все эти проблемы можно решить с помощью финализаторов, но для этого потребуется приличное количество кода. Ханс-Дж. У Бёма есть отличная презентация, в которой показаны эти проблемы и возможные решения.

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

  • Сохранение в volatile в конце каждого метода + чтение того же volatile в финализаторе.
  • Снять блокировку объекта в конце каждого метода + получить блокировку в начале финализатора (см. keepAlive реализацию на слайдах Бёма).

Чтобы гарантировать доступность (если она еще не гарантирована спецификацией языка), вы можете использовать:


Разница между обычными finalize и PhantomReferences заключается в том, что последний дает вам больше контроля над различными аспектами финализации:

  • Может иметь несколько очередей, получающих фантомные ссылки, и выбирать поток, выполняющий финализацию для каждой из них.
  • Может завершиться в том же потоке, который выполнял выделение (например, локальный поток ReferenceQueues).
  • Легче обеспечить порядок: сохраняйте сильную ссылку на объект B, который должен оставаться активным, когда A завершается как поле от PhantomReference до A;
  • Легче реализовать безопасное завершение, так как вы должны держать PhantomRefereces надежно доступными до тех пор, пока они не будут поставлены в очередь сборщиком мусора.
person Dmitry Timofeev    schedule 28.06.2017
comment
Замечательный ответ! На слайдах Бема слайд 34, по-видимому, указывает на то, что сохранение в volatile в конце каждого метода + чтение того же volatile в finalize() достаточно как для видимости , так и для достижимости, но это выглядит так: вы считаете, что этот подход хорош только для видимости, а не для досягаемости. Вы можете это прокомментировать? - person jbapple; 03.10.2020
comment
@jbapple спасибо за отзыв, я ценю это! Я думаю, что это, должно быть, была неясная формулировка, я уточнил, что синхронизация, как описано в предыдущем абзаце, уже обеспечивает достижимость. Два других элемента (передача ссылки на this и любые другие объекты, которые должны оставаться доступными, и ограждение) могут использоваться для обеспечения доступности в некоторых редких случаях, когда вам не важна видимость, поэтому не используйте синхронизацию ( например, родной одноранговый узел создается до завершения Java-ctor и является неизменяемым). - person Dmitry Timofeev; 05.10.2020

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

Таким образом, пусть финализатор будет последней попыткой, но не первой.

person cineam mispelt    schedule 05.06.2017
comment
Предположим, что голосование против парня, который знает лучший способ, но не говорит. Что ж, продолжай голосовать в том же духе, приятель, и я продолжу писать первоклассный код. - person cineam mispelt; 06.06.2017
comment
Привет, спасибо за ответ. Использование явных закрытых функций и области действия — хороший совет, когда это работает, но, как сказал Бём в своем выступлении — и по моему опыту — явное освобождение объекта-делегата может быть пугающим и подверженным ошибкам. На мой взгляд, это похоже на попытку выполнить работу сборщика мусора по уничтожению половины объекта. Вот почему нам нужно что-то более автоматическое, например, фантомная ссылка или... удаление объекта делегата / равноправного объекта в финализаторе :) - person athos; 10.06.2017

Я думаю, что большая часть этих споров связана с устаревшим статусом finalize(). Он был введен в Java для решения проблем, которые не покрывает сборка мусора, но не обязательно таких вещей, как системные ресурсы (файлы, сетевые подключения и т. д.), поэтому он всегда казался наполовину испеченным. Я не обязательно согласен с использованием чего-то вроде фантомной ссылки, которая претендует на то, чтобы быть лучшим финализатором, чем finalize(), когда сам шаблон проблематичен.

Хьюг Моро указал, что finalize() будет объявлен устаревшим в Java 9. Предпочтительный шаблон команды Java выглядит следующим образом: относиться к таким вещам, как собственные одноранговые узлы, как к системному ресурсу и очищать их с помощью попытки использования ресурсов. Реализация AutoCloseable позволяет сделать это. Обратите внимание, что try-with-resources и AutoCloseable относятся к дате непосредственного участия Джоша Блоха в Java и 2-м издании Effective Java.

person Scott    schedule 10.06.2017

см. https://github.com/android/platform_frameworks_base/blob/master/graphics/java/android/graphics/Bitmap.java#L135 использовать фантомную ссылку вместо финализатора

person wanpen    schedule 10.06.2017
comment
Привет, спасибо, но, как указано в вопросе, видео ввода-вывода Google, на которое я ссылаюсь, посвящено использованию фантомной ссылки. В чем я не убежден, так это в абсолютной негодности финализаторов, и в частности из четырех заявленных в видео причин не использовать их - person athos; 10.06.2017
comment
Я должен добавить, что в том же видео Бем показывает один из недостатков фантомной ссылки, преждевременное освобождение, из-за которого он избегает передачи java-объектов нативным методам... решение, которое будет работать также с использованием финализатора (см. Финализатор может быть вызван, пока собственный метод находится до точки выполнения в вопросе). - person athos; 10.06.2017

Как это может быть сборщиком мусора?

Потому что функция nativeMultiply(long xCppPtr, long yCppPtr) является статической. Если нативная функция является статической, ее вторым параметром будет jclass, указывающий на ее класс, а не jobject, указывающий на this. Так что в данном случае this не является одним из аргументов.

Если бы он не был статичным, проблема была бы только с объектом other.

person ferini    schedule 08.01.2018
comment
По сути, nativeMultiply() может быть статическим, но принимать два параметра jobject. Пусть код JNI собственного метода извлекает mNativeHandle для обоих аргументов. В идеальном мире код Java никогда не должен иметь дело с непрозрачными нативными дескрипторами. - person Alex Cohn; 01.08.2020

Позвольте мне выступить с провокационным предложением. Если ваша сторона C++ управляемого объекта Java может быть размещена в непрерывной памяти, то вместо традиционного собственного указателя long вы можете использовать DirectByteBuffer. Это действительно может изменить правила игры: теперь сборщик мусора может быть достаточно умным в отношении этих небольших оболочек Java вокруг огромных нативных структур данных (например, решить собрать их раньше).

К сожалению, большинство реальных объектов C++ не попадают в эту категорию...

person Alex Cohn    schedule 11.06.2017