postgresql-jdbc.git / summary / log / commit / refs
commit bdd930bf458e3d490d93edc4dc24a67e3a2d5964
Author: Vladimir Sitnikov <sitnikov.vladimir@gmail.com>
Date: Wed Dec 03 07:37:35 2025 +0000
fix: avoid memory leaks caused by Thread.inheritedAccessControlContext
Thread.inheritedAccessControlContext inherits the caller ProtectionDomain, thus
it effectively holds its classloader.
There's no way to "unset inheritedAccessControlContext".
For Java 11+ use java.lang.ref.Cleaner-based approach via multi-release jar
We workaround that by going for ForkJoinPool + ManagedBlocker so it is JDK
that creates a thread, thus it should have no inheritedAccessControlContext.
The logic is as follows:
1) ForkJoinPool task runs on CPU and it processes the queue
2) If the queue is empty, the blocking call delegates to ManagedBlocker
It fits nice with FJP: CPU tasks do not create extra threads, and blocking
queue operations do not starve FJP CPU threads.
See https://stackoverflow.com/questions/78163385/does-postgres-jdbc-lazycleaner-cause-a-classloader-memory-leak/79835942
.github/workflows/main.yml | 2 +-
.../kotlin/build-logic.checkerframework.gradle.kts | 28 +--
pgjdbc/build.gradle.kts | 38 ++++
.../java/org/postgresql/jdbc/PgConnection.java | 3 +-
.../main/java/org/postgresql/util/LazyCleaner.java | 37 ++++
.../java/org/postgresql/util/LazyCleanerImpl.java | 202 ++++++++++++---------
.../main/java/org/postgresql/util/SharedTimer.java | 2 +-
.../java/org/postgresql/util/StreamWrapper.java | 2 +-
.../org/postgresql/util/LazyCleanerImpl.java | 151 +++++++++++++++
.../WindowsSecurityContextImpl.astub | 0
.../org/postgresql/test/jdbc2/StatementTest.java | 3 +-
.../java/org/postgresql/util/LazyCleanerTest.java | 162 +++++++++++------
12 files changed, 474 insertions(+), 156 deletions(-)
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 80d4ff34..888ebf0f 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -79,7 +79,7 @@ jobs:
with:
read-only: false
job-id: checker-jdk21
- arguments: --scan --no-parallel --no-daemon -PenableCheckerframework classes
+ arguments: --scan --no-parallel --no-daemon -PenableCheckerframework classes :postgresql:compileJava11
source-distribution-check:
name: Source distribution
diff --git a/build-logic/verification/src/main/kotlin/build-logic.checkerframework.gradle.kts b/build-logic/verification/src/main/kotlin/build-logic.checkerframework.gradle.kts
index 6d133910..2a0db7d7 100644
--- a/build-logic/verification/src/main/kotlin/build-logic.checkerframework.gradle.kts
+++ b/build-logic/verification/src/main/kotlin/build-logic.checkerframework.gradle.kts
@@ -3,6 +3,7 @@ import org.gradle.kotlin.dsl.dependencies
plugins {
id("build-logic.build-params")
id("org.checkerframework")
+ id("java")
}
dependencies {
@@ -33,21 +34,26 @@ checkerFramework {
checkers.add("org.checkerframework.checker.optional.OptionalChecker")
// checkers.add("org.checkerframework.checker.index.IndexChecker")
checkers.add("org.checkerframework.checker.regex.RegexChecker")
- extraJavacArgs.add("-Astubs=" +
- files(
+ // Translation classes are autogenerated
+ extraJavacArgs.add("-AskipDefs=^org\\.postgresql\\.translation\\.")
+ // The below produces too many warnings :(
+ // extraJavacArgs.add("-Alint=redundantNullComparison")
+}
+
+sourceSets.all {
+ val sourceSetName = name
+ // Every sourceset
+ tasks.named<JavaCompile>(compileJavaTaskName) {
+ options.compilerArgs.add(
+ "-Astubs=" + files(
// Global stubs
fileTree("$rootDir/config/checkerframework") {
include("*.astub")
},
- // Project-local stubs
- fileTree(layout.projectDirectory.dir("src/main/checkerframework")) {
+ fileTree(layout.projectDirectory.dir("src/${sourceSetName}Checkerframework")) {
include("*.astub")
},
- )
- .asPath
- )
- // Translation classes are autogenerated, and they
- extraJavacArgs.add("-AskipDefs=^org\\.postgresql\\.translation\\.")
- // The below produces too many warnings :(
- // extraJavacArgs.add("-Alint=redundantNullComparison")
+ ).asPath
+ )
+ }
}
diff --git a/pgjdbc/build.gradle.kts b/pgjdbc/build.gradle.kts
index 65838f88..2f8b8a73 100644
--- a/pgjdbc/build.gradle.kts
+++ b/pgjdbc/build.gradle.kts
@@ -46,6 +46,41 @@ java {
}
}
+// Create a separate source set for Java 11+ specific code (e.g., java.lang.ref.Cleaner)
+val java11 by sourceSets.creating {
+ java {
+ srcDir("src/main/java11")
+ }
+ // Make java11 source set depend on main source set (to access LazyCleaner base class)
+ compileClasspath += sourceSets.main.get().output
+}
+
+if (buildParameters.testJdkVersion >= 11) {
+ // By default, Gradle uses "test classes" dir for classpath, so multi-release jar is not used there
+ // So we explicitly prepend the classpath with Java 11 classes
+ tasks.test {
+ classpath = java11.output + classpath
+ }
+}
+
+// Configure the java11 source set to compile with Java 11
+tasks.named<JavaCompile>(java11.compileJavaTaskName) {
+ options.release.set(11)
+ // Ensure main classes are compiled before java11 classes
+ dependsOn(tasks.compileJava)
+}
+
+fun CopySpec.addMultiReleaseContents() {
+ into("META-INF/versions/11") {
+ from(java11.output)
+ }
+}
+
+// Add java11 compiled classes to the main JAR
+tasks.jar {
+ addMultiReleaseContents()
+}
+
val knows by tasks.existing {
group = null // Hide the task from `./gradlew tasks` output
description = "This is a dummy task, unfortunately the author refuses to remove it: https://github.com/johnrengelman/shadow/issues/122"
@@ -109,6 +144,7 @@ dependencies {
shaded("com.ongres.scram:scram-client:3.2")
implementation("org.checkerframework:checker-qual:3.52.0")
+ java11.implementationConfigurationName("org.checkerframework:checker-qual:3.52.0")
testKitSourcesWithoutAnnotations(projects.testkit)
@@ -227,6 +263,7 @@ tasks.configureEach<Jar> {
manifest {
attributes["Main-Class"] = "org.postgresql.util.PGJDBCMain"
attributes["Automatic-Module-Name"] = "org.postgresql.jdbc"
+ attributes["Multi-Release"] = "true"
}
}
@@ -243,6 +280,7 @@ tasks.shadowJar {
exclude("META-INF/NOTICE*")
exclude("LICENSE")
exclude("NOTICE")
+ addMultiReleaseContents()
listOf(
"com.ongres"
).forEach {
diff --git a/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java b/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java
index 34159c84..4ff26540 100644
--- a/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java
+++ b/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java
@@ -43,6 +43,7 @@ import org.postgresql.util.DriverInfo;
import org.postgresql.util.GT;
import org.postgresql.util.HostSpec;
import org.postgresql.util.LazyCleaner;
+import org.postgresql.util.LazyCleanerImpl;
import org.postgresql.util.LruCache;
import org.postgresql.util.PGBinaryObject;
import org.postgresql.util.PGInterval;
@@ -398,7 +399,7 @@ public class PgConnection implements BaseConnection {
replicationConnection = PGProperty.REPLICATION.getOrDefault(info) != null;
xmlFactoryFactoryClass = PGProperty.XML_FACTORY_FACTORY.getOrDefault(info);
- cleanable = LazyCleaner.getInstance().register(leakHandle, finalizeAction);
+ cleanable = LazyCleanerImpl.getInstance().register(leakHandle, finalizeAction);
}
private static ReadOnlyBehavior getReadOnlyBehavior(@Nullable String property) {
diff --git a/pgjdbc/src/main/java/org/postgresql/util/LazyCleaner.java b/pgjdbc/src/main/java/org/postgresql/util/LazyCleaner.java
new file mode 100644
index 00000000..24b9b468
--- /dev/null
+++ b/pgjdbc/src/main/java/org/postgresql/util/LazyCleaner.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2023, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.util;
+
+public interface LazyCleaner {
+ /**
+ * Cleanable interface for objects that can be manually cleaned.
+ *
+ * @param <T> the type of exception that can be thrown during cleanup
+ */
+ interface Cleanable<T extends Throwable> {
+ void clean() throws T;
+ }
+
+ /**
+ * CleaningAction interface for cleanup actions that are notified whether cleanup
+ * occurred due to a leak (automatic) or manual cleanup.
+ *
+ * @param <T> the type of exception that can be thrown during cleanup
+ */
+ interface CleaningAction<T extends Throwable> {
+ void onClean(boolean leak) throws T;
+ }
+
+ /**
+ * Registers an object for cleanup when it becomes phantom reachable.
+ *
+ * @param obj the object to monitor for cleanup (should not be the same as action)
+ * @param action the action to perform when the object becomes unreachable
+ * @param <T> the type of exception that can be thrown during cleanup
+ * @return a Cleanable that can be used to manually trigger cleanup
+ */
+ <T extends Throwable> Cleanable<T> register(Object obj, CleaningAction<T> action);
+}
diff --git a/pgjdbc/src/main/java/org/postgresql/util/LazyCleanerImpl.java b/pgjdbc/src/main/java/org/postgresql/util/LazyCleanerImpl.java
index 6c29a9e5..48d55541 100644
--- a/pgjdbc/src/main/java/org/postgresql/util/LazyCleanerImpl.java
+++ b/pgjdbc/src/main/java/org/postgresql/util/LazyCleanerImpl.java
@@ -25,73 +25,71 @@ package org.postgresql.util;
import org.checkerframework.checker.nullness.qual.Nullable;
import java.lang.ref.PhantomReference;
+import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.time.Duration;
-import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ForkJoinPool;
+import java.util.function.BooleanSupplier;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* LazyCleaner is a utility class that allows to register objects for deferred cleanup.
*
+ * <p>This is the Java 8 compatible implementation that uses PhantomReferences
+ * and ForkJoinPool for deferred cleanup operations.</p>
+ *
+ * <p>On Java 11+, this class is replaced by a version that uses the native
+ * {@link java.lang.ref.Cleaner} API via the multi-release JAR mechanism.</p>
+ *
* <p>Note: this is a driver-internal class</p>
*/
-public class LazyCleaner {
- private static final Logger LOGGER = Logger.getLogger(LazyCleaner.class.getName());
- private static final LazyCleaner instance =
- new LazyCleaner(
- Duration.ofMillis(Long.getLong("pgjdbc.config.cleanup.thread.ttl", 30000)),
- "PostgreSQL-JDBC-Cleaner"
- );
-
- public interface Cleanable<T extends Throwable> {
- void clean() throws T;
- }
-
- public interface CleaningAction<T extends Throwable> {
- void onClean(boolean leak) throws T;
- }
+public class LazyCleanerImpl implements LazyCleaner {
+ private static final Logger LOGGER = Logger.getLogger(LazyCleanerImpl.class.getName());
+ private static final LazyCleanerImpl instance = new LazyCleanerImpl(
+ "PostgreSQL-JDBC-Cleaner",
+ Duration.ofMillis(Long.getLong("pgjdbc.config.cleanup.thread.ttl", 30000))
+ );
private final ReferenceQueue<Object> queue = new ReferenceQueue<>();
- private final long threadTtl;
- private final ThreadFactory threadFactory;
+ private final String threadName;
+ private final Duration threadTtl;
private boolean threadRunning;
- private int watchedCount;
private @Nullable Node<?> first;
+ /**
+ * Creates a LazyCleaner with the specified configuration.
+ *
+ * @param threadName the name for the cleanup thread
+ * @param threadTtl the maximum time the cleanup thread will wait for references
+ */
+ public LazyCleanerImpl(String threadName, Duration threadTtl) {
+ this.threadName = threadName;
+ this.threadTtl = threadTtl;
+ }
+
/**
* Returns a default cleaner instance.
*
* <p>Note: this is driver-internal API.</p>
* @return the instance of LazyCleaner
*/
- public static LazyCleaner getInstance() {
+ public static LazyCleanerImpl getInstance() {
return instance;
}
- public LazyCleaner(Duration threadTtl, final String threadName) {
- this(threadTtl, runnable -> {
- Thread thread = new Thread(runnable, threadName);
- thread.setDaemon(true);
- return thread;
- });
- }
-
- private LazyCleaner(Duration threadTtl, ThreadFactory threadFactory) {
- this.threadTtl = threadTtl.toMillis();
- this.threadFactory = threadFactory;
- }
-
+ @Override
public <T extends Throwable> Cleanable<T> register(Object obj, CleaningAction<T> action) {
assert obj != action : "object handle should not be the same as cleaning action, otherwise"
+ " the object will never become phantom reachable, so the action will never trigger";
- return add(new Node<T>(obj, action));
- }
-
- public synchronized int getWatchedCount() {
- return watchedCount;
+ return add(new Node<>(obj, action));
}
+ /**
+ * Returns whether the cleanup thread is currently running.
+ *
+ * @return true if cleanup operations are active
+ */
public synchronized boolean isThreadRunning() {
return threadRunning;
}
@@ -110,7 +108,6 @@ public class LazyCleaner {
first.prev = node;
}
first = node;
- watchedCount++;
if (!threadRunning) {
threadRunning = startThread();
@@ -118,56 +115,92 @@ public class LazyCleaner {
return node;
}
+ /**
+ * RefQueueBlocker retrieves references from the reference queue without blocking ForkJoinPool
+ * CPU threads.
+ *
+ * @param <T> the type of the objects referenced by the {@link Reference}s in the queue
+ */
+ private static class RefQueueBlocker<T> implements ForkJoinPool.ManagedBlocker {
+ private final ReferenceQueue<T> queue;
+ private final String threadName;
+ private @Nullable Reference<? extends T> ref;
+ private final long blockTimeoutMillis;
+ private final BooleanSupplier shouldTerminate;
+
+ RefQueueBlocker(ReferenceQueue<T> queue, String threadName, Duration blockTimeout, BooleanSupplier shouldTerminate) {
+ this.queue = queue;
+ this.threadName = threadName;
+ this.blockTimeoutMillis = blockTimeout.toMillis();
+ this.shouldTerminate = shouldTerminate;
+ }
+
+ @Override
+ public boolean isReleasable() {
+ if (ref != null || shouldTerminate.getAsBoolean()) {
+ return true; // already have a ref from a previous call
+ }
+ // non-blocking check
+ ref = queue.poll();
+ // no need to block if we already have a ref from a previous call
+ return ref != null;
+ }
+
+ @Override
+ public boolean block() throws InterruptedException {
+ if (isReleasable()) {
+ return true;
+ }
+ Thread currentThread = Thread.currentThread();
+ // ForkJoinPool reuses threads, so we set the thread name just for the blocking operation
+ String oldName = currentThread.getName();
+ try {
+ currentThread.setName(threadName);
+ // Perform blocking operation
+ ref = queue.remove(blockTimeoutMillis);
+ } finally {
+ currentThread.setName(oldName);
+ }
+ return false;
+ }
+
+ public @Nullable Reference<? extends T> drainOne() {
+ Reference<? extends T> ref = this.ref;
+ this.ref = null;
+ return ref;
+ }
+ }
+
private boolean startThread() {
- Thread thread = threadFactory.newThread(new Runnable() {
- @Override
- public void run() {
- while (true) {
- try {
- // Clear setContextClassLoader to avoid leaking the classloader
- Thread.currentThread().setContextClassLoader(null);
- Thread.currentThread().setUncaughtExceptionHandler(null);
- // Node extends PhantomReference, so this cast is safe
- Node<?> ref = (Node<?>) queue.remove(threadTtl);
- if (ref == null) {
- if (checkEmpty()) {
+ // We use ForkJoinPool to work around Thread.inheritedAccessControlContext memory leak
+ // Java creates FJP threads without caller's access control context, thus we reduce
+ // the surface for the leak.
+ ForkJoinPool.commonPool().execute(
+ () -> {
+ // Clear setContextClassLoader to avoid leaking the classloader
+ Thread.currentThread().setContextClassLoader(null);
+ RefQueueBlocker<Object> blocker =
+ new RefQueueBlocker<>(queue, threadName, threadTtl, this::checkEmpty);
+ while (!checkEmpty()) {
+ try {
+ ForkJoinPool.managedBlock(blocker);
+ Node<?> ref = (Node<?>) blocker.drainOne();
+ if (ref != null) {
+ ref.onClean(true);
+ }
+ } catch (InterruptedException e) {
+ if (!blocker.isReleasable()) {
+ LOGGER.log(Level.FINE, "Got interrupt and the cleanup queue is empty, will terminate the cleanup thread");
break;
}
- continue;
- }
- try {
- ref.onClean(true);
+ LOGGER.log(Level.FINE, "Got interrupt and the cleanup queue is NOT empty. Will ignore the interrupt");
} catch (Throwable e) {
- if (e instanceof InterruptedException) {
- // This could happen if onClean uses sneaky-throws
- LOGGER.log(Level.WARNING, "Unexpected interrupt while executing onClean", e);
- throw e;
- }
- // Should not happen if cleaners are well-behaved
LOGGER.log(Level.WARNING, "Unexpected exception while executing onClean", e);
}
- } catch (InterruptedException e) {
- if (LazyCleaner.this.checkEmpty()) {
- LOGGER.log(
- Level.FINE,
- "Cleanup queue is empty, and got interrupt, will terminate the cleanup thread"
- );
- break;
- }
- LOGGER.log(Level.FINE, "Ignoring interrupt since the cleanup queue is non-empty");
- } catch (Throwable e) {
- // Ignore exceptions from the cleanup action
- LOGGER.log(Level.WARNING, "Unexpected exception in cleaner thread main loop", e);
}
}
- }
- });
- if (thread != null) {
- thread.start();
- return true;
- }
- LOGGER.log(Level.WARNING, "Unable to create cleanup thread");
- return false;
+ );
+ return true;
}
private synchronized boolean remove(Node<?> node) {
@@ -191,13 +224,12 @@ public class LazyCleaner {
node.next = node;
node.prev = node;
- watchedCount--;
return true;
}
private class Node<T extends Throwable> extends PhantomReference<Object> implements Cleanable<T>,
CleaningAction<T> {
- private final @Nullable CleaningAction<T> action;
+ private final CleaningAction<T> action;
private @Nullable Node<?> prev;
private @Nullable Node<?> next;
@@ -217,9 +249,7 @@ public class LazyCleaner {
if (!remove(this)) {
return;
}
- if (action != null) {
- action.onClean(leak);
- }
+ action.onClean(leak);
}
}
}
diff --git a/pgjdbc/src/main/java/org/postgresql/util/SharedTimer.java b/pgjdbc/src/main/java/org/postgresql/util/SharedTimer.java
index 27b45fbc..ee793a13 100644
--- a/pgjdbc/src/main/java/org/postgresql/util/SharedTimer.java
+++ b/pgjdbc/src/main/java/org/postgresql/util/SharedTimer.java
@@ -62,7 +62,7 @@ public class SharedTimer {
Thread.currentThread().setContextClassLoader(null);
this.timer = timer = new Timer("PostgreSQL-JDBC-SharedTimer-" + index, true);
- this.timerCleanup = LazyCleaner.getInstance().register(refCount, new TimerCleanup(timer));
+ this.timerCleanup = LazyCleanerImpl.getInstance().register(refCount, new TimerCleanup(timer));
} finally {
Thread.currentThread().setContextClassLoader(prevContextCL);
}
diff --git a/pgjdbc/src/main/java/org/postgresql/util/StreamWrapper.java b/pgjdbc/src/main/java/org/postgresql/util/StreamWrapper.java
index 03072dfc..8c1190d1 100644
--- a/pgjdbc/src/main/java/org/postgresql/util/StreamWrapper.java
+++ b/pgjdbc/src/main/java/org/postgresql/util/StreamWrapper.java
@@ -75,7 +75,7 @@ public final class StreamWrapper implements Closeable {
this.stream = null; // The stream is opened on demand
TempFileHolder tempFileHolder = new TempFileHolder(tempFile);
this.tempFileHolder = tempFileHolder;
- cleaner = LazyCleaner.getInstance().register(leakHandle, tempFileHolder);
+ cleaner = LazyCleanerImpl.getInstance().register(leakHandle, tempFileHolder);
} else {
this.rawData = rawData;
this.stream = null;
diff --git a/pgjdbc/src/main/java11/org/postgresql/util/LazyCleanerImpl.java b/pgjdbc/src/main/java11/org/postgresql/util/LazyCleanerImpl.java
new file mode 100644
index 00000000..a6296242
--- /dev/null
+++ b/pgjdbc/src/main/java11/org/postgresql/util/LazyCleanerImpl.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (c) 2023, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.util;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.lang.ref.Cleaner;
+import java.time.Duration;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * LazyCleaner is a utility class that allows to register objects for deferred cleanup.
+ *
+ * <p>This is the Java 11+ implementation that uses the native {@link Cleaner} API
+ * introduced in Java 9+ for deferred cleanup operations.</p>
+ *
+ * <p>This class replaces the Java 8 PhantomReference-based implementation via the
+ * multi-release JAR mechanism when running on Java 11+.</p>
+ *
+ * <p>Note: this is a driver-internal class</p>
+ */
+public class LazyCleanerImpl implements LazyCleaner {
+ private static final Logger LOGGER = Logger.getLogger(LazyCleanerImpl.class.getName());
+ private static final LazyCleanerImpl instance = new LazyCleanerImpl(
+ "PostgreSQL-JDBC-Cleaner",
+ Duration.ofMillis(Long.getLong("pgjdbc.config.cleanup.thread.ttl", 30000))
+ );
+
+ private final Cleaner cleaner;
+
+ /**
+ * Creates a LazyCleaner with the specified configuration.
+ *
+ * <p>Note: The {@code threadName} and {@code threadTtl} parameters are ignored
+ * in this implementation since the JVM manages Cleaner threads internally.</p>
+ *
+ * @param threadName the name for the cleanup thread (ignored, for API compatibility)
+ * @param threadTtl the maximum time the cleanup thread will wait (ignored, for API compatibility)
+ */
+ public LazyCleanerImpl(String threadName, Duration threadTtl) {
+ // threadName and threadTtl are ignored in Java 11+ since Cleaner manages its own threads
+ this.cleaner = Cleaner.create();
+ }
+
+ /**
+ * Returns a default cleaner instance.
+ *
+ * <p>Note: this is driver-internal API.</p>
+ * @return the instance of LazyCleaner
+ */
+ public static LazyCleanerImpl getInstance() {
+ return instance;
+ }
+
+ @Override
+ public <T extends Throwable> Cleanable<T> register(Object obj, CleaningAction<T> action) {
+ assert obj != action : "object handle should not be the same as cleaning action, otherwise"
+ + " the object will never become phantom reachable, so the action will never trigger";
+
+ // Wrapper is needed to prevent double cleanup (e.g. user-managed cleanups and automatic cleanup)
+ CleanableWrapper<T> wrapper = new CleanableWrapper<>(action);
+
+ // Register with the native Cleaner, using the wrapper's cleanup method
+ Cleaner.Cleanable nativeCleanable = cleaner.register(obj, wrapper::leakDetected);
+
+ // Add the reference so the cleanable could be deregistered when user calls clean()
+ wrapper.setNativeCleanable(nativeCleanable);
+ return wrapper;
+ }
+
+ /**
+ * Returns whether the cleanup thread is currently running.
+ *
+ * <p>For the Java 11+ implementation, this always returns false since the Cleaner
+ * manages its own thread pool internally and there is no dedicated cleanup thread.</p>
+ *
+ * @return false, since the Cleaner manages threads internally
+ */
+ public boolean isThreadRunning() {
+ // The Cleaner manages its own thread pool, so we return false meaning "there's no extra cleanup thread running"
+ return false;
+ }
+
+ /**
+ * Wrapper that adapts the native Cleaner.Cleanable to our Cleanable interface.
+ */
+ private static class CleanableWrapper<T extends Throwable> implements Cleanable<T> {
+ private Cleaner.@Nullable Cleanable nativeCleanable;
+ private volatile @Nullable CleaningAction<T> action;
+
+ CleanableWrapper(CleaningAction<T> action) {
+ this.action = action;
+ }
+
+ void setNativeCleanable(Cleaner.Cleanable nativeCleanable) {
+ this.nativeCleanable = nativeCleanable;
+ }
+
+ private synchronized @Nullable CleaningAction<T> getCleaningAction() {
+ CleaningAction<T> action = this.action;
+ this.action = null;
+ return action;
+ }
+
+ /**
+ * Performs the actual cleanup if it hasn't been done yet.
+ * This method is called both from manual clean() and from the Cleaner thread.
+ */
+ void leakDetected() {
+ CleaningAction<T> cleaningAction = getCleaningAction();
+ if (cleaningAction == null) {
+ return;
+ }
+ try {
+ cleaningAction.onClean(true);
+ } catch (Throwable e) {
+ if (e instanceof InterruptedException) {
+ LOGGER.log(Level.WARNING, "Unexpected interrupt while executing onClean", e);
+ } else {
+ // Should not happen if cleaners are well-behaved
+ LOGGER.log(Level.WARNING, "Unexpected exception while executing onClean", e);
+ }
+ }
+ }
+
+ @Override
+ public void clean() throws T {
+ CleaningAction<T> cleaningAction = getCleaningAction();
+ if (cleaningAction == null) {
+ return;
+ }
+
+ // Deregister from native Cleaner to prevent automatic cleanup
+ // This is safe to call even if cleanup already happened
+ Cleaner.Cleanable nativeCleanable = this.nativeCleanable;
+ if (nativeCleanable != null) {
+ // just in case
+ this.nativeCleanable = null;
+ nativeCleanable.clean();
+ }
+
+ // Call performCleanup with leak=false for manual cleanup
+ // We rethrow the exceptions so callers can handle them
+ cleaningAction.onClean(false);
+ }
+ }
+}
diff --git a/pgjdbc/src/main/checkerframework/WindowsSecurityContextImpl.astub b/pgjdbc/src/mainCheckerframework/WindowsSecurityContextImpl.astub
similarity index 100%
rename from pgjdbc/src/main/checkerframework/WindowsSecurityContextImpl.astub
rename to pgjdbc/src/mainCheckerframework/WindowsSecurityContextImpl.astub
diff --git a/pgjdbc/src/test/java/org/postgresql/test/jdbc2/StatementTest.java b/pgjdbc/src/test/java/org/postgresql/test/jdbc2/StatementTest.java
index bfc3f74d..f587970b 100644
--- a/pgjdbc/src/test/java/org/postgresql/test/jdbc2/StatementTest.java
+++ b/pgjdbc/src/test/java/org/postgresql/test/jdbc2/StatementTest.java
@@ -21,6 +21,7 @@ import org.postgresql.jdbc.PgStatement;
import org.postgresql.test.TestUtil;
import org.postgresql.test.util.StrangeProxyServer;
import org.postgresql.util.LazyCleaner;
+import org.postgresql.util.LazyCleanerImpl;
import org.postgresql.util.PSQLState;
import org.postgresql.util.SharedTimer;
@@ -1067,7 +1068,7 @@ class StatementTest {
// Create several cleaners, so they can clean leaks concurrently
List<LazyCleaner> cleaners = new ArrayList<>();
for (int i = 0; i < 16; i++) {
- cleaners.add(new LazyCleaner(Duration.ofSeconds(2), "pgjdbc-test-cleaner-" + i));
+ cleaners.add(new LazyCleanerImpl("pgjdbc-test-cleaner-" + i, Duration.ofSeconds(2)));
}
for (int q = 0; System.nanoTime() < deadline || leaks.get() < 10000; q++) {
diff --git a/pgjdbc/src/test/java/org/postgresql/util/LazyCleanerTest.java b/pgjdbc/src/test/java/org/postgresql/util/LazyCleanerTest.java
index fb97b36b..92b18ea4 100644
--- a/pgjdbc/src/test/java/org/postgresql/util/LazyCleanerTest.java
+++ b/pgjdbc/src/test/java/org/postgresql/util/LazyCleanerTest.java
@@ -24,15 +24,19 @@ package org.postgresql.util;
import static java.time.Duration.ofSeconds;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.postgresql.core.JavaVersion;
+
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
public class LazyCleanerTest {
@Test
@@ -40,7 +44,7 @@ public class LazyCleanerTest {
List<Object> list = new ArrayList<>(Arrays.asList(
new Object(), new Object(), new Object()));
- LazyCleaner t = new LazyCleaner(ofSeconds(5), "Cleaner");
+ LazyCleanerImpl t = new LazyCleanerImpl("Cleaner", ofSeconds(5));
String[] collected = new String[list.size()];
List<LazyCleaner.Cleanable<RuntimeException>> cleaners = new ArrayList<>();
@@ -60,42 +64,32 @@ public class LazyCleanerTest {
)
);
}
- assertEquals(
- list.size(),
- t.getWatchedCount(),
- "All objects are strongly-reachable, so getWatchedCount should reflect it"
- );
- assertTrue(t.isThreadRunning(),
- "cleanup thread should be running, and it should wait for the leaks");
+ if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) {
+ assertTrue(t.isThreadRunning(),
+ "cleanup thread should be running, and it should wait for the leaks");
+ }
cleaners.get(1).clean();
- assertEquals(
- list.size() - 1,
- t.getWatchedCount(),
- "One object has been released properly, so getWatchedCount should reflect it"
- );
-
list.set(0, null);
System.gc();
System.gc();
- Await.until(
- "One object was released, and another one has leaked, so getWatchedCount should reflect it",
- ofSeconds(5),
- () -> t.getWatchedCount() == list.size() - 2
- );
-
list.clear();
System.gc();
System.gc();
- Await.until(
- "The cleanup thread should detect leaks and terminate within 5-10 seconds after GC",
- ofSeconds(10),
- () -> !t.isThreadRunning()
- );
+ if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) {
+ Await.until(
+ "The cleanup thread should detect leaks and terminate within 5-10 seconds after GC",
+ ofSeconds(10),
+ () -> !t.isThreadRunning()
+ );
+ } else {
+ // Allow some room for Java's Cleaner to clean the refs
+ Thread.sleep(1000);
+ }
assertEquals(
Arrays.asList("LEAK", "NO LEAK", "LEAK").toString(),
@@ -105,46 +99,106 @@ public class LazyCleanerTest {
}
@Test
- void getThread() throws InterruptedException {
+ void cleanupCompletesAfterManualClean() throws InterruptedException {
String threadName = UUID.randomUUID().toString();
- LazyCleaner t = new LazyCleaner(ofSeconds(5), threadName);
+ LazyCleanerImpl t = new LazyCleanerImpl(threadName, ofSeconds(5));
+
+ AtomicBoolean cleaned = new AtomicBoolean();
List<Object> list = new ArrayList<>();
list.add(new Object());
- LazyCleaner.Cleanable<IllegalStateException> cleanable =
+
+ LazyCleaner.Cleanable<RuntimeException> cleanable =
t.register(
list.get(0),
- leak -> {
- throw new IllegalStateException("test exception from CleaningAction");
- }
+ leak -> cleaned.set(true)
);
- assertTrue(t.isThreadRunning(),
- "cleanup thread should be running, and it should wait for the leaks");
- Thread thread = getThreadByName(threadName);
- thread.interrupt();
- Await.until(
- "The cleanup thread should ignore the interrupt since there's one object to monitor",
- ofSeconds(10),
- () -> !thread.isInterrupted()
+
+ if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) {
+ assertTrue(t.isThreadRunning(),
+ "cleanup thread should be running when there are objects to monitor");
+ }
+
+ // Manually clean the object
+ cleanable.clean();
+
+ // Verify it was cleaned
+ assertTrue(cleaned.get(), "Object should be cleaned after manual clean");
+
+ // Clear the reference and verify cleanup thread eventually stops
+ list.clear();
+ System.gc();
+ System.gc();
+
+ if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) {
+ Await.until(
+ "Cleanup thread should stop when no objects remain",
+ ofSeconds(10),
+ () -> !t.isThreadRunning()
+ );
+ }
+ }
+
+ @Test
+ void exceptionsDuringCleanupAreHandled() throws InterruptedException {
+ LazyCleanerImpl t = new LazyCleanerImpl("test-cleaner", ofSeconds(5));
+
+ java.util.concurrent.atomic.AtomicInteger cleanupCount = new java.util.concurrent.atomic.AtomicInteger(0);
+ List<Object> list = new ArrayList<>();
+
+ // Register object that throws during cleanup
+ list.add(new Object());
+ t.register(
+ list.get(0),
+ leak -> {
+ cleanupCount.incrementAndGet();
+ throw new IllegalStateException("test exception from CleaningAction");
+ }
);
- assertThrows(
- IllegalStateException.class,
- cleanable::clean,
- "Exception from cleanable.clean() should be rethrown"
+
+ // Register another object that should still be cleaned
+ list.add(new Object());
+ AtomicBoolean secondCleaned = new AtomicBoolean(false);
+ t.register(
+ list.get(1),
+ leak -> secondCleaned.set(true)
);
- thread.interrupt();
+
+ if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) {
+ assertTrue(t.isThreadRunning(),
+ "cleanup thread should be running when there are objects to monitor");
+ }
+
+ // Trigger cleanup
+ list.clear();
+ System.gc();
+ System.gc();
+
Await.until(
- "The cleanup thread should exit shortly after interrupt as there's no leaks to monitor",
- ofSeconds(1),
- () -> !t.isThreadRunning()
+ "Both cleanups should complete despite exception",
+ ofSeconds(10),
+ () -> cleanupCount.get() == 1 && secondCleaned.get()
);
- }
- public static Thread getThreadByName(String threadName) {
- for (Thread t : Thread.getAllStackTraces().keySet()) {
- if (t.getName().equals(threadName)) {
- return t;
- }
+ if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) {
+ Await.until(
+ "Cleanup thread should stop after all objects are cleaned",
+ ofSeconds(10),
+ () -> !t.isThreadRunning()
+ );
}
- throw new IllegalStateException("Cleanup thread " + threadName + " not found");
+ }
+
+ @Test
+ void exceptionOnCleanRethrowsToCaller() {
+ LazyCleanerImpl t = new LazyCleanerImpl("test-cleaner", ofSeconds(5));
+ Object obj = new Object();
+ RuntimeException expectedException = new RuntimeException("test exception");
+
+ LazyCleaner.Cleanable<RuntimeException> cleanable = t.register(obj, leak -> {
+ throw expectedException;
+ });
+
+ RuntimeException thrownException = assertThrows(RuntimeException.class, cleanable::clean);
+ assertSame(expectedException, thrownException, "Expected same exception instance to be thrown");
}
}
[parent: 76e8a6539068]