What does GC.GetTotalMemory really tell us?

  • Thread starter Thread starter Daniel Billingsley
  • Start date Start date
D

Daniel Billingsley

Jon Skeet posted some code in another thread using the
GC.GetTotalMemory(true) method as a way to demonstrate the amount of memory
being allocated for a certain data type.

That method was new to me and I thought it looked like a great simple way to
implement some general memory monitoring. So I put a timer on my Mdi parent
form and had it display the results of that call every couple of seconds.
When the results were pretty weird compared to what I expected, I wrote the
following console app. I think I understand why the results do what they do
except:
- Why the big 11k jump when the hog is created? Is that mostly the
MemoryHog class itself?
- Why the jump and not drop when hog is set to null?

Also, if I understand the documentation for passing true in the parameter,
doing so doesn't necessarily guarantee collection of all generations, only
whatever can be done in a preset amount of time. Not that it probably
matters in my scenario, but just for my curiosity.

--------------------
using System;

using System.IO;



namespace JunkConsoleApp

{

public class TestClass

{

public static void Main()

{

long howMuchBefore = 0L;

long howMuchDuring = 0L;

long howMuchAfter = 0L;

long memorybeforeCall = 0L;

long memoryAfterCall = 0L;

howMuchBefore = GC.GetTotalMemory(true);

Console.WriteLine("Memory Before:\t\t{0}", howMuchBefore);

MemoryHog hog = new MemoryHog();

howMuchDuring = GC.GetTotalMemory(true);

Console.WriteLine("Memory with hog:\t{0}", howMuchDuring);

hog = null;

GC.Collect(GC.MaxGeneration); // Just to be absolutely sure!

GC.Collect(GC.MaxGeneration);

howMuchAfter = GC.GetTotalMemory(true);

Console.WriteLine("Memory After:\t\t{0}", howMuchAfter);

Console.WriteLine(new string('-', 50));

memorybeforeCall = GC.GetTotalMemory(true);

Console.WriteLine("TestMethod-Before call: \t{0}", memorybeforeCall);

TestMethod();

memoryAfterCall = GC.GetTotalMemory(true);

Console.WriteLine("TestMethod-After return:\t{0}", memoryAfterCall);

Console.WriteLine(new string('-', 50));

memorybeforeCall = GC.GetTotalMemory(true);

Console.WriteLine("TestMethod-Before call: \t{0}", memorybeforeCall);

TestMethod();

memoryAfterCall = GC.GetTotalMemory(true);

Console.WriteLine("TestMethod-After return:\t{0}", memoryAfterCall);

Console.WriteLine(new string('-', 50));

Console.WriteLine("Press a key");

Console.Read();

}

public static void TestMethod()

{

long memoryAtStart = 0L;

long memoryWithHog = 0L;

memoryAtStart = GC.GetTotalMemory(true);

Console.WriteLine("TestMethod-Start:\t\t\t{0}", memoryAtStart);

MemoryHog hog = new MemoryHog();

memoryWithHog = GC.GetTotalMemory(true);

Console.WriteLine("TestMethod-With hog:\t\t\t{0}", memoryWithHog);

}

}

class MemoryHog

{

private string s1 = "hello";

private string s2 = "hello";

private string s3 = "hello";

private string s4 = "hello";

private string s5 = "hello";

}

} // namespace JunkConsoleApp
 
Daniel Billingsley said:
Jon Skeet posted some code in another thread using the
GC.GetTotalMemory(true) method as a way to demonstrate the amount of memory
being allocated for a certain data type.

That method was new to me and I thought it looked like a great simple way to
implement some general memory monitoring. So I put a timer on my Mdi parent
form and had it display the results of that call every couple of seconds.
When the results were pretty weird compared to what I expected, I wrote the
following console app. I think I understand why the results do what they do
except:
- Why the big 11k jump when the hog is created? Is that mostly the
MemoryHog class itself?

I would imagine it's loading the type information, yes.
- Why the jump and not drop when hog is set to null?

I didn't see the drop. Could you post your results? Here are my
results:

Memory Before: 12556
Memory with hog: 19820
Memory After: 19820
--------------------------------------------------
TestMethod-Before call: 19820
TestMethod-Start: 19956
TestMethod-With hog: 19956
TestMethod-After return: 19956
--------------------------------------------------
TestMethod-Before call: 19956
TestMethod-Start: 19956
TestMethod-With hog: 19956
TestMethod-After return: 19956
--------------------------------------------------

The difference of 136 bytes when you call the method is due to JITting
of the method, I suspect.
Also, if I understand the documentation for passing true in the parameter,
doing so doesn't necessarily guarantee collection of all generations, only
whatever can be done in a preset amount of time. Not that it probably
matters in my scenario, but just for my curiosity.

Yes, it looks like that's true.
 
I didn't see the drop. Could you post your results? Here are my
results:

Memory Before: 12556
Memory with hog: 19820
Memory After: 19820

You mean you didn't see the JUMP? I didn't either just now when I ran it
again. But when I run it from VS in debug mode I get
8164
19392
19404
for the first three numbers.

But why not a drop after the variable is set to null in any of the cases?
Is that just CLR being smart enough to realize the class is used again later
and it might as well just hang onto it.

Now that I think about it, as a side note, those strings in MemoryHog are
just going to be references to an interned "hello", right?

I guess my underlying question is still if you feel this could be used as a
general memory usage monitor as I described in the first post?
 
The difference of 136 bytes when you call the method is due to JITting
of the method, I suspect.

I don't think so, the Jitted code ends in a separate CLR managed heap, not
the GC heap.

Willy.
 
Willy Denoyette said:
I don't think so, the Jitted code ends in a separate CLR managed heap, not
the GC heap.

Hmmm... I didn't think that was the case, but it seems to be.

However, I've just worked out (by trial and error) where the
discrepancy is - it's in loading the Console type and everything needed
to convert an int to a string (culture info etc).

If you insert a

Console.WriteLine(1);

just before the first call to GC.GetTotalMemory in Daniel's program,
the "before" and "after" figures are *much* closer.
 
Jon,

Inline ***

Willy.

Jon Skeet said:
Hmmm... I didn't think that was the case, but it seems to be.
*** This is not what I see when running the debugger (windbg and .load
sos.dll), you can perfectly see where the jitted code is stored, and it's
not the GC heap.
Moreover, the Jitted code is stored in a preallocated heap, in which chuncks
of 8Kb are committed when one becomes full.
Note also that once the IL is jitted it cannot be relocated, so it can
hardly be stored in the GC heap.
I don't rely too much on what GetTotalMemory is telling me, as it doesn't
correspond with what I see in the debugger (the "before" and "after" are
exactly the same, and the GC heap contains exactly the same objects).
 
Could you explain a little further what you mean there? That's my real
question, what is this method telling us in relation to the application's
memory allocation, if anything.
 
Willy Denoyette said:
I don't rely too much on what GetTotalMemory is telling me, as it doesn't
correspond with what I see in the debugger (the "before" and "after" are
exactly the same, and the GC heap contains exactly the same objects).

As I said in another post though, types and resources are loaded by the
call to Console.WriteLine which is also formatting a number. Did you
try my test of inserting an extra Console.WriteLine before the first
call to GetTotalMemory? Does it not make sense to you that calling
Console.WriteLine for the first time *could* load resources etc which
take up GC heap memory?
 
Jon,
The 136 bytes difference you see when calling Testmethod() are take by the
two strings passed as argument to the Console.Writeline methods in
TestMethod().

"TestMethod-Start:\t\t\t{0}
"TestMethod-With hog:\t\t\t{0}"

string 1 object takes 64 bytes (12 bytes header , 4 bytes for buffer size, 4
bytes for the string length followed by the string values 44 bytes , string
2 72 bytes (12, 4, 4, 52).

But my point is, that you shouldn't rely too much on the numbers reported by
GetTotalMemory as they are only an approximation, simply because it runs on
a dynamic system, you aren't controling the behavior of the GC when calling
this method passing true as argument, just as there is any guarantee that a
collection occurs when calling GC.Collect (this is something you see very
well when running a native debugger).

When attaching the debugger (I'm preferring windbg together with sos.dll),
you can dump the EE heap and you'll see the two string objects being
allocated. But you also see that the value reported by GetTotalMemory is not
the same as the memory currently taken up by the GC heap (G0, G1 and G2),
both shown by the debugger and the CLR memory perfcounters, sometimes the
difference is only a few hundred bytes, but sometimes it's a lot more.
Another point is that GetTotalMemory doesn't report the Large object heap
size, which can be considerably larger than the Gen0, 1, 2 heap.

Willy.
 
Willy Denoyette said:
The 136 bytes difference you see when calling Testmethod() are take by the
two strings passed as argument to the Console.Writeline methods in
TestMethod().

I wasn't talking about that (although looking back I can see it was
confusing) - I was talking about the jump of about 8K.
"TestMethod-Start:\t\t\t{0}
"TestMethod-With hog:\t\t\t{0}"

string 1 object takes 64 bytes (12 bytes header , 4 bytes for buffer size, 4
bytes for the string length followed by the string values 44 bytes , string
2 72 bytes (12, 4, 4, 52).

Right. That does indeed explain the 136 bytes.
But my point is, that you shouldn't rely too much on the numbers reported by
GetTotalMemory as they are only an approximation, simply because it runs on
a dynamic system, you aren't controling the behavior of the GC when calling
this method passing true as argument, just as there is any guarantee that a
collection occurs when calling GC.Collect (this is something you see very
well when running a native debugger).

Interesting - I thought one of the differences between Java and .NET
was that running GC.Collect in .NET definitely *did* trigger a garbage
collection.

Certainly using GC.Collect(), GC.WaitForPendingFinalizers etc would be
better than just using the "true" bit.
When attaching the debugger (I'm preferring windbg together with sos.dll),
you can dump the EE heap and you'll see the two string objects being
allocated. But you also see that the value reported by GetTotalMemory is not
the same as the memory currently taken up by the GC heap (G0, G1 and G2),
both shown by the debugger and the CLR memory perfcounters, sometimes the
difference is only a few hundred bytes, but sometimes it's a lot more.
Another point is that GetTotalMemory doesn't report the Large object heap
size, which can be considerably larger than the Gen0, 1, 2 heap.

If that last part is right then it's indeed an eye-opener. But does the
EE heap also show the resources that have been loaded by formatting the
integer in the Console.WriteLine?
 
Jon Skeet said:
Interesting - I thought one of the differences between Java and .NET
was that running GC.Collect in .NET definitely *did* trigger a garbage
collection.

Certainly using GC.Collect(), GC.WaitForPendingFinalizers etc would be
better than just using the "true" bit.

I did try using GC.Collect() and it didn't make any difference. I didn't
add the second step.

I also had noted that the documentation is conspicuously incomplete in
describing the exact effect of the true parameter.

I'm still curious why the memory is apparently never deallocated even when
the variable is set to null. Or, in the case of the actual application
where I was using it, when the form's Close() is called. I mean in that
case even a minute later. I thought the whole point of being a good little
Disposing boy was to free up memory resources, but if GetTotalMemory is
telling us anything at all it's not nearly so simple.

Would the following be correct?

Calling Dispose() does not guarantee that a resource will be freed, it just
guarantees that the GC will be able to free it if necessary. The GC may
very well only free it if in fact it does believe it necessary. Maybe it
makes sense that the memory would remain allocated as long as it wasn't
needed as it would help performance if the item was needed again later.
 
Daniel Billingsley said:
I did try using GC.Collect() and it didn't make any difference. I didn't
add the second step.

I also had noted that the documentation is conspicuously incomplete in
describing the exact effect of the true parameter.

I'm still curious why the memory is apparently never deallocated even when
the variable is set to null. Or, in the case of the actual application
where I was using it, when the form's Close() is called. I mean in that
case even a minute later. I thought the whole point of being a good little
Disposing boy was to free up memory resources, but if GetTotalMemory is
telling us anything at all it's not nearly so simple.

Would the following be correct?

Calling Dispose() does not guarantee that a resource will be freed, it just
guarantees that the GC will be able to free it if necessary. The GC may
very well only free it if in fact it does believe it necessary. Maybe it
makes sense that the memory would remain allocated as long as it wasn't
needed as it would help performance if the item was needed again later.

Dispose doesn't really say anything about GC - it isn't usually used
for managed resources. It's more usually used for unmanaged resources
or references to unmanaged resources.

I suspect the more likely culprit for the "strange" results is that
you're actually allowing the garbage collector to collect before you
think anyway. For instance:

MemoryHog hog = new MemoryHog();
howMuchDuring = GC.GetTotalMemory(true);
Console.WriteLine("Memory with hog:\t{0}", howMuchDuring);
hog = null;

There's nothing to stop the GC from collecting the new object in the
second line - the local variable isn't read at any time after its
initial assignment.
 
Jon Skeet said:
I wasn't talking about that (although looking back I can see it was
confusing) - I was talking about the jump of about 8K.

Oh I see the 8k jump.
Well , to have a clear picture about what objects are allocated and where
they reside, I included a number of breakpoints [1, 2, 3] in OP's code as
follows:

....
long memoryAfterCall = 0L;
DebugBreak(); // [1]
howMuchBefore = GC.GetTotalMemory(true);
Console.WriteLine("Memory Before:\t\t{0}", howMuchBefore);
DebugBreak(); // [2]
MemoryHog hog = new MemoryHog();
howMuchDuring = GC.GetTotalMemory(true);
Console.WriteLine("Memory with hog:\t{0}", howMuchDuring);
DebugBreak(); // [3]

.....
The results of both debugger and program output is as follows:

Snap #objects G0 G1 G2 Total *
GetTotalMemory
1 113 12224 12 12 12248
12700
2 200 8204 12 7204 15420 -
3 201 8204 12 10976 19192 20000

* this is the total of G0,1 an G2 heap while GetTotalMemory shows the
output from the two Console.WriteLine's
Notice the little difference between both, this is quite normal as
GetTotalMemory also involves some heap allocation.
The difference between [1]and [2] is due to the loading of a large number of
objects (87) used to format the string used by the previous WriteLine.
Objects loaded are types like System.Globalization.NumberFormatInfo,
S.G.CultureInfo, an Hastable and ArrayList some Strings, Char and Byte
arrays, to name the largest.
When you consider the heap generations you'll notice:
- a promotion from G0 to G2, that means two GC run's occured
- 7204 bytes survived the collections and are now in G2
- A number of new objects (87) are now in G0 occupying 3183 Bytes from the
8204 bytes reserved for G0, and this is realy important, as we see later.
Let's step to snapshot [3] now, here we see a survival of a number of
objects (most of the 87) accounting for (10976-7204) bytes and again an
increase of the total heap size from 15420 up to 19192 (20000 shown by
GetTotalMemory).
So here we see the jump of 8Kb, but what's important as I said before, is to
look at how many objects live in G0 by now, he! there are only 5 of them
accounting for 256 bytes. What we see is that the GC maintains a lower limit
for the G0 heap, here 8204 bytes (V2.0 takes a larger number). So while we
effectively see a jump of 8kB, for the heap size, only a part of the
increase is due to real object allocations.
Another point is that when reaching [3] the G2 heap is still fragmented,
that is it contains free blocks, that means that G2 is not compacted when
the GC runs (this is an optimization as there is no real need for it under
normal situations), only G0 is. As far as I see G2 is only compacted under
memory pressure, and this is what I meant by saying "GC.Collect() does not
promise a GC collection (at lest not for G1 and G2), but you can read more
about it in Rico Marianni's Blog at :
http://blogs.msdn.com/ricom/archive/2003/12/02/40780.aspx.

Hope this helps.
Willy.
 
As far as I see G2 is only compacted under
memory pressure, and this is what I meant by saying "GC.Collect() does not
promise a GC collection (at lest not for G1 and G2), but you can read more
about it in Rico Marianni's Blog at :
http://blogs.msdn.com/ricom/archive/2003/12/02/40780.aspx.

Either the documentation is wrong or Rico is though. The MSDN docs for
GC.Collect() specifically say:

"Forces garbage collection of all generations."

Now that doesn't necessarily mean that all potential garbage will be
cleaned up - finalizers etc cause problems here - but it's not like
GC.Collect() could decide to only collect gen0, without breaking the
documented contract.

I'll add to the blog and see if Rico replies...
 
Jon Skeet said:
Either the documentation is wrong or Rico is though. The MSDN docs for
GC.Collect() specifically say:

"Forces garbage collection of all generations."

Now that doesn't necessarily mean that all potential garbage will be
cleaned up - finalizers etc cause problems here - but it's not like
GC.Collect() could decide to only collect gen0, without breaking the
documented contract.

I'll add to the blog and see if Rico replies...

Jon,

I had some problems with too when I read this the first time, but IMO Rico
is correct, like I said in my previous post, G2 is not (always) compacted
when a GC.Collect() is called , a (limited) number of blocks are kept (free)
no objects are not on the finalizer queue and no pinned objects are
surrounding the free areas. Note here I'm talking about collecting, not
marking.
I remember reading somewhere that this is done to save cycles during
collection and the GC keeps a list of free slots (their location and size)
and will move objects that fit to these slots when promoting G1 objects to
G2. This is something you'll notice when running the debugger.

Willy.
 
Just found this, http://blogs.msdn.com/yunjin/archive/2004/01/27/63642.aspx
<quote from "Free space in GC heap" >
Sometimes GC could choose not to compact part of the heap when it's not
necessary. Since relocating all objects could be expensive, GC might avoid
doing so under some conditions. In that case, GC will keep a list of free
space in heap for future compaction. This won't cause heap fragmentation
because GC has full control over the free space. GC could fill up those
blocks anytime later when necessary.
</quote>

Willy.
 
Willy Denoyette said:
I had some problems with too when I read this the first time, but IMO Rico
is correct, like I said in my previous post, G2 is not (always) compacted
when a GC.Collect() is called , a (limited) number of blocks are kept (free)
no objects are not on the finalizer queue and no pinned objects are
surrounding the free areas. Note here I'm talking about collecting, not
marking.
I remember reading somewhere that this is done to save cycles during
collection and the GC keeps a list of free slots (their location and size)
and will move objects that fit to these slots when promoting G1 objects to
G2. This is something you'll notice when running the debugger.

I have no problems with G2 not being compacted, but to some extent the
objects have effectively been collected, haven't they? Maybe not - I'm
confused at this point.

Either way though, I still believe one of Rico or MSDN must be wrong:

"Your first problem is that GC.Collect() doesn't promise a Gen1
collect."

and

[System.GC()] "Forces garbage collection of all generations."

are mutually contradictory IMO.
 
Just gone back a bit in the thread and seen this:

Willy Denoyette said:
*** This is not what I see when running the debugger (windbg and .load
sos.dll)

Now, I've previously used cordbg more than windbg. I can't find
anything in the SOS documentation for cordbg - do you know if there's
any way of using it within there?
 
Jon Skeet said:
I have no problems with G2 not being compacted, but to some extent the
objects have effectively been collected, haven't they? Maybe not - I'm
confused at this point.
*** Objects are effectively been collected ( during the mark phase), but G2
and G1 are not always compacted.
Either way though, I still believe one of Rico or MSDN must be wrong:

"Your first problem is that GC.Collect() doesn't promise a Gen1
collect."

and

[System.GC()] "Forces garbage collection of all generations."

are mutually contradictory IMO.

Jon,
Indeed, but IMO the term "garbage collection" is too general, does each
collection includes a compactation of all generations?
IMO it's not , and I guess this is exactly Rico and some others point. If
this is the case, all we could say is that the docs could be more explicit.

Willy.


 
Jon Skeet said:
Just gone back a bit in the thread and seen this:



Now, I've previously used cordbg more than windbg. I can't find
anything in the SOS documentation for cordbg - do you know if there's
any way of using it within there?

I don't think you can load debugger extensions like sos.dll in cordbg , but
I could be wrong.
If you download the latest windbg version, you'll get also the latest and
greatest sos.dll which can be used with version 1.0 and v1.1. Seems like
VS2003 can also load sos.dll, though I never used it. The outdated
documentation of SOS.DLL is included with the sdk "Tool developers guide"
samples, but the online help (!help debugger command) is very detailed and
all you need.
Note: you need to load the sos.dll extension after mscorlib (the CLR) has
been loaded.
There is also a version available through PPS see
http://blogs.msdn.com/mvstanton/archive/2004/04/05/108023.aspx, It looks
like it is the same as the one included with windbg, I've used both without
any problem, but I stick with the windbg version for now.

Willy.
 
Back
Top