溫故知新(1)深入認識Java中的字串

2020-09-19 12:03:36

相關學習推薦:

初學Java時我們已經知道Java中可以分為兩巨量資料型別,分別為基本資料型別和參照資料型別。而在這兩巨量資料型別中有一個特殊的資料型別String,String屬於參照資料型別,但又有區別於其它的參照資料型別。可以說它是資料型別中的一朵奇葩。那麼,本篇文章我們就來深入的認識一下Java中的String字串。

一、從String字串的記憶體分配說起

上一篇文章《溫故知新--你不知道的JVM記憶體分配》詳細的分析了JVM的記憶體模型。在常數池部分我們瞭解了三種常數池,分別為:字串常數池、Class檔案常數池以及執行時常數池。而字串的記憶體分配則和字串常數池有著莫大的關係。

我們知道,範例化一個字串可以通過兩種方法來實現,第一種最常用的是通過字面量賦值的方式,另一種是通過構造方法傳參的方式。程式碼如下:

    String str1="abc";
    String str2=new String("abc");複製程式碼

這兩種方式在記憶體分配上有什麼不同呢? 相信大家在初學Java的時候老師都有給我們講解過:

1.通過字面量賦值的方式建立String,只會在字串常數池中生成一個String物件。 2.通過構造方法傳入String引數的方式會在堆記憶體和字串常數池中各生成一個String物件,並將堆記憶體上String的參照放入棧。

這樣的回答正確嗎?至少在現在看來並不完全正確,因為它完全取決於使用的Java版本。上一篇文章《溫故知新--你不知道的JVM記憶體分配》談到HotSpot虛擬機器器在不同的JDK上對於字串常數池的實現是不同的,摘錄如下:

在JDK7以前,字串常數池在方法區(永久代)中,此時常數池中存放的是字串物件。而在JDK7中,字串常數池從方法區遷移到了堆記憶體,同時將字串物件存到了Java堆,字串常數池中只是存入了字串物件的參照。

這句話應該怎麼理解呢?我們以String str1=new String("abc")為例來分析:

1.JDK6中的記憶體分配

先來分析一下JDK6的記憶體分配情況,如下圖所示:

當呼叫new String("abc")後,會在Java堆與常數池中各生成一個「abc」物件。同時,將str1指向堆中的「abc」物件。

2.JDK7中的記憶體分配

而在JDK7及以後版本中,由於字串常數池被移到了堆記憶體,所以記憶體分配方式也有所不同,如下圖所示:

當呼叫了new String("abc")後,會在堆記憶體中建立兩個「abc"物件,str1指向其中一個」abc"物件,而常數池中則會生成一個「abc"物件的參照,並指向另一個」abc"物件。

至於Java中為什麼要這麼設計,我們在上篇文章中也已經解釋了: 因為String是Java中使用最頻繁的一種資料型別,為了節省程式記憶體提高程式效能,Java的設計者們開闢了一塊字串常數池區域,這塊區域是是所有類共用的,每個虛擬機器器只有一個字串常數池。因此,在使用字面量方式賦值的時候,如果字串常數池中已經有了該字串,則不會在堆記憶體中重新建立物件,而是直接將其指向了字串常數池中的物件。

二、String的intern()方法

在瞭解了String的記憶體分配之後,我們需要再來認識一下String中一個很重要的方法:String.intern()。

很多讀者可能對於這一方法並不是太瞭解,但並不代表他不重要。我們先來看一下intern()方法的原始碼:

/**
     * Returns a canonical representation for the string object.
     * <p>
     * A pool of strings, initially empty, is maintained privately by the
     * class {@code String}.
     * <p>
     * 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.
     * <p>
     * It follows that for any two strings {@code s} and {@code t},
     * {@code s.intern() == t.intern()} is {@code true}
     * if and only if {@code s.equals(t)} is {@code true}.
     * <p>
     * All literal strings and string-valued constant expressions are
     * interned. String literals are defined in section 3.10.5 of the
     * <cite>The Java&trade; Language Specification</cite>.
     *
     * @return  a string that has the same contents as this string, but is
     *          guaranteed to be from a pool of unique strings.
     */
    public native String intern();複製程式碼

