I have some level of understanding of how the memory is allocated and garbage collected in C#. But I'm missing some clarity on GC even after reading multiple articles and trying out few test c# programs. To simplify the ask, I have created as dummy ASP core Web API project with a DummyPojo class which holds one int property and DummyClass which creates million object of the DummyPojo. Even after running forced GC the dead objects are not collected. Can someone please throw some light on this.
public class DummyPojo
{
public int MyInt { get; set; }
~DummyPojo() {
//Debug.WriteLine("Finalizer");
}
}
public class DummyClass
{
public async Task TestObjectsMemory()
{
await Task.Delay(1000 * 10);
CreatePerishableObjects();
await Task.Delay(1000 * 10);
GC.Collect();
}
private void CreatePerishableObjects()
{
for (int i = 0; i < 100000; i++)
{
DummyPojo obj = new() { MyInt = i };
}
}
}
Then in my program.cs file I just called the method to start creating objects.
DummyClass dc = new DummyClass();
_ = dc.TestObjectsMemory();
I ran the program and took 3 memory snapshots with three 10 seconds wait each using visual studio's memory diagnostic tool, one at before creating objects, second one after creating object and the last one is after triggering GC.
My questions are:
Why is memory usage not going down to the original size? I understand that GC runs only when it needed only like memory crunch. In my case I triggered the GC and I see finalizers also getting called. I was expecting the GC to collect dead objects and compact the memory. As per my understanding this is nothing to do with LOH as I did not use any large objects or arrays. Please look at the below images for each snapshot and GC numbers.
While memory keep increasing, the snapshots differences shows in green color which indicates memory decrease. why would it show the opposite?
when I opened the snapshot#3 I see DummyPojo dead objects. Shouldn't it be collected by the forced GC?
I'm not super familiar with the VS memory profiler, so some of this will be conjecture.
First of all, it is important to keep in mind what type of memory you are monitoring. The graph says Process Memory, which I would assume means all the memory allocated from the OS by the process. This memory obviously needs to increase when you allocate more objects, but it will not directly correlate with the size of the managed heap, i.e. the actual memory you are using. The GC can over allocate memory to reduce the number of times it needs to request memory from the OS, and it will not release memory back to the OS unless it is sure it will not need it again for a while. I have mostly used dotMemory, and its graph is split into each generation + LOH + unmanaged, making the effect of GCs easier to see.
Contrary to some comments GC.Collect
should block until the collection is complete:
Use this method to try to reclaim all memory that is inaccessible. It performs a blocking garbage collection of all generations.
But you are declaring a finalizer, this will mean that all objects will be put on the finalizer queue when they are collected. Because of this they will remain kind of half alive even after they are "collected". Finalizers are meant to ensure unmanaged resources are collected, and this should be rare. I would really recommend removing the finalizer when doing any kind of investigation into the GC. The GC is difficult enough to understand without involving the finalizer queue and all the complexity it entails.
Also note that the compiler is allowed to do any optimization as long as the behavior is unchanged. Since memory allocations are not seen as "behavior", it would be allowed to just remove the entire loop.