This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new 2215b6873 WW-5537 Resolve classloader/memory leaks during Tomcat hot
deployment (#1632)
2215b6873 is described below
commit 2215b6873cc25aa6967a8d2bc2db1b407b5142ef
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Mar 29 07:17:57 2026 +0200
WW-5537 Resolve classloader/memory leaks during Tomcat hot deployment
(#1632)
* WW-5537 Add InternalDestroyable and ContextAwareDestroyable interfaces
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 ContainerHolder: ThreadLocal with AtomicLong generation counter
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 FinalizableReferenceQueue: volatile instance, join, classloader
null
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 ScopeInterceptor.clearLocks: add synchronized block
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 CompoundRootAccessor, DefaultFileManager: implement
InternalDestroyable
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 Add InternalDestroyable adapter classes for static cache cleanup
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 Register InternalDestroyable beans in struts-beans.xml
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 JSON plugin: add JSONCacheDestroyable for BeanInfo cache cleanup
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 Dispatcher.cleanup: refactor into focused methods with
InternalDestroyable discovery
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 Rewrite DispatcherCleanupTest for InternalDestroyable discovery
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 Add log4j-web for proper Log4j2 lifecycle in Servlet container
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 Dispatcher.destroyObjectFactory: add early return on null, use
pattern matching
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 Fix @since annotations: 7.1.0 -> 7.2.0
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 Add Container.destroy() to clear internal caches on undeploy
Container now exposes a destroy() method that clears factories, injectors,
constructors, and ThreadLocals. This releases Class<?> keys and JDK
DelegatingClassLoader instances that pin the webapp classloader.
DefaultConfiguration.destroy() calls container.destroy() and
reloadContainer() delegates to destroy() to avoid duplication.
Also fixes JSONCacheDestroyable referencing non-existent DefaultJSONWriter
(renamed to StrutsJSONWriter).
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 Fix Container.destroy(): don't clear factories, don't call from
reloadContainer
factories must remain intact because existing code holds direct
references to the Container after destroyConfiguration() and expects
it to still resolve dependencies (e.g. during configuration reload).
reloadContainer() reverted to clearing packageContexts/loadedFileNames
directly — calling destroy() there nulled the container reference and
cleared state needed during the bootstrap transition.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 Restore destroy() call in reloadContainer()
The test failures were caused by factories.clear() in
Container.destroy(), not by calling destroy() from reloadContainer().
Now that factories.clear() is removed, destroy() is safe to call
here — it clears packageContexts, loadedFileNames, and the container's
reflection caches in one place.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* WW-5537 Fix Sonar issues: thread-safe FinalizableReferenceQueue, empty
method comments
- Replace volatile field with AtomicReference in FinalizableReferenceQueue
for proper thread safety using getAndSet()
- Add comments to empty destroy() implementations in test mocks
- Replace deprecated new URL() with URI.toURL() in DispatcherCleanupTest
- Add comments to empty listener methods in DispatcherCleanupTest
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
apps/showcase/pom.xml | 4 +
.../org/apache/struts2/components/Component.java | 8 +
.../struts2/config/impl/DefaultConfiguration.java | 7 +-
.../ComponentCacheDestroyable.java} | 44 ++---
.../apache/struts2/dispatcher/ContainerHolder.java | 53 +++++-
.../dispatcher/ContextAwareDestroyable.java | 53 ++++++
.../DebugUtilsCacheDestroyable.java} | 44 ++---
.../org/apache/struts2/dispatcher/Dispatcher.java | 105 +++++++++---
.../FinalizableReferenceQueueDestroyable.java} | 44 ++---
.../dispatcher/FreemarkerCacheDestroyable.java | 56 +++++++
.../struts2/dispatcher/InternalDestroyable.java | 45 +++++
.../OgnlCacheDestroyable.java} | 45 ++---
.../struts2/dispatcher/PrepareOperations.java | 1 +
.../ScopeInterceptorCacheDestroyable.java} | 44 ++---
.../java/org/apache/struts2/inject/Container.java | 9 +
.../org/apache/struts2/inject/ContainerImpl.java | 19 +++
.../inject/util/FinalizableReferenceQueue.java | 45 ++++-
.../struts2/interceptor/ScopeInterceptor.java | 9 +
.../org/apache/struts2/mock/MockContainer.java | 5 +
.../ognl/accessor/CompoundRootAccessor.java | 15 +-
.../java/org/apache/struts2/util/DebugUtils.java | 7 +
.../apache/struts2/util/fs/DefaultFileManager.java | 16 +-
core/src/main/resources/struts-beans.xml | 18 ++
.../struts2/dispatcher/ContainerHolderTest.java | 78 +++++++++
.../struts2/dispatcher/DispatcherCleanupTest.java | 185 +++++++++++++++++++++
.../util/fs/DefaultFileManagerFactoryTest.java | 4 +
.../struts2/views/util/DefaultUrlHelperTest.java | 4 +
.../PackageBasedActionConfigBuilderTest.java | 4 +
.../apache/struts2/json/JSONCacheDestroyable.java | 44 ++---
.../org/apache/struts2/json/StrutsJSONWriter.java | 8 +
plugins/json/src/main/resources/struts-plugin.xml | 2 +
.../rest/DefaultContentTypeHandlerManagerTest.java | 5 +
32 files changed, 789 insertions(+), 241 deletions(-)
diff --git a/apps/showcase/pom.xml b/apps/showcase/pom.xml
index e54572c7a..0754eaeac 100644
--- a/apps/showcase/pom.xml
+++ b/apps/showcase/pom.xml
@@ -119,6 +119,10 @@
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.logging.log4j</groupId>
+ <artifactId>log4j-web</artifactId>
+ </dependency>
<dependency>
<groupId>org.sitemesh</groupId>
diff --git a/core/src/main/java/org/apache/struts2/components/Component.java
b/core/src/main/java/org/apache/struts2/components/Component.java
index c9cd1bd67..10e6373cc 100644
--- a/core/src/main/java/org/apache/struts2/components/Component.java
+++ b/core/src/main/java/org/apache/struts2/components/Component.java
@@ -68,6 +68,14 @@ public class Component {
*/
protected static ConcurrentMap<Class<?>, Collection<String>>
standardAttributesMap = new ConcurrentHashMap<>();
+ /**
+ * Clears the standard attributes cache to prevent classloader memory
leaks during hot redeployment.
+ * The cache uses Class keys which pin the webapp classloader.
+ */
+ public static void clearStandardAttributesMap() {
+ standardAttributesMap.clear();
+ }
+
protected boolean devMode = false;
protected boolean escapeHtmlBody = false;
protected ValueStack stack;
diff --git
a/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java
b/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java
index 70c62f775..6c46fd264 100644
---
a/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java
+++
b/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java
@@ -249,6 +249,10 @@ public class DefaultConfiguration implements Configuration
{
public void destroy() {
packageContexts.clear();
loadedFileNames.clear();
+ if (container != null) {
+ container.destroy();
+ container = null;
+ }
}
@Override
@@ -266,8 +270,7 @@ public class DefaultConfiguration implements Configuration {
*/
@Override
public synchronized List<PackageProvider>
reloadContainer(List<ContainerProvider> providers) throws
ConfigurationException {
- packageContexts.clear();
- loadedFileNames.clear();
+ destroy();
List<PackageProvider> packageProviders = new ArrayList<>();
ContainerProperties props = new ContainerProperties();
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
b/core/src/main/java/org/apache/struts2/dispatcher/ComponentCacheDestroyable.java
similarity index 53%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to
core/src/main/java/org/apache/struts2/dispatcher/ComponentCacheDestroyable.java
index 41fab15fe..d581a333e 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++
b/core/src/main/java/org/apache/struts2/dispatcher/ComponentCacheDestroyable.java
@@ -16,44 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.struts2.mock;
+package org.apache.struts2.dispatcher;
-import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
-
-import java.util.Set;
+import org.apache.struts2.components.Component;
/**
- * Mock implementation to be used in unittests
+ * Clears {@link Component}'s static standard attributes cache to prevent
+ * classloader leaks on hot redeploy.
+ *
+ * @since 7.2.0
*/
-public class MockContainer implements Container {
-
- public void inject(Object o) {
-
- }
-
- public <T> T inject(Class<T> implementation) {
- return null;
- }
-
- public <T> T getInstance(Class<T> type, String name) {
- return null;
- }
+public class ComponentCacheDestroyable implements InternalDestroyable {
- public <T> T getInstance(Class<T> type) {
- return null;
+ @Override
+ public void destroy() {
+ Component.clearStandardAttributesMap();
}
-
- public Set<String> getInstanceNames(Class<?> type) {
- return null;
- }
-
- public void setScopeStrategy(Scope.Strategy scopeStrategy) {
-
- }
-
- public void removeScopeStrategy() {
-
- }
-
}
diff --git
a/core/src/main/java/org/apache/struts2/dispatcher/ContainerHolder.java
b/core/src/main/java/org/apache/struts2/dispatcher/ContainerHolder.java
index 5e0b0ceb1..708d897f0 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/ContainerHolder.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/ContainerHolder.java
@@ -20,28 +20,65 @@ package org.apache.struts2.dispatcher;
import org.apache.struts2.inject.Container;
+import java.util.concurrent.atomic.AtomicLong;
+
/**
- * Simple class to hold Container instance per thread to minimise number of
attempts
- * to read configuration and build each time a new configuration.
+ * Per-thread cache for the Container instance, minimising repeated reads from
+ * {@link org.apache.struts2.config.ConfigurationManager}.
* <p>
- * As ContainerHolder operates just per thread (which means per request) there
is no need
- * to check if configuration changed during the same request. If changed
between requests,
- * first call to store Container in ContainerHolder will be with the new
configuration.
+ * WW-5537: Uses a ThreadLocal for per-request isolation with an AtomicLong
generation
+ * counter for cross-thread invalidation during app undeploy. When
+ * {@link #invalidateAll()} is called, all threads see the updated generation
on their
+ * next {@link #get()} and return {@code null}, forcing a fresh read from
+ * ConfigurationManager. This prevents classloader leaks caused by idle pool
threads
+ * retaining stale Container references after hot redeployment.
*/
class ContainerHolder {
- private static final ThreadLocal<Container> instance = new ThreadLocal<>();
+ private static final ThreadLocal<CachedContainer> instance = new
ThreadLocal<>();
+
+ /**
+ * Incremented on each {@link #invalidateAll()} call. Threads compare
their cached
+ * generation against this value to detect staleness.
+ */
+ private static final AtomicLong generation = new AtomicLong();
public static void store(Container newInstance) {
- instance.set(newInstance);
+ instance.set(new CachedContainer(newInstance, generation.get()));
}
public static Container get() {
- return instance.get();
+ CachedContainer cached = instance.get();
+ if (cached == null) {
+ return null;
+ }
+ if (cached.generation() != generation.get()) {
+ instance.remove();
+ return null;
+ }
+ return cached.container();
}
+ /**
+ * Clears the current thread's cached container reference.
+ * Used for per-request cleanup.
+ */
public static void clear() {
instance.remove();
}
+ /**
+ * Invalidates all threads' cached container references by advancing the
generation
+ * counter. Each thread will detect the stale generation on its next
{@link #get()}
+ * call and clear its own ThreadLocal. Also clears the calling thread
immediately.
+ * <p>
+ * Used during application undeploy ({@link Dispatcher#cleanup()}) to
ensure idle
+ * pool threads do not pin the webapp classloader via retained Container
references.
+ */
+ public static void invalidateAll() {
+ generation.incrementAndGet();
+ instance.remove();
+ }
+
+ private record CachedContainer(Container container, long generation) {}
}
diff --git
a/core/src/main/java/org/apache/struts2/dispatcher/ContextAwareDestroyable.java
b/core/src/main/java/org/apache/struts2/dispatcher/ContextAwareDestroyable.java
new file mode 100644
index 000000000..09e27214c
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/dispatcher/ContextAwareDestroyable.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.struts2.dispatcher;
+
+import jakarta.servlet.ServletContext;
+
+/**
+ * Extension of {@link InternalDestroyable} for components that require
+ * {@link ServletContext} during cleanup (e.g. clearing servlet-scoped caches).
+ *
+ * <p>During {@link Dispatcher#cleanup()}, the discovery loop checks each
+ * {@code InternalDestroyable} bean: if it implements this subinterface,
+ * {@link #destroy(ServletContext)} is called instead of {@link
#destroy()}.</p>
+ *
+ * @since 7.2.0
+ * @see InternalDestroyable
+ * @see Dispatcher#cleanup()
+ */
+public interface ContextAwareDestroyable extends InternalDestroyable {
+
+ /**
+ * Releases state that requires access to the {@link ServletContext}.
+ *
+ * @param servletContext the current servlet context, may be {@code null}
+ * if the Dispatcher was created without one
+ */
+ void destroy(ServletContext servletContext);
+
+ /**
+ * Default no-op — {@link Dispatcher} calls
+ * {@link #destroy(ServletContext)} instead when it recognises this type.
+ */
+ @Override
+ default void destroy() {
+ // no-op: context-aware variant is the real entry point
+ }
+}
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
b/core/src/main/java/org/apache/struts2/dispatcher/DebugUtilsCacheDestroyable.java
similarity index 53%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to
core/src/main/java/org/apache/struts2/dispatcher/DebugUtilsCacheDestroyable.java
index 41fab15fe..358e109fe 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++
b/core/src/main/java/org/apache/struts2/dispatcher/DebugUtilsCacheDestroyable.java
@@ -16,44 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.struts2.mock;
+package org.apache.struts2.dispatcher;
-import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
-
-import java.util.Set;
+import org.apache.struts2.util.DebugUtils;
/**
- * Mock implementation to be used in unittests
+ * Clears {@link DebugUtils}'s static logged-keys cache to prevent memory leaks
+ * during hot redeployment.
+ *
+ * @since 7.2.0
*/
-public class MockContainer implements Container {
-
- public void inject(Object o) {
-
- }
-
- public <T> T inject(Class<T> implementation) {
- return null;
- }
-
- public <T> T getInstance(Class<T> type, String name) {
- return null;
- }
+public class DebugUtilsCacheDestroyable implements InternalDestroyable {
- public <T> T getInstance(Class<T> type) {
- return null;
+ @Override
+ public void destroy() {
+ DebugUtils.clearCache();
}
-
- public Set<String> getInstanceNames(Class<?> type) {
- return null;
- }
-
- public void setScopeStrategy(Scope.Strategy scopeStrategy) {
-
- }
-
- public void removeScopeStrategy() {
-
- }
-
}
diff --git a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
index 5cbd66b56..34d6c13c8 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
@@ -441,37 +441,78 @@ public class Dispatcher {
* Releases all instances bound to this dispatcher instance.
*/
public void cleanup() {
- // clean up ObjectFactory
+ destroyObjectFactory();
+
+ // clean up Dispatcher itself for this thread
+ instance.remove();
+ servletContext.setAttribute(StrutsStatics.SERVLET_DISPATCHER, null);
+
+ destroyDispatcherListeners();
+
+ destroyInterceptors();
+
+ destroyInternalBeans();
+
+ // WW-5537: Invalidate all threads' cached Container references to
prevent
+ // classloader leaks from idle pool threads retaining stale references
after undeploy.
+ ContainerHolder.invalidateAll();
+
+ //cleanup action context
+ ActionContext.clear();
+
+ // clean up configuration
+ configurationManager.destroyConfiguration();
+ configurationManager = null;
+ }
+
+ /**
+ * Destroys the {@link ObjectFactory} if it implements {@link
ObjectFactoryDestroyable}.
+ *
+ * @since 7.2.0
+ */
+ protected void destroyObjectFactory() {
if (objectFactory == null) {
LOG.warn("Object Factory is null, something is seriously wrong, no
clean up will be performed");
+ return;
}
- if (objectFactory instanceof ObjectFactoryDestroyable) {
+ if (objectFactory instanceof ObjectFactoryDestroyable ofd) {
try {
- ((ObjectFactoryDestroyable) objectFactory).destroy();
+ ofd.destroy();
} catch (Exception e) {
- // catch any exception that may occur during destroy() and log
it
LOG.error("Exception occurred while destroying ObjectFactory
[{}]", objectFactory.toString(), e);
}
}
+ }
- // clean up Dispatcher itself for this thread
- instance.remove();
- servletContext.setAttribute(StrutsStatics.SERVLET_DISPATCHER, null);
-
- // clean up DispatcherListeners
+ /**
+ * Notifies all registered {@link DispatcherListener}s that this dispatcher
+ * is being destroyed, then clears the listener list.
+ *
+ * @since 7.2.0
+ */
+ protected void destroyDispatcherListeners() {
if (!dispatcherListeners.isEmpty()) {
for (DispatcherListener l : dispatcherListeners) {
l.dispatcherDestroyed(this);
}
+ // WW-5537: Clear the static listener list to release references
that may
+ // pin the webapp classloader after undeploy.
+ dispatcherListeners.clear();
}
+ }
- // clean up all interceptors by calling their destroy() method
+ /**
+ * Destroys all interceptors registered in the current configuration.
+ *
+ * @since 7.2.0
+ */
+ protected void destroyInterceptors() {
Set<Interceptor> interceptors = new HashSet<>();
Collection<PackageConfig> packageConfigs =
configurationManager.getConfiguration().getPackageConfigs().values();
for (PackageConfig packageConfig : packageConfigs) {
for (Object config :
packageConfig.getAllInterceptorConfigs().values()) {
- if (config instanceof InterceptorStackConfig) {
- for (InterceptorMapping interceptorMapping :
((InterceptorStackConfig) config).getInterceptors()) {
+ if (config instanceof InterceptorStackConfig isc) {
+ for (InterceptorMapping interceptorMapping :
isc.getInterceptors()) {
interceptors.add(interceptorMapping.getInterceptor());
}
}
@@ -480,16 +521,38 @@ public class Dispatcher {
for (Interceptor interceptor : interceptors) {
interceptor.destroy();
}
+ }
- // Clear container holder when application is unloaded / server
shutdown
- ContainerHolder.clear();
-
- //cleanup action context
- ActionContext.clear();
-
- // clean up configuration
- configurationManager.destroyConfiguration();
- configurationManager = null;
+ /**
+ * Discovers and invokes all {@link InternalDestroyable} beans registered
+ * in the container, clearing static caches and stopping daemon threads
+ * to prevent classloader leaks during hot redeployment (WW-5537).
+ *
+ * <p>Beans implementing {@link ContextAwareDestroyable} receive the
+ * {@link jakarta.servlet.ServletContext} via
+ * {@link
ContextAwareDestroyable#destroy(jakarta.servlet.ServletContext)}.</p>
+ *
+ * @since 7.2.0
+ */
+ protected void destroyInternalBeans() {
+ if (configurationManager != null &&
configurationManager.getConfiguration() != null) {
+ Container container =
configurationManager.getConfiguration().getContainer();
+ Set<String> destroyableNames =
container.getInstanceNames(InternalDestroyable.class);
+ for (String name : destroyableNames) {
+ try {
+ InternalDestroyable destroyable =
container.getInstance(InternalDestroyable.class, name);
+ if (destroyable instanceof ContextAwareDestroyable cad) {
+ cad.destroy(servletContext);
+ } else {
+ destroyable.destroy();
+ }
+ } catch (Exception e) {
+ LOG.warn("Error during internal cleanup [{}]", name, e);
+ }
+ }
+ } else {
+ LOG.warn("ConfigurationManager is null during cleanup,
InternalDestroyable beans will not be invoked");
+ }
}
private void init_FileManager() throws ClassNotFoundException {
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
b/core/src/main/java/org/apache/struts2/dispatcher/FinalizableReferenceQueueDestroyable.java
similarity index 53%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to
core/src/main/java/org/apache/struts2/dispatcher/FinalizableReferenceQueueDestroyable.java
index 41fab15fe..cac8114bc 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++
b/core/src/main/java/org/apache/struts2/dispatcher/FinalizableReferenceQueueDestroyable.java
@@ -16,44 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.struts2.mock;
+package org.apache.struts2.dispatcher;
-import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
-
-import java.util.Set;
+import org.apache.struts2.inject.util.FinalizableReferenceQueue;
/**
- * Mock implementation to be used in unittests
+ * Adapter that exposes {@link FinalizableReferenceQueue#stopAndClear()} as an
+ * {@link InternalDestroyable} bean.
+ *
+ * @since 7.2.0
*/
-public class MockContainer implements Container {
-
- public void inject(Object o) {
-
- }
-
- public <T> T inject(Class<T> implementation) {
- return null;
- }
-
- public <T> T getInstance(Class<T> type, String name) {
- return null;
- }
+public class FinalizableReferenceQueueDestroyable implements
InternalDestroyable {
- public <T> T getInstance(Class<T> type) {
- return null;
+ @Override
+ public void destroy() {
+ FinalizableReferenceQueue.stopAndClear();
}
-
- public Set<String> getInstanceNames(Class<?> type) {
- return null;
- }
-
- public void setScopeStrategy(Scope.Strategy scopeStrategy) {
-
- }
-
- public void removeScopeStrategy() {
-
- }
-
}
diff --git
a/core/src/main/java/org/apache/struts2/dispatcher/FreemarkerCacheDestroyable.java
b/core/src/main/java/org/apache/struts2/dispatcher/FreemarkerCacheDestroyable.java
new file mode 100644
index 000000000..16899cdb2
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/dispatcher/FreemarkerCacheDestroyable.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.struts2.dispatcher;
+
+import freemarker.ext.beans.BeansWrapper;
+import freemarker.template.Configuration;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.views.freemarker.FreemarkerManager;
+
+import jakarta.servlet.ServletContext;
+
+/**
+ * WW-5537: Clears FreeMarker's template and class introspection caches
+ * stored in {@link ServletContext} during application undeploy, preventing
+ * classloader leaks.
+ *
+ * @since 7.2.0
+ */
+public class FreemarkerCacheDestroyable implements ContextAwareDestroyable {
+
+ private static final Logger LOG =
LogManager.getLogger(FreemarkerCacheDestroyable.class);
+
+ @Override
+ public void destroy(ServletContext servletContext) {
+ if (servletContext == null) {
+ return;
+ }
+ Object fmConfig =
servletContext.getAttribute(FreemarkerManager.CONFIG_SERVLET_CONTEXT_KEY);
+ if (fmConfig instanceof Configuration cfg) {
+ cfg.clearTemplateCache();
+ cfg.clearEncodingMap();
+ if (cfg.getObjectWrapper() instanceof BeansWrapper bw) {
+ bw.clearClassIntrospectionCache();
+ }
+
servletContext.removeAttribute(FreemarkerManager.CONFIG_SERVLET_CONTEXT_KEY);
+ LOG.debug("FreeMarker configuration cleaned up");
+ }
+ }
+}
diff --git
a/core/src/main/java/org/apache/struts2/dispatcher/InternalDestroyable.java
b/core/src/main/java/org/apache/struts2/dispatcher/InternalDestroyable.java
new file mode 100644
index 000000000..2207d4d58
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/dispatcher/InternalDestroyable.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.struts2.dispatcher;
+
+/**
+ * Internal framework interface for components that hold static state
+ * (caches, daemon threads, etc.) requiring cleanup during application
+ * undeploy to prevent classloader leaks.
+ *
+ * <p>Implementations are registered as named beans in {@code
struts-beans.xml}
+ * (or plugin descriptors) with type {@code InternalDestroyable}. During
+ * {@link Dispatcher#cleanup()}, all registered implementations are discovered
+ * via {@code Container.getInstanceNames(InternalDestroyable.class)} and
+ * invoked automatically.</p>
+ *
+ * <p>This is not part of the public user API. For user/plugin lifecycle
+ * callbacks, use {@link DispatcherListener} instead.</p>
+ *
+ * @since 7.2.0
+ * @see Dispatcher#cleanup()
+ */
+public interface InternalDestroyable {
+
+ /**
+ * Releases static state held by this component. Called once during
+ * {@link Dispatcher#cleanup()}.
+ */
+ void destroy();
+}
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
b/core/src/main/java/org/apache/struts2/dispatcher/OgnlCacheDestroyable.java
similarity index 53%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to
core/src/main/java/org/apache/struts2/dispatcher/OgnlCacheDestroyable.java
index 41fab15fe..f19afdcdb 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/OgnlCacheDestroyable.java
@@ -16,44 +16,23 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.struts2.mock;
+package org.apache.struts2.dispatcher;
-import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
+import org.apache.struts2.ognl.OgnlUtil;
-import java.util.Set;
+import java.beans.Introspector;
/**
- * Mock implementation to be used in unittests
+ * Clears OGNL runtime caches and JDK introspection caches that hold
+ * {@code Class<?>} references, preventing classloader leaks on hot redeploy.
+ *
+ * @since 7.2.0
*/
-public class MockContainer implements Container {
-
- public void inject(Object o) {
+public class OgnlCacheDestroyable implements InternalDestroyable {
+ @Override
+ public void destroy() {
+ OgnlUtil.clearRuntimeCache();
+ Introspector.flushCaches();
}
-
- public <T> T inject(Class<T> implementation) {
- return null;
- }
-
- public <T> T getInstance(Class<T> type, String name) {
- return null;
- }
-
- public <T> T getInstance(Class<T> type) {
- return null;
- }
-
- public Set<String> getInstanceNames(Class<?> type) {
- return null;
- }
-
- public void setScopeStrategy(Scope.Strategy scopeStrategy) {
-
- }
-
- public void removeScopeStrategy() {
-
- }
-
}
diff --git
a/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java
b/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java
index 09df2c15f..eb37b4812 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java
@@ -77,6 +77,7 @@ public class PrepareOperations {
} finally {
ActionContext.clear();
Dispatcher.clearInstance();
+ ContainerHolder.clear();
devModeOverride.remove();
}
});
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
b/core/src/main/java/org/apache/struts2/dispatcher/ScopeInterceptorCacheDestroyable.java
similarity index 53%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to
core/src/main/java/org/apache/struts2/dispatcher/ScopeInterceptorCacheDestroyable.java
index 41fab15fe..26005b58d 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++
b/core/src/main/java/org/apache/struts2/dispatcher/ScopeInterceptorCacheDestroyable.java
@@ -16,44 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.struts2.mock;
+package org.apache.struts2.dispatcher;
-import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
-
-import java.util.Set;
+import org.apache.struts2.interceptor.ScopeInterceptor;
/**
- * Mock implementation to be used in unittests
+ * Clears {@link ScopeInterceptor}'s static locks map to prevent classloader
+ * leaks on hot redeploy.
+ *
+ * @since 7.2.0
*/
-public class MockContainer implements Container {
-
- public void inject(Object o) {
-
- }
-
- public <T> T inject(Class<T> implementation) {
- return null;
- }
-
- public <T> T getInstance(Class<T> type, String name) {
- return null;
- }
+public class ScopeInterceptorCacheDestroyable implements InternalDestroyable {
- public <T> T getInstance(Class<T> type) {
- return null;
+ @Override
+ public void destroy() {
+ ScopeInterceptor.clearLocks();
}
-
- public Set<String> getInstanceNames(Class<?> type) {
- return null;
- }
-
- public void setScopeStrategy(Scope.Strategy scopeStrategy) {
-
- }
-
- public void removeScopeStrategy() {
-
- }
-
}
diff --git a/core/src/main/java/org/apache/struts2/inject/Container.java
b/core/src/main/java/org/apache/struts2/inject/Container.java
index cc20cf17b..38513777d 100644
--- a/core/src/main/java/org/apache/struts2/inject/Container.java
+++ b/core/src/main/java/org/apache/struts2/inject/Container.java
@@ -130,4 +130,13 @@ public interface Container extends Serializable {
* Removes the scope strategy for the current thread.
*/
void removeScopeStrategy();
+
+ /**
+ * Releases all internal resources held by this container, including caches,
+ * factory maps, and thread-local state. This allows the webapp classloader
+ * to be garbage collected after hot redeployment.
+ *
+ * @since 7.2.0
+ */
+ void destroy();
}
diff --git a/core/src/main/java/org/apache/struts2/inject/ContainerImpl.java
b/core/src/main/java/org/apache/struts2/inject/ContainerImpl.java
index a690b3c38..fc53bd0e6 100644
--- a/core/src/main/java/org/apache/struts2/inject/ContainerImpl.java
+++ b/core/src/main/java/org/apache/struts2/inject/ContainerImpl.java
@@ -651,6 +651,25 @@ class ContainerImpl implements Container {
void inject(InternalContext context, Object o);
}
+ /**
+ * Clears all internal caches, factory maps, and ThreadLocals to release
+ * Class references that would otherwise pin the webapp classloader after
undeploy.
+ * <p>
+ * The {@code injectors} and {@code constructors} ReferenceCache maps hold
+ * {@code Class<?>} keys and reflection accessor objects ({@code Method},
+ * {@code Constructor}) whose JDK-generated {@code DelegatingClassLoader}
+ * instances retain the webapp classloader as their parent.
+ *
+ * @since 7.2.0
+ */
+ @Override
+ public void destroy() {
+ injectors.clear();
+ constructors.clear();
+ localContext.remove();
+ localScopeStrategy.remove();
+ }
+
static class MissingDependencyException extends Exception {
MissingDependencyException(String message) {
super(message);
diff --git
a/core/src/main/java/org/apache/struts2/inject/util/FinalizableReferenceQueue.java
b/core/src/main/java/org/apache/struts2/inject/util/FinalizableReferenceQueue.java
index 6556000f7..07b7ececa 100644
---
a/core/src/main/java/org/apache/struts2/inject/util/FinalizableReferenceQueue.java
+++
b/core/src/main/java/org/apache/struts2/inject/util/FinalizableReferenceQueue.java
@@ -18,6 +18,7 @@ package org.apache.struts2.inject.util;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -26,11 +27,13 @@ import java.util.logging.Logger;
*
* @author Bob Lee ([email protected])
*/
-class FinalizableReferenceQueue extends ReferenceQueue<Object> {
+public class FinalizableReferenceQueue extends ReferenceQueue<Object> {
private static final Logger logger =
Logger.getLogger(FinalizableReferenceQueue.class.getName());
+ private final AtomicReference<Thread> cleanupThread = new
AtomicReference<>();
+
private FinalizableReferenceQueue() {}
void cleanUp(Reference reference) {
@@ -49,18 +52,39 @@ class FinalizableReferenceQueue extends
ReferenceQueue<Object> {
Thread thread = new Thread("FinalizableReferenceQueue") {
@Override
public void run() {
- while (true) {
+ while (!Thread.currentThread().isInterrupted()) {
try {
cleanUp(remove());
- } catch (InterruptedException e) { /* ignore */ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
}
}
};
thread.setDaemon(true);
thread.start();
+ cleanupThread.set(thread);
+ }
+
+ /**
+ * Stops the background cleanup thread to prevent classloader memory leaks
during hot redeployment.
+ */
+ void stop() {
+ Thread t = cleanupThread.getAndSet(null);
+ if (t != null) {
+ t.interrupt();
+ try {
+ t.join(5000);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ t.setContextClassLoader(null);
+ }
}
- static ReferenceQueue<Object> instance = createAndStart();
+ private static final AtomicReference<ReferenceQueue<Object>> instance =
+ new AtomicReference<>(createAndStart());
static FinalizableReferenceQueue createAndStart() {
FinalizableReferenceQueue queue = new FinalizableReferenceQueue();
@@ -72,6 +96,17 @@ class FinalizableReferenceQueue extends
ReferenceQueue<Object> {
* Gets instance.
*/
public static ReferenceQueue<Object> getInstance() {
- return instance;
+ return instance.get();
+ }
+
+ /**
+ * Stops the cleanup thread and clears the instance to prevent classloader
+ * memory leaks during hot redeployment.
+ */
+ public static void stopAndClear() {
+ ReferenceQueue<Object> q = instance.getAndSet(null);
+ if (q instanceof FinalizableReferenceQueue frq) {
+ frq.stop();
+ }
}
}
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/ScopeInterceptor.java
b/core/src/main/java/org/apache/struts2/interceptor/ScopeInterceptor.java
index b899b7f84..c654316c2 100644
--- a/core/src/main/java/org/apache/struts2/interceptor/ScopeInterceptor.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/ScopeInterceptor.java
@@ -263,6 +263,15 @@ public class ScopeInterceptor extends AbstractInterceptor
implements PreResultLi
}
}
+ /**
+ * Clears the locks map to prevent memory leaks during hot redeployment.
+ */
+ public static void clearLocks() {
+ synchronized (locks) {
+ locks.clear();
+ }
+ }
+
protected void after(ActionInvocation invocation, String result) throws
Exception {
Map<String, Object> session = ActionContext.getContext().getSession();
if ( session != null) {
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
b/core/src/main/java/org/apache/struts2/mock/MockContainer.java
index 41fab15fe..a03ec2d67 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++ b/core/src/main/java/org/apache/struts2/mock/MockContainer.java
@@ -56,4 +56,9 @@ public class MockContainer implements Container {
}
+ @Override
+ public void destroy() {
+ // no-op in mock
+ }
+
}
diff --git
a/core/src/main/java/org/apache/struts2/ognl/accessor/CompoundRootAccessor.java
b/core/src/main/java/org/apache/struts2/ognl/accessor/CompoundRootAccessor.java
index d4cc814dd..801be939b 100644
---
a/core/src/main/java/org/apache/struts2/ognl/accessor/CompoundRootAccessor.java
+++
b/core/src/main/java/org/apache/struts2/ognl/accessor/CompoundRootAccessor.java
@@ -18,6 +18,7 @@
*/
package org.apache.struts2.ognl.accessor;
+import org.apache.struts2.dispatcher.InternalDestroyable;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.ognl.OgnlValueStack;
import org.apache.struts2.util.CompoundRoot;
@@ -57,7 +58,7 @@ import static org.apache.commons.lang3.BooleanUtils.toBoolean;
* @author Rainer Hermanns
* @version $Revision$
*/
-public class CompoundRootAccessor implements RootAccessor {
+public class CompoundRootAccessor implements RootAccessor, InternalDestroyable
{
/**
* Used by OGNl to generate bytecode
@@ -322,6 +323,18 @@ public class CompoundRootAccessor implements RootAccessor {
}
+ public static void clearCache() {
+ invalidMethods.clear();
+ }
+
+ /**
+ * @since 7.2.0
+ */
+ @Override
+ public void destroy() {
+ clearCache();
+ }
+
static class MethodCall {
Class clazz;
String name;
diff --git a/core/src/main/java/org/apache/struts2/util/DebugUtils.java
b/core/src/main/java/org/apache/struts2/util/DebugUtils.java
index cc8ca5f5f..6938bb948 100644
--- a/core/src/main/java/org/apache/struts2/util/DebugUtils.java
+++ b/core/src/main/java/org/apache/struts2/util/DebugUtils.java
@@ -32,6 +32,13 @@ public final class DebugUtils {
private static final Set<String> IS_LOGGED = ConcurrentHashMap.newKeySet();
+ /**
+ * Clears the logged-keys cache to prevent memory leaks during hot
redeployment.
+ */
+ public static void clearCache() {
+ IS_LOGGED.clear();
+ }
+
public static void notifyDeveloperOfError(Logger log, Object action,
String message) {
if (action instanceof TextProvider tp) {
message = tp.getText("devmode.notification", "Developer
Notification:\n{0}", new String[]{message});
diff --git
a/core/src/main/java/org/apache/struts2/util/fs/DefaultFileManager.java
b/core/src/main/java/org/apache/struts2/util/fs/DefaultFileManager.java
index 82bf06348..764cb386a 100644
--- a/core/src/main/java/org/apache/struts2/util/fs/DefaultFileManager.java
+++ b/core/src/main/java/org/apache/struts2/util/fs/DefaultFileManager.java
@@ -19,6 +19,7 @@
package org.apache.struts2.util.fs;
import org.apache.struts2.FileManager;
+import org.apache.struts2.dispatcher.InternalDestroyable;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -40,7 +41,7 @@ import static java.util.Objects.requireNonNullElseGet;
/**
* Default implementation of {@link FileManager}
*/
-public class DefaultFileManager implements FileManager {
+public class DefaultFileManager implements FileManager, InternalDestroyable {
private static final Logger LOG =
LogManager.getLogger(DefaultFileManager.class);
@@ -56,6 +57,19 @@ public class DefaultFileManager implements FileManager {
public DefaultFileManager() {
}
+ public static void clearCache() {
+ files.clear();
+ lazyMonitoredFilesCache.clear();
+ }
+
+ /**
+ * @since 7.2.0
+ */
+ @Override
+ public void destroy() {
+ clearCache();
+ }
+
public void setReloadingConfigs(boolean reloadingConfigs) {
if (reloadingConfigs && !this.reloadingConfigs) {
//starting monitoring cached not-monitored files (lazy monitoring
on demand because of performance)
diff --git a/core/src/main/resources/struts-beans.xml
b/core/src/main/resources/struts-beans.xml
index 7c59a88da..614178691 100644
--- a/core/src/main/resources/struts-beans.xml
+++ b/core/src/main/resources/struts-beans.xml
@@ -260,4 +260,22 @@
<bean type="org.apache.struts2.interceptor.csp.CspNonceReader"
name="struts"
class="org.apache.struts2.interceptor.csp.StrutsCspNonceReader"/>
+ <!-- WW-5537: InternalDestroyable beans for automatic cleanup during
undeploy -->
+ <bean type="org.apache.struts2.dispatcher.InternalDestroyable"
name="componentCache"
+ class="org.apache.struts2.dispatcher.ComponentCacheDestroyable"/>
+ <bean type="org.apache.struts2.dispatcher.InternalDestroyable"
name="compoundRootAccessor"
+ class="org.apache.struts2.ognl.accessor.CompoundRootAccessor"/>
+ <bean type="org.apache.struts2.dispatcher.InternalDestroyable"
name="defaultFileManager"
+ class="org.apache.struts2.util.fs.DefaultFileManager"/>
+ <bean type="org.apache.struts2.dispatcher.InternalDestroyable"
name="scopeInterceptorCache"
+
class="org.apache.struts2.dispatcher.ScopeInterceptorCacheDestroyable"/>
+ <bean type="org.apache.struts2.dispatcher.InternalDestroyable"
name="ognlCache"
+ class="org.apache.struts2.dispatcher.OgnlCacheDestroyable"/>
+ <bean type="org.apache.struts2.dispatcher.InternalDestroyable"
name="finalizableReferenceQueue"
+
class="org.apache.struts2.dispatcher.FinalizableReferenceQueueDestroyable"/>
+ <bean type="org.apache.struts2.dispatcher.InternalDestroyable"
name="freemarkerCache"
+ class="org.apache.struts2.dispatcher.FreemarkerCacheDestroyable"/>
+ <bean type="org.apache.struts2.dispatcher.InternalDestroyable"
name="debugUtilsCache"
+ class="org.apache.struts2.dispatcher.DebugUtilsCacheDestroyable"/>
+
</struts>
diff --git
a/core/src/test/java/org/apache/struts2/dispatcher/ContainerHolderTest.java
b/core/src/test/java/org/apache/struts2/dispatcher/ContainerHolderTest.java
new file mode 100644
index 000000000..a11717dbc
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/dispatcher/ContainerHolderTest.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.struts2.dispatcher;
+
+import org.apache.struts2.inject.Container;
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+public class ContainerHolderTest {
+
+ @After
+ public void tearDown() {
+ ContainerHolder.clear();
+ }
+
+ @Test
+ public void storeAndGet() {
+ Container c = mock(Container.class);
+ ContainerHolder.store(c);
+ assertThat(ContainerHolder.get()).isSameAs(c);
+ }
+
+ @Test
+ public void clearRemovesCurrentThread() {
+ ContainerHolder.store(mock(Container.class));
+ ContainerHolder.clear();
+ assertThat(ContainerHolder.get()).isNull();
+ }
+
+ @Test
+ public void invalidateAllMakesOtherThreadsSeeNull() throws Exception {
+ Container c = mock(Container.class);
+
+ // Another thread stores a container
+ Thread t = new Thread(() -> ContainerHolder.store(c));
+ t.start();
+ t.join();
+
+ // Invalidate on main thread
+ ContainerHolder.invalidateAll();
+
+ // Other thread's cached value should now be stale
+ AtomicReference<Container> otherThreadResult = new AtomicReference<>();
+ Thread t2 = new Thread(() ->
otherThreadResult.set(ContainerHolder.get()));
+ t2.start();
+ t2.join();
+
+ assertThat(otherThreadResult.get()).isNull();
+ }
+
+ @Test
+ public void invalidateAllClearsCallingThread() {
+ ContainerHolder.store(mock(Container.class));
+ ContainerHolder.invalidateAll();
+ assertThat(ContainerHolder.get()).isNull();
+ }
+}
diff --git
a/core/src/test/java/org/apache/struts2/dispatcher/DispatcherCleanupTest.java
b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherCleanupTest.java
new file mode 100644
index 000000000..d55cb4438
--- /dev/null
+++
b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherCleanupTest.java
@@ -0,0 +1,185 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.struts2.dispatcher;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.StrutsJUnit4InternalTestCase;
+import org.apache.struts2.components.Component;
+import org.apache.struts2.inject.Container;
+import org.apache.struts2.ognl.accessor.CompoundRootAccessor;
+import org.apache.struts2.util.DebugUtils;
+import org.apache.struts2.util.fs.DefaultFileManager;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.net.URI;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentMap;
+
+import static java.util.Collections.emptyMap;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * WW-5537: Verifies that Dispatcher.cleanup() properly clears all static state
+ * that could prevent classloader garbage collection during hot redeployment.
+ */
+public class DispatcherCleanupTest extends StrutsJUnit4InternalTestCase {
+
+ @Test
+ public void cleanupDiscoversAllInternalDestroyableBeans() {
+ initDispatcher(emptyMap());
+
+ Container container =
dispatcher.getConfigurationManager().getConfiguration().getContainer();
+ Set<String> names =
container.getInstanceNames(InternalDestroyable.class);
+
+ Set<String> expected = new HashSet<>(Arrays.asList(
+ "componentCache", "compoundRootAccessor", "defaultFileManager",
+ "scopeInterceptorCache", "ognlCache",
"finalizableReferenceQueue",
+ "freemarkerCache", "debugUtilsCache"
+ ));
+ assertThat(names).containsAll(expected);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void cleanupClearsComponentStandardAttributesMap() throws Exception
{
+ initDispatcher(emptyMap());
+
+ Field mapField =
Component.class.getDeclaredField("standardAttributesMap");
+ mapField.setAccessible(true);
+ ConcurrentMap<Class<?>, Collection<String>> map =
+ (ConcurrentMap<Class<?>, Collection<String>>)
mapField.get(null);
+
+ map.put(String.class, new ArrayList<>());
+ assertThat(map).isNotEmpty();
+
+ dispatcher.cleanup();
+
+ assertThat(map).isEmpty();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void cleanupClearsCompoundRootAccessorCache() throws Exception {
+ initDispatcher(emptyMap());
+
+ Field field =
CompoundRootAccessor.class.getDeclaredField("invalidMethods");
+ field.setAccessible(true);
+ Map<Object, Boolean> invalidMethods = (Map<Object, Boolean>)
field.get(null);
+
+ invalidMethods.put("testKey", Boolean.TRUE);
+ assertThat(invalidMethods).isNotEmpty();
+
+ dispatcher.cleanup();
+
+ assertThat(invalidMethods).isEmpty();
+ }
+
+ @Test
+ public void cleanupClearsDefaultFileManagerFilesMap() throws Exception {
+ initDispatcher(emptyMap());
+
+ Field filesField = DefaultFileManager.class.getDeclaredField("files");
+ filesField.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ Map<String, Object> files = (Map<String, Object>) filesField.get(null);
+
+ files.put("test-key", new Object());
+ assertThat(files).isNotEmpty();
+
+ dispatcher.cleanup();
+
+ assertThat(files).isEmpty();
+ }
+
+ @Test
+ public void cleanupClearsDefaultFileManagerLazyCache() throws Exception {
+ initDispatcher(emptyMap());
+
+ Field lazyCacheField =
DefaultFileManager.class.getDeclaredField("lazyMonitoredFilesCache");
+ lazyCacheField.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ List<URL> lazyCache = (List<URL>) lazyCacheField.get(null);
+
+ lazyCache.add(new URI("file:///test").toURL());
+ assertThat(lazyCache).isNotEmpty();
+
+ dispatcher.cleanup();
+
+ assertThat(lazyCache).isEmpty();
+ }
+
+ @Test
+ public void cleanupClearsDispatcherListeners() throws Exception {
+ initDispatcher(emptyMap());
+
+ Dispatcher.addDispatcherListener(new DispatcherListener() {
+ @Override
+ public void dispatcherInitialized(Dispatcher du) {
+ // intentionally empty — test only verifies listener list is
cleared
+ }
+ @Override
+ public void dispatcherDestroyed(Dispatcher du) {
+ // intentionally empty — test only verifies listener list is
cleared
+ }
+ });
+
+ dispatcher.cleanup();
+
+ Field listenersField =
Dispatcher.class.getDeclaredField("dispatcherListeners");
+ listenersField.setAccessible(true);
+ List<?> listeners = (List<?>) listenersField.get(null);
+ assertThat(listeners).isEmpty();
+ }
+
+ @Test
+ public void cleanupClearsThreadLocals() {
+ assertThat(Dispatcher.getInstance()).isNotNull();
+ assertThat(ActionContext.getContext()).isNotNull();
+
+ dispatcher.cleanup();
+
+ assertThat(Dispatcher.getInstance()).isNull();
+ assertThat(ActionContext.getContext()).isNull();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void cleanupClearsDebugUtilsCache() throws Exception {
+ initDispatcher(emptyMap());
+
+ Field field = DebugUtils.class.getDeclaredField("IS_LOGGED");
+ field.setAccessible(true);
+ Set<String> isLogged = (Set<String>) field.get(null);
+
+ isLogged.add("test-key");
+ assertThat(isLogged).isNotEmpty();
+
+ dispatcher.cleanup();
+
+ assertThat(isLogged).isEmpty();
+ }
+}
diff --git
a/core/src/test/java/org/apache/struts2/util/fs/DefaultFileManagerFactoryTest.java
b/core/src/test/java/org/apache/struts2/util/fs/DefaultFileManagerFactoryTest.java
index a09dc0cad..55617b7c2 100644
---
a/core/src/test/java/org/apache/struts2/util/fs/DefaultFileManagerFactoryTest.java
+++
b/core/src/test/java/org/apache/struts2/util/fs/DefaultFileManagerFactoryTest.java
@@ -112,6 +112,10 @@ class DummyContainer implements Container {
public void removeScopeStrategy() {
}
+ public void destroy() {
+ // no-op in test dummy
+ }
+
}
class DummyFileManager implements FileManager {
diff --git
a/core/src/test/java/org/apache/struts2/views/util/DefaultUrlHelperTest.java
b/core/src/test/java/org/apache/struts2/views/util/DefaultUrlHelperTest.java
index 22c1a2b9d..4823f5c5a 100644
--- a/core/src/test/java/org/apache/struts2/views/util/DefaultUrlHelperTest.java
+++ b/core/src/test/java/org/apache/struts2/views/util/DefaultUrlHelperTest.java
@@ -393,5 +393,9 @@ public class DefaultUrlHelperTest extends
StrutsInternalTestCase {
public void setScopeStrategy(Strategy scopeStrategy) {
parent.setScopeStrategy(scopeStrategy);
}
+
+ public void destroy() {
+ parent.destroy();
+ }
}
}
diff --git
a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
index fce42e617..7b5d57959 100644
---
a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
+++
b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
@@ -1264,5 +1264,9 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
public void setScopeStrategy(Strategy scopeStrategy) {
}
+ public void destroy() {
+ // no-op in test dummy
+ }
+
}
}
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
b/plugins/json/src/main/java/org/apache/struts2/json/JSONCacheDestroyable.java
similarity index 53%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to
plugins/json/src/main/java/org/apache/struts2/json/JSONCacheDestroyable.java
index 41fab15fe..032951aec 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++
b/plugins/json/src/main/java/org/apache/struts2/json/JSONCacheDestroyable.java
@@ -16,44 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.struts2.mock;
+package org.apache.struts2.json;
-import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
-
-import java.util.Set;
+import org.apache.struts2.dispatcher.InternalDestroyable;
/**
- * Mock implementation to be used in unittests
+ * WW-5537: Clears JSON plugin's static BeanInfo caches when the Dispatcher is
+ * destroyed, preventing classloader leaks during hot redeployment.
+ *
+ * @since 7.2.0
*/
-public class MockContainer implements Container {
-
- public void inject(Object o) {
-
- }
-
- public <T> T inject(Class<T> implementation) {
- return null;
- }
-
- public <T> T getInstance(Class<T> type, String name) {
- return null;
- }
+public class JSONCacheDestroyable implements InternalDestroyable {
- public <T> T getInstance(Class<T> type) {
- return null;
+ @Override
+ public void destroy() {
+ StrutsJSONWriter.clearBeanInfoCaches();
}
-
- public Set<String> getInstanceNames(Class<?> type) {
- return null;
- }
-
- public void setScopeStrategy(Scope.Strategy scopeStrategy) {
-
- }
-
- public void removeScopeStrategy() {
-
- }
-
}
diff --git
a/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java
b/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java
index c75e54e0a..391800474 100644
--- a/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java
+++ b/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java
@@ -76,6 +76,14 @@ public class StrutsJSONWriter implements JSONWriter {
private static final ConcurrentMap<Class<?>, BeanInfo>
BEAN_INFO_CACHE_IGNORE_HIERARCHY = new ConcurrentHashMap<>();
private static final ConcurrentMap<Class<?>, BeanInfo> BEAN_INFO_CACHE =
new ConcurrentHashMap<>();
+ /**
+ * Clears both BeanInfo caches to prevent classloader leaks on hot
redeploy.
+ */
+ public static void clearBeanInfoCaches() {
+ BEAN_INFO_CACHE_IGNORE_HIERARCHY.clear();
+ BEAN_INFO_CACHE.clear();
+ }
+
private final StringBuilder buf = new StringBuilder();
private final Deque<Object> stack = new ArrayDeque<>();
private boolean ignoreHierarchy = true;
diff --git a/plugins/json/src/main/resources/struts-plugin.xml
b/plugins/json/src/main/resources/struts-plugin.xml
index 88451b39b..3d246ff36 100644
--- a/plugins/json/src/main/resources/struts-plugin.xml
+++ b/plugins/json/src/main/resources/struts-plugin.xml
@@ -29,6 +29,8 @@
<bean type="org.apache.struts2.json.JSONReader" name="struts"
class="org.apache.struts2.json.StrutsJSONReader"
scope="prototype"/>
<bean class="org.apache.struts2.json.JSONUtil" scope="prototype"/>
+ <bean type="org.apache.struts2.dispatcher.InternalDestroyable"
name="jsonCache"
+ class="org.apache.struts2.json.JSONCacheDestroyable"/>
<constant name="struts.json.writer" value="struts"/>
<constant name="struts.json.reader" value="struts"/>
diff --git
a/plugins/rest/src/test/java/org/apache/struts2/rest/DefaultContentTypeHandlerManagerTest.java
b/plugins/rest/src/test/java/org/apache/struts2/rest/DefaultContentTypeHandlerManagerTest.java
index 5f54f63f0..432cdc921 100644
---
a/plugins/rest/src/test/java/org/apache/struts2/rest/DefaultContentTypeHandlerManagerTest.java
+++
b/plugins/rest/src/test/java/org/apache/struts2/rest/DefaultContentTypeHandlerManagerTest.java
@@ -184,4 +184,9 @@ class DummyContainer implements Container {
public void removeScopeStrategy() {
}
+
+ @Override
+ public void destroy() {
+ // no-op in test mock
+ }
}