Skip to main content
Go for JVM Engineers

Memory and GC Differences

Ravinder··6 min read
GoJVMJavaGarbageCollectionMemoryPerformance
Share:
Memory and GC Differences

JVM engineers who care about latency learn the GC tuning flags early: -XX:+UseG1GC, heap sizes, survivor ratios, GC log analysis. Go's runtime also has a garbage collector, but its design goals and knobs are radically different. Understanding those differences helps you write Go code that the GC handles efficiently — and explains why Go services often hit single-digit millisecond GC pauses out of the box.

The JVM GC Model (Brief Recap)

The HotSpot JVM uses a generational collector. Most objects die young (the weak generational hypothesis), so allocations land in the Eden space. Surviving objects are promoted through survivor spaces to old gen. Stop-the-world pauses happen during young GC (short) and full GC (potentially long). Tuning means balancing heap size, pause targets, and promotion thresholds.

flowchart LR subgraph JVM Heap direction LR Eden --> S0[Survivor 0] S0 --> S1[Survivor 1] S1 --> OldGen[Old Gen] OldGen -->|Full GC| Compact[Compaction] end Alloc[new Object] --> Eden

Go's Concurrent Tricolor Mark-Sweep

Go's GC is not generational. It is a concurrent, tricolor mark-and-sweep collector that runs almost entirely alongside application goroutines. The world is not stopped for the full collection — only for two brief STW phases: one to enable the write barrier before marking starts, and one to finish marking.

flowchart LR subgraph Go GC Cycle direction LR STW1[STW: enable\nwrite barrier] --> Mark[Concurrent\nmark goroutines] Mark --> STW2[STW: finish\nmark, disable\nwrite barrier] STW2 --> Sweep[Concurrent\nsweep] end

The STW pauses are typically under 1 ms even for heaps of several gigabytes — the design target is sub-millisecond. This is a different tradeoff from G1 or ZGC: Go's GC has lower throughput than a well-tuned JVM GC, but pause predictability is excellent.

The primary knob is GOGC (default: 100), which sets the percentage of heap growth that triggers a collection. GOGC=200 doubles the heap before collecting — higher throughput, more memory. GOGC=50 collects more aggressively — lower peak memory, more CPU spent collecting.

Go 1.19 added GOMEMLIMIT — a soft ceiling on total memory use — which is more intuitive for containerised deployments than GOGC alone.

# Collect when heap doubles (default)
GOGC=100 ./server
 
# Cap memory, let the runtime decide GC frequency
GOMEMLIMIT=256MiB ./server

Escape Analysis — Stack vs Heap

This is the biggest mental model shift for JVM engineers. In Java, every object allocation goes to the heap (with JIT escape analysis as an optimisation, but you never see it). In Go, the compiler performs escape analysis at compile time and places variables on the goroutine stack if they do not escape the function scope.

Stack allocation is free: no GC pressure, no heap fragmentation, and the memory is reclaimed when the function returns.

// Does NOT escape to heap — allocated on stack
func sumSquares(n int) int {
    type pair struct{ a, b int } // stack allocated
    p := pair{a: n, b: n * n}
    return p.a + p.b
}
 
// Escapes to heap — pointer returned to caller
func newPoint(x, y int) *Point {
    return &Point{X: x, Y: y} // Point escapes, lives on heap
}

Check escape decisions with:

go build -gcflags='-m' ./...

Output lines like ./main.go:12:10: &Point{...} escapes to heap tell you exactly which allocations are GC-managed.

Practical Allocation Patterns

Because stack allocation is free, small, short-lived values should be passed by value, not pointer:

// Prefer — small struct, no escape
func area(r Rectangle) float64 {
    return r.Width * r.Height
}
 
// Avoid for hot paths — forces heap allocation if r escapes
func area(r *Rectangle) float64 {
    return r.Width * r.Height
}

For high-frequency allocations, pool objects with sync.Pool:

var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}
 
func encode(data []byte) []byte {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)
 
    json.NewEncoder(buf).Encode(data)
    return buf.Bytes()
}

sync.Pool items are eligible for GC between collections, so they reduce allocation pressure without leaking memory — the Go equivalent of an object pool in a Java hot path.

Memory Layout — Structs vs Objects

Java objects carry a header (mark word + class pointer, typically 16 bytes on 64-bit JVMs). A Point object in Java is at least 16 bytes even if it only has two int fields. In Go, a struct{ X, Y int32 } is exactly 8 bytes on the heap and zero overhead in a slice element.

type Point struct {
    X, Y int32
} // sizeof = 8 bytes, no header
 
points := make([]Point, 1_000_000) // 8 MB, contiguous, cache-friendly

Compare with Java:

Point[] points = new Point[1_000_000];
// Each element is a reference (8 bytes) to a heap object (16+ bytes)
// Total: ~24+ MB, scattered across heap, poor cache locality

Slice-of-structs is dramatically more cache-friendly than slice-of-pointers. In Go, the default is already the efficient layout.

GOGC and Latency Trade-offs

Setting Throughput Peak Memory GC CPU
GOGC=50 Lower Lower Higher
GOGC=100 (default) Baseline Baseline Baseline
GOGC=200 Higher Higher Lower
GOMEMLIMIT only Adaptive Capped Adaptive

For latency-sensitive microservices in containers, the recommended baseline is:

GOGC=off GOMEMLIMIT=<80% of container limit> ./server

This disables percentage-based GC and relies solely on the memory limit trigger, which avoids GC cycles proportional to your live set growing with traffic.

Key Takeaways

  • Go's GC is concurrent tricolor mark-sweep with sub-millisecond STW pauses — not generational, so there is no Eden/old-gen tuning, but also no throughput headroom that a well-tuned G1 achieves.
  • GOGC controls how aggressively the runtime collects relative to heap growth; GOMEMLIMIT (Go 1.19+) adds a hard memory ceiling, which is the right knob for container deployments.
  • Escape analysis determines whether a variable lives on the goroutine stack (free) or the heap (GC-managed); run go build -gcflags='-m' to inspect escape decisions.
  • Pass small structs by value in hot paths — it avoids heap allocation entirely, unlike Java where every object is always heap-allocated.
  • Slice-of-struct gives dense, cache-coherent memory layout; slice-of-pointer gives scattered, pointer-chasing layout — Go defaults to the efficient option, Java defaults to the scattered one.
  • Use sync.Pool to amortise allocation cost for frequently created and discarded objects without the risk of a memory leak.
Share: