DEV Community

GameDevToolLab
GameDevToolLab

Posted on

A Deep Dive into C# Dispose: GC, Finalizers, and UnityEngine.Object Destruction

Assumptions

This article assumes Unity 6.2 or later. However, the GC-related explanations are based on the official Unity 6.2 documentation available at the time of writing. Future Unity versions may change implementation details or documentation wording.

The goal of this article is to avoid mixing up the lifetime-management mechanisms used by C# and Unity:

  • C# Dispose
  • GC
  • finalizers
  • UnityEngine.Object
  • Addressables
  • NativeContainer

In Unity 6.2, Incremental GC is enabled by default. On the other hand, System.GC.Collect() is treated as a full blocking collection, while UnityEngine.Scripting.GarbageCollector.CollectIncremental() is the Unity-side API for incremental collection.

In this article, we will separate the following concepts:

  • the C# managed heap
  • external resources closed by IDisposable
  • native Unity engine objects
  • GPU resources
  • Addressables reference counts
  • native memory allocated by NativeContainer types

If you think of all of these as “the GC will clean them up eventually,” Unity will hurt you. Possibly with a chair.

Introduction

When writing C#, you often hear statements like these:

C# has GC, so I do not need to free memory manually.

But I still need to call IDisposable.

Unity Texture and Material instances should be closed with Object.Destroy, not Dispose, if I created and own those runtime instances.

C# also has ~ClassName(), so can I just clean things up there?

With the right assumptions, each of these statements is true.

The confusing part is that several different lifetime-management systems are being discussed at once.

This article looks at Dispose not as a generic “cleanup method,” but as a way to express ownership. We will also cover why finalizers are not enough, why UnityEngine.Object uses Destroy, and why GC.Collect() is not a magic memory-cleanup button.

The short version is this:

Dispose is not magic for deleting memory.

Dispose is a contract for explicitly closing a lifetime that the GC cannot understand on its own.

Unity’s Destroy is a different mechanism. It tells the Unity engine to destroy an object managed by the engine-side object system.

“When references disappear, the object is destroyed” is not quite right

A common explanation of C# GC is:

Objects with no references are collected by the GC.

As a rough explanation, that is fine.

But C# GC is not reference counting. It does not count the number of references to each object and immediately destroy the object when the count reaches zero.

Roughly speaking, the GC checks whether an object is reachable from GC Roots.

GC Roots include references on the stacks of running threads, static fields, CPU register references, GC handles, and runtime-internal references related to finalization. These are representative examples, and the exact details depend on the runtime environment.

The GC traces the object graph from those roots. Objects that can be reached are considered alive. Objects that cannot be reached are candidates for collection.

For example, consider this cyclic reference:

public sealed class Node
{
    public Node Other;
}

void CreateCycle()
{
    var a = new Node();
    var b = new Node();

    a.Other = b;
    b.Other = a;
}
Enter fullscreen mode Exit fullscreen mode

a and b refer to each other.

In a reference-counting system, this kind of cycle can be a problem because the reference count never reaches zero.

But if neither a nor b is reachable from GC Roots, the GC can consider both objects collectible.

In C#, what matters is not “how many references exist,” but “whether the object is reachable from the live world.”

Do not explain Unity GC exactly like CoreCLR GC

If you search for C# or .NET GC, you will often find explanations about generational GC, Gen0 / Gen1 / Gen2, compaction, and LOH. Those topics are important for CoreCLR, but importing that model directly into Unity can be misleading.

Unity documentation explains that both Mono and IL2CPP use Boehm GC. In this article, terms like finalizer queue and finalizer thread are used as a general C#/.NET mental model. The exact implementation in Unity depends on the backend.

In real Unity projects, managed heap behavior is only one part of the picture. You also need to think about Unity engine native memory, assets, GPU resources, Addressables reference counts, and NativeContainer allocations.

C# finalizers are not called when references disappear

C# has this syntax:

public sealed class Sample
{
    ~Sample()
    {
        // finalizer
        // In real code, do not call Unity APIs or access other managed objects here.
    }
}
Enter fullscreen mode Exit fullscreen mode

This ~Sample() is a finalizer.

It may look like a cleanup method that runs as soon as the object becomes unnecessary, but that is not how it works.

The rough flow is:

