今天我會聊聊日常使用的字符串,別看它似乎很簡單,但其實字符串幾乎在所有編程語言里都是個特殊的存在,因為不管是數量還是體積,字符串都是大多數應用中的重要組成。
今天我要問你的問題是,理解 Java 的字符串,String、StringBuffer、StringBuilder 有什么區別?
## 典型回答
String 是 Java 語言非常基礎和重要的類,提供了構造和管理字符串的各種基本邏輯。它是典型的 Immutable 類,被聲明成為 final class,所有屬性也都是 final 的。也由于它的不可變性,類似拼接、裁剪字符串等動作,都會產生新的 String 對象。由于字符串操作的普遍性,所以相關操作的效率往往對應用性能有明顯影響。
StringBuffer 是為解決上面提到拼接產生太多中間對象的問題而提供的一個類,我們可以用 append 或者 add 方法,把字符串添加到已有序列的末尾或者指定位置。StringBuffer 本質是一個線程安全的可修改字符序列,它保證了線程安全,也隨之帶來了額外的性能開銷,所以除非有線程安全的需要,不然還是推薦使用它的后繼者,也就是 StringBuilder。
StringBuilder 是 Java 1.5 中新增的,在能力上和 StringBuffer 沒有本質區別,但是它去掉了線程安全的部分,有效減小了開銷,是絕大部分情況下進行字符串拼接的首選。
## 考點分析
幾乎所有的應用開發都離不開操作字符串,理解字符串的設計和實現以及相關工具如拼接類的使用,對寫出高質量代碼是非常有幫助的。關于這個問題,我前面的回答是一個通常的概要性回答,至少你要知道 String 是 Immutable 的,字符串操作不當可能會產生大量臨時字符串,以及線程安全方面的區別。
如果繼續深入,面試官可以從各種不同的角度考察,比如可以:
* 通過 String 和相關類,考察基本的線程安全設計與實現,各種基礎編程實踐。
* 考察 JVM 對象緩存機制的理解以及如何良好地使用。
* 考察 JVM 優化 Java 代碼的一些技巧。
* String 相關類的演進,比如 Java 9 中實現的巨大變化。
* …
針對上面這幾方面,我會在知識擴展部分與你詳細聊聊。
## 知識擴展
1\. 字符串設計和實現考量
我在前面介紹過,String 是 Immutable 類的典型實現,原生的保證了基礎線程安全,因為你無法對它內部數據進行任何修改,這種便利甚至體現在拷貝構造函數中,由于不可變,Immutable 對象在拷貝時不需要額外復制數據。
我們再來看看 StringBuffer 實現的一些細節,它的線程安全是通過把各種修改數據的方法都加上 synchronized 關鍵字實現的,非常直白。其實,這種簡單粗暴的實現方式,非常適合我們常見的線程安全類實現,不必糾結于 synchronized 性能之類的,有人說“過早優化是萬惡之源”,考慮可靠性、正確性和代碼可讀性才是大多數應用開發最重要的因素。
為了實現修改字符序列的目的,StringBuffer 和 StringBuilder 底層都是利用可修改的(char,JDK 9 以后是 byte)數組,二者都繼承了 AbstractStringBuilder,里面包含了基本操作,區別僅在于最終的方法是否加了 synchronized。
另外,這個內部數組應該創建成多大的呢?如果太小,拼接的時候可能要重新創建足夠大的數組;如果太大,又會浪費空間。目前的實現是,構建時初始字符串長度加 16(這意味著,如果沒有構建對象時輸入最初的字符串,那么初始值就是 16)。我們如果確定拼接會發生非常多次,而且大概是可預計的,那么就可以指定合適的大小,避免很多次擴容的開銷。擴容會產生多重開銷,因為要拋棄原有數組,創建新的(可以簡單認為是倍數)數組,還要進行 arraycopy。
前面我講的這些內容,在具體的代碼書寫中,應該如何選擇呢?
在沒有線程安全問題的情況下,全部拼接操作是應該都用 StringBuilder 實現嗎?畢竟這樣書寫的代碼,還是要多敲很多字的,可讀性也不理想,下面的對比非常明顯。
~~~
String strByBuilder = new
StringBuilder().append("aa").append("bb").append("cc").append
("dd").toString();
String strByConcat = "aa" + "bb" + "cc" + "dd";
~~~
其實,在通常情況下,沒有必要過于擔心,要相信 Java 還是非常智能的。
我們來做個實驗,把下面一段代碼,利用不同版本的 JDK 編譯,然后再反編譯,例如:
~~~
public class StringConcat {
public static String concat(String str) {
return str + “aa” + “bb”;
}
}
~~~
先編譯再反編譯,比如使用不同版本的 JDK:
~~~
${JAVA_HOME}/bin/javac StringConcat.java
${JAVA_HOME}/bin/javap -v StringConcat.class
~~~
JDK 8 的輸出片段是:
~~~
0: new #2 // class java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
7: aload_0
8: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
11: ldc #5 // String aa
13: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
16: ldc #6 // String bb
18: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
~~~
而在 JDK 9 中,反編譯的結果就會有點特別了,片段是:
~~~
// concat method
1: invokedynamic #2, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
// ...
// 實際是利用了 MethodHandle, 統一了入口
0: #15 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
~~~
你可以看到,非靜態的拼接邏輯在 JDK 8 中會自動被 javac 轉換為 StringBuilder 操作;而在 JDK 9 里面,則是體現了思路的變化。Java 9 利用 InvokeDynamic,將字符串拼接的優化與 javac 生成的字節碼解耦,假設未來 JVM 增強相關運行時實現,將不需要依賴 javac 的任何修改。
在日常編程中,保證程序的可讀性、可維護性,往往比所謂的最優性能更重要,你可以根據實際需求酌情選擇具體的編碼方式。
2\. 字符串緩存
我們粗略統計過,把常見應用進行堆轉儲(Dump Heap),然后分析對象組成,會發現平均 25% 的對象是字符串,并且其中約半數是重復的。如果能避免創建重復字符串,可以有效降低內存消耗和對象創建開銷。
String 在 Java 6 以后提供了 intern() 方法,目的是提示 JVM 把相應字符串緩存起來,以備重復使用。在我們創建字符串對象并調用 intern() 方法的時候,如果已經有緩存的字符串,就會返回緩存里的實例,否則將其緩存起來。一般來說,JVM 會將所有的類似“abc”這樣的文本字符串,或者字符串常量之類緩存起來。
看起來很不錯是吧?但實際情況估計會讓你大跌眼鏡。一般使用 Java 6 這種歷史版本,并不推薦大量使用 intern,為什么呢?魔鬼存在于細節中,被緩存的字符串是存在所謂 PermGen 里的,也就是臭名昭著的“永久代”,這個空間是很有限的,也基本不會被 FullGC 之外的垃圾收集照顧到。所以,如果使用不當,OOM 就會光顧。
在后續版本中,這個緩存被放置在堆中,這樣就極大避免了永久代占滿的問題,甚至永久代在 JDK 8 中被 MetaSpace(元數據區)替代了。而且,默認緩存大小也在不斷地擴大中,從最初的 1009,到 7u40 以后被修改為 60013。你可以使用下面的參數直接打印具體數字,可以拿自己的 JDK 立刻試驗一下。
~~~
-XX:+PrintStringTableStatistics
~~~
你也可以使用下面的 JVM 參數手動調整大小,但是絕大部分情況下并不需要調整,除非你確定它的大小已經影響了操作效率。
~~~
-XX:StringTableSize=N
~~~
Intern 是一種**顯式地排重機制**,但是它也有一定的副作用,因為需要開發者寫代碼時明確調用,一是不方便,每一個都顯式調用是非常麻煩的;另外就是我們很難保證效率,應用開發階段很難清楚地預計字符串的重復情況,有人認為這是一種污染代碼的實踐。
幸好在 Oracle JDK 8u20 之后,推出了一個新的特性,也就是 G1 GC 下的字符串排重。它是通過將相同數據的字符串指向同一份數據來做到的,是 JVM 底層的改變,并不需要 Java 類庫做什么修改。
注意這個功能目前是默認關閉的,你需要使用下面參數開啟,并且記得指定使用 G1 GC:
~~~
-XX:+UseStringDeduplication
~~~
前面說到的幾個方面,只是 Java 底層對字符串各種優化的一角,在運行時,字符串的一些基礎操作會直接利用 JVM 內部的 Intrinsic 機制,往往運行的就是特殊優化的本地代碼,而根本就不是 Java 代碼生成的字節碼。Intrinsic 可以簡單理解為,是一種利用 native 方式 hard-coded 的邏輯,算是一種特別的內聯,很多優化還是需要直接使用特定的 CPU 指令,具體可以看相關[源碼](http://hg.openjdk.java.net/jdk/jdk/file/44b64fc0baa3/src/hotspot/share/classfile/vmSymbols.hpp),搜索“string”以查找相關 Intrinsic 定義。當然,你也可以在啟動實驗應用時,使用下面參數,了解 intrinsic 發生的狀態。
~~~
-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
// 樣例輸出片段
180 3 3 java.lang.String::charAt (25 bytes)
@ 1 java.lang.String::isLatin1 (19 bytes)
...
@ 7 java.lang.StringUTF16::getChar (60 bytes) intrinsic
~~~
可以看出,僅僅是字符串一個實現,就需要 Java 平臺工程師和科學家付出如此大且默默無聞的努力,我們得到的很多便利都是來源于此。
我會在專欄后面的 JVM 和性能等主題,詳細介紹 JVM 內部優化的一些方法,如果你有興趣可以再深入學習。即使你不做 JVM 開發或者暫時還沒有使用到特別的性能優化,這些知識也能幫助你增加技術深度。
3.String 自身的演化
如果你仔細觀察過 Java 的字符串,在歷史版本中,它是使用 char 數組來存數據的,這樣非常直接。但是 Java 中的 char 是兩個 bytes 大小,拉丁語系語言的字符,根本就不需要太寬的 char,這樣無區別的實現就造成了一定的浪費。密度是編程語言平臺永恒的話題,因為歸根結底絕大部分任務是要來操作數據的。
其實在 Java 6 的時候,Oracle JDK 就提供了壓縮字符串的特性,但是這個特性的實現并不是開源的,而且在實踐中也暴露出了一些問題,所以在最新的 JDK 版本中已經將它移除了。
在 Java 9 中,我們引入了 Compact Strings 的設計,對字符串進行了大刀闊斧的改進。將數據存儲方式從 char 數組,改變為一個 byte 數組加上一個標識編碼的所謂 coder,并且將相關字符串操作類都進行了修改。另外,所有相關的 Intrinsic 之類也都進行了重寫,以保證沒有任何性能損失。
雖然底層實現發生了這么大的改變,但是 Java 字符串的行為并沒有任何大的變化,所以這個特性對于絕大部分應用來說是透明的,絕大部分情況不需要修改已有代碼。
當然,在極端情況下,字符串也出現了一些能力退化,比如最大字符串的大小。你可以思考下,原來 char 數組的實現,字符串的最大長度就是數組本身的長度限制,但是替換成 byte 數組,同樣數組長度下,存儲能力是退化了一倍的!還好這是存在于理論中的極限,還沒有發現現實應用受此影響。
在通用的性能測試和產品實驗中,我們能非常明顯地看到緊湊字符串帶來的優勢,**即更小的內存占用、更快的操作速度**。
今天我從 String、StringBuffer 和 StringBuilder 的主要設計和實現特點開始,分析了字符串緩存的 intern 機制、非代碼侵入性的虛擬機層面排重、Java 9 中緊湊字符的改進,并且初步接觸了 JVM 的底層優化機制 intrinsic。從實踐的角度,不管是 Compact Strings 還是底層 intrinsic 優化,都說明了使用 Java 基礎類庫的優勢,它們往往能夠得到最大程度、最高質量的優化,而且只要升級 JDK 版本,就能零成本地享受這些益處。
## 一課一練
關于今天我們討論的題目你做到心中有數了嗎?限于篇幅有限,還有很多字符相關的問題沒有來得及討論,比如編碼相關的問題。可以思考一下,很多字符串操作,比如 getBytes()/[String](https://docs.oracle.com/javase/9/docs/api/java/lang/String.html#String-byte:A-)?(byte\[\] bytes) 等都是隱含著使用平臺默認編碼,這是一種好的實踐嗎?是否有利于避免亂碼?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?