Kazimieras Mikelis' Game Devlog

Memory Management & Garbage Collection in Unreal Engine 5

All you need to know to master garbage collection and memory management in your Unreal Engine 5 projects.

Mikelis' Game Devlog

If you are a bit like me, in learning to program for Unreal Engine, you have eventually bumped into the garbage collector (GC). For some, this first encounter is agreeable — it’s a relief that GC looks after us and prevents memory leaks by design. For others, rubbing shoulders with the garbage collector isn’t that great. If we do not play nice with its rules, it will not play nice with our objects. This post is all about running through the basics of being on the right side of the garbage collector.

What is and isn’t Collected

Before we dive into some concrete examples, let’s familiarize ourselves with the core design of the Unreal Engine’s garbage collector. The principal idea is straightforward — the garbage collection mechanism in the engine keeps track of hard-referenced UObjects directly or indirectly by the root set. Once a UObject becomes unreferenced ("unreachable" in the engine's terms), it will be destroyed, and its memory will be freed up. This will happen when GC purges run periodically.

In practice, there are many different ways to create a hard reference to a UObject in Unreal Engine and preventing the GC of the referenced object. Here are the common ones:

  1. Holding a regular (not soft) reference to a UObject in an instance of a blueprint class.

  2. Holding a reference to a UObject through a raw C++ pointer declared a class as a member field and decorated with UPROPERTY():

    // If this points to an object at runtime, there will be a hard reference between 
    // an instance of this class and that object.
    UPROPERTY()
    UObject* HardUObjectReference;
    
  3. Using a raw C++ pointer in a container that's a member field decorated with UPROPERTY:

    UPROPERTY()
    TArray<UObject*> ListOfObjectsWithHardReferences;
    
  4. Adding the object/instance to a Root Set, ensuring that this UObject and all other ones with hard references from it will not be garbage collected:

    void UMyObject::UMyFunction()
    {
     UMyClass* MyObject = NewObject<UMyClass>(this);
      MyObject->AddToRoot();
      // At this point, MyObject will not be garbage collected until it's removed from 
      // the Root Set with RemoveFromRoot().
    }
    

Additionally, there are also ways to hold on to UObject in weak ways that will not prevent their garbage collection. Commonly this is done by:

  1. Passing around a raw C++ pointer in function arguments, or using it in function bodies:

    void UMyObject::UMyFunction()
    {
     UMyClass* MyObject = NewObject<UMyClass>(this);
      // At this point, the object pointed to by MyObject can be garbage-collected 
      // practically as soon as UMyFunction() finishes executing.
    }
    
  2. Using raw C++ pointers as members in classes and structs - although smarter IDEs like JetBrains Rider will warn us annoyingly that it will be garbage collected as they think we might intend the objects not to be.

  3. Using a weak UObject pointer such as TWeakObjectPtr or TSoftObjectPointer as a member:

    // This one can only point to objects in memory.
    UPROPERTY()
    TWeakObjectPtr<UMyClass> WeakUObjectReference; 
    
    // This one can point to objects in memory or unloaded assets, and it's useful in 
    // lazy-loading.
    UPROPERTY()
    TSoftObjectPointer<UMyClass> SoftUObjectReference;
    

However the UObject is referred to, its destruction can always be started by calling ConditionalBeginDestroy() on it, or adding the RF_PendingKill tag to it in the GUObjectArray, but this is a more advanced topic.

Garbage collection only works with UObjects and not other types like structs. Usually, memory management for structs and object instances not derived from the UObject class is handled with the Unreal Smart Pointer Library which provides the basic C++ 11 smart pointer alternatives. In some parts of the engine that are quickly being deprecated now, we can still find some memory management with raw pointers to structs. It is not advisable to do our own memory management in Unreal Engine when we have both garbage collection and smart poitners at our disposal.

Advanced Considerations

Even though the core design is simple, there are some implications and concepts that might not be immediately obvious. Here are a few of those:

  • Hard references generally imply object ownership, and soft references imply association. Generally, one UObject in Unreal Engine tends to only have one owner (this is called single ownership in object-oriented programming), although many other UObjects could be associated with it through weak pointers.

  • There are certain UObjects such as the UWorld that all actors belong to that are guaranteed by the engine to be hard-referenced from the Root Set when a world is used. However, when a level is unloaded, the engine orphans the UWorld and causes UObjects owned by it (such as actors in the level) to be garbage-collected.

  • There are some types of objects that will register with their owners when they are instantiated, and automatically create a hard reference to them. The best known example is AActors, who are automatically hard referenced from the level they are in when they are spawned. Another example is registered components and UActorComponents made with CreateDefaultSubobject in the actor's constructor.

  • Some raw pointers to a garbage-collected UObject will be nulled, but not all. Raw pointers that are member variables decorated with UPROPERTY and that are in containers decorated with UPROPERTY will be nulled when they are garbage-collected. All other raw pointers will continue to point to where the UObject was in memory. In contrast, all templated weak object pointers will be invalidated when objects they point to are garbage-collected.

    // This will be nulled. IsValid(ExampleOne) will return false as soon as the object 
    // this pointer points to starts being GC'd.
    UPROPERTY()
    UObject* ExampleOne; 
    
    // Elements of this will be nulled as they are garbage-collected. IsValid(ExampleTwo[i])
    // will likewise return false as soon as the object the element points to starts 
    // being GC'd.
    UPROPERTY()
    TArray<UObject*> ExampleTwo; 
    
    // This will NOT be nulled (except for some very rare, specific and advanced cases).
    // There is simply no way for the engine to know that this pointer exists by default. 
    // For debugging, testing and development build purposes, we can check this pointer 
    // with ExampleThree->IsValidLowLevel() but this is too unreliable and slow for 
    // shipping code.
    UObject* ExampleThree; 
    
    // This will be nulled. ExampleFour.IsValid() will become false as soon as the object
    // starts being GC'd. ExampleFour.IsStale() will in that case become true. This happens
    // irregardless of whether the member variable is decorated with UPROPERTY or not.
    TWeakObjectPtr<UObject> ExampleFour; 
    
    // This will also be nulled. This happens irregardless of whether the member variable
    // is decorated with UPROPERTY or not.
    TSoftObjectPointer<UObject> ExampleFive;
    

    Finally, remember that raw pointers captured and used in a lambda function will not be nulled, and this is especially important for lambdas used as call-backs. The general advice for that use case is to use a TWeakObjectPtr.

Listening for UObject Creation and Destruction

Sometimes we have a class that needs to listen to UObject creation and destruction for UObjects that it does not own, nor is associated with. In those cases, we can use the GUObjectArray which is a global symbol of FUObjectArray type that can inform all other objects implementing the FUObjectCreateListener and FUObjectDeleteListener interfaces about all UObject creations and deletions happening in the entire process.

It will generally always be more efficient to use a TWeakObjectPtr<> to understand when a particular object is destroyed, but the following pseudocode example demonstrates doing that without any knowledge of the instance destroyed.

// In .h

UCLASS()
public MYGAME_API AMyActor : public AActor, public FUObjectDeleteListener
{
  GENERATED_BODY()
public:
  virtual void BeginPlay() override;
  virtual void EndPlay(EEndPlayReason::Type EndPlayReason) override;

  // Begin FUObjectDeleteListener Interface
  virtual void NotifyUObjectDeleted(const UObjectBase* Object, int32 IdxInGUObjectArray) override;
  // End FUObjectDeleteListener Interface
};

// In .cpp
void AMyActor::BeginPlay()
{
    Super::BeginPlay();

  // Start listening for UObject destructions.
  GUObjectArray->AddUObjectDeleteListener(this);
}

void AMyActor::EndPlay(EEndPlayReason::Type EndPlayReason)
{
  Super::EndPlay(EndPlayReason);

  // Stop listening for UObject destructions.
  GUObjectArray->RemoveUObjectDeleteListener(this);
}

void AMyActor::NotifyUObjectDeleted(const UObjectBase* Object, int32 IdxInGUObjectArray)
{
  // This will be called every time when a UObject is destroyed. It would be wise to not
  // put too slow/time-complex/heavy code here. However, as seen below, this could be a 
  // useful function for debugging.

  // Some kind of a debugging assertion could go here.
  check(...); 

}

Garbage Collection Graph

So far, we have been talking about the practical rules and principles of garbage collection in Unreal. But what really happens in a garbage collection purge?

To give a high-level overview, the purge consists of two main steps:

  1. Building a garbage-collection graph - a tree of objects hard-referenced by something starting at the Root Set,
  2. Destroying unreachable/unrefrerenced objects.

Starting at step 1, the engine will start with the Root Set of objects and see what they are hard-referencing and pointing to through the reflection system. Whatever is being pointed to or referenced will be added to the “untouchable list” - the GC graph. Then it will examine what these added objects hard-reference, and it will add all of that to the graph as well. Moving along the graph this way, eventually, the garbage collection system builds a tree of all untouchable objects, and it will just delete everything else.

Here’s a basic illustration of a garbage collection graph:

Mikelis' Game Devlog

Seeing this “parent — child” relationship in the graph, one might reasonably think that there must be some logical overlap with Unreal Engine’s outer classes. When a new object is created, we have an opportunity to specify which object is its parent, or whether our new object should be in the transient package. These relationships are not the same as hard references. Although sometimes a hard reference will be created automatically by specific classes when their outers are what they expect.

Testing Pointers

As briefly mentioned above, we have a series of tools to see if the hard and soft pointers we have point to valid, non-garbage-collected UObjects, and we should do so often in code to provide alternative code paths or throw assertions when our game is executing outside of a safe state.

How we do this depends on the pointer type. For example, here are some assertion code samples for testing various pointers:

  • A raw C++ pointer can be tested using IsValid() and IsValidLowLevel():

    ensureAlwaysMsgf(IsValid(MyObject), 
      TEXT("Uh oh! MyObject has been garbage collected or will be imminently"));
    
    ensureAlwaysMsgf(MyObject->IsValidLowLevel(), 
      TEXT("MyObject pointer seems to point to a memory location which doesn't seem to have a UObject"))
    

    A lot of new Unreal Engine programmers make the mistake and assume that checking whether a raw C++ pointer is null like if(MyObject) is always sufficient to know whether the pointer points to a valid UObject. This is not true. For example, the UObject might have already begun destruction, or the pointer might be stale if it hasn't been decorated by UPROPERTY and reflected in Unreal Engine. Use IsValid() - if(IsValid(MyObject)) - for reflected pointers (those member pointers decorated by UPROPERTY or in member containers decorated by UPROPERTY). Use IsValidLowLevel() - if(MyObject->IsValidLowLevel()) - for non-reflected raw C++ pointers, but generally try and move away from non-reflected raw pointers as IsValidLowLevel() is slow and has some edge cases where it might return false positives.

  • A TWeakObjectPtr<> can be tested using .IsExplicitlyNull(), .IsValid() and .IsStale():

    ensureAlwaysMsgf(!MyObject.IsExplicitlyNull(), 
      TEXT("MyObject was Reset() or never initialized to point to a UObject. In other words, it's == nullptr."));
    
    ensureAlwaysMsgf(MyObject.IsValid(), 
      TEXT("MyObject doesn't point to a valid object."));
    
    ensureAlwaysMsgf(!MyObject.IsStale(), 
      TEXT("MyObject used to point to a UObject, but that UObject has been destroyed."));
    
  • A TSoftObjectPointer<> can be tested using .IsNull(), .IsValid() and .IsPending():

    ensureAlwaysMsgf(!MyObject.IsNull(), 
      TEXT("MyObject is not initialized or was Reset()."));
    
    ensureAlwaysMsgf(MyObject.IsValid(), 
      TEXT("MyObject is not pointing to a UObject in memory, although it might point to an unloaded asset."));
    
    ensureAlwaysMsgf(!MyObject.IsPending(), 
      TEXT("MyObject is not pointing to a UObject in memory, but it may later."));
    

Conclusion

In summation, the garbage collection system is a robust part of Unreal Engine that affords C++ programmers a lot of safety from memory leaks, as well as convenience. With this high-level discussion, I was aiming to introduce the system at a conceptual level, and I hope I have achieved that.

If you’d like to read more about the Garbage Collection in Unreal Engine, I suggest browsing the Unreal Engine Architecture Documentation as well as Unreal Engine Scripting with C++ Cookbook by William Sherif and Stephen Whittle. The latter recommendation will have a less conceptual overview with a focus on code examples.


An older version of this article was published on Devlog on 11 Jul, 2020. It was reviewed on 16 Oct, 2022.

#Engineering #Unreal Engine #Cpp #Garbage Collection #Game Development #Longform

- 7 toasts