Java Module 4 – Arrays and strings in depth
Unicode, UTF-16 and Indian scripts
Why नमस्ते has length 6 in Java: chars, code points and grapheme clusters in Hindi, Tamil and emoji, reversing and cutting text safely, and normalisation.
What you will learn
- Count a string in chars, code points and grapheme clusters, and say which one a reader sees
- Explain how combining marks and conjunct linkers join code points into clusters in Indian scripts
- Split text into grapheme clusters to reverse it or cut it to a length without breaking a character
- Normalise two spellings of the same text to NFC before comparing them
- Choose the charset wherever text becomes bytes, and recognise mojibake and question marks in output
Before you start
What you’ll learn · Before you start
What you will learn
- Count a string in chars, code points and grapheme clusters, and say which one a reader sees
- Explain how combining marks and conjunct linkers join code points into clusters in Indian scripts
- Split text into grapheme clusters to reverse it or cut it to a length without breaking a character
- Normalise two spellings of the same text to NFC before comparing them
- Choose the charset wherever text becomes bytes, and recognise mojibake and question marks in output
Before you start
On this page
"नमस्ते".length() is 6 in Java, although a Hindi reader sees three characters: न, म and स्ते. Java strings are
sequences of chars, 16-bit units of the UTF-16 encoding, and a char is not a character as people count them.
length(), charAt and substring work in chars, and even StringBuilder.reverse, which never splits a surrogate
pair, knows nothing about the characters readers see, so all of them miscount or break text in Indian scripts and
emoji.
You will measure strings in three units on JDK 25, step through the rules that join code points into what a reader sees, reverse and shorten text without breaking it, normalise strings that look identical but are not, and follow text to the edges of a program, where it becomes bytes. The exercise counts and reverses what a reader sees.
A string has three lengths: chars, code points and clusters
Three units are at work in every Java string. A char is one UTF-16 code unit: One 16-bit number of the UTF-16 encoding of text, the unit a Java char holds and String.length() counts. Most characters are one code unit; characters beyond the first 65,536, such as many emoji, take two. More in “char, boolean, casting and conversions”,
the unit length() counts. A code point: The number Unicode gives one character, written U+ and at least four hexadecimal digits, such as U+0928 for न. A code point above U+FFFF, such as most emoji, takes two UTF-16 code units in a Java string. More in “Strings: first look and equality” is one entry of the Unicode table, such as
U+0928 DEVANAGARI LETTER NA: a char holds one code point of the first 65,536, the
Basic Multilingual Plane: The first 65,536 code points, U+0000 to U+FFFF, which hold the letters of almost every modern script, Indian scripts included. Each takes one char in a Java string; a code point above it takes a surrogate pair., while a code point above U+FFFF, as most emoji
are, takes two chars, a surrogate pair: Two UTF-16 code units, a high one from D800 to DBFF and a low one from DC00 to DFFF, that together encode one character beyond U+FFFF, such as an emoji. length() counts both; codePointCount counts one. More in “char, boolean, casting and conversions”. A
grapheme cluster: What a reader counts as one character: a letter with its marks, a conjunct such as स्ते, or an emoji with its modifiers. Java finds them with the regular expression \X or with BreakIterator.getCharacterInstance(). is what a reader counts as one character: a consonant with its
vowel sign, a conjunct such as स्ते, or an emoji with its skin tone. The figure counts one word and one emoji in each
unit:
One word counted four ways, and one emoji counted three ways
Text description of the diagram
The upper part counts the Hindi word नमस्ते (namaste) in four rows.
- Grapheme clusters, what a reader sees: 3. The boxes are न, म and the conjunct स्ते, which spans the last four code points.
- Code points: 6. They are न U+0928, म U+092E, स U+0938, the virama ् U+094D, त U+0924 and the vowel sign े U+0947.
- Chars, what length() counts: 6, one per code point, because all six lie below U+FFFF: 0928, 092E, 0938, 094D, 0924 and 0947.
- UTF-8 bytes in a file: 18, three for each code point: E0 A4 A8, E0 A4 AE, E0 A4 B8, E0 A5 8D, E0 A4 A4 and E0 A5 87.
The lower part counts the emoji thumbs up with a medium skin tone. It is 1 grapheme cluster; 2 code points, U+1F44D THUMBS UP SIGN and U+1F3FD the skin-tone modifier; and 4 chars, because each code point above U+FFFF takes a surrogate pair: D83D DC4D and D83C DFFD.
Counts.java measures eight strings in all three units, finds their clusters in two ways, and adds the bytes each
string takes in UTF-8:
import java.text.BreakIterator;
// Four ways to measure the length of a string, on words in four Indian scripts, two emoji and two spellings of café.
void main() {
String[][] samples = {
{"Hindi", "नमस्ते"},
{"Tamil", "தமிழ்"},
{"Kannada", "ಕನ್ನಡ"},
{"Bengali", "বাংলা"},
{"thumbs up, medium skin tone", "👍🏽"},
{"flag of India", "🇮🇳"},
{"café, é as one code point", "caf\u00E9"},
{"café, e + combining accent", "cafe\u0301"},
};
IO.println("chars code points \\X BreakIterator UTF-8 bytes text");
for (String[] sample : samples) {
String s = sample[1];
IO.println(String.format("%5d %11d %2d %13d %11d %s (%s)", s.length(), s.codePointCount(0, s.length()),
clusters(s).size(), breakIteratorCount(s), s.getBytes(StandardCharsets.UTF_8).length, s, sample[0]));
}
IO.println();
for (String word : new String[] {"नमस्ते", "தமிழ்", "ಕನ್ನಡ", "বাংলা"}) {
IO.println(word + " -> " + clusters(word));
}
}
// The extended grapheme clusters of s, as the regular expression \X finds them: what a reader counts as characters.
List<String> clusters(String s) {
List<String> out = new ArrayList<>();
Matcher m = Pattern.compile("\\X").matcher(s);
while (m.find()) {
out.add(m.group());
}
return out;
}
// The same count from java.text.BreakIterator, the older API for character boundaries.
int breakIteratorCount(String s) {
BreakIterator it = BreakIterator.getCharacterInstance(Locale.ROOT);
it.setText(s);
int count = 0;
while (it.next() != BreakIterator.DONE) {
count++;
}
return count;
} Output
chars code points \X BreakIterator UTF-8 bytes text
6 6 3 3 18 नमस्ते (Hindi)
5 5 3 3 15 தமிழ் (Tamil)
5 5 4 4 15 ಕನ್ನಡ (Kannada)
5 5 2 2 15 বাংলা (Bengali)
4 2 1 1 8 👍🏽 (thumbs up, medium skin tone)
4 2 1 1 8 🇮🇳 (flag of India)
4 4 4 4 5 café (café, é as one code point)
5 5 4 4 6 café (café, e + combining accent)
नमस्ते -> [न, म, स्ते]
தமிழ் -> [த, மி, ழ்]
ಕನ್ನಡ -> [ಕ, ನ್, ನ, ಡ]
বাংলা -> [বাং, লা]
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Counts.java
- For the Indian words, chars and code points agree: every Devanagari, Tamil, Kannada and Bengali code point lies in the Basic Multilingual Plane, so each takes one char. The trouble is grapheme clusters: नमस्ते is 6 code points and 3 clusters.
- Emoji lie above U+FFFF, so each code point takes two chars.
👍🏽is the thumbs-up sign followed by a skin-tone modifier: 2 code points, 4 chars, 1 cluster. A flag is two One of 26 code points, U+1F1E6 to U+1F1FF, that stand for the letters A to Z. Two of them in a row spell a country code, which phones and browsers draw as that country's flag. letters, I and N for India. - The regular expression
\Xandjava.text.BreakIteratorcounted the same number of clusters in every string. Both follow the default rules of Unicode Standard Annex #29, as of Unicode 16.0, the version Java 25’sCharacterclass implements. - UTF-8, the encoding of most files and web pages, takes 3 bytes for each of these Indian code points, so नमस्ते is 18 bytes on disk. Inside the JVM a string with any character beyond Latin-1 is stored as UTF-16, 2 bytes per char, while plain English text takes 1 byte per char (JEP 254).
Checkpoint
Check: Chars, code points and clusters
Combining marks and conjuncts join code points into clusters
The last four lines of the run list the clusters themselves. Inspect.java shows why they fall where they do: it
prints the code points of two strings with their Unicode names and general categories:
// The code points inside two strings: their numbers, their Unicode names and their general categories.
void main() {
show("नमस्ते");
show("👍🏽");
}
void show(String s) {
IO.println(s + ": " + s.length() + " chars, " + s.codePointCount(0, s.length()) + " code points");
s.codePoints().forEach(cp -> IO.println(String.format(" U+%04X %-2s %d char%s %s",
cp, category(cp), Character.charCount(cp), Character.charCount(cp) == 1 ? " " : "s", Character.getName(cp))));
}
// The two-letter Unicode general category of the code points used here.
String category(int cp) {
return switch (Character.getType(cp)) {
case Character.OTHER_LETTER -> "Lo";
case Character.NON_SPACING_MARK -> "Mn";
case Character.COMBINING_SPACING_MARK -> "Mc";
case Character.OTHER_SYMBOL -> "So";
case Character.MODIFIER_SYMBOL -> "Sk";
default -> "?";
};
} Output
नमस्ते: 6 chars, 6 code points U+0928 Lo 1 char DEVANAGARI LETTER NA U+092E Lo 1 char DEVANAGARI LETTER MA U+0938 Lo 1 char DEVANAGARI LETTER SA U+094D Mn 1 char DEVANAGARI SIGN VIRAMA U+0924 Lo 1 char DEVANAGARI LETTER TA U+0947 Mn 1 char DEVANAGARI VOWEL SIGN E 👍🏽: 4 chars, 2 code points U+1F44D So 2 chars THUMBS UP SIGN U+1F3FD Sk 2 chars EMOJI MODIFIER FITZPATRICK TYPE-4
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Inspect.java
Letters are Lo, “other letter”, and the virama and the vowel sign E are Mn, non-spacing marks:
combining mark: A code point that attaches to the character before it instead of standing alone, such as a vowel sign, the virama or an accent. Its Unicode category is M (Mn, Mc or Me), so Character.isLetter is false for it., which attach to the character before them. The default rules of
Unicode Standard Annex #29 never break before a mark, so a mark joins the cluster of the character before it, and in
some scripts a virama between two consonants joins them into one conjunct: Two or more consonants joined by a virama and written as one shape, such as स्ते in नमस्ते. Unicode's default rules keep a conjunct in one grapheme cluster only in scripts whose virama is a conjunct linker.. Step through
how those rules group नमस्ते, one code point at a time:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- न starts cluster 1. The first code point, न, starts the first cluster. The next one is the letter म, and no rule joins two letters that way, so a boundary falls after न (rule GB999, break everywhere else).
- म is cluster 2. म stands alone for the same reason: the letter after it, स, starts a new cluster. Two clusters so far, each one code point and one char.
- The virama is a mark. The virama, U+094D, is a non-spacing mark, category Mn in Inspect.java. Rule GB9 never breaks before a mark, so it joins स in cluster 3.
- त joins the conjunct. In Devanagari the virama is a conjunct linker. Rule GB9c keeps a consonant, a linker and the next consonant together, so त joins cluster 3 instead of starting cluster 4.
- The vowel sign is a mark. The vowel sign े, U+0947, is a non-spacing mark as well, so GB9 attaches it too. Cluster 3 is स्ते: four code points that a reader sees as one character.
- 6 code points, 3 clusters. न, म and स्ते are the 3 clusters that the regular expression \X and BreakIterator both found in the run, and the 3 characters a Hindi reader counts.
The skin-tone modifier works the same way: rule GB9 attaches it to the thumb before it, so 👍🏽 is one cluster of
two code points. Marks also explain two everyday bugs. Character.isLetter is false for a vowel sign, and a word
pattern made of letters alone, [\p{L}]+, cuts पुणे into pieces: include marks with [\p{L}\p{M}]+.
Conjuncts and the default rules
Grapheme clusters approximate what readers perceive, and Annex #29 says plainly that some scripts need tailored rules. The default rules keep a consonant, a virama and the next consonant together only in scripts whose virama the Unicode Character Database lists as a conjunct linker, and in Unicode 16.0 those are Devanagari, Bengali, Gujarati, Oriya, Telugu and Malayalam. That is why स्ते stayed whole while the Kannada conjunct ನ್ನ came out as two clusters, ನ್ and ನ, although a Kannada reader sees one. Tamil writes most consonant clusters with a visible pulli (ழ்) instead of a conjunct, so its clusters usually match what a Tamil reader expects.
Checkpoint
Check: How marks and conjuncts form clusters
reverse() keeps surrogate pairs but breaks clusters
Reversing text is a common exercise and a common bug. Reverse.java reverses three strings twice, with
StringBuilder.reverse() and by clusters:
// Reversing text: by chars, by code points, and by grapheme clusters.
void main() {
for (String s : new String[] {"नमस्ते", "👍🏽 ok", "🇮🇳"}) {
IO.println(s);
IO.println(" StringBuilder.reverse(): " + new StringBuilder(s).reverse());
IO.println(" reversed by \\X clusters: " + reverseClusters(s));
}
}
String reverseClusters(String s) {
List<String> clusters = new ArrayList<>();
Matcher m = Pattern.compile("\\X").matcher(s);
while (m.find()) {
clusters.add(m.group());
}
Collections.reverse(clusters);
return String.join("", clusters);
} Output
नमस्ते StringBuilder.reverse(): ेत्समन reversed by \X clusters: स्तेमन 👍🏽 ok StringBuilder.reverse(): ko 🏽👍 reversed by \X clusters: ko 👍🏽 🇮🇳 StringBuilder.reverse(): 🇳🇮 reversed by \X clusters: 🇮🇳
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Reverse.java
StringBuilder.reverse() keeps each surrogate pair in its order, so it never produces half an emoji, but it reverses
everything else code point by code point. The Hindi vowel sign े ended up at the front, attached to nothing, and the
skin-tone modifier moved in front of the thumb it belonged to. The flag shows the danger best:
| Text | Its two code points | The flag |
|---|---|---|
| 🇮🇳 | U+1F1EE U+1F1F3, I then N | India |
after reverse() |
U+1F1F3 U+1F1EE, N then I | Nicaragua |
A flag is two regional indicator letters, the country’s code, so swapping them spells another country. Collecting the
\X matches and reversing that list keeps every cluster whole, as the second line of each result shows.
Checkpoint
Check: Reversing by clusters
Two spellings of one letter are equal only after normalising
Unicode often has two ways to write what looks like the same text: a precomposed letter, or a base letter followed by
combining marks. equals compares code points, so it calls such strings different. java.text.Normalizer converts
text to a standard form first, which is called normalisation: Rewriting text in one standard form, so that two spellings that mean the same, such as é as one code point or as e and an accent, become the same code points. NFC, the usual form for storage, composes where Unicode allows it.: NFC composes
where Unicode allows it, NFD takes every composed character apart. The program compares three such pairs:
import java.text.Normalizer;
// Pairs of strings that look the same but are made of different code points, before and after normalisation.
// The escapes spell out the code points, so no editor can change them by accident.
void main() {
compare("Hindi qa as one code point", "\u0958", "qa as ka + nukta", "\u0915\u093C");
compare("Tamil ko, one vowel sign", "\u0B95\u0BCA", "ko, the sign in two parts", "\u0B95\u0BC6\u0BBE");
compare("café, é as one code point", "caf\u00E9", "café, e + combining accent", "cafe\u0301");
}
void compare(String nameA, String a, String nameB, String b) {
String nfcA = Normalizer.normalize(a, Normalizer.Form.NFC);
String nfcB = Normalizer.normalize(b, Normalizer.Form.NFC);
IO.println(a + " (" + nameA + ") and " + b + " (" + nameB + ")");
IO.println(" code points: " + hex(a) + " and " + hex(b) + "; equals: " + a.equals(b));
IO.println(" after NFC: " + hex(nfcA) + " and " + hex(nfcB) + "; equals: " + nfcA.equals(nfcB));
}
String hex(String s) {
List<String> parts = new ArrayList<>();
s.codePoints().forEach(cp -> parts.add(String.format("U+%04X", cp)));
return String.join(" ", parts);
} Output
क़ (Hindi qa as one code point) and क़ (qa as ka + nukta) code points: U+0958 and U+0915 U+093C; equals: false after NFC: U+0915 U+093C and U+0915 U+093C; equals: true கொ (Tamil ko, one vowel sign) and கொ (ko, the sign in two parts) code points: U+0B95 U+0BCA and U+0B95 U+0BC6 U+0BBE; equals: false after NFC: U+0B95 U+0BCA and U+0B95 U+0BCA; equals: true café (café, é as one code point) and café (café, e + combining accent) code points: U+0063 U+0061 U+0066 U+00E9 and U+0063 U+0061 U+0066 U+0065 U+0301; equals: false after NFC: U+0063 U+0061 U+0066 U+00E9 and U+0063 U+0061 U+0066 U+00E9; equals: true
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Normalise.java
Step through what NFC does to each spelling of the three pairs:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- क़ two ways. क़ arrives as U+0958 from one keyboard and as U+0915 U+093C, क followed by the nukta, from another. equals compares code points, so the two strings differ.
- क़ taken apart. NFC first takes every character apart into its canonical pieces: U+0958 becomes U+0915 U+093C. The other spelling is already in that form.
- क़ is not put back. Then NFC joins pieces again, except the pairs Unicode lists as composition exclusions, and U+0958 is one of them. Both spellings end as two code points, U+0915 U+093C, and they are equal.
- Tamil ko. கொ arrives as U+0B95 U+0BCA or as U+0B95 U+0BC6 U+0BBE. Taken apart, both are the three-code-point form; put together, U+0BC6 U+0BBE become U+0BCA, so both end as U+0B95 U+0BCA.
- café. é as U+00E9 and e followed by U+0301 both take apart into U+0065 U+0301, and both are put together as U+00E9: café in four code points either way.
- Equal after NFC. After NFC each pair compares equal, as the run printed equals: true three times. Normalise both sides with the same form before you compare, search or use text as a key.
- The Hindi letter क़ (qa) exists as one code point, U+0958, and as क followed by the nukta sign, U+0915 U+093C. NFC turns both into the two-code-point form, because Unicode excludes U+0958 from composition: normalising does not always make a string shorter.
- The Tamil vowel sign ொ (o) has a single code point, U+0BCA, and a two-part spelling, U+0BC6 U+0BBE. NFC composes the two parts into one.
écomposed (U+00E9) andewith a combining acute accent (U+0065 U+0301) become equal after NFC as well.
Normalise both sides with the same form before comparing, searching or using text as a map key. Text typed on different keyboards, pasted from PDF files or produced by different operating systems mixes the forms, and NFC, the form most text already arrives in, is the usual choice for storage.
Checkpoint
Check: Normalising before comparing
Cut text at a cluster boundary, not at a char index
A notification, an SMS preview or a database column often holds only so many characters, and the obvious
substring(0, n) counts chars:
// A notification shows only the first 11 characters of a message. Three ways to cut it there.
void main() {
String message = "Thank you 🙏🏽 for the help";
String byChars = message.substring(0, 11);
IO.println("substring(0, 11): [" + byChars + "] ends with a lone surrogate: "
+ Character.isHighSurrogate(byChars.charAt(byChars.length() - 1)));
String byCodePoints = message.substring(0, message.offsetByCodePoints(0, 11));
IO.println("11 code points: [" + byCodePoints + "] " + byCodePoints.codePoints().count() + " code points");
IO.println("11 clusters: [" + firstClusters(message, 11) + "]");
}
// The first n grapheme clusters of s, or all of s when it has fewer.
String firstClusters(String s, int n) {
Matcher m = Pattern.compile("\\X").matcher(s);
int end = 0;
for (int i = 0; i < n && m.find(); i++) {
end = m.end();
}
return s.substring(0, end);
} Output
substring(0, 11): [Thank you ?] ends with a lone surrogate: true 11 code points: [Thank you 🙏] 11 code points 11 clusters: [Thank you 🙏🏽]
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Truncate.java
Step through where each of the three cuts falls in the message:
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- 11 chars. substring(0, 11) keeps chars 0 to 10. Char 10 is the high half of 🙏, so the cut falls inside the emoji and leaves a lone surrogate, which the run printed as ?.
- 11 code points. offsetByCodePoints(0, 11) counts 10 code points of text and 🙏 as the eleventh, so the cut falls after char 11. The emoji is whole, but its skin tone, a code point of its own, is gone.
- 11 clusters. Counting \X matches finds 10 one-char clusters, then 🙏🏽 as the eleventh: four chars that a reader sees as one. The cut falls after char 13, and the message keeps the folded hands with their skin tone.
substring(0, 11)ended between the two chars of 🙏 and kept only its high surrogate, which no encoding can write: the output shows?in its place.deleteCharAtandcharAtloops cause the same damage.- Counting code points with
offsetByCodePointskept the emoji whole but dropped its skin tone, a separate code point. - Counting grapheme clusters with
\Xkept 🙏🏽 together. When a limit is measured in what people see, cut at a cluster boundary; when it is measured in bytes, as in some database columns, count UTF-8 bytes and still cut at a cluster boundary below the limit.
Checkpoint
Check: Cutting text to a length
Encodings turn chars into bytes at the edges
Inside a program a string is a sequence of chars; files, terminals and networks carry bytes, and a character encoding: The rule that turns characters into bytes and back, also called a charset: UTF-8 takes 1 to 4 bytes per code point, UTF-16 2 or 4, and ISO-8859-1 one byte for each of its 256 characters. Java's default is UTF-8 since JDK 18. turns one into the other:
import java.io.PrintStream;
// The same six code points as bytes in three encodings, read back with the wrong one, and printed through an
// encoding that has no Devanagari at all.
void main() {
String hello = "नमस्ते";
IO.println("file.encoding " + System.getProperty("file.encoding") + ", stdout.encoding " + System.getProperty("stdout.encoding"));
byte[] utf8 = hello.getBytes(StandardCharsets.UTF_8);
IO.println("UTF-8: " + utf8.length + " bytes, UTF-16BE: " + hello.getBytes(StandardCharsets.UTF_16BE).length
+ " bytes, UTF-16 (with a byte order mark): " + hello.getBytes(StandardCharsets.UTF_16).length + " bytes");
String wrong = new String(utf8, StandardCharsets.ISO_8859_1);
IO.println("UTF-8 bytes read as ISO-8859-1: " + wrong.length() + " chars, starting " + hexStart(wrong));
PrintStream latin1 = new PrintStream(System.out, true, StandardCharsets.ISO_8859_1);
latin1.println("through an ISO-8859-1 stream: " + hello);
}
String hexStart(String s) {
List<String> parts = new ArrayList<>();
for (int i = 0; i < 3; i++) {
parts.add(String.format("U+%04X", (int) s.charAt(i)));
}
return String.join(" ", parts);
} Output
file.encoding UTF-8, stdout.encoding UTF-8 UTF-8: 18 bytes, UTF-16BE: 12 bytes, UTF-16 (with a byte order mark): 14 bytes UTF-8 bytes read as ISO-8859-1: 18 chars, starting U+00E0 U+00A4 U+00A8 through an ISO-8859-1 stream: ??????
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Encoding.java
Step through the word’s way out of the JVM, and back with the wrong charset:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Chars in memory. Inside the JVM the word is 6 chars, one per code point. Nothing about bytes is decided until the text leaves the program, as a file, a request or console output.
- Encoded as UTF-8. getBytes(StandardCharsets.UTF_8) writes 3 bytes for each of these code points: 18 bytes. UTF-16BE would take 12, and UTF-16 with its byte order mark 14.
- Decoded with the wrong charset. new String(bytes, ISO_8859_1) reads each byte as one Latin-1 char, so the 18 bytes become 18 chars that start U+00E0 U+00A4 U+00A8. No error is raised: the result is mojibake.
- Printed with no Devanagari. A PrintStream that encodes with ISO-8859-1 has no byte for any of the 6 code points, so it writes a question mark for each one: ??????.
- Name the charset. Every edge needs the right charset: UTF-8 for files and the network, which is Java's default since JDK 18, and a console set to UTF-8 for output, as stdout.encoding reports.
- Since JDK 18,
file.encodingis UTF-8 on every platform unless you change it (JEP 400), soFiles.readString,new String(bytes)andjavacitself read UTF-8 by default. Source files with Hindi string literals compile the same everywhere. - The same six code points take 18 bytes in UTF-8, 12 in UTF-16, and 14 when UTF-16 starts with a byte order mark.
- Decoding UTF-8 bytes with the wrong charset does not fail: ISO-8859-1 turned 18 bytes into 18 Latin characters
starting with
à, the familiar mojibake: Garbled text that appears when bytes written in one encoding are read with another, such as न for the UTF-8 bytes of न read as ISO-8859-1. The bytes are intact; the decoding is wrong, and no error is raised. of a text file opened with the wrong encoding. System.outencodes withstdout.encoding, which the JVM takes from the host environment, such as the console’s settings; it was UTF-8 here. A stream whose charset has no Devanagari prints??????, which is what a console set to a legacy code page shows. Set the console to UTF-8, or start Java with-Dstdout.encoding=UTF-8, the one value theSystemdocumentation allows for it on the command line, when output must carry Indian scripts.
Key idea
Text is chars inside the JVM and bytes everywhere else: name the charset wherever one turns into the other, because the wrong one fails without an error.
Checkpoint
Check: Encodings and mojibake
Practise: count and reverse what a reader sees
Visible.java asks for two methods built on \X. The checks use Hindi, Tamil, an emoji with a skin tone, two flags
and a decomposed é. As it starts, both methods return nothing useful, so five of the six checks fail:
// Your turn: count and reverse what a reader sees. visibleLength("नमस्ते") is 3, and reverseVisible("नमस्ते") is
// "स्तेमन": the conjunct स्ते stays whole. Use the regular expression \X, which matches one extended grapheme cluster.
int visibleLength(String s) {
return 0; // Write your code here.
}
String reverseVisible(String s) {
return ""; // Write your code here.
}
int failed = 0;
int total = 0;
void check(String s, int length, String reversed) {
total++;
int gotLength = visibleLength(s);
String gotReversed = reverseVisible(s);
if (gotLength != length || !gotReversed.equals(reversed)) {
failed++;
IO.println("FAIL \"" + s + "\": expected " + length + " and \"" + reversed + "\", got " + gotLength + " and \"" + gotReversed + "\"");
}
}
void main() {
check("नमस्ते", 3, "स्तेमन");
check("தமிழ்", 3, "ழ்மித");
check("👍🏽 ok", 4, "ko 👍🏽");
check("🇮🇳🇯🇵", 2, "🇯🇵🇮🇳");
check("cafe\u0301", 4, "e\u0301fac"); // é written as e and a combining accent
check("", 0, "");
IO.println(failed + " of " + total + " checks failed");
if (failed > 0) {
System.exit(1);
}
} Output (exit status 1)
FAIL "नमस्ते": expected 3 and "स्तेमन", got 0 and "" FAIL "தமிழ்": expected 3 and "ழ்மித", got 0 and "" FAIL "👍🏽 ok": expected 4 and "ko 👍🏽", got 0 and "" FAIL "🇮🇳🇯🇵": expected 2 and "🇯🇵🇮🇳", got 0 and "" FAIL "café": expected 4 and "éfac", got 0 and "" 5 of 6 checks failed
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Visible.java
You are done when it prints 0 of 6 checks failed. Hints: Pattern.compile("\\X").matcher(s) finds one cluster per
call of find(); count the matches for visibleLength, and for reverseVisible add each group() to a list,
reverse it with Collections.reverse and join it with String.join("", list). Try codePointCount and
StringBuilder.reverse() first, to see which checks they fail.
Six ideas are left to test, from the three units to encodings. The practice picks the one you are least sure of first and explains each choice the moment you make it:
Practice that adapts to you
Practise this lesson
13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · Why can "👍".length() be 2 in Java, and how do you count the characters a user sees?
A Java string is a sequence of chars, and a char is a 16-bit UTF-16 code unit. Code points above U+FFFF, which
include most emoji, are stored as a surrogate pair of two chars, so "👍".length() is 2 while
codePointCount(0, s.length()) is 1. Even code points are not what users count: 👍🏽 is a thumbs-up and a
skin-tone modifier, 2 code points that display as one symbol, and Hindi नमस्ते has 6 code points but 3 visible
characters, because vowel signs and the virama combine with the consonants around them. What a user sees as one
character is an extended grapheme cluster, and Java finds them with the regular expression \X or with
BreakIterator.getCharacterInstance(). A good answer adds that substring and charAt work in chars, so cutting
text by index can split a surrogate pair or a cluster.
A good interviewer pushes the same idea one step further. Answer these two, then read why each option holds or fails:
Checkpoint
Interview round: two follow-up questions
For practice at length, the module bank adds two names that two systems measure differently, and two usernames that look identical:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A profile form accepts a name when name.length() <= 20 and passes it to a payment gateway that allows 40 bytes of UTF-8. The form refuses a Tamil name of 15 visible characters, and the gateway rejects a Hindi name the form accepted. Explain both, and say how you would define the limits.
-
Mid to senior
Search finds a Hindi word typed on one keyboard but not the same word typed on another, and two accounts have usernames that look identical although the uniqueness check passed. Explain the cause, and design the fix, including what you store and how you treat existing data.
Key takeaways
length(),charAt,substringandStringBuildercount UTF-16 chars;codePointCount,codePoints()andoffsetByCodePointscount code points;\XandBreakIteratorfind grapheme clusters, what readers count.- Indian scripts fit one char per code point, but vowel signs, the virama and the nukta are combining marks, so नमस्ते is 6 chars and 3 clusters; the default rules join conjuncts in only six Indian scripts as of Unicode 16.0, so Kannada ನ್ನ splits into two clusters.
StringBuilder.reverse()keeps surrogate pairs but breaks clusters, and turns the Indian flag into Nicaragua’s; reverse a list of\Xmatches instead.- Visually identical strings can differ in code points: compare them after
Normalizer.normalize(s, Form.NFC). - Cut text at grapheme cluster boundaries;
substring(0, n)can leave a lone surrogate. - UTF-8 is Java’s default charset since JDK 18, decoding with the wrong charset gives mojibake without an error, and
System.outfollowsstdout.encoding, which the host environment, such as the console, decides.
Terms in this lesson
- Basic Multilingual Plane
- The first 65,536 code points, U+0000 to U+FFFF, which hold the letters of almost every modern script, Indian scripts included. Each takes one char in a Java string; a code point above it takes a surrogate pair.
- character encoding
- The rule that turns characters into bytes and back, also called a charset: UTF-8 takes 1 to 4 bytes per code point, UTF-16 2 or 4, and ISO-8859-1 one byte for each of its 256 characters. Java's default is UTF-8 since JDK 18.
- combining mark
- A code point that attaches to the character before it instead of standing alone, such as a vowel sign, the virama or an accent. Its Unicode category is M (Mn, Mc or Me), so Character.isLetter is false for it.
- conjunct
- Two or more consonants joined by a virama and written as one shape, such as स्ते in नमस्ते. Unicode's default rules keep a conjunct in one grapheme cluster only in scripts whose virama is a conjunct linker.
- grapheme cluster
- What a reader counts as one character: a letter with its marks, a conjunct such as स्ते, or an emoji with its modifiers. Java finds them with the regular expression \X or with BreakIterator.getCharacterInstance().
- mojibake
- Garbled text that appears when bytes written in one encoding are read with another, such as न for the UTF-8 bytes of न read as ISO-8859-1. The bytes are intact; the decoding is wrong, and no error is raised.
- normalisation
- Rewriting text in one standard form, so that two spellings that mean the same, such as é as one code point or as e and an accent, become the same code points. NFC, the usual form for storage, composes where Unicode allows it.
References
- Character, Unicode Character Representations (Java SE 25 API) (Oracle)
- Pattern: the \X grapheme cluster matcher (Java SE 25 API) (Oracle)
- BreakIterator (Java SE 25 API) (Oracle)
- Normalizer (Java SE 25 API) (Oracle)
- Unicode Standard Annex #29: Unicode Text Segmentation (revision 45, Unicode 16.0.0) (Unicode Consortium)
- Unicode 16.0.0 Character Database: DerivedCoreProperties.txt (Indic_Conjunct_Break) (Unicode Consortium)
- Unicode Standard Annex #15: Unicode Normalization Forms (revision 56, Unicode 16.0.0) (Unicode Consortium)
- Unicode 16.0.0 Character Database: CompositionExclusions.txt (Unicode Consortium)
- JEP 400: UTF-8 by Default (OpenJDK)
- System properties stdout.encoding and native.encoding (Java SE 25 API) (Oracle)
- JEP 254: Compact Strings (OpenJDK)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress