Rico Mariani, an Architect on the CLR team and someone who definately knows his stuff, posted to his blog a few weeks ago two things you should avoid in .NET for better memory usage. These two things are fairly common in .NET code especially when old habits are lingering from previous non-.NET languages. Here's a quick recap (I'll also post a link to the full article at the end of this post). Although there are always exceptions, here are some guidelines for better memory usage in .NET:
1) Never call GC.Collect()
According to Rico, if you need to force a collection then you've gone wrong in your design. Instead of attempting to force the garbage collection ask yourself what sort of collection you think you need (Gen0, Gen1, Gen2) and what you did in your code to cause the need for the collection.
If you think you need a Gen0 collection: Gen0 collects happen comparatively often anyway, and they're also comparatively cheap. The collector will have been using your usual allocation rate plus your processors cache size to figure out how much temporary memory it should be letting you create before it's economical to collect gen0. If you force the collect before then, you may be giving it too small of a time sample for it to do a good job predicting the right budget for the next collect and you may end up with more gen0 collects than you need. Since gen0 never gets incredibly big, your best bet is to just leave it be, it will be cleaned up really soon and with best economy if you just let the GC do it as scheduled.
If you think you need a Gen1 collection: Your first problem is that GC.Collect() doesn't promise a Gen1 collect. Your second problem is that to know how big gen1 even is you'd need to be looking at things like the survival rate in gen0, so it is pretty tricky to know if gen1 collect would really be a good idea. The final problem is that gen1 is also comparatively small/cheap to collect (not quite as cheap as gen0) the same problems I describe for gen0 still apply... probably even more so with regard to the gen1 budget because promotion to gen1 can have greater variance than the raw allocation rate.
If you think you need a Gen2 collection: If this is happening often enough that its a problem for you then you're in a world of pain. Generation 2 collects are full collects, so they are much more expensive than gen1, or gen0. If your algorithm is regularly producing objects that live to gen2 and then die shortly thereafter, you're going to find that the percent time spent in GC goes way up. Forcing more of these collects is really the last thing you wanted to do (assuming you could, note again GC.Collect() doesn't promise to do a gen2 collect). The advice I gave earlier about finding ways to have as much of your memory be reclaimable as soon as possible, before those objects live into gen2, applies doubly here. If you're using the performance counters to watch the GC then you want gen2 size to be growing VERY slowly after startup. Promoting lots of objects to gen2 means those objects will die an expensive death.
If you still feel that memory is not being reclaimed then it is most likely due to memory that has aged to a generation that collects less often then you need. The aging rate of all your objects is relative to the aging rate of your most temporary objects. If you can find ways to have more objects die sooner, and to reduce the overall churn rate then you get much better behavior from the collector. Using allocation profiler to find objects that are getting relocated (implying that they survived a collect) will give you a good indication of where your surviving objects are coming from. Target those that you can for early eradication.
On to rule #2.
2) Never have finalizers
This was a tough habit for me to break when I first started with C# and before I understood how the garbage collection worked.
In C++ it's common to use destructors to release memory. It's tempting to do that with finalizers but you must not. The GC will collect the memory associated with the members of a dead object just fine without those members needing to be nulled. You only need to null the members if you want part of the state to go away early, while the object is still alive (see above). So really the only reason to finalize is if you're holding on to an unmanaged resource (like some kind of operating system handle) and you need to Close the handle or something like that.
Why is this so important? Well, objects that need to be finalized don't die right away. When the GC discovers they are dead they are temporarily brought back to life so that they can get queued up for finalization by the finalizer thread. Since the object is now alive so is everything that it points to! So if you were to put a finalizer on all tree nodes in an application for instance, when you released the root of the tree, no memory would be reclaimed at first because the root of the tree holds on to everything else. If those tree nodes need to be finalized because they might hold an unmanaged resource it would be much better to wrap that unmanged resource in a object that does nothing else but hold the resource and let that wrapper object be the finalized thing. Then your tree nodes are just normal and the only thing that's pending finalization is the one wrapper object (a leaf), which doesn't keep any other objects alive.
This is a bigger deal than one might think. The fact that you even have a finalizer causes your object to survive at least one collection. The reason being that the first round it is only brought back to life to queue it up for finalization, which may even promote it a generation. If this happens it won't even get reclaimed at the next collect and will instead have to wait for the next bigger collection to reclaim the memory (which likely only happens 1/10th as often).
Solution. Don't use finalizers. Ever.