Memory Management & Garbage Collection Questions
Memory as a performance resource in garbage-collected and managed runtimes (JVM, .NET, Go, V8, CPython). Covers allocation patterns and allocation pressure (boxing, value versus reference types, stack versus heap), GC algorithms and behaviour (generational, mark-and-sweep, reference counting, concurrent and compacting collectors, GC roots), pauses and tuning, heap sizing, fragmentation, object pooling and buffer reuse, data-oriented layouts and per-object overhead, on-heap versus off-heap trade-offs, and detecting and fixing memory leaks with heap dumps and profilers, including leaks from caches, closures, goroutines, asyncio tasks and native extensions. Emphasizes how memory pressure affects throughput, latency and stability, including low-latency and frame-rate-bound applications. Pointer, ownership and manual-allocation semantics, embedded memory budgets, virtual memory and paging, generic data-processing memory tricks, and GPU framework memory are covered elsewhere.
In a managed runtime, how do stack and heap allocation differ, and how are value types and reference types stored? Using Java, C# or Go as an example, explain when the runtime can avoid a heap allocation and what that does to GC load.
Sample Answer
Short answer. The stack holds a thread's call frames (locals, arguments, return addresses) and is reclaimed instantly when a function returns, with no GC involvement. The heap holds objects whose lifetime is not tied to one call and is reclaimed by the garbage collector (GC). In a managed runtime the language decides what is allowed to live where, and the compiler or JIT decides what actually does when it can prove it is safe. Every allocation moved off the heap is one the GC never has to trace, count or copy.
Value types and reference types
- Java. Primitives (
int,double) are values held directly in a local variable, field or array slot. Objects are reached through references, and an object is conceptually allocated on the heap. A reference held in a local lives in the frame; the object it points to is heap. - C#. The Microsoft docs say a variable of a value type "contains an instance of the type", while a reference-type variable contains a reference to an instance. So a struct local holds its data in place (in the frame), a struct field sits inside its parent object, and an array of structs stores them inline. When a value type is boxed to
objector an interface, a heap object is allocated and the value copied into it. - Go. Any type can be held by value (
Point{}) or through a pointer (*Point), so there is no fixed value-type/reference-type split like C#'s struct and class, and no stack or heap keyword: the compiler decides placement from escape analysis (does a value outlive its function?). The Go GC guide says non-pointer Go values stored in local variables will likely not be managed by the GC at all, because Go arranges for their memory to be tied to the scope in which they are created, and that values whose lifetime the compiler cannot determine are said to "escape to the heap", where the GC does manage them.
When the runtime avoids a heap allocation
Escape analysis proves an object is only used inside the method that creates it (it does not "escape" through a return, a field store, or a call that keeps it). Scalar replacement (a JIT optimisation, mainly in HotSpot's optimising compiler) then does not build the object at all and keeps its fields in registers (the CPU's fastest storage, a handful of named slots inside the processor) or the frame. This happens only in JIT-compiled code (code the runtime compiles to machine code while the program runs, once a method is hot), typically after inlining (copying a called method's body into its caller) lets the compiler see that the object never leaves, and the language does not promise it.
Java demonstration: a Point created and consumed inside one method, versus the same method also storing the point in a static field.
import java.lang.management.ManagementFactory;
public class EscapeAnalysisAllocation {
record Point(double x, double y) {}
static Point leaked;
static double distanceSquared(double x, double y) {
Point p = new Point(x, y); // never leaves this method
return p.x() * p.x() + p.y() * p.y();
}
static double distanceSquaredEscaping(double x, double y) {
Point p = new Point(x, y);
leaked = p; // stored in a static field: escapes
return p.x() * p.x() + p.y() * p.y();
}
public static void main(String[] args) {
var tm = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
long id = Thread.currentThread().threadId();
double acc = 0;
int n = 5_000_000;
for (int i = 0; i < n; i++) acc += distanceSquared(i, 1); // warm up / compile
long b0 = tm.getThreadAllocatedBytes(id);
for (int i = 0; i < n; i++) acc += distanceSquared(i, 1);
long b1 = tm.getThreadAllocatedBytes(id);
for (int i = 0; i < n; i++) acc += distanceSquaredEscaping(i, 1);
long b2 = tm.getThreadAllocatedBytes(id);
System.out.println("non-escaping Point, bytes allocated per call: " + (b1 - b0) / (double) n);
System.out.println("escaping Point, bytes allocated per call: " + (b2 - b1) / (double) n);
System.out.println(acc > 0);
}
}
Run in an eclipse-temurin:21-jdk container (OpenJDK 21.0.12) with java EscapeAnalysisAllocation.java, then again with java -XX:-DoEscapeAnalysis EscapeAnalysisAllocation.java (in HotSpot flag syntax + switches an option on and - switches it off, so this turns escape analysis off). On a machine with several CPUs it prints:
(default)
non-escaping Point, bytes allocated per call: 0.0
escaping Point, bytes allocated per call: 32.0000096
(with -XX:-DoEscapeAnalysis)
non-escaping Point, bytes allocated per call: 32.0
escaping Point, bytes allocated per call: 32.0000096
With escape analysis on, the non-escaping point costs about zero bytes per call; with it disabled it costs 32 bytes, the same as the escaping one, on every run. The escaping point always allocates because a static field holds it. The default-mode figure for the non-escaping point is timing dependent, because the program compiles its own hot loop while it runs: on a container with only one or two CPUs the JIT may not have finished compiling when the measured loop starts, and the figure then lands somewhere between 0 and 32 instead of at 0.0. The last decimals of the 32.0000096 also vary slightly. Re-run it, or lengthen the warm-up loop, if the first line does not show the effect. The -XX:-DoEscapeAnalysis run is the stable half of the comparison.
Go demonstration, with the compiler's own report and a runtime measurement:
package main
import (
"fmt"
"testing"
)
type Point struct{ X, Y float64 }
var global *Point
//go:noinline
func sumLocal(x, y float64) float64 {
p := Point{x, y} // stays in this frame
return p.X*p.X + p.Y*p.Y
}
//go:noinline
func newPoint(x, y float64) *Point {
return &Point{x, y} // address returned: must outlive the frame
}
//go:noinline
func storeInInterface(x float64) any {
return x * 2 // float64 converted to interface: boxed on the heap
}
func main() {
fmt.Println("sumLocal allocs/call:", testing.AllocsPerRun(1000, func() { _ = sumLocal(1, 2) }))
fmt.Println("newPoint allocs/call:", testing.AllocsPerRun(1000, func() { global = newPoint(1, 2) }))
fmt.Println("storeInInterface allocs/call:", testing.AllocsPerRun(1000, func() { _ = storeInInterface(1.5) }))
}
Run on golang:1.23 (Go 1.23.12). The //go:noinline comments stop the compiler inlining those functions into main; without them it could fold the tiny functions into the caller and the measurement would no longer show each function's own behaviour. go build -gcflags=-m escape.go prints, among other lines:
./escape.go:20:9: &Point{...} escapes to heap
./escape.go:25:11: x * 2 escapes to heap
and go run escape.go prints:
sumLocal allocs/call: 0
newPoint allocs/call: 1
storeInInterface allocs/call: 1
The local struct was stack allocated and cost nothing. Returning its address (&Point{...} is newPoint) forced a heap allocation. Converting a float64 to any boxed it onto the heap, the same cost shape as boxing in Java or C#. (The Go guide points to -gcflags=-m=3 for the escape-analysis details; plain -m, used above, prints the shorter report shown.)
Effect on GC load
Fewer heap objects means a lower allocation rate, so the young generation (the region where new objects start) fills more slowly and young collections run less often; the live heap is unchanged but the churn drops. What one collection costs depends mostly on what survives it, not on how much garbage there was, so removing short-lived temporaries mainly cuts the number of collections and the work of creating and zeroing the objects. Pauses get shorter, and copying drops, only when the avoided objects would otherwise have survived a collection. In a hot path, avoiding allocation is often a cheaper fix than retuning the collector, but measure the allocation rate and the GC share of CPU before choosing.
Practical rules
- Keep hot-path temporaries small, local and non-escaping: no storing them in fields, no passing them to long-lived collections, no boxing them.
- Do not rely on escape analysis for correctness or sizing: it is an optimisation, so confirm with an allocation measurement or a
-gcflags=-mcheck, as above. - Prefer value types and primitive arrays for bulk numeric data (C# structs, Java primitive arrays) over one object per element.
What are boxing and unboxing, and why can boxing create allocation pressure in a managed runtime? Give an example in C# or Java, and say how you would spot it in a hot path.
Sample Answer
Short answer. Boxing wraps a primitive or value-type value (an int, a long, a struct) in a heap object so it can be used where an object is required. Unboxing reads the value back out. Each box is a new heap allocation unless the runtime reuses one, so a hot path that boxes millions of times per second feeds the garbage collector (GC) a steady stream of short-lived objects. That is allocation pressure: more frequent young collections and more CPU spent on GC.
What the runtimes document
- C#. Microsoft's documentation defines boxing as converting a value type to
object(or an interface it implements): the CLR "wraps the value inside a System.Object instance and stores it on the managed heap". It states that boxing allocates a new object and that boxing and unboxing are computationally expensive compared with simple assignments. Boxing is implicit and unboxing needs a cast. - Java. There is no implicit "everything is an object", but generics only work with reference types, so
List<Integer>,Map<Long, X>andOptional<Integer>holdInteger/Longobjects, andLong sum = 0L; sum += iis autoboxing (the compiler insertsLong.valueOfcalls).Integer.valueOfis documented to always cache values from -128 to 127 and may cache others.
Example in C#
using System;
using System.Collections.Generic;
int i = 123;
object o = i; // boxing: allocates an object on the heap holding a copy of 123
int j = (int)o; // unboxing: checks the type, copies the value out
static long Boxed()
{
long before = GC.GetAllocatedBytesForCurrentThread();
var list = new List<object>(); // every Add(int) boxes
for (int k = 0; k < 1_000_000; k++) list.Add(k); // 1,000,000 heap objects
GC.KeepAlive(list);
return GC.GetAllocatedBytesForCurrentThread() - before;
}
static long Plain()
{
long before = GC.GetAllocatedBytesForCurrentThread();
var list = new List<int>(); // ints stored directly in the array
for (int k = 0; k < 1_000_000; k++) list.Add(k);
GC.KeepAlive(list);
return GC.GetAllocatedBytesForCurrentThread() - before;
}
Boxed(); Plain(); // warm-up, so JIT work is not counted
Console.WriteLine($"List<object>: {Boxed()} bytes List<int>: {Plain()} bytes");
List<int> stores the ints directly in its backing array and does not box; List<object> or string.Concat("Answer", 42, true) (the Microsoft docs example, a version taking three object arguments) does. Built in Release as a .NET 8 console project (top-level statements) in mcr.microsoft.com/dotnet/sdk:8.0 and run repeatedly, the program printed essentially the same line on 1, 4 and 8 CPUs (the List<object> total moved by a few bytes between settings):
List<object>: 40777688 bytes List<int>: 8389080 bytes
The backing arrays account for most of the List<int> figure: it doubles from 4 slots up to 1,048,576, and those arrays sum to about 2.1 million slots x 4 bytes = 8.4 MB. List<object> needs the same doubling with 8-byte references (about 16.8 MB), and the remaining 40.78 - 16.78 = about 24 MB is the 1,000,000 boxes, 24 bytes each (object header, type pointer and the 4-byte value, rounded up). That 24 bytes per box is an observed figure on this runtime, 64-bit, and the printed total can differ by a few bytes under other GC settings such as server GC.
Example in Java, measured
import java.lang.management.ManagementFactory;
public class BoxingAllocation {
static final com.sun.management.ThreadMXBean TM =
(com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
static long sink;
static long sumPrimitive(int n) { long s = 0; for (int i = 0; i < n; i++) s += i; return s; }
static long sumBoxed(int n) { Long s = 0L; for (int i = 0; i < n; i++) s += i; return s; }
static long bytesFor(java.util.function.IntToLongFunction f, int n) {
long id = Thread.currentThread().threadId();
long before = TM.getThreadAllocatedBytes(id);
sink += f.applyAsLong(n);
return TM.getThreadAllocatedBytes(id) - before;
}
public static void main(String[] args) {
int n = 10_000_000;
for (int w = 0; w < 5; w++) { bytesFor(BoxingAllocation::sumPrimitive, n); bytesFor(BoxingAllocation::sumBoxed, n); }
System.out.println("primitive long sum, bytes allocated: " + bytesFor(BoxingAllocation::sumPrimitive, n));
System.out.println("boxed Long sum, bytes allocated: " + bytesFor(BoxingAllocation::sumBoxed, n));
Integer a = 127, b = 127, c = 128, d = 128;
System.out.println("127 == 127 (boxed): " + (a == b) + ", 128 == 128 (boxed): " + (c == d));
}
}
Run as java BoxingAllocation.java on eclipse-temurin:21-jdk (OpenJDK 21.0.12):
primitive long sum, bytes allocated: 0
boxed Long sum, bytes allocated: 119999808
127 == 127 (boxed): true, 128 == 128 (boxed): false
The long loop allocated nothing. The Long loop, over 10 million iterations, allocated about 120 MB on this run; the figure is the JVM's own per-thread allocation counter. Naive arithmetic predicts twice that: a boxed Long here is a 12-byte header (8-byte mark word plus a 4-byte compressed class pointer) plus an 8-byte value, 20 bytes, rounded up to the JVM's 8-byte alignment to 24, and 10,000,000 x 24 = 240,000,000 bytes. Re-running with the JIT's optimisation disabled (java -Xint, interpreter only) allocated 239,999,616 bytes, and so did -XX:-DoEscapeAnalysis; escape analysis is the JIT compiler's (the runtime optimiser's) check that an object never leaves the method, which lets it skip allocating that object. With it on, half the boxes in this loop were never allocated, which is why the default run shows 119,999,808. Under the default G1, switching off loop unrolling (-XX:LoopUnrollLimit=0) or the compiler's box elimination (-XX:-EliminateAutoBox) also gives 239,999,616, so there the saving needs both the loop optimisation and box elimination (under Serial, turning off loop unrolling alone still allocated nothing, so the exact mechanism depends on the collector and flags); the likely mechanism (inferred from those flags, not inspected in the compiler) is that the JIT unrolls the loop and the box handed from one unrolled iteration to the next never escapes, so it is removed. The count therefore varies with JVM version, flags and even the collector: the 120 MB figure is what G1 (the default here) and ZGC printed, while the same program under -XX:+UseSerialGC (the collector the JVM picks on its own on a machine with one CPU) or -XX:+UseParallelGC allocated nothing in the boxed loop, so the JIT evidently removed every box. A zero from a boxing microbenchmark therefore does not prove the production path is clean: measure with the production collector and flags. The contrast between the primitive loop and the boxed loop is the point where boxes are really created, as in the interpreter or with escape analysis off. The last line is the trap that comes with boxing: == on two Integer objects compares references, so 127 matches (cached) and 128 does not.
Spotting it in a hot path
- Allocation profile. JFR (Java Flight Recorder) and async-profiler can show allocations by type and call stack; look for
java.lang.Long,Integer,Doubleat the top. On .NET use an allocation profiler, or watch the per-thread allocated-bytes counter, and look forSystem.Int32or boxed struct types. - GC symptoms. Young collections that run many times per second while live data stays small (as in the GC log of a program with high churn), or a high allocation rate in monitoring.
- Code smells to grep. Generic collections of numeric types in inner loops,
objectorObjectparameters taking numbers, string formatting andString.Concat-style calls on numbers, interface-typed calls on structs, lambdas overFunction<Integer, Integer>instead ofIntUnaryOperator. - Benchmark, not guess. Compare allocated bytes before and after a change, as the program above does.
Fixes
Use primitive-specialised paths: arrays and IntStream/LongStream in Java, List<int> and generic methods with struct constraints in C#, primitive-keyed collections from a library when a map is on the hot path. Avoid object parameters for numeric data. Keep boxing outside the hot loop, and only optimise paths a profiler shows.
You inherit a C# analytics client inside a Unity game that causes intermittent frame drops from GC spikes. Propose a plan to hold 60 FPS while still capturing analytics reliably.
Sample Answer
Short answer. Treat it as an allocation problem first and a GC-settings problem second. Prove with the Unity Profiler that the spikes are garbage collection and which code allocates each frame. Then change the analytics client so that recording an event allocates nothing on the main thread (a pre-allocated ring buffer, a fixed-size array reused in a circle so the newest event overwrites the oldest slot when it is full, holding plain structs, small value-type records), and move serialization, disk and network work to a background thread that works in batches. Back that with incremental GC and explicit collections at safe moments. Verify with a scripted run on the lowest-spec target device, not the editor.
Why GC causes frame drops here
At 60 FPS every frame has 1000 / 60 = 16.67 ms for everything: scripts, physics, rendering submission. A garbage collector (GC) reclaims memory the program no longer references. The managed heap is the memory region the C# runtime manages and the collector scans; every new of a class instance, string or array lands there. Unity's manual says its collector is the Boehm-Demers-Weiser collector, that when a script allocates on the managed heap without enough free memory Unity runs the collector, which examines all objects in the managed heap, and that in non-incremental mode it is a stop-the-world collector that stops the main CPU thread (incremental mode, covered in Step 3, is the default). Because a collection examines all objects in the managed heap, its cost grows with the whole heap and every reference in it. Three things follow: allocate nothing in per-frame code, keep the managed heap small, and avoid reference-heavy structures that the collector must walk. Unity's guidance is to get managed allocations to 0 bytes per frame, or as close as you can.
A client that allocates a little every frame looks harmless and still adds up: illustratively, 1 KB per frame is 60 x 1,024 = 61,440 bytes per second, about 211 MiB per hour of garbage, and each collection pays for the full heap.
Step 1: measure before changing anything
- Capture the Profiler on the target device (a development build connected to the Profiler, not the editor). Find a spiking frame and check whether a GC collection appears in it, and sort the CPU Usage module hierarchy by the
GC Alloccolumn to find which methods allocate in normal frames. The manual tells you to watch the Profiler's GC Alloc counter for unexpected allocations. - Use Deep Profile (a Profiler mode that instruments every method call) or the Memory Profiler package sparingly to find the owner of the allocation; deep profiling distorts timing, so use it only to attribute allocations, not to judge frame time.
- Record a baseline: frame-time percentiles (p95 and p99 are the frame times that 95% and 99% of frames come in under, so p99 shows the worst 1 in 100 frames that an average hides; plus the worst frame) over a scripted 5-minute playthrough, plus GC Alloc bytes per frame. Without it you cannot show the fix worked.
Step 2: make capturing an event allocation-free
The analytics client is the suspect, so redesign its hot path (the call the game makes to record an event):
- Events are structs in a pre-allocated array. A class instance is a separate object on the managed heap that the collector must track; a struct is a value type stored inline, so an array of structs is one single heap object and writing a struct into a slot copies its fields into memory that already exists, which allocates nothing. For example, an event of an int id, an int sequence number, a long timestamp and four floats is 4 + 4 + 8 + 16 = 32 bytes. A ring buffer of 4,096 of them is 4,096 x 32 = 131,072 bytes (128 KiB), allocated once at start-up. At a peak of 200 events per second it holds 4,096 / 200 = 20.48 seconds of backlog. Recording an event writes into the next slot and advances an index: no new objects, no strings, no boxing.
- No strings on the hot path. Event names become integer ids from a table built at load time; parameter values are numbers. The manual notes strings are immutable, so concatenation creates temporary strings; build text only on the worker thread, with a reused
StringBuilderor byte buffer. - Remove the other usual sources. The manual calls boxing (a value type such as
intconverted toobject, for example by passing it to a method that takesobject) one of the most common unintended allocations in Unity projects, warns about closures (lambdas or local functions that capture variables from the surrounding method), which need a compiler-generated object to hold the captured variables. Separately, a C#paramsmethod allocates an array on every call. Replace them with typed overloads, cached delegates and non-capturing code. For temporary collections use Unity'sUnityEngine.PoolAPIs, which the manual recommends for reusing frequently created objects. - A background worker drains the ring in batches. It copies a batch, serializes into a reused byte buffer, writes it to disk, and sends it over the network on a timer (for example every 2 seconds) with a transport your target platforms support. If the transport requires the main thread, hand over one finished byte buffer per batch, at a fixed point in the frame, so the cost is one predictable allocation per batch rather than one per event.
The recording path and the drain in code. This is plain C# for a console app, which keeps the pattern runnable anywhere; in Unity the same classes are used from a MonoBehaviour and a worker thread:
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
struct AnalyticsEvent // a struct is stored inline in the array, not as separate heap objects
{
public int Id;
public int Seq;
public long Timestamp;
public float A, B, C, D;
}
sealed class EventRing
{
readonly AnalyticsEvent[] slots; // allocated once
readonly object gate = new object(); // protects head, count and slots
int head; // index of the oldest event
int count;
public long Dropped;
public EventRing(int capacity) { slots = new AnalyticsEvent[capacity]; }
public void Record(int id, int seq, long ts, float a, float b, float c, float d)
{
lock (gate)
{
if (count == slots.Length) // full: overwrite the oldest event
{
head = (head + 1) % slots.Length;
count--;
Dropped++;
}
int slot = (head + count) % slots.Length; // next free slot
slots[slot] = new AnalyticsEvent { Id = id, Seq = seq, Timestamp = ts, A = a, B = b, C = c, D = d };
count++;
}
}
// Worker side: copy up to batch.Length events into a reused array, then free their slots.
public int Drain(AnalyticsEvent[] batch)
{
lock (gate)
{
int n = Math.Min(count, batch.Length);
for (int i = 0; i < n; i++) batch[i] = slots[(head + i) % slots.Length];
head = (head + n) % slots.Length;
count -= n;
return n;
}
}
}
static class Program
{
static void Main()
{
Console.WriteLine("event size = " + Unsafe.SizeOf<AnalyticsEvent>() + " bytes");
var ring = new EventRing(4096);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++) ring.Record(1, i, Stopwatch.GetTimestamp(), 0f, 1f, 2f, 3f);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine("bytes allocated by 10,000 Record calls = " + allocated);
Console.WriteLine("dropped (no worker running) = " + ring.Dropped);
var batch = new AnalyticsEvent[256];
int drained = ring.Drain(batch); // one batch of 256
Console.WriteLine("first batch: " + drained + " events, oldest surviving Seq = " + batch[0].Seq);
// Concurrent phase: the game thread records 200,000 events while a worker drains. The worker
// spins (a real one would sleep between batches) so the two threads contend on the lock.
var ring2 = new EventRing(4096);
long drained2 = 0, bad = 0;
bool done = false;
var worker = new Thread(() =>
{
var b = new AnalyticsEvent[256];
long last = -1;
while (true)
{
bool finished = Volatile.Read(ref done);
int k = ring2.Drain(b);
for (int j = 0; j < k; j++) { if (b[j].Seq <= last) bad++; last = b[j].Seq; }
drained2 += k;
if (finished && k == 0) break;
}
});
worker.Start();
for (int i = 0; i < 200_000; i++) ring2.Record(1, i, 0, 0, 0, 0, 0);
Volatile.Write(ref done, true);
worker.Join();
Console.WriteLine("recorded = 200000, drained + dropped = " + (drained2 + ring2.Dropped) + ", out of order = " + bad);
}
}
Run in mcr.microsoft.com/dotnet/sdk:8.0 (.NET 8, Release build), it prints:
event size = 32 bytes
bytes allocated by 10,000 Record calls = 0
dropped (no worker running) = 5904
first batch: 256 events, oldest surviving Seq = 5904
recorded = 200000, drained + dropped = 200000, out of order = 0
Reading it: Record writes into the next slot ((head + count) % slots.Length, with head the oldest event) and increments count; the GC.GetAllocatedBytesForCurrentThread difference is 0 bytes across 10,000 calls, which is the property that keeps the collector idle. With nothing draining, the 4,096-slot ring keeps the newest 4,096 events and counts 10,000 - 4,096 = 5,904 drops; the oldest surviving Seq is 5,904, as drop-oldest promises. The worker's Drain copies up to 256 events into a reused array, so the batch costs no per-event allocation either. Thread safety: the game thread and the worker both touch head, count and the slots, so both take the same lock. The lock is held only for one struct write or one 256-element copy, so contention is brief, and it is what makes overwriting the oldest slot safe while the worker is copying: they can never interleave. The one real risk of a lock on the game thread is a worker that is descheduled while it holds the lock, which makes the game thread wait; that follows from how locks work, so measure it in the Profiler on the target device rather than assuming it. If the Profiler ever shows it, use Monitor.TryEnter on the game thread and count the event in Dropped instead of waiting. The concurrent run checks two things on every run: of 200,000 recorded events, every one is either drained or counted in Dropped, and the events the worker receives arrive in strictly increasing Seq order, so a half-written or duplicated slot would show as out of order. The worker spins on purpose so the two threads fight over the lock; that is what makes the check able to fail. Delete the lock (replace lock (gate) with if (true)) and most re-runs printed a wrong total and a non-zero out of order count (the exact figures change from run to run and with the CPU count, and an occasional run still passed on both checks, so run the broken version several times before concluding anything), whereas a worker that sleeps 1 ms between drains and checks only the total still printed a matching total with the lock removed, so that weaker version proves nothing about the lock. A lock-free single-producer ring is possible but needs careful memory-ordering work; a short lock is the simpler default for this rate.
Step 3: keep the collector from choosing bad moments
- Unity's manual says incremental GC is enabled by default (Project Settings > Player). It spreads the work across several frames in small slices instead of one long stop. Its documented limits: write barriers (extra code on reference changes) add overhead to managed code; if references change too fast the marking may never finish; and the web platform does not support it. So it reduces spikes but does not replace removing allocations.
- The
GarbageCollector.GCModeAPI can stop automatic collection for a critical stretch. UseGarbageCollector.Mode.Manual: it disables automatic collections but still lets you callGC.Collect()(orGarbageCollector.CollectIncremental()) yourself at moments where a pause is invisible (loading screens, level transitions, menus). Do not useMode.Disabledfor this: in that modeGC.Collect()has no effect and the manual warns the heap grows until the operating system shuts the application down. - Warm up pools and the ring buffer during loading, so the first minutes of play do not allocate.
Step 4: capture analytics reliably
Smoothness must not cost data, so decide the loss policy deliberately:
- Bounded buffer with drop-oldest and a counter. If the worker cannot keep up for 20 seconds, overwrite the oldest events and record a "dropped N events" count in the next batch so the backend knows data is missing.
- Persist batches to disk before upload, and delete them only after the server acknowledges. Flush on pause and quit, because mobile platforms may kill the app without warning.
- Give each event a sequence number and session id so retries after a lost acknowledgement can be de-duplicated server-side. At-least-once delivery means the client resends until it is acknowledged, so the server may receive an event twice; de-duplicating means discarding the second copy by its session id and sequence number.
Verify
Repeat the scripted playthrough on the same device: GC Alloc per frame in gameplay should be 0 B or close to it, with no GC collection inside gameplay frames, and p99 frame time inside 16.67 ms. Check the analytics side separately by comparing events generated with events received over a flaky-network test (airplane mode toggled mid-session).
Trade-offs and pitfalls
- A pre-allocated buffer costs fixed memory (128 KiB in the example), which is cheap next to one garbage spike.
- Do not fix this with
GC.Collect()every frame or by disabling the GC for the whole session: the first makes every frame pay for a full-heap collection, the second grows the heap until the OS kills the app. - If the spikes come from third-party SDKs or from Unity's own APIs, the profile will show it. Then reduce how often you call them, or batch.
In a Unity game running at 60 FPS, when does preferring a C# struct over a class reduce GC pressure, and when does it backfire? Cover copying versus boxing and where the data actually lives.
Sample Answer
Direct answer
A struct (a C# value type) reduces garbage-collector (GC) pressure when it is small, immutable and lives somewhere that needs no separate heap object: a local variable, a field inside another object, or an element of an array. Then creating one costs no allocation and leaves nothing for the collector to find. It backfires when the struct gets boxed (wrapped in a heap object), when it is large and copied on every call, or when it is stored in a place that forces a copy on every access. "Struct means stack" is the wrong mental model: a value type lives wherever its owner lives.
Why 60 FPS makes this matter
At 60 frames per second (FPS) each frame has 1000 / 60 = 16.67 ms for everything: input, physics, scripts, rendering. Unity's own documentation says its garbage collector is not generational (it cannot cheaply sweep small, frequent temporary allocations) and is non-compacting (it does not move objects to close gaps). So temporary heap objects accumulate until a collection runs, and that collection is a single pause that can eat the frame budget. Unity offers an incremental mode that spreads the work over several frames, but its documentation warns that if references change too fast the marking phase never finishes and the collector falls back to a full non-incremental collection. Allocation avoidance on the frame path is therefore the primary tool, not collector tuning.
Where the data actually lives
| Where the struct value is | Where its bytes are | Heap object for the GC? |
|---|---|---|
| Local variable or parameter | The stack frame (the per-thread region for temporary data) | No |
| Field of a class | Inside that class instance, on the managed heap | No extra object; it is part of its owner |
Element of T[] or List<T> | Inline in the array, on the heap, one contiguous block | No extra object per element |
Cast to object or to an interface it implements | A new box object on the heap | Yes, one per boxing |
| Local captured by a lambda | In a compiler-generated class on the heap | Yes |
Unity's documentation states that all reference-type objects and all boxed value-type objects must be allocated on the managed heap, and that closures make the compiler generate a class to hold captured variables. Microsoft's design guidance says value types are allocated on the stack or inline in their containing types, and that value-type arrays store the actual instances inline, which also gives better locality (neighbouring elements are adjacent in memory, so the CPU cache is used well).
Copying versus boxing
- Copying is what a struct always does: assignment, passing an argument and returning a result copy the whole value (Microsoft's struct reference says exactly this). Copying a 12-byte
Vector3(three 4-byte floats) is trivial. Copying a 28-byteBullet(seven floats: position, velocity, remaining life) three times per frame per bullet is not free, but it creates no garbage. Its cost is CPU time, not GC time. - Boxing is the GC problem.
object o = bullet;, passing a struct to a non-generic method that takesobject, formatting it into a string, or using it through an interface it implements allocates a heap object each time. Unity's documentation calls boxing one of the most common sources of unintended temporary allocations and notes it appears in profiler call stacks asBox(...).
Worked example: 2,000 bullets
Class version: class Bullet { Vector3 pos; Vector3 vel; float life; } stored in a List<Bullet>. The list holds 2,000 references to 2,000 separate heap objects. Spawning 50 bullets per frame at 60 FPS creates 50 x 60 = 3,000 heap objects per second, all garbage once the bullets expire, and the collector must also track the 2,000 live ones.
Struct version: struct Bullet with the same seven floats (28 bytes, no padding because every field is 4 bytes) in one preallocated Bullet[2000]. The array is a single object of about 2,000 x 28 = 56,000 bytes allocated once. Spawning writes into a free slot; expiring marks it free. Steady-state allocation per frame is zero.
struct Bullet { public Vector3 pos; public Vector3 vel; public float life; }
Bullet[] bullets = new Bullet[2000]; // allocated once, outside the frame loop
void Tick(float dt)
{
for (int i = 0; i < bullets.Length; i++)
{
ref Bullet b = ref bullets[i]; // a reference to the element in the array, not a copy
b.pos += b.vel * dt;
b.life -= dt;
}
}
Two details make this work. ref points at the array element, so the update modifies the real data. Writing var b = bullets[i]; b.life -= dt; would modify a copy and silently do nothing. And a List<Bullet> cannot be updated this way: its indexer returns a copy, so list[i].life -= dt is rejected by the compiler.
When the struct backfires
- Boxing in disguise: logging a struct,
string.Format, storing it in anobjectfield, a non-generic collection, or calling it through an interface. Each call allocates. Keep structs on generic paths and avoid interface calls in per-frame code. - Large structs: Microsoft's guidance is to avoid a struct unless it represents a single value, is under 16 bytes, is immutable and is not boxed often. A 28-byte struct breaks the size rule, so process it in place with
reforinparameters instead of passing it by value. - Mutable structs copied by accident: the silent no-op above is the classic bug. Make small value types
readonly structand mutate big ones only throughref. - Reference fields inside the struct: a struct holding a
stringor an array copies only the reference. The referenced objects are still on the heap and still garbage when dropped. - A struct inside a churned class: if you
new Enemy()every spawn andEnemycontains the struct, the heap allocation is theEnemy, so nothing was saved. Pool the owner. - Captured by a closure: a struct local used inside a lambda is moved into a heap-allocated closure object.
Recommendation
For per-frame data with no identity (positions, velocities, particles, hit results): small readonly structs, or large structs in preallocated arrays updated through ref. For things with identity and a lifecycle (enemies, UI elements): classes created at load time or taken from a pool. Verify with the Profiler on a device build, looking for Box frames and per-frame allocation in the call hierarchy, because the compiler can allocate in places the source does not show. What would flip the decision: if the struct grows past the size where per-frame copying shows in the CPU profile, or if it must be used through an interface, a pooled class is the better choice.
Compare an object-per-record design with a data-oriented layout for processing millions of small records in memory. How does each affect allocation rate, cache behaviour and GC cost?
Sample Answer
The two designs. Object-per-record means each record is its own heap object (a class Order with id, price, qty), and your collection is an array of references to them. Data-oriented layout (also called structure of arrays, as opposed to the array of structures that object-per-record gives you) means one primitive array per field: int[] id, double[] price, int[] qty, and record i is index i in each. Same data, different memory shape.
Where the cost of object-per-record comes from (JVM numbers). Every Java object carries a header (the runtime's bookkeeping: type pointer plus GC and lock bits), and sizes round up to a multiple of 8 bytes. With compressed object pointers (the JVM stores each reference in 4 bytes instead of 8, which is the default for heaps under about 32 GB) one Order is 12 bytes of header + 4 (int) + 8 (double) + 4 (int) = 28, rounded to 32 bytes, plus a 4-byte reference slot in the array: 36 bytes per record against 16 bytes of real data in the column layout. For 5 million records: 5,000,000 x 36 = 180 MB (about 172 MiB) vs 5,000,000 x 16 = 80 MB (about 76 MiB). That is 2.25x before any GC overhead, and the header ratio is worse for smaller records. The measured heap growth below matches for the object layout (172 MiB against an estimate of 171.7) and lands about 3 MiB above the estimate for the columns (79 MiB against 76.3). The gap is G1's region accounting: with a 2 GiB heap G1 uses 1 MiB regions, and an array larger than half a region is a humongous object that occupies whole regions. The double[] of 38.15 MiB takes 39 regions and each int[] of 19.07 MiB takes 20, which is 39 + 20 + 20 = 79 MiB. Setting -XX:G1HeapRegionSize=32m moves the column figure well above 100 MiB, which shows the accounting, not the data, is what moves. The used-heap figures are therefore specific to G1 and this region size; other collectors report different totals for the same data.
Measured (JDK 21 container on an arm64 laptop, G1, -Xms2g -Xmx2g, 5 million records, each layout in its own JVM so one cannot disturb the other; this is a plain System.nanoTime harness, not JMH, so read the ratios, not the milliseconds). The heap figures and GC counts are essentially fixed (the object layout's live-heap figure moved by 1 MiB on an occasional re-run); the timings vary from run to run and machine to machine, so the lines below are one run and the ranges over repeated re-runs follow them:
object-per-record: live heap +172 MiB, 1 GCs while building, sequential scan 3 ms, random-order scan 61 ms, full GC 11 ms
column arrays: live heap +79 MiB, 0 GCs while building, sequential scan 3 ms, random-order scan 45 ms, full GC 2 ms
(The block above is from a 14-CPU container. Across repeated runs the sequential scan was a near tie and the random-order scan was consistently slower for the object layout by a modest margin. The full-GC time depends strongly on the CPU count, because the collector marks with parallel worker threads: the same program in containers limited to two CPUs, and then to one, took several times longer for the object layout's full GC, while the column layout's stayed at a millisecond or two. The live-heap figures and the GC counts did not change with the CPU count.) Two details of the program: usedMiB calls System.gc() twice so that garbage left over from the work just done is collected before the heap is read, which makes the before and after readings comparable; and the order array is a seeded shuffle of the indexes 0 to N - 1, a random visiting order that defeats the hardware prefetcher and is identical for both layouts. The program, run as javac RecordLayoutDemo.java && java -XX:+UseG1GC -Xms2g -Xmx2g RecordLayoutDemo objects and again with columns:
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.Arrays;
import java.util.Random;
public class RecordLayoutDemo {
static final int N = 5_000_000;
static final class Order { // object-per-record layout
final int id;
final double price;
final int qty;
Order(int id, double price, int qty) { this.id = id; this.price = price; this.qty = qty; }
}
static long gcCount() {
return ManagementFactory.getGarbageCollectorMXBeans().stream().mapToLong(GarbageCollectorMXBean::getCollectionCount).sum();
}
static long usedMiB() {
System.gc();
System.gc();
Runtime rt = Runtime.getRuntime();
return (rt.totalMemory() - rt.freeMemory()) >> 20;
}
static volatile double sink;
static long medianMs(Runnable r) {
long[] t = new long[9];
for (int i = 0; i < t.length; i++) {
long t0 = System.nanoTime();
r.run();
t[i] = (System.nanoTime() - t0) / 1_000_000;
}
Arrays.sort(t);
return t[t.length / 2];
}
static long fullGcMs() {
long[] t = new long[5];
for (int i = 0; i < t.length; i++) {
long t0 = System.nanoTime();
System.gc();
t[i] = (System.nanoTime() - t0) / 1_000_000;
}
Arrays.sort(t);
return t[t.length / 2];
}
public static void main(String[] args) {
String mode = args[0];
int[] order = new int[N]; // random visiting order, same for both layouts
for (int i = 0; i < N; i++) order[i] = i;
Random rnd = new Random(42);
for (int i = N - 1; i > 0; i--) { int j = rnd.nextInt(i + 1); int t = order[i]; order[i] = order[j]; order[j] = t; }
long base = usedMiB();
long g0 = gcCount();
long seqMs, randMs, liveMiB;
if (mode.equals("objects")) {
Order[] orders = new Order[N];
for (int i = 0; i < N; i++) orders[i] = new Order(i, i * 0.01, i % 7 + 1);
long gcs = gcCount() - g0;
liveMiB = usedMiB() - base;
seqMs = medianMs(() -> { double s = 0; for (Order o : orders) s += o.price * o.qty; sink = s; });
randMs = medianMs(() -> { double s = 0; for (int i : order) { Order o = orders[i]; s += o.price * o.qty; } sink = s; });
System.out.printf("object-per-record: live heap +%d MiB, %d GCs while building, sequential scan %d ms, random-order scan %d ms, full GC %d ms%n", liveMiB, gcs, seqMs, randMs, fullGcMs());
} else {
double[] price = new double[N];
int[] qty = new int[N];
int[] id = new int[N];
for (int i = 0; i < N; i++) { id[i] = i; price[i] = i * 0.01; qty[i] = i % 7 + 1; }
long gcs = gcCount() - g0;
liveMiB = usedMiB() - base;
seqMs = medianMs(() -> { double s = 0; for (int i = 0; i < N; i++) s += price[i] * qty[i]; sink = s; });
randMs = medianMs(() -> { double s = 0; for (int i : order) s += price[i] * qty[i]; sink = s; });
System.out.printf("column arrays: live heap +%d MiB, %d GCs while building, sequential scan %d ms, random-order scan %d ms, full GC %d ms%n", liveMiB, gcs, seqMs, randMs, fullGcMs());
if (id[N - 1] != N - 1) System.out.println("unreachable");
}
}
}
Reading the numbers, one effect at a time.
- Allocation rate. Building the object layout makes 5 million small allocations (5,000,000 x 32 bytes = 160 MB through the young generation) and triggered a collection; the column layout makes three array allocations and no collection. In a service that rebuilds such data per request, per-record allocation is what sets how often young collections run, and their CPU time is taken from request handling.
- GC cost. A tracing collector pays per live object when it marks: 5 million objects plus an array of 5 million references to follow, versus three arrays of primitives that contain no references, so the collector does not look inside them. The full-GC times printed above (11 ms against 2 ms, from a 14-CPU container) point the same way, and the gap widens when fewer CPUs share the marking work. Records that die young are cheap for a generational collector, but records that live for minutes get copied between generations and re-marked.
- Cache behaviour depends on the access pattern. A sequential scan was a near tie (3 ms vs 3 ms in the block above) because objects allocated in a loop sit next to each other and the hardware prefetcher (it fetches upcoming cache lines before you ask) hides the header overhead. In random order the object layout was slower, 61 ms against 45 ms in the block above (a modest gap, well under 2x at this size). A cache line is the 64-byte block the CPU fetches from memory at a time: touching one byte of a record pulls in its whole line. In this scan a random visit costs about two line fetches in either layout (objects: the reference slot in the array, then the
Orderitself, which is 32 bytes, so half of its line belongs to a neighbour; columns: one line inprice, one inqty), so the line count alone does not explain the gap. Footprint (172 against 79 MiB) does not explain it alone either: re-running the same program with the record count changed (the constant is replaced bystatic final int N = Integer.getInteger("n", 5_000_000);and the program is run with-Dn=1500000or-Dn=3000000) kept the footprint ratio near 2.2x at every size, yet the random-order gap was about 3x at 1.5 million records and clearly smaller at 3 million and smaller again at 5 million, where both layouts are probably bound by main-memory latency. A plausible extra contributor is that the object layout's two reads are dependent (the address of theOrdercomes from the array slot, so the second miss cannot start until the first returns) while the two column reads are independent and can overlap; this program does not isolate that, so treat it as a hypothesis. Columns win most when a loop reads only some fields (a sum ofprice * qtynever touchesid, and a loop overpricealone touches one line per record) and lose their edge when every access needs all three fields: three arrays then cost three line fetches per record against two for the object layout.
Trade-offs, and what I would do. Columns cost you convenience: no per-record identity, no polymorphism, more index bookkeeping, and mutation that must keep arrays in step. Objects keep the code simple and are fine up to hundreds of thousands of records. My recommendation: keep objects for domain logic and switch to columns (or a packed ByteBuffer or memory segment) only for the large, read-mostly hot set, once an allocation profile or GC log shows record objects dominate allocation rate or the old generation, which happens at millions of records. Game engines make the same trade with entity-component systems (an architecture in which each kind of data, such as position or health, is stored in its own contiguous array and entities are just indexes into them). What would flip it: if records are short-lived, the young-generation collector discards them cheaply and the object layout can be fine; if the set is long-lived and huge, columns win on footprint and GC time.
That is every published Memory Management & Garbage Collection question for Game Developer so far. Browse the other topics in this category, or practice this one interactively.