Practical DiskDiff in C#: Garbage Collection in the .NET Runtime

Garbage collection has a bad reputation in a few areas of the software world. Some programmers think they can do a better job at memory allocation than a garbage collector (GC) can.

They’re correct; they can do a better job but only with a custom allocator for each program and possibly for each class. Also, custom allocators are a lot of work to write, to understand, and to maintain.

In the vast majority of cases, a well-tuned garbage collector will give similar or better performance to an unmanaged heap allocator.

The following sections explain a bit about how the garbage collector works, how it can be controlled, and what can’t be controlled in a garbage-collected world. The information presented describes the situation for platforms such as PCs and servers that run full versions of Windows. Systems with more constrained resources, such as the Pocket PC, are likely to have simpler GC systems.

Note also that there are optimizations performed for multiproc and server machines (covered later in the “Server vs. Workstation Garbage Collection” section).

1. Allocation

Heap allocation in the .NET runtime world is fast; all the system has to do is make sure the managed heap has enough room for the requested object, return a pointer to that memory, and increment the pointer to the end of the object.

Garbage collectors trade simplicity at allocation time for complexity at cleanup time. Allocations are really, really fast in most cases, though if there isn’t enough room, a garbage collection might be required to obtain enough room for object allocation.

Of course, to make sure it has enough room, the system might have to perform a garbage collection.

To improve performance, large objects (greater than 20KB) are allocated from a large object heap.

2. Mark and Compact

The .NET garbage collector uses a Mark and Compact algorithm. When a collection is performed, the garbage collector starts at root objects (including globals, statics, locals, and CPU registers) and finds all the objects referenced from those root objects. This collection of objects denotes the objects that are in use at the time of the collection, and therefore all other objects in the system are no longer needed.

To finish the collection process, all the referenced objects are copied down in the managed heap, and the pointers to those objects are all fixed up. Then, the pointer for the next available spot is moved to the end of the referenced objects.

Since the garbage collector is moving objects and object references, no other operations can be going on in the system. In other words, all useful work must stop while the GC takes place.

3. Generations

It’s costly to walk through all the objects that are currently referenced. Much of the work in doing this will be wasted work, since the older an object is, the more likely it is to stay. Conversely, the younger an object is, the more likely it is to be unreferenced.

The runtime capitalizes on this behavior by implementing generations in the garbage collector. It divides the objects in the heap into three generations:

  • Generation 0 objects are newly allocated objects that have never been considered for collection.
  • Generation 1 objects have survived a single garbage collection.
  • Generation 2 objects have survived multiple garbage collections.

In design terms, generation 2 tends to contain long-lived objects, such as applications; generation 1 tends to contain objects with medium lifetimes, such as forms or lists; and gener­ation 0 tends to contain short-lived objects, such as local variables.

When the runtime needs to perform a collection, it first performs a generation 0 collection. This generation contains the largest percentage of unreferenced objects and will therefore yield the most memory for the least work. If collecting that generation doesn’t generate enough memory, generation 1 will then be collected and finally, if required, generation 2.

Figure 38-2 illustrates some objects allocated on the heap before a garbage collection takes place. The numerical suffix indicates the generation of the object; initially, all objects will be of generation 0. Active objects are the only ones shown on the heap, but space exists for additional objects to be allocated.

At the time of the first garbage collection, B and D are the only objects still in use. The heap looks like Figure 38-3 after collection.

Since B and D survived a collection, their generation is incremented to 1. New objects are then allocated, as shown in Figure 38-4.

Time passes. When another garbage collection occurs, D, G, and H are the live objects. The garbage collector tries a generation 0 collection, which leads to the layout shown in Figure 38-5.

Even though B is no longer live, it doesn’t get collected because the collection was for generation 0 only. After a few new objects are allocated, the heap looks like Figure 38-6.

Time passes, and the live objects are D, G, and L. The next garbage collection does both generation 0 and generation 1, which leads to the layout shown in Figure 38-7.

4. Finalization

The GC supports finalization, which is somewhat analogous to destructors in C++. In C#, they’re known as destructors and are declared with the same syntax as C++ destructors, but from the runtime perspective, they’re known as finalizers.

Finalizers allow the opportunity to perform some cleanup before an object is collected, but they have considerable limitations and therefore really shouldn’t be used much.

Before we discuss their limitations, we’ll describe how they work. When an object with a finalizer is allocated, the runtime adds the object reference to a list of objects that will need finalization. When a garbage collection occurs, if an object has no references but is contained on the finalization list, it’s marked as ready for finalization.

After the garbage collection has completed, the finalizer thread wakes up and calls the finalizer for all objects that are ready for finalization. After the finalizer is called for an object, it’s removed from the list of objects that need finalizers, which will make it available for collection the next time garbage collection occurs.

