[ 
https://issues.apache.org/jira/browse/OPENJPA-3012?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18125465#comment-18125465
 ] 

ASF subversion and git services commented on OPENJPA-3012:
----------------------------------------------------------

Commit 114e6f442087d8c6bc792528159e037f8cf0e713 in openjpa's branch 
refs/heads/OPENJPA-3012 from Richard Zowalla
[ https://gitbox.apache.org/repos/asf?p=openjpa.git;h=114e6f442 ]

[OPENJPA-3012] Test the class resolution of the deserializing stream

The resolution paths are reachable without constructing an ObjectStreamClass:
serializing int.class yields a descriptor whose name is "int", which
Class.forName rejects, so it is resolved only by the fallback to the stream's
own resolveClass; that test fails before this change and passes after it. A
class loader that redefines the classes of the test instead of delegating gives
a class reachable from one loader only, which pins that the thread context
loader is consulted and takes precedence. Array classes and an array instance
cover the descriptors that name a type rather than a class.


> 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
>            Priority: Major
>             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)

Reply via email to