Slice internals in Go ↔ Java: from header to hidden allocations
Slice in Go is one of those structures that looks simple, but under the hood behaves like a little clever beast. If you are a Java developer, you might think: "well, this is just an ArrayList." And this is where the magic begins — it’s not quite so.
In this article, we will discuss: how the slice header works, why append can suddenly create a new array, how capacity grows, what aliasing is, and why copy is not just a copy, but a memory control tool.
And, of course, all this will be compared to Java: arrays and ArrayList, so you can see not only the "how," but also the "why."
copy vs append behavior
What it is and what happens under the hood
In Go, there are two fundamental ways to work with slices: append and copy. They look similar but perform fundamentally different tasks.
append adds elements to a slice. If the capacity allows — elements are simply written to the existing array. If not — the runtime creates a new array, copies the old data there, and adds the new.
copy always copies data from one slice to another. It never increases capacity and does not perform magical allocations — only copying into already allocated memory.
The equivalent in Java is a combination of ArrayList.add() and System.arraycopy(). But the key difference is: in Go, you explicitly manage copying, while append might "betray" you with hidden allocation.
package main
import "fmt"
func main() {
s := make([]int, 0, 2) // len=0, cap=2
s = append(s, 1)
s = append(s, 2)
// As long as cap is enough — we work in the same array
fmt.Println("before:", s, len(s), cap(s))
s = append(s, 3) // this will trigger realloc!
fmt.Println("after:", s, len(s), cap(s))
}
import java.util.ArrayList;
public class Main {
public static void main(String[] args) {
ArrayList<Integer> list = new ArrayList<>(2); // capacity = 2
list.add(1);
list.add(2);
// as long as capacity is enough
System.out.println("before: " + list);
list.add(3); // there will be a resize of the array inside ArrayList
System.out.println("after: " + list);
}
}
Use copy when you want controlled copying, and append when you are ready to accept possible allocation. Under the hood, append is a conditional "if it doesn't fit — recreate the array." This means: potential GC pressure, loss of references, and aliasing effects. copy, on the other hand, strictly operates within already allocated memory, making it a predictable tool.
append is ideal for building dynamic lists, streaming data, and pipelines. But if you are working with buffers (for example, network ones), copy gives control. In Java, a similar situation: ArrayList is convenient, but under heavy loads, arrays and System.arraycopy are used. The advantage of append is simplicity. The downside is hidden allocations. copy — on the contrary: more control, but more code.
slice header (ptr, len, cap)
What is it and what happens under the hood
Slice is not an array. It is a structure with three fields:
type slice struct {
ptr *T // pointer to the array
len int // length (how many elements are available)
cap int // capacity (how many can fit)
}
So slice is a "window" into an array. You can have multiple slices pointing to the same array.
In Java, the analog is an array + ArrayList. But ArrayList hides this structure, while slice makes it part of the way you think.
package main
import "fmt"
func main() {
arr := []int{1,2,3,4,5}
s := arr[1:4] // ptr points to arr[1]
fmt.Println(s) // [2 3 4]
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 4 (from arr[1] to the end of the array)
}
public class Main {
public static void main(String[] args) {
int[] arr = {1,2,3,4,5};
// In Java there is no slice — only copying
int[] sub = java.util.Arrays.copyOfRange(arr, 1, 4);
// sub is a new array, not a view!
System.out.println(sub.length);
}
}
Always remember: slice is a view, not a copy. This means that changing one slice can change another. Under the hood, it is just a pointer to the same array. In Java, you are protected from this because copying happens more often. In Go — no, and this is a source of both power and bugs.
Slice header makes Go powerful for working with large data: you can pass chunks without copying. This is used in network buffers, parsers, streams. The plus — zero allocation. The minus — aliasing. In Java, copies are made more often → safer, but more expensive in memory.
slice capacity growth algorithm
What it is and what happens under the hood
When capacity runs out, Go increases it. But not linearly.
Up to ~1024 elements — usually a doubling. After that — growth by about 25%. This is a compromise between speed and memory efficiency.
In Java, ArrayList increases by about 1.5x. That is, the growth is smoother.
// pseudo-logic
if cap < 1024 {
newCap = cap * 2
} else {
newCap = cap * 1.25
}
// Java ArrayList
newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5x
Anticipate the size of the slice. If you know the volume — use make with capacity. Under the hood, each resize is an allocation + copying. It’s not free. In Go, this is particularly noticeable due to frequent appends.
In high-load systems (logging, streaming), it’s important to set the capacity in advance. This reduces GC pressure. In Java, the equivalent is new ArrayList(capacity). Plus — less realloc. Minus — can overestimate and waste memory.
slice reallocation
What it is and what happens under the hood
When append does not fit in capacity — a new array is created. The old one remains in memory as long as there are references to it.
// ASCII scheme
Old array: [1 2]
New array: [1 2 3 0]
ptr -> new array
s := []int{1,2}
t := s
s = append(s, 3) // s now points to a new array
// t still points to the old one
import java.util.ArrayList;
public class Main {
public static void main(String[] args) {
ArrayList<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
// a new array is created inside ArrayList when it grows
list.add(3);
}
}
Don’t forget: append can "detach" the slice from the old array. This breaks aliasing. Under the hood, it changes ptr. If you passed the slice to a function — it might work with a different array than you think.
This is critical when working with shared data. For example, in caching or when working with buffers. The plus — safety (new array). The minus — unexpected behavior. In Java, the analogy is resize in ArrayList, but it is hidden.
slice aliasing
What it is and what happens under the hood
Aliasing is when multiple slices point to the same array.
arr := []int{1,2,3,4}
a := arr[0:2]
b := arr[1:3]
a[1] = 100
// b will also change!
int[] arr = {1,2,3,4};
int[] a = java.util.Arrays.copyOfRange(arr, 0, 2);
int[] b = java.util.Arrays.copyOfRange(arr, 1, 3);
// changes do not intersect
// ASCII scheme
arr: [1 2 3 4]
a -> [1 2]
b -> [2 3]
Change in the intersection affects both
Aliasing is a strength and a trap. Under the hood it's one array. If you don't want this — use copy. It's like shared mutable state — a source of complex bugs.
Used in high-performance systems (e.g., zero-copy parsing). Plus — speed. Minus — complexity. In Java, aliasing is often avoided because copying is cheaper than bugs.
General Comparison Table
| Term | Go | Java | Comment |
|---|---|---|---|
| append vs copy | append may realloc | add + arraycopy | Go hides allocations, Java — more explicit model |
| slice header | ptr + len + cap | hidden in ArrayList | Go provides control, Java — abstraction |
| growth | 2x → 1.25x | 1.5x | different trade-offs of memory vs speed |
| reallocation | new array | new array | more noticeable in Go due to aliasing |
| aliasing | exists | almost none | Go gives power, Java — safety |
Output / Conclusion
Slice is not just a "dynamic-sized array." It is an abstraction over memory. And like any powerful abstraction, it requires respect.
Go gives you control: you see len, cap, understand when realloc happens, and can manage memory. But with that comes responsibility: aliasing, hidden allocations, unexpected copies.
Java takes a different approach: it hides the details. ArrayList resizes, copies arrays, but you rarely think about it. This reduces cognitive load but also removes control.
If you are writing high-load systems, understanding slice internals gives you an advantage. You start to see not just code, but the movement of data in memory. And it is there, in those invisible moves, that performance often hides.
The key idea: Go is about transparency and control, Java is about abstraction and safety. And the true power of a developer is knowing when one model is needed and when the other is.
Оставить комментарий
Useful Articles:
New Articles: