Skip to content

Commit 4274887

Browse files
committed
Stop the buffer pool pruner thread when the pool is empty
PROBLEM The driver starts a thread that removes idle buffers from PowerOfTwoBufferPool.DEFAULT. That thread never stops. A thread that runs forever keeps the class loader of all driver classes in memory. The static data of those classes also stays in memory. Then an application server cannot unload an application, and the memory of that application stays in use. Users report this behavior in GitHub issue 2029 and in JAVA-5643. CAUSE DEFAULT is a static field, and it calls enablePruning() during class initialization. That method schedules a periodic task. The executor starts the worker thread for the first task, but the pool is empty at that time. Therefore the thread has no work, and it continues to wake up forever. The thread keeps the class loader in memory because the JVM captures the class loader when it constructs the thread. The data that the thread holds is not the cause. For this reason, a change to the references of the thread cannot release the class loader. The thread must stop. SOLUTION enablePruning() now sets a flag. It does not schedule a task. The release() method schedules one prune when it puts a buffer into the pool. Each prune schedules the next prune, but only if the pool still holds a buffer. The pruner does not schedule the next prune when the pool becomes empty. The pruner also uses these settings on its executor: - allowCoreThreadTimeOut(true), so that the worker thread can stop - a keep-alive time of maxIdleTime / 2 - setRemoveOnCancelPolicy(true) The work queue becomes empty after the last prune. Then the keep-alive time expires, and the worker thread stops. A later call to release() schedules a new prune, and the executor starts a new thread. Two threads must not schedule a prune at the same time. A prune must also not stop while a different thread adds a buffer to the pool. The AtomicBoolean pruningScheduled prevents both conditions. The prune clears the flag, and then it examines the pool one more time before it stops. DRAWBACKS The pool releases the class loader about 90 seconds after the last buffer release. The default value of maxIdleTime is one minute. A buffer is old enough to remove only after two prunes, and the keep-alive time adds 30 seconds. The class loader stays in memory during that period. A tool that examines threads at the moment of an undeployment can still find a live thread. A pool that becomes idle and then busy starts a new thread. This adds a small cost. A test measures this cost. A busy pool keeps one thread, because new work arrives before the keep-alive time expires. prune() keeps its current behavior after an error. It writes a log message and throws the error again, and the pruner does not start again. This behavior is the same as before this change. PRIOR ART Netty has the same problem and uses the same solution. GlobalEventExecutor is a single-thread singleton. It starts its thread when work arrives, and it stops the thread when the task queue stays empty for a quiet period. The deprecated ThreadDeathWatcher class uses the same pattern. The steps that this change uses to clear and then examine the flag follow GlobalEventExecutor.TaskRunner. Netty also sets the context class loader of a new thread to null. See netty#7290 and JDK-7008595. That change corrects a different problem, which is a driver thread that keeps an application class loader in memory. This commit does not include that change. TESTS New tests in PowerOfTwoBufferPoolTest show three results. An empty pool starts no thread. The thread stops after the pruner empties the pool. The pruner starts again after the thread stops. A separate test harness measures class loader retention. That harness loads the driver into a child class loader, opens a MongoClient, closes it, and then waits for the class loader to become unreachable. Before this change, the class loader stayed in memory. After this change, the JVM collects it. The harness is a local development tool, and it is not part of this commit. JAVA-6279
1 parent d751950 commit 4274887

2 files changed

Lines changed: 186 additions & 8 deletions

File tree

driver-core/src/main/com/mongodb/internal/connection/PowerOfTwoBufferPool.java

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@
2828
import java.util.HashMap;
2929
import java.util.Map;
3030
import java.util.concurrent.ConcurrentLinkedDeque;
31-
import java.util.concurrent.Executors;
32-
import java.util.concurrent.ScheduledExecutorService;
31+
import java.util.concurrent.RejectedExecutionException;
32+
import java.util.concurrent.ScheduledThreadPoolExecutor;
3333
import java.util.concurrent.TimeUnit;
34+
import java.util.concurrent.atomic.AtomicBoolean;
3435

3536
/**
3637
* <p>This class is not part of the public API and may be removed or changed at any time</p>
@@ -40,6 +41,13 @@ public class PowerOfTwoBufferPool implements BufferProvider {
4041

4142
/**
4243
* The global default pool. Pruning is enabled on this pool. Idle buffers are pruned after one minute.
44+
*
45+
* <p>The pruner thread does not run all the time. It starts when the pool holds a buffer. It stops when the pool
46+
* becomes empty.</p>
47+
*
48+
* <p>The pruner thread must stop. A thread that runs forever keeps the class loader of all driver classes in
49+
* memory. The static data of those classes also stays in memory. Then an application server cannot unload the
50+
* application. See <a href="https://jira.mongodb.org/browse/JAVA-6279">JAVA-6279</a>.</p>
4351
*/
4452
public static final PowerOfTwoBufferPool DEFAULT = new PowerOfTwoBufferPool().enablePruning();
4553

@@ -63,7 +71,13 @@ public ByteBuffer getBuffer() {
6371

6472
private final Map<Integer, BufferPool> powerOfTwoToPoolMap = new HashMap<>();
6573
private final long maxIdleTimeNanos;
66-
private final ScheduledExecutorService pruner;
74+
private final ScheduledThreadPoolExecutor pruner;
75+
/**
76+
* True if the pruner has a scheduled prune. Two threads must not schedule a prune at the same time, and this flag
77+
* prevents that. The method {@link #pruneAndRescheduleIfNeeded()} also uses this flag when it stops the pruner.
78+
*/
79+
private final AtomicBoolean pruningScheduled = new AtomicBoolean();
80+
private volatile boolean pruningEnabled;
6781

6882
/**
6983
* Construct an instance with a highest power of two of 24.
@@ -96,21 +110,51 @@ public ByteBuffer getBuffer() {
96110
powerOfTwo = powerOfTwo << 1;
97111
}
98112
maxIdleTimeNanos = timeUnit.toNanos(maxIdleTime);
99-
pruner = Executors.newSingleThreadScheduledExecutor(new DaemonThreadFactory("BufferPoolPruner"));
113+
pruner = new ScheduledThreadPoolExecutor(1, new DaemonThreadFactory("BufferPoolPruner"));
114+
// The worker thread must stop when it has no more work. Then an idle pool holds no thread.
115+
//
116+
// These three settings are sufficient only because this class schedules one prune at a time. It schedules the
117+
// next prune only if the pool is not empty. Then the work queue becomes empty and the keep-alive time expires.
118+
// A periodic task stays in the work queue forever. Then the worker thread always has a task to wait for, and
119+
// the keep-alive time never expires.
120+
//
121+
// The keep-alive time applies only after the last prune. While a prune is in the work queue, the worker thread
122+
// waits for that prune. Because of this, a short keep-alive time does not change the interval between prunes.
123+
// A short keep-alive time also decreases the time that an idle pool keeps our class loader in memory.
124+
pruner.setKeepAliveTime(Math.max(1, maxIdleTimeNanos / 2), TimeUnit.NANOSECONDS);
125+
pruner.allowCoreThreadTimeOut(true);
126+
pruner.setRemoveOnCancelPolicy(true);
100127
}
101128

102129
/**
103-
* Call this method at most once to enable a background thread that prunes idle buffers from the pool
130+
* Call this method one time only. It permits the pool to prune idle buffers.
131+
*
132+
* <p>This method does not start a thread. An empty pool has no buffers to prune. The pruner starts when you
133+
* {@linkplain #release(ByteBuffer) release} a buffer. The pruner stops when the pool becomes empty.</p>
104134
*/
105135
PowerOfTwoBufferPool enablePruning() {
106-
pruner.scheduleAtFixedRate(this::prune, maxIdleTimeNanos, maxIdleTimeNanos / 2, TimeUnit.NANOSECONDS);
136+
pruningEnabled = true;
137+
if (!allPoolsEmpty()) {
138+
// The pool can hold buffers from before this call, and those buffers also need a prune. An empty pool
139+
// must not start a thread.
140+
startPruningIfNeeded();
141+
}
107142
return this;
108143
}
109144

110145
void disablePruning() {
146+
pruningEnabled = false;
111147
pruner.shutdownNow();
112148
}
113149

150+
/**
151+
* @return The number of threads that the pruner uses. This method is package-private because the tests must show
152+
* that no thread runs when the pool has no buffers to prune. JAVA-6279 is about that behavior.
153+
*/
154+
int prunerThreadCount() {
155+
return pruner.getPoolSize();
156+
}
157+
114158
@Override
115159
public ByteBuf getBuffer(final int size) {
116160
return new PooledByteBufNIO(getByteBuffer(size));
@@ -136,7 +180,59 @@ public void release(final ByteBuffer buffer) {
136180
powerOfTwoToPoolMap.get(log2(roundUpToNextHighestPowerOfTwo(buffer.capacity())));
137181
if (pool != null) {
138182
pool.release(new IdleTrackingByteBuffer(buffer));
183+
startPruningIfNeeded();
184+
}
185+
}
186+
187+
private void startPruningIfNeeded() {
188+
if (pruningEnabled && pruningScheduled.compareAndSet(false, true)) {
189+
schedulePrune();
190+
}
191+
}
192+
193+
private void schedulePrune() {
194+
try {
195+
pruner.schedule(this::pruneAndRescheduleIfNeeded, maxIdleTimeNanos / 2, TimeUnit.NANOSECONDS);
196+
} catch (RejectedExecutionException e) {
197+
// Another thread called `disablePruning` and stopped the executor. A release of a buffer must not fail
198+
// because of this.
199+
pruningScheduled.set(false);
200+
}
201+
}
202+
203+
/**
204+
* Prunes the pool. Then schedules the next prune, but only if the pool is not empty.
205+
*
206+
* <p>This method does not cancel a task to stop the pruner. It stops the pruner when it does not schedule the next
207+
* prune. Then the work queue becomes empty and the pruner thread stops.</p>
208+
*
209+
* <p>The steps below prevent a lost pruner. A thread that releases a buffer reads {@link #pruningScheduled}. If
210+
* that flag is true, the thread does not schedule a prune, because it relies on this method to schedule the next
211+
* prune. For this reason, this method clears the flag and then examines the pool one more time. If the pool is not
212+
* empty, this method takes the next prune. If it cannot take the next prune, the other thread has taken it. The
213+
* class {@code io.netty.util.concurrent.GlobalEventExecutor.TaskRunner} uses the same steps.</p>
214+
*/
215+
private void pruneAndRescheduleIfNeeded() {
216+
prune();
217+
if (allPoolsEmpty()) {
218+
pruningScheduled.set(false);
219+
if (allPoolsEmpty()) {
220+
return;
221+
}
222+
if (!pruningScheduled.compareAndSet(false, true)) {
223+
return;
224+
}
225+
}
226+
schedulePrune();
227+
}
228+
229+
private boolean allPoolsEmpty() {
230+
for (BufferPool pool : powerOfTwoToPoolMap.values()) {
231+
if (!pool.isEmpty()) {
232+
return false;
233+
}
139234
}
235+
return true;
140236
}
141237

142238
private void prune() {
@@ -204,5 +300,9 @@ void prune() {
204300
long now = System.nanoTime();
205301
available.removeIf(cur -> now - cur.getLastUsedNanos() >= maxIdleTimeNanos);
206302
}
303+
304+
boolean isEmpty() {
305+
return available.isEmpty();
306+
}
207307
}
208308
}

driver-core/src/test/unit/com/mongodb/internal/connection/PowerOfTwoBufferPoolTest.java

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222

2323
import java.nio.ByteBuffer;
2424
import java.util.concurrent.TimeUnit;
25+
import java.util.function.BooleanSupplier;
2526

2627
import static org.junit.Assert.assertEquals;
2728
import static org.junit.Assert.assertNotSame;
2829
import static org.junit.Assert.assertSame;
30+
import static org.junit.Assert.assertTrue;
2931

3032
public class PowerOfTwoBufferPoolTest {
3133
private PowerOfTwoBufferPool pool;
@@ -75,7 +77,6 @@ public void testHugeBufferRequest() {
7577
assertNotSame(buf, pool.getBuffer((int) Math.pow(2, 10) + 1));
7678
}
7779

78-
// Racy test
7980
@Test
8081
public void testPruning() throws InterruptedException {
8182
PowerOfTwoBufferPool pool = new PowerOfTwoBufferPool(10, 5, TimeUnit.MILLISECONDS)
@@ -84,11 +85,88 @@ public void testPruning() throws InterruptedException {
8485
ByteBuf byteBuf = pool.getBuffer(256);
8586
ByteBuffer wrappedByteBuf = byteBuf.asNIO();
8687
byteBuf.release();
87-
Thread.sleep(50);
88+
// The pruner stops only after it empties the pool. Therefore a thread count of zero shows that the pruner
89+
// removed the buffer. A wait for a fixed period would make this test racy.
90+
assertTrue("the pruner must empty the pool", await(() -> pool.prunerThreadCount() == 0));
8891
ByteBuf newByteBuf = pool.getBuffer(256);
8992
assertNotSame(wrappedByteBuf, newByteBuf.asNIO());
9093
} finally {
9194
pool.disablePruning();
9295
}
9396
}
97+
98+
/**
99+
* The pruner removes idle buffers, and an empty pool has no idle buffers. Therefore {@code enablePruning} must not
100+
* start a thread. A thread that runs keeps the class loader of all driver classes in memory. See JAVA-6279.
101+
*/
102+
@Test
103+
public void testEnablePruningStartsNoThreadWhileThePoolIsEmpty() {
104+
PowerOfTwoBufferPool pool = new PowerOfTwoBufferPool(10, 5, TimeUnit.MILLISECONDS).enablePruning();
105+
try {
106+
assertEquals(0, pool.prunerThreadCount());
107+
} finally {
108+
pool.disablePruning();
109+
}
110+
}
111+
112+
/**
113+
* The pruner empties the pool. Then it has no more work, and the thread must stop. The thread must not continue to
114+
* wake up. This behavior is the correction for JAVA-6279.
115+
*/
116+
@Test
117+
public void testPrunerThreadTerminatesOnceThePoolIsDrained() throws InterruptedException {
118+
PowerOfTwoBufferPool pool = new PowerOfTwoBufferPool(10, 5, TimeUnit.MILLISECONDS).enablePruning();
119+
try {
120+
pool.getBuffer(256).release();
121+
assertTrue("the pruner thread should terminate once the pool is drained",
122+
await(() -> pool.prunerThreadCount() == 0));
123+
} finally {
124+
pool.disablePruning();
125+
}
126+
}
127+
128+
/**
129+
* The pruner must start again. A pool can become idle and then busy. If the pruner does not start again, the pool
130+
* keeps the buffers that you release after the idle period.
131+
*/
132+
@Test
133+
public void testPruningResumesAfterTheThreadHasTerminated() throws InterruptedException {
134+
PowerOfTwoBufferPool pool = new PowerOfTwoBufferPool(10, 5, TimeUnit.MILLISECONDS).enablePruning();
135+
try {
136+
pool.getBuffer(256).release();
137+
assertTrue("precondition: the pruner thread terminates once drained",
138+
await(() -> pool.prunerThreadCount() == 0));
139+
140+
ByteBuf byteBuf = pool.getBuffer(256);
141+
ByteBuffer wrapped = byteBuf.asNIO();
142+
byteBuf.release();
143+
assertTrue("a buffer released after termination should still be pruned",
144+
await(() -> pool.getBuffer(256).asNIO() != wrapped));
145+
} finally {
146+
pool.disablePruning();
147+
}
148+
}
149+
150+
/** A pool without pruning must not start a pruner thread. The number of buffers does not change this behavior. */
151+
@Test
152+
public void testPruningDisabledPoolNeverStartsAThread() {
153+
ByteBuf byteBuf = pool.getBuffer(256);
154+
ByteBuffer wrapped = byteBuf.asNIO();
155+
byteBuf.release();
156+
// This assertion needs no wait. The executor creates its worker thread when it accepts a task, and not when it
157+
// runs that task. Therefore a pool that schedules a prune has a thread before `release` returns.
158+
assertEquals(0, pool.prunerThreadCount());
159+
assertSame("the pool must keep the buffer because it does not prune", wrapped, pool.getBuffer(256).asNIO());
160+
}
161+
162+
private static boolean await(final BooleanSupplier condition) throws InterruptedException {
163+
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
164+
while (System.nanoTime() < deadline) {
165+
if (condition.getAsBoolean()) {
166+
return true;
167+
}
168+
Thread.sleep(5);
169+
}
170+
return condition.getAsBoolean();
171+
}
94172
}

0 commit comments

Comments
 (0)