It’s extremely common to see somebody ask a question about avoiding the garbage collector only to be answered with “just use a pool” as if that immediately and totally solved the problem. While pools will often keep the garbage collector at bay, they’ll also introduce a whole slew of new problems that you’ve got to deal with instead. Today’s article goes through several of these problems so you’ll be aware of the tradeoffs involved and hopefully avoid some pitfalls.

Object Pools Recap

We want to avoid frame spikes/hitches in our games caused by the slow, all-at-once, memory-fragmenting, main thread-blocking garbage collection that Unity provides. So we often turn to an object pool which is designed to hold objects in a list and never release the references for the GC to collect. I’ve provided a simple pool before and there are many more if you search online. Here’s the one I made as an example of your typical pool:

public interface IPoolableObject<TInitArgs>
	where TInitArgs : struct
{
	void Init(TInitArgs initArgs);
	void Release();
}
 
public class ObjectPool<TObject, TInitArgs>
	where TObject : class, IPoolableObject<TInitArgs>, new()
	where TInitArgs : struct
{
	private readonly Stack<TObject> unused = new Stack<TObject>();
 
	public TObject Get(TInitArgs initArgs)
	{
		var obj = unused.Count == 0 ? new TObject() : unused.Pop();
		obj.Init(initArgs);
		return obj;
	}
 
	public void Release(TObject obj)
	{
		obj.Release();
		unused.Push(obj);
	}
}

It’s really simple! Instead of using new, call ObjectPool.Get and it’ll reuse an object from the pool if one’s available. When you’re done with it, simply call ObjectPool.Release and it’ll go back on the Stack. So what could possibly go wrong?

Requires explicit free

For starters, when you’re done with a pooled object you must remember to put it back in the pool. That sounds easy until you realize that if you forget you’ll get basically no feedback alerting you of that. There’s no compiler error or warning, no debug logs will be printed, and the game won’t crash. Just by overwriting a variable you’ll release the object it references and garbage collection will eventually occur.

The only way you’ll find that this is happening is if you profile the game and watch for garbage collection, probably after noticing GC-caused frame hitches. Then you get to backtrack over all the frames since the last garbage collection and look at the “GC alloc” column to find out what was allocated and guess which of those objects weren’t returned to the pool. The whole process is horribly time-consuming and tedious. There’s basically no tools to support you in the process and all it takes to bring on this mess is to omit one simple line of code.

Contents not released when returned to pool

But let’s say you never forget to put objects back in the pool. What else could go wrong? Well, you have those Init and Release functions on your IPooledObject type and that’s where you’re supposed to reset the object’s state so it’s fresh when it’s reused. Unfortunately, it’s really easy to forget to properly reset an object’s state. You can easily add a field and forget to reset it in Release. You might have complex objects that need complex resetting. For example, you might have a IPooledObject field that you need to put back in its own pool.

If you forget to do any of this then the object’s state will “leak” between uses of it. That’s also really hard to track down! You might wonder why—intermittently—your enemies start off with a buff on them or with 30% of their health missing. You’ll have to—somehow—find out that it’s because the enemy’s state wasn’t properly reset when released. Again, there’s no tooling to give you warnings or errors that you’ve simply omitted a line of code or two. You’ll have to slog through a debugging session trying to find out why your enemies are in a weird state… sometimes.

Multiple references to pooled object

Another nasty issue crops up when you have multiple references to a pooled object. With garbage collection, the object will be cleaned up automatically after the last reference is released. With pooling you need to manually keep track of how many references you have to an object. That means every time you pass a pooled object to a function or store it on a field of a class you need to remember to increment a reference count somewhere. Every time those functions return or those classes overwrite their reference field you’ve got to remember to decrement that number. And don’t forget about closures and coroutines, which secretly create classes with fields for your local variables because you’ll need to count those too.

If you forget even one increment or one decrement then your reference count will be inaccurate. If it doesn’t drop to zero the object will never be put back into the pool. If it drops to zero before all the references are released then the remaining references will continue to use an object that has (hopefully) been cleaned up or even reused by another area of the code. Again, there’s no tooling to warn you about this and you’ll need to track down why—sometimes—damaging one enemy damages another one and all sorts of other tricky issues like that.

Hard to pool collections

Collections like arrays, List, and Dictionary are essential building blocks for most Unity games. They’re also hard to pool! Imagine you wrap an int[] in a PooledIntArray class. How likely is it that another chunk of code will want to get an array of that exact length? Not very. If you use a List you can change the length, but there are still issues. First, as the list grows it’ll internally release its reference to its array and copy the elements to a new, larger array. You can’t interject into that process to insert your own pool of arrays. You could make a list that starts with a really big capacity and hope you never exceed it, but then your memory usage will always be at the “high water mark” of what you need. The same problems apply to Dictionary and the other collection types. How do you work around this? Pools don’t provide a solution here.

Adds overhead to pooled types

The pool itself contains some kind of list of objects, like the Stack in the above example. That’s memory that you wouldn’t have otherwise used and even more if the list needs to resize due to an excess of objects being released. The pool class and it’s list are also GC allocated and tracked objects, but hopefully you never release them. More advanced pools will need even more memory to store data indicating which objects are in the pool or out of it. And there’s a bunch of virtual function calls involved, like Get and Release. This is all CPU and memory overhead on top of what you’d normally use without object pools.

Not thread-safe by default

Using the new operator and setting a reference to null are completely thread-safe operations. When pools get involved the waters become murky. You could create one pool per thread, but that’s inefficient for short-lived threads and because objects aren’t reused across threads. Or you could create a single pool for all threads and add lock statements in your Get and Release functions. That’s even more overhead on top of the above.

There are also reentrancy problems. When the pool calls Init or Release, those functions may call back into the pool to Get or Release. For more advanced pools than the simple one above, that could cause major problems with the state of the pool itself and possibly affect far-reaching areas of the game. The lock statement won’t help here since it only protects against multiple threads running the same code. So you’ll need even more overhead, such as a volatile bool flag that you set and check everywhere. It’s a lot of overhead and complexity that’s difficult to get right and expensive even if you do.

No default constructor

Pools like the above need to be able to create objects when the pool is empty, so they add a where T : new() constraint so there’s guaranteed to be a default constructor. The trouble is that many classes don’t have a default constructor. Consider a Person class that needs a first name, last name, and age in order to construct into a valid state. Instead, it’ll need to have a default constructor that leaves those strings null (or empty) and that age at something invalid like zero or -1. The Init function will be called right away, but only the pool is set up to do that right every time. It’s easy to write code that just calls new Person and skips the pool and the Init function. Once again there are no warnings that a vital function has been skipped and only later on will there be a crash due to a null string or some bizarre arithmetic due to a -1 age.

Awkward to use

Finally, pools are simply awkward to use. You need a reference to the pool in order to get or release objects. To get that reference you need to pass it to each function that creates an object, like you would with a factory. That bloats up parameter lists. It also seems strange that a caller of a function would know how the function is going to create or release an object.

To tackle that daisy chain of parameter passing the pool, you’ll probably be very tempted to make a global pool variable of some sort. You might call it a “singleton” or a “service” or a “manager”, but it’ll be globally accessible from every function in the game. That may be acceptable in this case, but it forces the “one pool for all threads” approach that will require locks unless your whole game is single-threaded.

You’ve also got to implement IPoolableObject in order to pool the objects of some class. Since you don’t have control over the many classes already defined by Unity, .NET, and other libraries, you can’t pool them. So if you want to pool a StringBuilder then you’ve got to wrap it in a PoolableStringBuilder type of class. Whenever you create one of those you’ll be allocating two objects: the wrapper itself and the wrapped object. That’s more overhead, more pressure on the GC, and more awkward code.

Conclusion

Object pools are not a panacea. They don’t easily and totally solve the problem of garbage collection in Unity games. Depending on your perspective, the cure might even be worse than the disease. They can be a useful tool in fighting frame spikes/hitches. If you decide to use them, don’t do so lightly. Keep the above downsides in mind when considering the tradeoffs.