1

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. enter image description here

My questions are:

  1. 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. 1st Snapshot 2nd Snapshot GC Run 3rd Snapshot

    • The 2nd snapshot shows 118MB (4MB jump from 114MB) is due to dead objects?
    • why it jumped to 125MB in snapshot3 even after the forced GC instead of going back to 114MB.
  2. While memory keep increasing, the snapshots differences shows in green color which indicates memory decrease. why would it show the opposite?

  3. when I opened the snapshot#3 I see DummyPojo dead objects. Shouldn't it be collected by the forced GC? pojo dead object

Elangovan Manickam
  • 413
  • 1
  • 5
  • 14
  • 5
    `GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();` – Charlieface Jun 25 '23 at 22:37
  • See also [this answer](https://stackoverflow.com/a/13955187/3744182) By Eric Lippert to [GC.Collect() and Finalize](https://stackoverflow.com/q/13954829/3744182) which elaborates on the comment of @Charlieface. Does that answer your question? – dbc Jun 25 '23 at 22:42
  • 1
    To explain a bit, `GC.Collect`doesn't force a garbage collection to happen synchronously, it schedules one. The call to `WaitForPendingFinalizers` waits till the collection (and any Finalizers the GC may have scheduled) to complete – Flydog57 Jun 26 '23 at 01:03
  • 1
    Tye other thing to realize is that a GC may not cause the "memory that you are using" to drop. The.memory manager has its own heuristics about when to release committed memory. If you really want to see what's going on, open _Performance Monitor_ (aka _PerfMon_) and look at the _.NET Memory Counters_ – Flydog57 Jun 26 '23 at 01:09
  • @Charlieface I tried the GC.WaitForPendingFinalizers() and the dead objects are gone. Thanks for the help. After this step, GC the memory did not drop instead it increases few more MBs as shown in my question. not sure why this though. – Elangovan Manickam Jun 26 '23 at 02:51
  • @Flydog57 thanks for your comment. I have not used the Performance Monitor so far, I will try to use the PerfMon and look into it as you suggested. – Elangovan Manickam Jun 26 '23 at 02:52
  • 2
    Read this epic document https://github.com/Maoni0/mem-doc/blob/master/doc/.NETMemoryPerformanceAnalysis.md#gc-fundamentals. The GC does not release memory to the OS whenever a GC runs. That memory may be reused for future allocations. – davidfowl Jun 26 '23 at 06:15

2 Answers2

3

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.

JonasH
  • 28,608
  • 2
  • 10
  • 23
  • .NET compilers are much more limited in what they can eliminate than a C++ compiler. They value correctness a lot more. Especially considering the class being allocated here has a finalizer. It is able to skip stack allocations if they aren't leaked out (and of course, having a finalizer already means you're leaking out anyway), but it can't arbitrarily remove a class initializer invocation. Of course, the GC can run at any point you make an allocation, so it could execute in the middle of CreatePerishableObjects... if there was enough memory pressure. – Luaan Jun 26 '23 at 14:01
  • @Luaan While you are right that the current compiler cannot remove a class initializer invocation, I do not see any argument why it would not be *allowed* to do it, if it can prove that doing so have no effect on the output. My understanding is that the conservative nature of the jitter has more to do with the real time requirements than what it is theoretically allowed, and the real time requirement might be reduced with multi stage compiling. – JonasH Jun 26 '23 at 14:35
  • I think it cannot be allowed to do that in the general case. The specification guarantees that any exceptions that should be thrown will be thrown, and there's no way to decide with just static analysis if calling the constructor will cause an exception (in fact, it's not even allowed to cause exceptions to happen in the "wrong order"). It might be possible with the default constructor (like in this case), but I wouldn't hold my breath - if you cared about that kind of optimization, you wouldn't use a class in the first place. – Luaan Jun 28 '23 at 10:22
  • @Luaan I agree that there is no way to prove if an exception is thrown *in general*. But it is absolutely possible to prove *in specific cases*. And I'm fairly sure that any side effects of allocation, like OutOfMemoryException, does not need to be preserved. The c# language spec tend to focus on behavior rather than implementation details, but I admit that I'm to lazy to find exact references. If you can find some references to prove otherwise I would be happy learn something new. – JonasH Jun 28 '23 at 12:43
  • The CLI specification mentions which kinds of optimizations are prohibited, and exceptions are considered a serious business in that regard (you have an option to set the behaviour as more relaxed explicitly, which allows a lot of previously unavailable optimizations). And no, running out of memory doesn't count - in fact, you could make a 100% compliant CLI implementation that doesn't have any form of GC at all (just like your typical old school Unix C tool :D ). C# specification doesn't really talk about optimizations much, and they are _really_ conservative. JIT is key. – Luaan Jun 28 '23 at 13:49
  • @JonasH after removing the finalizer, I see the GC.Collect alone collects the dead objects and I don't see those dead objects in the memory snapshots. In actual project environment, we won't be calling the GC.Collect, in that case, can we simply ignore the dead objects in the memory as it will be taken care by GC at some point of time? – Elangovan Manickam Jun 28 '23 at 22:09
  • @ElangovanManickam Yes, that is the idea behind garbage collection. – JonasH Jun 29 '23 at 06:54
