Garbage collection (GC) is automatic memory management in which the language runtime finds objects that are no longer reachable from the program and reclaims their memory. It removes whole classes of bugs such as use-after-free, but costs CPU time and can pause application threads, which shows up as higher tail latency.
What garbage collection is
In languages such as Java, Kotlin, C#, Go, JavaScript and Python, developers allocate objects but do not free them explicitly. The runtime's garbage collector periodically determines which objects are still reachable from roots, such as thread stacks, global variables and registers, and reclaims the memory of everything else. Most modern collectors are tracing collectors: they mark reachable objects, then sweep or compact the rest. CPython is the notable exception, using reference counting plus a cycle collector.
GC is relevant to observability because it consumes CPU and, depending on the collector, briefly stops application threads ("stop-the-world" pauses). Both effects appear as slower requests, especially at the 99th percentile, and as lower throughput under memory pressure.
Key concepts
- Generational hypothesis. Most objects die young. Generational collectors keep a young generation that is collected often and cheaply, and an old generation collected less often.
- Stop-the-world vs concurrent. Some phases pause all application threads; concurrent collectors do most of their marking while the application runs, at the cost of extra CPU and barriers.
- Moving vs non-moving. Compacting or copying collectors move objects to reduce fragmentation; non-moving collectors leave objects in place.
- Allocation rate. The faster a program allocates, the more often the collector must run. Reducing allocations is often the most effective GC tuning.
How major runtimes do it
JVM (Java, Kotlin, Scala)
The JVM offers several collectors. G1 has been the default for most server configurations since JDK 9; it is generational, divides the heap into regions and aims for a configurable pause-time goal (-XX:MaxGCPauseMillis, 200 ms by default). ZGC and Shenandoah are low-pause collectors that do almost all work concurrently, including compaction, keeping pauses typically in the sub-millisecond to low-millisecond range independent of heap size. ZGC became generational in JDK 21, and generational mode is its default since JDK 23. Parallel GC optimizes for throughput and accepts longer pauses; Serial GC suits small heaps.
.NET
The .NET GC is generational with generations 0, 1 and 2, plus a Large Object Heap for objects of 85,000 bytes or more and, since .NET 5, a Pinned Object Heap. Gen 0 and gen 1 collections are short and stop the world; gen 2 can run as a background GC concurrently with the application. Workstation GC targets client apps, while Server GC uses a heap and GC thread per core for throughput. Since .NET 9, Server GC adapts heap count to the workload by default (DATAS).
Go
Go uses a concurrent, tri-color mark-and-sweep collector that is non-generational and non-moving. Stop-the-world phases are short, usually well under a millisecond, but marking consumes CPU and can slow goroutines that allocate heavily through mark assists. Tuning uses two knobs: GOGC (default 100, letting the heap grow by roughly 100% of the live heap before the next cycle) and GOMEMLIMIT (a soft memory limit since Go 1.19). Go 1.26 enabled the Green Tea collector by default, which improves memory locality during marking.
What to monitor
| Signal | Why it matters |
|---|---|
| GC pause duration (histogram) | Direct contribution to tail latency |
| Share of CPU spent in GC | Capacity lost to memory management |
| Heap used after GC | The live set; a steady rise suggests a leak |
| Allocation rate | Drives GC frequency |
| Collection count by generation or type | Frequent old-generation or full GCs indicate pressure |
OpenTelemetry semantic conventions define runtime metrics for these signals, for example jvm.gc.duration and jvm.memory.used_after_last_gc for the JVM, with equivalents for .NET and Go. Correlate them with request latency: if p99 spikes line up with GC pauses, the collector is part of the explanation.
Best practices
- Measure before tuning. Collect GC metrics and pause logs in production; defaults are good for most workloads.
- Reduce allocations on hot paths, guided by allocation profiles and flame graphs.
- Size memory to the container. Make sure the runtime knows its limit: modern JVMs and .NET respect cgroup limits; for Go, set
GOMEMLIMITsomewhat below the container limit. - Pick the collector for the goal. Low-latency services benefit from ZGC or Shenandoah on the JVM; batch jobs often prefer throughput-oriented collectors.
Common mistakes
Teams often copy GC flags from blog posts without measuring, raise the heap until the problem disappears temporarily, or confuse a memory leak with GC pressure. A leak shows as a heap-after-GC line that never returns to its baseline; GC pressure shows as frequent collections with a stable live set.
How Obseria helps
Obseria collects runtime GC metrics through OpenTelemetry and the Obseria Agent for the JVM, .NET and Go, and places them next to request latency. Continuous profiling adds allocation profiles, so you can see which code creates the garbage.