String字符串是我们日常使用频率最为频繁的数据类型之一,以正确高效的方式使用String字符串,是提升程序运行性能的手段之一。下面将从几个示例中给出具体的使用方式。
String字符串的特性
我们先从String的源代码入手,如下所示:
//源码基于 JDK 1.8
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
// String 值的实际存储容器,用final修饰
private final char value[];
public String() {
this.value = "".value;
}
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
// 忽略其他信息
}
从以上源码可以看出,String 这个类 以及他的 值 value[ ]都被 final修饰了,我们知道,被 final修饰的类是不能被继承的,即其不能拥有子类,再者,其值value[ ]也被final修饰了,而被final修饰的变量称为常量,只能赋值一次;这就是说,String一旦被创建之后,就不能再被修改了。
String为什么不能被修改呢?
String的这个类以及他的值value[ ]都被 final 修饰了,这样做的好处有以下几点:
1.避免网络安全问题
如果字符串是可变的,那么会引起很严重的安全问题。譬如,数据库的用户名、密码都是以字符串的形式传入来获得数据库的连接,或者在socket编程中,主机名和端口都是以字符串的形式传入。因为字符串是不可变的,所以它的值是不可改变的,否则黑客们可以钻到空子,改变字符串指向的对象的值,造成安全漏洞。
2.线程安全
因为字符串是不可变的,所以是多线程安全的,同一个字符串实例可以被多个线程共享。这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。
3.处理速度快
String 不可变之后就保证的 hash 值的唯一性,这样它就更加高效,并且更适合做 HashMap 的 key- value 缓存。
4.节约内存
String 的不可变性是它实现字符串常量池的基础,字符串常量池指的是字符串在创建时,先去“常量池”查找是否有此“字符串”,如果有,则不会开辟新空间创作字符串,而是直接把常量池中的引用返回给此对象,这样就能更加节省空间。
示例1:字符串少用“+”号进行拼接
通过上面的内容,我们知道了 String 类是不可变的,那么在使用 String 时就不能频繁的用 “+“ 拼接字符串了,因为这样JVM内存中会增加很多无引用的对象,导致垃圾回收频繁,程序性能会受影响。
官方为我们提供了两种字符串拼加的方案:StringBuffer 和 StringBuilder,其中 StringBuilder 为非线程安全的,而 StringBuffer 则是线程安全的,StringBuffer 的拼加方法使用了关键字 synchronized 来保证线程的安全,源码如下:
@Override
public synchronized StringBuffer append(CharSequence s) {
toStringCache = null;
super.append(s);
return this;
}
当然也因为使用 synchronized 修饰,所以 StringBuffer 的拼加性能会比 StringBuilder 低。
我们通过代码测试一下,“+”号拼接跟StringBuilder拼接之间的性能差别:
public class StringTest {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
// 直接“+”号进行拼接
long st1 = System.currentTimeMillis(); // 开始时间
doAdd();
long et1 = System.currentTimeMillis(); // 结束时间
System.out.println("String 拼加,执行时间:" + (et1 - st1));
// 使用StringBuilder进行拼接
long st2 = System.currentTimeMillis(); // 开始时间
doAppend();
long et2 = System.currentTimeMillis(); // 结束时间
System.out.println("StringBuilder 拼加,执行时间:" + (et2 - st2));
System.out.println();
}
}
public static String doAdd() {
String result = "";
for (int i = 0; i < 1000; i++) {
result += "String";
}
return result;
}
public static String doAppend() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append("String");
}
return sb.toString();
}
}
执行结果如下:
String 拼加,执行时间:325
StringBuilder 拼加,执行时间:1
从结果可以看出,优化前后的性能相差很大。
所以为什么 StringBuilder.append() 方法比 += 的性能高?我们打开源码就知道其中的原因,如下所示为StringBuilder 父类 AbstractStringBuilder 的实现源码:
abstract class AbstractStringBuilder implements Appendable, CharSequence {
char[] value;//可见value值是可变的
int count;
@Override
public AbstractStringBuilder append(CharSequence s, int start, int end) {
if (s == null)
s = "null";
if ((start < 0) || (start > end) || (end > s.length()))
throw new IndexOutOfBoundsException(
"start " + start + ", end " + end + ", s.length() "
+ s.length());
int len = end - start;
ensureCapacityInternal(count + len);
for (int i = start, j = count; i < end; i++, j++)
value[j] = s.charAt(i);//修改value数组
count += len;
return this;
}
// 忽略其他信息...
}
由上可见,StringBuilder 使用了父类提供的char[ ]作为自己值的实际存储单元,每次在拼加时只需修改char[ ]数组即可,不用额外去创建新的String对象,所以StringBuilder的性能就会高很多。
示例2:善用String.intern() 方法
善用 String.intern() 方法可以有效的节约字符串所占内存,当调用 intern 方法时,如果字符串常量池中已经包含此字符串,则直接返回此字符串的引用,如果不包含此字符串,先将字符串添加到常量池中,再返回此对象的引用。
但是性能效率相比于直接new()创建会有所下降,可以算是一种时间换空间的方法,所以需要考虑具体时间成本以及空间成本对于企业的权重,即要根据实际业务场景需求进行选择使用~
示例3:谨慎使用String.split() 方法
在实际应用场景中,因为 Split 方法大多数情况下使用的是正则表达式,这种分割方式本身没有什么问题,但是由于正则表达式的性能是非常不稳定的,使用不恰当会引起回溯问题,很可能导致 CPU 居高不下。
为什么回溯问题会引起CPU使用居高不下?首先,Java 正则表达式使用的引擎实现是 NFA(Non deterministic Finite Automaton,不确定型有穷自动机)自动机,这种正则表达式引擎在进行字符匹配时会发生回溯(backtracking),而一旦发生回溯,那其消耗的时间就会变得很长,有可能是几分钟,也有可能是几个小时,时间长短取决于回溯的次数和表达式的复杂度。
回溯问题的一个示例:
text = "abbc";
regex = "ab{1,3}c";
上面的这个例子的目的比较简单,匹配以 a 开头,以 c 结尾,中间有 1-3 个 b 字符的字符串。
而NFA 引擎对其解析的过程是这样子的:
- 首先,读取正则表达式第一个匹配符 a 和 字符串第一个字符 a 比较,匹配上了,于是读取正则表达式第二个字符;
- 读取正则表达式第二个匹配符 b{1,3} 和字符串的第二个字符 b 比较,匹配上了。但因为 b{1,3} 表示 1-3 个 b 字符串,以及 NFA 自动机的贪婪特性(也就是说要尽可能多地匹配),所以此时并不会再去读取下一个正则表达式的匹配符,而是依旧使用 b{1,3} 和字符串的第三个字符 b 比较,发现还是匹配上了,于是继续使用 b{1,3} 和字符串的第四个字符 c 比较,发现不匹配了,此时就会发生回溯;
- 发生回溯后,我们已经读取的字符串第四个字符 c 将被吐回去,指针回到第三个字符串的位置,之后程序读取正则表达式的下一个操作符 c,然后再读取当前指针的下一个字符 c 进行对比,发现匹配上了,于是读取下一个操作符,然后发现已经结束了。
所以如果正则表达式编写不当,会使NFA引擎频繁发生回溯,最终导致CPU占用过高、消耗时间过多等问题,而且当需要匹配的字符串特别长时候,一个一个字符回溯回去,更会加重性能下降的现象。
所以我们应该慎重使用 Split() 方法,我们可以用 String.indexOf() 方法代替 Split() 方法完成字符串的分割。如果实在无法满足需求,就在使用 Split() 方法时,对回溯问题加以重视就可以了。