Java Cup
Inside Java

News and views from members of the Java team at Oracle

A Valhalla Ticker in One int

A ticker symbol is an abbreviation that uniquely identifies a financial product such as a publicly traded share. Well-known ticker symbols are "ORCL" and "GOOGL". In this article, we will devise a Valhalla value class that can represent a ticker symbol using only a single int field. Value classes are part of JEP 401: Value Objects, a preview feature in JDK 28. Performance-wise, the value class might, under certain conditions be scalarized. That means allowing the single int field to flow through local variables, arguments, and returns without a separate object allocation, thereby improving speed. More on that in the performance section.

Let's begin the implementation from the following class scaffold that defines the encoding alphabet, the immutable field representation and conversion boundaries:

// The keyword `value` indicates this is a value class. A value class has no identity.
public value class Ticker {

    // These are the allowed characters we can pack.
    private static final String ALPHABET = ".0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";

    // The one and only immutable instance field.
    private final int packed;

    public Ticker(String symbol) {
        // Compute a packed `int` value from the input string.
        this.packed = ...
    }

    @Override
    public String toString() {
        // Unpack the `int` again and convert it back to a String.
        return ...
    }
}

As can be seen, the class can hold short strings: any sequence of one to five characters drawn from uppercase A–Z, digits 0–9, and .. This 37-symbol alphabet requires six bits per character, allowing up to five characters to be encoded within 30 bits of a 32-bit int, with the two most significant bits remaining unused.

The constructor is responsible for converting the ticker symbol into its packed 30-bit representation:

public Ticker(String symbol) {
    if (symbol.isEmpty() || symbol.length() > 5) {
        throw new IllegalArgumentException("Expected one to five characters");
    }

    int bits = 0;
    for (int i = 0; i < symbol.length(); i++) {
        // + 1 below means zero is reserved for padding.
        int code = ALPHABET.indexOf(symbol.charAt(i)) + 1;
        if (code == 0) {
            throw new IllegalArgumentException("Expected A-Z, 0-9, or .");
        }
        bits = (bits << 6) | code;
    }
    this.packed = bits << (6 * (5 - symbol.length()));
}

The length check rejects empty symbols and symbols longer than five characters. For each character, ALPHABET.indexOf() finds its position in the alphabet. Adding one gives a code between 1 and 37. An unsupported character has an index of -1, so its code becomes zero and the constructor rejects it.

The loop packs the characters together in the order they appear in the symbol, moving the previous codes left by six bits to make room for each new one. Once they're all packed, the constructor moves the whole sequence to the left of the 30-bit space. A four-character symbol then has one empty position on the right; a five-character symbol fills the space. Those empty positions stay zero, so we can find the end of the symbol when we read it back from packed.

To recover the symbol from the packed int, toString() reads those slots from left to right and builds a String:


@Override
public String toString() {
    StringBuilder text = new StringBuilder(5);
    for (int shift = 24; shift >= 0; shift -= 6) {
        int code = (packed >>> shift) & 0x3f;
        if (code == 0) {
            break;
        }
        text.append(ALPHABET.charAt(code - 1));
    }
    return text.toString();
}

The loop starts at bit position 24, where the first character's six-bit slot begins. The unsigned right shift brings that slot down to the lowest bits, and the mask 0x3f keeps only those six bits. Subtracting one from the recovered code reverses the constructor's offset, giving the character's index in ALPHABET.

The loop moves six bits at a time until it finds zero or reads all five slots. Zero ends the symbol because the constructor uses it only for padding. There is no separate length field to decode.

The encoding also preserves string order. The characters in ALPHABET appear in the same order that String.compareTo() uses for this character set: the period comes before digits, and digits come before uppercase letters. The first character occupies the highest slot, so the first differing character determines the integer comparison too. When one symbol is a prefix of another, its zero padding makes the shorter symbol sort first.

The upper two bits are always zero, so every packed value is nonnegative. Consequently, a direct signed int comparison gives the same order as comparing the ticker strings: negative, zero, or positive. Therefore, sorting and comparing need no decoded strings. This observation allows us to easily implement a performant Comparable<Ticker> method:

public value class Ticker implements Comparable<Ticker> {

    // Other methods not shown for brevity

    @Override
    public int compareTo(Ticker other) {
        return Integer.compare(packed, other.packed);
    }
}

Implementing CharSequence also lets callers read the symbol's length and individual characters without first converting it to a String. The class adds those operations to the encoding and comparison methods:

public value class Ticker implements CharSequence, Comparable<Ticker> {

    // Other methods not shown for brevity

    @Override
    public int length() {
        int length = 0;
        for (int shift = 24; shift >= 0; shift -= 6) {
            if (((packed >>> shift) & 0x3f) == 0) {
                break;
            }
            length++;
        }
        return length;
    }

    @Override
    public char charAt(int index) {
        if (index < 0 || index >= length()) {
            throw new IndexOutOfBoundsException(index);
        }
        int code = (packed >>> (24 - 6 * index)) & 0x3f;
        return ALPHABET.charAt(code - 1);
    }

    @Override
    public CharSequence subSequence(int start, int end) {
        return toString().substring(start, end);
    }

}

ORCL is an illustrative code: new Ticker("ORCL") stores 0x1A74E5C0. Its 6-bit blocks are 26 ("O"), 29 ("R"), 14 ("C"), 23 ("L"), and 0 (unused), so toString() reconstructs ORCL. Five-character symbols such as GOOGL use every slot; codes such as 130A use the same encoding for digits and letters.

length() counts nonzero slots, reading at most five of them. charAt() first checks its index against that length, then extracts the code from the requested slot. Neither method builds a string. subSequence() takes a different approach: it decodes the whole symbol with toString(), then returns the requested slice as a String.

A Ticker can be passed to APIs expecting CharSequence; for example, new StringBuilder("Symbol: ").append(new Ticker("ORCL")) produces Symbol: ORCL using append(CharSequence). That overload reads the ticker's characters through the interface, so it does not need to call the ticker's toString() method first. With the whole symbol packed into one integer, the JVM can sometimes work directly with that value and avoid allocating a separate Ticker object.

The constructor checks the symbol's format, but does not verify whether it is listed on an exchange. Its five-character limit is specific to this example. Nasdaq's symbol specification permits longer symbols, so a production ticker type must follow the format of its exchange or data feed.

Improved constructor

If we look back at the constructor, it only needs the symbol's length and individual characters, both available through CharSequence. While making that change, we can also replace the ALPHABET search with a direct calculation of each six-bit code. So, the idea is to replace the old Ticker(String) constructor with this one that also compiles with a String argument provided:

public Ticker(CharSequence symbol) {
    int length = symbol.length();
    if (length < 1 || length > 5) {
        throw new IllegalArgumentException("Expected one to five characters");
    }

    int bits = 0;
    for (int i = 0; i < length; i++) {
        char ch = symbol.charAt(i);
        int code;
        if (ch == '.') {
            code = 1;
        } else if (ch >= '0' && ch <= '9') {
            code = ch - '0' + 2;
        } else if (ch >= 'A' && ch <= 'Z') {
            code = ch - 'A' + 12;
        } else {
            throw new IllegalArgumentException("Expected A-Z, 0-9, or .");
        }
        bits = (bits << 6) | code;
    }
    this.packed = bits << (6 * (5 - length));
}

The three branches assign the same codes as the alphabet lookup: the period gets 1, digits get 2 through 11, and uppercase letters get 12 through 37. The shift and padding operations are unchanged, so both constructors produce the same packed integer for a given symbol.

This version reads the length once and replaces each alphabet search with character comparisons and arithmetic. Whether that improves performance in a particular caller needs measurement.

Performance with Valhalla

At the Java language level, a Ticker is still a reference value, even though it is declared as a value class. In hot code compiled by HotSpot's C2 compiler, a Ticker may be scalarized so its single int flows through local variables, arguments, and returns without a separate object allocation. This is an optimization, not a guaranteed calling convention. Calls through CharSequence or Comparable can make the concrete type harder for C2 to see.

A concrete Ticker[] array can use a flat layout. The JVM may place each ticker's bits inside the array instead of storing references to separate objects. The array still has identity, and its elements can be null. The layout must account for null values as well as the packed integer, so four bytes of field data do not guarantee four bytes per element.

Storage such as CharSequence[] can hold several concrete types and generally cannot use the same layout. These optimizations depend on the JVM and the code it compiles. Scalarization starts with C2 compilation after warm-up, and calls to toString() or subSequence() can still allocate strings. OpenJDK's value-object performance notes describe those limits.

Array scan benchmark

To provide indicative performance figures, I wrote a benchmark that compares a minimal ValueTicker with an otherwise equivalent ordinary IdentityTicker. Both arrays contain the same valid five-letter encodings and are populated before timing starts. The timed loop reads the packed values; it does not construct or decode tickers.

On an Apple M4 Pro with a local OpenJDK 28-internal build, the medians of five JVM runs were:

Array Nanoseconds per element
ValueTicker[] 0.261
IdentityTicker[] 0.385

Each JVM warmed up for three seconds, then measured eight trials of ten scans over eight million elements. Each table entry is the median of five per-JVM median trial times. The median within-JVM speedup was 1.47×; the five ratios ranged from 1.44× to 1.52×. A separate five-JVM run of the same timed loops gave 1.40×, illustrating run-to-run variation.

An internal layout check reported the value array as flat and the identity array as not flat.

The result measures traversal of large, concrete arrays on one JDK build. It does not measure construction time, decoding, CharSequence calls, or toString(). Other array sizes and JVM builds may produce different results. The benchmark source is outside the scope of this article. To assess the effect on your application, measure its workload with JMH, including the operations it uses.

In order to compile a complete Ticker class, you need to combine the snippets above and enable preview features in the JDK.

Enabling preview features in JDK 28

When compiling value classes with JDK 28, pass --enable-preview --release 28 to javac, and pass --enable-preview to java when launching your application. Preview features must be enabled at both stages. Use matching JDK 28 tools to compile and run these examples, with a build that includes the value-object preview.

Further improvements

Six bits can represent 64 codes. Reserving zero for padding leaves space for 63 characters, so ALPHABET could grow beyond its current 37. To keep the existing comparison method, the code assignments must still follow character order. The arithmetic constructor would also need to match the expanded alphabet.

Longer symbols need more storage. In JDK 28, value objects with 64 bits or more of field data cannot normally be flattened in nullable fields or arrays. The JVM needs an atomic read or write of the whole value, including a representation of null. Smaller values are candidates for flattening, but the JVM chooses the layout.

For example, an int, a short, and a byte field together provide 56 bits of field data, enough for nine six-bit character codes (54 bits), compared with five in the current class. That encoding would need to split the bits across fields and update the decoding and comparison methods to match. Field alignment and null handling would still affect the resulting JVM layout.

Storing the length explicitly would let length() avoid scanning the character slots. The current encoding leaves two unused bits, enough for four distinct lengths. It accepts five lengths, from one through five, so those two bits alone cannot record all of them. Restricting the accepted lengths could make that approach fit, but would change which symbols the constructor allows.

An encoding based on character frequency could assign shorter codes to common characters. It would also need a way to find character boundaries, and the existing integer comparison would need rechecking. Saving bits can add work to encoding, decoding, and comparison; the choice depends on the operations the application performs most often.

The Ticker value class is an experiment in packing a small immutable value. Use an exchange's symbol rules to choose the format, then measure the operations your application needs before adopting it. For existing classes, JEP 401's migration guidance explains when removing object identity is appropriate.