- unsafe.Pointer
- Struct field alignment (alignment of fields)
- Pointer arithmetic (pointer arithmetic)
- Zero-copy techniques
- iota
- Interface internals (itab, dynamic dispatch)
- runtime.SetFinalizer
- runtime.KeepAlive
- Overview of Topics
- Key Differences Between Go ↔ Java Philosophy
- Comparative table of all terms
- How to choose the approach (Go vs Java)
- Conclusion
Low-level mechanisms - part 2 | Go ↔ Java
In this article, we gathered the key low-level mechanisms of Go that most often raise questions for developers coming from Java. We will consider: unsafe.Pointer, struct alignment, pointer arithmetic, zero-copy, iota, the internals of interfaces, runtime.SetFinalizer, and runtime.KeepAlive.
We will discuss 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 analogous 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 an 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 just an address in memory without type information.
Unlike regular pointers (*T), which are strictly typed, unsafe.Pointer allows casts between incompatible types. This is similar to void* in C. However, the Go compiler and runtime lose type information, which means the garbage collector may behave 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 accessed 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 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 the value
unsafe.putLong(address, 42L);
// Reading the value
long value = unsafe.getLong(address);
System.out.println(value);
// Freeing the memory
unsafe.freeMemory(address);
}
}
Use unsafe.Pointer only in extreme cases. The reason is that you bypass Go's 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 use 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 is high speed, no allocations. The downside is lack of safety, possible errors when transferring between architectures. In Java, similar tasks are solved via ByteBuffer or DirectByteBuffer, but with less control and more safety.
Struct field alignment (alignment of fields)
Struct field alignment - Memory alignment for the ruler 📏🧩 | packing fields for speed. . optimizes the placement of fields in the structure
Alignment is the way of placing structure fields in memory considering CPU requirements. Each type has its own alignment (for example, int64 is usually aligned to 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 decides 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 a waste of memory due to poor field order. 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 violate the order. Under the hood, this is related to the fact that access to unaligned data may require multiple operations or even raise 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 worsened 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 give a noticeable advantage.
Pointer arithmetic (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 an 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 does not exist 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, while 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 delete an object, while uintptr will remain an old address. This leads to dangling pointer. Always perform the conversion in one line. In Java, there are no such problems 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 — unsafety and complexity. In Go, this is a tool for systems programming. In Java, arrays, ByteBuffer and Unsafe (rarely) are used instead. The difference is — Go provides control, Java — safety.
In this article, we continue to delve into low-level aspects of Go that are particularly interesting to developers coming from Java. We will cover 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 internals 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 with that comes greater responsibility.
Zero-copy techniques
Zero-copy techniques - Zero data copies ⚡📦 | stream without extra copying. . avoids unnecessary data copying in memory
Zero-copy is an approach where data is not copied when transferring between components of the system. Instead of creating new arrays or buffers, references to already existing memory are used. In Go, this is particularly important when working with slices ([]byte), strings, and network buffers.
Under the hood, a slice in Go is a structure consisting of three fields: a pointer to the array, length, and capacity. When you take a slice of a slice, only the structure (3 words) is copied, not the actual data. This is zero-copy.
In Java, similar behavior exists with ByteBuffer and String (until Java 7u6, strings could share a char[] array), but nowadays copying happens more often. The JVM tries to balance 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 also change because it's the same memory
fmt.Println(string(sub)) // Hello
}
Code Example (Java)
// In Java, copying happens more often
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 diagram:
// 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, there are 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 issues happen less frequently because copying is more often done 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. The advantages are minimal allocations, high performance, less GC pressure. The disadvantages are complexity, risk of accidental data modification, security issues. In Go, this is the main optimization tool, in Java, ByteBuffer and Netty (with pooled buffers) are used more often with control and abstraction.
iota
iota - Auto-incrementing constants 🔢⚡ | 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 automatic generation of sequential values.
Under the hood, iota is just a counter that increases by 1 with each new line in the const block. But its power is 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)
// 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 scheme:
// Go:
// iota: 0 -> 1 -> 2 -> 3 ...
// Java:
// enum.ordinal(): calculated 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 lock the values firmly. In Java, enum is safer, because it has a type and a name, not just a number. In Go, it's better to explicitly define values if they are involved in external contracts (for example, API).
iota is used for creating flags, states, bit masks: logging, access rights, FSM states. Pros — compact code, absence of magic numbers. Cons — lack of clarity for beginners, risk of breaking the contract. In Go, this is standard practice, in Java — enums + fields are used more often. In low-level code, iota is frequently used for generating bit values (1<<iota).
Interface internals (itab, dynamic dispatch)
Interface internals - Method table of the interface 🧩🔧 | dynamic dispatch through itab. . implements calls through type tables
Interfaces in Go are not just abstractions, but concrete structures at runtime. Each interface value consists of two parts: type (itab) and data pointer.
itab (interface table) is a structure that holds type information and a method table. When you invoke 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 representation of Go interface:
// interface:
// [ itab pointer | data pointer ]
// itab:
// [ type info | method table ]
// data:
// [ actual value ]
So the interface is a wrapper around the 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 through 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 through JVM
s.speak();
}
}
In both languages, dynamic dispatch is used, 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 via JIT, while Go cannot (or to a lesser extent). 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 often necessary to manually optimize. In high-performance code, interfaces are sometimes avoided or generics are used.
If you come from Java, you are familiar with finalize(), Cleaner, PhantomReference, and other tools for working with the lifecycle of objects. In Go, everything is arranged differently: the language offers fewer guarantees and more low-level control.
We will look at 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 a cleanup function for 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 that, the object can again become reachable (if the finalizer maintained 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 in the past, but it has been 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"}
// Set the finalizer
runtime.SetFinalizer(r, func(res *Resource) {
fmt.Println("Finalizer called for:", res.name)
})
// Remove the reference
r = nil
// Force GC
runtime.GC()
// Give the finalizer time to execute
runtime.Gosched()
}
Code example (Java)
// Analogue via 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");
// Remove the reference
r = null;
System.gc(); // DOES NOT guarantee cleaner invocation
}
}
In both languages, the invocation of 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 runs. In Java, finalize() has been recognized as problematic for the same reasons. It is better to use explicit closure (Close/AutoCloseable). Finalizers are a fallback, not the primary mechanism.
SetFinalizer is used rarely: in runtime libraries, wrappers over C (cgo), managing off-heap resources. Pros — the ability to secure and clean up resources. Cons — unpredictability, debugging difficulty, GC overhead. In Go, it 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 - Protection of the object from GC 🛡️🧠 | holds memory until the end of use. . prevents premature collection of the object
runtime.KeepAlive is a function that informs the compiler and runtime, that the object should be considered "alive" until a certain point in the code.
This is necessary due to compiler optimizations. Go can determine, that the variable is no longer used, and "kill" it earlier than you expect. GC can free the object before the function ends.
KeepAlive prevents this: it ensures that the object is considered alive until the call to KeepAlive.
// 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 almost non-existent because the JVM manages the lifetime of objects more conservatively and JIT considers escape analysis.
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 may consider that r is no longer needed
// and the GC may free it earlier
runtime.KeepAlive(r) // ensure that r is alive up to this point
}
Code example (Java)
// In Java, such a mechanism does not exist
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 guarantees that r will not be collected prematurely
// KeepAlive is not needed
}
}
In Java, the developer does not worry about premature GC, in Go — sometimes must.
Use KeepAlive only in conjunction with unsafe or cgo. The reason is regular Go code does not require this. Under the hood, the problem arises when the object is used "implicitly" (for example, passed to C or used via a pointer). The compiler does not see the usage and may remove it. KeepAlive fixes the point of life. Without it, use-after-free bugs are possible, which are extremely difficult to debug.
Used 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 this is a tool for system programming. In Java, similar problems are almost non-existent, because the JVM manages lifetime 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 is done in Go” ↔ “how it is done in Java”. This will help to better understand the philosophy of the languages: Go gives more control over memory and runtime, while Java offers more abstraction and safety.
Overview of Topics
All the topics under consideration can be conditionally divided into several categories:
- Memory Management: unsafe.Pointer, pointer arithmetic, zero-copy
- Layout Optimization: struct alignment
- Compilation: iota
- Runtime and Dispatch: interface internals
- Object Lifecycle: SetFinalizer, KeepAlive
// General 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 and the level of safety decreases.
Key Differences Between Go ↔ Java Philosophy
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, cast between types | No (only Unsafe API) | In Go this is a way to bypass the type system and work with memory directly. Under the hood, it's just an address. GC cannot always track such pointers correctly. In Java, the equivalent through Unsafe is not part of the standard API and is rarely used. The JVM hides addresses to maintain security. |
| struct alignment | Developer influences layout | The JVM manages it automatically | In Go the order of fields affects the size of the struct and cache locality. The compiler adds padding. In Java, layout is managed by the JVM, and sometimes even fields are reordered. The developer does not control this directly, but the JVM optimizes for the specific architecture. |
| pointer arithmetic | Through unsafe.Pointer + uintptr | Forbidden | In Go you can manually traverse 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 carries a 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 via itab. In Java, a virtual table is used, and JIT can optimize calls. In Go, the overhead is more predictable, while in Java it can be optimized. |
| runtime.SetFinalizer | Finalizers via 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 of object lifetime | Not required | In Go, the compiler can "kill" an object earlier than you expect. KeepAlive fixes the point of life. This is important when working with unsafe and cgo. In Java, GC is more conservative, and such problems usually do not exist. |
How to choose the approach (Go vs Java)
// ASCII scheme of choice:
// Is maximum performance required?
// |
// YES
// |
// Use Go low-level:
// - zero-copy
// - unsafe
// - alignment
//
// NO
// |
// Use safe abstractions:
// - Go: regular types, without unsafe
// - Java: standard APIs
//
// Is memory management required?
// |
// 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 need to 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 operations. You can manage the layout of structures, avoid data copying, work directly with pointers, and even intervene in the work of the GC. But along with this, you lose some safety guarantees.
Java, on the other hand, hides these details. The JVM takes care of 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 absolutely necessary. For example, in high-load systems, network services, and data processing, where every allocation counts. In other cases, it is better to stick to safe abstractions.
If you are a Java developer—think of Go as a language that allows you to "go below the JVM". If you are a Go developer—keep in mind that Java offers powerful optimizations through JIT, and sometimes a high-level approach can be equally 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 job.
Оставить комментарий
Useful Articles:
New Articles: