- unsafe.Pointer
- Struct field alignment (alignment of fields)
- Pointer arithmetic
- Zero-copy techniques
- iota
- Interface internals (itab, dynamic dispatch)
- runtime.SetFinalizer
- runtime.KeepAlive
- Overview of Topics
- Key differences between Go ↔ Java
- Comparative table of all terms
- How to Choose an Approach (Go vs Java)
- Conclusion
Low-Level Mechanisms - Part 2 | Go ↔ Java
In this article, we have gathered key low-level mechanisms of Go that most often raise questions for developers coming from Java. We will discuss: unsafe.Pointer, struct alignment, pointer arithmetic, zero-copy, iota, the internals of interfaces, runtime.SetFinalizer and runtime.KeepAlive.
We will break down three key topics: unsafe.Pointer, struct field alignment and pointer arithmetic. For each topic, we will look at how it is implemented in Go and how similar tasks are solved in Java, what limitations, risks, and practical applications exist.
unsafe.Pointer
unsafe.Pointer - Dangerous memory bridge ☠️🧠 | direct access to raw bytes. . allows bypassing Go's type safety
In Go, the type unsafe.Pointer is a universal pointer that can be converted to any other pointer type. It is the only way in Go to bypass strict pointer typing. Under the hood, it is simply an address in memory without type information.
Unlike regular pointers (*T), which are strictly typed, unsafe.Pointer allows casting between incompatible types. This is similar to void* in C. However, the Go compiler and runtime lose type information in this case, which means that the garbage collector may work incorrectly if unsafe is used improperly.
In Java, there is no analogous mechanism in the standard API. The JVM completely hides memory addresses. However, something similar can be obtained through sun.misc.Unsafe or VarHandle, but these are internal APIs that are not recommended for use.
Code Example (Go)
package main
import (
"fmt"
"unsafe"
)
func main() {
var i int = 42
// Getting a pointer to the variable
ptr := &i
// Casting to unsafe.Pointer (losing type)
unsafePtr := unsafe.Pointer(ptr)
// Converting back, but now as *float64
floatPtr := (*float64)(unsafePtr)
// Reading the value as float64 (this is already undefined behavior)
fmt.Println(*floatPtr)
}
Code Example (Java)
// In Java, there is no direct access to memory, but there is Unsafe (not recommended)
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class Main {
public static void main(String[] args) throws Exception {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
// Allocating memory manually (off-heap)
long address = unsafe.allocateMemory(8);
// Writing a value
unsafe.putLong(address, 42L);
// Reading the value
long value = unsafe.getLong(address);
System.out.println(value);
// Freeing memory
unsafe.freeMemory(address);
}
}
Use unsafe.Pointer only in extreme cases. The reason is that you bypass the Go type system, and the compiler can no longer guarantee correctness. Under the hood, the garbage collector relies on type information to understand where references are. If you store pointers in unsafe.Pointer or convert them incorrectly, the GC may not see the reference and free the memory prematurely. This leads to elusive bugs and crashes. In Java, a similar situation exists with Unsafe — its usage can violate JVM guarantees.
unsafe.Pointer is used in high-performance libraries: serialization, networking, zero-copy optimizations. For example, when reading binary protocols you can directly interpret bytes as structures. The plus — high speed, no allocations. The minus — lack of safety, possible errors when transferring between architectures. In Java, similar tasks are solved through ByteBuffer or DirectByteBuffer, but with less control and greater safety.
Struct field alignment (alignment of fields)
Struct field alignment - Memory alignment under a ruler 📏🧩 | packing fields for speed. . optimizes the placement of fields in a structure
Alignment is the way of placing structure fields in memory considering CPU requirements. Each type has its own alignment (for example, int64 usually aligns on 8 bytes).
In Go, the order of fields directly affects the size of the structure. The compiler adds padding to maintain alignment. This affects performance and memory consumption.
In Java, the developer does not control the layout of objects. The JVM determines how to place fields, including padding and reordering (in some cases).
Code Example (Go)
package main
import (
"fmt"
"unsafe"
)
type Bad struct {
a bool // 1 byte
b int64 // 8 bytes (requires alignment)
c bool // 1 byte
}
type Good struct {
b int64
a bool
c bool
}
func main() {
fmt.Println("Bad size:", unsafe.Sizeof(Bad{}))
fmt.Println("Good size:", unsafe.Sizeof(Good{}))
}
Code Example (Java)
// In Java, you cannot control the layout directly
class Bad {
boolean a;
long b;
boolean c;
}
class Good {
long b;
boolean a;
boolean c;
}
public class Main {
public static void main(String[] args) {
// Sizes cannot be known directly without tools (JOL)
System.out.println("Use JOL to inspect object layout");
}
}
// ASCII memory scheme (Bad struct):
// | a | padding(7 bytes) | b (8 bytes) | c | padding(7 bytes) |
// Good struct:
// | b (8 bytes) | a | c | padding(6 bytes) |
In the first structure, there is memory overuse due to the bad order of fields. In the second, memory is used more efficiently.
Always group fields by decreasing size. The reason is that the CPU works faster with aligned data, and the compiler inserts padding if you break the order. Under the hood, this is related to the fact that accessing unaligned data may require multiple operations or even trigger exceptions on some architectures. In Java, this is hidden, but the JVM also considers alignment; you just cannot influence it.
This is critical in systems with a large number of objects: caches, high-load services, network structures. The plus is reduced memory and improved cache locality. The downside is reduced readability of the structure. In Go, you manually optimize the layout; in Java, you use tools like JOL or simply trust the JVM. In low-latency systems (for example, trading), this can provide a noticeable gain.
Pointer arithmetic
Pointer arithmetic - Pointer arithmetic ➕📍 | memory jumps like on a map. . allows shifting through memory addresses
In Go, you cannot directly perform pointer arithmetic like in C. But through unsafe.Pointer and uintptr it is possible. uintptr is an integer representation of the address.
Under the hood, it is just a memory address. But importantly: uintptr is not tracked by the garbage collector, so if you store a pointer in uintptr, the GC may free the memory.
In Java, this capability is not available at all. All references are managed by the JVM, and arithmetic on them is prohibited.
Code example (Go)
package main
import (
"fmt"
"unsafe"
)
func main() {
arr := [3]int{10, 20, 30}
// Get a pointer to the first element
ptr := unsafe.Pointer(&arr[0])
// Shift to the second element
secondPtr := (*int)(unsafe.Pointer(uintptr(ptr) + unsafe.Sizeof(arr[0])))
fmt.Println(*secondPtr) // 20
}
Code example (Java)
// In Java there is no pointer arithmetic, only indices
public class Main {
public static void main(String[] args) {
int[] arr = {10, 20, 30};
// Access through index
int second = arr[1];
System.out.println(second);
}
}
// ASCII diagram:
// arr:
// [10][20][30]
// ptr -> [10]
// ptr + sizeof(int) -> [20]
// ptr + 2*sizeof(int) -> [30]
In Go, you manually move through memory, in Java — you use safe indices.
Do not store uintptr between operations. The reason is — the GC does not track uintptr as a reference. Under the hood, the garbage collector may move or remove an object, while uintptr will remain an old address. This leads to a dangling pointer. Always perform the conversion in one line. In Java, such problems do not exist, because pointers are fully controlled by the JVM.
Used in high-performance code: working with arrays, network buffers, mmap. Plus — maximum performance, zero-overhead. Minus — insecurity and complexity. In Go, this is a tool for system programming. In Java, arrays, ByteBuffer, and Unsafe (rarely) are used instead. The difference is — Go gives control, Java — safety.
In this article, we continue to delve into low-level aspects of Go that are particularly interesting for developers coming from Java. We will discuss three important topics: zero-copy techniques, iota, and the internals of interfaces (itab, dynamic dispatch).
These topics lie at the intersection of performance and the internal workings of the language. In Java, many of these things are either hidden by the JVM or implemented differently. In Go, however, the developer is given more control, but along with that — more responsibility.
Zero-copy techniques
Zero-copy techniques - Zero data copies ⚡📦 | stream without unnecessary copying. . avoids unnecessary copying of data in memory
Zero-copy is an approach where data is not copied during transmission between system components. Instead of creating new arrays or buffers, references to already existing memory are used. In Go, this is especially important when working with slices ([]byte), strings, and network buffers.
Under the hood, a slice in Go is a structure with three fields: a pointer to an array, length, and capacity. When you make a slice of a slice, only the structure (3 words) is copied, and not the actual data. This is zero-copy.
In Java, similar behavior is present with ByteBuffer and String (until Java 7u6, strings could share a char[] array), but nowadays, copying happens more frequently. The JVM tries to balance between safety and optimizations.
Code example (Go)
package main
import (
"fmt"
)
func main() {
data := []byte("hello world")
// Create a slice without copying
sub := data[0:5]
// Modify the original array
data[0] = 'H'
// sub will change too, because it's the same memory
fmt.Println(string(sub)) // Hello
}
Code example (Java)
// In Java, copying occurs more frequently
public class Main {
public static void main(String[] args) {
byte[] data = "hello world".getBytes();
// Create a new array (copying)
byte[] sub = new byte[5];
System.arraycopy(data, 0, sub, 0, 5);
data[0] = 'H';
// sub will NOT change
System.out.println(new String(sub)); // hello
}
}
// ASCII scheme:
// Go:
// data -> [h e l l o _ w o r l d]
// sub -> [h e l l o] (reference to the same data)
// Java:
// data -> [h e l l o _ w o r l d]
// sub -> [h e l l o] (copy)
In Go — this is one array, in Java — two different arrays.
Be careful with zero-copy: you are working with the same memory. The reason is that changing one slice affects all others that reference the same backing array. Under the hood, Go does not perform copy-on-write like some systems, so any changes are immediately visible. This can lead to bugs, especially in multithreaded code. In Java, such problems occur less frequently because copying usually happens automatically or an immutable approach is used (for example, String).
Zero-copy is widely used in network servers, parsers, and data processing systems. For example: HTTP servers, Kafka clients, high-load APIs. Advantages — minimal allocations, high performance, less GC pressure. Disadvantages — complexity, risk of accidental data modification, security issues. In Go, this is a primary optimization tool, in Java — ByteBuffer, Netty (with pooled buffers) are used, but more often with control and abstraction.
iota
iota - Automatic numbering of constants 🔢⚡ | a counter of values within the compiler. . generates sequential constants in Go
iota is a special identifier in Go that is used within const blocks for the automatic generation of sequential values.
Under the hood, iota is just a counter that increments by 1 with each new line in the const block. But its power lies in the fact that it can be used in expressions, creating bit masks, enum-like structures, and much more.
In Java, the equivalent is enum or simply static final constants. But Java does not have a built-in auto-increment mechanism in declarations.
Code Example (Go)
package main
import "fmt"
const (
A = iota // 0
B // 1
C // 2
)
func main() {
fmt.Println(A, B, C)
}
Code Example (Java)
// The equivalent through enum
public class Main {
enum Value {
A, B, C
}
public static void main(String[] args) {
System.out.println(Value.A.ordinal()); // 0
System.out.println(Value.B.ordinal()); // 1
}
}
// ASCII diagram:
// Go:
// iota: 0 -> 1 -> 2 -> 3 ...
// Java:
// enum.ordinal(): computed at runtime
In Go, values are computed at compile time, while in Java, part of the logic occurs at runtime.
Use iota for declarative sets of values, but do not rely on their order in the API. The reason is that changing the order of constants will change the values. Under the hood, iota is just auto-increment, and the compiler does not fix values rigidly. In Java, enum is safer, because it has a type and a name, not just a number. In Go, it is better to explicitly set values if they are involved in external contracts (for example, API).
iota is used to create flags, states, bit masks: logging, access rights, FSM states. Pros — compact code, absence of magic numbers. Cons — lack of clarity for newcomers, risk of breaking contracts. In Go, this is standard practice, while in Java — enum + fields are used more often. In low-level code, iota is often applied to generate bit values (1<<iota).
Interface internals (itab, dynamic dispatch)
Interface internals - Method table of the interface 🧩🔧 | dynamic dispatch via itab. . implements calls through type tables
Interfaces in Go are not just abstractions, but concrete structures in runtime. Each interface value consists of two parts: type (itab) and data pointer.
itab (interface table) is a structure that stores information about the type and the method table. When you call a method through an interface, dynamic dispatch occurs through this table.
In Java, interfaces are implemented through virtual tables (vtable) and invokevirtual/invokeinterface. The JVM can optimize calls through inline and devirtualization.
// ASCII diagram of Go interface:
// interface:
// [ itab pointer | data pointer ]
// itab:
// [ type info | method table ]
// data:
// [ actual value ]
So, an interface is a wrapper around a value + metadata.
Example code (Go)
package main
import "fmt"
type Speaker interface {
Speak()
}
type Human struct{}
func (h Human) Speak() {
fmt.Println("Hello")
}
func main() {
var s Speaker = Human{}
// dynamic dispatch via itab
s.Speak()
}
Example code (Java)
// In Java interface and dynamic dispatch
interface Speaker {
void speak();
}
class Human implements Speaker {
public void speak() {
System.out.println("Hello");
}
}
public class Main {
public static void main(String[] args) {
Speaker s = new Human();
// dynamic dispatch via JVM
s.speak();
}
}
Both languages use dynamic dispatch, but the implementation is different.
Remember that interfaces in Go have a cost. The reason is that each call goes through itab, which adds indirection. Under the hood, this is an additional pointer dereference. In hot-path code, this can be critical. In Java, the JVM can optimize such calls through JIT, while Go does not (or less). Therefore, in Go, concrete types are sometimes used instead of interfaces in performance-critical places.
Interfaces are used everywhere: DI, testing, abstractions. Pros - flexibility, loose coupling. Cons - call overhead, loss of inline optimizations. In Go, this is especially noticeable in tight loops. In Java, the JVM can eliminate overhead through JIT. In Go, it is more often necessary to optimize manually. In high-performance code, interfaces are sometimes avoided or generics are used.
If you come from Java, then you are familiar with finalize(), Cleaner, PhantomReference, and other tools for working with object lifecycles. In Go, everything is arranged differently: the language provides fewer guarantees and more low-level control.
We will analyze how these mechanisms work under the hood, when to use them, and why in most cases it is better to avoid them.
runtime.SetFinalizer
runtime.SetFinalizer - Finalizer 🧹⚰️ | executed before the object is deleted. . sets the cleanup function during garbage collection
runtime.SetFinalizer allows binding a finalizer function to an object. This function will be called by the garbage collector before the object is deleted.
Under the hood, Go runtime tracks objects for which a finalizer is set. When the GC detects that an object is no longer reachable (no references), it does not delete it immediately but places it in the finalization queue.
Then a special goroutine calls the finalizer. After this, the object may become reachable again (if the finalizer saved a reference), or it will be permanently deleted in the next GC cycle.
// ASCII diagram:
// [object] --(no refs)--> GC mark phase
// -> move to finalizer queue
// -> finalizer goroutine executes
// -> object may resurrect OR be collected next cycle
In Java, finalize() was used before, but it is considered deprecated. Now, Cleaner and PhantomReference are used, which provide more predictable behavior.
Code Example (Go)
package main
import (
"fmt"
"runtime"
)
type Resource struct {
name string
}
func main() {
r := &Resource{name: "file"}
// Setting the finalizer
runtime.SetFinalizer(r, func(res *Resource) {
fmt.Println("Finalizer called for:", res.name)
})
// Removing the reference
r = nil
// Forcing GC
runtime.GC()
// Giving time for the finalizer to execute
runtime.Gosched()
}
Code Example (Java)
// Analog through Cleaner (modern approach)
import java.lang.ref.Cleaner;
public class Main {
static class Resource {
private static final Cleaner cleaner = Cleaner.create();
private final Cleaner.Cleanable cleanable;
Resource(String name) {
this.cleanable = cleaner.register(this, () -> {
System.out.println("Cleaning resource: " + name);
});
}
}
public static void main(String[] args) {
Resource r = new Resource("file");
// Removing the reference
r = null;
System.gc(); // DOES NOT guarantee calling cleaner
}
}
In both languages, the call to the finalizer is not guaranteed and is non-deterministic.
Do not use SetFinalizer for releasing critical resources (files, sockets). The reason is the lack of execution time guarantees. Under the hood, finalizers are executed asynchronously in a separate goroutine, and the GC may delay their execution. This means resource leaks until the GC starts. In Java, finalize() was considered problematic for the same reasons. It is better to use explicit closing (Close/AutoCloseable). Finalizers are a fallback, not the primary mechanism.
SetFinalizer is rarely used: in runtime libraries, wrappers over C (cgo), managing off-heap resources. Pros — a way to ensure cleanup of resources. Cons — unpredictability, debugging difficulty, GC overhead. In Go, this is a "last resort" tool. In Java, Cleaner is used similarly — as a safety net. In high-load systems, reliance on finalizers can lead to leaks and performance degradation.
runtime.KeepAlive
runtime.KeepAlive - Protecting an object from GC 🛡️🧠 | retains memory until the end of use. . prevents premature collection of the object
runtime.KeepAlive is a function that tells the compiler and runtime that the object should be considered "alive" until a certain point in the code.
This is necessary because of compiler optimizations. Go can determine that a variable is no longer used and "kill" it sooner than you expect. GC may free the object before the function completes.
KeepAlive prevents this: it ensures that the object is considered alive until the KeepAlive call.
// ASCII diagram:
// obj used
// last use detected early by compiler
// GC may collect here ❌
// KeepAlive(obj) -> ensures object alive until here ✅
In Java, this problem is practically non-existent because the JVM manages the lifespan of objects more conservatively, and JIT takes escape analysis into account.
Code example (Go)
package main
import (
"fmt"
"runtime"
)
type Resource struct {
value int
}
func useResource(r *Resource) {
fmt.Println(r.value)
}
func main() {
r := &Resource{value: 42}
useResource(r)
// The compiler might think that r is no longer needed
// and GC may free it sooner
runtime.KeepAlive(r) // ensures that r is alive up to this point
}
Code example (Java)
// In Java, there is no such mechanism
public class Main {
static class Resource {
int value = 42;
}
public static void useResource(Resource r) {
System.out.println(r.value);
}
public static void main(String[] args) {
Resource r = new Resource();
useResource(r);
// The JVM ensures that r will not be collected prematurely
// KeepAlive is not needed
}
}
In Java, developers do not think about premature GC, in Go — sometimes they must.
Use KeepAlive only in conjunction with unsafe or cgo. The reason — ordinary Go code does not require it. Under the hood, the issue arises when an object is used "implicitly" (for example, passed to C or used through a pointer). The compiler does not see the usage and can remove it. KeepAlive fixes the lifetime point. Without it, use-after-free bugs are possible, which are extremely difficult to debug.
Applied in low-level code: cgo, system libraries, working with file descriptors, mmap. Pros — prevention of critical bugs. Cons — complexity of understanding, risk of misuse. In Go, it is a tool for system programming. In Java, similar problems are almost non-existent, because the JVM manages the lifespan more strictly. In high-level Go code, KeepAlive is rarely used.
The main goal is to show not only how it works in Go but also to make a direct comparison: "how it's done in Go" ↔ "how it's done in Java". This will help to better understand the philosophy of the languages: Go gives more control over memory and runtime, while Java provides more abstraction and safety.
Overview of Topics
All the topics under consideration can be conditionally divided into several categories:
- Working with memory: unsafe.Pointer, pointer arithmetic, zero-copy
- Layout optimization: struct alignment
- Compilation: iota
- Runtime and dispatch: interface internals
- Object lifecycle: SetFinalizer, KeepAlive
// Common scheme of levels:
// High-level:
// interfaces, iota
//
// Mid-level:
// zero-copy, struct alignment
//
// Low-level:
// unsafe.Pointer, pointer arithmetic
//
// Runtime control:
// SetFinalizer, KeepAlive
This scheme shows how the level of control gradually increases while the level of safety decreases.
Key differences between Go ↔ Java
Before moving on to the table, it is important to understand the fundamental difference:
- Go — gives access to memory and runtime (through unsafe and runtime)
- Java — hides implementation details behind the JVM
- Go — less magic, more responsibility
- Java — more optimizations from the JVM (JIT, GC tuning)
Comparative table of all terms
| Topic | As in Go | As in Java | Comment |
|---|---|---|---|
| unsafe.Pointer | Direct memory access, type casting | No (only Unsafe API) | In Go, this is a way to bypass the type system and work with memory directly. Under the hood, it is just an address. The GC may not always track such pointers correctly. In Java, the analogue via Unsafe is not part of the standard API and is used rarely. The JVM hides addresses to maintain safety. |
| struct alignment | Developer influences layout | JVM manages automatically | In Go, the order of fields affects the size of the structure and cache locality. The compiler adds padding. In Java, the layout is managed by the JVM, and sometimes even field reordering occurs. The developer does not control this directly, but the JVM optimizes for a specific architecture. |
| pointer arithmetic | Through unsafe.Pointer + uintptr | Prohibited | In Go, you can manually move through memory like in C, but only through unsafe. This completely disables safety. In Java, pointers are hidden, and access is done through array indices. This prevents memory-level errors. |
| zero-copy | Slices share backing array | More often copying | In Go, a slice is a view on an array. Under the hood, only the slice structure is copied. This provides high performance, but the risk of shared state. In Java, copies are more often created, or ByteBuffer is used. The JVM sometimes optimizes, but does not provide direct control. |
| iota | Compile-time counter | enum / static final | iota is computed at compile time and is convenient for generating sequences and bit masks. In Java, an enum is a full-fledged type with methods and safety. iota is simpler but less expressive and more fragile to changes. |
| interface internals | itab + data pointer | vtable / invokeinterface | In Go, an interface is a structure (type + data). Each call is a lookup through itab. In Java, a virtual table is used, and JIT can optimize calls. In Go, overhead is more predictable, in Java — it can be optimized. |
| runtime.SetFinalizer | Finalizers through runtime | Cleaner / PhantomReference | In Go, finalizers run asynchronously and are not guaranteed. This is a fallback mechanism. In Java, finalize() is deprecated, Cleaner is a more modern option. In both cases, one should not rely on finalization for resource management. |
| runtime.KeepAlive | Control object lifetime | Not required | In Go, the compiler may "kill" an object sooner than you expect. KeepAlive fixes the lifetime point. This is important when working with unsafe and cgo. In Java, the GC is more conservative, and such problems usually do not occur. |
How to Choose an Approach (Go vs Java)
// ASCII scheme of choice:
// Is maximum performance needed?
// |
// YES
// |
// Use Go low-level:
// - zero-copy
// - unsafe
// - alignment
//
// NO
// |
// Use safe abstractions:
// - Go: regular types, without unsafe
// - Java: standard APIs
//
// Is memory management needed?
// |
// YES
// |
// Go: unsafe + runtime
// Java: almost impossible (only Unsafe)
//
// NO
// |
// Trust GC
This scheme shows the key idea: Go provides tools for control, but you must understand the consequences.
Conclusion
All the mechanisms discussed show a fundamental difference between Go and Java. Go is a language that gives the developer access to the details of memory and runtime operation. You can manage the layout of structures, avoid data copying, work directly with pointers, and even intervene in the operation of the GC. But along with this, you lose some safety guarantees.
Java, on the other hand, hides these details. The JVM takes over memory management, call optimization, object layout, and lifespan. This makes the code safer and simpler, but limits the possibilities for low-level optimizations.
Practical takeaway: use the low-level capabilities of Go only when it’s truly necessary. For example, in high-load systems, network services, data processing, where each allocation matters. In other cases, it’s better to stick to safe abstractions.
If you are a Java developer—think of Go as a language that allows you to "go lower than the JVM." If you are a Go developer—remember that Java provides powerful optimizations through JIT, and sometimes a high-level approach can be just as effective.
The main balance: control ↔ safety. Go leans towards control, while Java leans towards safety. Understanding this balance allows you to choose the right tool for the task.
Оставить комментарий
Useful Articles:
New Articles: