Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import java.lang.ref.Cleaner;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import org.apiguardian.api.API;
import org.apiguardian.api.API.Status;
import org.slf4j.Logger;
Expand All @@ -32,6 +33,12 @@
@API(since = "0.0.1", status = Status.INTERNAL)
public final class GarbageDisposal {

// Tuning for JVMs on Windows.
private static final int THREAD_PRIORITY = Thread.MAX_PRIORITY - 1;
private static final int THREAD_STACK_SIZE = 20 * 1_024;

private static final AtomicInteger CLEANER_THREAD_ID = new AtomicInteger(0);
private static final ThreadGroup THREAD_GROUP = new ThreadGroup("JCT GarbageDisposal thread");
private static final Logger LOGGER = LoggerFactory.getLogger(GarbageDisposal.class);
private static final Lazy<Cleaner> CLEANER = new Lazy<>(GarbageDisposal::newCleaner);

Expand Down Expand Up @@ -63,12 +70,23 @@ public static void onPhantom(Object ref, String name, AutoCloseable hook) {

private static Cleaner newCleaner() {
// This thread factory has exactly 1 thread created from it.
return Cleaner.create(runnable -> {
var thread = new Thread(runnable);
thread.setName("JCT GC hook caller");
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
return Cleaner.create(runnable -> newThread("cleaner thread", runnable));
}

private static Thread newThread(String name, Runnable runnable) {
var thread = new Thread(
THREAD_GROUP,
runnable,
"JCT GarbageDisposal - thread #"
+ CLEANER_THREAD_ID.incrementAndGet()
+ " - "
+ name,
THREAD_STACK_SIZE,
false
);
thread.setDaemon(false);
thread.setPriority(THREAD_PRIORITY);
return thread;
}

private GarbageDisposal() {
Expand All @@ -87,26 +105,24 @@ private CloseableDelegate(String name, AutoCloseable closeable) {

@Override
public void run() {
var thread = new Thread(() -> {
try {
LOGGER.debug("Closing {} ({})", name, closeable);
closeable.close();
} catch (Exception ex) {
var thisThread = Thread.currentThread();
LOGGER.error(
"Failed to close {} ({}) on thread {} [{}]",
name,
closeable,
thisThread.getId(),
thisThread.getName(),
ex
);
}
});
newThread("dispose of " + name, this::runSync).start();
}

thread.setName(toString());
thread.setPriority(Thread.MAX_PRIORITY);
thread.start();
private void runSync() {
try {
LOGGER.debug("Closing {} ({})", name, closeable);
closeable.close();
} catch (Exception ex) {
var thisThread = Thread.currentThread();
LOGGER.error(
"Failed to close {} ({}) on thread {} [{}]",
name,
closeable,
thisThread.getId(),
thisThread.getName(),
ex
);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.ascopes.jct.testing.integration.basic;
package io.github.ascopes.jct.testing.integration;

import static io.github.ascopes.jct.assertions.JctAssertions.assertThatCompilation;
import static io.github.ascopes.jct.pathwrappers.RamFileSystem.newRamFileSystem;
Expand All @@ -28,7 +28,7 @@
* @author Ashley Scopes
*/
@DisplayName("Basic legacy compilation integration tests")
class BasicLegacyCompilationTest {
class BasicLegacyCompilationIntegrationTest {

@DisplayName("I can compile a 'Hello, World!' program")
@JavacCompilerTest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.ascopes.jct.testing.integration.basic;
package io.github.ascopes.jct.testing.integration;

import static io.github.ascopes.jct.assertions.JctAssertions.assertThatCompilation;
import static io.github.ascopes.jct.pathwrappers.RamFileSystem.newRamFileSystem;
Expand All @@ -28,7 +28,7 @@
* @author Ashley Scopes
*/
@DisplayName("Basic module compilation integration tests")
class BasicModuleCompilationTest {
class BasicModuleCompilationIntegrationTest {

@DisplayName("I can compile a 'Hello, World!' module program")
@JavacCompilerTest(modules = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.ascopes.jct.testing.integration.basic;
package io.github.ascopes.jct.testing.integration;

import static io.github.ascopes.jct.assertions.JctAssertions.assertThatCompilation;
import static io.github.ascopes.jct.pathwrappers.RamFileSystem.newRamFileSystem;
Expand All @@ -29,7 +29,7 @@
* @author Ashley Scopes
*/
@DisplayName("Basic multi-module compilation integration tests")
class BasicMultiModuleCompilationTest {
class BasicMultiModuleCompilationIntegrationTest {

@DisplayName("I can compile a single module using multi-module layout")
@JavacCompilerTest(modules = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/*
* Copyright (C) 2022 - 2022 Ashley Scopes
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.ascopes.jct.testing.integration.utils;

import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.InstanceOfAssertFactories.iterable;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.core.Is.is;
import static org.junit.jupiter.api.Assumptions.abort;

import io.github.ascopes.jct.testing.helpers.ThreadPool;
import io.github.ascopes.jct.utils.GarbageDisposal;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
import org.awaitility.core.ConditionTimeoutException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepeatedTest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Integration tests for {@link GarbageDisposal}.
*
* @author Ashley Scopes
*/
@DisplayName("GarbageDisposal integration tests")
class GarbageDisposalIntegrationTest {

static final Logger LOGGER = LoggerFactory.getLogger(GarbageDisposalIntegrationTest.class);

ThreadPool threadPool;

@BeforeAll
static void ensureSystemGcHookTriggersGc() {
var initialCollections = ManagementFactory
.getGarbageCollectorMXBeans()
.stream()
.mapToLong(GarbageCollectorMXBean::getCollectionCount)
.sum();

System.gc();

try {
await("ensure System.gc() calls garbage collector immediately")
.pollInterval(10, MILLISECONDS)
.atMost(5, SECONDS)
.until(() -> initialCollections < ManagementFactory
.getGarbageCollectorMXBeans()
.stream()
.mapToLong(GarbageCollectorMXBean::getCollectionCount)
.sum());

} catch (ConditionTimeoutException ex) {
abort(
"Calling System.gc() did not trigger the GC hook in time, this test pack would be flaky"
);
}
}

@BeforeEach
@SuppressWarnings("InfiniteLoopStatement")
void setUp() {
// Set up GC stress threads.
var random = new Random();
threadPool = new ThreadPool(1);
threadPool.execute(() -> {
LOGGER.info("Starting GC stress thread");
// Put stress on the garbage collector to run during the tests many times.
while (true) {
var array = new int[1_000_000];
Arrays.fill(array, random.nextInt());
Arrays.fill(array, array[random.nextInt(array.length)]);
}
});
}

@AfterEach
void stopGcStress() {
LOGGER.info("Stopping GC stress thread");
threadPool.shutdownNow();
threadPool.close();
}

@DisplayName("onPhantom(Object, String, AutoCloseable) cleans up reference on garbage disposal")
@RepeatedTest(3)
void onPhantomForSingleReferenceCleansUpOnGarbageDisposal() {
// Given
var closedCount = new AtomicInteger(0);
AutoCloseable closeable = closedCount::incrementAndGet;

// When
GarbageDisposal.onPhantom(new Object(), "foobar baz bork", closeable);

// Then
await("the closeable object gets closed during garbage collection")
.atMost(20, SECONDS)
.pollInterval(10, MILLISECONDS)
.failFast(System::gc)
.untilAtomic(closedCount, is(greaterThanOrEqualTo(1)));

assertThat(closedCount)
.withFailMessage("Expected exactly one closure to occur")
.hasValue(1);
}

@DisplayName("onPhantom(Object, String, AutoCloseable) cleans up once if an exception is raised")
@RepeatedTest(3)
void onPhantomForSingleReferenceCleansUpOnceIfAnExceptionIsRaised() {
// Given
var closedCount = new AtomicInteger(0);
AutoCloseable closeable = () -> {
closedCount.incrementAndGet();
throw new IllegalStateException("Something is wrong here, I can feel it...");
};

// When
GarbageDisposal.onPhantom(new Object(), "throwing closeable", closeable);

// Then
await("the closeable object gets closed during garbage collection")
.atMost(20, SECONDS)
.pollInterval(10, MILLISECONDS)
.failFast(System::gc)
.untilAtomic(closedCount, is(greaterThanOrEqualTo(1)));

assertThat(closedCount)
.withFailMessage("Expected exactly one closure to occur")
.hasValue(1);
}

@DisplayName("onPhantom(Object, Map) cleans up references on garbage disposal")
@RepeatedTest(3)
void onPhantomForMultipleReferencesCleansUpOnGarbageDisposal() {
// Given
var closeCounts = new AtomicInteger[50];
var mapping = new HashMap<Integer, AutoCloseable>();
for (var i = 0; i < closeCounts.length; ++i) {
closeCounts[i] = new AtomicInteger(0);
mapping.put(i, closeCounts[i]::incrementAndGet);
}

// When
GarbageDisposal.onPhantom(new Object(), mapping);

// Then
await("the closeable objects get closed during garbage collection")
.atMost(40, SECONDS)
.pollInterval(1, MILLISECONDS)
.failFast(System::gc)
.untilAsserted(() -> assertThat(mapping)
.extracting(Map::keySet, iterable(int.class))
.allSatisfy(index -> assertThat(closeCounts[index]).hasValueGreaterThanOrEqualTo(1)));

assertThat(closeCounts)
.withFailMessage("Expected exactly one closure to occur in all closables")
.allSatisfy(count -> assertThat(count).hasValue(1));
}

@DisplayName("onPhantom(Object, Map) cleans up all resources once if an exception is raised")
@RepeatedTest(3)
void onPhantomForSingleReferenceCleansUpAllResourcesOnceIfAnExceptionIsRaised() {

// Given
var closeCounts = new AtomicInteger[50];
var mapping = new HashMap<Integer, AutoCloseable>();
for (var i = 0; i < closeCounts.length; ++i) {
closeCounts[i] = new AtomicInteger(0);

// Last closeable will raise an exception in this scenario.
if (i == closeCounts.length - 1) {
var closeCount = closeCounts[i];
mapping.put(i, () -> {
closeCount.incrementAndGet();
throw new IllegalStateException("Something is wrong here, I can feel it...");
});
} else {
mapping.put(i, closeCounts[i]::incrementAndGet);
}
}

// When
GarbageDisposal.onPhantom(new Object(), mapping);

// Then
await("the closeable objects get closed during garbage collection")
.atMost(20, SECONDS)
.pollInterval(10, MILLISECONDS)
.failFast(System::gc)
.untilAsserted(() -> assertThat(mapping)
.extracting(Map::keySet, iterable(int.class))
.allSatisfy(index -> assertThat(closeCounts[index]).hasValueGreaterThanOrEqualTo(1)));

assertThat(closeCounts)
.withFailMessage("Expected exactly one closure to occur in all closables")
.allSatisfy(count -> assertThat(count).hasValue(1));
}
}
Loading