
前言
String,StringBuilder,StringBuffer的差別是啥?這個面試題估計每個JAVA都應該碰到過吧。依稀記得第一次面試的時候,面試官問我這個問題時,心想着能有啥差別不都是拼接字元串嘛。深入了解這個問題後,發現并不簡單?
前菜
面試官:你好,你是不一樣的科技宅是吧?
小宅:面試官你好,我是不一樣的科技宅。
面試官:你好,麻煩做一個簡單的自我介紹吧。
小宅:我叫不一樣的科技宅,來自xxx,做過的項目主要有xxxx用到xxx,xxx技術。
面試官:好的,對你的的履曆有些基本了解了,那我們先聊點基礎知識吧。
小宅:内心OS(放馬過來吧)
開胃小菜
面試官:String,StringBuilder,StringBuffer的差別是啥?
小宅:這個太簡單了吧,這是看不起我?
- 從可變性來講String的是不可變的,StringBuilder,StringBuffer的長度是可變的。
- 從運作速度上來講StringBuilder > StringBuffer > String。
- 從線程安全上來StringBuilder是線程不安全的,而StringBuffer是線程安全的。
是以 String:适用于少量的字元串操作的情況,StringBuilder:适用于單線程下在字元緩沖區進行大量操作的情況,StringBuffer:适用多線程下在字元緩沖區進行大量操作的情況。
面試官:為什麼String的是不可變的?
小宅:因為存儲資料的char數組是使用final進行修飾的,是以不可變。
面試官:剛才說到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
的原因了。
面試官:剛才你說到了 synchronized
關鍵字 ,那能講講 synchronized
的表現形式嘛?
synchronized
synchronized
小宅:
- 對于普通同步方法 ,鎖是目前執行個體對象。
- 對于靜态同步方法,鎖是目前類的class對象。
- 對于同步方法塊,鎖是Synchonized括号配置的對象。
面試官:能講講 synchronized
的原理嘛?
synchronized
synchronized
是一個重量級鎖,實作依賴于
JVM
的
monitor
螢幕鎖。主要使用
monitorenter
和
monitorexit
指令來實作方法同步和代碼塊同步。在編譯的是時候,會将
monitorexit
指令插入到同步代碼塊的開始位置,而
monitorexit
插入方法結束處和異常處,并且每一個
monitorexit
都有一個與之對應的
monitorexit
。
任何對象都有一個
monitor
與之關聯,當一個
monitor
被持有後,它将被處于鎖定狀态,線程執行到
monitorenter
指令時間,會嘗試擷取對象所對應的
monitor
的所有權,即擷取獲得對象的鎖,由于在編譯期會将
monitorexit
插入到方法結束處和異常處,是以在方法執行完畢或者出現異常的情況會自動釋放鎖。
硬菜來了
面試官:前面你提到 synchronized
是個重量級鎖,那它的優化有了解嘛?
synchronized
小宅:為了減少獲得鎖和和釋放鎖帶來的性能損耗引入了偏向鎖、輕量級鎖、重量級鎖來進行優化,鎖更新的過程如下:
首先是一個無鎖的狀态,當線程進入同步代碼塊的時候,會檢查對象頭内和棧幀中的鎖記錄裡是否存入存入目前線程的ID,如果沒有使用
CAS
進行替換。以後該線程進入和退出同步代碼塊不需要進行
CAS
操作來加鎖和解鎖,隻需要判斷對象頭的
Mark word
内是否存儲指向目前線程的偏向鎖。如果有表示已經獲得鎖,如果沒有或者不是,則需要使用
CAS
進行替換,如果設定成功則目前線程持有偏向鎖,反之将偏向鎖進行撤銷并更新為輕量級鎖。
輕量級鎖加鎖過程,線程在執行同步塊之前,JVM會在目前線程的棧幀中建立用于存儲鎖記錄的空間,并将對象頭的
Mark Word
複制到鎖記錄(
Displaced Mark Word
)中,然後線程嘗試使用
CAS
将對象頭中的
Mark Word
替換為指向鎖記錄的指針。如果成功,目前線程獲得鎖,反之表示其他線程競争鎖,目前線程便嘗試使用自旋來獲得鎖。
輕量級鎖解鎖過程,解鎖時,會使用CAS将
Displaced Mark Word
替換回到對象頭,如果成功,則表示競争沒有發生,反之則表示目前鎖存在競争鎖就會膨脹成重量級鎖。
更新過程流程圖
白話一下:
可能上面的更新過程和更新過程圖,有點難了解并且還有點繞。我們先可以了解下為什麼會有鎖更新這個過程?
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的輕量級鎖給搶走了,這個時候就會更新成重量級鎖了。
吃完撤退
面試官:内心OS:竟然沒問倒他,看來讓他教育訓練是沒啥希望了,讓他回去等通知吧 。
小宅是吧,你的水準我這邊基本了解了,我對你還是比較滿意的,但是我們這邊還有幾個候選人還沒面試,沒辦法直接給你答複,你先回去等通知吧。
小宅:好的好的,謝謝面試官,我這邊先回去了。多虧我準備的充分,全回答上來了,應該能收到offer了吧。
面試題解析
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。
結尾
如果覺得對你有幫助,可以多多評論,多多點贊哦,也可以到我的首頁看看,說不定有你喜歡的文章,也可以随手點個關注哦,謝謝。
我是不一樣的科技宅,每天進步一點點,體驗不一樣的生活。我們下期見!