Character & Text Handling
The char primitive is a 16-bit UTF-16 code unit, not always a full Unicode character — understanding code units, code points, and surrogate pairs is essential for handling text correctly.
Java's char type is a 16-bit unsigned value representing a single UTF-16 code unit. For a long time this felt equivalent to "one character" because the Basic Multilingual Plane (BMP) covers most common scripts within 16 bits. But Unicode has far more characters than fit in 16 bits, and characters outside the BMP — many emoji, some historic and less-common scripts — require two char values (a surrogate pair) to represent a single logical character (a code point). Working with text correctly means knowing when char-by-char reasoning breaks down.
char: A 16-bit UTF-16 Code Unit
char is an unsigned 16-bit primitive, holding values from 0 to 65,535, interpreted as a UTF-16 code unit. Character literals use single quotes, and char supports the usual arithmetic operators since it's ultimately a numeric type under the hood.
char c = 'A';
System.out.println((int) c); // 65 — the underlying numeric code unit value
char next = (char) (c + 1);
System.out.println(next); // 'B'
System.out.println('a' < 'b'); // true — chars compare by their numeric code unit valueCode Units vs. Code Points
A Unicode code point is a single logical character, identified by a number from 0 to 0x10FFFF (over a million possible values). A code unit is the fixed-size building block Java's char uses to represent text — 16 bits. For code points within the Basic Multilingual Plane (0 to 0xFFFF), one code unit exactly represents one code point. For code points beyond that (0x10000 and above, the "supplementary" planes), it takes two code units — a surrogate pair — to represent that single code point.
Code point range Code units needed (UTF-16)
------------------------ ---------------------------
U+0000 - U+FFFF 1 char (Basic Multilingual Plane)
U+10000 - U+10FFFF 2 chars (surrogate pair, supplementary planes)Surrogate Pairs
A surrogate pair is two specially-reserved char values — a high surrogate (0xD800–0xDBFF) followed by a low surrogate (0xDC00–0xDFFF) — that together encode one supplementary code point. Neither half is a valid character on its own; splitting a string in the middle of a surrogate pair (e.g. via a naive substring at the wrong index) produces malformed, unrenderable text.
String emoji = "😀"; // U+1F600, a supplementary-plane code point
System.out.println(emoji.length()); // 2 — two char code units (a surrogate pair)
System.out.println(Character.isHighSurrogate(emoji.charAt(0))); // true
System.out.println(Character.isLowSurrogate(emoji.charAt(1))); // true
System.out.println(emoji.codePointAt(0)); // 128512 — the single logical code point
System.out.println(Character.toChars(128512).length); // 2 — confirms it needs two chars to encodeWhy char Is Not Always a Complete Character
This is the central lesson: char c = someString.charAt(i) might give you only half of a surrogate pair rather than a full logical character, and someString.length() counts code units, not user-perceived characters. Code that assumes "one char equals one character" silently mishandles supplementary-plane text — a real concern for full emoji support and several less-common scripts.
Iterating with charAt in a simple loop is code-unit-based and can split supplementary characters; codePoints() iterates whole logical characters correctly.
String text = "A😀B";
System.out.println(text.length()); // 4 — 'A', high surrogate, low surrogate, 'B'
System.out.println(text.codePointCount(0, text.length())); // 3 — correctly counts 'A', the emoji, 'B'
for (int i = 0; i < text.length(); i++) {
System.out.print(text.charAt(i) + " "); // iterates code UNITS — splits the emoji into two odd chars
}
System.out.println();
// Correct, code-point-aware iteration:
text.codePoints().forEach(cp -> System.out.print(Character.toString(cp) + " ")); // A 😀 BCharacter Utility Methods
java.lang.Character provides static helper methods for classifying and converting individual characters — testing for digits, letters, whitespace, case, and converting between cases or to/from numeric values.
System.out.println(Character.isDigit('7')); // true
System.out.println(Character.isLetter('$')); // false
System.out.println(Character.isWhitespace(' ')); // true
System.out.println(Character.isUpperCase('A')); // true
System.out.println(Character.toUpperCase('a')); // 'A'
System.out.println(Character.toLowerCase('Z')); // 'z'
System.out.println(Character.isLetterOrDigit('_')); // false
System.out.println(Character.getNumericValue('7')); // 7 — as an intUnicode-Safe Reasoning
When correctness for arbitrary Unicode text matters, prefer code-point-aware operations over raw char-indexed ones: codePointAt, codePointCount, offsetByCodePoints, and the codePoints() stream, along with Character.isSupplementaryCodePoint, Character.charCount(codePoint) (returns 1 or 2), and Character.toChars(codePoint).
int cp = "😀".codePointAt(0);
System.out.println(Character.charCount(cp)); // 2 — this code point needs 2 chars
System.out.println(Character.isSupplementaryCodePoint(cp)); // true
// Safely advancing by one logical character, not one char code unit:
String s = "A😀B";
int index = 0;
while (index < s.length()) {
int codePoint = s.codePointAt(index);
System.out.println(Character.toString(codePoint));
index += Character.charCount(codePoint); // advance by 1 or 2, correctly
}Practical Usage
Everyday text processing — parsing digits, validating input characters, simple ASCII-range formatting — works perfectly well with plain char reasoning, since the vast majority of common text (Latin letters, digits, standard punctuation) lives well within the Basic Multilingual Plane. Code-point-aware handling becomes essential specifically when correctness for arbitrary user-generated text (including emoji, or scripts like certain historic or musical notations) is required, such as in a general-purpose text editor, chat application, or string-length limit enforced on user-perceived characters rather than raw code units.
static boolean isSimpleIdentifierChar(char c) {
return Character.isLetterOrDigit(c) || c == '_';
}Edge Cases
substring and other index-based String operations that land in the middle of a surrogate pair silently produce a lone, invalid surrogate char rather than throwing an error — this can corrupt rendering or downstream processing without any exception to alert you.
Use offsetByCodePoints to compute code-point-safe substring boundaries when the input text might contain supplementary characters.
String s = "A😀B"; // length 4: 'A', high-surrogate, low-surrogate, 'B'
String broken = s.substring(0, 2); // cuts right through the surrogate pair!
System.out.println(broken.length()); // 2
System.out.println(Character.isHighSurrogate(broken.charAt(1))); // true — a lone, invalid surrogateBest Practices
Don't assume length() equals "number of characters a user would count" for arbitrary text. Use codePoints()/codePointAt/charCount when iterating or slicing text that might contain supplementary characters. For most ASCII/BMP-only domains (identifiers, numeric parsing, simple validation), plain char methods remain perfectly correct and are simpler — don't over-engineer where it isn't needed.
Complete Example
public class CharDemo {
static long countLogicalCharacters(String s) {
return s.codePoints().count(); // correct even with supplementary characters
}
static int countVowels(String s) {
int count = 0;
for (char c : s.toCharArray()) { // safe here — vowels are all within the BMP
char lower = Character.toLowerCase(c);
if (lower == 'a' || lower == 'e' || lower == 'i' || lower == 'o' || lower == 'u') {
count++;
}
}
return count;
}
public static void main(String[] args) {
String text = "Hello 😀 World";
System.out.println("char length: " + text.length()); // counts code units
System.out.println("logical chars: " + countLogicalCharacters(text)); // counts code points
System.out.println("vowels: " + countVowels(text)); // 3 ('e','o','o')
}
}Key Takeaways
char is a 16-bit UTF-16 code unit, not a guaranteed complete Unicode character — for code points within the Basic Multilingual Plane the two coincide, but not beyond it.
Supplementary-plane characters (many emoji among them) require two chars — a surrogate pair — to represent a single logical code point.
String.length() counts UTF-16 code units, not user-perceived characters; use codePointCount for the logical character count.
Index-based operations like substring can silently split a surrogate pair, producing invalid lone surrogates with no exception thrown.
For plain ASCII/BMP text (identifiers, digits, common punctuation), ordinary char-based reasoning remains simple and correct.
for (int i = 0; i < s.length(); i++) { process(s.charAt(i)); // splits supplementary characters into two calls } s.codePoints().forEach(cp -> process(Character.toString(cp))); // processes whole logical characters
Iterating char-by-char can split a surrogate pair across two separate iterations, corrupting supplementary characters like many emoji; iterate by code point when text might contain them.
String truncated = text.substring(0, 10); // fixed char count, may cut a surrogate pair in half int safeEnd = text.offsetByCodePoints(0, Math.min(10, text.codePointCount(0, text.length()))); String truncated = text.substring(0, safeEnd);
Slicing by a raw char index can land in the middle of a surrogate pair, producing an invalid lone surrogate; use codePoint-aware offset calculations for text that may contain supplementary characters.
int userVisibleLength = text.length(); // assumes 1 char = 1 visible character long userVisibleLength = text.codePoints().count(); // correctly counts logical characters length() returns the count of UTF-16 code units, which can exceed the number of characters a user would actually perceive when the text contains supplementary-plane characters.
What exactly does the char primitive represent in Java, and why is it not always equivalent to one complete Unicode character?
char represents a single 16-bit UTF-16 code unit. It equals one complete Unicode character only for code points within the Basic Multilingual Plane (U+0000 to U+FFFF); code points beyond that range (supplementary characters, including many emoji) require two char values — a surrogate pair — to represent a single logical character, so one char alone doesn't capture it.
What is a surrogate pair, and what happens if a String is split (e.g., via substring) right in the middle of one?
A surrogate pair is two special char values — a high surrogate followed by a low surrogate — that together encode one supplementary-plane Unicode code point; neither char is valid on its own. Splitting a string in the middle of a surrogate pair produces a lone, invalid surrogate char with no exception thrown, silently corrupting that character.
Why does "😀".length() return 2 instead of 1?
Because the emoji's code point lies in a supplementary Unicode plane (above U+FFFF), so it requires two UTF-16 code units (a surrogate pair) to represent, and String.length() counts code units, not logical characters — codePointCount(0, length()) would correctly return 1.
What is the difference between codePointAt(index) and charAt(index) on a String?
charAt(index) returns a single 16-bit char code unit at that index, which may be only half of a surrogate pair for supplementary characters. codePointAt(index) returns the full logical Unicode code point starting at that index as an int, correctly combining a surrogate pair into one value when present.
What does Character.charCount(codePoint) return, and why is it useful when iterating text safely?
It returns either 1 or 2 — the number of char code units needed to represent that specific code point in UTF-16. It's useful for correctly advancing an index while manually iterating a string by code point, since you add 1 or 2 to the position depending on whether the current code point was a BMP character or a supplementary one.
For everyday text processing like parsing digits or validating simple identifiers, is plain char-by-char reasoning usually sufficient? Why?
Yes — digits, common punctuation, and Latin letters all live within the Basic Multilingual Plane, where one char always equals one code point, so ordinary char-based methods like Character.isDigit or charAt-based loops are simple and entirely correct for that domain. Code-point-aware handling only becomes necessary when the text might contain supplementary-plane characters.
How would you correctly count the number of user-perceived characters (not code units) in an arbitrary String that might contain emoji?
Use text.codePoints().count() (or equivalently text.codePointCount(0, text.length())), which iterates by logical Unicode code point rather than by raw 16-bit char code unit, correctly counting a surrogate pair as a single character instead of two.
Write a method countLetters that counts how many characters in a String are letters, using Character.isLetter, and test it on a mixed string.
public class CountLettersDemo { static int countLetters(String s) { int count = 0; for (char c: s.toCharArray()) { if (Character.isLetter(c)) { count++; } } return count; } public static void main(String[] args) { System.out.println(countLetters("Room 42B!")); // 5 ('R','o','o','m','B') } } countLetters iterates each char of the string and uses Character.isLetter to test it, incrementing the count for every alphabetic character while skipping digits, spaces, and punctuation — for "Room 42B!" the letters are R, o, o, m, and B, totaling 5.
What does this print? (Assume 'é' is a single precomposed BMP character, not a combining sequence.)
It prints 4, then é. Because 'é' as a single precomposed Unicode character lies within the Basic Multilingual Plane, it occupies exactly one char code unit, so "café" has length 4 (c, a, f, é) and charAt(3) correctly retrieves the full 'é' character — this case does NOT involve a surrogate pair, unlike supplementary-plane characters such as many emoji.
What does this print, and why is the resulting substring problematic?
It prints 3, then true. "Hi😀!" is encoded as 'H','i', high-surrogate, low-surrogate, '!' (length 5). substring(0, 3) takes indices 0,1,2 — 'H', 'i', and the LONE high surrogate — cutting the emoji's surrogate pair in half. cut.charAt(2) is that high surrogate, so isLowSurrogate on it is actually checking the wrong half — but more importantly, cut now contains an invalid, unpaired surrogate at its end, which is malformed UTF-16 text that can break rendering or further processing.
String s = "café";
System.out.println(s.length());
char last = s.charAt(s.length() - 1);
System.out.println(last);String s = "Hi😀!";
String cut = s.substring(0, 3);
System.out.println(cut.length());
System.out.println(Character.isLowSurrogate(cut.charAt(2)));