Skip to main content

Compact Strings in Java: Measured with JOL on JDK 25 (plus Compact Object Headers)

Compact Strings measured with JOL on JDK 25: one million strings weigh 89.8 MB, against 132.0 MB with -XX:-CompactStrings (31.9% less), and one euro sign per string makes them 49% bigger. Includes the Latin-1 vs UTF-16 coder byte, byte[] sizes by length, a JMH comparison and compact object headers (opt-in on JDK 25, default on JDK 27).

A String on Java 25 is a 24-byte object that points to a byte[], and one private byte inside it, coder, says whether each character in that array takes one byte (Latin-1) or two bytes (UTF-16). That is Compact Strings, and I measured what it is worth with JOL: one million random lower-case strings averaging 42.5 characters weigh 89.8 MB, against 132.0 MB with -XX:-CompactStrings, a saving of 31.9%. Add one euro sign to each string and the whole population is 49% bigger again (133.8 MB), because a single character outside Latin-1 turns the entire string’s byte[] into UTF-16. This updated version replaces the theory in the original article with those measurements: the real field layout of String, the coder byte read from live objects, the footprint by string length, the cases where a string falls back to UTF-16 (and where it comes back), a JMH comparison with the feature switched off, and a section on compact object headers, the separate JEP that shaves another 4.1% off the same million strings and became the default in JDK 27. For how strings are created, interned and concatenated, read the companion article, Java Strings Deep Dive: Interning, StringBuilder, Text Blocks, and the Concatenation Benchmark.
Versions. Compact Strings is JEP 254, delivered in Java 9 (openjdk.org/jeps/254). Measured here on Temurin 25.0.4.1+1 (JDK 25, an LTS release) and Amazon Corretto 27+35 (JDK 27, GA 2026-09-15), with JOL 0.17, which is the latest release in Maven Central’s maven-metadata.xml for org.openjdk.jol:jol-core, and JMH 1.37, on a 2-vCPU virtual machine. Compact object headers are JEP 450 (JDK 24, experimental), JEP 519 (JDK 25, product, off by default) and JEP 534 (JDK 27, on by default). Code and transcripts: the compact-strings module of the javademos repository.

The Memory Drain Java 9 Tried to Solve: Two Bytes for Every Character

Prior to JDK 9, every character inside a String was stored as a 16-bit char, using the UTF-16 encoding. The problem? For common languages like English, or text data formats like XML, JSON, log messages, and HTTP headers, most characters fall within the Latin-1 (ISO-8859-1) set, which perfectly fits into a single 8-bit byte. JEP 254 puts it the same way: most String objects contain only Latin-1 characters, so half of the space in the internal char arrays of such strings goes unused. This meant that for a vast majority of applications, half of every character array was filled with redundant zeroes. That silent waste translated directly to:
  • Larger heap footprint: your application demands more RAM.
  • More GC pressure: the garbage collector has to work harder and longer.
  • Lower CPU-cache locality: data is scattered, slowing down processing.
While developers could manually pack bytes, it came at the cost of code clarity and API compatibility. Clearly, a JVM-level fix was needed to make string handling efficient by default.

What Exactly Is a “Compact String”? A byte[] Plus a coder Flag

The solution, introduced in JEP 254, was simple. Since JDK 9, each String instance no longer stores a char[] internally. Instead, it stores a byte[] along with an extra private field called coder. This coder field remembers the encoding:
coder valueEncoding usedMemory per character
0Latin-1 (ISO-8859-1)1 byte (the “compact” form)
1UTF-162 bytes (the legacy form)

The Logic

The JVM is smart:
  1. When a string is created, it checks if every single character fits within the Latin-1 range (0-255).
  2. If it does, the VM uses the 1-byte per character Latin-1 form (coder = 0).
  3. Otherwise, it falls back to the legacy 2-byte per character UTF-16 form (coder = 1).
Crucially, this change is completely transparent to your code. Public APIs like new String(), charAt, length, and regular expressions remain unchanged. Only the internal memory layout differs. Here is the real thing, from JOL on JDK 25. The class layout of java.lang.String has four fields (output: 01-layout-jdk25-default.txt):
java.lang.String object internals:
OFF  SZ      TYPE DESCRIPTION               VALUE
  0   8           (object header: mark)     N/A
  8   4           (object header: class)    N/A
 12   4       int String.hash               N/A
 16   1      byte String.coder              N/A
 17   1   boolean String.hashIsZero         N/A
 18   2           (alignment/padding gap)   
 20   4    byte[] String.value              N/A
Instance size: 24 bytes
Space losses: 2 bytes internal + 0 bytes external = 2 bytes total
and this is the same String holding "hello" and, below it, "h€llo". The only differences are the coder byte (0 against 1) and the contents of the byte[] it points to, five bytes against ten (same file, output/01):
OFF  SZ      TYPE DESCRIPTION               VALUE
  0   8           (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4           (object header: class)    0x00176520
 12   4       int String.hash               0
 16   1      byte String.coder              0
 17   1   boolean String.hashIsZero         false
 18   2           (alignment/padding gap)   
 20   4    byte[] String.value              [104, 101, 108, 108, 111]
...
java.lang.String object internals:
OFF  SZ      TYPE DESCRIPTION               VALUE
  0   8           (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4           (object header: class)    0x00176520
 12   4       int String.hash               0
 16   1      byte String.coder              1
 17   1   boolean String.hashIsZero         false
 18   2           (alignment/padding gap)   
One String is always 24 bytes; the byte[] it points to is what changesString24 bytescoder = 0 or 1value → byte[]coder = 0, “hello” (Latin-1)byte[] : 16 header + 5 elements + 3 padding = 24 bytescoder = 1, “h€llo” (UTF-16)byte[] : 16 header + 10 elements + 6 padding = 32 bytesJDK 25, default flags: 12-byte object header, so the array length sits at offset 12 and the elements start at offset 16.Both strings have length() 5. The extra five bytes of the UTF-16 string come entirely from the elements.Read straight from output/01 (JOL ClassLayout of the String and of its byte[]).
The diagram shows why the saving is smaller than the headline “half”: the String object (24 bytes) and the array header (16 bytes) are paid either way, and only the elements halve. The coder byte is free, since it sits in what would otherwise be padding. The code that reads coder is four lines of reflection, and it needs --add-opens java.base/java.lang=ALL-UNNAMED (StringLayout.java):
static byte coder(String s) throws Exception {
    Field f = String.class.getDeclaredField("coder");
    f.setAccessible(true);                       // needs --add-opens java.base/java.lang=ALL-UNNAMED
    return f.getByte(s);
}
Going deeper
  • Why hash sits before coder and what hashIsZero is for: the layout table above, and the Java 25 String source
  • The full proposal and its non-goals (UTF-8 internally was explicitly not a goal): JEP 254
  • Footprint by length, every flag set: output/01 to output/06

How Much Memory Do You Really Save? 31.9% on a Million Strings

The savings aren’t theoretical, but they are not “half” either. The test below builds one million random lower-case strings of 30 to 55 characters (the same shape the old version of this post used, average 42.5 characters) and asks JOL’s GraphLayout for the total size of the String[], every String and every byte[] it reaches. Set B is the same strings with one € appended. The old version of this post measured with Lucene’s RamUsageEstimator inside a JMH harness; JOL does the same job with less machinery, and it prints the same object graph, so the two are comparable (MillionStrings.java):
String[] latin = new String[COUNT];
String[] wide = new String[COUNT];
Random r = new Random(1);
long chars = 0;
for (int i = 0; i < COUNT; i++) {
    int len = 30 + r.nextInt(26);
    StringBuilder sb = new StringBuilder(len);
    for (int j = 0; j < len; j++) sb.append((char) ('a' + r.nextInt(26)));
    latin[i] = sb.toString();
    wide[i] = latin[i] + "\u20AC";            // one non-Latin-1 character flips the whole string to UTF-16
    chars += len;
}
On JDK 25 with default flags, then with -XX:-CompactStrings (output: output/11, output/12):
$ java -Xmx3g  MillionStrings      (JDK 25)
...
coder == 0 (LATIN1) in set A : 1,000,000
coder == 1 (UTF16)  in set B : 1,000,000
A: lower-case ASCII only     : 89,844,680 bytes  (89.8 MB, 89.8 bytes per string)
B: same + one euro sign      : 133,839,824 bytes  (133.8 MB, 133.8 bytes per string)
B / A                        : 1.490
$ java -Xmx3g -XX:-CompactStrings MillionStrings      (JDK 25)
...
coder == 0 (LATIN1) in set A : 0
coder == 1 (UTF16)  in set B : 1,000,000
A: lower-case ASCII only     : 131,995,184 bytes  (132.0 MB, 132.0 bytes per string)
B: same + one euro sign      : 133,839,824 bytes  (133.8 MB, 133.8 bytes per string)
B / A                        : 1.014
JDK 25 (Temurin 25.0.4.1)Set A: ASCII onlySet B: ASCII + one €Compact Strings saving on set A
default (Compact Strings on)89.8 MB133.8 MB31.9% vs the next row
-XX:-CompactStrings132.0 MB133.8 MB0%
So on this population the feature is worth 31.9%, and the old post’s claim of “10-30%” was close but its table (152.4 MB against 114.5 MB, “25%”) is not a number I could reproduce; I did not have a JDK 8 to compare against, so the closest thing this article can measure is the same JVM with the feature off. Set B is the trap: with one € per string, every string’s coder is 1 and the compact run is 49% bigger than set A (133.8 MB against 89.8 MB), byte for byte the same size as the -XX:-CompactStrings run on the same data (133,839,824 bytes in both).
One million strings, total heap bytes seen by JOL (MB)25 -CompactStrings132 MB25 default, A (ASCII)89.8 MB25 +CompactHeaders, A86.2 MB25 default, B (+ euro)134 MBSource: output/11 to 14, Temurin 25.0.4.1. Amber = every string is UTF-16, green = Latin-1.
The bars make one point and the table behind them a second one. The point: the saving belongs to the text, not to the program, so a heap full of Latin-1 strings (identifiers, JSON keys, log lines, HTTP headers) benefits and a heap full of CJK or emoji does not. The second one: a fixed cost of about 44 bytes per string (a 4-byte slot in the array, the 24-byte String, the 16-byte array header) is untouched, so the shorter the strings the smaller the percentage. The by-length table below shows it for 0 to 1000 characters.
Footprint by string length on JDK 25 (JOL, bytes) Taken from output/01 (default) and output/02 (-XX:-CompactStrings). Each row is one fresh string of the given length, String object plus byte[]; the array slot in a containing array is not included.
Charactersbyte[] length (Latin-1)String + byte[] (Latin-1)byte[] length (UTF-16)String + byte[] (UTF-16)Saving
00400400.0%
11482480.0%
5548105614.3%
101056206412.5%
202064408020.0%
4040808012033.3%
10010014420024040.0%
1000100010402000204049.0%
Two things to notice. An empty string still costs 40 bytes, and a one-character string costs 48 either way: at the short end the saving is zero, because 8-byte alignment rounds a 1-byte and a 2-byte array to the same 24 bytes. At 1000 characters the saving approaches the theoretical 50%.
The original article’s results table (kept for the record, not reproduced) The previous version of this post reported the following for one million strings of average length 43 on a 64-bit HotSpot JVM with compressed oops. I could not reproduce it (no JDK 8 here, and a different estimator), so use the measured table above instead.
JDKCompact StringsTotal Size (MB)Savings vs. JDK 8
8N/A152.4–
17ON (default)114.525 %
17-XX:-CompactStrings152.40 %
Going deeper
Real-world services that cache or log a lot of text often see 10-30% reductions in live-set size, which directly translates into shorter GC pauses and higher throughput. That sentence is from the original article and I have not re-measured it on a real service. The measured, reproducible figure here is 31.9% for one synthetic Latin-1 population; your number depends on the mix of text in your own heap (take a heap histogram and look at [B and java.lang.String).

One Character Can Double a String: When Strings Stay Compact and When They Do Not

The VM looks at the whole string. If every character is in the range 0 to 255 the string is Latin-1; if even one is outside it, all of it is stored as UTF-16. That range is wider than ASCII: é (U+00E9) is Latin-1, € (U+20AC) is not, and neither is any CJK character or emoji. The program below prints the coder of strings produced by common operations (output: 20-upgrade.txt):
java.version = 25.0.4.1
"hello" (literal)                              length=5  coder=0  byte[].length=5
"h\u00E9llo" (e-acute, Latin-1 supplement)     length=5  coder=0  byte[].length=5
"h\u20ACllo" (euro sign)                       length=5  coder=1  byte[].length=10
"hello" + "\u20AC" (concat)                    length=6  coder=1  byte[].length=12
wide = "\u20ACabcdef"                          length=7  coder=1  byte[].length=14
wide.substring(1)  (all Latin-1 again)         length=6  coder=0  byte[].length=6
wide.replace('\u20AC', 'E')                    length=7  coder=0  byte[].length=7
wide.toLowerCase()                             length=7  coder=1  byte[].length=14
new String("caf\u00E9".getBytes(UTF_8), UTF_8) length=4  coder=0  byte[].length=4
new String(utf8 bytes of ASCII, UTF_8)         length=5  coder=0  byte[].length=5
new String(char[] {'a','b'})                   length=2  coder=0  byte[].length=2
new String(char[] {'a','\u4E2D'})              length=2  coder=1  byte[].length=4
"\uD83D\uDE00" (emoji, a surrogate pair)       length=2  coder=1  byte[].length=4
StringBuilder("ascii only").toString()         length=10  coder=0  byte[].length=10
... after append('\u20AC'), toString()         length=11  coder=1  byte[].length=22
... after setLength(0) + ASCII, toString()     length=13  coder=0  byte[].length=13
Three things fall out of it. A string can come back to Latin-1: substring(1) of "€abcdef" and replace('€', 'E') both return a string with coder=0, so the fallback is a property of one string, not a one-way door. A StringBuilder follows the same rule: after append('€') its toString() is UTF-16, and after setLength(0) plus ASCII text its next toString() is Latin-1 again. And length() counts UTF-16 code units: the emoji is one character to a reader and length() == 2 to the JVM, whichever coder it has. Switch the feature off and the same program prints coder=1 for every line, including "hello" (the second block of the transcript). That is the whole effect of -XX:-CompactStrings: every String and every StringBuilder result is UTF-16, nothing else changes.
Trap: one wide character per record. If every record you cache carries a €, an emoji, or a pasted “smart quote” (U+2019 is not Latin-1 either), that record doubles. In the million-string run above, one € per string turned 89.8 MB into 133.8 MB. If you can, keep the wide character out of the cached key and in the value, or normalise it, rather than hoping the JVM will not notice.
Going deeper
  • Why substring and replace can recompress but toLowerCase() on a string that still contains € cannot: the table above and the JDK’s StringUTF16.compress in the String source
  • Latin-1 versus UTF-8 on the wire: new String(bytes, UTF_8) decodes into the narrowest form (next-but-one section)

Compact Object Headers: The Other 4 Bytes (JDK 25 Opt-In, JDK 27 Default)

Compact Strings makes the contents of a String smaller. A different feature, compact object headers, makes the header of every object smaller, including the String and the byte[] behind it. The two are independent and they add up. A header normally takes 12 bytes on HotSpot (an 8-byte mark word and a 4-byte compressed class pointer); with compact headers it is 8 bytes. Here is exactly what the OpenJDK pages say, and what I ran:
  • JDK 24, JEP 450: compact object headers as an experimental feature. The JEP says the feature “must therefore be disabled by default and enabled only by explicit user request”, and enabling it needs -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders.
  • JDK 25, JEP 519: the experimental status is removed, so it is a product feature: -XX:+UseCompactObjectHeaders is enough. The JEP’s non-goals say it is not a goal to make compact headers the default layout. So JDK 25 still uses 12-byte headers unless you ask.
  • JDK 27, JEP 534 (“Compact Object Headers by Default”, Release 27): compact headers become the default layout, and -XX:-UseCompactObjectHeaders turns them off. The JEP says it is not a goal to remove the old layout “at this time”.
  • What I ran: -XX:+PrintFlagsFinal reports UseCompactObjectHeaders = false on JDK 25.0.4.1 and true on JDK 27 (Corretto 27+35), and CompactStrings = true on both (21-flags.txt).
$ java -XX:+PrintFlagsFinal -version | grep -E 'CompactStrings|CompactObjectHeaders'      (JDK 25)
     bool CompactStrings                           = true                                   {pd product} {default}
     bool UseCompactObjectHeaders                  = false                          {product lp64_product} {default}
...
$ java -XX:+PrintFlagsFinal -version | grep -E 'CompactStrings|CompactObjectHeaders'      (JDK 27)
     bool CompactStrings                           = true                                   {pd product} {default}
     bool UseCompactObjectHeaders                  = true                           {product lp64_product} {default}
On JDK 27 the JOL layout of the byte[] behind "hello" shows the difference directly: the 8-byte mark word is all there is (JOL labels it Lilliput), the array length moves up from offset 12 to offset 8 and the elements from 16 to 12 (output: output/05):
[B object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0017980000000001 (Lilliput)
  8   4        (array length)            5
 12   5   byte [B.<elements>             N/A
 17   7        (object alignment gap)    
Instance size: 24 bytes
Space losses: 0 bytes internal + 7 bytes external = 7 bytes total
StringString object, 25 defaultString, compact headersbyte[], 25 defaultbyte[], compact headersTotal, 25 defaultTotal, compact headers
5 chars, €242432245648
10 chars, ASCII242432245648
10 chars, €242440326456
40 chars, ASCII242456568080
100 chars, ASCII2424120112144136
The String object itself stays at 24 bytes (its fields re-pack: value moves from offset 20 to 16, as output/03 shows), so the saving comes from the byte[], and only when the 4 bytes cross an 8-byte boundary. Rows where nothing changes are as informative as rows where something does. Over the million strings, compact headers take set A from 89.8 MB to 86.2 MB (4.1%), and from 132.0 MB to 128.0 MB with -XX:-CompactStrings (3.0%). Both features together: 86.2 MB against 132.0 MB for the pre-Java 9 representation on a 12-byte header, 34.7% smaller. JDK 27 default and JDK 25 with -XX:+UseCompactObjectHeaders give byte-identical numbers (86,150,520 bytes for set A, output/15 and output/13), and JDK 27 with -XX:-UseCompactObjectHeaders reproduces the JDK 25 default (output/16).
Trap: JOL and your capacity maths change under you. Anything that hard-codes “a header is 12 bytes” (object-size estimators, a constant in a capacity plan, a test that asserts an Instance size) is wrong on JDK 27 by default. JOL 0.17 also prints one Unable to attach Serviceability Agent line on JDK 27 (it reads a flag that no longer exists); it is harmless, and it is kept in the transcripts because it is real. If you want to try this on a JDK 25 production service first, add -XX:+UseCompactObjectHeaders in a canary: it is a product flag, no unlock needed.
Going deeper

Performance Impact on Common Operations

The benefit isn’t just memory; many common String operations are faster because they can operate directly on a compact byte[]. The original article quoted three figures here. I re-measured what could be measured with JMH 1.37 on 256-character strings, once with the default and once with -XX:-CompactStrings (output: 30-jmh.txt):
Operation (256 chars)Compact Strings on, ns/op-XX:-CompactStrings, ns/opSlower with it off
equals, equal content8.6 ± 0.214.4 ± 0.91.68x
compareTo, last char differs13.5 ± 0.116.6 ± 1.21.23x
indexOf(char)2.9 ± 0.13.7 ± 0.31.28x
indexOf(String), absent36.6 ± 0.271.3 ± 1.71.95x
concat75.7 ± 1.2143.0 ± 2.81.89x
toUpperCase276.1 ± 11.2558.9 ± 22.02.02x
The benchmark class (StringBench.java); the strings are built from a char[] so no two share an array:
@Setup
public void setup() {
    char[] x = new char[256];
    for (int i = 0; i < x.length; i++) x[i] = (char) ('a' + (i % 26));
    a = new String(x);
    b = new String(x);                // equal content, different object
    x[255] = 'Z';
    c = new String(x);                // differs in the last character
}

@Benchmark public boolean equalsSameContent() { return a.equals(b); }
@Benchmark public int compareToLastCharDiffers() { return a.compareTo(c); }
@Benchmark public int indexOfLastChar() { return a.indexOf('z', 200); }
@Benchmark public int indexOfAbsentString() { return a.indexOf("zyx"); }
@Benchmark public String concatTwo() { return a.concat(b); }
@Benchmark public String toUpper() { return a.toUpperCase(); }
  • String::equals / compareTo: the claim was that two Latin-1 strings compare with a simple memcmp and that the JIT vectorises it, “up to 2x”. Measured: equals is 1.68x faster with Compact Strings on, compareTo 1.23x. The direction is right; the “2x” holds only for some operations.
  • substring, split, regex: these copy fewer bytes. I measured the neighbours: concat is 1.89x and toUpperCase 2.02x slower with the feature off, indexOf(String) 1.95x. I did not benchmark substring, split or a regex directly.
  • String::hashCode, “~15% less CPU”: not re-measured. String caches its hash, so a naive loop measures the cache. Treat the 15% as unverified.
Edge case: If your code routinely mixes scripts (emoji, CJK, math symbols), the VM keeps those strings in UTF-16, and you simply fall back to the pre-Java 9 representation. No harm done: in the million-string run, set B is exactly as big with Compact Strings on as with it off (133,839,824 bytes in both).
These numbers are shape, not a verdict. 2 vCPUs, a shared virtual machine, 5 iterations in 2 forks per benchmark, one string length. The error column is JMH’s 99.9% confidence interval, and it does not cover differences between machines or JDK builds. Run run-jmh.sh on your hardware before you quote any ratio.

Gotchas and Tips for the Compact Era

While Compact Strings are mostly transparent, a few considerations can help you maximize the benefit and avoid common pitfalls: Update your profiling tools. Libraries that measure object size, such as JOL (Java Object Layout), Jamm, or profilers like YourKit, had to be updated to support the new byte[] layout. Ensure you are using modern versions, or they may report incorrect sizes. For JOL that means 0.17 (the latest release), and on JDK 24 and later it needs -Djdk.attach.allowAttachSelf=true -XX:+EnableDynamicAgentLoading --sun-misc-unsafe-memory-access=allow to run without warnings, plus --add-opens java.base/java.lang=ALL-UNNAMED if you want to read String‘s private fields (run.sh). JOL 0.17 also cannot lay out a record without -Djol.magicFieldOffset=true, as documented in javademos chapter 3. Avoid the new String(bytes, charset) anti-pattern. If you already have a byte array in UTF-8, use:
// Good: VM can often decode directly into a compact String
String s = new String(bytes, StandardCharsets.UTF_8);
Avoid reading the bytes into a temporary char[] first. Let the VM handle the decoding directly so it can choose the narrowest, most compact encoding. A correction to how the original put that: the result is just as compact either way, because new String(char[]) also compresses (the Upgrade transcript shows coder=0 for new String(new char[] {'a','b'})). What the temporary char[] costs is two bytes per character of short-lived garbage and a copy, not a wider string. StringBuilder is compact-aware. StringBuilder also became byte-based internally. When the builder is converted to a String via toString(), the VM chooses the narrowest encoding. Appending only ASCII characters and then calling toString() ensures the final string is compact (as the Upgrade transcript shows, including the round trip through a € and back). Switching the feature off. Although the VM defaults to compact strings, you can disable it for benchmarking or compatibility:
java -XX:-CompactStrings MyApp
Disabling forces every String to UTF-16. The original article called this flag diagnostic and said it “may disappear in future releases”. That is not what the VM reports: -XX:+PrintFlagsFinal lists CompactStrings as a {pd product} flag on JDK 25 and 27, and java -XX:-CompactStrings -version starts normally on both (21-flags.txt). It is still a flag you should use only for testing; I found nothing in the sources I checked promising it will stay, and I did not find a removal notice either.
Should you turn Compact Strings off? My opinion: almost never. The only reasons I can see are an A/B measurement like the one above, or a heap that is almost entirely non-Latin-1 text where the check buys nothing (even then, set B was exactly the same size on and off, so the check cost no memory). Leave it on, and if you want a free memory win on JDK 25 try -XX:+UseCompactObjectHeaders in a canary instead.

Quick FAQ

  • Does Compact Strings change String‘s public API? No. length() still returns the number of 16-bit code units (chars), charAt returns the UTF-16 code unit, etc. Your source code is 100% compatible. (The emoji in the Upgrade transcript has length() == 2.)
  • Can I force a string to stay Latin-1? No. If any character needs more than one byte (e.g., a Chinese character, a €, or an emoji), the VM must upgrade that string to UTF-16 to maintain data integrity. Characters up to U+00FF, such as é, ñ and ü, are still compact. The upgrade is per string, and derived strings can compress again.
  • Does G1 / Parallel / Shenandoah benefit equally? All collectors benefit from smaller objects (less to manage). The original article added that G1 and Shenandoah especially enjoy reduced region evacuation times; I did not measure any collector here, so treat that part as unverified.
  • How do I see a string’s coder? Reflection with --add-opens java.base/java.lang=ALL-UNNAMED (the four lines above), or JOL’s ClassLayout.parseInstance(s).toPrintable(). It is not public API.
  • Is Compact Strings related to compact object headers? No. Compact Strings (JDK 9) shrinks the characters; compact object headers (opt-in on 25, default on 27) shrink the header of every object. They add up.

The Bottom Line

Java 9 replaced String‘s internal char array with a byte array plus a coder flag. ASCII/Latin-1 heavy strings occupy about half the character storage, which was 31.9% of the total heap size for one million average-length strings on JDK 25, while full-Unicode strings stay unchanged (and one stray € is enough to make a string full-Unicode). Compact object headers add another 4.1% on top, are an opt-in flag on JDK 25 and the default on JDK 27. No source-code changes are required, but keep an eye on profilers and memory meters to confirm the win in your own application. Enable (it’s the default), measure, and enjoy the free performance upgrade.
Run it yourself Everything above regenerates with one script (about 40 seconds, plus 6 minutes for the JMH part): run.sh. It downloads JOL 0.17 and checks it against Maven Central’s published sha1.
JDK25=/path/to/jdk-25 JDK27=/path/to/jdk-27 ./run.sh
./jmh/fetch.sh && JDK25=/path/to/jdk-25 ./jmh/run-jmh.sh
JDK 27 here is Amazon Corretto 27+35 from corretto.aws/downloads/latest/amazon-corretto-27-x64-linux-jdk.tar.gz; the module README lists every version. The only machine-dependent file is output/30-jmh.txt.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.