Java 字符串常量池、字符串比較/拼接問題、String類的不可變性


@


Java中用於處理字符串常用的有三個類:

1、java.lang.String

2、java.lang.StringBuffer

3、java.lang.StrungBuilder

相同點: 都是final類, 不允許被繼承;

不同點:

  • StringBuffered/StringBuilder 都繼承自抽象類AbstractStringBuilder
    (實現了Appendable, CharSequence接口),可以通過append()、insert()等方法進行字符串的操作
  • String實現了三個接口: Serializable、Comparable 、CarSequence,
    String的實例可以通過compareTo方法進行比較
    StringBuilder/StringBuffer只實現了兩個接口Serializable、CharSequence
  • StringBuffer是線程安全的(Synchronized 加鎖),可以不需要額外的同步用於多線程中
    StringBuilder不是線程安全的,但是效率比StringBuffer高

本篇主要討論String類型

1.字符串的比較

1. 1 字符串常量池

字符串常量池(以下簡稱常量池/字符串池)的存在意義:實際開發中,String類是使用頻率非常高的一種引用對象類型。但是不斷地創建新的字符串對象,會極大地消耗內存。因此,JVM為了提升性能和減少內存開銷,內置了一塊特殊的內存空間即常量池,以此來避免字符串的重復創建。JDK 1.8 后,常量池被放入到堆空間中。

字符串常量池的特點是有且只有一份相同的字面量,如果有其它相同的字面量,JVM則返回這個字面量的引用地址,如果沒有相同的字面量,則在字符串常量池創建這個字面量並返回它的引用地址。

字符串常量池是全局共享的,故也稱全局字符串池。字符串池中維護了共享的字符串對象,這些字符串不會被垃圾收集器回收。

1.1.1 字符串常量池在Java內存區域的存放位置?

在 JDK6.0及之前版本,字符串常量池是放在 Perm Gen區 (也就是方法區) 中,方法區與堆分離。
由於方法區的內存空間太小,在 JDK 7.0版本,字符串常量池被移到了堆中。

1.1.2 字符串常量池是如何實現的?

在 HotSpot 虛擬機里實現的 String Pool 功能的是一個 StringTable 類,它是一個 Hash 表,默認值大小長度是1009;這個 StringTable 在每個 HotSpot 虛擬機的實例只有一份,被所有的類共享。字符串常量由一個一個字符組成,放在了 StringTable 上。

在 JDK 6.0中,StringTable 的長度是固定的,長度就是1009,因此如果放入 String Pool 中的String類字符串非常多,就會造成哈希沖突,導致鏈表過長,當調用 String.intern()時會需要到鏈表上一個一個查找,從而導致性能大幅度下降。

在 JDK7.0中,StringTable的長度可以通過參數指定:
-XX:StringTableSize=66666

1.2 String 類型的比較方式

若直接使用“==”進行比較對象,則比較的是兩個對象的引用地址;

若使用str1.equals(str2)方法進行比較,由於String類內部已經覆蓋Object類中的equals()方法,實際比較的是兩個字符串的值。

  • 比較原理:
    先判斷對象地址是否相等,若相等則直接返回true;
    若不相等再去參數判斷括號內傳入的參數是否為String類型的:若不是字符串將最終返回false;若是字符串,再依次比較所有字符是否一樣。
// 源碼
public boolean equals(Object anObject) {
	if (this == anObject) {
    	return true;
    }
    if (anObject instanceof String) {
        String aString = (String)anObject;
        if (coder() == aString.coder()) {
        	return isLatin1() ? StringLatin1.equals(value, aString.value) : StringUTF16.equals(value, aString.value);
        }
     }
     return false;
}

1.3 String 的創建方式

1.3.1 直接使用“=”進行字面量賦值

String str_01 = "aa";
String str_02 = "aa";
System.out.println(str_01 == str_02);

.java文件編譯后得到 .class文件,里面包含了類的信息,其中有一塊叫做常量池(Constant Pool)的區域 (.class常量池和內存中的 String Pool 並不是同一個東西),.class文件常量池主要存儲的就包括字面量,字面量包括類中定義的常量,由於String是不可變的(String為什么是不可變的?)所以字符串“Hello”就存放在 .class文件常量池里。

當程序調用類時,.class文件被解析到內存中的方法區,同時 .class文件中的常量池信息會被加載到運行時常量池。但 String類不會這樣,“aa”會在堆區中創建一個對象,同時會在 String Pool 存放一個它的引用。

此時類剛剛被加載,main函數中的 str_01 並沒有被創建,而“aa”對象已經創建在於堆中。
當主線程開始創建 str_01 變量時,JVM會去 String Pool 中查找是否有 與“aa”相等的String,如果相等就把在字符串池中“Hello”的引用賦值給str。如果找不到相等的字符串,就會在堆中新建一個字符串對象,同時把引用駐留在字符串池,再把引用賦給str。

因此,當用字面量賦值的方法創建字符串時,無論創建多少次,只要String的值相同,它們所指向的都是堆中的同一個對象。

// result
true

1.3.2 使用“new”關鍵字創建新對象

String str_01 = new String("xyz");
String str_02 = new String("xyz");
System.out.println(str_01 == str_02);

當利用new關鍵字去創建字符串時,之前的加載過程是一樣的,只是在運行時無論 String Pool 中有沒有與 String構造器內傳入參數面值相等的字符串,都會在堆中新開辟一塊內存,創建一個字符串對象。

因為本質是調用了 String類的構造器方法 public String(String original){...},所以在堆中一定會創建一個字符串對象。

故使用"new"關鍵字創造對象主要分為三步:

  1. 首先會在堆中創建一個字符串對象;
  2. 判斷 String Pool 是否存在與構造器參數中的字符串值相等的常量;
  3. 如果 String Pool 中已有這樣的字符串值存在,則直接返回堆中的字符串實例對象地址,賦值給棧中的變量;如果不存在,會先創建一個這樣的字面量在 String Pool 中,同時把引用駐留在字符串池,再返回它的引用,賦值給棧中的變量。
    ( String Pool 中存的對象是引用值而不是具體的實例對象,具體的實例對象是在堆中開辟的一塊空間存放的。)
// result
false

1.3.3 intern() 方法返回的引用地址

String str_01 = new String("abc");
String s1_Intern = str_01.intern();
String str_02 = "abc";
String s2_Intern = str_01.intern();
String str_03 = new String("abc");
String s3_Intern = str_03.intern();

System.out.println(str_01 == str_02);
System.out.println(str_02 == str_03);
System.out.println(s1_Intern == str_02);
System.out.println(s2_Intern == str_02);
System.out.println(s3_Intern == str_02);

String str_04 = "cba";
String str_05 = new String("cba");
String s5_Intern = str_05.intern();
System.out.println(str_04 == str_05);
System.out.println(str_04 == s5_Intern);

intern() 方法能使一個位於堆中的字符串在運行期間動態地加入到 String Pool 中( String Pool 的內容是程序啟動的時候就已經加載好了),如果 String Pool 中有該對象對應的字面量,則返回該池中該字面量的引用,否則,存儲一份該字面量的引用到 String Pool 中並將該引用返回,但這個引用實際指向堆中的對象。

// result
false
false
true
true
true

false
true

2. 字符串類的可變性與不可變性

String類字符串的本質:
事實上,Java中並沒有內置的字符串類型,而是在Java 標准類庫中提供了一個類名恰好為"String"的預定義類,所有由雙引號""括起來的內容都是String類的一個實例。

  • String類的說明,在API中有明確的給出解釋:
    The {@code String} class represents character strings. All string literals in Java programs, such as {@code "abc"}, are implemented as instances of this class.

String在Java 9 之前使用 private final char[] str 保存數據,從Java 9 開始及后續版本使用private final byte[] value保存數據,並有一個coder標志符,來表示數據是用哪一種編碼保存的,以方便之后的方法進行區分對待。同時,這個改變使字符串能夠占用更少的空間,由原來實現的數組是2個字節長度的char類型數組更改為byte數組后,每個元素只有1個字節的長度。

值得注意的是,JVM並不一定把字符串實現為代碼單元序列。Java 9 后,只包含單字節代碼單元的字符串使用bytes數組實現,其他字符串則由char數組實現。

String類型的不可變性指的是內存地址不可變,如果將一個字符串變量重新賦值,則本質上是改變了其引用對象。 這一點也可以從API的文檔中可以看出,String類是final的,並且沒有提供可以修改字符串的方法,所有看似修改了字符串的方法實際上是創建並返回了一個新的字符串。

String a = "hello";
System.out.println(a.hashCode());
a = "hello";
System.out.println(a.hashCode());
String b = a.toUpperCase();
System.out.println(a==b);
// result
99162322
103196
false

StringBuffer 類型和StringBuilder 類型也被final修飾,但這兩種類型的字符串定義好后可雖不會創建新的內存地址,但可以進行值改變。

StringBuilder a = new StringBuilder();
System.out.println(a.hashCode());
a.append("Hello");
a.append("World");
System.out.println(a.hashCode());
// result
1395089624
1395089624

3. 字符串的相加/拼接

3.1 字符串與非字符串類型的相加/拼接

String類中的valueOf(Object obj)方法可以將任意一個對象轉換為字符串類型。

// 源碼
public static String valueOf(Object obj) {
  return (obj == null) ? "null" : obj.toString();
}

String類中,重載了+與+=運算,這也是Java中唯一重載的兩個運算符。

兩個字符串相加即是字符串的拼接,在進行拼接時,會先調用valueOf(Object obj)方法將其為字符串類型,再進行拼接。從源碼可以看出,如果字符串為null,會將其轉換為字面值為"null"的字符串。

String s = null;
s = s + "World";
System.out.println("Hello " +s);
// result: Hello nullWorld

因此在進行字符串拼接時,初始字符串應該設置成空字符串"",而非null。

3.2 兩個String類型對象相加/拼接原理

在字符串間使用加法運算時:

  • 若是常量字符串相加,如: "AB"+"CD",則是編譯優化。
    凡是單獨使用雙引號" "引用起來的內容直接拼接時,均會被編譯優化,編譯時就已經確定其值,即為拼接后的值。
  • 若是字符串變量相加,如:
    String temp1 = "AB";
    String temp2 = "CD";
    String str = temp1 + temp2;
    則是在底層調用了StringBuilder類中的構造方法、append()方法和toString()方法來輔助完成:
    String str = new StringBuilder().append(temp1).append(temp2).toString();
		String str1 = "ABCD";
		String str2 = "AB" + "CD";
		String str3 = "A" + "B" + "C" + "D";
		String temp1 = "AB";
		String temp2 = "CD";
		String str4 = temp1 + temp2;
		// String str4 = new StringBuilder().append(temp1).append(temp2).toString();
		
		String temp = "AB";
		String str5 = temp + "CD";
		// String str5 = new StringBuilder(String.valueOf(temp)).append("CD").toString();
		
		System.out.println(str1 == str2);
		System.out.println(str1 == str3);
		System.out.println(str1 == str4);
		System.out.println(str1 == str5);
// result
true
true
false
false

4. final類型的String類字符串

首先要明確:final關鍵字修飾的變量,若為基本數據類型則值無法改變,若是引用數據類型則引用地址不能改變。

4.1 final修飾靜態類變量

4.1.1 直接聲明字段時賦值

public class test {
	public static final String str1 = "abc";
	public static final String str2 = "def";
	public static void main(String[] args) {
		String str3 = str1 + str2;
		String str4 = "abcdef";
		System.out.println(str3 == str4);
	}
}
  • 如果使用final和static同時修飾一個字段,並直接定義時賦值,並且這個字段是基本數據類型或者String類型的,那么編譯器在編譯這個字段的時候,會在類的字段屬性表中一個ConstantValue屬性,在 JVM加載類的准備階段變量就會被初始化為ConstValue屬性所指定的常量值。

  • 如果該field字段並沒有被final修飾,或者不是基本數據類型或者String類型,那么將在靜態區域或構造方法中進行初始化。

這里的str1和str2都是static和final類型的,因此在編譯時,在 JVM加載類的准備階段會被賦值了,相當於一個常量,當執行Strings str3 = str1 + str2 的時候,str3已經是"abcdef"常量了,已被添加在常量池中,所以地址是相等的。

// result
true

4.1.2 靜態區域內初始化

public class test {
public static final String s1;
public static final String s2;
	static{
	s1 = "ab";
	s2 = "cd";
	}
	public static void main(String[] args) {
		String s3 = s1 + s2;
		String s4 = "abcd";
		System.out.println(s3 == s4); 
	}
}

雖然s1和s2都是final類型,但卻是在靜態區域完成初始化,其本質仍然是變量,只不過變量的值不能改變。因此在拼接時仍然會調用 StringBuilder類中的構造方法、append()方法和toString()方法來創建新的字符串s3,返回的是新字符串s3在堆中的地址,所以與s4不相等。

// result
false

4.2 final修飾成員變量

由於修飾的是成員變量,所以其初始化一定發生在構造器中,本質仍然是值固定的變量。在拼接時同樣會創建新的字符串。

public class Test {

	private final String str1 = "AB";
	private final String str2;
	
	public Test(){
		str2 = "AB";
		}
	
	public static void main(String[] args) {
		Test t = new Test();
		String s = "ABC";
		String s1 = t.str1 + "C";
		String s2 = t.str2 + "C";
		System.out.println(s == s1);
		System.out.println(s == s2);
		System.out.println(s1 == s2);
	}
}
/* result */
// false
// false
// false

參考內容:

  1. Java-String常量池的知識點你知道多少?-結合jdk版本變更 by hz90s
  2. "Core Java Volume I - Fundamentals" (11 Edition) by Cay S. Horstmann
  3. static final的初始化與賦值問題 by 一毛六ABV
  4. Java中的常量池(字符串常量池、class常量池和運行時常量池) by shoshana
  5. 理解Java字符串常量池與intern()方法 by 沒課割綠地

If you have any question, please let me know, your words are always welcome.
新人入坑,如有錯誤/不妥之處,歡迎指出,共同學習。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM