Skip to content

From Slices to Objects: Structural Differences in Go and Java Memory Usage Models

Overview

A comparative analysis of how Go and Java differ in data representation, arrays, slices, object layout, parameter passing, escape analysis, GC, runtime design, and engineering tradeoffs.

Abstract

Go and Java are both modern programming languages with automatic memory management, but they differ structurally in data representation, array design, object models, parameter passing, local views, escape analysis, and runtime optimization. Go tends to express data layout through value types, structs, arrays, slices, and pointers. Java centers its abstractions on objects, references, array objects, collection frameworks, and JVM runtime optimization. In mainstream 64-bit implementations, a Go slice is usually composed of three machine words: a pointer, a length, and a capacity. This small descriptor can express a continuous view over an underlying array. Java arrays and collection views are built on top of the object and reference model, so similar local views are usually expressed through object-oriented APIs such as List.subList() and ByteBuffer.slice(). This article analyzes the memory usage models of Go and Java through arrays, slices, collections, object headers, boxing, GC, and runtime capabilities, and then gives objective conclusions for engineering decisions.

Keywords: Go; Java; memory model; arrays; slices; object header; value types; GC; JVM


1. Introduction

Memory efficiency is not the result of a single syntax feature. It comes from the combined effects of a language type system, runtime model, object layout, compiler optimization, and standard-library abstractions. Go and Java both provide arrays, collections, automatic garbage collection, and cross-platform capabilities, but they start from different assumptions about how data exists in memory.

Go's basic data organization is closer to "values + pointers + runtime descriptors." Arrays and structs are values, and slices are lightweight descriptors over underlying arrays. The Go specification states that arrays and structs are self-contained values, while a slice value contains a length, a capacity, and a reference to its underlying array. (The Go Programming Language)

Java's basic data organization is closer to "objects + references + JVM management." The Java Language Specification states that an object may be either a class instance or an array, reference-type values are references to objects, and arrays themselves belong to the object system. (Oracle Docs)

Therefore, the memory differences between Go and Java should not be reduced to "which one is faster" or "which one uses less memory." They should be understood as differences between two models. Go expresses many lightweight data structures directly as values. Java unifies many abstractions into the object and reference system, and then relies on the JVM, JIT, and GC for optimization.


2. Go Arrays and Slices: Separating Fixed Data from Dynamic Views

In Go, an array is a fixed-length value type. The array length is part of the array type. For example, [4]int and [8]int are different types. An array variable stores the array value itself, and array assignment and array parameter passing follow value-copy semantics. The Go specification describes an array as a numbered sequence of elements of a single type and states that the array length is part of the array type. (The Go Programming Language)

This design makes Go arrays suitable for data structures with stable lengths and explicit layouts, such as fixed protocol headers, hash digests, small coordinates, and matrix blocks. However, fixed-length arrays also bring three limitations: the length cannot grow dynamically, passing an array may copy the entire array value, and slicing a local range cannot naturally be represented as an independent lightweight view.

Go uses slices to solve these limitations. A slice is not the underlying array itself, but a descriptor for a continuous segment of an underlying array. The Go specification states that a slice has a length and a capacity, that a slice value contains a reference to an underlying array, and that multiple slices may share the same underlying array. (The Go Programming Language)

From the Go runtime source code, the slice structure in the current implementation consists of three fields: an unsafe.Pointer to the underlying array, len, and cap. (The Go Programming Language)

go
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

On mainstream 64-bit platforms, a pointer is usually 8 bytes and int is usually 8 bytes, so the slice descriptor commonly occupies 24 bytes:

text
Pointer  8 bytes
Length   8 bytes
Capacity 8 bytes
Total   24 bytes

This 24-byte size is not a fixed ABI guarantee made by the Go language specification. It is a common fact of current mainstream 64-bit implementations. The specification guarantees slice semantics: length, capacity, and a reference to the underlying array. The concrete memory layout belongs to the implementation layer.


3. The Core Value of Slices: Expressing Large Array Views with Small Descriptors

The core value of a Go slice is not merely that it can grow dynamically. More importantly, it separates dynamic-array behavior into two layers: the upper layer is the lightweight slice descriptor, and the lower layer is the actual underlying array.

When the following code is executed:

go
s2 := s1[2:5]

