1. 程式人生 > >java中的string物件深入瞭解

java中的string物件深入瞭解

這裡來對Java中的String物件做一個稍微深入的瞭解。

Java物件實現的演進

String物件是Java中使用最頻繁的物件之一,所以Java開發者們也在不斷地對String物件的實現進行優化,以便提升String物件的效能。

Java6以及之前版本中String物件的屬性

在Java6以及之前版本中,String物件是對char陣列進行了封裝實現的物件,其主要有4個成員成員變數,分別是char陣列、偏移量offset、字元數量count和雜湊值hash。String物件是通過offset和count兩個屬性來定位char[]陣列,獲取字串。這樣做可以高效、快速地共享陣列物件,同時節省記憶體空間,但是這種方式卻可能會導致記憶體洩漏的發生。

Java7、8版本中String物件的屬性

從Java7版本開始,Java對String類做了一些改變,具體是String類不再有offset和count兩個變量了。這樣做的好處是String物件佔用的記憶體稍微少了點,同時String.substring()方法也不再共享char[]了,從而解決了使用該方法可能導致的記憶體洩漏問題。

Java9以及之後版本中String物件的屬性

從Java9版本開始,Java將char[]陣列改為了byte[]陣列。我們都知道,char是兩個位元組的,如果用來存一個位元組的情況下就會造成記憶體空間的浪費。而為了節約這一個位元組的空間,Java開發者就改成了一個使用一個位元組的byte來儲存字串。

另外,在Java9中,String物件維護了一個新的屬性coder,這個屬性是編碼格式的標識,在計算字串長度或者呼叫indexOf()方法的時候,會需要根據這個欄位去判斷如何計算字串長度。coder屬性預設有0和1兩個值,其中0代表Latin-1(單位元組編碼),1則表示UTF-16編碼。

String物件的建立方式與在記憶體中的存放

在Java中,對於基本資料型別的變數和對物件的引用,儲存在棧記憶體的區域性變量表中;而通過new關鍵字和Constructor建立的物件,則是儲存在堆記憶體中。而String物件的建立方式一般為兩種,一種是字面量(字串常量)的方式,一種則是建構函式(String())的方式,兩種方式在記憶體中的存放有所不同。

字面量(字串常量)的建立方式

使用字面量的方式建立字串時,JVM會在字串常量池中先檢查是否存在該字面量,如果存在,則返回該字面量在記憶體中的引用地址;如果不存在,則在字串常量池中建立該字面量並返回引用。使用這種方式建立的好處是避免了相同值的字串在記憶體中被重複建立,節約了記憶體,同時這種寫法也會比較簡單易讀一些。

String str = "i like yanggb.";

字串常量池

這裡要特別說明一下常量池。常量池是JVM為了減少字串物件的重複建立,特別維護了一個特殊的記憶體,這段記憶體被稱為字串常量池或者字串字面量池。在JDK1.6以及之前的版本中,執行時常量池是在方法區中的。在JDK1.7以及之後版本的JVM,已經將執行時常量池從方法區中移了出來,在Java堆(Heap)中開闢了一塊區域用來存放執行時常量池。而從JDK1.8開始,JVM取消了Java方法區,取而代之的是位於直接記憶體的元空間(MetaSpace)。總結就是,目前的字串常量池在堆中。

我們所知道的幾個String物件的特點都來源於String常量池。

1.在常量池中會共享所有的String物件,因此String物件是不可被修改的,因為一旦被修改,就會導致所有引用此String物件的變數都隨之改變(引用改變),所以String物件是被設計為不可修改的,後面會對這個不可變的特性做一個深入的瞭解。

2.String物件拼接字串的效能較差的說法也是來源於此,因為String物件不可變的特性,每次修改(這裡是拼接)都是返回一個新的字串物件,而不是再原有的字串物件上做修改,因此建立新的String物件會消耗較多的效能(開闢另外的記憶體空間)。

3.因為常量池中建立的String物件是共享的,因此使用雙引號宣告的String物件(字面量)會直接儲存在常量池中,如果該字面量在之前已存在,則是會直接引用已存在的String物件,這一點在上面已經描述過了,這裡再次提及,是為了特別說明這一做法保證了在常量池中的每個String物件都是唯一的,也就達到了節約記憶體的目的。

建構函式(String())的建立方式

使用建構函式的方式建立字串時,JVM同樣會在字串常量池中先檢查是否存在該字面量,只是檢查後的情況會和使用字面量建立的方式有所不同。如果存在,則會在堆中另外建立一個String物件,然後在這個String物件的內部引用該字面量,最後返回該String物件在記憶體地址中的引用;如果不存在,則會先在字串常量池中建立該字面量,然後再在堆中建立一個String物件,然後再在這個String物件的內部引用該字面量,最後返回該String物件的引用。

String str = new String("i like yanggb.");

這就意味著,只要使用這種方式,建構函式都會另行在堆記憶體中開闢空間,建立一個新的String物件。具體的理解是,在字串常量池中不存在對應的字面量的情況下,new String()會建立兩個物件,一個放入常量池中(字面量),一個放入堆記憶體中(字串物件)。

String物件的比較

比較兩個String物件是否相等,通常是有【==】和【equals()】兩個方法。

在基本資料型別中,只可以使用【==】,也就是比較他們的值是否相同;而對於物件(包括String)來說,【==】比較的是地址是否相同,【equals()】才是比較他們內容是否相同;而equals()是Object都擁有的一個函式,本身就要求對內部值進行比較。

String str = "i like yanggb.";
String str1 = new String("i like yanggb.");

System.out.println(str == str1); // false
System.out.println(str.equals(str1)); // true

因為使用字面量方式建立的String物件和使用建構函式方式建立的String物件的記憶體地址是不同的,但是其中的內容卻是相同的,也就導致了上面的結果。

String物件中的intern()方法

我們都知道,String物件中有很多實用的方法。為什麼其他的方法都不說,這裡要特別說明這個intern()方法呢,因為其中的這個intern()方法最為特殊。它的特殊性在於,這個方法在業務場景中幾乎用不上,它的存在就是在為難程式設計師的,也可以說是為了幫助程式設計師瞭解JVM的記憶體結構而存在的(?我信你個鬼,你個糟老頭子壞得很)。

/**
* When the intern method is invoked, if the pool already contains a
* string equal to this {@code String} object as determined by
* the {@link #equals(Object)} method, then the string from the pool is
* returned. Otherwise, this {@code String} object is added to the
* pool and a reference to this {@code String} object is returned.
**/
public native String intern();

上面是原始碼中的intern()方法的官方註釋說明,大概意思就是intern()方法用來返回常量池中的某字串,如果常量池中已經存在該字串,則直接返回常量池中該物件的引用。否則,在常量池中加入該物件,然後返回引用。然後我們可以從方法簽名上看出intern()方法是一個native方法。

下面通過幾個例子來詳細瞭解下intern()方法的用法。

第一個例子

String str1 = new String("1");
System.out.println(str1 == str1.intern()); // false
System.out.println(str1 == "1"); // false

在上面的例子中,intern()方法返回的是常量池中的引用,而str1儲存的是堆中物件的引用,因此兩個列印語句的結果都是false。

第二個例子

String str2 = new String("2") + new String("3");
System.out.println(str2 == str2.intern()); // true
System.out.println(str2 == "23"); // true

在上面的例子中,str2儲存的是堆中一個String物件的引用,這和JVM對【+】的優化有關。實際上,在給str2賦值的第一條語句中,建立了3個物件,分別是在字串常量池中建立的2和3、還有在堆中建立的字串物件23。因為字串常量池中不存在字串物件23,所以這裡要特別注意:intern()方法在將堆中存在的字串物件加入常量池的時候採取了一種截然不同的處理方案——不是在常量池中建立字面量,而是直接將該String物件自身的引用複製到常量池中,即常量池中儲存的是堆中已存在的字串物件的引用。根據前面的說法,這時候呼叫intern()方法,就會在字串常量池中複製出一個對堆中已存在的字串常量的引用,然後返回對字串常量池中這個對堆中已存在的字串常量池的引用的引用(就是那麼繞,你來咬我呀)。這樣,在呼叫intern()方法結束之後,返回結果的就是對堆中該String物件的引用,這時候使用【==】去比較,返回的結果就是true了。同樣的,常量池中的字面量23也不是真正意義的字面量23了,它真正的身份是堆中的那個String物件23。這樣的話,使用【==】去比較字面量23和str2,結果也就是true了。

第三個例子

String str4 = "45";
String str3 = new String("4") + new String("5");
System.out.println(str3 == str3.intern()); // false
System.out.println(str3 == "45"); // false

這個例子乍然看起來好像比前面的例子還要複雜,實際上卻和上面的第一個例子是一樣的,最難理解的反而是第二個例子。

所以這裡就不多說了,而至於為什麼還要舉這個例子,我相信聰明的你一下子就明白了(我有醫保,你來打我呀)。

String物件的不可變性

先來看String物件的一段原始碼。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** 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類用了final修飾符,這就意味著這個類是不能被繼承的,這是決定String物件不可變特性的第一點。從類中的陣列char[] value來看,這個類成員變數被private和final修飾符修飾,這就意味著其數值一旦被初始化之後就不能再被更改了,這是決定String物件不可變特性的第二點。

Java開發者為什麼要將String物件設定為不可變的,主要可以從以下三個方面去考慮:

1.安全性。假設String物件是可變的,那麼String物件將可能被惡意修改。

2.唯一性。這個做法可以保證hash屬性值不會頻繁變更,也就確保了唯一性,使得類似HashMap的容器才能實現相應的key-value快取功能。

3.功能性。可以實現字串常量池(究竟是先有設計,還是先有實現呢)。

String物件的優化

字串是常用的Java型別之一,所以對字串的操作是避免不了的。而在對字串的操作過程中,如果使用不當的話,效能可能會有天差地別,所以有一些地方是要注意一下的。

拼接字串的效能優化

字串的拼接是對字串的操作中最頻繁的一個使用。由於我們都知道了String物件的不可變性,所以我們在開發過程中要儘量減少使用【+】進行字串拼接操作。這是因為使用【+】進行字串拼接,會在得到最終想要的結果前產生很多無用的物件。

String str = 'i';
str = str + ' ';
str = str + 'like';
str = str + ' ';
str = str + 'yanggb';
str = str + '.';

System.out.println(str); // i like yanggb.

事實上,如果我們使用的是比較智慧的IDE編寫程式碼的話,編譯器是會提示將程式碼優化成使用StringBuilder或者StringBuffer物件來優化字串的拼接效能的,因為StringBuilder和StringBuffer都是可變物件,也就避免了過程中產生無用的物件了。而這兩種替代方案的區別是,在需要執行緒安全的情況下,選用StringBuffer物件,這個物件是支援執行緒安全的;而在不需要執行緒安全的情況下,選用StringBuilder物件,因為StringBuilder物件的效能在這種場景下,要比StringBuffer物件或String物件要好得多。

使用intern()方法優化記憶體佔用

前面吐槽了intern()方法在實際開發中沒什麼用,這裡又來說使用intern()方法來優化記憶體佔用了,這人真的是,嘿嘿,真香。關於方法的使用就不說了,上面有詳盡的用法說明,這裡來說說具體的應用場景好了。有一位Twitter的工程師在Qcon全球軟體開發大會上分享了一個他們對String物件優化的案例,他們利用了這個String.intern()方法將以前需要20G記憶體儲存優化到只需要幾百兆記憶體。具體就是,使用intern()方法將原本需要建立到堆記憶體中的String物件都放到常量池中,因為常量池的不重複特性(存在則返回引用),也就避免了大量的重複String物件造成的記憶體浪費問題。

什麼,要我給intern()方法道歉?不可能。String.intern()方法雖好,但是也是需要結合場景來使用的,並不能夠亂用。因為實際上,常量池的實現是類似於一個HashTable的實現方式,而HashTable儲存的資料越大,遍歷的時間複雜度就會增加。這就意味著,如果資料過大的話,整個字串常量池的負擔就會大大增加,有可能效能不會得到提升卻反而有所下降。

字串分割的效能優化

字串的分割是字串操作的常用操作之一,對於字串的分割,大部分人使用的都是split()方法,split()方法在大部分場景下接收的引數都是正則表示式,這種分割方式本身沒有什麼問題,但是由於正則表示式的效能是非常不穩定的,使用不恰當的話可能會引起回溯問題並導致CPU的佔用居高不下。在以下兩種情況下split()方法不會使用正則表示式:

1.傳入的引數長度為1,且不包含“.$|()[{^?*+\”regex元字元的情況下,不會使用正則表示式。

2.傳入的引數長度為2,第一個字元是反斜槓,並且第二個字元不是ASCII數字或ASCII字母的情況下,不會使用正則表示式。

所以我們在字串分割時,應該慎重使用split()方法,而首先考慮使用String.indexOf()方法來進行字串分割,在String.indexOf()無法滿足分割要求的時候再使用Split()方法。而在使用split()方法分割字串時,需要格外注意回溯問題。

總結

雖然說在不瞭解String物件的情況下也能使用String物件進行開發,但是瞭解String物件可以幫助我們寫出更好的程式碼。

 

"只希望在故事的最後,我還是我,你也還是你