|
Garbage-collected environments are systems that provide for high-speed memory allocation, automatic disposal of unused allocations, and automatic fragmentation management. This really means that unlike languages such as C, where you must manage your own memory allocations with functions like malloc(), Garbage Collectors (GCs) enable you to allocate what you need, when you need it, and the rest is managed for you. Unfortunately, this often leads to a hands-off attitude by many programmers. They assume that because the GC is running in the background, nothing needs to be done with their code. The truth is that the GC can be your best friend and worst enemy, depending on how you write your code. Garbage Collection InternalsGenerationsGenerations are used to partition up the managed heap. The managed heap is the storage area where all memory allocations by your Common Language Runtime application are performed. Figure 14.1 shows the managed heap and the three generations. We will talk more about what all of this means shortly. Figure 14.1. Managed heap model showing three generations.Throughout the lifetime of a .NET application, two types of garbage collections can occur: full collections and partial collections. A full collection stops the execution of your program in order to scan through the entire managed heap looking for roots. Roots can be a number of things, but most of the time roots consist of stack variables and global objects that contain references into the managed heap (such as class instances that have global scope throughout your application). During a full collection, the collector goes through all the roots and tags each reachable, or live, object. When the full collection finishes, the list of reachable objects is preserved and all unreachable objects become condemned and are destined to be thrown in the trash by the Garbage Collector. Part of the final collection process involves compacting and defragmenting the managed heap. Because full collections have an incredible effect on the performance of your application (your application will stop responding until the full collection is complete), there is a need to be able to do something faster to decrease the consequences to your application. This is the job of a partial collection. Partial collections involve the use of generations (shown in Figure 14.1). The more recently an object has been allocated, the lower its generation number. So, you can assume that Generation 0 (often referred to as Gen0 or G0) contains the newly allocated objects. When a partial collection occurs, the roots are traversed just as in a full collection. However, the old objects are temporarily ignored (they are in Gen1 or Gen2) and only the roots for Gen0 are examined and collected. This shortcut scenario allows the work of a full collection to be postponed by assuming that all the older objects are still live. Through research and profiling applications, Microsoft determined that the highest degree of churn (quick allocation and deallocation) occurs in short-term objects such as temporary variables, temporary strings, placeholders, and small utility classes. In short, Generation 0 objects are collected most frequently and collecting Generation 0 objects takes the shortest amount of time. This works only if the assumption that old objects are still live. To accurately determine when the collector needs to work on Generation 1 or 2, we need to know which objects in the older generations have been modified. Storing this "dirty" flag is accomplished by a data structure called the card table. The card table is an array of bits, with each bit representing a specific segment of memory. (The size of memory monitored by each bit varies with different GC implementations on different operating systems.) When an object written to that is in a memory segment monitored by the card table, the bit flag is switched, which indicates that piece of memory has been modified. The real partial collection in the GC occurs just as mentioned earlier, but with one new twist. After the collection of G0 takes place, the all older objects that have been modified (as indicated by the card table) are then treated as roots. Those roots are then traversed as if they were G0 roots and a collection is performed. Coding with the Garbage Collector in MindNow that you have a basic idea of how the Garbage Collector works, you can think about how to make sure that your code doesn't work in such a way that it slows down the GC. Entire books have been written about the Common Language Runtime and the code that drives it, including the Garbage Collector, so this overview will just talk about how to avoid some common GC pitfalls in your code. If you know how the Garbage Collector works, you know how it can slow down. One of the most common ways in which the GC can be slowed down is through having too many things allocated. The more roots there are to traverse, the slower each collection will be. You can't even count on a partial collection to save time, particularly if the high number of allocations is in Generation 0 objects. When you are writing code, always keep in mind how many allocations you are performing. Array creation usually generates quite a few allocations that might or might not be necessary, depending on your code. Keep in mind that the GC needs to walk the roots in order to find unused objects. The more roots there are to examine, the longer it takes the collector to run. If you create a large structure that contains a large number of references (pointers), the collector has to follow each of those references to determine which objects are live and which objects aren't live each time it runs. If a large structure with a large number of pointers (or any other structure that is time-consuming for the GC to examine) is long-lived, garbage collection will be handled by a full collection. If a large structure is short-lived, continually collecting that same structure slows down the GC and could also slow down the application. You should also avoid deeply recursive methods that have many object pointers within each method. Such methods create a large number of roots (remember, roots are stack variables and global object pointers) to deal with on a G0 collection. You might notice that your deeply recursive method takes too long to execute because the GC is struggling to keep up with the root allocations. Here are some Microsoft guidelines for keeping the number of roots low and the G1 size from expanding quickly:
The best things that you can do for your code are to run it through a profiler that shows Garbage Collector activity, and to use the preceding advice to tune your code to be more GC-friendly. Caveat: Nondeterministic Finalization Versus DeconstructionOne fundamental difference between objects in C# (and the .NET Framework in general) and objects in languages like C++ is the distinction between destructors and finalizers. A destructor is a method that is called on an object at the instant it is destroyed with a keyword such as delete in C++. A finalizer is a method that is called by the Garbage Collector during a collection process. There is a cost to finalization that you should be aware of. If you implement a finalizer on your class, it will not be called when you set the object to null or when the object goes out of scope. It will be called during the next appropriate stage of collection. When the GC encounters an object that has a finalizer, it has to stop collecting that object. The GC puts the object in a list to deal with later. The problem occurs because any object references inside the finalizer must remain valid until collection time. Everything that the object with a finalizer references, either directly or indirectly, continues to live until the object is collected. If the object manages to get into G2, it could take a very long time to collect. In fact, depending on how long the application runs, there's a good chance that the object won't be collected until your application exits. There is a way out. If your object absolutely must make use of a finalizer, you can tell the GC to skip the normal collection process for that object if the object implements the IDisposable interface and the code invokes the Dispose method. In essence, this allows your object to finalize itself and does not put the burden on the GC. Listing 14.1 shows a simple class that implements the IDisposable interface. Listing 14.1. The Code for a Simple IDisposable Implementationusing System; namespace Disposable { /// <summary> /// Summary description for DisposableObject. /// </summary> public class DisposableObject : IDisposable { private int memberData; public DisposableObject() { // obtain resources to support object } public int SomeProperty { get { return memberData; } set { memberData = value; } } private void Cleanup() { // get rid of resources used to support object } #region IDisposable Members public void Dispose() { Cleanup(); //tell the GC that it doesn't need to take care of this object, we're done //with it. System.GC.SuppressFinalize(this); } #endregion } } There are a couple of good things about IDisposable. The first is that implementing it in a class that requires finalization enables you to do it on your own without slowing down the GC to take care of it. The second is that C# contains the using keyword, which makes blocks of code using the IDisposable object easier to create and to read. After the code inside a using block has completed, anything declared within the using statement automatically has its Dispose method called, even if an exception occurred during the block. The following is a demonstration of employing the using keyword on the class from Listing 14.1: using (DisposableObject dispObject = new DisposableObject()) { // do something with the object dispObject.SomeProperty = 42; } This section on the Garbage Collector won't make you an expert on the GC overnight, but at least you will be aware of what the GC is, how it works, and some of the things you can do to speed up your code. The biggest step toward improving your code's GC performance is simply acknowledging that the GC is there to begin with. |
|