之前我介绍了 Java
中 String
相关类的知识,了解了为什么 String
类型是不可变的,接下来我们学习一下字符串 API 中的 StringBuilder
和 StringBuffer
看看他们之间的区别是什么。
StringBuilder
首先我们先来使用一下 StringBuilder
// 使用无参构造函数创建
StringBuilder sb0 = new StringBuilder();
sb0.append("hello");
StringBuilder appended0 = sb0.append(" world");
System.out.println(sb0 == appended0); // true
// 使用有参构造函数创建, 传入字符串作为参数
StringBuilder sb1 = new StringBuilder("hello");
StringBuilder appended1 = sb1.append(" world");
System.out.println(sb1 == appended1); // true
// 使用有参构造函数创建, 传入int作为参数, 表示初始容量
StringBuilder sb2 = new StringBuilder(5);
sb2.append("hello");
StringBuilder appended2 = sb2.append(" world");
System.out.println(sb2 == appended2); // true
基础使用:在使用 StringBuilder
类时,先通过构造方法创建出对象,然后使用 append
方法向字符串末尾继续插入新的字符串,这个方法会返回修改之后的 StringBuilder
对象。通过运行结果我们可以看到,所有的修改都是在同一个对象上进行的。
小tips:
- 如果创建
StringBuilder
对象时没有指定初始容量,会默认是16- 如果创建
StringBuilder
对象时传入的是字符串参数,那么初始容量就是 字符串长度 + 16
OK 现在我们来看一下相关的底层实现
这里有一个抽象父类 AbstractStringBuilder
abstract class AbstractStringBuilder {
char[] value;
int count;
}
- 第一个字段和
String
类一样,也是使用了一个char[]
来存储字符串,但是这里并没有使用final
修饰,也就意味着是可以修改的。 - 第二个字段是
count
,这个字段是用来记录数组中的有效数据长度。
然后我们来看一下 append
方法的实现,这里的源码非常简单,小白也能看懂!!!
public StringBuilder append(String var1) {
// 首先会调用父类的 append 方法
super.append(var1);
// 这里也可以发现,最后返回的还是当前对象,而不是一个新的对象
return this;
}
public AbstractStringBuilder append(String var1) {
// 这里会比较有意思,如果我们传入的参数为((String) null)
if (var1 == null) {
// 这里最终会将 null 作为一个字符串加入到字符数组中
return this.appendNull();
} else {
// 获取到传入字符串的长度
int var2 = var1.length();
// 这里是保证字符数组的容量足够容纳新的数据(参数是存入新字符串之后的总字符数)
this.ensureCapacityInternal(this.count + var2);
// 将新的数据存入数组中
var1.getChars(0, var2, this.value, this.count);
// 更新有效数据长度
this.count += var2;
// 返回当前对象
return this;
}
}
private void ensureCapacityInternal(int var1) {
// 判断如果新的字符数量超过了当前字符数组的最大长度,否则就不管
if (var1 - this.value.length > 0) {
// 扩容操作:计算出新的长度,然后扩容
this.value = Arrays.copyOf(this.value, this.newCapacity(var1));
}
}
private int newCapacity(int var1) {
// 计算新长度时使用到了位运算,这里等同于 => 原来的数组大小 * 2 + 2
int var2 = (this.value.length << 1) + 2;
// 如果新长度计算之后依然小于修改后的字符串总长度
if (var2 - var1 < 0) {
// 就把字符串长度设置为修改后的字符串总长度
var2 = var1;
}
// 校验然后返回这个长度
return var2 > 0 && 2147483639 - var2 >= 0 ? var2 : this.hugeCapacity(var1);
}
public static char[] copyOf(char[] var0, int var1) {
// 创建一个新的字符串数组对象,将原数据拷贝到新数组中,返回这个新数组
char[] var2 = new char[var1];
// 这个是一个 native 本地方法,实现数组的拷贝
System.arraycopy(var0, 0, var2, 0, Math.min(var0.length, var1));
return var2;
}
public void getChars(int var1, int var2, char[] var3, int var4) {
// 前面都是参数校验
if (var1 < 0) {
throw new StringIndexOutOfBoundsException(var1);
} else if (var2 > this.value.length) {
throw new StringIndexOutOfBoundsException(var2);
} else if (var1 > var2) {
throw new StringIndexOutOfBoundsException(var2 - var1);
// 如果参数无误,也是使用这个 native 方法将新数据存入数组中
} else {
System.arraycopy(this.value, var1, var3, var4, var2 - var1);
}
}
我猜这个时候有的友友们会有一个疑问:
数组长度不够的话扩容的时候不是会创新一个新的数组吗,那后面的操作是在新的数组上进行的呀,这样看来不应该也是不可变的吗???
这个问题就非常好了,说明友友们很爱思考,但是我们需要注意的是,我们所说的在原对象上进行修改其实指的是除了扩容操作之外的修改操作,扩容操作本事不是一个修改操作,它并没有插入或者删除数据,它只是让整个数组的容量变得更大,可以装入更多数据,如果还是不能理解的话,那我们不访在创建对象的时候使用带有初始容量参数的构造方法,一开始就将数组的长度设置得很大很大,大到根本用不完,那么在后续的使用中我们也就不需要进行扩容,那么任何修改操作就是在一开始创建的字符串数组上进行的了,这样是不是就能理解了。当然我们在写代码时也是要进行预估衡量的,初始容量太小可能会导致后期频繁扩容,这对性能上是有一定的损耗的,但是也不能够一开始就创建一个大得用不完的对象,这样会占用太多的堆内存,也是会影响程序的性能的。
这下友友们懂了 StringBuilder
是如何做到字符串可变的吧。
StringBuffer
我们直接切入正题, StringBuilder
和 StringBuffer
的区别主要在以下几个方面:
-
线程安全性
我们知道String
类是使用了final
定义的字符串数组,所以一旦被创建,就无法被修改,非常适合多线程环境,但是对于StringBuilder
类,由于是可以被修改的,那么在多线程环境下在不使用任何线程同步机制的情况下就会出现可见性问题。而StringBuffer
的出现就是为了解决这个问题,它综合了前两者的优点,既能修改,又是线程安全类,那它是怎样实现线程安全的呢?// 使用了 synchronized 锁 public synchronized StringBuffer append(String var1) { // 将缓存字符数组置为空 this.toStringCache = null; // 和 StringBuilder 是一样的 super.append(var1); return this; }
可以看到在
append
方法上使用到了 synchronized 锁,它可以保证在任何时间点都只能有一个线程可以进行后面的操作,只有当占有锁的线程修改结束释放掉锁之后,其他的线程才有机会拿到这个锁进行修改操作。在单线程的条件下StringBuilder
的性能会显著高于StringBuffer
。 -
缓存字符数组
在StringBuffer
中有一个额外的字段,toStringCache
,他的作用是在调用toString
方法时,直接利用缓存字符串数组创建一个新的String
对象返回。private transient char[] toStringCache; public synchronized String toString() { // 如果缓存字符串数组为空 if (this.toStringCache == null) { // 更新缓存字符串数组的值 this.toStringCache = Arrays.copyOfRange(this.value, 0, this.count); } // 这个构造方法是将传入的字符串数组直接赋给 String 对象中的字符串数组 return new String(this.toStringCache, true); } String(char[] var1, boolean var2) { this.value = var1; }
我们来看看
String
中的toString
方法实现:public String toString() { return new String(this.value, 0, this.count); } public String(char[] var1, int var2, int var3) { if (var2 < 0) { throw new StringIndexOutOfBoundsException(var2); } else { if (var3 <= 0) { if (var3 < 0) { throw new StringIndexOutOfBoundsException(var3); } if (var2 <= var1.length) { this.value = "".value; return; } } if (var2 > var1.length - var3) { throw new StringIndexOutOfBoundsException(var2 + var3); } else { this.value = Arrays.copyOfRange(var1, var2, var2 + var3); } } }
这里我们需要注意的是两个
toString
方法创建字符串时调用的构造方法是不一样的,一个是使用提前就准备好的字符串数组,而另外的一个则是重新使用Arrays.copyOfRange()
方法得到一个新的字符串数组,所以相比之下,在多次连续重复调用(因为修改会导致缓存字符串数组重置)toString
方法的前提下,前者的性能明显会高于后者。
大概分享的就是这么多了,希望友友们能收获到新东西,如果哪里不对希望在评论区指出,谢谢。
本文中提到的 synchronized
锁知识如果有时间我会再写两篇相关的博客跟大家分享。