由String、String Builder、String Buffer 引起的面試慘案

pexels-pixabay-60504

前言

  String,StringBuilder,StringBuffer的區別是啥?這個面試題估計每個JAVA都應該碰到過吧。依稀記得第一次面試的時候,面試官問我這個問題時,心想着能有啥區別不都是拼接字符串嘛。深入瞭解這個問題後,發現並不簡單?

前菜

面試官:你好,你是不一樣的科技宅是吧?

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

面試官:你好,麻煩做一個簡單的自我介紹吧。

小宅:我叫不一樣的科技宅,來自xxx,做過的項目主要有xxxx用到xxx,xxx技術。

20180719930824_lrEKIh

面試官:好的,對你的的履歷有些基本瞭解了,那我們先聊點基礎知識吧。

小宅:內心OS(放馬過來吧)

開胃小菜

面試官:String,StringBuilder,StringBuffer的區別是啥?

小宅:這個太簡單了吧,這是看不起我?

  • 從可變性來講String的是不可變的,StringBuilder,StringBuffer的長度是可變的。
  • 從運行速度上來講StringBuilder > StringBuffer > String。
  • 從線程安全上來StringBuilder是線程不安全的,而StringBuffer是線程安全的。

  所以 String:適用於少量的字符串操作的情況,StringBuilder:適用於單線程下在字符緩衝區進行大量操作的情況,StringBuffer:適用多線程下在字符緩衝區進行大量操作的情況。

75f3cd331b7ab4ca4552847a746952da

面試官:爲什麼String的是不可變的?

小宅:因爲存儲數據的char數組是使用final進行修飾的,所以不可變。

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
         3: new           #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
        30: return

  我們可以發現,在使用+ 進行拼接的時候,實際上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

面試官:剛纔你說到了synchronized關鍵字 ,那能講講synchronized的表現形式嘛?

小宅

  • 對於普通同步方法 ,鎖是當前實例對象。
  • 對於靜態同步方法,鎖是當前類的class對象。
  • 對於同步方法塊,鎖是Synchonized括號配置的對象。

面試官:能講講synchronized的原理嘛?

小宅synchronized是一個重量級鎖,實現依賴於JVMmonitor 監視器鎖。主要使用monitorentermonitorexit指令來實現方法同步和代碼塊同步。在編譯的是時候,會將monitorexit指令插入到同步代碼塊的開始位置,而monitorexit插入方法結束處和異常處,並且每一個monitorexit都有一個與之對應的monitorexit

  任何對象都有一個monitor與之關聯,當一個monitor被持有後,它將被處於鎖定狀態,線程執行到monitorenter指令時間,會嘗試獲取對象所對應的monitor的所有權,即獲取獲得對象的鎖,由於在編譯期會將monitorexit插入到方法結束處和異常處,所以在方法執行完畢或者出現異常的情況會自動釋放鎖。

硬菜來了

面試官:前面你提到synchronized是個重量級鎖,那它的優化有了解嘛?

006qir4oly1g0qzhmov3tj30gw0cgjte

小宅:爲了減少獲得鎖和和釋放鎖帶來的性能損耗引入了偏向鎖、輕量級鎖、重量級鎖來進行優化,鎖升級的過程如下:

  首先是一個無鎖的狀態,當線程進入同步代碼塊的時候,會檢查對象頭內和棧幀中的鎖記錄裏是否存入存入當前線程的ID,如果沒有使用CAS 進行替換。以後該線程進入和退出同步代碼塊不需要進行CAS 操作來加鎖和解鎖,只需要判斷對象頭的Mark word內是否存儲指向當前線程的偏向鎖。如果有表示已經獲得鎖,如果沒有或者不是,則需要使用CAS進行替換,如果設置成功則當前線程持有偏向鎖,反之將偏向鎖進行撤銷並升級爲輕量級鎖。

  輕量級鎖加鎖過程,線程在執行同步塊之前,JVM會在當前線程的棧幀中創建用於存儲鎖記錄的空間,並將對象頭的Mark Word複製到鎖記錄(Displaced Mark Word)中,然後線程嘗試使用CAS 將對象頭中的Mark Word替換爲指向鎖記錄的指針。如果成功,當前線程獲得鎖,反之表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲得鎖。

  輕量級鎖解鎖過程,解鎖時,會使用CAS將Displaced Mark Word替換回到對象頭,如果成功,則表示競爭沒有發生,反之則表示當前鎖存在競爭鎖就會膨脹成重量級鎖。

升級過程流程圖

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

喫完撤退

面試官:內心OS:竟然沒問倒他,看來讓他培訓是沒啥希望了,讓他回去等通知吧 。

  小宅是吧,你的水平我這邊基本瞭解了,我對你還是比較滿意的,但是我們這邊還有幾個候選人還沒面試,沒辦法直接給你答覆,你先回去等通知吧。

小宅:好的好的,謝謝面試官,我這邊先回去了。多虧我準備的充分,全回答上來了,應該能收到offer了吧。

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。

結尾

  如果覺得對你有幫助,可以多多評論,多多點贊哦,也可以到我的主頁看看,說不定有你喜歡的文章,也可以隨手點個關注哦,謝謝。

  我是不一樣的科技宅,每天進步一點點,體驗不一樣的生活。我們下期見!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章