Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add limit on ClassLoader WeakReference cache #7698

Closed

Conversation

tylerbenson
Copy link
Member

Problem

Having many WeakReferences can cause problems in an application. To reduce the number, we have a cache lookup to allow us to reuse those WeakReference instances. This cache is currently unlimited in size, and only reduced by cleaning references to GC'd ClassLoaders. Most applications have a limited number of persistent ClassLoaders. Some applications generate them dynamically. This is where the cache can grow excessively.

Solution

Add a size limit to the cache so that less commonly used loaders are evicted.

Note: This could cause a common entry to be evicted which may result in a less performant lookup (because identity comparison won't match for subsequently generated WeakReferences). This is considered an acceptable tradeoff to avoid excessive memory use.

Related: #7678

@tylerbenson tylerbenson requested a review from a team as a code owner January 31, 2023 20:02
@tylerbenson tylerbenson force-pushed the tyler/weak-bounded branch 2 times, most recently from cd13582 to 726fe30 Compare January 31, 2023 21:38
## Problem
Having many WeakReferences can cause problems in an application. To reduce the number, we have a cache lookup to allow us to reuse those WeakReference instances. This cache is currently unlimited in size, and only reduced by cleaning references to GC'd ClassLoaders.  Most applications have a limited number of persistent ClassLoaders. Some applications generate them dynamically.  This is where the cache can grow excessively.

## Solution
Add a size limit to the cache so that less commonly used loaders are evicted.

Note: This could cause a common entry to be evicted which may result in a less performant lookup (because identity comparison won't match for subsequently generated WeakReferences). This is considered an acceptable tradeoff to avoid excessive memory use.
These are optimizations where caching is just an optimization and invalidation can be recalculated.  I didn't change the cache in `HelperInjector` as that seems to have more functionality around it.
@laurit
Copy link
Contributor

laurit commented Feb 21, 2023

@opentelemetrybot update

@laurit
Copy link
Contributor

laurit commented Feb 21, 2023

I measured heap usage for WeakLockFreeCache from starting Liferay (osgi application with many class loaders). For this I took heap dump after Liferay had started and in Eclipse memory analyzer looked up all instances of WeakLockFreeCache and summed the retained heap column.
baseline 1,012 objects consuming 13,117,752
this pr 1,012 objects consuming 18,115,592
#7866 806 objects consuming 1,017,816

Surprisingly the bound cache used in this pr does not result in smaller memory usage. As far as I can tell it is because ConcurrentLinkedHashMap is fairly large data structure. On my macbook

is a 16x128 array which filled with atomic refs already consumes 41,296

On a run without the javaagent or with my cache limit fixes, the assertion passes.  When run on main, the test fails because the heap size difference is over 200MB.
@tylerbenson
Copy link
Member Author

@trask per your request, I created a test that runs with the full javaagent. When run on main the test fails, when run with this branch or without the javaagent the test passes. (Notice, I @Ignored it to avoid running it in CI.)

@laurit I'm not familiar with Liferay. How many classloaders would you say it generates/uses? If the classloader usage is spread out more it probably isn't as problematic. This PR is explicitly trying to handle dynamically generated classloaders.

@laurit
Copy link
Contributor

laurit commented Mar 17, 2023

@tylerbenson Junit 5 has a different annotation for disabling tests, see https://junit.org/junit5/docs/current/user-guide/#writing-tests-disabling
I used liferay for testing just because it is a large app that is easily available so you could verify whether I messed up something in the measurements. It is osgi based so it has a lot of class loaders, a bit over 1000 if I remember correctly. You could use some other large app that you have at hand.
I had to strip one 0 zero off of the iteration count to make it pass on my laptop. I ran this test with your pr, main and my pr. Firstly I don't think Runtime.getRuntime().totalMemory() is the one you want to measure. It shows the size of the heap, not how much of it is in use. I think you should be using Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() instead to get how much of the heap is currently in use. I took heap dumps at the end of the run and compared them. Memory analyzer reported the following heap sizes
your pr - 33 MB
main - 26.2 MB
my pr - 16.6 MB
I'll merge #7866 you can rebase this pr if you so wish. That pr would be useful even when we decide to adopt your approach for limiting cache sizes. Instead of having a separate ClassLoader -> Boolean map in each matcher it uses on ClassLoader -> BitSet map to track the matching status. Heap usage reduction comes from having only one map instead of the 200+ maps. To limit the cache size you'll have to modify this line


My understanding is that when using one shared map what increases when class loaders are added is the array backing that map. Creating class loaders will eventually exhaust the heap and get gcd. That will prevent that map from growing further. As class loaders are much larger than what is in that map I doubt that you can observe that map growing too large even if you don't explicitly limit its size.

laurit added a commit that referenced this pull request Mar 17, 2023
See
#7698
This is an attempt to reduce memory usage for
`ClassLoaderHasClassesNamedMatcher`. Instead of having each matcher keep
a `Map<ClassLoader, Boolean>` we can have one `Map<ClassLoader, BitSet>`
where each matcher uses one bit in the `BitSet`. Alternatively
`Map<ClassLoader, Set<ClassLoaderHasClassesNamedMatcher>>` where set
contains matchers that match for given class loader would also work well
because these matchers usually don't match so we can expect to have only
a few elements in the set.
# Conflicts:
#	javaagent-extension-api/src/main/java/io/opentelemetry/javaagent/extension/matcher/ClassLoaderHasClassesNamedMatcher.java
@tylerbenson
Copy link
Member Author

@laurit Thanks for the pointer about @Disabled.

The reason that tracking and keeping Runtime.getRuntime().totalMemory() constrained is important is because of systems like Docker that monitor the process memory usage and kill the process if it exceeds a configured threshold. Minimizing large swings in allocated memory is valuable and often preferred even with a tradeoff of higher average memory (or even cpu) usage. Controlling those large swings is ultimately the goal of configuring these cache limits.

@tylerbenson
Copy link
Member Author

@laurit I ran the included test on main with your change included and it does indeed solve the problem. I will go ahead and close this PR. Thanks for the fix.

@tylerbenson tylerbenson deleted the tyler/weak-bounded branch March 20, 2023 16:45
@@ -39,7 +39,7 @@ class MuzzleMatcher implements AgentBuilder.RawMatcher {
private final InstrumentationModule instrumentationModule;
private final Level muzzleLogLevel;
private final AtomicBoolean initialized = new AtomicBoolean(false);
private final Cache<ClassLoader, Boolean> matchCache = Cache.weak();
private final Cache<ClassLoader, Boolean> matchCache = Cache.weakBounded(25);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@laurit do you think your change should be applied to this matcher too?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants