由String,StringBuilder,StringBuffer引起的面试惨案

pexels-pixabay-60504

前言

  String,StringBuilder,StringBuffer的区别是啥?这个面试题估计每个JAVA都应该碰到过吧。依稀记得第一次面试的时候,面试官问我这个问题时,心想着能有啥区别不都是拼接字符串嘛。深入了解这个问题后,发现并不简单?

前菜

面试官:你好,你是不一样的科技宅是吧?

小宅:面试官你好,我是不一样的科技宅。

面试官:你好,麻烦做一个简单的自我介绍吧。

小宅:我叫不一样的科技宅,来自xxx,做过的项目主要有xxxx用到xxx,xxx技术。

20180719930824_lrEKIh
20180719930824_lrEKIh

面试官:好的,对你的的履历有些基本了解了,那我们先聊点基础知识吧。

小宅:内心OS(放马过来吧)

开胃小菜

面试官:String,StringBuilder,StringBuffer的区别是啥?

小宅:这个太简单了吧,这是看不起我?

  • 从可变性来讲String的是不可变的,StringBuilder,StringBuffer的长度是可变的。
  • 从运行速度上来讲StringBuilder > StringBuffer > String。
  • 从线程安全上来StringBuilder是线程不安全的,而StringBuffer是线程安全的。

  所以 String:适用于少量的字符串操作的情况,StringBuilder:适用于单线程下在字符缓冲区进行大量操作的情况,StringBuffer:适用多线程下在字符缓冲区进行大量操作的情况。

75f3cd331b7ab4ca4552847a746952da
75f3cd331b7ab4ca4552847a746952da

面试官:为什么String的是不可变的?

小宅:因为存储数据的char数组是使用final进行修饰的,所以不可变。

image-20200714151350294
image-20200714151350294

面试官:刚才说到String是不可变,但是下面的代码运行完,却发生变化了,这是为啥呢?

public class Demo {

    public static void main(String[] args) {
        String str = "不一样的";
        str = str + "科技宅";
        System.out.println(str);
    }

}

很明显上面运行的结果是:不一样的科技宅

我们先使用javac Demo.class 进行编译,然后反编译javap -verbose Demo 得到如下结果:

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: ldc           #2                  // String 不一样的
         2: astore_1
         3new           #3                  // class java/lang/StringBuilder
         6: dup
         7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
        10: aload_1
        11: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        14: ldc           #6                  // String 科技宅
        16: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        22: astore_1
        23: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
        26: aload_1
        27: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        30return

  我们可以发现,在使用+ 进行拼接的时候,实际上jvm是初始化了一个StringBuilder进行拼接的。相当于编译后的代码如下:

public class Demo {

    public static void main(String[] args) {
        String str = "不一样的";
        StringBuilder builder =new StringBuilder();
        builder.append(str);
        builder.append("科技宅");
        str = builder.toString();
        System.out.println(str);
    }

}

我们可以看下builder.toString(); 的实现。

@Override
public String toString() {
  // Create a copy, don't share the array
  return new String(value, 0, count);
}

  很明显toString方法是生成了一个新的String对象而不是更改旧的str的内容,相当于把旧str的引用指向的新的String对象。这也就是str发生变化的原因。

分享我碰到过的一道面试题,大家可以猜猜答案是啥?文末有解析哦

public class Demo {

    public static void main(String[] args) {
        String str = null;
        str = str + "";
        System.out.println(str);
    }

}

面试官:String类可以被继承嘛?

小宅:不可以,因为String类使用final关键字进行修饰,所以不能被继承,并且StringBuilder,StringBuffer也是如此都被final关键字修饰。

面试官:为什么String Buffer是线程安全的?

小宅:这是因为在StringBuffer类内,常用的方法都使用了synchronized 进行同步所以是线程安全的,然而StringBuilder并没有。这也就是运行速度StringBuilder > StringBuffer的原因了。

20181119593911_rYDslC
20181119593911_rYDslC

面试官:刚才你说到了`synchronized`关键字 ,那能讲讲`synchronized`的表现形式嘛?

小宅

  • 对于普通同步方法 ,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的class对象。
  • 对于同步方法块,锁是Synchonized括号配置的对象。

面试官:能讲讲`synchronized`的原理嘛?

小宅synchronized是一个重量级锁,实现依赖于JVMmonitor 监视器锁。主要使用monitorentermonitorexit指令来实现方法同步和代码块同步。在编译的是时候,会将monitorexit指令插入到同步代码块的开始位置,而monitorexit插入方法结束处和异常处,并且每一个monitorexit都有一个与之对应的monitorexit

  任何对象都有一个monitor与之关联,当一个monitor被持有后,它将被处于锁定状态,线程执行到monitorenter指令时间,会尝试获取对象所对应的monitor的所有权,即获取获得对象的锁,由于在编译期会将monitorexit插入到方法结束处和异常处,所以在方法执行完毕或者出现异常的情况会自动释放锁。

硬菜来了

面试官:前面你提到`synchronized`是个重量级锁,那它的优化有了解嘛?

006qir4oly1g0qzhmov3tj30gw0cgjte
006qir4oly1g0qzhmov3tj30gw0cgjte

