Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework |
Jeffrey Richter |
Level of Difficulty 1 2 3 |
|
SUMMARY Garbage collection in the Microsoft .NET common language runtime environment completely absolves the developer from tracking memory usage and knowing when to free memory. However, you'll want to understand how it works. Part 1 of this two-part article on .NET garbage collection explains how resources are allocated and managed, then gives a detailed step-by-step description of how the garbage collection algorithm works. Also discussed are the way resources can clean up properly when the garbage collector decides to free a resource's memory and how to force an object to clean up when it is Implementing proper resource management for your applications can be a difficult, tedious task. It can distract your concentration from the real problems that you're trying to solve. Wouldn't it be wonderful if some mechanism existed that simplified the mind-numbing task of memory management for the developer? Fortunately, in .NET there is: garbage collection (GC). Let's back up a minute. Every program uses resources of one sort or another memory buffers, screen space, network connections, database resources, and so on. In fact, in an object-oriented environment, every type identifies some resource available for your program's use. To use any of these resources requires that memory be allocated to represent the type. The steps required to access a resource are as follows: 1. Allocate memory for the type that represents the resource. 2. Initialize the memory to set the initial state of the resource and to make the resource usable. 3. Use the resource by accessing the instance members of the type (repeat as necessary). 4. Tear down the state of the resource to clean up. 5. Free the memory. This seemingly simple paradigm has been one of the major sources of programming errors. After all, how many times have you forgotten to free memory when it is no longer needed or attempted to use memory after you've already freed it? Resource Allocation The Microsoft® .NET common language runtime requires that all resources be allocated from the managed heap. This is similar to a C-runtime heap except that you never free objects from the managed heap objects are automatically freed when they are no longer needed by the application. This, of course, raises the question: how does the managed heap know when an object is no longer in use by the application? I will address this question shortly.
At this point, NextObjPtr is incremented past the object so that it points to where the next object will be placed in the heap. Figure 1 shows a managed heap consisting of three objects: A, B, and C. The next object to be allocated will be placed where NextObjPtr points (immediately after object C). In reality, a collection occurs when generation 0 is completely full. Briefly, a generation is a mechanism implemented by the garbage collector in order to improve performance. The idea is that newly created objects are part of a young generation, and objects created early in the application's lifecycle are in an old generation. Separating objects into generations can allow the garbage collector to collect specific generations instead of collecting all objects in the managed heap. Generations will be discussed in more detail in Part 2 of this article. The Garbage Collection Algorithm The garbage collector checks to see if there are any objects in the heap that are no longer being used by the application. If such objects exist, then the memory used by these objects can be reclaimed. (If no more memory is available for the heap, then the new operator throws an OutOfMemoryException.) How does the garbage collector know if the application is using an object or not? As you might imagine, this isn't a simple question to answer.
Once this part of the graph is complete, the garbage collector checks the next root and walks the objects again. As the garbage collector walks from object to object, if it attempts to add an object to the graph that it previously added, then the garbage collector can stop walking down that path. This serves two purposes. First, it helps performance significantly since it doesn't walk through a set of objects more than once. Second, it prevents infinite loops should you have any circular linked lists of objects.
After all the garbage has been identified, all the non-garbage has been compacted, and all the non-garbage pointers have been fixed-up, the NextObjPtr is positioned just after the last non-garbage object. At this point, the new operation is tried again and the resource requested by the application is successfully created. class Application {
public static int Main(String[] args) {
// ArrayList object created in heap, myArray is now a root
ArrayList myArray = new ArrayList();
// Create 10000 objects in the heap
for (int x = 0; x < 10000; x++) {
myArray.Add(new Object()); // Object object created in heap
}
// Right now, myArray is a root (on the thread's stack). So,
// myArray is reachable and the 10000 objects it points to are also
// reachable.
Console.WriteLine(a.Length);
// After the last reference to myArray in the code, myArray is not
// a root.
// Note that the method doesn't have to return, the JIT compiler
// knows
// to make myArray not a root after the last reference to it in the
// code.
// Since myArray is not a root, all 10001 objects are not reachable
// and are considered garbage. However, the objects are not
// collected until a GC is performed.
}
}
Figure 4 Allocating and Managing Resources If GC is so great, you might be wondering why it isn't in ANSI C++. The reason is that a garbage collector must be able to identify an application's roots and must also be able to find all object pointers. The problem with C++ is that it allows casting a pointer from one type to another, and there's no way to know what a pointer refers to. In the common language runtime, the managed heap always knows the actual type of an object, and the metadata information is used to determine which members of an object refer to other objects. Finalization The garbage collector offers an additional feature that you may want to take advantage of: finalization. Finalization allows a resource to gracefully clean up after itself when it is being collected. By using finalization, a resource representing a file or network connection is able to clean itself up properly when the garbage collector decides to free the resource's memory. public class BaseObj {
public BaseObj() {
}
protected override void Finalize() {
// Perform resource cleanup code here...
// Example: Close file/Close network connection
Console.WriteLine("In Finalize.");
}
}
Now you can create an instance of this object by calling: Some time in the future, the garbage collector will determine that this object is garbage. When that happens, the garbage collector will see that the type has a Finalize method and will call the method, causing "In Finalize" to appear in the console window and reclaiming the memory block used by this object. · Finalizable objects get promoted to older generations, which increases memory pressure and prevents the object's memory from being collected when the garbage collector determines the object is garbage. In addition, all objects referred to directly or indirectly by this object get promoted as well. Generations and promotions will be discussed in Part 2 of this article. · Finalizable objects take longer to allocate. · Forcing the garbage collector to execute a Finalize method can significantly hurt performance. Remember, each object is finalized. So if I have an array of 10,000 objects, each object must have its Finalize method called. · Finalizable objects may refer to other (non-finalizable) objects, prolonging their lifetime unnecessarily. In fact, you might want to consider breaking a type into two different types: a lightweight type with a Finalize method that doesn't refer to any other objects, and a separate type without a Finalize method that does refer to other objects. · You have no control over when the Finalize method will execute. The object may hold on to resources until the next time the garbage collector runs. · When an application terminates, some objects are still reachable and will not have their Finalize method called. This can happen if background threads are using the objects or if objects are created during application shutdown or AppDomain unloading. In addition, by default, Finalize methods are not called for unreachable objects when an application exits so that the application may terminate quickly. Of course, all operating system resources will be reclaimed, but any objects in the managed heap are not able to clean up gracefully. You can change this default behavior by calling the System.GC type's RequestFinalizeOnShutdown method. However, you should use this method with care since calling it means that your type is controlling a policy for the entire application. · The runtime doesn't make any guarantees as to the order in which Finalize methods are called. For example, let's say there is an object that contains a pointer to an inner object. The garbage collector has detected that both objects are garbage. Furthermore, say that the inner object's Finalize method gets called first. Now, the outer object's Finalize method is allowed to access the inner object and call methods on it, but the inner object has been finalized and the results may be unpredictable. For this reason, it is strongly recommended that Finalize methods not access any inner, member objects. If you determine that your type must implement a Finalize method, then make sure the code executes as quickly as possible. Avoid all actions that would block the Finalize method, including any thread synchronization operations. Also, if you let any exceptions escape the Finalize method, the system just assumes that the Finalize method returned and continues calling other objects' Finalize methods. public class BaseObj {
public BaseObj() {
}
protected override void Finalize() {
Console.WriteLine("In Finalize.");
base.Finalize(); // Call base type's Finalize
}
}
Note that you'll usually call the base type's Finalize method as the last statement in the
causes the compiler to generate this code: class MyObject { protected override void Finalize() { //… base.Finalize(); } } Note that this C# syntax looks identical to the C++ language's syntax for defining a destructor. But remember, C# doesn't support destructors. Don't let the identical syntax fool you. Finalization Internals On the surface, finalization seems pretty straightforward: you create an object and when the object is collected, the object's Finalize method is called. But there is more to finalization than this.
When a GC occurs, objects B, E, G, H, I, and J are determined to be garbage. The garbage collector scans the finalization queue looking for pointers to these objects. When a pointer is found, the pointer is removed from the finalization queue and appended to the freachable queue (pronounced "F-reachable"). The freachable queue is another internal data structure controlled by the garbage collector. Each pointer in the freachable queue identifies an object that is ready to have its Finalize method called.
There is a special runtime thread dedicated to calling Finalize methods. When the freachable queue is empty (which is usually the case), this thread sleeps. But when entries appear, this thread wakes, removes each entry from the queue, and calls each object's Finalize method. Because of this, you should not execute any code in a Finalize method that makes any assumption about the thread that's executing the code. For example, avoid accessing thread local storage in the Finalize method.
The next time the garbage collector is invoked, it sees that the finalized objects are truly garbage, since the application's roots don't point to it and the freachable queue no longer points to it. Now the memory for the object is simply reclaimed. The important thing to understand here is that two GCs are required to reclaim memory used by objects that require finalization. In reality, more than two collections may be necessary since the objects could get promoted to an older generation. Figure 7 shows what the managed heap looks like after the second GC. Resurrection The whole concept of finalization is fascinating. However, there is more to it than what I've described so far. You'll notice in the previous section that when an application is no longer accessing a live object, the garbage collector considers the object to be dead. However, if the object requires finalization, the object is considered live again until it is actually finalized, and then it is permanently dead. In other words, an object requiring finalization dies, lives, and then dies again. This is a very interesting phenomenon called resurrection. Resurrection, as its name implies, allows an object to come back from the dead. public class BaseObj { protected override void Finalize() { Application.ObjHolder = this; } } class Application { static public Object ObjHolder; // Defaults to null }
In this case, when the object's Finalize method executes, a pointer to the object is placed in a root and the object is reachable from the application's code. This object is now resurrected and the garbage collector will not consider the object to be garbage. The application is free to use the object, but it is very important to note that the object has been finalized and that using the object may cause unpredictable results. Also note: if BaseObj contained members that pointed to other objects (either directly or indirectly), all objects would be resurrected, since they are all reachable from the application's roots. However, be aware that some of these other objects may also have been finalized. public class BaseObj { protected override void Finalize() { Application.ObjHolder = this; GC.ReRegisterForFinalize(this); } }
When this object's Finalize method is called, it resurrects itself by making a root point to the object. The Finalize method then calls ReRegisterForFinalize, which appends the address of the specified object (this) to the end of the finalization queue. When the garbage collector detects that this object is unreachable again, it will queue the object's pointer on the freachable queue and the Finalize method will get called again. This specific example shows how to create an object that constantly resurrects itself and never dies, which is usually not desirable. It is far more common to conditionally set a root to reference the object inside the Finalize method. Make sure that you call ReRegisterForFinalize no more than once per resurrection, or the object will have its Finalize method called multiple times. This happens because each call to ReRegisterForFinalize appends a new entry to the end of the finalization queue. When an object is determined to be garbage, all of these entries move from the finalization queue to the freachable queue, calling the object's Finalize method multiple times. Forcing an Object to Clean Up If you can, you should try to define objects that do not require any clean up. Unfortunately, for many objects, this is simply not possible. So for these objects, you must implement a Finalize method as part of the type's definition. However, it is also recommended that you add an additional method to the type that allows a user of the type to explicitly clean up the object when they want. By convention, this method should be called Close or Dispose. public class FileStream : Stream {
public override void Close() {
// Clean up this object: flush data and close file
•••
// There is no reason to Finalize this object now
GC.SuppressFinalize(this);
}
protected override void Finalize() {
Close(); // Clean up this object: flush data and close file
}
// Rest of FileStream methods go here
•••
}
FileStream's Type Implementation Figure 8 shows FileStream's type implementation. When you call SuppressFinalize, it turns on a bit flag associated with the object. When this flag is on, the runtime knows not to move this object's pointer to the freachable queue, preventing the object's Finalize method from being called. FileStream fs = new FileStream("C://SomeFile.txt", FileMode.Open, FileAccess.Write, FileShare.Read); StreamWriter sw = new StreamWriter(fs); sw.Write ("Hi there"); // The call to Close below is what you should do sw.Close(); // NOTE: StreamWriter.Close closes the FileStream. The FileStream // should not be explicitly closed in this scenario
Notice that the StreamWriter's constructor takes a FileStream object as a parameter. Internally, the StreamWriter object saves the FileStream's pointer. Both of these objects have internal data buffers that should be flushed to the file when you're finished accessing the file. Calling the StreamWriter's Close method writes the final data to the FileStream and internally calls the FileStream's Close method, which writes the final data to the disk file and closes the file. Since StreamWriter's Close method closes the FileStream object associated with it, you should not call fs.Close yourself. void method() {
// The MyObj type has a Finalize method defined for it
// Creating a MyObj places a reference to obj on the finalization table.
MyObj obj = new MyObj();
// Append another 2 references for obj onto the finalization table.
GC.ReRegisterForFinalize(obj);
GC.ReRegisterForFinalize(obj);
// There are now 3 references to obj on the finalization table.
// Have the system ignore the first call to this object's Finalize
// method.
GC.SuppressFinalize(obj);
// Have the system ignore the first call to this object's Finalize
// method.
GC.SuppressFinalize(obj); // In effect, this line does absolutely
// nothing!
obj = null; // Remove the strong reference to the object.
// Force the GC to collect the object.
GC.Collect();
// The first call to obj's Finalize method will be discarded but
// two calls to Finalize are still performed.
}
ReRegisterForFinalize and SuppressFinalize ReRegisterForFinalize and SuppressFinalize are implemented the way they are for performance reasons. As long as each call to SuppressFinalize has an intervening call to ReRegisterForFinalize, everything works. It is up to you to ensure that you do not call ReRegisterForFinalize or SuppressFinalize multiple times consecutively, or multiple calls to an object's Finalize method can occur. ConclusionThe motivation for garbage-collected environments is to simplify memory management for the developer. The first part of this overview looked at some general GC concepts and internals. In Part 2, I will conclude this discussion. First, I will explore a feature called WeakReferences, which you can use to reduce the memory pressure placed on the managed heap by large objects. Then I'll examine a mechanism that allows you to artificially extend the lifetime of a managed object. Finally, I'll wrap up by discussing various aspects of the garbage collector's performance. I'll discuss generations, multithreaded collections, and the performance counters that the common language runtime exposes, which allow you to monitor the garbage collector's real-time behavior. |
Garbage Collection Part 2: Automatic Memory Management in the Microsoft .NET Framework |
||||||||||||||||||||||||||||||||||||||||||
Jeffrey Richter |
||||||||||||||||||||||||||||||||||||||||||
Level of Difficulty 1 2 3 |
||||||||||||||||||||||||||||||||||||||||||
SUMMARY The first part of this two-part article explained how the garbage collection algorithm works, how resources can clean up properly when the garbage collector decides to free a resource's memory, and how to force an object to clean up when it is freed. The conclusion of this series explains strong and weak object references that help to manage memory for large objects, as well as object generations and how they improve performance. In addition, the use of methods and properties for controlling garbage collection, resources for monitoring collection performance, and garbage collection for multithreaded applications are covered. |
||||||||||||||||||||||||||||||||||||||||||
Last month, I described the motivation for garbage-collected environments: to simplify memory management for the developer. I also discussed the general algorithm used by the common language runtime (CLR) and some of the internal workings of this algorithm. In addition, I explained how the developer must still explicitly handle resource management and cleanup by implementing Finalize, Close, and/or Dispose methods. This month, I will conclude my discussion of the CLR garbage collector. Weak References When a root points to an object, the object cannot be collected because the application's code can reach the object. When a root points to an object, it's called a strong reference to the object. However, the garbage collector also supports weak references. Weak references allow the garbage collector to collect the object, but they also allow the application to access the object. How can this be? It all comes down to timing. Void Method() { Object o = new Object(); // Creates a strong reference to the // object. // Create a strong reference to a short WeakReference object. // The WeakReference object tracks the Object. WeakReference wr = new WeakReference(o); o = null; // Remove the strong reference to the object o = wr.Target; if (o == null) { // A GC occurred and Object was reclaimed. } else { // a GC did not occur and we can successfully access the Object // using o } } Void Method() { Object o = new Object(); // Creates a strong reference to the // object. // Create a strong reference to a short WeakReference object. // The WeakReference object tracks the Object. WeakReference wr = new WeakReference(o); o = null; // Remove the strong reference to the object o = wr.Target; if (o == null) { // A GC occurred and Object was reclaimed. } else { // a GC did not occur and we can successfully access the Object // using o } } Figure 1 Strong and Weak References Why might you use weak references? Well, there are some data structures that are created easily, but require a lot of memory. For example, you might have an application that needs to know all the directories and files on the user's hard drive. You can easily build a tree that reflects this information and as your application runs, you'll refer to the tree in memory instead of actually accessing the user's hard disk. This procedure greatly improves the performance of your application.
The target parameter identifies the object that the WeakReference object should track. The trackResurrection parameter indicates whether the WeakReference object should track the object after it has had its Finalize method called. Usually, false is passed for the trackResurrection parameter and the first constructor creates a WeakReference that does not track resurrection. (For an explanation of resurrection, see part 1 of this article at http://msdn.microsoft.com/msdnmag/issues/1100/GCI/GCI.asp.) Weak Reference Internals From the previous discussion, it should be obvious that WeakReference objects do not behave like other object types. Normally, if your application has a root that refers to an object and that object refers to another object, then both objects are reachable and the garbage collector cannot reclaim the memory in use by either object. However, if your application has a root that refers to a WeakReference object, then the object referred to by the WeakReference object is not considered reachable and may be collected. 1. The garbage collector builds a graph of all the reachable objects. Part 1 of this article discussed how this works. 2. The garbage collector scans the short weak reference table. If a pointer in the table refers to an object that is not part of the graph, then the pointer identifies an unreachable object and the slot in the short weak reference table is set to null. 3. The garbage collector scans the finalization queue. If a pointer in the queue refers to an object that is not part of the graph, then the pointer identifies an unreachable object and the pointer is moved from the finalization queue to the freachable queue. At this point, the object is added to the graph since the object is now considered reachable. 4. The garbage collector scans the long weak reference table. If a pointer in the table refers to an object that is not part of the graph (which now contains the objects pointed to by entries in the freachable queue), then the pointer identifies an unreachable object and the slot is set to null. 5. The garbage collector compacts the memory, squeezing out the holes left by the unreachable objects. Once you understand the logic of the garbage collection process, it's easy to understand how weak references work. Accessing the WeakReference's Target property causes the system to return the value in the appropriate weak reference table's slot. If null is in the slot, the object was collected. Generations When I first started working in a garbage-collected environment, I had many concerns about performance. After all, I've been a C/C++ programmer for more than 15 years and I understand the overhead of allocating and freeing memory blocks from a heap. Sure, each version of Windows® and each version of the C runtime has tweaked the internals of the heap algorithms in order to improve performance. · The newer an object is, the shorter its lifetime will be. · The older an object is, the longer its lifetime will be. · Newer objects tend to have strong relationships to each other and are frequently accessed around the same time. · Compacting a portion of the heap is faster than compacting the whole heap. Of course, many studies have demonstrated that these assumptions are valid for a very large set of existing applications. So, let's discuss how these assumptions have influenced the implementation of the garbage collector.
Now, if more objects are added to the heap, the heap fills and a garbage collection must occur. When the garbage collector analyzes the heap, it builds the graph of garbage (shown here in purple) and non-garbage objects. Any objects that survive the collection are compacted into the left-most portion of the heap. These objects have survived a collection, are older, and are now considered to be in generation 1 (see Figure 3).
As even more objects are added to the heap, these new, young objects are placed in generation 0. If generation 0 fills again, a GC is performed. This time, all objects in generation 1 that survive are compacted and considered to be in generation 2 (see Figure 4). All survivors in generation 0 are now compacted and considered to be in generation 1. Generation 0 currently contains no objects, but all new objects will go into generation 0.
Currently, generation 2 is the highest generation supported by the runtime's garbage collector. When future collections occur, any surviving objects currently in generation 2 simply stay in generation 2. Generational GC Performance Optimizations As I stated earlier, generational garbage collecting improves performance. When the heap fills and a collection occurs, the garbage collector can choose to examine only the objects in generation 0 and ignore the objects in any greater generations. After all, the newer an object is, the shorter its lifetime is expected to be. So, collecting and compacting generation 0 objects is likely to reclaim a significant amount of space from the heap and be faster than if the collector had examined the objects in all generations. Direct Control with System.GC The System.GC type allows your application some direct control over the garbage collector. For starters, you can query the maximum generation supported by the managed heap by reading the GC.MaxGeneration property. Currently, the GC.MaxGeneration property always returns 2.
The first method allows you to specify which generation to collect. You may pass any integer from 0 to GC.MaxGeneration, inclusive. Passing 0 causes generation 0 to be collected; passing 1 causes generation 1 and 0 to be collected; and passing 2 causes generation 2, 1, and 0 to be collected. The version of the Collect method that takes no parameters forces a full collection of all generations and is equivalent to calling: Under most circumstances, you should avoid calling any of the Collect methods; it is best to just let the garbage collector run on its own accord. However, since your application knows more about its behavior than the runtime does, you could help matters by explicitly forcing some collections. For example, it might make sense for your application to force a full collection of all generations after the user saves his data file. I imagine Internet browsers performing a full collection when pages are unloaded. You might also want to force a collection when your application is performing other lengthy operations; this hides the fact that the collection is taking processing time and prevents a collection from occurring when the user is interacting with your application.
The first version of GetGeneration takes an object reference as a parameter, and the second version takes a WeakReference reference as a parameter. Of course, the value returned will be somewhere between 0 and GC.MaxGeneration, inclusive. private static void GenerationDemo() { // Let's see how many generations the GCH supports (we know it's 2) Display("Maximum GC generations: " + GC.MaxGeneration); // Create a new BaseObj in the heap GenObj obj = new GenObj("Generation"); // Since this object is newly created, it should be in generation 0 obj.DisplayGeneration(); // Displays 0 // Performing a garbage collection promotes the object's generation Collect(); obj.DisplayGeneration(); // Displays 1 Collect(); obj.DisplayGeneration(); // Displays 2 Collect(); obj.DisplayGeneration(); // Displays 2 (max generation) obj = null; // Destroy the strong reference to this object Collect(0); // Collect objects in generation 0 WaitForPendingFinalizers(); // We should see nothing Collect(1); // Collect objects in generation 1 WaitForPendingFinalizers(); // We should see nothing Collect(2); // Same as Collect() WaitForPendingFinalizers(); // Now, we should see the Finalize // method run Display(-1, "Demo stop: Understanding Generations.", 0); } GC Methods Demonstration The code in Figure 5 will help you understand how generations work. It also demonstrates the use of the garbage collection methods just discussed. Performance for Multithreaded Applications In the previous section, I explained the GC algorithm and optimizations. However, there was a big assumption made during that discussion: only one thread is running. In the real world, it is quite likely that multiple threads will be accessing the managed heap or at least manipulating objects allocated within the managed heap. When one thread sparks a collection, other threads must not access any objects (including object references on its own stack) since the collector is likely to move these objects, changing their memory locations. Garbage-collecting Large Objects There is one more performance improvement that you might want to be aware of. Large objects (those that are 20,000 bytes or larger) are allocated from a special large object heap. Objects in this heap are finalized and freed just like the small objects I've been talking about. However, large objects are never compacted because shifting 20,000-byte blocks of memory down in the heap would waste too much CPU time. Monitoring Garbage CollectionsThe runtime team at Microsoft has created a set of performance counters that provide a lot of real-time statistics about the runtime's operations. You can view these statistics via the Windows 2000 System Monitor ActiveX® control. The easiest way to access the System Monitor control is to run PerfMon.exe and select the + toolbar button, causing the Add Counters dialog box to appear (see Figure 6). To monitor the runtime's garbage collector, select the COM+ Memory Performance object. Then, you can select a specific application from the instance list box. Finally, select the set of counters that you're interested in monitoring and press the Add button followed by the Close button. At this point, the System Monitor will graph the selected real-time statistics. Figure 7 describes the function of each counter.
ConclusionSo that's just about the full story on garbage collection. Last month I provided the background on how resources are allocated, how automatic garbage collection works, how to use the finalization feature to allow an object to clean up after itself, and how the resurrection feature can restore access to objects. This month I explained how weak and strong references to objects are implemented, how classifying objects in generations results in performance benefits, and how you can manually control garbage collection with System.GC. I also covered the mechanisms the garbage collector uses in multithreaded applications to improve performance, what happens with objects that are larger than 20,000 bytes, and finally, how you can use the Windows 2000 System Monitor to track garbage collection performance. With this information in hand, you should be able to simplify memory management and boost performance in your applications. |