04 - Memory

1) Types of memory

  • Code (text) - the part of your program that holds the actual instructions the CPU runs (your functions). It’s read-only/executable code only-no variables or data.
  • Globals - package-level variables and constants. Variables you can change live in writable memory (.data if they start non-zero, .bss if they start zero) and are set up before main, while constants never change and their bytes (like string text) sit in read-only memory (.rodata) so you can read them but not modify them.
  • Stack - each goroutine’s private call area that holds function parameters, locals, and return addresses. it grows/shrinks automatically as you call/return (LIFO). It is very fast, and data there doesn’t outlive the function.
  • Heap - dynamic allocations that outlive scope. Managed by the runtime.

2) Escape Analysis

Go compiler decides storage based on escape analysis.

Mental model

  • Locals by value that never leak => stack.
  • Locals whose address escapes (e.g. returned pointer) => heap.
  • Inspect with: go build -gcflags="all=-m" (look for escapes to heap).

Example

escape_demo.go
package main

import "fmt"

type Box struct{ W, H int }

// stack: returned by value
func MakeBox(w, h int) Box {
	b := Box{W: w, H: h}
	return b
}

// heap: pointer escapes
func NewBox(w, h int) *Box {
	b := Box{W: w, H: h}
	return &b
}

func main() {
	a := MakeBox(2, 3)
	p := NewBox(4, 5)
	fmt.Println(a, p)
}

3) Struct Padding & Alignment

How field order, offsets, and alignment rules determine memory size.

Definitions

  • Alignment - How even the address must be. unsafe.Alignof(x) returns 1, 2, 4, 8, … and a value must live at an address divisible by that number (e.g., uint32 => 4, uint64 => 8). A struct’s alignment is the largest alignment of any of its fields.
  • Offset - Where a field starts inside its struct, in bytes from the start (0, 1, 2, …). unsafe.Offsetof(s.F) includes any padding before F and is a compile-time constant.
  • Size - How many bytes the value itself occupies. unsafe.Sizeof(x) for structs counts all field bytes plus padding to the end. It does not include data behind pointers/slices/maps/strings-only their fixed-size headers (e.g., a string header is 16 bytes on 64-bit).

Formal rules

  • Field offset off must satisfy: off % Alignof(field) == 0.
  • Struct alignment = max alignment of its fields.
  • Struct size = rounded up to multiple of its alignment.
  • Padding can be interior (between fields) or tail (at end of struct).

Why padding is necessary

Alignment puts data where the CPU expects it. That makes reads/writes fast and safe. misalignment can slow things down or even crash, and alignment also keeps Go and C layouts compatible for cgo.

Bad vs Good layout

layout.go
package main

import (
	"fmt"
	"unsafe"
)

// Bad layout: wasted padding
type Bad struct {
	A uint8  // 1B
	B uint64 // align 8 => needs pad after A
	C uint8  // 1B => pad at end
}

// Good layout: large fields first
type Good struct {
	B uint64
	A uint8
	C uint8
}

func main() {
	fmt.Println("Bad size:", unsafe.Sizeof(Bad{}), "align:", unsafe.Alignof(Bad{}))
	fmt.Println("Good size:", unsafe.Sizeof(Good{}), "align:", unsafe.Alignof(Good{}))
}
// Bad = 24B, Good = 16B

Descriptor sizes

descriptor_sizes.go

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var p *int
	s := "hi"
	sl := make([]int, 0, 8)
	m := make(map[int]int)
	ch := make(chan int, 1)
	var i any = 123

	fmt.Printf("*T pointer        size=%d align=%d\n", unsafe.Sizeof(p), unsafe.Alignof(p))
	fmt.Printf("string            size=%d align=%d (len=%d)\n", unsafe.Sizeof(s), unsafe.Alignof(s), len(s))
	fmt.Printf("[]int (slice)     size=%d align=%d (len=%d cap=%d)\n", unsafe.Sizeof(sl), unsafe.Alignof(sl), len(sl), cap(sl))
	fmt.Printf("map[int]int       size=%d align=%d\n", unsafe.Sizeof(m), unsafe.Alignof(m))
	fmt.Printf("chan int          size=%d align=%d\n", unsafe.Sizeof(ch), unsafe.Alignof(ch))
	fmt.Printf("interface{} (any) size=%d align=%d\n", unsafe.Sizeof(i), unsafe.Alignof(i))
}
Note

Sizeof for pointers, strings, slices, maps, chans, interfaces

Sizeof counts the small header/descriptor only - not the data it points to.

Nested struct offsets

nested.go
package main

import (
	"fmt"
	"unsafe"
)

type Inner struct {
	U16 uint16
	B   byte
}

type Outer struct {
	B   byte
	In  Inner
	U32 uint32
}

func main() {
	var o Outer
	fmt.Println("Alignof(Inner):", unsafe.Alignof(Inner{}))
	fmt.Println("Alignof(Outer):", unsafe.Alignof(Outer{}))
	fmt.Println("Offset B   :", unsafe.Offsetof(o.B))
	fmt.Println("Offset In  :", unsafe.Offsetof(o.In))
	fmt.Println("Offset U32 :", unsafe.Offsetof(o.U32))
	fmt.Println("Sizeof(Inner):", unsafe.Sizeof(Inner{}))
	fmt.Println("Sizeof(Outer):", unsafe.Sizeof(Outer{}))
}
// Alignof(Inner): 2
// Alignof(Outer): 4
// Offset B   : 0
// Offset In  : 2
// Offset U32 : 8
// Sizeof(Inner): 4
// Sizeof(Outer): 12

Empty struct

empty.go
package main

import (
	"fmt"
	"unsafe"
)

func main() {
	type Empty struct{}
	fmt.Println("Sizeof Empty:", unsafe.Sizeof(Empty{})) // 0
}
Note
Go’s struct{} has size 0. In C++ it is 1 byte to ensure distinct addresses. Go can overlay multiple struct{} values at the same address, perfect for zero-cost markers in maps/channels.

4) Endianness

Endianness refers to the order in which bytes or smaller units of data are stored in a computer's memory or transmitted over a network.

Unsafe pointer casts: how to reinterpret memory

unsafe.Pointer lets you look at the same memory as if it were a different type. Nothing is copied - you’re just reinterpreting the bytes at the same address. Use this only when you really need low-level access.

How to do it (plain steps):

  1. Take the address of a value (a normal pointer).
  2. Convert that pointer to unsafe.Pointer.
  3. Convert the unsafe.Pointer to the new pointer type you want.
  4. Read carefully - writing through a reinterpreted pointer can be dangerous.

Examples:

reinterpret_view.go

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var n uint32 = 0x11333311

	// Reinterpret the uint32 as an array of 4 bytes
	bytes := (*[4]byte)(unsafe.Pointer(&n))
	fmt.Printf("Raw bytes: % x\n", bytes)

	b1, b2, b3, b4 := bytes[0], bytes[1], bytes[2], bytes[3]

	left := b1 % b4
	right := b2 % b3

	fmt.Printf("(%d %% %d) = %d, (%d %% %d) = %d\n", b1, b4, left, b2, b3, right)

	if left == right {
		fmt.Println("Condition holds")
	} else {
		fmt.Println("Condition fails")
	}
}
reinterpret_view.go
package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var f float64 = 3.141592653589793
	// View the exact same 8 bytes as a uint64 (bit-level reinterpretation).
	u := *(*uint64)(unsafe.Pointer(&f))
	fmt.Printf("float64=%v and rawBits=0x%016x\n", f, u)

	// Reinterpret u's bytes as a float64
	f2 := *(*float64)(unsafe.Pointer(&u))
	fmt.Println("round-trip:", f2)
}
bytes_view.go
package main

import (
	"fmt"
	"unsafe"
)

type Header struct {
	ID   uint32
	Flag uint16
	Pad  byte
}

func main() {
	h := Header{ID: 0xA1B2C3D4, Flag: 0x7788}
	// Treat &h as *[N]byte to inspect its in-memory bytes (including padding).
	n := unsafe.Sizeof(h)
	b := (*[32]byte)(unsafe.Pointer(&h)) // big enough static bound
	fmt.Printf("size=%d bytes: % x\n", n, b[:n])
}

Detect endianness

endian_detect.go
package main

import (
	"fmt"
	"unsafe"
)

// isLittleEndian reports whether the current machine uses little-endian byte order.
func isLittleEndian() bool {
	var x uint16 = 0x0102
	b := (*[2]byte)(unsafe.Pointer(&x)) // treat x's address as 2 raw bytes
	// If the first byte in memory is the low byte (0x02), we're little-endian.
	// If it's 0x01, we're big-endian.
	return b[0] == 0x02
}

func main() {
	fmt.Println("Is little endian: ", isLittleEndian())
}