ChangeListenerCleaner.java
package sprouts.impl;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;
/**
* This class stores actions which are being executed when an associated object is being garbage collected.
* This class is similar to the cleaner class introduced in JDK 11, however the minimal version compatibility target
* for Sprouts is Java 8, which means that this cleaner class introduced in Java 11 is not available here!
* That is why a custom cleaner implementation is needed.<br>
* <br> <br>
* <b>Warning: This is an internal class, meaning it should not be used
* anywhere but within this library. <br>
* This class or its public methods might change or get removed in future versions!</b>
*/
final class ChangeListenerCleaner
{
private static final Logger log = LoggerFactory.getLogger(ChangeListenerCleaner.class);
private static final ChangeListenerCleaner _INSTANCE = new ChangeListenerCleaner();
public static ChangeListenerCleaner getInstance() {
return _INSTANCE;
}
private final ReferenceQueue<Object> _referenceQueue = new ReferenceQueue<>();
/**
* Guards {@link #_toBeCleaned} and the thread life-cycle.
* Used so that {@link #register} and the cleaner thread can coordinate
* safely when adding or removing tracked references.
*/
private final ReentrantLock _lock = new ReentrantLock();
/**
* Tracks every live {@link ReferenceWithCleanup} so that the GC-root
* keeps the phantom references reachable (which is required for them to
* be enqueued) and so we can remove them after cleanup in O(1).
* <p>
* An {@link IdentityHashMap}-backed set is used to make the identity-based
* semantics explicit: {@link ReferenceWithCleanup} deliberately does not
* override {@code equals}/{@code hashCode}, and this set makes that
* contract impossible to break silently in the future.
*/
private final Set<ReferenceWithCleanup<Object>> _toBeCleaned = Collections.newSetFromMap(new IdentityHashMap<>());
/**
* The active cleaner thread. Non-final so it can be recreated if a previous
* instance was interrupted and reached {@link Thread.State#TERMINATED}.
* All reads and writes must be performed while holding {@link #_lock}.
*/
private Thread _thread;
private ChangeListenerCleaner() {
_thread = _newCleanerThread();
}
/** Creates a new, not-yet-started daemon thread that runs {@link #_run()}. */
private Thread _newCleanerThread() {
Thread t = new Thread(this::_run, "Sprouts-Cleaner");
// A background infrastructure thread must be a daemon so it does not
// prevent the JVM from exiting once all user threads have finished.
t.setDaemon(true);
return t;
}
/**
* Ensures the cleaner thread is running. If the thread has never been started
* ({@link Thread.State#NEW}) it is started directly. If it previously terminated
* (e.g. because it was interrupted in a test environment) a fresh thread is created
* and started, since a {@link Thread} cannot be restarted once it has terminated.
*
* <p><b>Must be called while holding {@link #_lock}.</b>
*/
private void _ensureThreadRunning() {
if ( _thread.getState() == Thread.State.TERMINATED )
_thread = _newCleanerThread();
if ( _thread.getState() == Thread.State.NEW )
_thread.start();
}
static final class ReferenceWithCleanup<T> extends PhantomReference<T>
{
/**
* Volatile so that the single-execution guarantee in {@link #cleanup()} is
* safe even if a future caller invokes it from a different thread than the
* one that constructed this reference. In the current design only the
* cleaner thread calls {@code cleanup()}, but the volatile makes the
* contract robust at negligible cost.
*/
private volatile @Nullable Runnable _action;
ReferenceWithCleanup( T referent, Runnable action, ReferenceQueue<T> queue ) {
super( referent, queue );
_action = action;
}
/**
* Executes the registered cleanup action exactly once.
* Subsequent calls are no-ops.
*/
public void cleanup() {
final Runnable action = _action;
if ( action != null ) {
_action = null; // clear before running so re-entrant calls are harmless
try {
action.run();
} catch ( Exception e ) {
Util.sneakyThrowExceptionIfFatal(e);
_logError("Failed to execute cleanup action '{}'.", action, e);
}
}
}
}
/**
* Registers {@code referent} for cleanup: when the GC determines that
* {@code referent} is phantom-reachable, {@code action} will be executed
* on the cleaner thread.
*
* @param referent The object whose collection should trigger the action.
* If {@code null}, the {@code action} is executed immediately on the
* calling thread and an error is logged; no cleanup registration is created.
* @param action The cleanup action to run. Must not retain a strong
* reference to {@code referent}, or the object will never
* be collected.
*/
public void register( @Nullable Object referent, Runnable action ) {
if ( referent == null ) {
// A null referent can never be collected, so fire the action immediately
// and log a warning so the caller can diagnose the misconfiguration.
_logError("Attempt to register a null object for cleanup. This is not allowed!");
try {
action.run();
} catch ( Exception e ) {
Util.sneakyThrowExceptionIfFatal(e);
_logError("Failed to execute cleanup action '{}'.", action, e);
}
return;
}
_lock.lock();
try {
_toBeCleaned.add(new ReferenceWithCleanup<>(referent, action, _referenceQueue));
// Start the thread lazily on the first registration, or restart it
// if a previous instance was interrupted and has since terminated.
_ensureThreadRunning();
} finally {
_lock.unlock();
}
}
/**
* Main loop of the cleaner thread.
*
* <p>The thread blocks indefinitely on {@link ReferenceQueue#remove()} until
* the GC enqueues a collected reference, then runs its cleanup action and
* removes it from the tracking set. This single-phase design avoids the
* periodic wakeups that a timed poll would cause when many long-lived
* referents are registered but none have been collected.
*
* <p>Because the thread is a daemon, the JVM will terminate it automatically
* when all non-daemon threads have exited, so there is no need for the thread
* to monitor whether the tracking set is empty and park itself.
*/
private void _run() {
while ( !Thread.currentThread().isInterrupted() ) {
try {
@SuppressWarnings("unchecked")
ReferenceWithCleanup<Object> ref =
(ReferenceWithCleanup<Object>) _referenceQueue.remove();
// Unbounded remove() never returns null; it blocks until a
// reference is enqueued or the thread is interrupted.
try {
ref.cleanup();
} catch ( Throwable e ) {
Util.sneakyThrowExceptionIfFatal(e);
_logError("Failed to perform cleanup!", e);
} finally {
// Remove under the lock so _toBeCleaned stays consistent with
// concurrent register() calls coming from the main thread.
_lock.lock();
try {
_toBeCleaned.remove(ref);
} finally {
_lock.unlock();
}
}
} catch ( InterruptedException e ) {
Thread.currentThread().interrupt();
return;
} catch ( Throwable e ) {
Util.sneakyThrowExceptionIfFatal(e);
_logError("Unexpected error in cleaner loop.", e);
}
}
}
/**
* Returns the current number of tracked registrations in a thread-safe way.
*/
private int _trackedCount() {
_lock.lock();
try {
return _toBeCleaned.size();
} finally {
_lock.unlock();
}
}
@Override
public String toString() {
return this.getClass().getSimpleName() + "@" + Integer.toHexString(this.hashCode()) + "[" +
"registered=" + _trackedCount() +
"]";
}
private static void _logError(String message, @Nullable Object... args) {
Util._logError(log, message, args);
}
}