emmmmm....居然是一個native方法,不過沒關係,即使看不到原始碼我們也能從其註釋中得到一些資訊:當呼叫intern方法的時候,如果字串常數池中已經包含了一個等於該String物件的字串,則直接返回字串常數池中該字串的參照。否則,會將該字串物件包含的字串新增到常數池,並返回此物件的參照。

1.一個關於intern()的簡單例子

瞭解了intern方法的用途之後,來看一個簡單的列子:

public class Test {    public static void main(String[] args) {
        String str1 = "hello world";
        String str2 = new String("hello world");
        String str3=str2.intern();
        System.out.println("str1 == str2:"+(str1 == str2));
        System.out.println("str1 == str3:"+(str1 == str3));
    }
}複製程式碼

上面的一段程式碼會輸出什麼?編譯執行之後如下:

如果理解了intern方法就很容易解釋這個結果了,從上面截圖中可以看到,我們的執行環境是JDK8。

String str1 = "hello world"; 這行程式碼會首先在Java堆中建立一個物件,並將該物件的參照放入字串常數池中,str1指向常數池中的參照。

String str2 = new String("hello world");這行程式碼會通過new來範例化一個String物件,並將該物件的參照賦值給str2,然後檢測字串常數池中是否已經有了與「hello world」相等的物件,如果沒有,則會在堆記憶體中再生成一個值為"hello world"的物件,並將其參照放入到字串常數池中,否則,不會再去建立。這裡,第一行程式碼其實已經在字串常數池中儲存了「hello world」字串物件的參照,因此,第二行程式碼就不會再次向常數池中新增「hello world"的參照。

String str3=str2.intern(); 這行程式碼會首先去檢測字串常數池中是否已經包含了」hello world"的String物件,如果有則直接返回其參照。而在這裡,str2.intern()其實剛好返回了第一行程式碼中生成的「hello world"物件。

因此【System.out.println("str1 == str3:"+(str1 == str3));】這行程式碼會輸出true.

如果切到JDK6,其列印結果與上一致,至於原因讀者可以自行分析。

2.改造例子,再看intern

上一節中我們通過一個例子認識了intern()方法的作用,接下來,我們對上述例子做一些修改:

public class Test {
    public static void main(String[] args) {
        String str1=new String("he")+new String("llo");
        String str2=str1.intern();
        String str3="hello";
        System.out.println("str1 == str2:"+(str1 == str2));
        System.out.println("str2 == str3:"+(str2 == str3)); 
    }
}複製程式碼

先別急著看下方答案,思考一下在JDK7(或JDK7之後)及JDK6上會輸出什麼結果?

1).JDK8的執行結果分析

我們先來看下我們先來看下JDK8的執行結果:

通過執行程式發現輸出的兩個結果都是true,這是為什麼呢?我們通過一個圖來分析:

String str1=new String("he")+new String("llo"); 這行程式碼中new String("he")和new String("llo")會在堆上生成四個物件,因為與本例無關,所以圖上沒有畫出,new String("he")+new String("llo")通過」+「號拼接後最終會生成一個"hello"物件並賦值給str1。

String str2=str1.intern(); 這行程式碼會首先檢測字串常數池,發現此時還沒有存在與」hello"相等的字串物件的參照,而在檢測堆記憶體時發現堆中已經有了「hello"物件,遂將堆中的」hello"物件的應用放入字串常數池中。

String str3="hello"; 這行程式碼發現字串常數池中已經存在了「hello"物件的參照,因此將str3指向了字串常數池中的參照。

此時,我們發現str1、str2、str3指向了堆中的同一個」hello"物件,因此,就有了上邊兩個均為true的輸出結果。

2).JDK6的執行結果分析

我們將執行環境切換到JDK6,來看下其輸出結果:

有點意思!相同的程式碼在不同的JDK版本上輸出結果竟然不相等。這是怎麼回事呢?我們還通過一張圖來分析:

String str1=new String("he")+new String("llo"); 這行程式碼會通過new String("he")和new String("llo")會分別在Java堆與字串常數池中各生成兩個String物件,由於與本例無關,所以並沒有在圖中畫出。而new String("he")+new String("llo")通過「+」號拼接後最終會在Java堆上生成一個"hello"物件,並將其賦值給了str1。

String str2=str1.intern(); 這行程式碼檢測到字串常數池中還沒有「hello"物件,因此將堆中的」hello「物件複製到了字串常數池,並將其賦值給str2。

