A curious case of free objects

It’s been a long time since I posted some memory dump investigation. Not that I haven’t done that, but it’s, most of the times, hard to tell a good story. In many cases we have a gotcha moment and in the context of the work we were doing, very exciting, but to tell a story to you who don’t want to be bothered with the underlying details (and we usually can’t share a lot of them), the story becomes much less exciting.

In many cases memory dump is an aid of the investigation, but not the only tool. It is easy to see what the app was doing at the exact moment the memory dump was taken, but when it’s an ongoing process, harder to pinpoint the cause. Nevertheless, it’s a powerful tool to at least, limit the scope of what you should look into.

A colleague reached out to me the other day because a customer has raising memory over time.

Normally you would want the memory to be relatively stable over time. An increasing memory over time should raise an eyebrow, especially if it’s not eventually reaching a plateau. As this is a customer that we just engaged with some other memory allocations, we wanted to make sure that everything was as it should. Another colleague helped to collect some memory dump, so game is on.

What immediately puzzles me was that there was a lot of free objects in the memory dump. That was indication of a GC happened. Not much left for us to track the source of memory allocations. Fine, let’s take another memory dump 30 minutes later. And it’s … the same.

7c343a686798    201,951     24,234,120 EPiServer.Core.Html.StringParsing.ContentFragment
7c34431d93a8     59,155     36,690,528 System.Collections.Generic.Dictionary<System.String, EPiServer.Core.PropertyData>+Entry[]
7c3438e63230    534,370     42,749,600 EPiServer.Core.PropertyLongString
7c3436362a28    202,652    107,559,201 System.Byte[]
7c34356d7c18    158,223  1,082,118,840 System.String[]
7c34355cd7c8 10,944,050  1,235,497,138 System.String
5b4faa00d1b0  8,039,645 11,560,389,656 Free
Total 28,779,604 objects, 14,685,150,235 bytes

What could potentially create that many free objects and leave them floating around. And memory keeps raising? The free objects indicating that there have been some heavy Garbage collection. But we can’t track where those come from because they were “freed”, rendering !gcroot useless.

I was pulling my hairs for a while (not that I have that many hairs left to pull). But then I realize, could these be big objects allocated in LOH (Larger objects heap). If they are big enough (more than 85.000 bytes in size), they will not be be freed when no longer referenced, but they are not “moved” (e.g. compacted).

0:000> !dumpheap -stat -mt 5b4faa00d1b0 -min 85000
Statistics:
          MT Count   TotalSize Class Name
5b4faa00d1b0    53 237,914,208 Free
Total 53 objects, 237,914,208 bytes

Hmm, we have 53 big object with more than 200MB in size, so averaging around 4MB per object. Let’s see the biggest objects by removing the -stat option

0:000> !dumpheap -mt 5b4faa00d1b0 -min 85000
         Address               MT           Size
    7bf42b10e140     5b4faa00d1b0         97,264 Free
    7bf42bd1bb50     5b4faa00d1b0      2,319,816 Free
    7bf42c8521a0     5b4faa00d1b0        586,096 Free
    7bf42ca8d448     5b4faa00d1b0         91,064 Free
    7bf42d71bdd8     5b4faa00d1b0      4,396,576 Free
    7bf42dc0a5a8     5b4faa00d1b0      2,428,160 Free
    7bf42e041680     5b4faa00d1b0      4,639,520 Free
    7bf42faad6e8     5b4faa00d1b0      2,527,600 Free
    7bf43057f0c8     5b4faa00d1b0      2,857,968 Free
    7bf431c98d60     5b4faa00d1b0      2,609,160 Free
    7bf4321c2a08     5b4faa00d1b0      4,194,392 Free
    7bf4325e2a78     5b4faa00d1b0      3,153,664 Free
    7bf43a747550     5b4faa00d1b0        662,736 Free
    7bf44c90f528     5b4faa00d1b0     13,684,008 Free
    7bf44da5b780     5b4faa00d1b0      3,578,672 Free
    7bf484525ad8     5b4faa00d1b0        386,352 Free
    7bf4845c8710     5b4faa00d1b0     15,650,872 Free
    7bf485522248     5b4faa00d1b0      2,131,104 Free
    7bf489000028     5b4faa00d1b0      5,767,368 Free
    7bf489597aa8     5b4faa00d1b0      2,568,696 Free
    7bf4b9c00028     5b4faa00d1b0      6,029,488 Free
    7bf4bc000060     5b4faa00d1b0     12,785,496 Free
    7bf4bcc517d0     5b4faa00d1b0    117,106,864 Free
    7bf4c7400028     5b4faa00d1b0      2,854,216 Free
    7bf4c7ae2930     5b4faa00d1b0      2,180,536 Free
    7bf4c7d0d260     5b4faa00d1b0      3,539,256 Free
    7bf4d7000028     5b4faa00d1b0     14,066,712 Free
    7bf4d7e0b350     5b4faa00d1b0        871,840 Free

