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

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

Мне известно о JNI: Правильно управляйте временем жизни вопроса об объектах java, но в этом вопросе рассматриваются причины, по-прежнему не использующие финализатор, ни для нативных пиров. Так что это вопрос / дискуссия о путанице ответов на вышеупомянутый вопрос.

Джошуа Блох в своей " Эффективной 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 содержит ссылку на собственный узел, который удаляется в методе finalizer, Bloch перечисляет недостатки такого подхода.

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

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

И как пример:

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

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

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

По правилам 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 neeed. 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-объекта для другого цикла сборки мусора, что означает, что для сборщиков мусора они могут фактически заставить его выжить в старом поколении, а время жизни может быть значительно увеличено в результате просто имея финализатор.

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

В заключение

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

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

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

6 ответов

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

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

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

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

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

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

  • Синхронизация.
  • Reference#reachabilityFence с Java 9.
  • Передайте ссылки на объекты, которые должны оставаться достижимыми (= не финализируемыми) в собственных методах. В разговоре вы упоминаете, nativeMultiply является static, следовательно this может быть мусором.

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

  • Может иметь несколько очередей, получающих фантомные ссылки, и выбирать поток, выполняющий финализацию для каждой из них.
  • Можно завершить в том же потоке, который сделал выделение (например, локальный поток ReferenceQueues).
  • Проще обеспечить порядок: сохраняйте сильную ссылку на объект B это должно остаться в живых, когда A завершается как поле PhantomReference в A;
  • Проще реализовать безопасное завершение, так как вы должны сохранить PhantomRefereces сильно достижимы, пока они не поставлены в GC.

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

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

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

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

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

Как это может быть возможно сбор мусора?

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

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

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

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

Другие вопросы по тегам