Understanding Automatic Memory Management
When an object, string or array is created, the memory required to store it is allocated from a central pool called the heap. When the item is no longer in use, the memory it once occupied can be reclaimed and used for something else. In the past, it was typically up to the programmer to allocate and release these blocks of heap memory explicitly with the appropriate function calls. Nowadays, runtime systems like Unity's Mono engine manage memory for you automatically. Automatic memory management requires less coding effort than explicit allocation/release and greatly reduces the potential for memory leakage (the situation where memory is allocated but never subsequently released).
Value and Reference Types
When a function is called, the values of its parameters are copied to an area of memory reserved for that specific call. Data types that occupy only a few bytes can be copied very quickly and easily. However, it is common for objects, strings and arrays to be much larger and it would be very inefficient if these types of data were copied on a regular basis. Fortunately, this is not necessary; the actual storage space for a large item is allocated from the heap and a small "pointer" value is used to remember its location. From then on, only the pointer need be copied during parameter passing. As long as the runtime system can locate the item identified by the pointer, a single copy of the data can be used as often as necessary.
Types that are stored directly and copied during parameter passing are called value types. These include integers, floats, booleans and Unity's struct types (eg, Color and Vector3). Types that are allocated on the heap and then accessed via a pointer are called reference types, since the value stored in the variable merely "refers" to the real data. Examples of reference types include objects, strings and arrays.
Allocation and Garbage Collection
The memory manager keeps track of areas in the heap that it knows to be unused. When a new block of memory is requested (say when an object is instantiated), the manager chooses an unused area from which to allocate the block and then removes the allocated memory from the known unused space. Subsequent requests are handled the same way until there is no free area large enough to allocate the required block size. It is highly unlikely at this point that all the memory allocated from the heap is still in use. A reference item on the heap can only be accessed as long as there are still reference variables that can locate it. If all references to a memory block are gone (ie, the reference variables have been reassigned or they are local variables that are now out of scope) then the memory it occupies can safely be reallocated.
To determine which heap blocks are no longer in use, the memory manager searches through all currently active reference variables and marks the blocks they refer to as "live". At the end of the search, any space between the live blocks is considered empty by the memory manager and can be used for subsequent allocations. For obvious reasons, the process of locating and freeing up unused memory is known as garbage collection (or GC for short).
Optimization
Garbage collection is automatic and invisible to the programmer but the collection process actually requires significant CPU time behind the scenes. When used correctly, automatic memory management will generally equal or beat manual allocation for overall performance. However, it is important for the programmer to avoid mistakes that will trigger the collector more often than necessary and introduce pauses in execution.
There are some infamous algorithms that can be GC nightmares even though they seem innocent at first sight. Repeated string concatenation is a classic example:-
function ConcatExample(intArray: int[]) { var line = intArray[0].ToString(); for (i = 1; i < intArray.Length; i++) { line += ", " + intArray[i].ToString(); } return line; }
The key detail here is that the new pieces don't get added to the string in place, one by one. What actually happens is that each time around the loop, the previous contents of the line variable become dead - a whole new string is allocated to contain the original piece plus the new part at the end. Since the string gets longer with increasing values of i, the amount of heap space being consumed also increases and so it is easy to use up hundreds of bytes of free heap space each time this function is called. If you need to concatenate many strings together then a much better option is the Mono library's System.Text.StringBuilder class.
However, even repeated concatenation won't cause too much trouble unless it is called frequently, and in Unity that usually implies the frame update. Something like:-
var scoreBoard: GUIText; var score: int; function Update() { var scoreText: String = "Score: " + score.ToString(); scoreBoard.text = scoreText; }
...will allocate new strings each time Update is called and generate a constant trickle of new garbage. Most of that can be saved by updating the text only when the score changes:-
var scoreBoard: GUIText; var scoreText: String; var score: int; var oldScore: int; function Update() { if (score != oldScore) { scoreText = "Score: " + score.ToString(); scoreBoard.text = scoreText; oldScore = score; } }
Another potential problem occurs when a function returns an array value:-
function RandomList(numElements: int) { var result = new float[numElements]; for (i = 0; i < numElements; i++) { result[i] = Random.value; } return result; }
This type of function is very elegant and convenient when creating a new array filled with values. However, if it is called repeatedly then fresh memory will be allocated each time. Since arrays can be very large, the free heap space could get used up rapidly, resulting in frequent garbage collections. One way to avoid this problem is to make use of the fact that an array is a reference type. An array passed into a function as a parameter can be modified within that function and the results will remain after the function returns. A function like the one above can often be replaced with something like:-
function RandomList(arrayToFill: float[]) { for (i = 0; i < arrayToFill.Length; i++) { arrayToFill[i] = Random.value; } }
This simply replaces the existing contents of the array with new values. Although this requires the initial allocation of the array to be done in the calling code (which looks slightly inelegant), the function will not generate any new garbage when it is called.
Requesting a Collection
As noted above, a garbage collection can sometimes create a pause in execution, especially if the search for live objects turns out to be complicated. If this happens during gameplay then the result is likely to be noticeable but there may be other occasions in the game where a pause would be harmless (eg, when the screen is faded out or a menu is being shown). It is possible to request that the system perform a garbage collection even when the heap isn't full, so as to avoid a pause at a more inopportune time. This is done with the System.GC.Collect function:-
function NotVeryMuchHappeningInGame() { System.GC.Collect(); }
Note that the memory manager doesn't necessarily perform a collection when this function is called. It is merely a suggestion that it would be a good time for GC if it is necessary.
Reusable Object Pools
There are many cases where you can avoid generating garbage simply by reducing the number of objects that get created and destroyed. There are certain types of objects in games, such as enemy characters, which may be encountered over and over again even though only a small number will ever be in play at once. In cases like this, it is often possible to reuse objects rather than destroy old ones and replace them with new ones. For example, when an enemy dies in the game, its Game Object can simply be hidden rather than destroyed. Then, when a new enemy instance is needed, the "dead" enemy can be brought back wherever it is needed. This technique is known as object pooling and can be applied to many different types of objects.
Implementation
A simple way to implement an object pool is to start with an array of the type of object to be pooled; for this example, let's say we are pooling Game Objects that represent enemies in the game. The array should have enough elements to contain the maximum number of enemies that will be needed at any one time.
var enemyPool: GameObject[]; var enemyPrefab: GameObject; var maxNumEnemies: int; function InitializeEnemyPool() { enemyPool = new GameObject[maxNumEnemies]; for (i = 0; i < enemyPool.Length; i++) { enemyPool[i] = Instantiate(enemyPrefab); enemyPool[i].renderer.enabled = false; } }
An enemy can be obtained from the pool simply by copying one of the array elements. To make sure that the enemy doesn't get allocated again (until it goes out of use), the array element should be set to null. Rather than make the allocator search through the array for the first non-null element on subsequent allocations, it is best to use an integer variable to point to the first index that contains an unallocated enemy.
var nextAvailableEnemy: int = 0; function GetEnemy() { var allocatedEnemy = enemyPool[nextAvailableEnemy]; allocatedEnemy.renderer.enabled = true; enemyPool[nextAvailableEnemy] = null; nextAvailableEnemy++; return allocatedEnemy; }
Once an enemy dies or otherwise goes out of use, it should be returned to the pool. This can be done by finding the array index just before nextAvailableEnemy and placing the enemy there.
function ReleaseEnemy(doomedEnemy: GameObject) { doomedEnemy.renderer.enabled = false; nextAvailableEnemy--; enemyPool[nextAvailableEnemy] = doomedEnemy; }
Efficiency
Object pools work best for types of objects that are created at a rapid rate but which have a short life span and so only a small number are actually in play at once. For example, "swarm" enemies may arrive in countless waves from entry points but each one is quickly defeated. Similarly, there may be an inexhaustible supply of things like spell sparkles, projectiles and explosions but they quickly disappear from play. If fresh instances of such objects are created frequently then the available heap memory will be used up rapidly and garbage collections will happen regularly. However, if the objects are reused then no additional allocations will be made after the pool is constructed.
However, object pools must be used with care to get the best performance. One issue is that the creation of a pool reduces the amount of heap memory available for other purposes; frequent allocations from the reduced heap may actually result in more garbage collections. Another is that the time taken for a collection increases with the number of live objects. With these issues in mind, it should be apparent that performance will suffer if you allocate pools that are too large or keep them active when the objects they contain will not be needed for some time. Furthermore, many types of objects don't lend themselves well to object pooling. For example, the game may include spell effects that persist for a considerable time or enemies that appear in large numbers but which are only killed gradually as the game progresses. In such cases, the performance overhead of an object pool greatly outweighs the benefits and so it should not be used.
Further Information
Memory management is a subtle and complex subject to which a great deal of academic effort has been devoted. If you are interested in learning more about it then memorymanagement.org is an excellent resource, listing many publications and online articles. Further information about object pooling can be found on the Wikipedia page and also at Sourcemaking.com.
Page last updated: 2011-11-25