Every composite literal in Go looks like a heap allocation. Every new in Java looks like a heap allocation. In practice, a large fraction of them never touch the heap at all, because the compiler runs an analysis pass that proves the object cannot outlive the stack frame that created it — and when that proof succeeds, allocation collapses to moving a stack pointer. When it fails, you pay for the allocator, and more importantly, you add work for the garbage collector that has to trace that object for the rest of its life.
The pass that makes this decision is escape analysis. It is one of those compiler internals that leaks into everyday performance work: the difference between a hot function that runs at cache speed and one that quietly feeds the GC hundreds of megabytes per second is often a single pointer that escapes where you didn’t expect. This post covers what the analysis decides, what forces an escape in Go and on the JVM, and how to measure the result instead of guessing.
What Escape Analysis Actually Decides
Conceptually the question is simple: for each allocation site, can the compiler prove that the reference to the newly created object never outlives the current stack frame? If yes, the object can live on the stack — allocating it is a pointer bump and freeing it is free when the function returns. If no — if the reference is returned, stored in a global, sent over a channel, captured by a closure that outlives the frame, or handed to code the compiler cannot see — the object escapes and must be heap-allocated.
The Go compiler runs this analysis at build time, which means you can ask it directly what it decided. The -m flag to the compiler prints its reasoning for every function it compiles:
package main
import "fmt"
type user struct {
name string
age int
}
// newStackValue: the struct never leaves this function.
func newStackValue() int {
u := user{name: "ada", age: 36}
return u.age
}
// newEscapingUser: the pointer is returned, so the struct
// must outlive this frame.
func newEscapingUser() *user {
u := user{name: "grace", age: 45}
return &u
}
func main() {
fmt.Println(newStackValue(), newEscapingUser())
}
Running go build -gcflags="-m" on this file produces output like:
./main.go:11:6: can inline newStackValue
./main.go:18:6: can inline newEscapingUser
./main.go:19:2: moved to heap: u
The two allocations that look identical in source code get different treatment. The struct in newStackValue gets no “moved to heap” verdict — it stays on the stack. The struct in newEscapingUser is reported at its declaration line as moved to the heap, because its address crosses the function boundary. Nothing about the type or the constructor syntax made that decision — the data flow did.
What Forces an Escape
Returning a pointer is the obvious trigger, but it is far from the only one. The compiler must be conservative: if it cannot prove a reference stays local, it escapes. The triggers worth memorizing:
- Storing into an interface. Converting a value to an interface usually forces the value to escape, because the interface may be observed by code the analysis cannot model. This is why passing a struct to a variadic
anyparameter — a logging call, for example — can push it to the heap even when nothing else references it. - Closures that outlive the frame. If a function literal captures a local variable and the closure itself escapes (returned, stored), the captured variable moves to the heap.
- Sending pointers over channels or storing them in long-lived structures. A pointer placed in a map, slice, or channel visible outside the frame can be read at any later time, so the pointee must live on the heap.
- Unknown size or very large objects. Stack frames are fixed-size, and goroutine stacks start small (a few kilobytes) and grow on demand. An allocation whose size is not a compile-time constant —
make([]byte, n)wherencomes from input — escapes by necessity, and huge constant-size objects may too. - Reflection and cgo boundaries. Any path where the reference reaches code compiled without knowledge of the local frame is treated as escaping.
The interface case deserves emphasis because it surprises people in profiling. A logging call in a hot loop that passes structs to a variadic any parameter can be responsible for most of a service’s allocation rate, even though the program never “allocates” anything in the obvious sense. Boxing into an interface, plus the eventual formatting work, turns a stack-resident struct into heap traffic.
The JVM Variant: Scalar Replacement
HotSpot’s JIT compilers run their own escape analysis, but the runtime story differs from Go’s in an important way. HotSpot’s analysis runs inside the optimizing compiler (C2), after aggressive inlining, and its main payoff is not stack allocation — HotSpot generally does not allocate objects on the stack. Instead, when an object provably does not escape the compiled fragment, the JIT performs scalar replacement: it decomposes the object into its individual fields and keeps them in registers or on the physical stack, eliminating the allocation and any associated synchronization entirely. The flag -XX:+DoEscapeAnalysis (on by default in modern JDKs) controls this.
The consequences of that design are visible in benchmarks. Scalar replacement only fires when the whole lifetime of the object is visible inside an inlined, JIT-compiled unit. An allocation that crosses a megamorphic call site, gets stored into a field the compiler cannot analyze, or happens in code the JIT never promotes stays a heap allocation. The same source code can be allocation-free in one execution profile and heap-bound in another, depending on what got inlined. Frameworks that allocate per-request context objects rely on this working; when profiling shows those objects back on the heap, the usual culprit is a call boundary the inliner could not cross.
Measuring Instead of Guessing
Both ecosystems give you allocation counts as a first-class benchmark metric, and that number is the ground truth — not your reading of the compiler’s decisions. In Go, benchmarks report allocs/op directly:
package main
import "testing"
type user struct {
name string
age int
}
func buildUsers(n int) int {
total := 0
for i := 0; i < n; i++ {
u := user{name: "x", age: i}
total += u.age
}
return total
}
func BenchmarkBuildUsers(b *testing.B) {
for i := 0; i < b.N; i++ {
buildUsers(1000)
}
}
Running go test -bench . -benchmem reports allocations per operation; a function that loops a thousand times over a non-escaping struct should show near-zero allocs/op, and if it doesn’t, the -gcflags="-m" output will name the line that pushed something to the heap. For production traffic, heap profiles collected with pprof can be filtered to allocation samples, which ranks call paths by the bytes and objects they allocate — the fastest way to find an unexpected escaper. The official Go GC guide covers how allocation rate translates into GC cost.
On the JVM, JMH with the gc profiler serves the same role: it reports allocation rate per operation per benchmark method, which tells you whether scalar replacement fired for your hot path under realistic inlining. Comparing a run with -XX:-DoEscapeAnalysis makes the effect visible, though that flag should be treated as a diagnostic experiment, not a production configuration.
When Pointer-Free Code Stops Helping
There is a temptation to redesign APIs to return values instead of pointers, hoist structs into the caller, or reach for pools everywhere, on the theory that fewer escapes always means faster code. The reality is more mixed. Passing large structs by value copies them on every call, and a copy can cost more than the allocation it avoided. Pools amortize allocation but add synchronization overhead and can hold memory alive longer than the collector would. The escape-analysis-friendly change that almost always pays off is narrower: keep allocations out of the innermost loops, avoid boxing into interfaces on hot paths, and let everything else be measured before it is optimized.
It is also worth remembering what the analysis buys you beyond raw speed. Every object that stays on the stack is an object the collector never traces, never relocates, and never has to consider during a GC cycle. Cutting allocation rate is the single most effective lever on pause times and on CPU spent inside the collector — which is why escape analysis behaves less like compiler trivia and more like a performance contract you should know how to read.
Wrapping Up
Escape analysis moves allocation decisions from “wherever the syntax points” to “wherever the compiler can prove the lifetime ends.” In Go the decision happens at compile time, is inspectable with -gcflags="-m", and is stable across runs. In the JVM it happens in the JIT after inlining, and manifests as scalar replacement rather than true stack allocation. In both cases the workflow is the same: write the straightforward code, benchmark with allocation counts enabled, and let the profiler — not intuition — tell you which allocation actually matters.