很多Java教程会把char简单描述为一个字符类型,但到了处理生僻字、Emoji或做字符串截取时,才发现它并不是直觉中的单个字符。要理解char的使用边界,必须回到Java的字符编码模型:char本质上是一个16位无符号整数,保存的是UTF-16编码中的一个代码单元,而不是完整的Unicode码点。这个差异决定了它在遇到增补字符时需要特殊处理。

本文会从char的底层表示讲起,再解释UTF-16的代理机制,接着列出常见使用误区,最后给出能够正确处理全量Unicode的实践方式。
一、char的底层表示:16位无符号整数
在Java中,char是最小的无符号整型,长度固定为16位,取值范围是\u0000到\uffff,对应十进制的0到65535。它和short虽然都占两个字节,但short是有符号的,最小值是-32768,而char没有负数。可以用单引号写字符字面量,也可以用整数或Unicode转义序列赋值,三者在编译后是等价的。
char a = 'A'; char b = 65; char c = '\u4e2d'; // 中 System.out.println(a == b); // true System.out.println((int) c); // 20013
由于char本质是数字,它可以直接参与自增、加减和比较运算。例如c - '0'可以把数字字符转成整数值,c >= 'a' && c <= 'z'可以判断小写字母。需要注意的是,char参与算术运算时结果会自动提升为int,如果要把结果重新存回char,需要显式强制转换。
还需要区分char和String。单引号表示char,双引号表示String。String内部在JDK不同版本中可能是char数组或字节数组,但对外仍表现为UTF-16代码单元序列。因此一个最典型的反直觉现象是:字符串"A中"的length()是2,而包含一个增补Emoji的字符串长度却不是1,而是2。理解这个行为需要进入UTF-16编码规则。
二、UTF-16编码规则:BMP、码点与代理区
Unicode给每个字符分配一个唯一码点,范围是U+0000到U+10FFFF。码点范围被划分为17个平面,其中最常用的是第0平面,也叫基本多语言平面BMP,范围U+0000到U+FFFF。Java的char正好能覆盖这个范围,所以对于多数常用文字,一个char就表示一个字符。
超出BMP的字符称为增补字符,码点范围是U+10000到U+10FFFF。UTF-16为了用16位单元表示这些字符,引入了代理机制:从BMP中预留U+D800到U+DFFF这段区域,不映射任何实际字符。其中U+D800到U+DBFF为高代理项,U+DC00到U+DFFF为低代理项。一个增补字符由一对代理项组成,高代理在前,低代理在后。Java的char只能保存单个代码单元,所以一个增补字符在Java字符串中必然占据两个char。
String emoji = "\uD83D\uDE00"; // U+1F600 System.out.println(emoji.length()); // 2 System.out.println(emoji.codePointCount(0, emoji.length())); // 1 int codePoint = emoji.codePointAt(0); System.out.println(Integer.toHexString(codePoint)); // 1f600
代码中length()返回的是UTF-16代码单元数量,而不是人眼看到的字符数量;codePointCount才是按Unicode码点统计的结果。这个差异会在字符串截取、数组遍历、分页和长度校验时造成严重问题。例如取前N个字符时如果只按charAt截断,可能把一个代理对拆开,产生乱码或问号。
另外,Java的String在内存中使用UTF-16作为内部表示,这是历史设计选择。将字符串写成字节时需要指定字符集,例如UTF-8、GBK等。UTF-8是一种变长编码,对ASCII字符占1个字节,中文通常占3个字节,增补字符占4个字节。这与char的固定2字节完全不同,不能直接用string.length()估算字节长度。
三、char使用中的常见误区
第一个误区是认为char等于一个完整字符。从前面的分析可以看出,char只是UTF-16代码单元。如果业务文本可能包含Emoji、部分CJK扩展B区汉字、生僻姓氏或历史文字,应优先使用String配合码点API,而不是单独操作char。例如判断用户名首字母时,不要直接取name.charAt(0)之后再判断大小写,因为如果首字符是增补字符,取到的只是一个代理项。
第二个误区是直接拼接char进行数值运算。看下面的代码:
char c1 = '1';
char c2 = '2';
System.out.println(c1 + c2); // 99,而不是"12"
System.out.println("" + c1 + c2); // 12
因为c1 + c2会先提升为int,输出的是ASCII码49加50的结果99。想得到字符串"12",需要先转换为字符串,或者使用Character.toString(c1)。类似的,char c = 'A' + 1可以编译,因为'A'是编译期常量,编译器会做常量折叠并自动收窄;但如果写成int n = 1; char c = 'A' + n;就会报错,因为此时结果是int,不能直接赋给char。
第三个误区是用char存储字节。早期处理二进制流时,有人会把byte强制转成char来打印,这很容易因为符号扩展和编码映射产生乱码。字节数据应该使用byte[]、ByteBuffer或十六进制字符串处理。字符数据与字节数据之间的转换必须经过明确的字符集,而不是简单的强制类型转换。
第四个误区是忽略char的默认值。char作为成员变量时默认值是\u0000,它是空字符,不是空格,打印时不可见。很多日志中出现的空白列,可能是数组初始化后未赋值导致的。使用Character.isWhitespace判断时,空字符会被识别为false,因为它不属于Unicode空白字符。
四、正确处理增补字符的编码实践
如果业务中明确要支持全量Unicode,建议从三个层面调整。首先是遍历字符串时使用codePointAt,而不是charAt。下面的示例按码点遍历字符串,并正确跳过代理对:
String text = "A中\uD83D\uDE00";
for (int i = 0; i < text.length(); ) {
int cp = text.codePointAt(i);
System.out.print(Integer.toHexString(cp) + " ");
i += Character.charCount(cp);
}
这里Character.charCount(cp)会根据码点返回1或2,对于BMP字符返回1,对于增补字符返回2。这样可以保证索引永远落在字符边界上。反向遍历则要使用offsetByCodePoints或从末尾手动判断低代理项。
其次,在字符串截取和长度判断时优先使用码点逻辑。例如截取前20个字符,可以先循环统计码点数并找到截断位置,再调用substring。如果使用StringBuilder拼接代理对,必须同时写入高代理和低代理,不能只写入其中一半。可以通过Character.toChars(codePoint)一次性获得两个char,再整体插入。
int cp = 0x1F600; char[] chars = Character.toChars(cp); String emoji = new String(chars); System.out.println(emoji.length()); // 2 System.out.println(emoji.codePointCount(0, emoji.length())); // 1
最后,在做文件读写、网络传输或数据库存储时,要显式指定字符集。推荐使用StandardCharsets.UTF_8,避免依赖平台默认编码。示例:byte[] bytes = text.getBytes(StandardCharsets.UTF_8);和new String(bytes, StandardCharsets.UTF_8)。如果收到的字节流编码不一致,不要尝试用char强制修正,而应先确定原始编码,再用相应字符集解码。
总之,char适合处理BMP范围内的单字符判断与运算,但处理文本内容时应始终以String和Unicode码点为边界。理解了UTF-16的代理机制,就能避免大多数乱码、截断和长度统计问题。