Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Java temurin-25.0.4+101.0.LTS
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

नमस्ते is 3 grapheme clusters, 6 code points, 6 chars and 18 UTF-8 bytes; a thumbs-up with skin tone is 1 cluster, 2 code points, 4 chars.One word, four ways to count itनमस्ते in Java 25Grapheme clusters, what a reader sees: 3नमस्तेCode points: 6नU+0928मU+092EसU+0938◌्U+094DतU+0924◌ेU+0947chars, what length() counts: 60928092E0938094D09240947UTF-8 bytes in a file: 18E0 A4 A8E0 A4 AEE0 A4 B8E0 A5 8DE0 A4 A4E0 A5 87👍🏽 thumbs up, medium skin toneGrapheme clusters: 1👍🏽Code points: 2U+1F44DU+1F3FDchars: 4, two surrogate pairsD83DDC4DD83CDFFD

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.

  1. Grapheme clusters, what a reader sees: 3. The boxes are न, म and the conjunct स्ते, which spans the last four code points.
  2. Code points: 6. They are न U+0928, म U+092E, स U+0938, the virama ् U+094D, त U+0924 and the vowel sign े U+0947.
  3. Chars, what length() counts: 6, one per code point, because all six lie below U+FFFF: 0928, 092E, 0938, 094D, 0924 and 0947.
  4. 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:

Four measures of eight strings, and the clusters of four words Java · Counts.java
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 \X and java.text.BreakIterator counted 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’s Character class 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

  1. Question 1 of 2 What is "🇮🇳🇯🇵".length(), the flags of India and Japan?

    Type a number.

    A hint

    Each flag is two regional indicator letters, and each of those lies above U+FFFF.

    Show the answer: Question 1 of 2

    Answer: 8 chars

    Each flag is two code points above U+FFFF, and each such code point takes a surrogate pair of two chars: 2 flags × 2 code points × 2 chars = 8 chars, 4 code points and 2 grapheme clusters.

  2. Question 2 of 2 पुणे is 4 code points, प, the vowel sign ु, ण and the vowel sign े, all in the Devanagari block. How many bytes does it take in UTF-8?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 12 bytes

    Every Devanagari code point lies between U+0800 and U+FFFF, where UTF-8 uses 3 bytes, as the run's 18 bytes for the 6 code points of नमस्ते showed: 4 × 3 = 12 bytes, for 4 chars and 2 grapheme 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 of नमस्ते and 👍🏽 Java · Inspect.java
// 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:

Interactive · stage by stage How Unicode's rules group नमस्ते into 3 clusters Step through the 6 code points in order and watch where a boundary falls, and which rule keeps the last four together.
How Unicode's rules group नमस्ते into 3 clusters, step 6 of 6. Showing न U+0928, a letter (cluster 1 starts and ends), म U+092E, a letter (cluster 2), स U+0938, a letter (cluster 3 starts), ् U+094D, virama (a mark: joins cluster 3), त U+0924, a letter (joined by the virama (GB9c)), े U+0947, vowel sign (a mark: joins cluster 3).न U+0928, a lettercluster 1 starts and endsम U+092E, a lettercluster 2स U+0938, a lettercluster 3 starts् U+094D, viramaa mark: joins cluster 3त U+0924, a letterjoined by the virama (GB9c)े U+0947, vowel signa mark: joins cluster 3

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. न 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).
  2. म 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.
  3. 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.
  4. त 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.
  5. 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. 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}]+.

Unicode Character Inspector Paste any word to see its code points, names and categories, the same table Inspect.java prints.

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

  1. Question 1 of 2 Why does \X find 4 clusters in ಕನ್ನಡ on JDK 25, although a Kannada reader sees 3 characters?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: In Unicode 16.0 the Kannada virama is not a conjunct linker, so the default rules split ನ್ನ into ನ್ and ನ

    The default rules join conjuncts in six scripts as of Unicode 16.0: Devanagari, Bengali, Gujarati, Oriya, Telugu and Malayalam. Other scripts need tailored rules to match what their readers see.

  2. Question 2 of 2 Which pattern matches the whole word पुणे as one match?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: [\p{L}\p{M}]+

    Character.isLetter is false for a vowel sign, because a mark is not a letter. Patterns and checks for words in Indian scripts must accept marks, \p{M}.

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:

Reversed with reverse() and by clusters Java · Reverse.java
// 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.

Reverse Text, Mirror & Upside Down Reverse a sentence in Hindi or with emoji: the tool reverses by user-perceived characters, like the \X version.

Checkpoint

Check: Reversing by clusters

  1. One question What does new StringBuilder("👍🏽").reverse() give?

    Choose one answer.

    Show the answer: One question

    Answer: The skin-tone modifier first, then the thumb, so the two code points swap places

    A modifier before the emoji it belongs to modifies nothing, so the thumb and the tone come out apart. Reversing a list of \X matches keeps 👍🏽 whole.

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:

Three pairs that look alike, before and after NFC Java · Normalise.java
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:

Interactive · stage by stage NFC on three pairs that look alike Step through what NFC does to each spelling of the three pairs in Normalise.java, then compare the results.
NFC on three pairs that look alike, step 6 of 6. Showing the text as typed (U+00E9 or U+0065 U+0301), decompose: take apart (U+0065 U+0301), compose: put together (U+00E9), compare with equals (true for all three pairs). In focus: compare with equals.the text as typedU+00E9 or U+0065 U+0301decompose: take apartU+0065 U+0301compose: put togetherU+00E9compare with equalstrue for all three pairs

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. क़ 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.
  2. क़ 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.
  3. क़ 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.
  4. 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.
  5. 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.
  6. 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) and e with 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

  1. Question 1 of 2 After Normalizer.normalize(s, Normalizer.Form.NFC), how many code points does क़, typed as U+0958, have?

    Type a number.

    A hint

    NFC takes characters apart first, and does not put every pair back.

    Show the answer: Question 1 of 2

    Answer: 2 code points

    NFC takes U+0958 apart into U+0915 U+093C, and does not join them again, because U+0958 is a composition exclusion. The run printed U+0915 U+093C for both spellings.

  2. Question 2 of 2 a holds café with é as one code point, U+00E9. b holds cafe followed by U+0301, a combining accent. Which comparison is true?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Normalizer.normalize(a, NFC).equals(Normalizer.normalize(b, NFC))

    Normalise both sides with the same form before you compare, search or use text as a map key.

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:

Three ways to cut a message Java · Truncate.java
// 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:

Interactive · stage by stage Three ways to cut a message at 11 Step through where substring(0, 11), 11 code points and 11 clusters each cut the message of Truncate.java.
Three ways to cut a message at 11, step 3 of 3. Showing Thank you and a space, 🙏 high half (kept), 🙏 low half (kept: 🙏 is whole), 🏽 skin tone (kept: 🙏🏽 is one cluster), for the help. In focus: Thank you and a space, 🙏 high half, 🙏 low half, 🏽 skin tone.Thank you and a spacechars 0 to 9, 10 code points🙏 high halfkept🙏 low halfkept: 🙏 is whole🏽 skin tonekept: 🙏🏽 is one clusterfor the helpchars 14 to 26, after a space

The drawing shows the whole scene; its 3 steps are listed below.

Every step as text
  1. 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 ?.
  2. 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.
  3. 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. deleteCharAt and charAt loops cause the same damage.
  • Counting code points with offsetByCodePoints kept the emoji whole but dropped its skin tone, a separate code point.
  • Counting grapheme clusters with \X kept 🙏🏽 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

  1. Question 1 of 2 A preview keeps "Hi 👍🏽 there".substring(0, 4). What does the preview end with?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A space and the high half of 👍, a lone surrogate

    A lone surrogate is not a character at all; output shows it as ?, as the run's substring(0, 11) did. Cut at a cluster boundary instead.

  2. Question 2 of 2 A notification shows the first 11 characters of "Thank you 🙏🏽 for the help", as a reader counts them. Which call of Truncate.java gives that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: firstClusters(message, 11), which counts \X matches

    When a limit is measured in what people see, cut at a cluster boundary. A limit in bytes, as in some database columns, is counted in bytes, and the cut still falls on a cluster boundary below it.

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:

One word in four encodings Java · Encoding.java
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:

Interactive · stage by stage नमस्ते out of the JVM and back Step through Encoding.java, from the 6 chars in memory to the bytes, the wrong decoding and the question marks.
नमस्ते out of the JVM and back, step 5 of 5. Showing नमस्ते in the JVM, getBytes(UTF_8) (18 bytes, E0 A4 A8 …), read as ISO-8859-1 (18 chars: mojibake, no error), a Latin-1 PrintStream (?????? for 6 code points). In focus: getBytes(UTF_8), read as ISO-8859-1, a Latin-1 PrintStream.नमस्ते in the JVM6 charsgetBytes(UTF_8)18 bytes, E0 A4 A8 …read as ISO-8859-118 chars: mojibake, no errora Latin-1 PrintStream?????? for 6 code points

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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: ??????.
  5. 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.encoding is UTF-8 on every platform unless you change it (JEP 400), so Files.readString, new String(bytes) and javac itself 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.out encodes with stdout.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 the System documentation allows for it on the command line, when output must carry Indian scripts.
Text Encoding Converter & Mojibake Fixer Open a file that shows mojibake and see it decoded with the right encoding.

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

  1. Question 1 of 2 A Hindi text file shows नमस... when a program reads it. What went wrong?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its UTF-8 bytes were decoded with a one-byte charset such as ISO-8859-1

    Decoding with the wrong charset raises no error, which is what makes mojibake hard to spot. Name the charset, StandardCharsets.UTF_8, whenever bytes become text.

  2. Question 2 of 2 How many bytes does पुणे, 4 code points, take in UTF-16BE?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 8 bytes

    Each code point of the Basic Multilingual Plane is one UTF-16 code unit of 2 bytes: 4 × 2 = 8. The run's नमस्ते took 12 bytes for 6 code points in the same way, and 14 with a byte order mark.

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:

The starting point Java · Visible.java
// 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.

  1. Question 1 of 13 What is "🇮🇳".length() in Java?

    Type a number.

    Show the answer: Question 1 of 13

    Answer: 4 chars

    The flag is two regional indicator symbols, I and N, and each lies above U+FFFF, so each takes a surrogate pair of two chars: 4 chars, 2 code points and 1 grapheme cluster.

  2. Question 2 of 13 How many code points does "नमस्ते" have?

    Type a number.

    Show the answer: Question 2 of 13

    Answer: 6 code points

    न, म, स, the virama ्, त and the vowel sign े are 6 code points, each in the Basic Multilingual Plane, so they are also 6 chars. A reader sees 3 characters, the grapheme clusters न, म and स्ते.

  3. Question 3 of 13 Which of these counts what a reader sees as characters?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: The number of matches of the regular expression \X

    \X matches one extended grapheme cluster, and BreakIterator.getCharacterInstance() finds the same boundaries. length() counts UTF-16 chars, codePointCount counts code points, and the byte count depends on the encoding.

  4. Question 4 of 13 What does the virama ् do in स्ते, under Unicode's default rules for Devanagari?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: It is a conjunct linker, so it joins स and त into one cluster

    The virama is a non-spacing mark that joins स in cluster 3, and as a conjunct linker it also pulls त into that cluster, which the vowel sign े then extends.

  5. Question 5 of 13 How many grapheme clusters does \X find in "👍🏽👍", a thumbs-up with a skin tone followed by a plain one?

    Type a number.

    Show the answer: Question 5 of 13

    Answer: 2 clusters

    The skin-tone modifier extends the thumb before it, so 👍🏽 is one cluster, and the plain 👍 is another: 2 clusters, 3 code points and 6 chars.

  6. Question 6 of 13 What does new StringBuilder("🇮🇳").reverse() give?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: The flag of Nicaragua, because the two regional indicators swap places

    reverse keeps each surrogate pair in order, so it never breaks a code point, but it does reverse the order of the code points: I and N become N and I, the regional indicators of Nicaragua. Reversing a list of \X matches keeps the flag whole.

  7. Question 7 of 13 Which way of reversing keeps every grapheme cluster whole?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Collect the \X matches in a list, reverse the list and join it

    Reverse.java does exactly this in reverseClusters, and it printed स्तेमन, ko 👍🏽 and 🇮🇳.

  8. Question 8 of 13 One keyboard types क़ as U+0958, another as U+0915 U+093C. How do you make the two strings compare as equal?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Normalise both with Normalizer.normalize(s, Normalizer.Form.NFC) and then call equals

    equals compares code points, so the two spellings differ. They are canonically equivalent, and NFC turns both into U+0915 U+093C, because Unicode excludes U+0958 from composition. Case folding has nothing to do with it.

  9. Question 9 of 13 Which statement about NFC is true?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: It can make a string longer, as U+0958 becomes U+0915 U+093C

    NFC is canonical decomposition followed by canonical composition, with the exceptions Unicode lists. Its job is one spelling for text that means the same, not a shorter one.

  10. Question 10 of 13 Why did substring(0, 11) in Truncate.java print a ? at the end?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: It kept only the high half of 🙏, a lone surrogate, which output cannot encode

    Character.isHighSurrogate on the last char was true, as the run printed. Cut at a cluster boundary, and the problem cannot occur.

  11. Question 11 of 13 What did the cut at 11 code points drop from "Thank you 🙏🏽 for the help"?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: The skin-tone modifier, which is a code point of its own

    Code points are better than chars for cutting, but still not what people see. Clusters are.

  12. Question 12 of 13 Since JDK 18, which charset do Files.readString and new String(bytes) use when you name none?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: UTF-8, on every platform

    Naming the charset, StandardCharsets.UTF_8, still makes the intent clear, and it works the same on older JDKs.

  13. Question 13 of 13 A program prints Hindi text and the console shows ??????. What is the most likely cause?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: The output stream encodes with a charset that has no Devanagari, so each code point becomes ?

    System.out encodes with stdout.encoding, which the JVM takes from the host environment, such as the console's settings. Set the console to UTF-8, or start Java with -Dstdout.encoding=UTF-8.

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

  1. Question 1 of 2 An interviewer asks how you would count the characters a user sees in a string. Which answer is strongest?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Count the matches of \X, or the boundaries of BreakIterator.getCharacterInstance(), because length() counts UTF-16 chars and codePointCount counts code points

    A strong answer names all three units, says which one each method counts, and adds that substring and charAt work in chars, so cutting by index can split a cluster.

  2. Question 2 of 2 The interviewer then asks how you would check whether a word in Hindi reads the same backwards. What do you compare?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Its list of grapheme clusters with the same list reversed, after normalising the word to NFC

    Normalise, split into clusters with \X, then compare the list with its reverse: the same steps as the exercise of this lesson, plus normalisation.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • length(), charAt, substring and StringBuilder count UTF-16 chars; codePointCount, codePoints() and offsetByCodePoints count code points; \X and BreakIterator find 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 \X matches 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.out follows stdout.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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.