1. Normal references to the object disappear.
2. Nothing happens immediately.
3. A GC occurs.
4. The GC determines that the object is unreachable.
5. The object becomes a finalization target.
6. The finalizer runs later.
7. The object memory may be reclaimed by a later GC.
Enter fullscreen mode Exit fullscreen mode

More precisely, objects with finalizers are registered for finalization when they are created. When the GC later finds them unreachable, they become eligible for finalizer execution.

The important point is:

A finalizer is not called at the moment references disappear.

Also, even when a GC happens, the finalizer body is not necessarily executed right there inside that GC.

You cannot control when a finalizer runs. It is also generally unsafe to touch other managed objects or Unity APIs from a finalizer. Having a finalizer can also make GC handling more expensive, and the actual memory for the object may only be reclaimed by a later GC.

A finalizer is not normal cleanup logic.

It is a last-resort safety net.

Files and sockets should be closed with Dispose. NativeContainers should be closed with Dispose. Runtime Texture and Material instances that you own should be closed with Destroy. These should not be left to finalizers.

Why Dispose exists

This is where Dispose comes in.

IDisposable is a very small interface:

public interface IDisposable
{
    void Dispose();
}
Enter fullscreen mode Exit fullscreen mode

There is no magic here.

Implementing IDisposable does not make the GC call it immediately. It also does not automatically dispose every field.

Dispose is just a method.

It matters because it expresses this contract:

This object owns a resource that the GC cannot fully handle.

Please explicitly close it when you are done.

For example, here is a simple logger class:

public sealed class FileLogger : IDisposable
{
    private readonly StreamWriter _writer;

    public FileLogger(string path)
    {
        _writer = new StreamWriter(path);
    }

    public void Log(string message)
    {
        _writer.WriteLine(message);
    }

