The root GC cause lies in javax.cache.Caching which holds a static collection of CachingProvider
-s shared between all Spring contexts if tests are run in the same JVM.
Spring contexts created during a test run share the same CachingProvider
and therefore the same CacheManagers
. When any of the contexts sharing a CachingProvider
is closed, all related CacheManagers are closed too thus leaving remaining Spring contexts referring to the closed CachingProvider
in an inconsistent state.
To address the issue, each request for a CacheManager
should return a completely new instance not shared with other contexts.
I wrote a simple CachingProvider
implementation that does just this and relies on existing CachingProviders
. Please find the code below.
The base class:
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.WeakHashMap;
import javax.cache.CacheManager;
import javax.cache.configuration.OptionalFeature;
import javax.cache.spi.CachingProvider;
/**
* The abstract JCache compatible {@link CachingProvider} suitable for test purposes.
*
* <p>When using JCache and {@link org.hibernate.cache.jcache.JCacheRegionFactory}, {@link CachingProvider}-s
* are shared between Spring contexts, which means that {@link CacheManager}-s are shared too. The class responsible
* for storing loaded {@link CachingProvider}-s is {@link javax.cache.Caching}. If any cached Spring context is closed,
* then all related {@link CacheManager}-s are closed as well, but since these {@link CacheManager}-s are shared with
* remaining Spring contexts, we end up with in an inconsistent state.</p>
*
* <p>The solution is to make sure that each time a {@link CacheManager} for a particular config URI is requested, a new
* instance not shared between Spring contexts is created</p>
*
* <p>The simplest approach is to create a new instance of {@link CachingProvider} for each {@link CacheManager} request
* and manage them separately from {@link CachingProvider}-s loaded via {@link javax.cache.Caching}. This approach
* allows reusing existing required {@link CachingProvider}-s and overcome any sharing issues.</p>
*
* <p>Tests relying on caching functionality MUST make sure that for regular caching the properties
* {@code spring.cache.jcache.provider} and {@code spring.cache.jcache.config} are set and for 2nd-level cache
* the properties {@code spring.jpa.properties.hibernate.javax.cache.provider} and
* {@code spring.jpa.properties.hibernate.javax.cache.uri} are set. Please note that classpath URI-s for
* the {@code spring.jpa.properties.hibernate.javax.cache.uri} property are supported by {@code hibernate-jcache} only
* since 5.4.1, therefore with earlier versions this property should be set programmatically, for example via
* {@link System#setProperty(String, String)}.</p>
*
* @see <a href="https://docs.jboss.org/hibernate/orm/5.2/userguide/html_single/Hibernate_User_Guide.html#caching-provider-jcache-cache-manager">Hibernate
* JCache configuration</a>
* @see org.hibernate.cache.jcache.JCacheRegionFactory
* @see CachingProvider
* @see javax.cache.Caching
*/
public abstract class AbstractTestJCacheCachingProvider implements CachingProvider {
/**
* The {@link CachingProvider}-s specific for a configuration {@link URI} for a specific {@link ClassLoader}.
*
* <p>All access MUST be handled in a <i>synchronized</i> manner.</p>
*/
private final Map<ClassLoader, Map<URI, List<CachingProvider>>>
classLoaderToUriToCachingProviders = new WeakHashMap<>();
/**
* {@inheritDoc}
*/
@Override
public CacheManager getCacheManager(URI uri, ClassLoader classLoader, Properties properties) {
Objects.requireNonNull(uri, "The cache manager configuration URI must not be null.");
Objects.requireNonNull(classLoader, "The class loader must not be null");
final CachingProvider cachingProvider = createCachingProvider();
synchronized (classLoaderToUriToCachingProviders) {
classLoaderToUriToCachingProviders
.computeIfAbsent(classLoader, k -> new HashMap<>())
.computeIfAbsent(uri, k -> new ArrayList<>())
.add(cachingProvider);
}
return cachingProvider.getCacheManager(uri, classLoader, properties);
}
/**
* Creates a {@link CachingProvider}.
*
* @return a created {@link CachingProvider}
*/
protected abstract CachingProvider createCachingProvider();
/**
* {@inheritDoc}
*/
@Override
public ClassLoader getDefaultClassLoader() {
return Thread.currentThread().getContextClassLoader();
}
/**
* {@inheritDoc}
*/
@Override
public URI getDefaultURI() {
throw new UnsupportedOperationException("Please specify an explicit cache manager configuration URI.");
}
/**
* {@inheritDoc}
*/
@Override
public Properties getDefaultProperties() {
return new Properties();
}
/**
* {@inheritDoc}
*/
@Override
public CacheManager getCacheManager(URI uri, ClassLoader classLoader) {
return getCacheManager(uri, classLoader, null);
}
/**
* {@inheritDoc}
*/
@Override
public CacheManager getCacheManager() {
throw new UnsupportedOperationException("The cache manager configuration URI must be specified.");
}
/**
* {@inheritDoc}
*/
@Override
public void close() {
synchronized (classLoaderToUriToCachingProviders) {
classLoaderToUriToCachingProviders.keySet().forEach(this::close);
}
}
/**
* {@inheritDoc}
*/
@Override
public void close(ClassLoader classLoader) {
Objects.requireNonNull(classLoader, "The class loader must not be null");
synchronized (classLoaderToUriToCachingProviders) {
// Process all CachingProvider collections regardless of the configuration URI.
classLoaderToUriToCachingProviders
.getOrDefault(classLoader, Collections.emptyMap())
.values().stream().flatMap(Collection::stream)
// Close all CachingProvider resources since we are sure that CachingProvider-s are not shared
// or reused.
.forEach(CachingProvider::close);
classLoaderToUriToCachingProviders.remove(classLoader);
}
}
/**
* {@inheritDoc}
*/
@Override
public void close(URI uri, ClassLoader classLoader) {
Objects.requireNonNull(uri, "The cache manager configuration URI must not be null");
Objects.requireNonNull(classLoader, "The class loader must not be null");
synchronized (classLoaderToUriToCachingProviders) {
final Map<URI, List<CachingProvider>> uriToCachingProviders = classLoaderToUriToCachingProviders
.getOrDefault(classLoader, Collections.emptyMap());
uriToCachingProviders
.getOrDefault(uri, Collections.emptyList())
// Close all CachingProvider resources since we are sure that CachingProvider-s are not shared
// or reused.
.forEach(CachingProvider::close);
uriToCachingProviders.remove(uri);
}
}
/**
* {@inheritDoc}
*/
@Override
public boolean isSupported(OptionalFeature optionalFeature) {
// Find the first available CachingProvider and delegate the request to it.
synchronized (classLoaderToUriToCachingProviders) {
return classLoaderToUriToCachingProviders.values().stream().findFirst()
.flatMap(uriToCachingProviders -> uriToCachingProviders.values().stream().findFirst())
.flatMap(cachingProviders -> cachingProviders.stream().findFirst())
.map(cachingProvider -> cachingProvider.isSupported(optionalFeature))
.orElse(false);
}
}
}
The Ehcache based implementation:
import javax.cache.spi.CachingProvider;
import org.ehcache.jsr107.EhcacheCachingProvider;
/**
* The test {@link CachingProvider} based on {@link EhcacheCachingProvider}.
*/
public class TestEhcacheJCacheCachingProvider extends AbstractTestJCacheCachingProvider {
@Override
protected CachingProvider createCachingProvider() {
return new EhcacheCachingProvider();
}
}