Subject: [pgjdbc/pgjdbc] PR #3886: fix: avoid memory leaks in Java <= 21 caused by Thread.inheritedAccessControlContext

From: "vlsi (@vlsi)" <[email-masked]>
Date: Wed, 03 Dec 2025 08:14:22 +0000

`Thread.inheritedAccessControlContext` inherits the caller `ProtectionDomain`, thus it effectively holds its classloader.
There's no way to "unset `inheritedAccessControlContext`".

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

---

An alternative option could be going for native `java.lang.ref.Cleaner` when running with Java 9+, however, it would still be nice to have a solution for Java 8.

---

From: "vlsi (@vlsi)" <[email-masked]>
Date: Wed, 03 Dec 2025 12:50:56 +0000

For reference, I tried difftastic by @wilfred, and it produces reasonable diff:
<img width="1678" height="983" alt="diff with difftastic" src="https://github.com/user-attachments/assets/b74d120a-917a-4c1f-9c02-8b2fb4f7e9db" />

---

From: "davecramer (@davecramer)" <[email-masked]>
Date: Thu, 04 Dec 2025 10:44:40 +0000

Using claude it suggested 
```
ForkJoinPool.commonPool().execute(() -> {
    RefQueueBlocker<Object> blocker =
        new RefQueueBlocker<>(queue, threadName, threadTtl, () -> !checkEmpty());

    while (!checkEmpty()) {
        try {
            ref.onClean(true);
            ForkJoinPool.managedBlock(blocker);

            Node<?> ref = (Node<?>) blocker.drainOne();
            if (ref != null) {
                ref.onClean(true);
            }
        } catch (InterruptedException e) {
            if (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) {
            LOGGER.log(Level.WARNING, "Unexpected exception while executing onClean", e);
        }
    }
});
``` 
as a simplification to the try catch logic

Also had this to say

5. Missing edge cases
- What happens if ForkJoinPool.commonPool() is unavailable (custom security manager)?
- No handling for RejectedExecutionException from execute()
- The threadFactory.newThread() can return null but only logs a warning
-

---

From: "davecramer (@davecramer)" <[email-masked]>
Date: Thu, 04 Dec 2025 10:54:53 +0000

What about creating a multi-release jar and have java 9+ code in there as well?  I expect the number of people actually using java 8 is declining.

---

From: "vlsi (@vlsi)" <[email-masked]>
Date: Thu, 04 Dec 2025 11:51:24 +0000

> What happens if ForkJoinPool.commonPool() is unavailable (custom security manager)?

Frankly, I do not think it is a real scenario.

> No handling for RejectedExecutionException from execute()

Frankly, `commonPool()` should not reject unless it shuts down.
I think we should not add workarounds for RejectedExecutionException.

---

From: "davecramer (@davecramer)" <[email-masked]>
Date: Thu, 04 Dec 2025 12:08:41 +0000

> Frankly, `commonPool()` should not reject unless it shuts down. I think we should not add workarounds for RejectedExecutionException.

Fair enough. After giving this more thought I really do think we should consider leaving the code as is and providing a multi-release jar that targets java 9+. I would imagine most folks that are running into the memory leak issue are using Java 9 or greater

---

From: "vlsi (@vlsi)" <[email-masked]>
Date: Mon, 08 Dec 2025 09:25:40 +0000

I realized that with multi-release jar we can have the same class name for Java 8 and Java 11 implementations, and we do not need reflection in the runtime

---

From: "vlsi (@vlsi)" <[email-masked]>
Date: Mon, 08 Dec 2025 14:37:12 +0000

I think the code is good to go now

---

From: "davecramer (@davecramer)" <[email-masked]>
Date: Mon, 15 Dec 2025 12:44:54 +0000

sorry, I was away last week, Great work on this!

---

From: "philipwhiuk (@philipwhiuk)" <[email-masked]>
Date: Tue, 17 Feb 2026 14:22:10 +0000

Switching to multi-release JAR broke my assembly build at runtime on Java 17 because it couldn't find LazyCleanerImpl until I added `<Multi-Release>true</Multi-Release>` to the assembled JAR manifest. Might be worth flagging up a bit more in the release notes (wasn't expecting a breaking change in a bugfix release).

```
Unhandled error occurred: org/postgresql/util/LazyCleanerImpl$CleanableWrapper
java.lang.NoClassDefFoundError: org/postgresql/util/LazyCleanerImpl$CleanableWrapper
        at org.postgresql.util.LazyCleanerImpl.register(LazyCleanerImpl.java:65)
        at org.postgresql.jdbc.PgConnection.<init>(PgConnection.java:403)
        at org.postgresql.Driver.makeConnection(Driver.java:448)
        at org.postgresql.Driver.connect(Driver.java:298)
        at com.zaxxer.hikari.util.DriverDataSource.getConnection(DriverDataSource.java:137)
```

---

From: "vlsi (@vlsi)" <[email-masked]>
Date: Tue, 17 Feb 2026 14:30:24 +0000

@philipwhiuk , could you clarify?
Do you mean you shade pgjdbc within your app?

Do you mean pgjdbc misses multi-release in jar manifest?

I just checked postgresql-42.7.10.jar, and it does seem to have `Multi-Release: true`