The biggest object is more than 100MB. Could there be any big objects that were alive of that size? Let’s go with !dumpheap -min 100000000 to see if we find any

!dumpheap -min 100000000
         Address               MT           Size
    7bf4bcc517d0     5b4faa00d1b0    117,106,864 Free
    7bf724c00048     7c34356d7c18  1,073,741,848 

Statistics:
          MT Count     TotalSize Class Name
5b4faa00d1b0     1   117,106,864 Free
7c34356d7c18     1 1,073,741,848 System.String[]
Total 2 objects, 1,190,848,712 bytes

The result turned out to be better than expected. We found two object – one is the biggest free object, and the one one is an array of string with size of almost 1GB. It has 134217728 elements, which is … a lot. (But also a lot of empty elements, more below)

0:000> !dumpobj /d 7bf724c00048
Name:        System.String[]
MethodTable: 00007c34356d7c18
EEClass:     00007c343551bee0
Tracked Type: false
Size:        1073741848(0x40000018) bytes
Array:       Rank 1, Number of elements 134217728, Type CLASS (Print Array)
Fields:
None

Let’s hope this is not a free object waiting to be collected. !gcroot 7bf724c00048 might give us some clue

          -> 7bf42810a4a8     Microsoft.Extensions.Http.DefaultHttpClientFactory 
          -> 7bf429868210     Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope 
          -> 7bf429882b78     System.Collections.Generic.List<System.Object> 
          -> 7bf429cf3280     System.Object[] 
          -> 7bf4299a5550     SomeNamespace.Services.CustomMemoryCache 
          -> 7bf4299a55a8     System.Collections.Generic.List<System.String> 
          -> 7bf724c00048     System.String[] 

A bit of dotPeek reveals the code

public class CustomMemoryCache : MemoryCache, IMemoryCache, IDisposable
  {
    private List<string> _cacheNames = new List<string>();

    public List<string> GetKeys() => this._cacheNames;

    public new ICacheEntry CreateEntry(object key)
    {
      this._cacheNames.Add(key.ToString());
      return base.CreateEntry(key);
    }

As you can see the _cacheNames will add the cache key to a list. Because List<T> is not unique, whenever a new cache item is added, the key will be added to the list (even if it was a duplicate), so it becomes ever growing. In the previous memory dump (that was taken 30 minutes earlier), there were 81928424 entries in the list. As this is strongly referenced, the list will remain forever and only increases over time.

Now the funny part – because the List<T> has a limit of 2GB on the array, but because string is a reference types, each element in the array is simply a reference of it – so 8 bytes per element. You can see that is a lot. Not only that, there are two things to know about List<T>

  • List<T> will create a new array, double in size copy the items over once it’s filed the internal array with items. The old array will become unreachable and ready to be GC’ed the next time. Which is also why we might have a lot of empty items in the list.
  • Once the array grows large enough, they will be created in the Large Object Heap (LOH). The threshold is 85.000 bytes, so around 10k items in the list (because each element is 8 bytes, they do not hold the actual string object, but a reference to it). After that, each time the list is doubled, a new object will be created, and the old list will remain on LOH as a free memory object. They will (almost) never be compacted, unless there was a need to create a big object in which they will be overwritten.

The fix in this case is to simply remove _cacheNames, as it does not serve a purpose. It took a while for the customer to deploy the fix, but when they do, the result was … impressive

Before the fix, the memory was ever increasing until the app needs to be restarted. After that, it remain below 3GB.

Moral of the story

  • Keep looking.
  • If there are a lot of big, free objects, most likely, there are live objects of equal size that can give you some ideas.

Leave a Reply

Your email address will not be published. Required fields are marked *