Where the memory goes
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:
go build -gcflags='-m' ./... 2>&1 | grep -E 'escapes to heap|moved to heap'
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.
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
}
// 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.
// 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.Poolis 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
float64toGoodand 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).