> standardAttributesMap = new ConcurrentHashMap<>();
+ /**
+ * Clears the cached standard attributes map to prevent classloader leaks on hot redeploy.
+ */
+ 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/dispatcher/ComponentCacheDestroyable.java b/core/src/main/java/org/apache/struts2/dispatcher/ComponentCacheDestroyable.java
new file mode 100644
index 0000000000..5d02491728
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/dispatcher/ComponentCacheDestroyable.java
@@ -0,0 +1,36 @@
+/*
+ * 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.components.Component;
+
+/**
+ * Clears {@link Component}'s static standard attributes cache to prevent
+ * classloader leaks on hot redeploy. Wrapper is needed because {@code Component}
+ * requires constructor arguments that prevent direct container instantiation.
+ *
+ * @since 6.9.0
+ */
+public class ComponentCacheDestroyable implements InternalDestroyable {
+
+ @Override
+ public void destroy() {
+ Component.clearStandardAttributesMap();
+ }
+}
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 2d1c61657e..28d4cc036a 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/ContainerHolder.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/ContainerHolder.java
@@ -21,27 +21,70 @@
import com.opensymphony.xwork2.inject.Container;
/**
- * 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 com.opensymphony.xwork2.config.ConfigurationManager}.
*
- * 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 a volatile 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 instance = new ThreadLocal<>();
+ private static final ThreadLocal instance = new ThreadLocal<>();
+
+ /**
+ * Incremented on each {@link #invalidateAll()} call. Threads compare their cached
+ * generation against this value to detect staleness.
+ */
+ private static volatile long generation = 0;
public static void store(Container newInstance) {
- instance.set(newInstance);
+ instance.set(new CachedContainer(newInstance, generation));
}
public static Container get() {
- return instance.get();
+ CachedContainer cached = instance.get();
+ if (cached == null) {
+ return null;
+ }
+ if (cached.generation != generation) {
+ 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.
+ *
+ * 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++;
+ instance.remove();
+ }
+
+ private static class CachedContainer {
+ final Container container;
+ final long generation;
+
+ CachedContainer(Container container, long generation) {
+ this.container = container;
+ this.generation = 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 0000000000..578620d914
--- /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 javax.servlet.ServletContext;
+
+/**
+ * Extension of {@link InternalDestroyable} for components that require
+ * {@link ServletContext} during cleanup (e.g. clearing servlet-scoped caches).
+ *
+ *
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()}.
+ *
+ * @since 6.9.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/dispatcher/Dispatcher.java b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
index f551087195..55f0228ba9 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
@@ -451,7 +451,37 @@ public void setThreadAllowlist(ThreadAllowlist threadAllowlist) {
* 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}.
+ * Called at the beginning of {@link #cleanup()}.
+ *
+ * @since 6.9.0
+ */
+ protected void destroyObjectFactory() {
if (objectFactory == null) {
LOG.warn("Object Factory is null, something is seriously wrong, no clean up will be performed");
}
@@ -459,23 +489,36 @@ public void cleanup() {
try {
((ObjectFactoryDestroyable) objectFactory).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 6.9.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. Listeners must be re-registered
+ // if a new Dispatcher is created (e.g. on redeploy).
+ dispatcherListeners.clear();
}
+ }
- // clean up all interceptors by calling their destroy() method
+ /**
+ * Destroys all interceptors registered in the current configuration.
+ * Called during {@link #cleanup()} before {@link #destroyInternalBeans()}.
+ *
+ * @since 6.9.0
+ */
+ protected void destroyInterceptors() {
Set interceptors = new HashSet<>();
Collection packageConfigs = configurationManager.getConfiguration().getPackageConfigs().values();
for (PackageConfig packageConfig : packageConfigs) {
@@ -490,16 +533,38 @@ public void cleanup() {
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).
+ *
+ * Beans implementing {@link ContextAwareDestroyable} receive the
+ * {@link javax.servlet.ServletContext} via
+ * {@link ContextAwareDestroyable#destroy(javax.servlet.ServletContext)}.
+ *
+ * @since 6.9.0
+ */
+ protected void destroyInternalBeans() {
+ if (configurationManager != null && configurationManager.getConfiguration() != null) {
+ Container container = configurationManager.getConfiguration().getContainer();
+ Set destroyableNames = container.getInstanceNames(InternalDestroyable.class);
+ for (String name : destroyableNames) {
+ try {
+ InternalDestroyable destroyable = container.getInstance(InternalDestroyable.class, name);
+ if (destroyable instanceof ContextAwareDestroyable) {
+ ((ContextAwareDestroyable) destroyable).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/dispatcher/FinalizableReferenceQueueDestroyable.java b/core/src/main/java/org/apache/struts2/dispatcher/FinalizableReferenceQueueDestroyable.java
new file mode 100644
index 0000000000..1535d87e26
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/dispatcher/FinalizableReferenceQueueDestroyable.java
@@ -0,0 +1,36 @@
+/*
+ * 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 com.opensymphony.xwork2.inject.util.FinalizableReferenceQueue;
+
+/**
+ * Adapter that exposes {@link FinalizableReferenceQueue#stopAndClear()} as an
+ * {@link InternalDestroyable} bean, since {@code FinalizableReferenceQueue}
+ * has a private constructor and cannot be directly registered in the container.
+ *
+ * @since 6.9.0
+ */
+public class FinalizableReferenceQueueDestroyable implements InternalDestroyable {
+
+ @Override
+ public void destroy() {
+ FinalizableReferenceQueue.stopAndClear();
+ }
+}
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 0000000000..946684e034
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/dispatcher/FreemarkerCacheDestroyable.java
@@ -0,0 +1,57 @@
+/*
+ * 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 javax.servlet.ServletContext;
+
+/**
+ * WW-5537: Clears FreeMarker's template and class introspection caches
+ * stored in {@link ServletContext} during application undeploy, preventing
+ * classloader leaks.
+ *
+ * @since 6.9.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) {
+ Configuration cfg = (Configuration) fmConfig;
+ cfg.clearTemplateCache();
+ cfg.clearEncodingMap();
+ if (cfg.getObjectWrapper() instanceof BeansWrapper) {
+ ((BeansWrapper) cfg.getObjectWrapper()).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 0000000000..085f548791
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/dispatcher/InternalDestroyable.java
@@ -0,0 +1,50 @@
+/*
+ * 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.
+ *
+ * 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.
+ *
+ * The order in which implementations are invoked is not guaranteed.
+ * Implementations must not depend on other {@code InternalDestroyable}
+ * beans having been (or not yet been) destroyed. Ordering can be
+ * influenced via the {@code order} attribute in bean registration.
+ *
+ * This is not part of the public user API. For user/plugin lifecycle
+ * callbacks, use {@link DispatcherListener} instead.
+ *
+ * @since 6.9.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/dispatcher/OgnlCacheDestroyable.java b/core/src/main/java/org/apache/struts2/dispatcher/OgnlCacheDestroyable.java
new file mode 100644
index 0000000000..9bf9915b91
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/dispatcher/OgnlCacheDestroyable.java
@@ -0,0 +1,38 @@
+/*
+ * 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 com.opensymphony.xwork2.ognl.OgnlUtil;
+
+import java.beans.Introspector;
+
+/**
+ * Clears OGNL runtime caches and JDK introspection caches that hold
+ * {@code Class>} references, preventing classloader leaks on hot redeploy.
+ *
+ * @since 6.9.0
+ */
+public class OgnlCacheDestroyable implements InternalDestroyable {
+
+ @Override
+ public void destroy() {
+ OgnlUtil.clearRuntimeCache();
+ Introspector.flushCaches();
+ }
+}
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 ab4323526e..961412a5d1 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 void cleanupRequest(final HttpServletRequest request) {
} finally {
ActionContext.clear();
Dispatcher.clearInstance();
+ ContainerHolder.clear();
devModeOverride.remove();
}
});
diff --git a/core/src/main/java/org/apache/struts2/dispatcher/ScopeInterceptorCacheDestroyable.java b/core/src/main/java/org/apache/struts2/dispatcher/ScopeInterceptorCacheDestroyable.java
new file mode 100644
index 0000000000..7a7f54406e
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/dispatcher/ScopeInterceptorCacheDestroyable.java
@@ -0,0 +1,37 @@
+/*
+ * 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.interceptor.ScopeInterceptor;
+
+/**
+ * Clears {@link ScopeInterceptor}'s static locks map to prevent classloader
+ * leaks on hot redeploy. Separated from the interceptor itself because the
+ * locks map is static and must be cleared regardless of whether the interceptor
+ * is configured in any package.
+ *
+ * @since 6.9.0
+ */
+public class ScopeInterceptorCacheDestroyable implements InternalDestroyable {
+
+ @Override
+ public void destroy() {
+ ScopeInterceptor.clearLocks();
+ }
+}
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 765fa40ed0..77789c1d26 100644
--- a/core/src/main/java/org/apache/struts2/interceptor/ScopeInterceptor.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/ScopeInterceptor.java
@@ -232,7 +232,21 @@ private static Object nullConvert(Object o) {
return o;
}
- private static Map