【校招VIP】StringBuilder&StringBuffer常见面试题

08月04日 收藏 0 评论 0 java开发

【校招VIP】StringBuilder&StringBuffer常见面试题

转载声明:文章来源:https://blog.csdn.net/congyan009/article/details/124248418

 一.StringBuilder和StringBuffer区别?

这是大家面试中常问的,我也都是这样背的,StringBuffer是线程安全的,它相关方法都加了synchronized 关键字,StringBuilder线程不安全。没错,确实如此,但是我们查看过源码会发现StringBuffer是从jdk1.0就开始了,StringBuilder是从jdk1.5开始的。于是有这样一个疑问,既然已经有了StringBuffer,为什么jdk5又出了一个StringBuilder,也就是单线程时候StringBuffer与StringBuilder到底有什么区别。

我们知道StringBuilder和StringBuffer的内部实现跟String是一样的,是通过一个char类型的数组进行存储字符串的,不同的是String类中的char数组是final修饰的,是不可变的,而StringBuilder和StringBuffer中的char数组没有被final修饰,是可变的。

public final class String
implements java.io.Serializable, Comparable, CharSequence {
/** The value is used for character storage. */
private final char value[]; //final修饰
public final class StringBuilder
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence
{
public final class StringBuffer
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence
{

在类的基本层面我们看StringBuilder和StringBuffer都是完全一致的

二.以StringBuilder为例有以下Java代码

StringBuilder stringBuilder=new StringBuilder();
stringBuilder.append("66");
System.out.println(stringBuilder.toString());

看StringBuilder的构造方法,默认容量是16

@Override
public StringBuilder append(String str) {
super.append(str);
return this;
}

然后进入到AbstractStringBuilder,创建了一个容量为capacity的char数组

AbstractStringBuilder(int capacity) {
value = new char[capacity];
}

然后执行stringBuilder.append("66");

public AbstractStringBuilder append(String str) {
if (str == null)//当添加null时吗,执行添加"null"字符串
return appendNull();
int len = str.length();//要添加的字符串长度
ensureCapacityInternal(count + len);//扩容(当前长度+新串长度)
str.getChars(0, len, value, count);
count += len;
return this;
}

扩容代码

private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0) {//需要的容量大于当前容量
value = Arrays.copyOf(value,
newCapacity(minimumCapacity));//扩容后的新数组
}
}

扩容代码

private int newCapacity(int minCapacity) {
// overflow-conscious code
int newCapacity = (value.length << 1) + 2//当前容量*2+2
if (newCapacity - minCapacity < 0) {//如果新容量小于所需容量,新容量设置为所需容量
newCapacity = minCapacity;
}
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity)
: newCapacity;//这里其实是int最大值的兜底
}

三.单线程StringBuffer与StringBuilder区别

单线程下StringBuffer加了synchronized,虽然是单线程, 但是synchronized获取锁和释放锁也还是需要时间的, 而StringBuilder没有,这个就是重点区别。因此重点要讨论synchronized锁的状态,从获取锁到释放锁的过程,因此需要讨论一下锁的升级和优化。

锁的4种状态:无锁、偏向锁、轻量级锁、重量级锁

(1)偏向锁:

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

为什么要引入偏向锁?

因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。

偏向锁的升级

当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。

(2)轻量级锁

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能

为什么要引入轻量级锁?

轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。

(3)重量级锁

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

轻量级锁什么时候升级为重量级锁?

线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;

如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。

但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。

*注意:为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。一句话就是锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。

综上可知,StringBuffer虽然是单线程,但它是有偏向锁升级过程判断的,会耗费时间,效率固然低于StringBuilder

四、StringBuffer与StringBuilder的应用场景

1、StringBuffer多线程安全,但是加了synchronized,其效率低。故适用于多线程下,并发量不是很高的场景

2、StringBuilder没有加任何锁,其效率高,适用单线程场景,但同时也适用于高并发场景中,提高高并发场景下程序的响应性能,至于线程安全问题可以通过其它手段解决,如ThreadLocal,CAS操作等。

3、所以对于高并发场景下,若有用到二者,还是建议优先使用StringBuilder的

五、我们来看一个StringBuilder不安全的例子

public static void main(String[] args) throws InterruptedException {
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < 100; i++) {
new Thread(() -> {
for (int j = 0; j < 100; j++) {
stringBuilder.append("a");
}
}).start();
}

Thread.sleep(500L);
System.out.println(stringBuilder.length());
}

Exception in thread "Thread-3" java.lang.ArrayIndexOutOfBoundsException
at java.lang.System.arraycopy(Native Method)
at java.lang.String.getChars(String.java:826)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:449)
at java.lang.StringBuilder.append(StringBuilder.java:136)
at com.glodon.estate.analysis.service.AnalysisApplication.lambda$main$0(AnalysisApplication.java:38)
at java.lang.Thread.run(Thread.java:748)
9843

这里创建了100个线程,每个线程循环100次往StringBuilder对象里append字符

正常情况应该输出10000,但是实际上为什么会抛出异常而且输出9843呢

这个错不是必现的,概率大概20%吧

1.为什么输出值和预期不一样

首先看一下StringBuilder和StringBuffer的两个成员变量,这两个成员变量存在于AbstractStringBuilder类中,因为StringBuilder和StringBuffer都继承了AbstractStringBuilder

//存储字符串的具体内容
char[] value;
//已经使用的字符数组的数量
int count;

在看一下StringBuilder的append方法:

@Override
public StringBuilder append(String str) {
super.append(str);
return this;
}

StringBuilder的append()方法调用的父类AbstractStringBuilder的append()方法

public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}

count += len不是一个原子操作。假设这个时候count值为10,len值为1,两个线程同时执行到了第七行,拿到的count值都是100,执行完加法运算后将结果赋值给count,所以两个线程执行完后count值为101,而不是102。这就是为什么测试代码输出的值要比10000小的原因。

2、为什么会抛出ArrayIndexOutOfBoundsException异常

我们看回AbstractStringBuilder的append()方法源码,ensureCapacityInternal()方法是检查StringBuilder对象的原char数组的容量能不能盛下新的字符串,如果盛不下就调用expandCapacity()方法对char数组进行扩容。

private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0)
expandCapacity(minimumCapacity);
}

扩容的逻辑就是new一个新的char数组,新的char数组的容量是原来char数组的两倍再加2,再通过System.arryCopy()函数将原数组的内容复制到新数组,最后将指针指向新的char数组。

void expandCapacity(int minimumCapacity) {
//计算新的容量
int newCapacity = value.length * 2 + 2;
//中间省略了一些检查逻辑
...
value = Arrays.copyOf(value, newCapacity);
}

Arrys.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;
}

AbstractStringBuilder的append()方法源码的第六行,是将String对象里面char数组里面的内容拷贝到StringBuilder对象的char数组里面,代码如下:

str.getChars(0, len, value, count);

getChars()方法

public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
//中间省略了一些检查
...
System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
}

拷贝流程见下图

假设现在有两个线程同时执行了StringBuilder的append()方法,两个线程都执行完了第五行的ensureCapacityInternal()方法,此刻count=5。

这个时候线程1的cpu时间片用完了,线程2继续执行。线程2执行完整个append()方法后count变成6了

线程1继续执行第六行的str.getChars()方法的时候拿到的count值就是6了,执行char数组拷贝的时候就会抛出ArrayIndexOutOfBoundsException异常。



C 0条回复 评论

帖子还没人回复快来抢沙发