DOD — Unity Memory Best Practices — Part 4

Nitzan Wilnai
6 min readJun 6, 2024

--

The Garbage Collector (GC)

In C# your heap memory is managed by the garbage collector. In theory, the garbage collector is a great invention: it manages memory for you so you don’t need to worry about deallocating objects yourself. In practice, it does a terrible job and we want to avoid it at all costs.

The Unity c# Garbage Collector works like so:

  1. It looks for objects that are no longer used.
  2. It then marks those objects to be deleted.
  3. At a later time, it does 1 or more garbage collection passes and removes those objects so their memory can be reused.

There are three issues with how the Garbage Collector is implemented in Unity:

  1. Memory isn’t freed immediately
    A lot of time can pass between when the GC realizes an object is no longer used and until that memory is freed for another object.
  2. Performance
    The GC can negatively affect performance when it runs, leading to an unpredictable drop in framerate.
  3. Memory fragmentation
    The Unity GC never compacts the memory it frees which can lead to memory fragmentation.

“The garbage collector is also non-compacting, which means that Unity doesn’t redistribute any objects in memory to close the gaps between objects.”
https://docs.unity3d.com/Manual/performance-incremental-garbage-collection.html

Let’s explore each issue separately:

GC Issue #1 — Memory isn’t freed immediately

Let’s say we have an object in heap memory:

Class Foo
{
// lots of member variables and methods
}
Foo myFooObject = new Foo();

You can let the GC know that myFooObjet is no longer used by setting it to null:

myFooObject = null;

Now when the GC runs, it will free the memory used by myFreeObject so it can be used by other objects.

The problem is that we have no control over when the GC runs. Even if you explicitly call it, which is not recommended, the GC runs over multiple frames. That means that even though we are no longer going to use myFooObject’s data, the memory cannot be re-used until the GC has finished running.

Note — You can toggle whether the GC runs over multiple frames or not, but if you set it to run in a single frame the performance hit will be even more apparent.

If we do something like this:

Foo myFooObject1 = new Foo();
myFooObject1 = null;
Foo myFooObject2 = new Foo();

We would expect myFooObject2 to take over the memory address from myFooObject1.

Instead our memory will look something like this:

Foo myFooObject1 = new Foo();

myFooObject1 is allocated on the heap:

Now we set myFooObject1 to becasue we want it removed:


myFooObject1 = null;

myFooObject1 is now marked as unused:

Now we allocate a new object, myFooObject2:

Foo myFooObject2 = new Foo();

myFooObject2 is now allocated after myFooObject1:

Both myFooObject1 and myFooObject2 are in memory, even though we already set myFooObject1 to null;

Sometime later, the Garbage Collector will run and free myFooObject1’s memory:

This means that at any one time you might be using more memory than you expect, because your old, unused objects are still in memory.

Another example: If we allocate a new object in the Unity Update() function, which is called every frame, and immediately free the object, we have no idea how many such objects will be in memory. Our memory will fill up until the GC decides to run:

Update()
{
Foo myFooObject = new Foo();
myFooObject = null;
}

This can easily lead us to run out of memory and for the app to crash, even though there is more than enough memory for a single object.

A quick way to test this yourself is to allocate a bunch of arrays in a loop in Update(), then look at the GC in the profiler:

private void Update()
{
for (int i = 0; i < NumIterations; i++)
{
byte[] array = new byte[1000];
array = null;
}
}

If NumIterations is set to 100, you’ll see 100KB in the GC.

This shows us that the memory manager was unable to reuse the array’s memory, even though we set it to null.

GC Issue #2 — Performance

When the GC runs it can cause your frame rate to drop. Unity has tried to alleviate this problem by making the GC run over multiple frames, but the GC is still taking away processing power when it is running. Because we have no control over when the GC runs, this can lead to unexpected stuttering.

We don’t want our game’s FPS to drop right when the user needs to make a critical action.

Even worse, because every user will have the GC run at a different time, it can cause users to have issues that are impossible to replicate. We really don’t want the CEO to come over to our desk and complain that the game is stuttering right when he is about to shoot the enemy. Trust me it’s happened to me!

GC Issue #3 — Memory Fragmentation

The Garbage Collector can also lead to memory fragmentation. As we saw in issue #1, unused objects are only freed when the Garbage Collector runs. This can easily lead to holes in memory:

Let’s say we create a new object in memory:

Foo myFooObject = new Foo();

Our memory will look like this:

Then we set it to null:

myFooObject = null;

The object will still be in memory, until the GC runs.

Then if we allocate a new object:

Bar myBarObject = new Bar();

It will be placed after myFooObject, because that memory has not been cleared yet:

And once the GC runs, we’ll be left with fragmented memory:

GC Best Practices

Unfortunately we cannot turn off the Garbage Collector, but we can limit its effect on our code.

The best way to avoid triggering the garbage collector is to follow the Ideal Memory Allocations suggestion above:

  1. Allocate all the data you need at once: for example when loading the game or loading a level
  2. Deallocate objects in the reverse order that you allocated them, similar to how the Stack works.

Sometimes we have no way around triggering the Garbage Collector. For example, when exiting a level we need to deallocate all the memory used by the level. In this case we know the Garbage Collector will run at some point. To avoid having the GC run when we don’t want it to, we can manually call System.GC.Collect() at a time when the player will be least affected.

For example when switching menus or during a loading screen.

Part 5 — https://medium.com/@nitzanwilnai/dod-unity-memory-best-practices-part-5-6403c696764c

--

--