小宅:为了减少获得锁和和释放锁带来的性能损耗引入了偏向锁、轻量级锁、重量级锁来进行优化,锁升级的过程如下:

  首先是一个无锁的状态,当线程进入同步代码块的时候,会检查对象头内和栈帧中的锁记录里是否存入存入当前线程的ID,如果没有使用CAS 进行替换。以后该线程进入和退出同步代码块不需要进行CAS 操作来加锁和解锁,只需要判断对象头的Mark word内是否存储指向当前线程的偏向锁。如果有表示已经获得锁,如果没有或者不是,则需要使用CAS进行替换,如果设置成功则当前线程持有偏向锁,反之将偏向锁进行撤销并升级为轻量级锁。

  轻量级锁加锁过程,线程在执行同步块之前,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的Mark Word复制到锁记录(Displaced Mark Word)中,然后线程尝试使用CAS 将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,反之表示其他线程竞争锁,当前线程便尝试使用自旋来获得锁。

  轻量级锁解锁过程,解锁时,会使用CAS将Displaced Mark Word替换回到对象头,如果成功,则表示竞争没有发生,反之则表示当前锁存在竞争锁就会膨胀成重量级锁。

升级过程流程图

533411-20200423173203871-980115964
533411-20200423173203871-980115964

白话一下:

  可能上面的升级过程和升级过程图,有点难理解并且还有点绕。我们先可以了解下为什么会有锁升级这个过程?HotSpot的作者经过研究发现,大多数情况下锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。为了避免获得锁和和释放锁带来的性能损耗引入锁升级这样一个过程。理解锁升级这个流程需要明确一个点:发生了竞争才锁会进行升级并且不能降级。

  我们以两个线程T1,T2执行同步代码块来演示锁是如何膨胀起来的。我们从无锁的状态开始 ,这个时候T1进入了同步代码块,判断当前锁的一个状态。发现是一个无锁的状态,这个时候会使用CAS将锁记录内的线程Id指向T1并从无锁状态变成了偏向锁。运行了一段时间后T2进入了同步代码块,发现已经是偏向锁了,于是尝试使用CAS去尝试将锁记录内的线程Id改为T2,如果更改成功则T2持有偏向锁。失败了说明存在竞争就升级为轻量级锁了。

  可能你会有疑问,为啥会失败呢?我们要从CAS操作入手,CAS是Compare-and-swap(比较与替换)的简写,是一种有名的无锁算法。CAS需要有3个操作数,内存地址V,旧的预期值A,即将要更新的目标值B,换句话说就是,内存地址0x01存的是数字6我想把他变成7。这个时候我先拿到0x01的值是6,然后再一次获取0x01的值并判断是不是6,如果是就更新为7,如果不是就再来一遍之道成功为止。这个主要是由于CPU的时间片原因,可能执行到一半被挂起了,然后别的线程把值给改了,这个时候程序就可能将错误的值设置进去,导致结果异常。

  简单了解了一下CAS现在让我们继续回到锁升级这个过程,T2尝试使用CAS进行替换锁记录内的线程ID,结果CAS失败了这也就意味着,这个时候T1抢走了原本属于T2的锁,很明显这一刻发生了竞争所以锁需要升级。在升级为轻量级锁前,持有偏向锁的线程T1会被暂停,并检查T1的状态,如果T1处于未活动的状态/已经退出同步代码块的时候,T1会释放偏向锁并被唤醒。如果未退出同步代码块,则这个时候会升级为轻量级锁,并且由T1获得锁,从安全点继续执行,执行完后对轻量级锁进行释放。

  偏向锁的使用了出现竞争了才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。并且偏向锁的撤销需要等待全局安全点(这个时间点没有任何正在执行的字节码)。

  T1由于没有人竞争经过一段时间的平稳运行,在某一个时间点时候T2进来了,产生使用CAS获得锁,但是发现失败了,这个时候T2会等待一下(自旋获得锁),由于竞争不是很激烈所以等T1执行完后,就能获取到锁并进行执行。如果长时间获取不到锁则就可能发生竞争了,可能出现了个T3把原本属于T2的轻量级锁给抢走了,这个时候就会升级成重量级锁了。

u=628528004,774370142&fm=26&gp=0
u=628528004,774370142&fm=26&gp=0

吃完撤退

面试官:内心OS:竟然没问倒他,看来让他培训是没啥希望了,让他回去等通知吧 。

  小宅是吧,你的水平我这边基本了解了,我对你还是比较满意的,但是我们这边还有几个候选人还没面试,没办法直接给你答复,你先回去等通知吧。

小宅:好的好的,谢谢面试官,我这边先回去了。多亏我准备的充分,全回答上来了,应该能收到offer了吧。

timg
timg

面试题解析

public class Demo {

    public static void main(String[] args) {
        String str = null;
        str = str + "";
        System.out.println(str);
    }

}

答案是 null,从之前我们了解到使用+进行拼接实际上是会转换为StringBuilder使用append方法进行拼接。所以我们看看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;
}
private AbstractStringBuilder appendNull() {
  int c = count;
  ensureCapacityInternal(c + 4);
  final char[] value = this.value;
  value[c++] = 'n';
  value[c++] = 'u';
  value[c++] = 'l';
  value[c++] = 'l';
  count = c;
  return this;
}

从代码中可以发现,如果传入的字符串是null时,调用appendNull方法,而appendNull会返回null。

结尾

  我是不一样的科技宅,每天进步一点点,体验不一样的生活。我们下期见!

  如果觉得对你有帮助,可以多多评论,多多点赞哦,也可以到我的主页看看,说不定有你喜欢的文章,也可以随手点个关注哦,谢谢。

评论

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×