gopherpath
0%

Where the memory goes

35 min · lesson 3 of 3 · #memory · #gc

There is no object header, no -Xmx, no young/old generation you tune. The question is not "how big is the heap" but "how many allocations per request" — because allocation, not collection, is what costs you throughput.

Escape analysis decides stack vs heap

The compiler puts a value on the stack unless it "escapes" (returned as a pointer, stored in an interface or a closure, captured by a goroutine). You can see the decision:

Shell
go build -gcflags='-m' ./... 2>&1 | grep -E 'escapes to heap|moved to heap'
Go
func sum(xs []int) int { // xs header on stack, array on heap (owned by caller)
	var total int
	for _, x := range xs {
		total += x
	}
	return total // no allocation at all
}

func newUser() *User { return &User{} } // escapes: caller keeps the pointer

In Java every object is on the heap until the JIT decides to scalar-replace it, and you have no direct visibility. In Go the decision is at compile time, visible with one flag, and stack allocation is free.

Struct layout is your memory, literally

Field order changes the size of every instance. This is the Go equivalent of @Contended — except you get it by sorting fields, not by tuning the JVM.

Go
package main

import (
	"fmt"
	"unsafe"
)

type Bad struct { // 1 + 7 pad + 8 + 1 + 7 pad = 24
	Flag  bool
	Count int64
	Done  bool
}

type Good struct { // 8 + 1 + 1 + 6 pad = 16
	Count int64
	Flag  bool
	Done  bool
}

func main() {
	fmt.Println(unsafe.Sizeof(Bad{}), unsafe.Sizeof(Good{}))
	// 1M instances: 24MB vs 16MB — free savings, zero API change
}
Java
// JVM object header: 12–16 bytes before your first field, plus padding to 8.
// No public sizeof. JOL is the tool: org.openjdk.jol.info.ClassLayout.parseClass(Bad.class)
record Bad(boolean flag, long count, boolean done) { }
// Field reordering is done for you by the JIT/HotSpot layout — you don't control it.
TypeScript
// V8 objects: hidden classes, tagged values. Nothing you can lay out by hand.

Rule: group fields widest first (int64, pointer, int32, bool). Do it for structs you allocate millions of.

The GC difference that matters

Go JVM (G1/ZGC)
Generational no (single generation, concurrent mark-sweep) yes (young/old), ZGC region-based
Pause target sub-millisecond, grows with pointer density tunable; ZGC also sub-ms
Tuning knobs GOGC (percent), GOMEMLIMIT (bytes) -Xmx, -XX:MaxGCPauseMillis, dozens more
Worst enemy allocation rate allocation rate + old-gen pressure
A pointer to the stack allowed (stack copying) impossible by definition

GOMEMLIMIT=1GiB plus GOGC=100 (default) is the modern answer for containers: it is a soft limit, honoured by the collector, not by the OS — which is why Go services rarely OOM but can get CPU-hungry under pressure.

Trap: sync.Pool is not an object cache. It is cleared by the GC at any time. Use it for scratch buffers on hot paths, never for state you need back.

Proof checkpoint

  • Run the size example; then add a float64 to Good and predict the new size before checking.
  • Find one function in a project you own that allocates a pointer it does not need to return.
  • Explain why []User{...} beats []*User{...} for 10k rows you only read (hint: pointer chasing + GC scanning).
stored in this browser only