String对象是我们日常工作中使用最频繁的对象,它的性能问题也是我们最容易忽略的。String对象作为Java语言中最重要的数据类型,是内存中占据空间最大的对象,高效地使用字符串,可以提升系统的整体性能。
今天这篇文章我们从String对象的实现、特性以及实际使用中的优化三方面,来深入了解String对象。
String对象是如何实现的
在Java更新的版本变化中,对String对象已经做了大量的优化,来节约内存空间,提升String对象在系统中的性能。来看看在Java版本迭代中String的优化过程;
- 在Java6以及以前的版本中,String对象是对char数组进行了封装实现的对象,主要有四个成员变量:char数组、偏移量offset、字符数量count、哈希值hash。
- 在Java7和8版本中,Java对String类做了改变,不再有offset和count两个变量,这样可以稍微减少String对象占用的内存。同时,String.substring()不再共享char[],从而解决了使用该方法可能导致的内存泄露问题。
- 从Java9版本开始,char[]改成了byte[],有维护了一个新的属性coder,它是一个编码格式的标识。
为什么从char[]改变成byte[],我们都知道一个char字符占用16位,2个字节,这种情况在存储单字节编码内的字符就有点浪费。Java9中String类为了更加节约内存空间,选择了占用8位,1字节的byte数组来存放字符串。
新属性coder的作用是在计算字符串长度或者使用indexOf()函数时,我们需要根据这个字段,判断如何计算字符串长度。coder属性默认有0和1两个值,0代表Latin-1(单字节编码),1代表UTF-16。如果String判断字符串只包含了Latin-1,则coder属性值为0,反之则为1.
String对象的不可变性
我们发现在String对象实现中, 不仅实现代码的String类被final关键字修饰,而且遍历charp[]也被final修饰。类被final修饰代表String类不能被继承,而charp[]被private和final修饰,代表了String对象不可被更改。Java实现的这个特性叫做String对象的不可变性,即String对象一旦被创建成功就不能进行修改了。
String对象不可变性有哪些好处?
- 保证了String对象的安全性。如果 String 对象是可变的,那么 String 对象将可能被恶意修改。
- 保证了hash属性值不会频繁变更,确保了唯一性,使得类似 HashMap 容器才能实现相应的 key-value 缓存功能。
- 可以实现字符串常量池。Java中有两种创建字符串对象的方式,一种是通过字符串常量的方式创建,另外一种是字符串变量通过new的形式创建。
当我们使用字符串常量创建字符串对象时,JVM会先检查该对象是在字符串常量池中,如果在就返回该对象的引用,否则新创建一个字符串对象保存到字符串常量池,并使用这个引用。这种方式可以减少同一个值的字符串对象的重复创建,节约了内存空间。
当我们使用new的形式创建,比如String str = new String(“abc”),在编译类文件的时候,“abc”常量字符串将会放入常量结构中,在类加载时,“abc”将会放到常量池中创建,在调用new时,JVM命令将会调用String的构造函数,同时引用常量池中的“abc"字符串,在堆内存中创建一个String对象,最后 str引用String对象。
String对象的优化
上边我们了解了String对象实现原理和特性,下边将结合实际场景,看看String对象在我们实际使用中有哪些需要注意的地方。
1. 字符串拼接
在编程过程中,字符串的拼接很常见。前边我们也说了String对象是不可变的,如果我们使用String对象相加,拼接我们想要的字符串,就会产生多个对象。例如如下代码:
String str = "ab" + "cd" + "ef";
分析可知,首先会生成ab对象,再生成abcd对象,最后生成abcdef对象,从理论上讲,这样做的效率很低。但是实际运行中,我们发现只有一个对象生成,这是为什么?我们再来看看编译后的代码,你会发现上边的代码编译器自动做了优化,如下:
String str = "abcdef";
上面介绍的是字符串常量的累加,再来看看字符串变量的累加:
String str = "abcdef";
for (int i = 0; i<1000;i++) {
str = str + i;
}
编译后,我们可以看到编译器同样对这段代码进行了优化,Java在进行字符串拼接时,偏向于使用StringBuilder,这样可以提高程序的效率。
String str = "abcdef";
for(int i=0; i<1000; i++) {
str = (new StringBuilder(String.valueOf(str))).append(i).toString();
}
我们可以看到即使使用‘’+”号作为字符串拼接,也一样被编译器优化成StringBuilder的方式,但是,仔细一看,你会发现编译器优化后的代码,每次循环的时候都会生成一个新的StringBuilder对象,同样会降低系统的性能。
我们平时做字符串拼接的时候,建议显示地使用StringBuilder来提升系统性能,如果是多线程编程中,String对象的拼接涉及到线程安全,可以使用StringBuffer。由于StringBuffer是线程安全的,涉及到锁竞争,所以从性能上说,要比StringBuilder差一些。
2. 使用String.intern节省内存
在每次赋值的时候使用 String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用,为了更好的理解,我们举一个简单的例子来看一下:
String a =new String("abc").intern();
String b = new String("abc").intern();
if(a==b) {
System.out.print("a==b");
}
输出结果,a==b
,分析一下;
创建 a 变量时,调用 new Sting() 会在堆内存中创建一个 String 对象,String 对象中的 char 数组将会引用常量池中字符串。在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
创建 b 变量时,调用 new Sting() 会在堆内存中创建一个 String 对象,String 对象中的 char 数组将会引用常量池中字符串。在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
而在堆内存中的两个对象,由于没有引用指向它,将会被垃圾回收。所以 a 和 b 引用的是同一个对象。
如果在运行时,创建字符串对象,将会直接在堆内存中创建,不会在常量池中创建。所以动态创建的字符串对象,调用 intern 方法,在 JDK1.6 版本中会去常量池中创建运行时常量以及返回字符串引用,在 JDK1.7 版本之后,会将堆中的字符串常量的引用放入到常量池中,当其它堆中的字符串对象通过 intern 方法获取字符串对象引用时,则会去常量池中判断是否有相同值的字符串的引用,此时有,则返回该常量池中字符串引用,跟之前的字符串指向同一地址的字符串对象。
用一张图来总结String字符串的创建和分配内存地址的情况:
使用 intern 方法需要注意的一点是,一定要结合实际场景。因为常量池的实现是类似于一个 HashTable 的实现方式,HashTable 存储的数据越大,遍历的时间复杂度就会增加。如果数据过大,会增加整个字符串常量池的负担。
3. 如何使用字符串的分割方法
分割字符串我们常用的方法就是Split()方法,Split()方法使用了正则表达式实现了其强大的分割能力,但是正则表达式的性能是很不稳定的,使用不当就会引起回溯问题,很有可能导致CPU居高不下。
在日常使用的时候,可以用String.indexOf()方法代替Split()方法完成对字符串的分割,如果无法满足需要,在使用Split()方法的时候对回溯问题要加以重视。
最后
以一个小问题结束本次分享,下边每组匹配的两个对象是否相等?
String str1= "abc";
String str2= new String("abc");
String str3= str2.intern();
assertSame(str1==str2);
assertSame(str2==str3);
assertSame(str1==str3)