String str3="hello"; 這行程式碼檢測到字串常數池中已經有了」hello「物件,因此直接將str3指向了字串常數池中的」hello「物件。 此時str1指向的是Java堆中的」hello「物件,而str2和str3均指向了字串常數池中的物件。因此,有了上面的輸出結果。

通過這兩個例子,相信大家因該對String的intern()方法有了較深的認識。那麼intern()方法具體在開發中有什麼用呢?推薦大家可以看下美團技術團隊的一篇文章《深入解析String#intern》中舉的兩個例子。限於篇幅,本文不再舉例分析。

三、String類的結構及特性分析

前兩節我們認識了String的記憶體分配以及它的intern()方法,這兩節內容其實都是對String記憶體的分析。到目前為止,我們還並未認識String類的結構以及它的一些特性。那麼本節內容我們就此來分析。先通過一段程式碼來大致瞭解一下String類的結構(程式碼取自jdk8):

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
        //  ...}複製程式碼

可以看到String類實現了Serializable介面、Comparable介面以及CharSequence介面,意味著它可以被序列化,同時方便我們排序。另外,String類還被宣告為了final型別,這意味著String類是不能被繼承的。而在其內部維護了一個char陣列,說明String是通過char陣列來實現的,同時我們注意到這個char陣列也被宣告為了final,這也是我們常說的String是不可變的原因。

1.不同JDK版本之間String的差異

Java的設計團隊一直在對String類進行優化,這就導致了不同jdk版本上String類的實現有些許差異,只是我們使用上並無感知。下圖列出了jdk6-jdk9中String原始碼的一些變化。

可以看到在Java6之前String中維護了一個char 陣列、一個偏移量 offset、一個字元數量 count以及一個雜湊值 hash。 String物件是通過 offset 和 count 兩個屬性來定位 char[] 陣列,獲取字串。這麼做可以高效、快速地共用陣列物件,同時節省記憶體空間,但這種方式很有可能會導致記憶體漏失。

在Java7和Java8的版本中移除了 offset 和 count 兩個變數了。這樣的好處是String物件佔用的記憶體稍微少了些,同時 String.substring 方法也不再共用 char[],從而解決了使用該方法可能導致的記憶體漏失問題。

從Java9開始,String中的char陣列被byte[]陣列所替代。我們知道一個char型別佔用兩個位元組,而byte佔用一個位元組。因此在儲存單位元組的String時,使用char陣列會比byte陣列少一個位元組,但本質上並無任何差別。 另外,注意到在Java9的版本中多了一個coder,它是編碼格式的標識,在計算字串長度或者呼叫 indexOf() 函數時,需要根據這個欄位,判斷如何計算字串長度。coder 屬性預設有 0 和 1 兩個值, 0 代表Latin-1(單位元組編碼),1 代表 UTF-16 編碼。如果 String判斷字串只包含了 Latin-1,則 coder 屬性值為 0 ,反之則為 1。

2.String字串的裁剪、拼接等操作分析

在本節內容的開頭我們已經知道了字串的不可變性。那麼為什麼我們還可以使用String的substring方法進行裁剪,甚至可以直接使用」+「連線符進行字串的拼接呢?

(1)String的substring實現

關於substring的實現,其實我們直接深入String的原始碼檢視即可,原始碼如下:

    public String substring(int beginIndex) {            if (beginIndex < 0) {                throw new StringIndexOutOfBoundsException(beginIndex);
            }            int subLen = value.length - beginIndex;            if (subLen < 0) {                throw new StringIndexOutOfBoundsException(subLen);
            }            return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
        }複製程式碼

從這段程式碼中可以看出,其實字串的裁剪是通過範例化了一個新的String物件來實現的。所以,如果在專案中存在大量的字串裁剪的程式碼應儘量避免使用String,而是使用效能更好的StringBuilder或StringBuffer來處理。

(2)String的字串拼接實現

1)字串拼接方案效能對比

關於字串的拼接有很多實現方法,在這裡我們舉三個例子來進行一個效能對比,分別如下:

使用」+「操作符拼接字串

    public class Test {        private static final int COUNT=50000;        public static void main(String[] args) {
            String str="";            for(int i=0;i<COUNT;i++) {
                str=str+"abc";
            }
    }複製程式碼

使用String的concat()方法拼接

    public class Test {        private static final int COUNT=50000;        public static void main(String[] args) {
            String str="";            for(int i=0;i<COUNT;i++) {
                str=str+"abc";
            }
    }複製程式碼

使用StringBuilder的append方法拼接

    public class Test {        private static final int COUNT=50000;        public static void main(String[] args) {
            StringBuilder str=new StringBuilder();            for(int i=0;i<COUNT;i++) {
                str.append("abc");
            }
    }複製程式碼

如上程式碼,通過三種方法分別進行了50000次字串拼接,每種方法分別執行了20次。統計耗時,得到以下表格:

拼接方法最小用時(ms)最大用時(ms)平均用時(ms)
"+"操作符486851464924
String的concat方法222724562296
StringBuilder的append方法4126.6

從以上資料中可以很直觀的看到」+「操作符的效能是最差的,平均用時達到了4924ms。其次是String的concat方法,平均用時也在2296ms。而表現最為優秀的是StringBuilder的append方法,它的平均用時竟然只有6.6ms。這也是為什麼在開發中不建議使用」+「操作符進行字串拼接的原因。

2)三種字串拼接方案原理分析

」+「操作符的實現原理由於」+「操作符是由JVM來完成的,我麼無法直接看到程式碼實現。不過Java為我們提供了一個javap的工具,可以幫助我們將Class檔案進行一個反組合,通過組合指令,大致可以看出」+「操作符的實現原理。

    public class Test {        private static final int COUNT=50000;        public static void main(String[] args) {            for(int i=0;i<COUNT;i++) {
                str=str+"abc";
            }
    }複製程式碼

把上邊這段程式碼編譯後,執行javap,得到如下結果:

注意圖中的」11:「行指令處範例化了一個StringBuilder,在"19:"行處呼叫了StringBuilder的append方法,並在第」27:"行處呼叫了String的toString()方法。可見,JVM在進行」+「字串拼接時也是用了StringBuilder來實現的,但為什麼與直接使用StringBuilder的差距那麼大呢?其實,只要我們將上邊程式碼轉換成虛擬機器器優化後的程式碼一看便知:

    public class Test {        private static final int COUNT=50000;        public static void main(String[] args) {
            String str="";            for(int i=0;i<COUNT;i++) {
                str=new StringBuilder(str).append("abc").toString();
            }
    }複製程式碼

可見,優化後的程式碼雖然也是用的StringBuilder,但是StringBuilder卻是在迴圈中範例化的,這就意味著迴圈了50000次,建立了50000個StringBuilder物件,並且呼叫了50000次toString()方法。怪不得用了這麼長時間!!!

String的concat方法的實現原理關於concat方法可以直接到String內部檢視其原始碼,如下:

public String concat(String str) {        int otherLen = str.length();        if (otherLen == 0) {            return this;
        }        int len = value.length;        char buf[] = Arrays.copyOf(value, len + otherLen);
        str.getChars(buf, len);        return new String(buf, true);
    }複製程式碼

可以看到,在concat方法中使用Arrays的copyOf進行了一次陣列拷貝,接下來又通過getChars方法再次進行了陣列拷貝,最後通過new範例化了String物件並返回。這也意味著每呼叫一次concat都會生成一個String物件,但相比」+「操作符卻省去了toString方法。因此,其效能要比」+「操作符好上不少。

至於StringBuilder其實也沒必要再去分析了,畢竟」+「操作符也是基於StringBuilder實現的,只不過拼接過程中」+「操作符建立了大量的物件。而StringBuilder拼接時僅僅建立了一個StringBuilder物件。

四、總結

本篇文章我們深入分析了String字串的記憶體分配、intern()方法,以及String類的結構及特性。關於這塊知識,網上的文章魚龍混雜,甚至眾說紛紜。筆者也是參考了大量的文章並結合自己的理解來做的分析。但是,避免不了的可能會出現理解偏差的問題,如果有,希望大家多多討論給予指正。 同時,文章中多次提到StringBuilder,但限於文章篇幅,沒能給出關於其詳細分析。不過不用擔心,我會在下一篇文章中再做探討。 不管怎樣,相信大家看完這篇文章後一定 對String有了更加深入的認識,尤其是瞭解String類的一些裁剪及拼接中可能造成的效能問題,在今後的開發中應該儘量避免。

以上就是溫故知新(1)深入認識Java中的字串的詳細內容,更多請關注TW511.COM其它相關文章!