【Java深度干货】如何高效构造字符串(String)?
字符串在 Java 中是不可变的,无论构造,还是截取,得到的总是一个新字符串。下面看一下构造一个字符串(String)的源码:
private final char value[];
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
原有的字符串的 value 数组直接通过引用赋值给新的字符串的 value 数组,也就是两个字符串共享一个 char[],因此这种构造方法有着最快的构造速度。
Java 中的 String 对象被设计为不可变,是指一旦程序获得字符串对象引用,则不必担心这个字符串在别的地方被修改,因为修改总意味着获得一个新的字符串,不可变意味着线程安全,不必担心并发修改。
更多时候是通过一个 char[],或者在某些分布式框架反序列化对象时使用 byte[]来构造字符串的,这种情况下性能会非常低。
以下是通过 char[]构造一个新的字符串的源码:
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
Arrays.copyOf 会重新复制一份新的数组,方法如下:
//Arrays.copyOf
public static char[] copyOf(char[] original, int newLength) {
char[] copy = new char[newLength];
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
可以看到通过 char[]构造字符串,实际上会创建一个新的字符串数组。如果不这样,还是直接引用 char[],那么一旦外部更改 char 数组,则这个新的字符串就被改变了。
char[] cs = new char[]{'a','b'};
String str = new String(cs);
cs[0] ='!'
上面的代码最后一行修改了 cs 数组,但不会影响 str。因为 str 实际上是由新的字符串数组构成的。
通过 char[]构造新的字符串是最常用的方法,后面会看到几乎每个修改的 API,都会调用这个方法构造新的字符串,比如 subString、concat、replace 等。
以下代码验证了通过字符串构造新的字符串,以及使用 char[]构造字符串的性能比较:
String str= "你好,String";
char[] chars = str.toCharArray();
@Benchmark
public String string(){34 │
return new String(str);
}
@Benchmark
public String stringByCharArray(){
return new String(chars);
}
结果按照 ns/op 来输出,即每次调用所用的纳秒数。可以看到通过 char[]构造字符串还是相当耗时的,如果数组特别长,那么更加耗时:
Benchmark Mode Score Units
c.i.c.c.NewStringTest.string avgt 4.235 ns/op
c.i.c.c.NewStringTest.stringByCharArray avgt 11.704 ns/op
通过 byte[]构造字符串是一种常见的情况,随着分布式和微服务的流行,字符串在客户端序列化成 byte[],并发送给服务器端。服务器端会有一个反序列化操作,通过 byte 构造字符串。
使用 byte[]构造字符串的性能测试:
byte[] bs = "你好,String".getBytes("UTF-8");
@Benchmark
public String stringByByteArray() throws Exception{
return new String(bs,"UTF-8");
}
通过测试结果可以看到 byte[]构造字符串太耗时了,尤其是要构造的字符串非常长的时候:
Benchmark Mode Score Units
c.i.c.c.NewStringTest.string avgt 4.649 ns/op
c.i.c.c.NewStringTest.stringByByteArray avgt 82.166 ns/op
c.i.c.c.NewStringTest.stringByCharArray avgt 12.138 ns/op
通过字节数组构造字符串主要涉及转码过程,内部会调用 StringCoding.decode 进行转码:
this.value = StringCoding.decode(charsetName, bytes, offset, length);
charsetName 表示字符集,bytes 是字节数组,offset 和 length 表示字节的起始位置和长度。
实际负责转码的是 charset 子类,比如 sun.nio.cs.UTF_8 的 decode 方法负责实现字节转码。如果深入了解这个类,会发现你看到的是“冰上一角”,“冰”下面的是一个相当消耗 CPU 计算资源的工作,属于无法优化的部分。
Unicode 是字符集,为每一个字符分配一个编号,UTF-8 是一种将字符转为二进制编码的规则。
UTF-8 一种变长字节编码方式,对于某一个字符的 UTF-8 编码,如果只有一个字节,则其最高二进制位为 0;如果是多字节,则其第一个字节从最高位开始,连续的二进制位值为 1 的个数决定了其编码的位数,其余各字节均以 10 开头。
UTF-8 最多可用到 6 个字节,Unicode 在转为 UTF-8 时需要用到下面的模板:
比如“汉”字的 Unicode 码是 6C49,二进制编码是 0110_1100_0100_1001。如果编码成 UTF-8,则需要用到三字节模板,即表格中的最后一行:1110xxxx 10xxxxxx 10xxxxxx。
将 6C49 按照三字节模板的分段方法分为 0110_110001_001001,依次代替模板中的 x,得到 1110-0110 10-11000110-001001,即 E6 B1 89,这就是其 UTF-8 的编码。
在多次的系统性能优化过程中,会发现通过字节数组构造字符串总是排在消耗 CPU 比较靠前的位置,转码消耗的系统性能相当于上百行的业务代码。
因此在设计分布式系统时,需要仔细设计传输的字段,尽量避免用 String,比如时间可以用 long 类型来表示,业务状态可以用 int类型来表示。需要序列化的对象如下:
public class OrderResponse{
//订单日期,格式为 yyyy-MM-dd
private String createDate;
//订单状态,0 表示正常
private String status;
private String payStatus;
}
可以改进成更好的定义,以减小序列化和反序列化的负担:
public class OrderResponse{
//订单日期
private long createDate;
//订单状态,0 表示正常
private int status;
private int payStatus;
}
有的系统为了高效地存储和传输 OrderResponse 对象,甚至会把 status 和 payStatus 合成一个 int 类型的值,高位表示 status,低位表示 payStatus。
内容摘自《高性能Java系统权威指南》第二章
本书特点:
内容上,总结作者从事Java开发20年来在头部IT企业的高并发系统经历的真实案例,极具参考意义和可读性。
对于程序员和架构师而言,Java 系统的性能优化是一个超常规的挑战。这是因为 Java 语言和 Java 运行平台,以及 Java 生态的复杂性决定了 Java 系统的性能优化不再是简单的升级配置或者简单的 “空间换时间”的技术实现,这涉及 Java 的各种知识点。
本书从高性能、易维护、代码增强以及在微服务系统中编写Java代码的角度来描述如何实现高性能Java系统,结合真实案例,让读者能够快速上手实战。
风格上,本书的风格偏实战,读者可以下载书中的示例代码并运行测试。读者可以从任意一章开始阅读,掌握性能优化知识为公司的系统所用。
本书适合:
中高级程序员和架构师;
以及有志从事基础技术研发、开源工具研发的极客阅读;
也可以作为 Java 笔试和面试的参考书。