Skip to content

Commit f9829ac

Browse files
LudoPekuefler
authored andcommitted
Fix memory leak when ThreadLocal are used inside tested code (#79)
Add support for an annotation that does extra garbage collection after each test to try and avoid memory leaks.
1 parent 2c25547 commit f9829ac

File tree

7 files changed

+138
-2
lines changed

7 files changed

+138
-2
lines changed

gwtmockito/src/main/java/com/google/gwtmockito/GwtMockitoTestRunner.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,9 @@ public void testStarted(Description description) throws Exception {
376376
super.run(wrapperNotifier);
377377
} finally {
378378
Thread.currentThread().setContextClassLoader(originalClassLoader);
379+
if (unitTestClass.isAnnotationPresent(WithExperimentalGarbageCollection.class)) {
380+
ThreadLocalCleaner.cleanUpThreadLocalValues(gwtMockitoClassLoader);
381+
}
379382
}
380383
}
381384

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.google.gwtmockito;
2+
3+
import java.lang.ref.WeakReference;
4+
import java.lang.reflect.Field;
5+
6+
/**
7+
* Utility classes to modify the private ThreadLocalMap attribute from the java.lang.Thread
8+
*/
9+
final class ThreadLocalCleaner {
10+
11+
private ThreadLocalCleaner() {
12+
// Nothing
13+
}
14+
15+
/**
16+
* Remove ThreadLocalMap entries of objects loaded through the given classLoader as they prevent garbage collection
17+
* of this classLoader while the thread that holds the ThreadLocalMap is alive
18+
*/
19+
public static void cleanUpThreadLocalValues(ClassLoader classLoader) {
20+
try {
21+
Object threadLocalMap = getPrivateAttribute(Thread.class, "threadLocals", Thread.currentThread());
22+
WeakReference[] table = (WeakReference[]) getPrivateAttribute("table", threadLocalMap);
23+
int length = table.length;
24+
for (int i = 0; i < length; i++) {
25+
WeakReference mapEntry = table[i];
26+
if (mapEntry != null && mapEntry.get() != null) {
27+
ClassLoader mapEntryKeyClassLoader = mapEntry.get().getClass().getClassLoader();
28+
Field mapEntryValueField = getPrivateAttributeAccessibleField(mapEntry.getClass(), "value");
29+
ClassLoader mapEntryValueClassLoader = mapEntryValueField.get(mapEntry).getClass().getClassLoader();
30+
if (mapEntryKeyClassLoader == classLoader || mapEntryValueClassLoader == classLoader) {
31+
mapEntry.clear();
32+
mapEntryValueField.set(mapEntry, null);
33+
// The ThreadLocalMap is able to expunge the remaining stale entries, no need to remove it from the map
34+
}
35+
}
36+
}
37+
} catch (IllegalAccessException | NoSuchFieldException e) {
38+
throw new AssertionError("Unable to access expected class fields for cleaning ThreadLocal values", e);
39+
}
40+
}
41+
42+
private static Object getPrivateAttribute(String attributeName, Object holder) throws NoSuchFieldException, IllegalAccessException {
43+
return getPrivateAttribute(holder.getClass(), attributeName, holder);
44+
}
45+
46+
private static Object getPrivateAttribute(Class attributeClass, String attributeName, Object holder) throws NoSuchFieldException, IllegalAccessException {
47+
Field field = getPrivateAttributeAccessibleField(attributeClass, attributeName);
48+
return field.get(holder);
49+
}
50+
51+
private static Field getPrivateAttributeAccessibleField(Class attributeClass, String attributeName) throws NoSuchFieldException {
52+
Field field = attributeClass.getDeclaredField(attributeName);
53+
field.setAccessible(true);
54+
return field;
55+
}
56+
57+
}

gwtmockito/src/main/java/com/google/gwtmockito/WithClassesToStub.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import java.lang.annotation.Target;
2222

2323
/**
24-
* Annotation allowing the the test to configure the set of classes that will be stubbed completely
24+
* Annotation allowing the test to configure the set of classes that will be stubbed completely
2525
* when the test is executed. If a class is stubbed, all of its non-abstract methods (including
2626
* native and final methods) will be replaced with a no-op implementation that returns a dummy value
2727
* if needed. This stubbing takes place at the classloader level, so all instances of the class
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.google.gwtmockito;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
8+
/**
9+
* Annotation allowing the test to enable a clean up in the ThreadLocalMap at the end of the test
10+
* in order to avoid a memory leak.
11+
*
12+
* <p> Without this annotation, if a test uses a ThreadLocal object load through GwtMockitoClassLoader
13+
* and stored in the test runner thread context, the garbage collection of the GwtMockitoClassLoader
14+
* will be prevented.
15+
*
16+
* <p> Note: the memory leak issue and this experimental garbage collection fix is dependent of the JVM
17+
* (observed with the OpenJDK). It will raise an AssertionError on uncompatible JVM.
18+
*/
19+
@Target(ElementType.TYPE)
20+
@Retention(RetentionPolicy.RUNTIME)
21+
public @interface WithExperimentalGarbageCollection {
22+
23+
}

gwtmockito/src/main/java/com/google/gwtmockito/WithPackagesToLoadViaStandardClassLoader.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import java.lang.annotation.Target;
2222

2323
/**
24-
* Annotation allowing the the test to configure a list of package names that should always be
24+
* Annotation allowing the test to configure a list of package names that should always be
2525
* loaded via the standard system classloader instead of through GwtMockito's custom classloader.
2626
* Any subpackages of these packages will also be loaded with the standard loader. If you're
2727
* getting "loader constraint violation" errors, try defining adding this annotation to your test
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.google.gwtmockito;
2+
3+
import com.google.gwtmockito.subpackage.ThreadLocalUsage;
4+
import org.junit.Test;
5+
import org.junit.runner.RunWith;
6+
import org.junit.runner.notification.RunNotifier;
7+
import org.junit.runners.JUnit4;
8+
import org.junit.runners.model.InitializationError;
9+
10+
import java.lang.ref.WeakReference;
11+
12+
import static junit.framework.TestCase.assertNull;
13+
14+
public class GwtMockitoMemoryLeakTest {
15+
16+
@Test
17+
public void shouldGarbageCollectGwtClassLoaderWhenThreadLocalIsUsed() throws InitializationError {
18+
GwtMockitoTestRunner runner = new GwtMockitoTestRunner(TestWithThreadLocal.class);
19+
runner.run(new RunNotifier());
20+
WeakReference<GwtMockitoTestRunner> wkRunner = new WeakReference<>(runner);
21+
// remove the reference to the runner to allow the garbage collector to collect it.
22+
runner = null;
23+
// Call the Garbage Collector to (try to) force the collection of the runner.
24+
// This test is not perfect and may depend of the JVM Garbage Collector implementation as
25+
// as return from "System.gc()" simply garantee that a "best effort to reclaim space from all discarded objects"
26+
// has been done.
27+
System.gc();
28+
// Check if the garbage colllector has collected the runner instance through the WeakReference
29+
assertNull("Expected to garbage collect the GwtMockitoTestRunner", wkRunner.get());
30+
}
31+
32+
@RunWith(JUnit4.class)
33+
public static class DummyTestClass {
34+
35+
@Test
36+
public void dummy() {
37+
}
38+
}
39+
40+
@RunWith(JUnit4.class)
41+
@WithExperimentalGarbageCollection
42+
public static class TestWithThreadLocal {
43+
@Test
44+
public void dummy() {
45+
ThreadLocalUsage threadLocalUsage = new ThreadLocalUsage();
46+
}
47+
}
48+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.google.gwtmockito.subpackage;
2+
3+
public class ThreadLocalUsage {
4+
private static ThreadLocal<ThreadLocalUsage> MY_DUMMY_LOCAL = ThreadLocal.withInitial(() -> new ThreadLocalUsage());
5+
}

0 commit comments

Comments
 (0)