    public void Dispose()
    {
        _writer.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode

FileLogger does not directly call an OS file handle API, but it owns a StreamWriter, and StreamWriter is IDisposable. So it is natural for FileLogger to be IDisposable too.

using (var logger = new FileLogger("log.txt"))
{
    logger.Log("start");
}
Enter fullscreen mode Exit fullscreen mode

With using, Dispose is called when the scope exits. Conceptually, it is close to a try/finally.

In C# 8 or later, you can also write:

using var logger = new FileLogger("log.txt");
logger.Log("start");
Enter fullscreen mode Exit fullscreen mode

But using var does not dispose the object immediately after the declaration line. It disposes the object when the variable goes out of scope. If you use it at the beginning of a long method, the lifetime may be longer than you expect. If you want a narrower lifetime, use the traditional using (...) { } block.

The key point is this:

using is not GC.

using does not make the GC run earlier. It is syntax for reliably calling Dispose.

GC checks reachability. Dispose closes a semantic lifetime. They are not the same thing.

Dispose means ending ownership, not deleting memory

You typically need Dispose in the following cases.

Owning an unmanaged resource directly

For example, you might hold a pointer or handle allocated through a native API.

The following code is a minimal example for explanation. If Dispose() is not called, this code leaks native memory. In production code, it is safer to wrap direct IntPtr ownership with something like SafeHandle whenever possible.

public sealed class NativeBuffer : IDisposable
{
    private IntPtr _ptr;

    public NativeBuffer(int size)
    {
        _ptr = Marshal.AllocHGlobal(size);
    }

    public void Dispose()
    {
        if (_ptr == IntPtr.Zero)
            return;

        Marshal.FreeHGlobal(_ptr);
        _ptr = IntPtr.Zero;
    }
}
Enter fullscreen mode Exit fullscreen mode

The memory pointed to by _ptr is not on the C# managed heap.

The GC can track the lifetime of the NativeBuffer object itself. But it does not know how to free the memory behind _ptr.

Should it call Marshal.FreeHGlobal? Should it call some custom Release function from a native library? The GC cannot infer that.

That is why Dispose is needed.

Owning fields that are IDisposable

Even if your class does not directly own native resources, if it owns an IDisposable object internally, it is usually natural for your class to be IDisposable too.

public sealed class SaveDataWriter : IDisposable
{
    private readonly BinaryWriter _writer;

    public SaveDataWriter(string path)
    {
        var stream = File.Create(path);
        _writer = new BinaryWriter(stream);
    }

    public void Write(int value)
    {
        _writer.Write(value);
    }

    public void Dispose()
    {
        _writer.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, BinaryWriter owns and closes the internal Stream, so Dispose() only calls _writer.Dispose().

If you create the BinaryWriter with leaveOpen: true, then the ownership of the Stream is separated, and you need to dispose the Stream elsewhere.

The point is not “dispose every field you have.”

The point is deciding who owns what.

Preventing use after disposal

Dispose does not delete the C# object itself. The reference can still remain after disposal.

writer.Dispose();
writer.Write("after dispose");
Enter fullscreen mode Exit fullscreen mode

To prevent this kind of misuse, classes often keep a _disposed flag and throw ObjectDisposedException when methods are called after disposal.

This is not about deleting memory. It is about marking the object as no longer usable after its owned resources have been closed.

You do not always need the full Dispose Pattern

C# samples often show the full Dispose Pattern with a finalizer, GC.SuppressFinalize(this), and protected virtual Dispose(bool disposing).

That pattern is meaningful, but it is not a ritual you need to write every time.

If you have a sealed class that only disposes owned IDisposable fields, a simple Dispose() is often enough. You do not need a finalizer.

If you directly own an IntPtr or other unmanaged resource, you need to think more carefully. Prefer SafeHandle where possible. If you directly own an unmanaged resource without SafeHandle, you need some finalizer-like safety net for missed disposal. If the class is intended to be inherited, consider the protected virtual Dispose(bool disposing) shape.

The Dispose Pattern is a design choice based on what you own.

GC.Collect is not a magic “free memory now” button

When memory usage grows, it is tempting to write:

GC.Collect();
Enter fullscreen mode Exit fullscreen mode

As a .NET API, GC.Collect() forces garbage collection. In Unity 6.2 documentation, System.GC.Collect() is described as a full blocking collection.

But the practical point is this:

Calling it does not guarantee that the amount of memory you expect will immediately go down.

First, objects still reachable from GC Roots are not collected.

private static byte[] _cache;

void Create()
{
    _cache = new byte[1024 * 1024 * 100];
    GC.Collect();
}
Enter fullscreen mode Exit fullscreen mode

Here, _cache is still referenced from a static field, so the GC considers it alive.

Objects with finalizers may also not be fully cleaned up by a single GC.

Most importantly, GC.Collect() primarily targets the managed heap.

The following are not fixed by GC.Collect() alone:

  • deterministically closing files or sockets that were not disposed
  • disposing NativeArray<T> or other NativeContainers
  • destroying Texture2D or Material instances that you created and own
  • Unity engine native memory
  • GPU resources
  • Addressables reference counts

Depending on implementation, files and sockets may eventually be closed through finalizers or SafeHandle, but that timing is not deterministic. The owner should close them with using / Dispose.

Also, Unity’s normal automatic GC may split work across multiple frames through Incremental GC, but that is not the same thing as System.GC.Collect(). If you want to advance incremental collection explicitly, that is the Unity-side GarbageCollector.CollectIncremental() API.

If GarbageCollector.GCMode is set to Disabled, Unity documentation says that calling System.GC.Collect() does not start a collection.

So GC.Collect() is not a fix for lifetime-management bugs. Before calling it, check whether references remain, whether Dispose was missed, and whether Destroy was missed.

Why Unity makes this more complicated

In Unity, C# code often operates on native Unity engine objects.

Texture2D texture = new Texture2D(1024, 1024);
Enter fullscreen mode Exit fullscreen mode

Texture2D looks like a C# object. But it inherits from UnityEngine.Object.

Objects like this are not just ordinary C# managed objects. Roughly speaking, they involve:

  • a C# wrapper
  • a native Unity engine object
  • sometimes GPU-side resources

The C# GC can basically track the C# managed object. Unity engine native memory and GPU resources are outside its direct responsibility.

So you should not expect the following code to free the native memory behind the texture:

Texture2D texture = new Texture2D(1024, 1024);
texture = null;
GC.Collect();
Enter fullscreen mode Exit fullscreen mode

This removes one C# reference and tries to run GC on the managed heap. But the native Unity object and GPU resources behind Texture2D are not directly released by the GC.

If you created and own a runtime UnityEngine.Object instance, such as a new Texture2D, you usually close that lifetime with Object.Destroy.

Texture2D texture = new Texture2D(1024, 1024);

// When you are done with it
UnityEngine.Object.Destroy(texture);
Enter fullscreen mode Exit fullscreen mode

But the important word here is ownership.

You should not destroy every UnityEngine.Object just because it inherits from UnityEngine.Object. Imported assets, shared assets loaded through Resources.Load, renderer.sharedMaterial, and Addressables asset results may be shared by other systems.

In Unity, destruction is not decided only by type.

It is decided by who created the object and who owns it.

UnityEngine.Object can become “kind of null”

If you have used Unity for a while, you have probably seen behavior like this:

UnityEngine.Object.Destroy(texture);

if (texture == null)
{
    UnityEngine.Debug.Log("null?");
}
Enter fullscreen mode Exit fullscreen mode

Unity’s Object has special == behavior that is different from normal C# objects.

The C# reference can still exist, but the native Unity object behind it may already be destroyed, or scheduled for destruction and treated as invalid by Unity.

Unity can make that state appear as == null.

In plain C#, if a reference remains, it is not null. But with Unity, the C# wrapper points to a native object whose lifetime must also be considered.

It is often useful to think of UnityEngine.Object as:

a C# wrapper with a handle to a native Unity engine object.

Also note that Unity’s special null behavior depends on the overloaded == null. ReferenceEquals, is null, null-conditional operators, and comparisons under generic constraints may not behave like Unity’s == null check.

Why UnityEngine.Object uses Destroy instead of Dispose

A natural question is:

If Texture owns native resources, why is it not IDisposable? Why not use Dispose?

The reason is that Unity’s Object.Destroy is not just resource cleanup.

Destroying a GameObject affects Components, Transform hierarchy, scene management, events, lifecycle callbacks, and more. Destroying a Component removes it from a GameObject. For MonoBehaviour, OnDisable and OnDestroy may be involved.

Unity destruction is not just “close the external resource owned by this object.”

It is an operation that removes an object from Unity’s engine-side object graph.

Also, Destroy usually does not immediately destroy the object on the spot. In the normal case, the object is actually destroyed after the current Update loop and before rendering.

This is useful for maintaining engine consistency during game-loop processing.

Destroy is not C# resource cleanup.

It is a destruction request to the Unity engine.

In Unity, choose the cleanup method by ownership, not just type

It is dangerous to remember cleanup rules only by type.

UnityEngine.Object means Destroy” is a helpful starting point, but in production code you need to ask:

Do I own this object?

Target Basic policy
Plain C# object Let GC handle it
C# object implementing IDisposable Dispose()
NativeArray<T> and other NativeContainers Dispose() or Dispose(JobHandle)
Runtime instance created with new Texture2D, new Material, or new GameObject Owner calls Object.Destroy()
Material implicitly cloned by renderer.material Usually tied to the Renderer lifetime. If you need to release it earlier than the Renderer, replace it, or keep ownership yourself, consider Destroy()
renderer.sharedMaterial, imported assets, shared asset references Do not destroy casually
Shared asset loaded through Resources.Load Do not destroy casually from the user side. Remove references and let it become a candidate for Resources.UnloadUnusedAssets() when appropriate
Asset from Addressables LoadAssetAsync Release the corresponding handle or result with Addressables.Release()
Instance created by Addressables InstantiateAsync Use Addressables.ReleaseInstance(gameObject) or Addressables.ReleaseInstance(handle)
Instance created by manually instantiating a Prefab loaded through Addressables Treat instance Destroy() and load-handle Release() separately. Keep the load handle alive until all instances are destroyed
Temporary RenderTexture from RenderTexture.GetTemporary RenderTexture.ReleaseTemporary()

Destroy, Dispose, and Release all close lifetimes, but they close different kinds of lifetimes.

Mixing them up is how memory bugs get their villain origin story.

Disposing NativeArray used by Jobs

Unity.Collections NativeContainer types such as NativeArray<T> implement IDisposable.

For synchronous code that does not involve Jobs, the owner disposes it:

var array = new Unity.Collections.NativeArray<int>(
    1024,
    Unity.Collections.Allocator.Persistent);

try
{
    // Synchronous usage
}
finally
{
    if (array.IsCreated)
        array.Dispose();
}
Enter fullscreen mode Exit fullscreen mode

If a Job is using the NativeArray, do not call Dispose() while the Job may still be accessing it. Either dispose it after handle.Complete(), or if the NativeContainer supports it, use Dispose(JobHandle) to make disposal depend on Job completion.

var handle = job.Schedule();
var disposeHandle = array.Dispose(handle);
disposeHandle.Complete();
Enter fullscreen mode Exit fullscreen mode

This example uses Allocator.Persistent because the point is to show that ownership must be explicitly closed.

How Resources.UnloadUnusedAssets fits in

Unity also has Resources.UnloadUnusedAssets().

It is a heavy operation for unloading unused assets and their corresponding Unity-side resources. It can be useful at large loading boundaries or scene transitions.

But it should not be used as a substitute for normal ownership management.

First, if unwanted references remain, the asset may not be considered unused. Static fields, event subscriptions, caches, and ScriptableObject references can keep assets alive.

At the same time, Resources.UnloadUnusedAssets() does not use exactly the same reachability rules as the C# GC. Unity 6.2 API documentation explains that Unity checks things like the GameObject hierarchy and static variables, but not the script execution stack.

So an asset referenced only by a temporary local variable is not something you should assume is always protected from UnloadUnusedAssets().

A safer mental model is:

  • Dynamically created UnityEngine.Object: decide the owner and call Destroy
  • Addressables result: use Release / ReleaseInstance depending on how it was acquired
  • NativeContainer: Dispose
  • Temporary RenderTexture: ReleaseTemporary
  • Large loading boundary: consider UnloadUnusedAssets if needed
  • Managed heap issue: inspect references and GC allocations

Common misconceptions in real Unity projects

Setting a variable to null destroys the object

_texture = null;
Enter fullscreen mode Exit fullscreen mode

This only removes one C# reference. If other references remain, the object is not collectible by GC.

For UnityEngine.Object types, removing the C# reference does not destroy the native Unity object. If you dynamically created and own a Texture or Material, decide the owner and call Destroy.

Dispose deletes the C# object

Dispose closes owned external resources. It does not delete the C# object immediately.

The reference still exists after Dispose, so use a _disposed flag or throw ObjectDisposedException if you need to prevent reuse.

Destroy means instant total disappearance

Destroy tells Unity to destroy the object, but in many cases destruction is not completed immediately on the spot.

If you keep touching the object in the same frame after calling Destroy, you may access an object that has already been scheduled for destruction. It is safer to treat a destroyed object as unusable immediately after calling Destroy.

GC.Collect also cleans up native memory

GC.Collect() primarily works on the managed heap.

Unity native memory, GPU resources, Addressables reference counts, and NativeContainer allocations have their own lifetime-management mechanisms.

When memory does not go down in Unity, first ask:

Who owns this thing, and where is that ownership closed?

Summary

The most important thing about Dispose is not to mix it up with GC.

GC checks whether managed objects are reachable from GC Roots. C# ~ClassName() is not deterministic destruction; it is a finalizer. Dispose is a contract for explicitly closing a lifetime the GC cannot understand.

Unity adds another layer with UnityEngine.Object. But you should not memorize “all UnityEngine.Object types must be destroyed.” Instead, ask whether you created and own the runtime instance. Shared assets, Addressables asset results, and renderer.sharedMaterial should not be casually destroyed by user code.

In practice:

  • Plain C# object: let GC handle it
  • IDisposable: close it with Dispose / using
  • NativeContainer: Dispose, or after Job completion / with Dispose(JobHandle)
  • Runtime UnityEngine.Object instance you created and own: Object.Destroy
  • Addressables: LoadAssetAsync uses Release, InstantiateAsync uses ReleaseInstance
  • Temporary RenderTexture: ReleaseTemporary
  • Large loading boundary: consider Resources.UnloadUnusedAssets() when appropriate

GC.Collect() does not fix lifetime-management bugs. GC manages the managed heap. Unity native memory, GPU resources, Addressables reference counts, and NativeContainer allocations each have their own lifetime rules.

Also, UnloadUnusedAssets() does not use the exact same reachability model as the C# GC. Assets reachable from Unity-side structures such as scene hierarchy or static variables are not treated as unused, but references that only exist on the script execution stack are not necessarily protected.

Dispose is a contract for closing ownership.

Destroy is a request to destroy Unity engine objects.

Keep those two separate, and a lot of Unity memory-management weirdness becomes much easier to reason about.

References

Top comments (0)