版權聲明:本文為博主原創文章,遵循 CC 4.0 by-sa 版權協議,轉載請附上原文出處鏈接和本聲明。
本文鏈接:https://blog.csdn.net/qq_34490018/article/details/82110578
目錄
JVM相關知識
String源碼分析
Srtring在JVM層解析
String典型案例
String被設計成不可變和不能被繼承的原因
JVM相關知識
下面這張圖是JVM的體系結構圖:
下面我們了解下Java棧、Java堆、方法區和常量池:
Java棧(線程私有數據區):
每個Java虛擬機線程都有自己的Java虛擬機棧,Java虛擬機棧用來存放棧幀,每個方法被執行的時候都會同時創建一個棧幀(Stack Frame)用於存儲局部變量表、操作棧、動態鏈接、方法出口等信息。每一個方法被調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中從入棧到出棧的過程。
Java堆(線程共享數據區):
在虛擬機啟動時創建,此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這里分配。
方法區(線程共享數據區):
方法區在虛擬機啟動的時候被創建,它存儲了每一個類的結構信息,例如運行時常量池、字段和方法數據、構造函數和普通方法的字節碼內容、還包括在類、實例、接口初始化時用到的特殊方法。在JDK8之前永久代是方法區的一種實現,而JDK8元空間替代了永久代,永久代被移除,也可以理解為元空間是方法區的一種實現。
常量池(線程共享數據區):
常量池常被分為兩大類:靜態常量池和運行時常量池。
靜態常量池也就是Class文件中的常量池,存在於Class文件中。
運行時常量池(Runtime Constant Pool)是方法區的一部分,存放一些運行時常量數據。
下面重點了解的是字符串常量池:
字符串常量池存在運行時常量池之中(在JDK7之前存在運行時常量池之中,在JDK7已經將其轉移到堆中)。
字符串常量池的存在使JVM提高了性能和減少了內存開銷。
使用字符串常量池,每當我們使用字面量(String s=”1”;)創建字符串常量時,JVM會首先檢查字符串常量池,如果該字符串已經存在常量池中,那么就將此字符串對象的地址賦值給引用s(引用s在Java棧中)。如果字符串不存在常量池中,就會實例化該字符串並且將其放到常量池中,並將此字符串對象的地址賦值給引用s(引用s在Java棧中)。
使用字符串常量池,每當我們使用關鍵字new(String s=new String(”1”);)創建字符串常量時,JVM會首先檢查字符串常量池,如果該字符串已經存在常量池中,那么不再在字符串常量池創建該字符串對象,而直接堆中復制該對象的副本,然后將堆中對象的地址賦值給引用s,如果字符串不存在常量池中,就會實例化該字符串並且將其放到常量池中,然后在堆中復制該對象的副本,然后將堆中對象的地址賦值給引用s。
下圖是API說明:
翻譯為:“初始化一個新創建的字符串對象,以便它表示與參數相同的字符序列;換句話說,新創建的字符串是參數字符串的副本。除非需要顯式的原始副本,否則使用此構造函數是不必要的,因為字符串是不可變的。”
由於String字符串的不可變性我們可以十分肯定常量池中一定不存在兩個相同的字符串。
鑒於String.intern()在API上的說明和new String(“a”)創建字符串(創建了兩個對象,如果字符串常量池存在則是一個對象)在官方API上的說明,我個人認為字符串常量池存的是字符串對象,當然在JKD7之后,常量池中存儲的可能是堆對象的引用,后面會講到。(可用javap -c反編譯即可得到JVM執行的字節碼內容,javap -verbose 反編譯查看常量池內容)
關於常量池,我會在后面的一篇相關文章中進行解析。。。。
String源碼分析
下面是String類的部分源碼:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence
{
/** The value is used for character storage. */
private final char value[];
/** The offset is the first index of the storage that is used. */
private final int offset;
/** The count is the number of characters in the String. */
private final int count;
/** Cache the hash code for the string */
private int hash; // Default to 0
/** use serialVersionUID from JDK 1.0.2 for interoperability */
private static final long serialVersionUID = -6849794470754667710L;
........
}
首先我們來看看String類,String類是用final修飾的,這意味着String不能被繼承,而且所有的成員方法都默認為final方法。
接下來看看String類實現的接口:
java.io.Serializable:這個序列化接口僅用於標識序列化的語意。
Comparable<String>:這個compareTo(T 0)接口用於對兩個實例化對象比較大小。
CharSequence:這個接口是一個只讀的字符序列。包括length(), charAt(int index), subSequence(int start, int end)這幾個API接口,值得一提的是,StringBuffer和StringBuild也是實現了改接口。
最后看看String的成員屬性:
value[] :char數組用於儲存String的內容。
offset :存儲的第一個索引。
count :字符串中的字符數。
hash :String實例化的hashcode的一個緩存,String的哈希碼被頻繁使用,將其緩存起來,每次使用就沒必要再次去計算,這也是一種性能優化的手段。這也是String被設計為不可變的原因之一,后面會講到。
下面是一個String類的一個方法實現:
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > count) {
throw new StringIndexOutOfBoundsException(endIndex);
}
if (beginIndex > endIndex) {
throw new StringIndexOutOfBoundsException(endIndex - beginIndex);
}
return ((beginIndex == 0) && (endIndex == count)) ? this :
new String(offset + beginIndex, endIndex - beginIndex, value);
}
可以發現,最初傳入的String並沒有改變,其返回的是一個new String(),即新創建的String對象。其實String類的其他方法也是如此,並不會改變原字符串。這也是String的不可變性,后面會講到。
Srtring在JVM層解析
創建字符串形式
首先形如聲明為S ss是一個類S的引用變量ss(我們常常稱之為句柄,后面JVM相關內容會講到),而對象一般通過new創建。所以這里的ss僅僅是引用變量,並不是對象。
創建字符串的兩種基本形式:
String s1=”1”;
String s2=new String(“1”);
從圖中可以看出,s1使用””引號(也是平時所說的字面量)創建字符串,在編譯期的時候就對常量池進行判斷是否存在該字符串,如果存在則不創建直接返回對象的引用;如果不存在,則先在常量池中創建該字符串實例再返回實例的引用給s1。注意:編譯期的常量池是靜態常量池,以后和會講。。。。
再來看看s2,s2使用關鍵詞new創建字符串,JVM會首先檢查字符串常量池,如果該字符串已經存在常量池中,那么不再在字符串常量池創建該字符串對象,而直接堆中復制該對象的副本,然后將堆中對象的地址賦值給引用s2,如果字符串不存在常量池中,就會實例化該字符串並且將其放到常量池中,然后在堆中復制該對象的副本,然后將堆中對象的地址賦值給引用s2。注意:此時是運行期,那么字符串常量池是在運行時常量池中的。。。。
“+”連接形式創建字符串(更多可以查看API):
(1)String s1=”1”+”2”+”3”;
使用包含常量的字符串連接創建是也是常量,編譯期就能確定了,直接入字符串常量池,當然同樣需要判斷是否已經存在該字符串。
(2)String s2=”1”+”3”+new String(“1”)+”4”;
當使用“+”連接字符串中含有變量時,也是在運行期才能確定的。首先連接操作最開始時如果都是字符串常量,編譯后將盡可能多的字符串常量連接在一起,形成新的字符串常量參與后續的連接(可通過反編譯工具jd-gui進行查看)。
接下來的字符串連接是從左向右依次進行,對於不同的字符串,首先以最左邊的字符串為參數創建StringBuilder對象(可變字符串對象),然后依次對右邊進行append操作,最后將StringBuilder對象通過toString()方法轉換成String對象(注意:中間的多個字符串常量不會自動拼接)。
實際上的實現過程為:String s2=new StringBuilder(“13”).append(new String(“1”)).append(“4”).toString();
當使用+進行多個字符串連接時,實際上是產生了一個StringBuilder對象和一個String對象。
(3)String s3=new String(“1”)+new String(“1”);
這個過程跟(2)類似。。。。。。
String.intern()解析
String.intern()是一個Native方法,底層調用C++的 StringTable::intern 方法,源碼注釋:當調用 intern 方法時,如果常量池中已經該字符串,則返回池中的字符串;否則將此字符串添加到常量池中,並返回字符串的引用。
下面我們來看個案例:
public class StringTest {
public static void main(String[] args) {
// TODO 自動生成的方法存根
String s3 = new String("1") + new String("1");
System.out.println(s3 == s3.intern());
}
}
JDK6的執行結果為:false
JDK7和JDK8的執行結果為:true
JDK6的內存模型如下:
我們都知道JDK6中的常量池是放在永久代的,永久代和Java堆是兩個完全分開的區域。而存在變量使用“+”連接而來的的對象存在Java堆中,且並未將對象存於常量池中,當調用 intern 方法時,如果常量池中已經該字符串,則返回池中的字符串;否則將此字符串添加到常量池中,並返回字符串的引用。所以結果為false。
JDK7JDK8的內存模型如下:
JDK7中,字符串常量池已經被轉移至Java堆中,開發人員也對intern 方法做了一些修改。因為字符串常量池和new的對象都存於Java堆中,為了優化性能和減少內存開銷,當調用 intern 方法時,如果常量池中已經存在該字符串,則返回池中字符串;否則直接存儲堆中的引用,也就是字符串常量池中存儲的是指向堆里的對象。所以結果為true。
String典型案例
關於equals和== :
(1)對於==,如果作用於基本數據類型的變量(byte,short,char,int,long,float,double,boolean ),則直接比較其存儲的"值"是否相等;如果作用於引用類型的變量(String),則比較的是所指向的對象的地址(即是否指向同一個對象)。
(2)equals方法是基類Object中的方法,因此對於所有的繼承於Object的類都會有該方法。在Object類中,equals方法是用來比較兩個對象的引用是否相等。
(3)對於equals方法,注意:equals方法不能作用於基本數據類型的變量。如果沒有對equals方法進行重寫,則比較的是引用類型的變量所指向的對象的地址;而String類對equals方法進行了重寫,用來比較指向的字符串對象所存儲的字符串是否相等。其他的一些類諸如Double,Date,Integer等,都對equals方法進行了重寫用來比較指向的對象所存儲的內容是否相等。
public class StringTest {
public static void main(String[] args) {
// TODO 自動生成的方法存根
/**
* 情景一:字符串池
* JAVA虛擬機(JVM)中存在着一個字符串池,其中保存着很多String對象;
* 並且可以被共享使用,因此它提高了效率。
* 由於String類是final的,它的值一經創建就不可改變。
* 字符串池由String類維護,我們可以調用intern()方法來訪問字符串池。
*/
String s1 = "abc";
//↑ 在字符串池創建了一個對象
String s2 = "abc";
//↑ 字符串pool已經存在對象“abc”(共享),所以創建0個對象,累計創建一個對象
System.out.println("s1 == s2 : "+(s1==s2));
//↑ true 指向同一個對象,
System.out.println("s1.equals(s2) : " + (s1.equals(s2)));
//↑ true 值相等
//↑------------------------------------------------------over
/**
* 情景二:關於new String("")
*
*/
String s3 = new String("abc");
//↑ 創建了兩個對象,一個存放在字符串池中,一個存在與堆區中;
//↑ 還有一個對象引用s3存放在棧中
String s4 = new String("abc");
//↑ 字符串池中已經存在“abc”對象,所以只在堆中創建了一個對象
System.out.println("s3 == s4 : "+(s3==s4));
//↑false s3和s4棧區的地址不同,指向堆區的不同地址;
System.out.println("s3.equals(s4) : "+(s3.equals(s4)));
//↑true s3和s4的值相同
System.out.println("s1 == s3 : "+(s1==s3));
//↑false 存放的地區多不同,一個棧區,一個堆區
System.out.println("s1.equals(s3) : "+(s1.equals(s3)));
//↑true 值相同
//↑------------------------------------------------------over
/**
* 情景三:
* 由於常量的值在編譯的時候就被確定(優化)了。
* 在這里,"ab"和"cd"都是常量,因此變量str3的值在編譯時就可以確定。
* 這行代碼編譯后的效果等同於: String str3 = "abcd";
*/
String str1 = "ab" + "cd"; //1個對象
String str11 = "abcd";
System.out.println("str1 = str11 : "+ (str1 == str11));
//↑------------------------------------------------------over
/**
* 情景四:
* 局部變量str2,str3存儲的是存儲兩個拘留字符串對象(intern字符串對象)的地址。
*
* 第三行代碼原理(str2+str3):
* 運行期JVM首先會在堆中創建一個StringBuilder類,
* 同時用str2指向的拘留字符串對象完成初始化,
* 然后調用append方法完成對str3所指向的拘留字符串的合並,
* 接着調用StringBuilder的toString()方法在堆中創建一個String對象,
* 最后將剛生成的String對象的堆地址存放在局部變量str4中。
*
* 而str5存儲的是字符串池中"abcd"所對應的拘留字符串對象的地址。
* str4與str5地址當然不一樣了。
*
* 內存中實際上有五個字符串對象:
* 三個拘留字符串對象、一個String對象和一個StringBuilder對象。
*/
String str2 = "ab"; //1個對象
String str3 = "cd"; //1個對象
String str4 = str2+str3;
String str5 = "abcd";
System.out.println("str4 = str5 : " + (str4==str5)); // false
//↑------------------------------------------------------over
/**
* 情景五:
* JAVA編譯器對string + 基本類型/常量 是當成常量表達式直接求值來優化的。
* 運行期的兩個string相加,會產生新的對象的,存儲在堆(heap)中
*/
String str6 = "b";
String str7 = "a" + str6;
String str67 = "ab";
System.out.println("str7 = str67 : "+ (str7 == str67));
//↑str6為變量,在運行期才會被解析。
final String str8 = "b";
String str9 = "a" + str8;
String str89 = "ab";
System.out.println("str9 = str89 : "+ (str9 == str89));
//↑str8為常量變量,編譯期會被優化
//↑------------------------------------------------------over
}
}
運行結果:
s1 == s2 : true
s1.equals(s2) : true
s3 == s4 : false
s3.equals(s4) : true
s1 == s3 : false
s1.equals(s3) : true
str1 = str11 : true
str4 = str5 : false
str7 = str67 : false
str9 = str89 : true
String被設計成不可變和不能被繼承的原因
String是不可變和不能被繼承的(final修飾),這樣設計的原因主要是為了設計考慮、效率和安全性。
字符串常量池的需要:
只有當字符串是不可變的,字符串池才有可能實現。字符串池的實現可以在運行時節約很多heap空間,因為不同的字符串變量都指向池中的同一個字符串。假若字符串對象允許改變,那么將會導致各種邏輯錯誤,比如改變一個對象會影響到另一個獨立對象. 嚴格來說,這種常量池的思想,是一種優化手段。
String對象緩存HashCode:
上面解析String類的源碼的時候已經提到了HashCode。Java中的String對象的哈希碼被頻繁地使用,字符串的不可變性保證了hash碼的唯一性。
安全性
首先String被許多Java類用來當參數,如果字符串可變,那么會引起各種嚴重錯誤和安全漏洞。
再者String作為核心類,很多的內部方法的實現都是本地調用的,即調用操作系統本地API,其和操作系統交流頻繁,假如這個類被繼承重寫的話,難免會是操作系統造成巨大的隱患。
最后字符串的不可變性使得同一字符串實例被多個線程共享,所以保障了多線程的安全性。而且類加載器要用到字符串,不可變性提供了安全性,以便正確的類被加載。
學習參考資料:
https://www.cnblogs.com/xiaoxi/p/6036701.html
https://tech.meituan.com/in_depth_understanding_string_intern.html
————————————————
版權聲明:本文為CSDN博主「我的書包哪里去了」的原創文章,遵循CC 4.0 by-sa版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/qq_34490018/article/details/82110578