История восходит к Java 9, когда был введен java.lang.ref.Cleaner в качестве замены устаревшему методу Object.finalize(), который страдал от непредсказуемого времени выполнения, проблем с производительностью и уязвимостей безопасности, включая атаки воскрешения. Основная проблема возникает из-за того, что finalize() позволял переопределенному методу хранить ссылку на this в существующем объектном графе, тем самым «воскрешая» объект, который сборщик мусора уже признал недостижимым, нарушая инвариант единственного создания и единственного разрушения и потенциально оставляя нативные ресурсы в неконсистентном состоянии.
Решение использует семантику PhantomReference в реализации Cleaner: действие очистки получает только Runnable или объект действия очистки, а не саму ссылку, и гарантируется, что ссылка находится в состоянии фантомной доступности — это значит, что она уже была финализирована и не может быть воскрешена — гарантируя, что логика очистки работает с безвозвратно недоступным объектом.
public class NativeResource { private static final Cleaner cleaner = Cleaner.create(); private final long nativeHandle; public NativeResource() { nativeHandle = allocateNative(); cleaner.register(this, new CleanupAction(nativeHandle)); Reference.reachabilityFence(this); } private static class CleanupAction implements Runnable { private final long handle; CleanupAction(long handle) { this.handle = handle; } @Override public void run() { releaseNative(handle); } } private native long allocateNative(); private static native void releaseNative(long handle); }
Наша команда управляла высокоп производительным конвейером обработки изображений, где объекты ImageProcessor оборачивали нативные буферы OpenCV, выделенные через JNI. Изначально мы полагались на finalize() для вызова cvReleaseImage(), но время от времени сталкивались с исчерпанием нативной памяти, несмотря на стабильность кучи Java, в сопровождении периодических ошибок сегментации, которые предполагали наличие ошибок use-after-free в нативной памяти.
Первый подход заключался в том, чтобы оставить finalize(), но добавить защиту от воскрешения, синхронизировав статическую карту для отслеживания «мертвых» объектов. Это страдало от непредсказуемой латентности — финализация могла происходить через минуты после начала давления на кучу — а сама логика защиты создавала утечки памяти, удерживая сильные ссылки на «мертвые» объекты в карте отслеживания, иронично полностью предотвращая их сборку, одновременно позволяя условия для воскрешения.
Второй подход заключался в использовании java.lang.ref.Cleaner наивно, захватывая нативный указатель в лямбда-выражении в конструкторе экземпляра: cleaner.register(this, () -> free(pointer)). Хотя это избегало задержек финализации, это создавало риск преждевременной сборки, если this выходил за пределы области во время создания, и критически, если лямбда случайно замыкалась на экземпляре ImageProcessor, а не просто на значении pointer, это создавало бы цикл сильной ссылки, предотвращая сборку мусора для ссылки, хотя все еще предотвращая воскрешение как таковое.
Мы выбрали третий подход: реализация статического вложенного класса CleaningAction, который хранил только нативный указатель (как long) и реализовывал Runnable, полностью декуплированный от внешнего экземпляра ImageProcessor. Мы зарегистрировали действие очистки сразу после успешного выделения нативного ресурса и явно вызвали Reference.reachabilityFence(this) в конце конструктора, чтобы гарантировать, что объект оставался доступным до завершения регистрации. Это устраняло риски воскрешения и утечки ресурсов, снижая инциденты давления на память с ежедневных случаев до нуля за шесть месяцев.
Почему Cleaner использует PhantomReference, а не WeakReference или SoftReference, и как это предотвращает доступ действия очистки к состоянию ссылки?
PhantomReference используется, потому что он позволяет сборщику мусора выявлять объекты, подлежащие восстановлению, не позволяя коду программы получать доступ к полям или методам ссылки после того, как она попадает в состояние фантомной доступности. В отличие от WeakReference, который позволяет извлекать ссылку через get() до сборки, PhantomReference всегда возвращает null из get(), обеспечивая, что действие очистки работает на основе предположения о том, что объект логически уничтожен и предотвращая любые попытки воскрешения или инспекции состояния, которые могут нарушить постмортальные инварианты.
Что такое атака «воскрешения» в контексте Object.finalize(), и почему она нарушает гарантии безопасности системы типов?
Воскрешение происходит, когда метод finalize() сохраняет ссылку this в статическом поле или живом объектном графе, делая объект снова доступным после того, как сборщик мусора отметил его для восстановления. Это нарушает инвариант, что конструктор объекта выполняется ровно один раз, а финализатор выполняется максимум один раз, позволяя вредоносному или ошибочному коду наблюдать объект в частично разрушенном состоянии, где нативные ресурсы освобождены, но поля Java все еще доступны, что приводит к уязвимостям use-after-free и несогласованному поведению объекта.
Как Reference.reachabilityFence взаимодействует с оптимизациями компиляции времени выполнения JVM, и когда необходимо использовать его при регистрации Cleaner?
Reference.reachabilityFence действует как барьер компилятора, который предотвращает переупорядочение или исключение ссылок на объекты до завершения критической секции, специально предотвращая «раннюю публикацию», когда объект становится недоступным, пока его конструктор все еще выполняется. Это строго необходимо при регистрации объектов с Cleaner во время создания, потому что без него JVM может определить, что this больше не нужен после выделения нативного ресурса, но до вызова регистрации, позволяя очистителю запуститься и освободить ресурс, пока конструктор продолжает инициализировать объект, что приводит к повреждению ресурсов.