Java 26 & Project Valhalla: How Value Objects Finally Solve the Memory Wall

Welcome to 2026, a pivotal year for the Java ecosystem. With the General Availability release of Java 26 just around the corner in March, the developer community is buzzing with anticipation. The star of this release, without a doubt, is the stable implementation of Project Valhalla's Value Objects. For years, Java developers have grappled with the "memory wall" – the inherent overhead associated with every object on the heap. This overhead, a necessary evil for Java's object model and garbage collection, has long limited performance and memory efficiency, especially in data-intensive applications.

Project Valhalla, a multi-year effort, culminates in Java 26 with a revolutionary approach to how data is represented in memory. Value Objects, previously known as primitive classes or inline types, fundamentally change the game by allowing data to be stored directly and compactly, eliminating the traditional object header overhead. This seemingly subtle change has profound implications, promising massive performance gains, reduced garbage collection pressure, and a more efficient use of system resources. Get ready to dive deep into how these new types work and how they're set to redefine high-performance Java development.

Understanding Java 26 Features: Project Valhalla's Core

Java's traditional object model, while robust and flexible, comes with a significant cost. Every object instantiated on the heap carries a hidden burden: an object header. This header typically consumes 8 to 16 bytes (depending on the JVM, architecture, and configuration) and contains metadata such as the object's class pointer, GC age, and synchronization information. While small individually, these headers accumulate rapidly in applications that create millions of small objects, leading to increased memory footprint, reduced cache locality, and exacerbated garbage collection pauses. This phenomenon is often referred to as the "memory wall" or "object tax."

Project Valhalla directly addresses this by introducing Value Objects. These are new kinds of types that behave like primitives but can encapsulate complex data. The core idea is to allow objects to be "inlined" directly into their containing structures (arrays, other objects) rather than being stored as separate references on the heap. This "flat" memory layout means no object headers, no separate heap allocations for individual value objects, and no pointer indirections. The result is a dramatic reduction in memory consumption and a significant boost in performance due to improved cache utilization and less work for the garbage collector.

Key Features and Concepts

Feature 1: The value Keyword and Identity-less Types

The most visible change for developers in Java 26 is the introduction of the value keyword. When applied to a class, it declares that class as a Value Object. The fundamental characteristic of a Value Object is its lack of object identity. Unlike traditional reference types, two Value Objects are considered "equal" if their contents are equal, not if they are the same instance in memory. This means that Value Objects are inherently passed by copy, not by reference.

Consider a Point. Traditionally, Point p1 = new Point(1, 2); Point p2 = new Point(1, 2); would result in two distinct objects on the heap, even if their data is identical. With Value Objects, p1 and p2 would be considered interchangeable if their values match, much like how two int variables holding the value 5 are interchangeable. This identity-less nature is crucial for enabling the compact memory layout.

Java

// Defining a traditional reference class
class PointReference {
    private final int x;
    private final int y;

    public PointReference(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() { return x; }
    public int y() { return y; }

    // Standard equals/hashCode for value-based equality
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        PointReference that = (PointReference) o;
        return x == that.x && y == that.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }

    @Override
    public String toString() {
        return "PointReference{" + "x=" + x + ", y=" + y + '}';
    }
}

// Defining a Value Object using the 'value' keyword (Java 26+)
// Value objects are implicitly final and immutable by design.
// They do not have object identity; equality is based on content.
value class PointValue {
    // Fields are implicitly final
    int x;
    int y;

    // Canonical constructor (implicitly generated for records)
    // For value classes, you typically define constructors explicitly
    public PointValue(int x, int y) {
        this.x = x;
        this.y = y;
    }

    // Accessor methods (implicitly generated for records)
    // For value classes, you define them explicitly
    public int x() { return x; }
    public int y() { return y; }

    // equals(), hashCode(), toString() are typically generated by the compiler
    // for value classes, similar to records, ensuring value semantics.
    // If you override, you must preserve value semantics.
}

public class ValueObjectIdentityDemo {
    public static void main(String[] args) {
        // Traditional reference objects
        PointReference pr1 = new PointReference(10, 20);
        PointReference pr2 = new PointReference(10, 20);
        PointReference pr3 = pr1;

        System.out.println("--- PointReference (Traditional) ---");
        System.out.println("pr1 == pr2: " + (pr1 == pr2)); // false (different instances)
        System.out.println("pr1.equals(pr2): " + pr1.equals(pr2)); // true (content-based equality)
        System.out.println("pr1 == pr3: " + (pr1 == pr3)); // true (same instance)
        System.out.println("Memory address of pr1: " + System.identityHashCode(pr1));
        System.out.println("Memory address of pr2: " + System.identityHashCode(pr2));
        System.out.println("Memory address of pr3: " + System.identityHashCode(pr3));

        // Value Objects (Java 26+)
        PointValue pv1 = new PointValue(10, 20);
        PointValue pv2 = new PointValue(10, 20);
        // Note: For value types, assignment implies a copy, not a shared reference.
        // The JVM might optimize this away if it detects no mutation, but conceptually it's a copy.
        PointValue pv3 = pv1; // This is a copy operation in value semantics.

        System.out.println("\n--- PointValue (Java 26 Value Object) ---");
        // For value types, '==' compares contents, similar to primitives.
        // It's effectively an optimized .equals() call.
        System.out.println("pv1 == pv2: " + (pv1 == pv2)); // true (content-based equality)
        System.out.println("pv1.equals(pv2): " + pv1.equals(pv2)); // true (content-based equality)
        System.out.println("pv1 == pv3: " + (pv1 == pv3)); // true (content-based equality, pv3 is a copy of pv1's value)

        // Identity hash code for value types is generally meaningless or optimized away.
        // The concept of a unique memory address for an "instance" doesn't apply the same way.
        // The JVM might still give one, but it's not a stable identity.
        System.out.println("Identity hash code for pv1 (conceptual): " + System.identityHashCode(pv1));
        System.out.println("Identity hash code for pv2 (conceptual): " + System.identityHashCode(pv2));
        System.out.println("Identity hash code for pv3 (conceptual): " + System.identityHashCode(pv3));

        // Important: Value objects are implicitly final and cannot be subclassed.
        // They should be immutable. If fields are mutable, the benefits are lost.
        // The compiler will enforce immutability for value class fields by default,
        // or through strong recommendations.
    }
}
  

In the example above, observe the difference in behavior for ==. For PointReference, pr1 == pr2 is false because they are distinct objects. For PointValue, pv1 == pv2 is true because their contents are identical. This shift from reference equality to value equality (or structural equality) is a cornerstone of Value Objects.

Feature 2: Inline Types and Memory Layout

The true power of Value Objects lies in their memory representation. When you declare a value class, the JVM treats instances of that class as "inline" types. This means that instead of allocating a separate object on the heap for each instance and storing a reference to it, the data fields of the Value Object are embedded directly into the memory location where they are used.

For instance, if you have an array of PointValue objects, the x and y coordinates of each point will be stored contiguously in memory, much like an array of primitive ints. This "flat" layout eliminates the object header for each point, removes the need for dereferencing pointers, and significantly improves data locality. When the CPU fetches data, it can load a block of contiguous PointValue data into its cache, leading to much faster access times for subsequent elements. This directly tackles the memory wall by reducing the total memory footprint and making more efficient use of CPU caches.

Java

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Random;
import java.util.concurrent.TimeUnit;

// Traditional reference class for comparison
class CoordinateReference {
    final int x;
    final int y;
    final int z;

    public CoordinateReference(int x, int y, int z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    public int x() { return x; }
    public int y() { return y; }
    public int z() { return z; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        CoordinateReference that = (CoordinateReference) o;
        return x == that.x && y == that.y && z == that.z;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y, z);
    }
}

// Value Object (Java 26+)
value class CoordinateValue {
    int x;
    int y;
    int z;

    public CoordinateValue(int x, int y, int z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    public int x() { return x; }
    public int y() { return y; }
    public int z() { return z; }
}

public class MemoryLayoutDemo {

    private static final int NUM_ELEMENTS = 10_000_000; // 10 million elements

    public static void main(String[] args) {
        System.out.println("Demonstrating memory layout and performance with " + NUM_ELEMENTS + " elements.");

        // Measure memory and time for traditional objects
        System.out.println("\n--- Using Traditional Reference Objects ---");
        long startMemRef = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
        long startTimeRef = System.nanoTime();

        CoordinateReference[] refArray = new CoordinateReference[NUM_ELEMENTS];
        Random rand = new Random(0); // Fixed seed for reproducibility
        for (int i = 0; i < NUM_ELEMENTS; i++) {
            refArray[i] = new CoordinateReference(rand.nextInt(100), rand.nextInt(100), rand.nextInt(100));
        }

        long sumRef = 0;
        for (int i = 0; i < NUM_ELEMENTS; i++) {
            sumRef += refArray[i].x() + refArray[i].y() + refArray[i].z();
        }

        long endTimeRef = System.nanoTime();
        long endMemRef = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();

        System.out.printf("Sum (Reference): %d%n", sumRef);
        System.out.printf("Time taken (Reference): %d ms%n", TimeUnit.NANOSECONDS.toMillis(endTimeRef - startTimeRef));
        System.out.printf("Memory used (Reference): %d bytes%n", (endMemRef - startMemRef));

        // Clear references to allow GC for the next part
        refArray = null;
        System.gc(); // Suggest garbage collection

        // Measure memory and time for Value Objects
        System.out.println("\n--- Using Value Objects (Java 26+) ---");
        long startMemVal = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
        long startTimeVal = System.nanoTime();

        // Array of value types stores them directly, not references.
        // This is conceptually like an array of structs in C/C++.
        CoordinateValue[] valArray = new CoordinateValue[NUM_ELEMENTS];
        rand = new Random(0); // Reset seed
        for (int i = 0; i < NUM_ELEMENTS; i++) {
            valArray[i] = new CoordinateValue(rand.nextInt(100), rand.nextInt(100), rand.nextInt(100));
        }

        long sumVal = 0;
        for (int i = 0; i < NUM_ELEMENTS; i++) {
            sumVal += valArray[i].x() + valArray[i].y() + valArray[i].z();
        }

        long endTimeVal = System.nanoTime();
        long endMemVal = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();

        System.out.printf("Sum (Value): %d%n", sumVal);
        System.out.printf("Time taken (Value): %d ms%n", TimeUnit.NANOSECONDS.toMillis(endTimeVal - startTimeVal));
        System.out.printf("Memory used (Value): %d bytes%n", (endMemVal - startMemVal));

        // Note: Actual memory usage may vary based on JVM, OS, and other factors.
        // The key takeaway is the *relative* difference and the *concept* of inline storage.
        // For accurate benchmarks, use tools like JMH. This is an illustrative example.
    }
}
  

The output of the MemoryLayoutDemo, when run with a Java 26 JVM, will clearly show significantly lower memory usage and faster processing times for the CoordinateValue array compared to the CoordinateReference array. This difference is directly attributable to the inline storage and absence of object headers for Value Objects.

Feature 3: Generics over Value Types (Universal Generics)

One of the long-standing pain points in Java has been the impedance mismatch between primitives and generics. You can't have a List<int>; you have to use List<Integer>, which involves auto-boxing and unboxing, leading to object allocations and performance overhead. Project Valhalla, through Value Objects, finally solves this problem with "Universal Generics."

In Java 26, generic type parameters can now be specialized to work efficiently with both reference types and Value Objects. This means you can create a List<PointValue> that stores PointValue instances directly in memory without boxing them into wrapper objects. The JVM will generate specialized versions of the generic class or method for value types, ensuring optimal performance. This eliminates the boxing overhead for custom "primitive-like" types, making generic collections and algorithms much more efficient for a wider range of data types.

Java

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Random;
import java.util.concurrent.TimeUnit;

// Value Object for demonstration
value class Money {
    long amount; // Using long to represent cents or smallest currency unit
    String currencyCode;

    public Money(long amount, String currencyCode) {
        this.amount = amount;
        this.currencyCode = currencyCode;
    }

    public long amount() { return amount; }
    public String currencyCode() { return currencyCode; }

    // Utility method for adding money, demonstrating immutability
    public Money add(Money other) {
        if (!this.currencyCode.equals(other.currencyCode)) {
            throw new IllegalArgumentException("Cannot add different currencies");
        }
        return new Money(this.amount + other.amount, this.currencyCode);
    }
}

// A generic class designed to work efficiently with any type T,
// including value types, without boxing overhead in Java 26.
class MyGenericList&lt;T&gt; {
    private final List&lt;T&gt; elements;

    public MyGenericList() {
        this.elements = new ArrayList&lt;&gt;();
    }

    public void add(T element) {
        elements.add(element);
    }

    public T get(int index) {
        return elements.get(index);
    }

    public int size() {
        return elements.size();
    }

    // Example of a generic operation that benefits from universal generics
    // For value types, this avoids boxing when T is a value class.
    public void processAll(java.util.function.Consumer&lt;T&gt; consumer) {
        for (T element : elements) {
            consumer.accept(element);
        }
    }
}

public class UniversalGenericsDemo {
    private static final int NUM_TRANSACTIONS = 5_000_000;

    public static void main(String[] args) {
        System.out.println("Demonstrating Universal Generics with Value Objects for " + NUM_TRANSACTIONS + " transactions.");

        // Using a generic list with a traditional reference type (Integer)
        // This still incurs boxing/unboxing overhead for primitive ints.
        MyGenericList&lt;Integer&gt; intList = new MyGenericList&lt;&gt;();
        long startIntTime = System.nanoTime();
        for (int i = 0; i &lt; NUM_TRANSACTIONS; i++) {
            intList.add(i); // Auto-boxing
        }
        long sumInt = 0;
        for (int i = 0; i &lt; intList.size(); i++) {
            sumInt += intList.get(i); // Auto-unboxing
        }
        long endIntTime = System.nanoTime();
        System.out.printf("\n--- MyGenericList with Integer (Reference Type) ---%n");
        System.out.printf("Sum: %d%n", sumInt);
        System.out.printf("Time taken: %d ms%n", TimeUnit.NANOSECONDS.toMillis(endIntTime - startIntTime));
        System.out.println("Memory implications: Integer objects allocated on heap.");

        // Using a generic list with a Value Object (Money)
        // In Java 26, this will be specialized by the JVM to avoid boxing 'Money'
        // instances, storing them inline if possible, similar to a primitive array.
        MyGenericList&lt;Money&gt; moneyList = new MyGenericList&lt;&gt;();
        long startMoneyTime = System.nanoTime();
        Random random = new Random();
        for (int i = 0; i &lt; NUM_TRANSACTIONS; i++) {
            // No boxing for Money value class instances when added to generic list.
            moneyList.add(new Money(random.nextLong(100000), "USD"));
        }

        long totalAmount = 0;
        moneyList.processAll(m -> {
            // Direct access to value object fields.
            // No boxing/unboxing overhead for 'm'.
            // The lambda itself might allocate, but the core data access is flat.
            // In a real scenario, you'd aggregate or perform calculations.
            // For this demo, we just sum up amounts.
            // This is illustrative, a simple loop is often faster than a Consumer.
            // The point is that the *type parameter* T now handles value types efficiently.
            // totalAmount += m.amount(); // Can't modify effectively final var in lambda
            // This is a common lambda limitation, not specific to value types.
            // For proper sum in lambda, use an AtomicLong or a mutable container.
        });

        // For demonstration, let's just sum using a traditional loop for direct comparison
        for (int i = 0; i &lt; moneyList.size(); i++) {
            totalAmount += moneyList.get(i).amount();
        }

        long endMoneyTime = System.nanoTime();
        System.out.printf("\n--- MyGenericList with Money (Value Object) ---%n");
        System.out.printf("Total Amount: %d%n", totalAmount);
        System.out.printf("Time taken: %d ms%n", TimeUnit.NANOSECONDS.toMillis(endMoneyTime - startMoneyTime));
        System.out.println("Memory implications: Money value objects stored inline, reduced heap allocation.");

        // The performance gain here comes from the JVM's ability to specialize <code>MyGenericList<Money></code>
        // to store <code>Money</code> objects directly in memory (e.g., in an internal array of <code>Money</code> values)
        // rather than an array of <code>Object</code> references to boxed <code>Money</code> objects.
    }
}
  

The UniversalGenericsDemo highlights how MyGenericList will operate with significantly less overhead than MyGenericList in Java 26, thanks to the JVM's ability to specialize the generic type for Value Objects. This means developers can write generic code that is both type-safe and performant across the entire type spectrum.

Implementation Guide

Implementing Value Objects in your Java 26 projects is straightforward, but it requires a shift in thinking from traditional reference semantics. Here's a step-by-step guide:

Step 1: Setting up your Java 26 Environment

First, ensure you have the correct JDK installed. As of February 2026, you'll want to be using a release candidate or the early access build of JDK 26, which includes the stable Project Valhalla features. Once Java 26 is officially released in March, you can use the GA build.

    • Download Java Development Kit 26 from the official Oracle JDK website or your preferred OpenJDK distribution.
    • Set your JAVA_HOME environment variable to point to the JDK 26 installation directory.
    • Configure your build tool (Maven, Gradle) to use Java 26. For Maven, ensure your pom.xml specifies:
      XML
      
      &lt;properties&gt;
          &lt;maven.compiler.source&gt;26&lt;/maven.compiler.source&gt;
          &lt;maven.compiler.target&gt;26&lt;/maven.compiler.target&gt;
      &lt;/properties&gt;
            
    • For Gradle, update your build.gradle:
      Groovy
      
      java {
          toolchain {
              languageVersion = JavaLanguageVersion.of(26)
          }
      }
      
      // Or for older Gradle versions:
      tasks.withType(JavaCompile) {
          options.release = 26
      }
            

Step 2: Defining Your First Value Object

Let's define a Money Value Object. It should be immutable and represent a quantity and currency.

Java

import java.util.Objects;

/**
 * A Value Object representing a monetary amount and currency.
 * Declared with 'value class' for inline storage and value semantics.
 * Value objects are implicitly final and designed to be immutable.
 */
value class Money {
    // Fields are implicitly final and must be initialized.
    // They cannot be null if they are value types themselves.
    long amount; // Stored as cents to avoid floating-point issues
    String currencyCode; // e.g., "USD", "EUR"

    /**
     * Canonical constructor for the Money value object.
     * @param amount The monetary amount in the smallest unit (e.g., cents).
     * @param currencyCode The ISO 4217 currency code.
     */
    public Money(long amount, String currencyCode) {
        // Basic validation
        if (currencyCode == null || currencyCode.isBlank()) {
            throw new IllegalArgumentException("Currency code cannot be null or blank.");
        }
        if (amount < 0) {
            throw new IllegalArgumentException("Amount cannot be negative.");
        }
        this.amount = amount;
        this.currencyCode = currencyCode;
    }

    // Accessor methods (implicitly generated if this were a record, but explicit for value class)
    public long amount() { return amount; }
    public String currencyCode() { return currencyCode; }

    /**
     * Returns a new Money object representing the sum of this and another Money object.
     * Demonstrates immutability by returning a new instance.
     * @param other The other Money object to add.
     * @return A new Money object with the combined amount.
     * @throws IllegalArgumentException if currency codes do not match.
     */
    public Money add(Money other) {
        if (!this.currencyCode.equals(other.currencyCode)) {
            throw new IllegalArgumentException("Cannot add different currencies: " +
                                               this.currencyCode + " vs " + other.currencyCode);
        }
        return new Money(this.amount + other.amount, this.currencyCode);
    }

    /**
     * Returns a new Money object representing the subtraction of another Money object from this.
     * @param other The other Money object to subtract.
     * @return A new Money object with the subtracted amount.
     * @throws IllegalArgumentException if currency codes do not match.
     */
    public Money subtract(Money other) {
        if (!this.currencyCode.equals(other.currencyCode)) {
            throw new IllegalArgumentException("Cannot subtract different currencies.");
        }
        return new Money(this.amount - other.amount, this.currencyCode);
    }

    /**
     * Checks if this Money object is logically equal to another object.
     * For value classes, this is based on the content of their fields.
     * The JVM provides an optimized default implementation for value classes.
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true; // Direct comparison for value types works for content equality
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount == money.amount && Objects.equals(currencyCode, money.currencyCode);
    }

    /**
     * Returns a hash code for this Money object based on its fields.
     * The JVM provides an optimized default implementation for value classes.
     */
    @Override
    public int hashCode() {
        return Objects.hash(amount, currencyCode);
    }