问题
-
使用 Java 的字符串时,如何准确评估其空间占用
结论
-
评估 String 空间占用时要分 JDK 版本、存储方式(存储在 JVM 堆内存还是磁盘)
-
使用 JDK8 及以下版本,字符串在 JVM 堆里以 UTF-16 编码存储,即一个字符占 2 个字节或 4 个字节,当序列化成文件存储到磁盘,根据系统编码不同占用大小不一,一般系统默认 UTF-8,即一个字符占用 1 到 6 个字节
-
我们一般的 key/value 字符串取值都是 ASCII 字符,因此它们在 JVM 堆里占 2 个字节,磁盘占 1 个字节,节省 50%的空间
-
JDK9 及以上模式开启了字符串压缩功能,ISO-8859-1 字符(0x0000-0x00FF)也只占 1 个字节了,其余字符仍使用 UTF-16 存储
-
Java 已经在考虑后续版本指定系统默认编码为 UTF-8 了(内部表示仍然是 UTF-16)
延伸 - Unicode
-
Unicode 在 91 年发布第一个版本时认为 16 位能表示世界上所有字符,定义了 2^16(0x0000 to 0xFFFF)个字符,后来发现不行,96 年发布了第二个版本,字符表示不再局限于 16bit,扩展到了 0x10FFFF
-
Unicode 是一套字符集,它使用一个数字来表示一个字符,这个数字被称为【code point】,每个 code point 可以对应一个 int 类型(只使用低 21 位即可,高 11 位只能为 0),因此 char 类型可以转为 int 类型
wiki:Unicode takes the role of providing a unique code point—a number, not a glyph—for each character. In other words, Unicode represents a character in an abstract way and leaves the visual rendering (size, shape, font, or style) to other software, such as a web browser or word processor
-
Unicode 将 code point 划分为 17 个 code panel,编号为 #0 到 #16。每个 code panel 包含 65,536(2^16)个代码点。其中,Plane#0 叫做基本多语言平面(Basic Multilingual Plane,BMP),其余平面叫做补充平面(Supplementary Planes),我们用到的大部分字母、汉字,都在 BMP 里(0x0000 到 0xFFFF)
- 0x0000 到 0x007F:ASCII 总共有 128 个字符,占据了 BMP 的前 128 个 code point
- 0x0080 到 0x00FF:ISO-8859-1 共 256 个字符,占据了 BMP 的前 256 个 code point
- 从 0xD800 到 0xDBFF 的 1024 个 code point 是 High-surrogate code point,从 0xDC00 到 0xDFFF 的 1024 个 code point 是 Low-surrogate code point。这 2048 个 code point 并不是有效的字符 code point,它们是为 UTF 编码保留的。一个 High-surrogate 和一个 Low-surrogate 组成一个 Surrogate Pair,可以在 UTF-16 里编码 BMP 之外的某个 code point
javadoc : The set of characters from U+0000 to U+FFFF is sometimes referred to as the Basic Multilingual Plane (BMP). Characters whose code points are greater than U+FFFF are called supplementary characters.
-
Unicode 里还有 code unit 的概念,代表编码格式里的最小表示单元,一个 code unit 对应一个 char,因此 Java 里补充字符对应一个 code point,但对应两个 char 或两个 code unit
wiki : The minimal bit combination that can represent a unit of encoded text for processing or interchange. The Unicode Standard uses 8-bit code units in the UTF-8 encoding form, 16-bit code units in the UTF-16 encoding form, and 32-bit code units in the UTF-32 encoding form
-
在 Unicode 出现之前,字符集都是和具体编码方案绑定在一起的,例如 ASCII 编码系统规定使用 7 比特来编码 ASCII 字符集;Unicode 在设计上就将字符集和字符编码方案分离开,也就是说,虽然每个字符在 Unicode 字符集中都能找到唯一确定的 code point,但是决定最终字节流的却是具体的字符集编码 character encoding。例如同样是对 Unicode 字符“A”进行编码,UTF-8 字符编码得到的字节流是 0x41,而 UTF-16(大端模式)得到的是 0x00 0x41
// 汉字“木”的Unicode码点为0x6728,在UTF-8中被编码为3字节E6 9C A8
char woodChar = '木';
int codePoint = woodChar;
log(codePoint); // 26408
log(Character.charCount(woodChar)); // 1
log(Integer.toHexString(woodChar)); // 6728
log(Arrays.toString(String.valueOf(woodChar) .getBytes(StandardCharsets.UTF_8.name()))); // [-26, -100, -88],字节流和具体编码方案有关
log(String.valueOf(woodChar).charAt(0)); // 木,charAt返回的是JVM的内部表示
复制代码
-
字符集编码实现时要考虑存储空间、与其他字符集的兼容等等,常用的 Unicode 字符集编码有 UCS-2、UTF-8 和 UTF-16,它们是一种算法实现,将 Unicode 的 code point 映射成自定义大小的字节,从而进行传输和保存
-
很多时候字符集和字符集编码的区分也并不严格,比如
java.nio.charset.StandardCharsets
里将 UTF-8 和 UTF-16 视为和 ASCII、ISO-8859-1 并列的字符集;ASCII 或 GBK 都内含了编码方案,所以既可以认为是字符集有是编码方案,但 Unicode 特殊,不能用来指代一种编码方案 -
java.nio.charset.StandardCharsets
里 UTF16 的编码方案分为 UTF16BE、UTF16LE 和 UTF16,这是 BOM(byte-order-mark)的概念,源自《格列佛游记》。鸡蛋通常一端大一端小,小人国的人们对于剥蛋壳时应从哪一端开始剥起有着不一样的看法。同样,计算机界对于传输多字节字(由多个字节来共同表示一个数据类型)时,是先传高位字节(大端)还是先传低位字节(小端)也有着不一样的看法。一般网络协议都采用大端模式进行传输。 -
BOM 规则比较乱,一般认为 windows 文件开头经常加 BOM,UTF-16 格式比 UTF-8 更可能有 BOM
log("a".getBytes(StandardCharsets.UTF_16).length); // return 4,第一个字节时BOM标识
// [-2, -1, 0, 97],-2 是 0xFE 而-1 是 0xFF,这个是默认的BOM字符
// FF为255,转为byte就变成了-1,FE同理
log(Arrays.toString("a".getBytes(StandardCharsets.UTF_16)));
复制代码
-
根据 Unicode 标准 (v6.2, p.30): UTF-8 既不要求也不推荐使用 BOM
延伸 - java.lang.Character
-
Java 在 96 发布的第一个版本基于 Unicode1.0,定义 char 类型为定长 16 位,后续 JDK 随着 Unicode 演变,但最初的决定无法改变。Java8 基于 Unicode6.2,Java11 基于 Unicode10,Unicode 最新版本是 12
-
限制 Java char 演变的不是编码,而是位长,Java 最初用的是 UCS-2,后来改为 UTF-16 编码(但 char 的表示范围还是 UCS-2 最初覆盖的 Unicode 字符,即编码方式改了,但 16 位只能实现一个 UTF-16 的子集),都是定长 16bit,因为位长没有变,因此对内部实现和外部 API 影响不大,但如果后续如果改为 UTF-8,大量 JDK 类库及字符处理类库行为都会发生变更,影响会非常大,所以 JDK 只能通过优化 String 的表示来提高性能,而不能改变最初的决定
javadoc : The char data type (and therefore the value that a Character object encapsulates) are based on the original Unicode specification, which defined characters as fixed-width 16-bit entities. The Unicode Standard has since been changed to allow for characters whose representation requires more than 16 bits. The range of legal code points is now U+0000 to U+10FFFF, known as Unicode scalar value
-
由于 char 的 16bit 定长,Unicode 里增补字符无法表示,因此,在 JDK5 里,Java 对 char 的实现逻辑进行了变更,添加了对增补字符的表示
blog :
1, In the end, the decision was for a tiered approach: Use the primitive type int to represent code points in low-level APIs, such as the static methods of the Character class. Interpret char sequences in all forms as UTF-16 sequences, and promote their use in higher-level APIs. Provide APIs to easily convert between various char and code point-based representations.
2, The Java Language Specification specifies that all Unicode letters and digits can be used in identifiers. So the Java Language Specification was updated to refer to new code point-based methods to define the legal characters in identifiers. The javac compiler and other tools that need to detect identifiers were changed to use these new methods.
3, Only where applications interpret individual characters themselves, pass individual characters to Java platform APIs, or call methods that return individual characters, and these character can be supplementary characters, does the application have to be changed
4, You might wonder whether it's better to convert all text into code point representation (say, an int[]) and process it in that representation, or whether it's better to stick with char sequences most of the time and only convert to code points when needed. Well, the Java platform APIs in general certainly have a preference forchar sequences, and using them will also save memory space.
-
Java 使用两个 char 来表示一个非 BMP 字符,而这两个 char 是 BMP 保留区域范围,特地用来表示非 BMP 字符的
javadoc : The Java platform uses the UTF-16 representation in chararrays and in the String and StringBuffer classes. In this representation, supplementary characters are represented as a pair of char values, the first from the high-surrogates range, (\uD800-\uDBFF), the second from the low-surrogates range (\uDC00-\uDFFF)
-
java.lang.Character 提供了大量方法来处理 code point、code unit,这也是 unicode 字符集处理的核心概念
javadoc : In the Java SE API documentation, Unicode code point is used for character values in the range between U+0000 and U+10FFFF, and Unicode code unit is used for 16-bit char values that are code units of the UTF-16 encoding
延伸 - java.lang.String
* String 是通过 char 数组实现,因此它的内部编码、内部表示方式都受到 char 的影响,比如 String 里的 index 都是指的 code point 数,而非字符数;String 的 length()表示的是 code unit 个数,而不是 code point;可以使用 codePointCount()方法获取字符数
Length() javaDoc:
Returns the length of this string. The length is equal to the number of Unicode code units in the string.
-
Java 内部使用了 UTF-16,所以 code point 大小就等于 code union 大小,但其他编码方式这两个概念大小不一
A String represents a string in the UTF-16 format in which supplementary characters are represented by surrogate pairs. Index values refer to char code units, so a supplementary character uses two positions in a String.
// JDK的indexOf实现
// 根据是否是BMP字符返回对应的code point
// 所以UTF-16需要用两个0xDC00到0xDFFF区code point表示增补字符来防止混乱
public int indexOf(int ch, int fromIndex) {
final int max = value.length;
if (fromIndex < 0) {
fromIndex = 0;
} else if (fromIndex >= max) {
// Note: fromIndex might be near -1>>>1.
return -1;
} if (ch < Character.MIN_SUPPLEMENTARY_CODE_POINT) {
// handle most cases here (ch is a BMP code point or a
// negative value (invalid code point))
final char[] value = this.value;
for (int i = fromIndex; i < max; i++) {
if (value[i] == ch) {
return i;
}
}
return -1;
} else {
return indexOfSupplementary(ch, fromIndex);
}
}
复制代码
-
String.getBytes()是一个使用指定编码来将 String 的内码转换为指定外码的方法,因此如果不是指定 UTF-16,和实际 JVM 内存中存储的大小是有区别的
-
根据文件的编码格式,可以使用 String.getBytes("xx")来确定 String 在文件里的大小
-
Java 的 Class 文件中的字符串常量与符号名字也都规定用 UTF-8 编码。这是当时设计者为了平衡运行时的时间效率(采用定长编码的 UTF-16)与外部存储的空间效率(采用变长的 UTF-8 编码)而做的取舍,后续为了保持一致性,JDK 可能会把外部默认编码统一为 UTF-8
延伸 - 其他 API
-
Java 里和字符集编码相关的 API 还是很多的,比如 java.io、java.nio、java.text、java.util.regex 和 java.lang 下最重要的几个 API,基本来说,一般都提供默认字符集编码的方法和指定字符集编码的方法
-
默认字符集编码,也就是 Java 属性 file.encoding 的值,我们可以通过
java.nio.charsets.Charset.defaultCharset()
来查看,或在启动时命令行来指定 -
避免使用 char 类型参数的方法:char 类型的参数在处理补充字符的时候会有问题,因此优先使用 int 类型参数的方法来处理字符
// 都是合法字符,但返回不一样
Character.isLetter('\uD840'); // return false,其实也是合法字符,但char支持不了大于FFFF的字符
Character.isLetter(0x2F81A); // return true
复制代码
-
注意
String.length
和codePointCount
的区别,前者返回的是 code unit,或 char 的个数,后者返回的才是字符的个数 -
删除字符的时候要小心,确认删除的是 char 还是字符,比如
StringBuilder.deleteCharAt
删除的就是 char,因此补充字符会删除一半 -
反转字符的时候要小心,比如
StringBuilder.reverse
反转的是 char,因此补充字符会高低位转换,会不被识别 -
使用 java 里字符或字符串相关的 api 时一定要注意是讨论的 code unit 还是 code point
- String.charAt()
:code unit 或 char
- String.length()
:code unit 或 char
- String.substring()
:code unit 或 char
- String.codePointAt()
:code point
-
接口
CharSequence
里提供了两个默认方法,十分有用
- public default IntStream chars()
- public default IntStream codePoints()