Go does not need to copy the elements from s1[2] to s1[4]. It creates a new slice value. This new slice value records the new starting position, length, and capacity, and continues to share the original underlying array. The Go specification states that when a slice expression is applied to an array, array pointer, or slice, it produces a new slice that shares the underlying array. (The Go Programming Language)

Therefore, a slicing operation in Go can essentially be described as:

text
Create a new slice descriptor
Adjust pointer / len / cap
Share the backing array
Do not copy elements

This allows Go to express "local continuous views" with very low metadata cost. For network buffers, log batching, protocol parsing, file reads, serialization, and deserialization, this design is direct: the underlying data can remain continuous, while business code passes different slice views.


4. Java Arrays: Object-Oriented Continuous Storage

Java arrays are also continuous data structures, but in the Java model they are objects. The Java Language Specification states that arrays are dynamically created objects, array types are reference types, and an array variable stores a reference to an array object rather than the array object itself. (Oracle Docs)

For example:

java
int[] values = new int[1024];

Here, values is a reference, and the actual array object resides on the heap. For primitive arrays such as int[] and byte[], the array object internally stores continuous primitive data. For reference arrays such as User[], the array object internally stores a sequence of object references, while the actual User objects are still scattered elsewhere on the heap.

A Java array object contains not only the element area but also JVM-maintained object metadata. HotSpot object-header layout is a JVM implementation detail. OpenJDK documentation describes the current object header as consisting of a mark word and a class word, while array objects also need to record array length. (OpenJDK)

This creates a key difference: a Go slice descriptor can be copied and passed as a normal value; a Java array reference is only a reference value with address semantics, and it cannot also carry extra metadata such as offset, length, and capacity.


5. Slice-Like Capabilities in Java: View Objects and Copy APIs

Java can express local views, but it usually does so through object APIs.

List.subList(from, to) returns a view backed by the original list. The official Java documentation states that subList() returns a list backed by the original list, that non-structural changes in the view are reflected in the original list, and that it is designed as a range-operation mechanism. (Oracle Docs)

java
List<Integer> view = list.subList(2, 5);

This is similar to Go slices in the sense that it shares underlying data and does not copy elements. However, the implementation path is different. A Go slice view is a small language-level value. Java subList() returns a view object from a library API.

ByteBuffer.slice() is closer to a Go slice. The official Java documentation states that ByteBuffer.slice() creates a new buffer whose content is a shared subsequence of the original buffer's content; content changes are visible between the two buffers, while position, limit, and mark are independent. (Oracle Docs)

java
ByteBuffer view = buffer.slice();

However, ByteBuffer.slice() still returns an object. This object needs to store position, limit, capacity, mark, a reference to the underlying storage, and other metadata. It is not a three-field value structure that universally exists at the language level like a Go slice.

Java also provides high-performance copy APIs. The official documentation describes System.arraycopy() as a utility method for quickly copying array segments, while Arrays.copyOfRange() copies a specified array range into a new array. (Oracle Docs)

java
int[] copied = Arrays.copyOfRange(source, 2, 5);

These APIs solve the copy problem, not the view problem. Go's s[2:5] expresses a shared view by default. Java's Arrays.copyOfRange() explicitly creates a new array and copies elements.


6. Value Types and Object Models: The Root Difference in Memory Density

A Go struct is a value type. Struct fields are laid out directly according to alignment rules, and elements in an array of structs are usually stored continuously. A struct containing two int32 fields can commonly be represented as 8 bytes of data; an array of such structs is a continuous sequence of struct values.

go
type User struct {
    ID  int32
    Age int32
}

users := make([]User, 10000)

This kind of structure is closer in memory to:

text
[ID,Age][ID,Age][ID,Age]...

A normal Java object has object identity, an object header, type information, lock state, GC metadata, and other runtime needs. Even a normal User object with only two int fields still needs an object header, field area, and alignment padding. The exact object-header size depends on the JVM implementation, compressed pointers, object alignment, and JDK version. OpenJDK object-header design documentation states that the HotSpot object header contains a mark word and a class word, and that array objects also include a length field. (OpenJDK)

Java primitive arrays are still efficient. For example, int[] stores continuous int values internally and has good memory density. However, Java collections cannot directly hold primitive generic parameters. ArrayList<Integer> stores Integer references, not raw int values. Java generics are implemented through type erasure. Oracle documentation states that type parameters are replaced by their bounds or by Object, and the generated bytecode contains only ordinary classes, interfaces, and methods. (Oracle Docs)

