Memory management is an important concern for a developer, and it is a very big topic. This chapter will touch on the important points in a simplified manner to help you understand memory management in programming.
A machine becomes slow over time.
A specific operation in an application takes longer to execute.
The worst case is that an application/system can crash.
But before we discuss memory leaks, it’ll be helpful if you can clarify your understanding of memory allocations and deallocations. A novice C# programmer often believes that the garbage collector (GC) can take care of memory management in every possible scenario. This is not true, and unfortunately, it is a common mistake. This chapter will cover this and also help you understand the cause of memory leaks, which we’ll discuss in Chapter 13.
Overview
In a programming language like C++, you deallocate the memory once the intended job is completed to avoid memory leaks. But .NET always tries to make your programming life easier. It has a garbage collector that clears the objects that do not have any use after a particular point. In programming, they are called dirty objects or unreferenced objects.
How does the garbage collector clear the dirty objects? In C#, the heap memory is managed. This means the CLR takes care of this responsibility. In the managed code, CLR’s garbage collector does this job for you, and you do not have to deallocate the managed memory. It removes the unused stuff on the heap and recollects the memory for further use. The garbage collector program runs in the background as a low-priority thread. It keeps track of the dirty objects for you. The .NET runtime on regular intervals can invoke this program to remove unreferenced or dirty objects from memory. At a given point in time, if an object has no reference, the garbage collector marks this object and reclaims the memory occupied by the object, assuming that it is no longer needed.
In theory, when a local variable references an object, it’s ready for garbage collection at the earliest point at which it is no longer needed. But if you disable the optimization in debug mode, the lifetime of the object extends to the end of the block. But garbage collection may not reclaim the memory immediately. There are various factors that affect this, such as available memory and the time since the last collection. This means an orphaned object can be released immediately, or there may be some delay that may vary.
However, there is a catch. Some objects require special code to release resources. Here are some common examples: you open a file, perform some reading or writing, but forget to close the file. A similar kind of attention is needed when you deal with unmanaged objects, locking mechanisms, the operating system (OS) handles in your programs, and so forth. Programmers explicitly need to release those resources. These are the cases where you need to put in special attention to prevent memory leaks. In general, when programmers themselves clean up (or release) the memory, you say that they dispose of the objects, but when CLR automatically releases the resources, you say that the garbage collector performs its job. The garbage collector uses the finalizers (or, destructors) of the class instance to perform the final cleanup. We’ll discuss them shortly.
Programmers can release resources by explicitly disposing of the objects, or the CLR automatically releases resources through a garbage collection mechanism. We often refer to them as the disposing and finalizing techniques, respectively.
Stack Memory vs. Heap Memory
To execute a program, the operating system gives you a pile of memory. The program splits this into several portions for various uses. There are two major parts; one is stack memory, and the other one is heap memory.
These two kinds of memories store different kinds of data.
For example, the stack is used for local variables and to keep track of the current state of the program. What are local variables? They are the variables that are declared in a method.
By contrast, the instance variables for reference types are stored on the heap. The static variables are stored on the heap too.
For the reference type variable, the variable itself will be stored on the stack, but the contents are stored on the heap.
For example, when you see the line A obA=new A();, you understand that the reference variable obA is stored on the stack, but the object/content is stored on the heap.
The stack follows the last in, first out (LIFO) mechanism. It works like a stack of frames, where one frame is placed on top of another frame. You can also think of it as a set of boxes, where one box is placed on top of another box. All local variables of a particular method can go into a single frame. At a particular moment, you can access the top frame of the stack, but you cannot access the lower frames.
Once the top frame is removed from a stack and discarded, the immediate lower frame can be accessed as it becomes the top frame. The process can continue until the stack is empty. But, in between, the stack size can further increase or decrease during the program execution.
But the most important point is that the stack-allocated memory blocks are discarded when a method finishes its execution.
Assume that the control entered into the method called SomeMethod. The top three lines of this method have been executed, but it does not reach the end of the method body. You can see that the stack is growing in this stage in the first part of this diagram.
The next parts of the diagram show that the cleaning up of the stack is in progress. It is true that when the control leaves the method body, all the variables a, b, and c are deleted. But following the LIFO structure, I have shown you the intermediate deletions one by one.
In short, for a stack allocation, you know that once you return from a method, the allocated frame is discarded, and you can use the space immediately.
On the other hand, heap memory is used for object/reference types. Here the tracking of a program state is not the concern. Instead, it focuses on storing the data. A program can easily allocate some space in the heap and start using the space to store the information.
In Visual Studio, in debug mode, you can see the call stack and analyze the stack trace. In addition, once you learn multithreaded programming, you’ll see that each thread can have its own stack, but they share the same heap space among them.
In this case, you need to remember the allocation, and before you reuse the space, someone needs to clear the old allocation. But what happens if you forget to delete the space? Or what happens if you use an already created reference to point to a different object in the heap, but later you make it null? These kinds of allocated memory spaces will keep increasing (which becomes garbage), and you’ll see the impact of the memory leaks. This is the point where the garbage collector (GC) in C# helps you. Periodically, the GC checks the status and tries to help you by freeing unused spaces.
Each time you create an object, the CLR allocates memory in the managed heap. It can keep allocating the memory until the address space in the managed heap is available. The GC has an optimizing engine to determine when to reclaim unused memories.
Q&A Session
12.1 What is a managed heap?
Answer:
The managed code is the code that is managed by a runtime, e.g., the Common Language Runtime (CLR). This CLR provides many services, and automatic memory management is one of them. When you initialize a process, the runtime reserves a contiguous address space for it. This reserved space is called the managed heap.
This managed heap has a pointer that points to the address where the next object will be allocated. You can surely guess that the allocation process for the first object starts with the managed heap’s base address. The allocation for the next object will occur to the address that immediately follows the previous object. The garbage collector repeats the process until the address space is available for use.
12.2 I have a solution in my mind. I can allocate memory on the heap, and once my job is done, I’ll delete it immediately. This way I can prevent the garbage from growing. Is my understanding correct?
Answer:
Yes, the proposed solution can work and help you prevent leaks. But this is not that easy. There are situations where the objects need to stay alive for a while. Consider an example: using an advanced printer, you simultaneously send multiple emails and faxes to different recipients. At the same time, you start printing some large documents. It is very unlikely that all the recipients receive the data at the same time or a document with a big number of pages is printed instantly. So, an immediate deletion is not a clever solution in these scenarios.
12.3 Let us assume there is a class, called Test. I understand that for the line Test testObj=new Test(); the space for the object will be allocated in the heap memory. But what about the reference variable?
Answer:
12.4 In many discussions, people say that the struct is on a heap. But my understanding is that the content of a struct should be in the stack. Am I missing something?
Answer:
This is interesting. You have to understand the context. For example, instance variables for a value type are stored in the same context as the variable that declares the value type. So, the struct variable that is declared within a method will always be on the stack, whereas a struct variable that is an instance field of a class will be stored on the heap.
12.5 Sometimes I wonder about these references. Are they similar to the pointers in C/C++?
Answer:
First, it frees up the garbage’s/unused spaces for you so that you can reuse the space.
Second, it can apply the compaction technique, which means it can remove all allocated space to one side of the memory and all the free space to the other side of the memory. It results in contiguous free space that helps you to allocate a large block of memory.
The first point is important and covered in this chapter. The second point is also important because the heap may contain scattered objects (see Figure 12-2). In many situations, you may need to have a big chunk of a contiguous memory that may not available at a particular time, though there is enough space in the heap. In these scenarios, the compaction helps to get enough space. These references are maintained by the garbage collector, and when this kind of shuffling is done, you are not aware of it.
Actually, you have two different types of heap; one is a large object heap (LOH), and another one is a small object heap (SOH). The objects of sizes 85,000 bytes and above are placed in a large object heap. Usually, these are array objects. To make the discussion easy, I simply use the word heap, instead of categorizing it. The SOH is used for three different generations, which you’ll read in the following section.
Now you can easily allocate five contiguous blocks of memory in the heap. What is the benefit? A new object can be allocated at the end of the contiguous allocation. In programming, you can do this by adding a value to the heap pointer. As a result, you do not need to iterate through a linked list of addresses to find spaces for the new object. In this way, a managed heap is different from an unmanaged heap.
What do I mean by an unmanaged heap? Consider a case when you manage the heap and you are responsible for allocating and deallocating spaces. In simple words, when an object is allocated in a managed heap, instead of getting the actual pointer, you get a “handle” to represent an indirection to a memory address. This is helpful because the actual memory location can be changed after the GC’s compaction. But for a native code (say when you use the malloc() function in the C/C++ code to allocate a space), you get pointers, not handles.
After the compaction, objects generally stay in the same area, so accessing them also becomes easier and faster (because page swapping happens less). The compaction technique is costly, but the overall gain can be greater. The Microsoft documentation says the following:
Memory is compacted only if a collection discovers a significant number of unreachable objects. If all the objects in the managed heap survive a collection, then there is no need for memory compaction.
To improve performance, the runtime allocates memory for large objects in a separate heap. The garbage collector automatically releases the memory for large objects. However, to avoid moving large objects in memory, this memory is usually not compacted.
If you are interested in more details, I encourage you to read the following .NET blog article: https://devblogs.microsoft.com/dotnet/large-object-heap-uncovered-from-an-old-msdn-article/
Now I return to the original question. It is important how you interpret the word pointer. In C/C++, using a pointer, you point to an address that is nothing but a number slot in the memory. But the problem is, if you point to an invalid address, you encounter surprises! So, a pointer in an “unsafe” context is tricky.
On the other hand, a reference in C# points to a valid address in the managed heap, or it is null. This is the kind of assurance you receive from C#. In addition, references are useful because when the data moves around the memory, you can access that data using these references.
The Garbage Collector in Action
A generational garbage collector (GC) is used to collect short-lived objects more frequently than longer-lived objects. We have three generations here: 0, 1, and 2. Short-lived objects (for example, temporary variables) are stored in generation 0. The longer-lived objects are pushed into the higher generations—either 1 or 2. The garbage collector works more frequently in the lower generations than in the higher ones.
Once you create an object, it resides in generation 0. When generation 0 is filled up, the garbage collector is invoked. The objects that survive generation 0 garbage collection are transferred to the next higher generation—generation 1. The objects that survive garbage collection in generation 1 enter the highest generation—generation 2. The objects that survive the generation 2 garbage collection stay in the same generation.
Sometimes you create a very large object. This kind of object directly goes to the large object heap (LOH). It is often referred to as generation 3. Generation 3 is a physical generation that’s logically collected as part of generation 2. In this context, I encourage you to read the online Microsoft documentation at https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/large-object-heap that says the following:
In the future, .NET may decide to compact the LOH automatically. This means that, if you allocate large objects and want to make sure that they don't move, you should still pin them.
I suggest you use the 3-3 rule to remember the different phases of a garbage collection and the different ways to invoke the GC.
Different Phases of Garbage Collection
Phase 1: This is the marking phase, in which the live objects are marked or identified.
Phase 2: This is the relocating phase, in which it updates the references of the objects that will be compacted in phase 3.
Phase 3: This is the compacting phase, which reclaims memory from dead (or unreferenced) objects, and the compaction operation is performed on the live objects. It moves the live objects (that survived until this point) to the older end of the segment.
Different Cases of Invoking the Garbage Collector
Case 1: You have low memory.
Case 2: The allocated objects (in a managed heap) surpass a defined threshold limit.
Case 3: You call the System.GC() method. There are many overloaded versions of GC.Collect(). The GC is a static class and is defined in the System namespace.
GC.Collect(Int32) forces an immediate garbage collection from generation 0 through a specified generation. This means that when you call Gc.Collect(0), the garbage collection will happen at generation 0. If you call Gc.Collect(1), the garbage collection will happen both at generation 0 and at generation 1, and so forth.
The CollectionCount method returns the number of times garbage collection has occurred for the specified generation of objects.
After I invoke the GC, I invoke the WaitForPendingFinalizers() method. This method definition says that this method suspends the current thread until the thread that is processing the queue of finalizers has emptied that queue.
Starting from C# 9.0, you can use a new syntax for a null check. This is shown here. So, the following block of code does not create any compile-time error:
In this program, you’ll see the following line:
At the time of this writing, there are five overloaded methods for Collect():
generation: This is the number of the oldest generation to be garbage collected.
mode: This is an enumeration value that specifies whether the garbage collection is forced (System.GCCollectionMode.Default or System.GCCollectionMode.Forced) or optimized (System.GCCollectionMode.Optimized).
blocking: You set this to true to perform a blocking garbage collection; set it to false to perform a background garbage collection where possible.
compacting: You set it to true to compact the small object heap; set it to false to sweep only.
To show you different generations of garbage collection
To demonstrate that an object can enter from one generation to the next generation if the garbage is not collected
Demonstration 1
Output
It is possible to see the different counters if additional garbage collection happens in between these calls. In this possible output, you can see that the sample instance was not collected in any of the GC invocation calls. So, it survived and gradually moved to generation 2.
The total memory allocations in this output seem to be logical because, after each GC invocation, you see that the total allocations are reducing. This may not happen in every possible output because you may not allow the GC to complete its job before you show the memory status. So, to get a more consistent result, I also introduced a sleep time, after I invoke the GC, and I also invoke WaitForPendingFinalizers(). This allows the GC to have more time to complete its job. Yes, it causes some performance penalties, but in my system, it produces a more consistent result. Based on your system configuration, you may need to vary the sleep time accordingly.
Notice that I have used the following overloaded version: GC.Collect(i, GCCollectionMode.Forced, false, true). You understand that I make the third parameter false to perform a background garbage collection if possible.
Another important point to note: before a garbage collection starts, all the managed threads are suspended, except the thread that invokes the GC. So, once the GC finishes its task, other threads can start allocating spaces again. If you know the concept of multithreading, understanding the previous line is easy for you.
One last point: these generations are a logical view of the GC heap. Physically these objects reside on the managed heap, which is a chunk of memory. The GC reserves this from the OS via calling VirtualAlloc. We are not going to discuss it in that detail.
Analysis
There are different generations of the GC.
You can see that once you called GC.Collect(2), the other generations are also called. Notice that the counters have increased. Similarly, when you called GC.Collect(1), generation 1 and generation 0 both are called.
You can also see the object that I created was originally placed in generation 0.
Q&A Session
12.6 Can you give examples of short-lived and long-lived objects?
Answer:
Temporary variables are examples of short-lived objects. By contrast, you can consider some objects in a server applications that use some static data throughout a process execution as typical long-lived objects.
12.7 What is the key benefit of having a generation-based garbage collection?
Answer:
Microsoft believes that most of the time we can reclaim enough memory when garbage collection occurs at generation 0. This means we can save time by not working on other generations. Freeing memory from one particular part of memory is faster than inspecting the whole memory area and releasing spaces. It saves time and increases efficiency.
12.8 Why is generation 2 garbage collection called a full garbage collection?
Answer:
Collecting a generation means collecting objects from the current generation and the younger generation. For example, generation 1 collection means collecting objects from generation 1 and generation 0. Similarly, generation 2 garbage collection means collecting objects from generation2, generation 1, and generation 0; that is the full collection from the managed heap.
Disposing of an Object
A programmer often needs to explicitly release some resources. Some common examples include when you work with events, locking mechanisms, file handling operations, or unmanaged objects. There are also cases when you know that you have used a very large block of memory that is not necessary after a certain point of execution. These are some examples where you want to release the memory or resources to improve the performance of your system.
Author’s Note Some common examples of unmanaged objects are seen when you wrap OS resources such as database connections or network connections. We call them unmanaged because the CLR cannot manage them. Why? These objects are created outside the .NET runtime.
From the previous description, you can see that you can release unmanaged resources using this method.
Finalize vs. Dispose
Author’s Note You can define finalizers for a class, but not for a struct in C#.
Compile this code segment and then examine the IL code for these classes.
You can use an IL disassembler to see the IL code. I often use ildasm.exe, which is automatically available in Visual Studio. To use this tool, you can follow these steps: open a developer’s command prompt for Visual Studio, type ildasm (you can see a new window will pop up), and drag a .dll to this window. Now expand/click the code elements. You can learn more about this tool at https://docs.microsoft.com/en-us/dotnet/framework/tools/ildasm-exe-il-disassembler.
You can see that a finalizer invocation implicitly translated to the following:
The Child class finalizer calls the Parent class finalizer, which in turn calls the Object class finalizer. This means that this method is called recursively for all instances in an inheritance chain, and the direction of the call is from the most specific to the least specific. In short, you understand that the finalizer of an object implicitly calls the Finalize method on the base class of the object.
Microsoft recommends not using empty finalizers. This is because, for a finalizer, an entry is made in the Finalize queue. When a finalizer is called, the GC starts processing this queue. So, if the finalizer is empty, you introduce an unnecessary performance penalty for it.
The static class GC is defined in the System namespace.
This class has a method, called SuppressFinalize(). If you pass the current object in the GC.SuppressFinalize() method, the finalize method of the current object is not invoked.
I want you to show a destructor invocation in .NET 7. Actually, the result is the same for the .NET core platform (for example in .NET 5, .NET 6, or .NET 7). In the .NET Framework, it is easy. Once you exit the program, it is called automatically. But a different logic is implemented in the .NET core platform. This is why I introduce another class, called A, and initialize a Sample object inside the constructor. I also do not use any Sample reference inside Main() before I invoke the GC. This helps the GC to figure out that the Sample object is no longer needed and it can collect the garbage. A similar logic can be implemented to mimic the behavior in the .NET core platform.
Run the following program now to see the output. Then go through the analysis. You need to understand an important design change in the .NET platform.
Ideally, unless it is required, you do not want to write code in the finalizer. Instead, you may prefer to use the Dispose() method to release unmanaged resources and avoid memory leaks.
Demonstration 2
Here is the complete demonstration. I ran it in .NET 7, but this time I did not use top-level statements because I wanted to compare the same program in an older edition (.NET Framework 4.7.2) and the latest editions (such as .NET 5+). Top-level statements have been available since .NET 6.
At the time of this writing, .NET is a common term for .NET Standard and all the .NET implementations and workloads. It is recommended that you use it for all the upcoming development, i.e., for .NET Core and .NET 5, and later versions. It is a cross-platform, high-performance, open-source implementation of .NET. By contrast, .NET Framework is designed only for Windows.Sometimes you see a plus sign after a version number. This plus sign after the version number means “and later versions.” For example, .NET 5+ should be interpreted as .NET 5 and the later/subsequent versions.
Output
Analysis
The Sample class object’s Dispose() and finalizer method are both called.
The statement GC.SuppressFinalize(this); is commented in the Dispose() method of the Sample class. This is why the destructor of the Sample instance was called too. If you enable/uncomment this statement, the finalizer of the Sample instance will not be called.
The object’s finalizer method has not been called yet.
I raised a ticket at Microsoft regarding the difference in output when using the .NET Framework and .NET Core. If you are interested to know about this discussion, you can refer to https://github.com/dotnet/docs/issues/24440. Microsoft believes that it is an expected behavior in .NET Core/.NET 5/.NET 6 applications. Different opinions exist as well.
The programmer has no control over when the finalizer is called; the garbage collector decides when to call it. The garbage collector checks for objects that are no longer being used by the application. If it considers an object eligible for finalization, it calls the finalizer (if any) and reclaims the memory used to store the object.
In .NET Framework applications (but not in .NET Core applications), finalizers are also called when the program exits. The explanation I got for this is that finalizers could produce a deadlock that prevented a program from exiting. Therefore, the code to run finalizers on exit was relaxed. The following link describes the issue in depth: https://github.com/dotnet/docs/issues/17463.
Whether or not finalizers are run as part of application termination is specific to each implementation of .NET. When an application terminates, .NET Framework makes every reasonable effort to call finalizers for objects that haven't yet been garbage collected, unless such cleanup has been suppressed (by a call to the library method GC.SuppressFinalize, for example). .NET 5 (including .NET Core) and later versions don't call finalizers as part of application termination.
Before you move on to the topic of memory leak in detail, let’s review our understanding in the following Q& A session.
Q&A Session
12.9 How can we call destructors (or finalizers)?
Answer:
You cannot call a destructor. The garbage collector takes care of that responsibility.
12.10 How can you free up a resource?
Answer:
The disposing parameter is false when called from a finalizer. But it is true when you invoke it from the Dispose method. In other words, it is true when it is deterministically called and false when it is nondeterministically called. This follows Microsoft’s programming guidelines.
In addition, you must remember that in C# programming, you do not override the Object.Finalize method to implement finalization; instead, you provide a finalizer.
Microsoft suggests that a Dispose method should be callable multiple times without throwing an exception.
12.11 When does the garbage collector call the Finalize() method?
Answer:
We never know. It may call instantly when an object is found with no references or later when the CLR needs to reclaim some memory. But you can force the garbage collector to run at a given point by calling GC.Collect(), which has many overloaded versions. You have seen two different usages already when I used different overloaded versions of GC.Collect() in demonstration 1 and demonstration 2.
12.12 Finalizers are called automatically when the program ends in the .NET Framework. But this is not the case in .NET Core or .NET 5 or NET 6. What is the reason behind this?
Answer:
In response to my ticket at https://github.com/dotnet/docs/issues/24440, the answer is summarized as follows: finalizers could produce a deadlock that could prevent a program from exiting. Therefore, the code to run finalizers on exit was relaxed. Microsoft believes that it is expected behavior in .NET Core and .NET 5+ applications.
12.13 When should we invoke the GC.Collect()?
Answer:
I already mentioned that invoking the GC is generally a costly operation. But in some special scenarios, if you can invoke GC, you’ll gain some significant benefits. Such an example may arise after you dereference a large number of objects in the code.
Another common example is when you try to find memory leaks through some common operations, such as executing a test repeatedly to find leaks in the system. After each of these operations, you may try to gather different counters to analyze memory growth and to get the correct counters. I’ll discuss memory leak analysis shortly.
When we see the use of the IDisposable interface, we assume that the programmer will call the Dispose() method correctly. Some experts suggest you have a destructor also as a precautionary measure. It can help in a sense when a call to the Dispose() is missed. Remember Microsoft’s philosophy (see https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose): to help ensure that resources are always cleaned up appropriately, the Dispose method should be idempotent, such that it is callable multiple times without throwing an exception. Furthermore, subsequent invocations of Dispose should do nothing.
12.14 Why did you use using statements in the previous demonstration (demonstration 2)?
Answer:
12.15 Can I directly allocate spaces in generation 1 or generation 2?
Answer:
No. A user code can allocate spaces in generation 0 or LOH only. It is the GC’s responsibility to promote an object from generation 0 to generation 1 (or, generation 2).
12.16 Can I overload a finalizer?
Answer:
What does this mean? It does not accept any parameter. As a result, a finalizer cannot be overloaded. This implies that a class can have at most one finalizer.
Summary
Memory management is an important topic. This chapter gave you a quick overview of this topic, but still, it is a big chapter! We began with a quick discussion about the importance of memory leaks. Then you saw how the memory is managed in C#.
I started the discussion about two different types of memory in C#, such as stack memory and heap memory. Then I discussed the garbage collector in C#. You saw different phases of garbage collection and learned different cases in which a GC can start its operation.
Then you learned about disposing of an object programmatically. You saw a discussion on the Dispose method versus the finalizer method. And in this case, you saw how the .NET Framework shows a different behavior than .NET Core or .NET 5+. Finally, you learned about the Dispose pattern that is often used in this context.
How is a heap memory different than a stack memory?
What is garbage collection? How does it work in C#?
What are the different GC generations?
What are the different ways to invoke the garbage collector?
How can we force GC to invoke?
How does the disposing differ from finalizing in C#?
How can you implement a dispose pattern in your program?