Maxim Solodovnik created OPENJPA-3012:
-----------------------------------------
Summary: memory leak in Serialization
Key: OPENJPA-3012
URL: https://issues.apache.org/jira/browse/OPENJPA-3012
Project: OpenJPA
Issue Type: Bug
Components: datacache, kernel
Affects Versions: 3.2.2
Reporter: Maxim Solodovnik
Assignee: Maxim Solodovnik
Fix For: 4.2.0
h2. Summary
org.apache.openjpa.util.Serialization.ClassResolvingObjectInputStream.resolveClass()
creates a new MultiClassLoader instance for every class descriptor it
resolves, and passes it to Class.forName(name, true, loader).
When a loader is used as the initiating loader in Class.forName, the JVM
registers it and allocates native ClassLoaderData structures for it. These show
up in Native Memory Tracking (NMT) under the "Class", "Module" and
"Synchronization" categories. The throw-away loaders become garbage
immediately, but in our environment their native memory was only reclaimed by a
Full GC. G1 concurrent cycles did not reclaim it.
TCPRemoteCommitProvider uses this stream to deserialize every incoming
RemoteCommitEvent. In a clustered deployment, every node therefore creates
several new class loaders for every commit made on any other node. Under
sustained write load this caused steady native memory growth on all nodes,
until the containers were OOMKilled. The Java heap was not the problem: it
still had plenty of free space when the containers were killed.
h2. How we use OpenJPA
* OpenJPA is the JPA provider, with the L2 DataCache enabled
(openjpa.DataCache=true).
* Cache coherence between replicas uses the Kubernetes remote commit provider
shipped in openjpa-kubernetes (introduced by OPENJPA-2801):
openjpa.RemoteCommitProvider=org.apache.openjpa.event.kubernetes.KubernetesTCPRemoteCommitProvider(CacheDurationMillis=30000,Namespace=<ns>,Label=<label>)
* Each replica sends a RemoteCommitEvent to its peers on every commit. Each
replica receives the events of all other replicas through
TCPRemoteCommitProvider$TCPPortListener$ReceiveSocketHandler.
* The workload is write-heavy (bulk provisioning): hundreds of thousands of
entity inserts and updates over 15+ hours, spread across all replicas.
h2. Symptoms
# Memory grew on every replica at about the same rate (~450 MB/hour). This was
true even for replicas with almost no local work. Every replica receives the
remote commit events of all the others, so the growth followed the cluster-wide
commit rate, not each replica's own load.
# Containers were repeatedly OOMKilled (exit code 137) while the Java heap was
far below its maximum.
* Example: container RSS reached 24.3 GB (the limit) while the Java heap was
11.4 GB, out of a 17.2 GB maximum.
* Increasing the container memory or tuning GC settings did not help.
# Some replicas were restarted by their liveness probe after long JVM pauses.
* This happened when a large backlog of ClassLoaderData was finally processed.
* It also happened when we ran diagnostic commands that walk all class loaders
(jcmd VM.classloader_stats).
h2. Evidence
h3. 1. NMT summary of a replica after ~11 hours
(-XX:NativeMemoryTracking=summary)
{code:java}
Total: reserved=26540MB, committed=12155MB
malloc: 7024MB #31316799
- Java Heap (reserved=17216MB, committed=4624MB)
- Class (reserved=4439MB, committed=3434MB)
(classes #28771)
(malloc=3415MB #12733544) (peak=4961MB #18457246)
( Metadata: )
( reserved=192MB, committed=142MB)
( used=140MB)
- Module (reserved=2545MB, committed=2545MB)
(malloc=2545MB #7582516) (peak=3697MB #11015426)
- Synchronization (reserved=251MB, committed=251MB)
(malloc=251MB #2528005)
- Symbol (reserved=222MB, committed=222MB)
- Metaspace (reserved=193MB, committed=143MB)
{code}
* No class loading problem. The number of loaded classes (~28.8K) and
Metaspace (~143 MB) were stable.
* Growth was in malloc only. It was millions of small native allocations in
the Class, Module and Synchronization categories. Together they used more
memory than the committed Java heap.
* The allocation ratio points to ClassLoaderData. The ratio of allocations
(Class : Module : Synchronization ≈ 5 : 3 : 1) matches what the JVM allocates
for each registered class loader: dictionary, unnamed module and package
tables, and the metaspace lock.
h3. 2. The throw-away loaders are MultiClassLoader instances
About 5 minutes after a replica restart, under load:
$ jcmd <pid> VM.classloader_stats | grep -c MultiClassLoader 14091
That is about 47 new org.apache.openjpa.lib.util.MultiClassLoader loaders per
second.
h3. 3. Only a Full GC reclaims the memory
||Step||NMT Class (committed)||NMT Module (committed)||
|Fresh replica, before|36 MB|15 MB|
|jcmd <pid> GC.run (with -XX:+ExplicitGCInvokesConcurrent, so a G1 concurrent
cycle), +2 min|50 MB|26 MB|
|Some minutes later|72 MB|43 MB|
|jcmd <pid> GC.class_histogram (forces a Full GC)|17 MB|1 MB|
* After the Full GC, the histogram showed 0 live MultiClassLoader instances.
The loaders are unreachable garbage, not a reference leak.
* However, their native memory was not released by the G1 concurrent cycle
(Remark took place, as shown in the GC log). It was only released by the Full
GC.
* In a long-running service, Full GCs are rare, so the memory accumulates.
h3. 4. Standalone reproduction (no application code)
{code:java}
import java.io.*;
import java.util.*;
import org.apache.openjpa.util.Serialization;
public class Repro {
public static void main(String[] args) throws Exception {
ArrayList<Object> value = new ArrayList<>();
value.add(new HashMap<>(Map.of("k", 1L)));
value.add(new HashSet<>(Set.of("x")));
value.add(new Date());
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
new ObjectOutputStream(bytes).writeObject(value);
byte[] data = bytes.toByteArray();
for (int i = 0; i < 50_000; i++) {
new Serialization.ClassResolvingObjectInputStream(new
ByteArrayInputStream(data)).readObject();
}
Thread.sleep(5_000); // inspect with: jcmd <pid> VM.native_memory
summary / VM.classloader_stats
}
}
{code}
Run with {{java -XX:NativeMemoryTracking=summary -XX:+UseG1GC -Xmx512m -cp
openjpa-3.2.2.jar:.}} Repro. Results with JDK 17:
|| ||NMT Class malloc||NMT Module malloc||MultiClassLoader loaders registered||
|OpenJPA 3.2.2|168 MB (606,620 allocations)|125 MB (363,310 allocations)|92,308|
|With the patch below|0.1 MB|0.03 MB|0|
h2. Root cause
{{{}openjpa-kernel/src/main/java/org/apache/openjpa/util/Serialization.java{}}}:
* 3.2.2:
[https://github.com/apache/openjpa/blob/3.2.2/openjpa-kernel/src/main/java/org/apache/openjpa/util/Serialization.java#L131-L141]
* master:
[https://github.com/apache/openjpa/blob/master/openjpa-kernel/src/main/java/org/apache/openjpa/util/Serialization.java#L122-L131]
{code:java}
@Override
protected Class resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException {
String name = BlacklistClassResolver.DEFAULT.check(desc.getName());
MultiClassLoader loader = AccessController
.doPrivileged(J2DoPrivHelper.newMultiClassLoaderAction()); // new
loader per class descriptor
addContextClassLoaders(loader);
loader.addClassLoader(getClass().getClassLoader());
loader.addClassLoader(MultiClassLoader.SYSTEM_LOADER);
return Class.forName(name, true, loader); // JVM
registers the new loader (native ClassLoaderData)
}
{code}
{{resolveClass()}} is called once for each class descriptor in the stream. A
single RemoteCommitEvent contains several class descriptors: the event class,
collections, and OpenJPA id types. So each received event registers several new
loaders with the JVM.
The hot caller is
{{{}TCPRemoteCommitProvider$TCPPortListener$ReceiveSocketHandler{}}}:
* 3.2.2:
[https://github.com/apache/openjpa/blob/3.2.2/openjpa-kernel/src/main/java/org/apache/openjpa/event/TCPRemoteCommitProvider.java#L712-L726]
* master:
[https://github.com/apache/openjpa/blob/master/openjpa-kernel/src/main/java/org/apache/openjpa/event/TCPRemoteCommitProvider.java#L704-L718]
{code:java}
ObjectInputStream ois = new Serialization.ClassResolvingObjectInputStream(in);
...
byte[] senderAddress = (byte[]) ois.readObject();
RemoteCommitEvent rce = (RemoteCommitEvent) ois.readObject();
Serialization.deserialize(...)
{code}
is also used by {{DBDictionary.getBlobObject()}} for serialized BLOB fields, so
applications that use such fields are affected in the same way.
On {{{}master{}}}, the {{doPrivileged}} wrapper was removed (OPENJPA-2940), but
{{resolveClass()}} still does {{new MultiClassLoader()}} per call. *The issue
is still present.*
h2. Fix we applied (tested in production-like load)
We changed only ClassResolvingObjectInputStream. The candidate loaders are
collected once per stream, in the same order as before. Classes are then
resolved directly against those existing loaders, so no new class loader is
ever passed to the JVM.
* The protected addContextClassLoaders(MultiClassLoader) hook is kept, so
PersistentObjectInputStream (which adds the StoreContext loader) works
unchanged.
* The MultiClassLoader instance is used only as an ordered, de-duplicating
container. It is never handed to Class.forName, so the JVM never registers it.
{code}
--- a/openjpa-kernel/src/main/java/org/apache/openjpa/util/Serialization.java
(3.2.2)
+++ b/openjpa-kernel/src/main/java/org/apache/openjpa/util/Serialization.java
@@ public static class ClassResolvingObjectInputStream extends
ObjectInputStream {
+ /**
+ * Candidate loaders, in the same order the original MultiClassLoader
+ * used them; resolved lazily once per stream.
+ */
+ private ClassLoader[] _loaders;
+
public ClassResolvingObjectInputStream(InputStream delegate)
throws IOException {
super(delegate);
}
@Override
protected Class resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException {
String name = BlacklistClassResolver.DEFAULT.check(desc.getName());
- MultiClassLoader loader = AccessController
- .doPrivileged(J2DoPrivHelper.newMultiClassLoaderAction());
- addContextClassLoaders(loader);
- loader.addClassLoader(getClass().getClassLoader());
- loader.addClassLoader(MultiClassLoader.SYSTEM_LOADER);
- return Class.forName(name, true, loader);
+
+ ClassNotFoundException notFound = null;
+ for (ClassLoader loader : getCandidateLoaders()) {
+ try {
+ return Class.forName(name, true, loader);
+ } catch (ClassNotFoundException e) {
+ if (notFound == null)
+ notFound = e;
+ }
+ }
+
+ // primitive types and anything the candidates could not see
+ try {
+ return super.resolveClass(desc);
+ } catch (ClassNotFoundException e) {
+ throw notFound != null ? notFound : e;
+ }
+ }
+
+ private ClassLoader[] getCandidateLoaders() {
+ if (_loaders == null) {
+ // MultiClassLoader is used here only as an ordered,
de-duplicating
+ // container so that subclasses overriding
addContextClassLoaders keep
+ // working; it is never handed to the JVM, hence no
ClassLoaderData
+ // is created for it.
+ MultiClassLoader holder = new MultiClassLoader();
+ addContextClassLoaders(holder);
+ holder.addClassLoader(getClass().getClassLoader());
+ holder.addClassLoader(MultiClassLoader.SYSTEM_LOADER);
+ _loaders = java.util.Arrays.stream(holder.getClassLoaders())
+ .filter(java.util.Objects::nonNull)
+ .distinct()
+ .toArray(ClassLoader[]::new);
+ }
+ return _loaders;
}
{code}
h3. Notes on behavior
* Lookup order is unchanged. The order is: thread context class loader, then
(for PersistentObjectInputStream) the StoreContext loader, then the stream's
own class loader, then the system class loader. Bootstrap classes are found
through normal parent delegation of each candidate.
* Initialization is unchanged. initialize = true is kept, as in the original.
* Added fallback. If no candidate can load the class, we fall back to
ObjectInputStream.resolveClass(desc), which also handles primitive types. If
that also fails, the first ClassNotFoundException is rethrown.
* Loaders are fixed per stream. The candidate list is computed once per stream
instance, so the context class loader is captured on the first resolveClass
call. For TCPRemoteCommitProvider the stream lives on a single receiver thread,
so this is equivalent to the original behavior.
h3. Result
* The reproduction above no longer creates any class loader (see the table in
Evidence 4).
* In a repeated 15+ hour load test on the same 6-replica cluster, the native
memory growth and the OOMKilled restarts no longer occur.
h3. Possible alternatives for the upstream fix
* Cache a single MultiClassLoader per stream (or per set of loaders) and reuse
it, instead of creating one per resolveClass() call. This still registers one
loader per stream, so it is less effective than resolving directly against the
candidate loaders.
* Review other places where a MultiClassLoader is created per call and then
passed to Class.forName. OPENJPA-2057 notes that class-loading decisions are
spread across the code base.
----
h2. Related existing issues (no exact duplicate found)
Searched on 2026-10-07 for MultiClassLoader, ClassResolvingObjectInputStream,
TCPRemoteCommitProvider, classloader leak, native memory, Metaspace and
ClassLoaderData.
||Issue||Status||Relation||
|OPENJPA-2454 Memory leak when using multiple classloaders|Open (2.2.2)|Also a
class-loader-related memory leak, but caused by application-created loaders
retaining classes. Different mechanism.|
|OPENJPA-2057 Rethinking ClassLoading architecture|Open|Notes that
class-loading strategy is spread across many places, including ad-hoc
MultiClassLoader usage. This report is one concrete runtime consequence.|
|OPENJPA-1755 MultiClassLoader relies on its underlying ClassLoaders for
hashCode|Open|Unrelated defect in MultiClassLoader. Its discussion assumes
MultiClassLoaders are not created in large numbers, which is not true on this
code path.|
|OPENJPA-2801 Kubernetes TCPRemoteCommitProvider|Resolved|Introduced the
provider we use. The leak is in the shared TCP receive path, so it affects all
TCPRemoteCommitProvider subclasses.|
|OPENJPA-2538, OPENJPA-2636, OPENJPA-285|Closed|Earlier class loader leaks
(PCRegistry, custom plugins). Different code paths.|
----
h2.
Best regards,
Greta Jang
--
This message was sent by Atlassian Jira
(v8.20.10#820010)