Back to course

Modified UTF-8

21 minadvancedModule 1: The ContainerContinues from jvm-constant-pool
21 min · Module 1: The Container · Algorithms

Why This Matters

Every name in a class file is a CONSTANT_Utf8 entry: every class name, every method name, every descriptor, every string literal, every generic signature. It is the only tag in the pool that carries text rather than a number or a reference, and it is the one every other tag eventually points at.

Which makes its encoding the single most consequential non-numeric decision in the format. And the encoding is called UTF-8 and is not UTF-8.

That is not a rhetorical flourish. A standard UTF-8 decoder, handed the bytes of a valid Java class file, fails on it. Not on a rare string — on any class file containing a supplementary character, which is any class file containing an emoji, and on any class file containing a NUL, which is rarer but entirely legal. This course's own decoder did exactly that while it was being written, and the failure is the best available demonstration that the deviation is real rather than a detail in a specification nobody reads.

A Simple Model

What the pool entry looks like, and then what is actually inside it:

CONSTANT_Utf8: tag 0x01 length u2, the number of BYTES that follow bytes that many bytes, in "modified UTF-8"

The length is in bytes, not characters, and not UTF-16 code units. Those three are different numbers for the same string and the format only ever stores the first, which means a reader that wants a character count has to count after decoding. Worth holding on to, because it is the reason the surrogate case below exists at all.

Standard UTF-8 encodes a character in one to four bytes depending on its code point: one for ASCII, two for the rest of Latin-1, three for the rest of the Basic Multilingual Plane, four for anything above U+FFFF. Modified UTF-8 changes exactly two of those cases, and the changes are both in service of Java's type system rather than of text.

The Actual Detail

Deviation one: NUL is two bytes

Java source containing a string with an embedded NUL, compiled and read back:

  Java source:      "a\0b"
  pool entry:      61 c0 80 62          length = 4
                  |  |  |
                  |  |  +-- 0x62 = 'b'
                  |  +----- 0x80: continuation of a 2-byte sequence
                  +-------- 0xc0: a 2-byte lead that is OVERLONG

  standard UTF-8 would be:   61 00 62        length = 3

A NUL costs two bytes in a class file, and one of them is 0xC0 — a lead byte that is deliberately overlong. The reasoning is about tools rather than languages: NUL is the traditional terminator for a C string, and a class file's strings are read by C and C++ tools constantly — linkers, debuggers, strings, hex dumpers. If a CONSTANT_Utf8 could contain a real zero byte, every one of those tools would silently truncate the string at that point and read a shorter name than the file intended.

So the format makes a NUL unrepresentable as a single byte. The byte pair 0xC0 0x80 decodes to U+0000, and no standard UTF-8 sequence is a NUL at all, because a well-formed UTF-8 encoder never emits an overlong sequence. That is the design in one sentence: the deviation exists so that the one byte value every string tool treats as "the end" can never appear inside the data.

And it has a consequence in the language too, which is a nice illustration of a file format leaking back into source. In Java you cannot write the NUL as "\u0000", because the unicode pre-processor runs over the source before parsing and would inject a raw zero byte into the token stream, breaking the lexer. The only way is the octal escape "\0". That asymmetry — \u0000 illegal, \0 required — is a direct consequence of the on-disk encoding, three layers away from the string it affects.

Deviation two: a supplementary character costs six bytes

This one is the more surprising, and it has a clean explanation that makes it feel less like a mistake once you know it. Take an emoji, U+1F389:

  Java source:      "hi \uD83C\uDF89"
  pool entry:      68 69 20 ed a0 bc ed be 89       length = 9
                                    |  |     |  |  |
                                    |  |     |  +----- 0x89: the low surrogate
                                    |  |     +-------- 0xbe: third byte of it
                                    |  +-------------- 0xed: a 3-byte lead
                                    +----------------- 0xa0-bc: 0xDF89 encoded
                                                      as its own 3-byte sequence

  standard UTF-8 would be:   68 69 20 f0 9f 8e 89       length = 7
                            (one 4-byte sequence, the character itself)

Six bytes where real UTF-8 uses four, and the reason is that the format stores the UTF-16 surrogate pair, not the character. The emoji is outside the Basic Multilingual Plane, so Java represents it as two char values — U+D83C and U+DF89 — because a char is sixteen bits by definition. Modified UTF-8 encodes each of those two halves as its own three-byte sequence. The encoding is called CESU-8 and it is a real thing with real uses, but it is not UTF-8, and the two disagree on exactly one class of character: those above U+FFFF.

You can see the rule in the lead bytes. 0xED is the standard three-byte lead for code points U+D000 to U+D7FF in real UTF-8 — which is precisely the high surrogate range. So real UTF-8 can already encode a high surrogate; it just never encodes a pair. Modified UTF-8 takes that existing ability and uses it for the second half of the pair. Nothing new was added to the encoding; the format simply uses a part of the existing space that UTF-8 had reserved and left empty. That is why the deviation is only six bytes and not something more elaborate.

And here is the demonstration, because it happened in this course's own tooling. The decoder shipped with this course was written to read CONSTANT_Utf8 as ordinary UTF-8, with one adjustment for the 0xC0 0x80 NUL. Run against Str.class, it threw: 'utf-8' codec can't decode byte 0xed in position 3: invalid continuation byte. That is not a subtle wrong answer — a conforming UTF-8 decoder rejecting a valid class file is exactly the symptom, and it is how a careful reader would discover this. The fix has an order to it that is easy to get wrong: recombine the surrogate pairs first, and only then collapse the 0xC0 0x80 pairs. Do it the other way round and you decode to a string containing two lone surrogate characters, which cannot be re-encoded to UTF-8 at all — so a decoder that decodes correctly and then cannot round-trip is worse than one that throws, because it fails silently at the point where the data is written back out.

What the two deviations have in common

Neither one is about representing text better. Both are about keeping the encoding compatible with something else. The NUL is two bytes so that C string tools cannot truncate a name. The supplementary character is six bytes so that the encoding matches the char-based string model the language already had, rather than introducing a fourth width that the runtime's own primitives do not have.

And that is a pattern worth naming, because it recurs across this collection. The class file's big-endian ordering exists so a file is unmistakably a class file. A WebAssembly i32 has no unsigned variant because the language had no unsigned integers. Here a text encoding is bent away from the standard so that a family of existing tools keeps working. In each case the format is choosing to be slightly wrong in a direction that keeps some other thing intact, and in each case the cost lands on whoever writes a decoder rather than on whoever reads the text.

A Real Example

Decoding a CONSTANT_Utf8 correctly, which is a two-pass operation and the order is the whole trick:

decode_modified_utf8(bytes) -> str: # PASS 1: recombine surrogate pairs into real characters. # Must happen first. A decoded lone surrogate cannot be # re-encoded as UTF-8, so deferring this produces a string # that decodes fine and then fails to serialise. out = [] i = 0 while i < len(bytes): if bytes[i] == 0xED and is_surrogate_tail(bytes[i+1..i+2]): hi = 0xD000 | ((bytes[i+1] & 0x3F) << 6) | (bytes[i+2] & 0x3F) if next three bytes are also an encoded low surrogate: lo = 0xD000 | (...) out.append(chr(0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00))) i += 6 else: out.append(REPLACEMENT) # unpaired: do NOT drop it silently i += 3 else: out.append(bytes[i]); i += 1 # PASS 2: collapse the two-byte NUL. return utf8_decode(out).replace("\xC0\x80", "\x00")

Three things in that are decisions rather than mechanics.

Pass 1 before pass 2, and the reason is round-tripping. If you collapse the NUL pairs first you get a byte string containing a real 0xC0 0x80 sequence, which UTF-8-decodes to U+0000, and then pass 1 has to recognise a zero byte as a surrogate lead — which it never will, because 0x00 is not 0xED. So the two passes do not commute, and the order is forced by the data rather than by taste. Getting it wrong does not crash; it produces a string that cannot be written back out, which is the worse failure because it surfaces much later and somewhere else entirely.

An unpaired surrogate is preserved, not dropped. A malformed or hand-built class file can contain an encoded high surrogate with no low surrogate after it. A decoder that skips it produces a shorter string, and a shorter class name is a different class — so the decoder's job is to report the problem, not to tidy it away. The shipped decoder emits U+FFFD, which is the conventional choice and is at least visible: a reader that sees replacement characters knows something was wrong, where a silently dropped surrogate leaves a name that looks fine and resolves to nothing.

And the length is in bytes, so the decoder cannot trust it as a character count — but it can and must trust it as a byte count, because it is what says where the entry ends. A CONSTANT_Utf8 is the only variable-length entry in the pool, so this length is the single field the whole pool walk depends on. If it is wrong, every subsequent entry is misread, which is why the payload sizes in the pool walk are stated as 2 + n rather than as a fixed size, and why a decoder that assumes fixed-size entries cannot read this format at all.

Try It Yourself

$ javap -v -p out/Str.class
$ python3 courses/jvm/assets/samples/class_decode.py out/Str.class --cp-only
$ python3 courses/jvm/assets/samples/crosscheck.py classes/Str.class

Check Your Understanding

Without looking back: a CONSTANT_Utf8 entry in Str.class is nine bytes long and decodes to the four characters h, i, space, and U+1F389. Why is the length nine and not four, and what would a standard UTF-8 decoder do with those nine bytes?

Apply It

You are writing a tool that reads class files and re-emits them, for example to rename a class. It works on every class in your build. On a dependency compiled from source that happens to contain one non-ASCII string, it produces an output file the JVM will not load, with no error from your tool — the write succeeded. Decoding the string back gives you the correct characters. What is the defect, why did nothing complain, and what is the check that would have caught it before the file was written?

The general habit: whenever a format hands you a decoder, work out the matching encoder and test that the two are inverse. A format that stores only one direction will not tell you the round trip is lossy — something downstream will, and it will be a file that will not load.

Connect

CESU-8 is not an invention of the class file format, and the course is more useful for saying so. It is a real, standardised encoding designed for exactly the constraint Java has: a runtime whose fundamental character type is sixteen bits, on a platform where the encoding has to interoperate with tools that assume standard UTF-8. The same problem, and the same answer, appear in ELF's ELFCLASS and ELFDATA bytes, which encode the same kind of decision in two bytes instead of in a string encoding. The pattern is the one this course keeps meeting: the format's encoding is a consequence of the runtime it has to fit inside, and understanding the runtime explains the encoding without needing the specification.

The NUL decision has a direct parallel in the other formats, and it is a decision about tools rather than about the language. A COFF string table prefixes each string with a four-byte length instead of terminating it with a zero, for the same reason: a length prefix permits embedded NULs where a terminator cannot, and a format read by C tools should not depend on C's conventions. A WebAssembly data segment makes the same choice for the same reason, and so does a PE import name only not — which is a genuine inconsistency in that format and a useful contrast. Length-prefix versus NUL-terminated is one of the most consequential small decisions in any binary format, and this course now has four examples of it, three of them length-prefixed.

Both deviations are instances of a broader idea that runs through this whole course, and it is worth naming before the course moves on to the code inside a method. Every encoding decision in a binary format is a decision about which mistake is easier to make. The magic catches a non-class-file in four bytes. The big-endian order catches a byte-swapped reader immediately. The two-slot rule makes a mis-sized index fetch produce an invalid tag rather than a wrong value. The NUL encoding makes a truncating tool produce a visibly wrong name. And modified UTF-8's second deviation makes a standard decoder fail loudly rather than silently return the wrong character — which is why this concept could be written at all: the deviation announces itself to anyone using a conforming tool.

A format designed to be wrong quietly is much harder to teach and much harder to debug, and it is the more common choice in practice. The class file is unusually well made in this respect. Back to the start of the course for the four readers, or forward to Module 2 for what happens after the pool: the fields, the methods, and the Code attribute where the actual instructions live.