1

Any object with a finalizer (that didn't explicitly disable its finalization) will be finalized asynchronously with respect to the GC.Collect() call. To wait for the finalizers to finish and the GC to reclaim all the memory, you have to do the full dance:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

It's rather important to ensure finalizers are almost never actually called; they're a final backup measure to avoid holding on to unmanaged stuff, not something you should use lightly. Most of the time, you want to keep the finalizers as simple and small as possible (note how .NET itself actually avoid using finalizers as much as possible, and instead hides them behind objects such as SafeHandles; and of course, once those are disposed, their finalizers are "unregistered").

Of course, just because the objects are collected doesn't necessarily mean the memory has been freed for use by other processes. You're not going to see that kind of expensive operation just for freeing less than a megabyte of memory. You can keep track of how much GC memory is currently in use with GC.GetTotalMemory. In general, don't expect to work with managed memory the way you would normally work with unmanaged memory in something like C.

Luaan
  • 62,244
  • 7
  • 97
  • 116
  • thanks for your answer, Its helpful. when I don't have finalizers (since I don't use any unmanaged memory to finalize) can I safely assume that the GC will collect the dead objects from managed memory when it has to do so. what worries me is that when I look at the memory in the task manager it grows up to GB's over time when my program has only few MBs of alive objects. My machine 64GB has RAM, since it has large memory available, will the GC delay the collection until it goes upto many GBs of dead objects? – Elangovan Manickam Jun 28 '23 at 22:23
  • @ElangovanManickam You can never safely assume the GC will collect anything. If your program keeps growing to GBs despite only having a few MB of alive objects, you likely have a problem, yes. It might be the dead objects aren't as dead as you think, or you might be abusing the GC (you don't actually use `GC.Collect` in production code, right?), or that the heap is severely fragmented (usually due to native interop, which includes things like I/O). It might also be you're looking at the wrong numbers, or that once in a while, you actually have GBs of alive objects at the same time. – Luaan Jun 29 '23 at 11:15
  • @ElangovanManickam You can safely assume that until there's actual memory pressure on the system, the GC will not aggressively reclaim memory for use by other processes. In recent versions by default the GC will only really start returning memory to OS when the free memory on the system drops below about 10% (unless you have more than 64 GB RAM). – Luaan Jun 29 '23 at 11:17
  • In my inspection of the memory snapshots (for example, in the screenshots in my question) the live objects count and the managed heap size stays the same. Only the overall memory is getting increased (like 114, 118, 125 MBs in the snapshots). Also when I open the snapshots, I don't see the extra MB's only until I enable the checkbox next to the "Dead Objects" option. With this information, I concluded the dead object are indeed dead objects. The same applies to when I see the GBs of data too. – Elangovan Manickam Jul 01 '23 at 15:50
  • My actual project (asp net core) is about streaming stock quotes for multiple symbols(over signalr hub), so it creates thousands of objects and writes to the websocket and throws away the object. This is endless process for keep streaming. Nothing hold references to those objects. Since many objects are created in short time, the memory grows very fast with these dead objects (as I think they are dead). – Elangovan Manickam Jul 01 '23 at 15:51
  • Should I invest more time in learning other memory analyzer tools to find out if the dead objects really dead objects and still hanging around due to some references that I'm missing to see. Or I should simply continue with project and look into this when I really see an issue of memory crunch? what would be your suggestion? – Elangovan Manickam Jul 01 '23 at 15:51
  • 1
    @ElangovanManickam You can use SOS to get much more detailed information about the managed and unmanaged objects in a .NET process; it's essential for debugging serious memory issues. But it does have quite a learning curve (it's a plugin for windbg :)). How do you serialize those objects to the websocket stream? Who owns _that_ memory? There's many ways to improve I/O handling to avoid issues with the memory that needs to be pinned to interop with native (the actual socket). Watch the long term trends - does memory keep growing over days? That would be a problem. – Luaan Jul 08 '23 at 09:52