Java 中字符集的编解码

我们来看看在 Java 7/8 中字符集编码和解码的性能。先看看下面两个 String 方法在不同字符集下的性能:


/* String to byte[] */

public byte[] getBytes(Charset charset);

/* byte[] to String */

public String(byte bytes[], Charset charset);


我把“Develop with pleasure”通过谷歌翻译为德语、俄语、日语和繁体中文。我们将根据这些短语构建指定大小的块,通过使用“\n”作为分隔符来连接它们直到到达指定的长度(在大多数情况下,结果会稍长一些)。在那之后我们将 100M 字符的 byte[] 数据转化为 String 数据(100M 是 Java 中 char 字符的总长度)。我们将转换 10 遍以确保结果更加可靠(因此,在下表中是转换 10 亿字符的时间)。


我们将使用 2 个块的大小:100 个字符用于测试短字符串转换的性能,100M 字符用来测试最初的转换性能,你可以在本文末尾找到文章的源代码。我们会用 UTF-8 的方式与“本地化的”字符集进行比较(英语 US-ASCII、德语 ISO-8859-1、俄语 windows-1251、日语 Shift_JIS、繁体中文 GB18030),将 UTF-8 作为通用编码时这些信息会非常很有(通常意味着更大的二进制转换开销)。我们也会对比 Java 7u51 和 Java 8(的版本特性)。为了避免 GC 带来的影响,所有测试都是在我搭载 Xmx32G 的 Xeon-2650(2.8Ghz)工作站上运行。


以下是测试结果。每个实例有两个时间结果:Java7 的时间(和 Java8 的时间)。”UTF-8″这一行遵循了每个“本地化的”字符集,它包含从前一行数据的转换时间(例如,最后一行包括了 string 从繁体中文转为 UTF-8 的编码、解码的时间)。


Charset getBytes, ~100 chars (chunk size) new String, ~100 chars (chunk size) getBytes, ~100M chars new String, ~100M chars
US-ASCII 2.451 sec(2.686 sec) 0.981 sec(0.971 sec) 2.402 sec(2.421 sec) 0.889 sec(0.903 sec)
UTF-8 1.193 sec(1.259 sec) 0.974 sec(1.181 sec) 1.226 sec(1.245 sec) 0.887 sec(1.09 sec)
ISO-8859-1 2.42 sec(0.334 sec) 0.816 sec(0.84 sec) 2.441 sec(0.355 sec) 0.761 sec(0.801 sec)
UTF-8 3.14 sec(3.534 sec) 3.373 sec(4.134 sec) 3.288 sec(3.498 sec) 3.314 sec(4.185 sec)
windows-1251 5.85 sec(5.826 sec) 2.004 sec(1.909 sec) 5.881 sec(5.747 sec) 1.902 sec(1.87 sec)
UTF-8 5.425 sec(5.256 sec) 11.561 sec(12.326 sec) 5.544 sec(4.921 sec) 11.29 sec(12.314 sec)
Shift_JIS 17.343 sec(9.355 sec) 24.85 sec(8.464 sec) 16.95 sec(9.24 sec) 24.6 sec(8.503 sec)
UTF-8 9.398 sec(13.201 sec) 12.007 sec(16.661 sec) 9.681 sec(11.801 sec) 12.035 sec(16.602 sec)
GB18030 18.754 sec(16.641 sec) 15.877 sec(16.267 sec) 18.494 sec(16.342 sec) 16.034 sec(16.406 sec)
UTF-8 9.374 sec(11.829 sec) 12.092 sec(16.672 sec) 9.678 sec(12.991 sec) 12.25 sec(16.745 sec)


测试结果


我们可以注意到以下事实:


  • 这里几乎没有 CPU 开销的分块输出——如果你为这个测试分配更少的内存,那么分块结果将变得更糟。

  • 如果是单字节字符集,那么将 byte[] 转换为 String 将非常快(US-ASCII、ISO-8859-1 和 windows-1251):一旦知道输入数据的大小,那么就可以分配结果中 char[] 的合适大小。同时,如果是在 java.lang 包中,可以使用一个受保护的 String 构造函数,这并不需要 char[] 的拷贝。

  • 同时,String.getBytes(UTF-8) 对于 non-ASCII 编码不能高效地工作——包括更复杂的映射,它分配了最大可能的 char[] 输出,然后复制实际使用的部分给 String 的返回结果。UTF-8 转换中文 / 日文的速度确实非常慢。

  • 如果是“本地化的”字符集,String -> byte[] 的转换效率通常是低于 byte[] -> String 的。出人意料的是,在使用 UTF-8 时会观察到相反的结果:String -> byte[] 普遍快于 byte[] -> String。

  • Shift_JIS 和 ISO-8859-1 的转换(可能也包括一些其它字符集)在 Java 8 中进行了极大的优化(绿色高亮):相比 Java 7,Java8 对日语转换的速度要快 2-3 倍。在 ISO-8859-1 的情况下,只有 String -> byte[] 进行了优化——它的运行速度比现在要快七倍!这个结果听起来确实令我吃惊(请接着往下看)。

  • 一个更加明显的区别是:byte[] -> String 对于 windows-1251 与 UTF-8 编码转换时间的比较(红色高亮)。它们大约相差六倍(windows-1251 比 UTF-8 快六倍)。我不确定是否有可能证明它只是由不同的二进制表示:如果使用 windows-1251,每个字符你需要 1 个字节的消耗;而如果使用 UTF-8,对于俄语字符集则是每个字符两个字节。ISO-8859-1 和 UTF-8 之间是有大同小异的地方的(蓝色高亮): 在德语字符串中只有一个字符不需要用 2 个 UTF-8 字符表示。而在俄语字符串中,(除空格外)几乎每个字符都需要 2 个 UTF-8 字符。


直接由 String->byte[]->String 转换为 ASCII / ISO-8859-1 数据

我尝试过研究 Java 8 中的 ISO-8859-1 编码器的表现。其算法本身非常简单,ISO-8859-1 字符集完全匹配 Unicode 表中前 255 个字符的位置,所以看起来像下面这样:


if (char <= 255)

    write it as byte to output

else

    skip input char, write Charset.replacement byte


Java 7 和 8 中 ISO_8859_1.java 的不同之处,Java 7 在单一方法中包含了各种优先权编码逻辑,但是 Java 8 提供了帮助方法(Helper Method)。当没有字符大于 255 时,将输入的 char[] 进行转换。我认为这种方法使得 JIT 产生更多高效的代码。


众所周知,US-ASCII 或者 ISO-8859-1 的编码器优于 JDK 编码器。只需要假设字符串仅包含有效的字符编码并且避免所有的“管道(plumbing)”:


private static byte[] toAsciiBytes( final String str)

{

    final byte[] res = new byte[ str.length() ];

    for (int i = 0; i < str.length(); i++)

        res[i] = (byte) str.charAt(i);

    return res;

}


这种方式取代了 Java 8 中 20-25% 的 ISO-8859-1 编码器,同时效率是 Java 7 的 3 到 3.5 倍。然而,它依赖 JIT 来进行数据访问和 String.charAt 的边界检查。


对于这两个数据集,取代 byte[] -> String 转换几乎是不可能的。因为没有公共的 String 构造函数或工厂方法,这将使用你提供的 char[] 类型。它们都进行了保护性的备份(否则将无法保证 String 的不变性)。性能方面最接近的是一个被弃用的 String(byte ascii[], int hibyte, int offset, int count)构造函数。如果你的字符集匹配的是一个 255 字节的 Unicode(US-ASCII, ISO-8859-1),那么对于 byte[]->String 编码器而言是非常有用的。不幸的是,这个构造函数从字符串结尾开始复制数据,并不像 CPU 缓存那么友好。


private static String asciiBytesToString(final byte[] ascii )

{

    //deprecated constructor allowing data to be copied directly into String char[]. So convenient...

    return new String(ascii, 0);

}


另一方面,String(byte bytes[],int offset, int length, Charset charset)减少了所有可能的边界类型(edge):对于 US-ASCII 和 ISO-8859-1,它分配了 char[] 所需的大小,进行一次低成本转换(使 byte 变为 char)同时提供 char[] 转为 String 构造函数的结果,在这种情况下就要信任编码器了。


总结


  • 首选 windows-1252 或者 Shift_JIS 这样的本地字符集,其次才是 UTF-8:(一般来说)它们生产更紧凑的二进制数据,并且速度比编、解码更快(在 Java 7 中有一些例外,但在 Java 8 中成为了一条规则)。

  • ISO-8859-1 在 Java 7 和 8 中总是快于 US-ASCII:如果你没有充足的理由使用 US-ASCII,请选择 ISO-8859-1。

  • 你可以写一个非常快速的 String->byte[] 进行 US-ASCII/ISO-8859-1 的转换,但是你并不能取代 Java 解码器——它们直接访问并创建 String 输出。