When a primitive int is placed in a context that requires Integer, autoboxing occurs. Oracle's official tutorial describes autoboxing as the process of converting primitives such as int into corresponding wrapper-class objects. (Oracle Docs)

Therefore:

java
int[] a = new int[10000];
List<Integer> b = new ArrayList<>();

These two forms do not have the same memory shape. int[] is one array object plus continuous primitive data. ArrayList<Integer> involves at least the ArrayList object, its internal Object[] array, and a large number of Integer objects or cached-object references. For large numeric data, Java primitive arrays are far more memory-efficient than boxed collections. Go's []int and []struct naturally organize data as value sequences.


7. Parameter Passing: Go Copies Small Descriptors, Java Copies Reference Values

Neither Go nor Java copies an entire object graph during function or method calls, but what gets copied is different.

In Go, an array is a value, so passing an array copies the array value. A slice is a small descriptor, so passing a slice copies the descriptor and does not copy the underlying array. The Go specification states that arguments are assigned to parameters during a function call; arrays and structs are self-contained values, while slice values contain references to underlying arrays. (The Go Programming Language)

go
func handleArray(a [1024]int) {
    // Copies the entire array value.
}

func handleSlice(s []int) {
    // Copies only the slice descriptor.
}

In Java, method parameters are also passed by value. However, if the parameter type is an object or array type, the copied value is the reference value. The Java Language Specification states that reference-type values are references to objects. (Oracle Docs)

java
void handleArray(int[] a) {
    // Copies the reference value, not the array object.
}

The difference is that a Go slice value itself carries pointer, length, and capacity. A Java reference value only expresses "points to an object." If Java needs to additionally describe range, offset, or capacity, another object is needed to store those fields.


8. Stack Allocation, Escape Analysis, and GC Pressure

Go's memory efficiency also comes from compiler escape analysis. The Go compiler analyzes whether a variable escapes the current scope. Objects that can safely stay on the stack do not need to enter the heap, reducing GC burden. The Go compiler source describes the goal of escape analysis as determining which variables and implicit allocations can be safely allocated on the stack. (The Go Programming Language)

For example, local structs, short-lived slice descriptors, and temporary objects can often be placed on the stack if they do not escape. Stack memory is allocated and released as function calls enter and return, so the GC does not need to track it like heap objects.

Java HotSpot also has JIT optimization capabilities such as escape analysis and scalar replacement, but Java's language-level abstractions still center on objects and references. SubList, ByteBuffer, ordinary business DTOs, and collection wrappers all exist as object APIs. The JIT may eliminate some allocations under specific conditions, but that is a runtime optimization result and does not change Java API object semantics.

Therefore, in scenarios with many small objects, short-lived views, and frequent buffer splitting, Go's value types and slice descriptors usually make low-allocation paths easier to form. Java relies more on JVM optimization, object reuse, primitive arrays, direct buffers, escape analysis, and GC tuning.


9. Strings and Immutable Views

Go strings also reflect a similar lightweight descriptor idea. The Go specification states that a string is an immutable sequence of bytes, and its length can be obtained through len. (The Go Programming Language)

In current mainstream implementations, a string can usually be understood as:

text
pointer + length

In other words, it is commonly a 16-byte descriptor on 64-bit platforms. A string has no capacity field, and its content is immutable. Both slices and strings reflect an important Go design tendency: use small values to describe a segment of underlying data, instead of turning every local view into an independent heap object.

Java String is an object. Modern JDK implementations of String have changed several times, for example from an earlier char[] representation to later compact strings. But at the language-model level, String is still an object reference, not an ordinary value descriptor. This allows Java to integrate strings into the object model, library methods, reflection, JIT, and GC system, but it also means its basic abstraction path differs from Go's.


10. GC and Runtime: Go Is Lighter, Java Is More Tunable

Both Go and Java have GC, but their runtimes have different positions.

The official Go GC guide states that the goal of Go's GC documentation is to help users understand application costs and improve resource usage; Go's GC design centers on automatic memory management, concurrent collection, and low pauses. (The Go Programming Language)

The Go runtime is relatively lightweight. Compiled artifacts usually include the required runtime directly and run as native binaries. For microservices, CLI tools, sidecars, agents, gateways, and log processors, Go's common advantages are fast startup, simple binary deployment, low idle memory, and controllable container images.

The Java JVM is a more complex managed runtime. It includes class loading, interpretation, JIT compilation, multiple GCs, runtime monitoring, thread models, and rich diagnostic tools. Oracle's official GC documentation lists several HotSpot garbage collectors, including Serial, Parallel, G1, and ZGC. Different collectors target different goals for latency, throughput, and heap size. (Oracle Docs)

ZGC is described in official documentation as a scalable low-latency GC that can perform expensive work concurrently, making it suitable for low-latency and large-heap scenarios. (Oracle Docs)

Therefore, Go's advantage is a simple default model, lightweight runtime, and easier formation of low-allocation paths. Java's advantage is strong JVM runtime optimization, rich GC strategies, and suitability for long-running large enterprise applications. For lightweight services ranging from tens to hundreds of megabytes, Go usually has a cleaner memory curve. For complex business systems, large heaps, long-term JIT optimization, APM monitoring, dynamic proxies, and mature framework ecosystems, Java still has clear advantages.


11. Java's Platform Advantages: Ecosystem, JIT, GC, and Dynamic Capabilities

Java is not at a comprehensive disadvantage compared with Go. Its strengths are concentrated in platform engineering capability rather than the memory density of a single data structure.

First, Java has a mature enterprise framework ecosystem. Spring, Netty, MyBatis, Kafka client, Flink, Hadoop, the Elasticsearch ecosystem, and many APM and diagnostic tools are all built on long-term JVM accumulation. For large enterprise applications, Java has mature standards, a deep talent pool, mature frameworks, and a complete problem-diagnosis toolchain.

Second, JIT is a core Java advantage. The JVM can collect hotspot information while a program runs and optimize hot code through inlining, devirtualization, escape analysis, scalar replacement, lock elimination, and other techniques. Go mainly relies on ahead-of-time compilation. It is simple to deploy and stable in behavior, but it does not have the JVM's long-running adaptive optimization capability.

Third, Java offers more diverse GC strategies. Parallel GC can favor throughput, G1 targets general server-side applications, and ZGC targets low latency and large heaps. Oracle documentation explicitly positions ZGC as a scalable low-latency collector and states that it is suitable for low-latency or very large heap applications. (Oracle Docs)

Finally, Java has strong dynamic capabilities. Reflection, dynamic proxies, instrumentation, Java agents, and bytecode enhancement make it easier for APM, distributed tracing, runtime diagnostics, non-intrusive instrumentation, mocking, and hot deployment to form mature ecosystems. Go has reflection, but its static compilation model makes it difficult to provide the same runtime weaving capability that exists at the JVM bytecode level.


12. Engineering Conclusion

The memory usage differences between Go and Java ultimately come from two different design centers.

Go's design center is:

text
Value types
Compact struct layout
Fixed array storage
Lightweight slice views
Explicit pointer-based sharing
Escape analysis that reduces heap allocation
Relatively lightweight runtime

Java's design center is:

text
Object model
Reference semantics
Array objects
Collection framework
JVM managed runtime
Long-running JIT optimization
Multiple GC strategies
Dynamic proxies and runtime enhancement

In memory density, Go's []struct, []int, []byte, slice operations, and local buffer processing are usually closer to the underlying data layout, allowing large volumes of continuous data to be expressed with fewer objects. This model is naturally favorable for log collection, network proxies, serialization, protocol processing, sidecar processes, CLI tools, lightweight microservices, and infrastructure agents.

In platform capability, Java's object model, JVM, JIT, GC matrix, and enterprise framework ecosystem are better suited for complex business systems. For large business-domain modeling, complex transaction systems, enterprise middle platforms, long-running services, large-heap applications, non-intrusive APM governance, and mature framework integration, Java still has clear engineering advantages.

Therefore, the most accurate conclusion is:

Go is more direct in memory expression, especially in using value types and slices to describe continuous data. Java is more complete in platform abstraction, especially in using the object model, JVM optimization, and ecosystem frameworks to support complex enterprise applications. A Go slice is not merely syntactic sugar; it is part of the language-level memory model. Java arrays and collection views do have similar capabilities, but they must be expressed through the object and reference system. This difference means Go makes it easier to write infrastructure programs with low memory usage and low object counts, while Java is better suited to large application systems that are complex, long-lived, and strongly dependent on mature ecosystems.

Chinese Reference

GitHub Discussions

Join the discussion

Comments are synchronized with GitHub Discussions in stellhub/stell-web.

Powered by VitePress and GitHub Discussions.