Java Unicode considerations
Java strings hold UTF-16, so correct text processing must handle surrogate pairs, iterate by code point, know about Unicode normalization, avoid locale-sensitive case conversion surprises and stop assuming that one char is one character.
Learning goals
- Recognise and handle surrogate pairs
- Iterate a string by code point
- Explain normalization and locale pitfalls
- Avoid naive char-based assumptions
Prerequisites
Surrogate pairs
Code points above U+FFFF are stored as a surrogate pair: a high surrogate (U+D800 to U+DBFF) followed by a low surrogate (U+DC00 to U+DFFF). Neither half is a valid character alone. Character.isHighSurrogate, isLowSurrogate and Character.toCodePoint(high, low) work with them.
Step through a string with an emoji and see both units form one pair.
1String s = "��";2char hi = s.charAt(0); // '\uD83D'3char lo = s.charAt(1); // '\uDE00'4boolean pair = Character.isHighSurrogate(hi) && Character.isLowSurrogate(lo); // true5int cp = Character.toCodePoint(hi, lo); // 0x1F600
UTF-16 code units vs Unicode code points
Interactive · edit the inputUTF-16 code units (hex) · length 5
Java strings store UTF-16 code units. s.length() counts units, not characters: here 5.
1int units = s.length(); // UTF-16 code units2int count = s.codePointCount(0, units); // real characters3for (int i = 0; i < units; ) {4int cp = s.codePointAt(i); // combines a surrogate pair into one code point5i += Character.charCount(cp); // 2 for supplementary characters, otherwise 16}
Live state
s.length()
5
Execution trace (dry run table)Show 6 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | s.length()=5 | Java strings store UTF-16 code units. s.length() counts units, not characters: here 5. |
| 2 | L4 | i=0 cp=U+48 charCount(cp)=1 | Unit 48 is a whole character (U+48). |
| 3 | L4 | i=1 cp=U+69 charCount(cp)=1 | Unit 69 is a whole character (U+69). |
| 4 | L4 | i=2 cp=U+1F600 charCount(cp)=2 | Units D83D and DE00 form a surrogate pair: one character, U+1F600. charAt(2) alone would return half of it. |
| 5 | L4 | i=4 cp=U+E9 charCount(cp)=1 | Unit E9 is a whole character (U+E9). |
| 6 | L6 | s.length()=5 codePointCount=4 return=5 units, 4 code points | 5 code units but 4 code points. Use codePointCount and codePoints() when you mean characters. |
Code-point iteration
Iterate code points rather than chars when the text may contain supplementary characters: use s.codePoints(), or a loop with codePointAt and Character.charCount. A plain for over charAt visits each half of a surrogate pair separately.
The animation advances by one or two units per step.
1String s = "a��b";2int i = 0, chars = 0;3while (i < s.length()) {4int cp = s.codePointAt(i);5chars++;6i += Character.charCount(cp);7} // chars == 3, while s.length() == 48long letters = s.codePoints().filter(Character::isLetter).count(); // 2
UTF-16 code units vs Unicode code points
Interactive · edit the inputUTF-16 code units (hex) · length 5
Java strings store UTF-16 code units. s.length() counts units, not characters: here 5.
1int units = s.length(); // UTF-16 code units2int count = s.codePointCount(0, units); // real characters3for (int i = 0; i < units; ) {4int cp = s.codePointAt(i); // combines a surrogate pair into one code point5i += Character.charCount(cp); // 2 for supplementary characters, otherwise 16}
Live state
s.length()
5
Execution trace (dry run table)Show 6 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | s.length()=5 | Java strings store UTF-16 code units. s.length() counts units, not characters: here 5. |
| 2 | L4 | i=0 cp=U+48 charCount(cp)=1 | Unit 48 is a whole character (U+48). |
| 3 | L4 | i=1 cp=U+69 charCount(cp)=1 | Unit 69 is a whole character (U+69). |
| 4 | L4 | i=2 cp=U+1F600 charCount(cp)=2 | Units D83D and DE00 form a surrogate pair: one character, U+1F600. charAt(2) alone would return half of it. |
| 5 | L4 | i=4 cp=U+E9 charCount(cp)=1 | Unit E9 is a whole character (U+E9). |
| 6 | L6 | s.length()=5 codePointCount=4 return=5 units, 4 code points | 5 code units but 4 code points. Use codePointCount and codePoints() when you mean characters. |
Normalization overview
Some characters can be written in more than one way: “é” is either one code point (U+00E9) or an “e” followed by a combining accent (U+0065 U+0301). The two look the same but are not equals. java.text.Normalizer converts text to a canonical form (NFC composes, NFD decomposes) so that comparisons and hashing agree. Normalize user input before comparing or storing it.
NFD followed by removing combining marks is a common way to strip accents.
1String a = "\u00E9"; // é as one code point2String b = "e\u0301"; // e + combining acute accent3boolean raw = a.equals(b); // false4boolean nfc = Normalizer.normalize(a, Normalizer.Form.NFC)5.equals(Normalizer.normalize(b, Normalizer.Form.NFC)); // true
Locale-sensitive case conversion
toLowerCase() and toUpperCase() without an argument use the default locale, which changes the result in some languages: in Turkish, uppercase I lowercases to dotless ı, so "TITLE".toLowerCase() is not "title". A single character can also change length (German ß uppercases to SS). Pass Locale.ROOT (or Locale.ENGLISH) for protocol text, identifiers and keys.
Use the user's locale only for text shown to people.
1String t = "TITLE".toLowerCase(Locale.ROOT); // "title" everywhere2String turkish = "TITLE".toLowerCase(new Locale("tr")); // "tıtle" (dotless i)3String up = "straße".toUpperCase(Locale.ROOT); // "STRASSE": length changed
Avoiding naive char assumptions
Common wrong assumptions: one char is one character (emoji take two units); length() is the number of characters; reversing char by char keeps text valid (use StringBuilder.reverse, which keeps pairs intact); toUpperCase keeps the length; two equal-looking strings are equals (they may differ in normalization); one character always has one case form. For ASCII-only data, such as DNA or identifiers, the simple char loops are fine.
Decide up front whether your input is ASCII, and validate it if you rely on it.
| Assumption | Counterexample | Fix |
|---|---|---|
| length() = number of characters | "😀".length() == 2 | codePointCount |
| Reversing chars is safe | reversed surrogates are invalid | StringBuilder.reverse() |
| toUpperCase keeps length | "ß" becomes "SS" | do not index by old length |
| Same look means equals | é vs e + U+0301 | Normalizer |
UTF-16 code units vs Unicode code points
Interactive · edit the inputUTF-16 code units (hex) · length 5
Java strings store UTF-16 code units. s.length() counts units, not characters: here 5.
1int units = s.length(); // UTF-16 code units2int count = s.codePointCount(0, units); // real characters3for (int i = 0; i < units; ) {4int cp = s.codePointAt(i); // combines a surrogate pair into one code point5i += Character.charCount(cp); // 2 for supplementary characters, otherwise 16}
Live state
s.length()
5
Execution trace (dry run table)Show 6 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | s.length()=5 | Java strings store UTF-16 code units. s.length() counts units, not characters: here 5. |
| 2 | L4 | i=0 cp=U+48 charCount(cp)=1 | Unit 48 is a whole character (U+48). |
| 3 | L4 | i=1 cp=U+69 charCount(cp)=1 | Unit 69 is a whole character (U+69). |
| 4 | L4 | i=2 cp=U+1F600 charCount(cp)=2 | Units D83D and DE00 form a surrogate pair: one character, U+1F600. charAt(2) alone would return half of it. |
| 5 | L4 | i=4 cp=U+E9 charCount(cp)=1 | Unit E9 is a whole character (U+E9). |
| 6 | L6 | s.length()=5 codePointCount=4 return=5 units, 4 code points | 5 code units but 4 code points. Use codePointCount and codePoints() when you mean characters. |
Practice ladder
Now try it yourself
Easy
- How many code points and how many units are in "a😀b"?
- Why is s.charAt(0) of an emoji not a valid character?
Medium
- Reverse a string that may contain emoji correctly
- Compare "é" written two ways using Normalizer
Advanced
- Write a method that truncates a string to n characters without splitting a surrogate pair
Key takeaways
- Supplementary characters are surrogate pairs; iterate code points to see characters.
- Normalize before comparing text that may use combining marks.
- Use Locale.ROOT for case conversion of program text.