This scheme results in the following limitations regarding finalizers:

  • Objects that have finalizers have more overhead in the system, and they hang around longer.
  • Finalization takes place on a separate thread from execution.
  • Finalization has no guaranteed order. If object a has a reference to object b, and both objects have finalizers, the object b finalizer might run before the object a finalizer, and therefore object a might not have a valid object b to use during finalization.
  • Although finalizers are usually called on normal program exit, sometimes this won’t occur. If a process is terminated aggressively (for example, if the Win32 TerminateProcess function is called), finalizers won’t run. Finalizers can also fail to run if the finalization queue gets stuck running finalizers for a long time on process exit. In this case, attempts to run the finalizers will time out.

All of these limitations are why doing work in destructors is discouraged.

5. Controlling GC Behavior

At times, it may be useful to control the GC behavior. You should do this in moderation; the whole point of a managed environment is that it controls what’s going on, and controlling it tightly can lead to problems elsewhere.

5.1. Forcing a Collection

The function System.GC.Collect() can be called to force a collection. Avoid this call in production code. Forcing a garbage collection is usually done as a futile attempt to reclaim resources that should have been disposed, and this problem has other remedies, such as using code-quality tools from companies such as Compuware that can pinpoint missed Dispose() calls.

5.2. Suppressing Finalization

As mentioned earlier, an instance of an object is placed on the finalization list when it’s created.

If it turns out that an object doesn’t need to be finalized (because the cleanup function has been called, for example), you can use the System.GC.SupressFinalize() function to remove the object from the finalization list.

5.3. Server vs. Workstation Garbage Collection

The .NET Framework ships with two flavors of the garbage collection: server and workstation. The term flavor prevents confusion between the two different garbage collectors and different versions of the .NET Framework, such as 1.0,1.1, and 2.0; we’ll use this term for the rest of this discussion.

The workstation garbage collection is further broken down into two separate modes: concurrent and nonconcurrent. Concurrent garbage collection refers to the execution of managed code while various aspects of a garbage collection cycle are being completed. In the early description of the Mark and Compact algorithm, we stated that all useful work needs to be stopped while a garbage collection takes place. With concurrent garbage collection, this isn’t quite true, because execution of managed code (in other words, useful work) can still occur during a couple of places during the collection cycle, notably during the Mark stage. Concurrent garbage collection makes for a slower overall garbage collection cycle, but during the collection the application stays more responsive. These characteristics make it the best choice for interactive applications such as Windows Forms applications, and this is the default mode for the garbage collector.

For some applications, such as those that operate in batch mode without a user interface, maximum performance in a garbage collection cycle is preferable to the minimization of pauses, and concurrent garbage collection should be turned off. If an unmanaged application written in a language such as C++ is explicitly hosting C# or other managed code, you can control the use of concurrent garbage collection by the value of parameters passed to the hosting APIs (see the documentation for CorBindToRuntimeEx for details). For applications written entirely in managed code, you can use a configuration file setting to turn off concurrent garbage collection:

<configuration>

<runtime>

<gcConcurrent enabled=”false”/>

</runtime>

</configuration>

For a multiprocessor machine, many different optimizations are possible. To implement these optimizations, a different flavor of the garbage collector is available. The server flavor of the garbage collection will load only on multiprocessor machines, but it’s possible to also load the workstation flavor on these machines, and this is actually the default behavior. Prior to Service Pack 1 (SP1) of the .NET Framework 1.1, the only supported way to load the server flavor of the garbage collector was via the hosting APIs and the use of unmanaged code (again, see the documentation for CorBindToRuntimeEx for details). From .NET 1.1 SP1 onward, you can load the server flavor by using a configuration file setting:

<configuration>

<runtime>

<gcServer enabled=“true”/>

</runtime>

</configuration>

The server flavor of the garbage collector doesn’t have a concurrent collection mode, and managed code is suspended for the duration of a collection cycle. This means that for UI-centric applications, the server version of the runtime may not give the best appearance of performing as well as the workstation flavor using concurrent collection. This is the reason for the default choice of concurrent workstation garbage collection regardless of the number of processors present.

For server applications, the optimizations present in the server flavor of the garbage collector can be quite significant. The main optimization is a separate garbage-collected heap and garbage collection thread for each processor. This dramatically reduces the amount of contention on global locks and resources needed to allocate and clean up memory. In the workstation flavor of the runtime, garbage collection takes places on the thread that requested the allocation that couldn’t be satisfied from the currently available memory, which in turn triggered the garbage collection. In contrast, the optimizations in the server GC mean that every garbage-collected heap can be collected simultaneously, maximizing the use of the available processor power.

The 2.0 release of the .NET Framework libraries includes a new class called GCSettings that can determine which flavor of the garbage collection is in use:

bool isServerGC = GCSettings.IsServerGC;

Source: Gunnerson Eric, Wienholt Nick (2005), A Programmer’s Introduction to C# 2.0, Apress; 3rd edition.

Leave a Reply

Your email address will not be published. Required fields are marked *