學習一下 JVM (二) -- 學習一下 JVM 中對象、String 相關知識


一、JDK 8 版本下 JVM 對象的分配、布局、訪問(簡單了解下)

1、對象的創建過程

(1)前言
  Java 是一門面向對象的編程語言,程序運行過程中在任意時刻都可能有對象被創建。開發中常用 new 關鍵字、反射等方式創建對象, JVM 底層是如何處理的呢?

(2)對象的創建的幾種常見方式?
  Type1:使用 new 關鍵字創建(常見比如:單例模式、工廠模式等創建)。
  Type2:反射機制創建(調用 class 的 newInstance() 方法)。
  Type3:克隆創建(實現 Cloneable 接口,並重寫 clone() 方法)。
  Type4:反序列化創建。

(3)對象創建步驟
Step1:判斷對象對應的類 是否已經被 加載、解析、初始化過。
  虛擬機執行 new 指令時,先去檢查該指令的參數 能否在 方法區(元空間)的運行時常量池中 定位到 某個類的符號引用,並檢查這個符號引用代表的 類是否 被加載、解析、初始化過。如果沒有,則在雙親委派模式下,查找相應的類 並加載。

Step2:為對象分配內存空間。
  類加載完成后,即可確定對象所需的內存大小,在堆中根據適當算法划分內存空間給對象。
划分算法:
  划分算法根據 Java 堆中內存是否 規整進行可划分為:指針碰撞、空閑列表。
  堆內存規整時,采用指針碰撞方式分配內存空間,由於內存規整,即指針只需移動 所需對象內存 大小即可。
  堆內存不規整時,采用空閑列表方式分配內存空間,存在內存碎片,需要維護一個列表用於記錄哪些內存塊可用,在列表中找到足夠大的內存空間分配給對象。

堆內存是否規整:
  堆內存是否規整由 垃圾回收器算法決定。
  使用 Serial、ParNew 等帶有 Compact(壓縮)過程的垃圾回收器時,堆內存規整,即指針碰撞。
  使用 CMS 等帶有 Mark-Sweep(標記清除)算法的垃圾回收器時,堆內存不規整,即空閑列表。

Step3:處理並發安全問題。
  分配內存空間時,指針修改可能會碰到並發問題(比如 對象 A 分配內存后,但指針還沒修改,此時 對象 B 仍使用原來指針 進行內存分配,那么 A 與 B 就會出現沖突)。
解決方式一:
  對分配內存空間的動作進行同步處理(CAS 加上失敗重試 保證更新操作的原子性)。

解決方式二:
  將分配內存空間的動作按照線程划分到不同空間中執行(Thread Local Allocation Buffer,TLAB,每個線程在堆中預先分配一小塊內存空間,哪個線程需要分配內存,就在哪個 TLAB 上進行分配)。

Step4:初始化屬性值。
  將內存空間中的屬性 賦 零值(默認值)。

Step5:設置對象的 對象頭。
  將對象所屬 類的元數據信息、對象的哈希值、對象 GC 分代年齡 等信息存儲在對象的對象頭。

Step6:執行 <init> 方法進行初始化。
  執行 <init> 方法,加載 非靜態代碼塊、非靜態變量、構造器,且執行順序為從上到下執行,但構造器最后執行。並將堆內對象的 首地址 賦值給 引用變量。

2、對象內存布局

(1)對象內存布局
  對象在內存中存儲布局可以分為:對象頭(Header)、實例數據(Instance Data)、對齊填充(Padding)。

(2)對象頭(Header)
  對象頭用於存儲 運行時元數據 以及 類型指針。
運行時元數據:
  對象的哈希值、GC 分代年齡、鎖狀態標志、偏向時間戳等。

類型指針:
  即對象指向 類元數據的 指針(通過該指針確定該對象屬於哪個類)。

(3)實例數據(Instance Data)
  其為對象 存儲的真實有效信息,即程序中 各類型字段的內容。

(4)對齊填充(Padding)
  不是必然存在的,起着占位符的作用。比如 HotSpot 中對象大小為 8 字節的整數倍,當對象實例數據不是 8 字節的整數倍時,通過對齊填充補全。

3、對象訪問定位(句柄訪問、直接指針)

(1)問題
  對象 存於堆中,而對象的引用 存放在棧幀中,如何根據 棧幀存放的引用 定位 堆中存儲的對象,即為對象訪問定位問題。取決於 JVM 的具體實現,常見方式:句柄訪問、直接指針。

(2)句柄訪問
  在堆中划分出一塊內存作為 句柄池,用於保存對象的句柄地址(指針),而棧幀中存放的即為 句柄地址。
  當對象被移動(垃圾回收)時,只需要改變 句柄池中 指向對象實例數據的指針 即可,不需要修改棧幀中的數據。

 

 

(3)直接訪問(HotSpot 使用)
  棧幀中直接存放 對象實例數據的地址,對象移動時,需要修改棧幀中的數據。
  相較於 句柄訪問,減少了一次 指針定位的時間開銷(積少成多還是很可觀的)。

 

 

 

二、JDK8 中的 String(可以深入研究一下,有不對的地方還望不吝賜教)

1、String 基本概念(JDK9 稍作改變)

(1)基本概念
  String 指的是字符串,一般使用雙引號括起來 "" 表示(比如: "hello")。
  使用 final 類型修飾 String 類,表示不可被繼承。
  String 類實現了 Serializable 接口,表示字符串支持序列化。
  String 類實現了 Comparable 接口,表示可以比較大小。
  String 類內部使用 final 修飾的數組存儲字符。
注:
  JDK8 及以前 內部使用 final char[] value 用於存儲字符串數據,
  JDK9 時改為 final byte[] 存儲數據(內部將 每個字符 與 0xFF 比較,當有一個比 0xFF 大時,使用 2 個字節存儲,否則使用 1 個字節存儲)。

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

 

 

 

(2)賦值方式
  String 賦值一般分為:字面量直接賦值、使用 new 關鍵字通過構造器賦值。

【字面量直接賦值:(值會存放於 字符串常量池 中)】
    String a = "hello";

【new + 構造器賦值:(值可能會存放於 字符串常量池 中,且 new 關鍵字會在堆中創建一個對象)】
    String a = new String("hello");    
注:
    值不一定會存放於 字符串常量池中,可以調用 String 的 intern() 方法將值放於字符串常量池中。
    intern() 方法在不同 JDK 版本中實現不同,后面會舉例,此處大概有個印象即可。

 

2、字符串常量池(String Pool)、String 不可變性

(1)字符串常量池(String Pool)
  JVM 內部維護一個 字符串常量池(String Pool),當 String 以字面量形式賦值時,此時字符串會聲明在字符串常量池中(比如:String a = "hello" 賦值時,會生成一個 "hello" 字符串存於 常量池中)。
  字符串常量池中不會存儲相同內容的字符串,其內部實現是一個固定大小的 Hashtable,如果常量池中存儲 String 過多,將會造成 hash 沖突,從而造成性能下降,可以通過 -XX:StringTableSize 設置 StringTable 大小(比如:-XX:StringTableSize=2000)。

注:
  常量池類似於 緩存,使程序運行更快、節省內存。
  JDK 6 及以前,字符串常量池存放於 永久代中,StringTable 默認長度為 1009。
  JDK 7 及之后,字符串常量池存放於 堆中,StringTable 默認長度為 60013,其最小值為 1009。

【常用 JVM 參數:】
-XX:StringTableSize    配置字符串常量池中的 StringTable 大小,JDK 8 默認:60013-XX:+PrintStringTableStatistics  在JVM 進程退出時,打印出 StringTable 相關統計信息。

 

(2)String 不可變性:
  String 一旦在內存中創建,其值將是不可變的(反射場景除外)。當值改變時,改變的是指向內存的引用,而非直接修改內存中的值。

JDK 8 String 不可變:
  JDK8 采用 final 修飾 String 類,表示該類不可被繼承。
  String 類內部采用 private final char value[] 存儲字符串,使用 private 修飾數組且不對外提供 setter 方法,即 外部不可修改字符串。使用 final 修飾數組,表示 內部不可修改字符串(引用地址不變,內容可變,使用反射可能會改變字符串)。且 String 提供的相關方法中,並沒有去修改原有字符串中的值,而是返回一個新的引用指向內存中新的 String 值(比如 replace() 方法返回一個 new String() 對象)。

 

 

 

(3)常見場景(修改引用地址)

  對現有字符串重新賦值時。
  對現有字符串進行連接操作時。
  使用字符串的 replace() 方法修改指定字符串時。

【舉例:(給現有字符串重新賦值)】
package com.test;

public class JVMDemo {

    public static void main(String[] args) {
        String C1 = new String("abc");
        String C2 = C1;
        System.out.println(C1 == C2); // true
        System.out.println(System.identityHashCode(C1));
        System.out.println(System.identityHashCode(C2));
        C2 = "abc";
        System.out.println(C1 == C2); // false
        System.out.println(System.identityHashCode(C1));
        System.out.println(System.identityHashCode(C2));
    }
}

 

 

 

 

 

 

3、String 拼接操作 -- 筆試題

(1)拼接操作可能存在的情況:
  常量與常量(字面量或者 final 修飾的變量)的拼接結果會存放於常量池中,由編譯期優化導致。
  拼接數據中若有一個是變量,則拼接結果 會存放於 堆中。由 StringBuilder 拼接。
  如果拼接結果調用 intern() 方法,且常量池中不存在該字符串對象,則將拼接結果 存放於 常量池中。

(2)常量(字面量)拼接 -- 拼接結果存於常量池
  對於兩個及以上字面量拼接操作,在編譯時會進行優化,若該拼接結果不存在於常量池中,則直接將其拼接結果存於常量池,並返回其引用地址。否則,返回常量池中該結果所在的引用地址。

【舉例:】
package com.test;

public class JVMDemo {

    public static void main(String[] args) {
        String c1 = "a" + "b" + "c";// 編譯期優化,等同於 "abc",並存放於常量池中
        String c2 = "abc"; // "abc" 已存在於常量池,此時直接將常量池中的地址 賦給 c2
        System.out.println(c1 == c2); // true
        System.out.println(System.identityHashCode(c1));
        System.out.println(System.identityHashCode(c2));
    }
}

 

 

 

(3)final 修飾的變量拼接(可以理解為常量) -- 拼接結果存於常量池
  由於 final 修飾的變量不可被修改,在編譯期優化等同於 常量進行拼接操作,所以結果存放於常量池中。

【舉例:】
package com.test;

public class JVMDemo {

    public static void main(String[] args) {
        final String c1 = "hello";
        final String c2 = "world";
        String c3 = "helloworld"; // "helloworld" 存放於常量池中
        String c4 = c1 + c2; // 等價於 "hello" + "world" 常量進行拼接操作
        System.out.println(c3 == c4); // true
        System.out.println(System.identityHashCode(c3));
        System.out.println(System.identityHashCode(c4));
    }
}

 

 

 

(4)一般變量拼接 -- 拼接結果存於 堆
  拼接操作中出現變量時,會觸發 new StringBuilder 操作,並使用 StringBuilder 的 append 方法進行字符串拼接,最終調用其 toString 方法轉為字符串,並返回引用地址。
注:
  StringBuilder 的 toString 方法內部調用 new String(),其最終拼接結果存放於 堆 中(不會將拼接結果存放於常量池,可以手動調用 intern() 方法將結果放入常量池,后面介紹,往下看)。

使用 StringBuilder 進行字符串拼接操作效率要遠高於使用 String 進行字符串拼接操作。
  使用 String 直接進行拼接操作時,若出現變量,則會先創建 StringBuilder 對象,最終輸出結果還得轉為 String 對象,即使用 String 進行字符串拼接過程中 可能出現多個 StringBuilder 和 String 對象(比如在 循環中 進行字符串拼接操作),且創建對象過多會占用更多的內存。
  而使用 StringBuilder 進行拼接操作時,只需要創建一個 StringBuilder 對象即可,可以節省內存空間以及提高效率執行。

【舉例:】
package com.test;

public class JVMDemo {

    public static void main(String[] args) {
        String c1 = "hello";
        String c2 = "world";
        String c3 = "hello" + "world";  // 等價於 "helloworld",存於常量池
        String c4 = "helloworld"; // 常量池中已存在,直接賦值常量池引用
        String c5 = "hello" + c2; // 拼接結果存於 堆
        String c6 = c1 + "world"; // 拼接結果存於 堆
        String c7 = c1 + c2; // 拼接結果存於 堆
        System.out.println(System.identityHashCode(c3));
        System.out.println(System.identityHashCode(c4));
        System.out.println(System.identityHashCode(c5));
        System.out.println(System.identityHashCode(c6));
        System.out.println(System.identityHashCode(c7));
    }
}

 

 

 

 

 

(5)拼接結果調用 intern 方法 -- 結果存放於常量池
  由於不同版本 JDK 的 intern() 方法執行結果不同,此處暫時略過,接着往下看。

4、String 使用 new 關鍵字創建對象問題 -- 筆試題

(1)new String("hello") 會創建幾個對象?
  可能會創建 1 個或 2 個對象。
  new 關鍵字會在堆中創建一個對象,而當字符串常量池中不存在 "hello" 時,會創建一個對象存入字符串常量池。若常量池中存在對象,則不會創建、會直接引用。

【舉例:】
public class JVMDemo {
    public static void main(String[] args) {
        String c1 = new String("hello");
        String c2 = new String("hello");
    }
}

 

 

 

(2)new String("hello") + new String("world") 創建了幾個對象?
  創建了 6 個對象(不考慮常量池是否存在數據)。
對象創建:
  由於涉及到變量的拼接,所以會觸發 new StringBuilder() 操作。此處創建 1 個對象。
  new String("hello") 通過上例分析,可以知道會創建 2 個對象(堆 1 個,字符串常量池 1 個)。
  同理 new String("world") 也會創建 2 個對象。
  最終拼接結果 會觸發 StringBuilder 的 toString() 方法,內部調用 new String() 在堆中創建一個對象(此處不會在字符串常量池中創建對象)。

注:
  StringBuilder 的 toString() 內部的 new String() 並不會在 字符串常量池 中創建對象。
  String str = new String("hello"); 這種形式創建的字符串 可以在字符串常量池中創建對象。
此處我是根據 字節碼文件 中是否有 ldc 指令來判斷的(后續根據 intern() 方法同樣也可以證明這點),有不對的地方,還望不吝賜教。

【舉例:】
public class JVMDemo {
    public static void main(String[] args) {
        String a = new String("hello") + new String("world");
    }
}

 

 

 

5、String 中的 intern() 相關問題 -- 筆試題

(1)intern() 作用
  對於非字面量直接聲明的 String 對象(通過 new 創建的對象),可以使用 String 提供的 intern 方法獲取字符串常量池中的數據。

該方法作用:

  從字符串常量池中查詢當前字符串是否存在(通過 equals 方法比較),如果不存在,則會將當前字符串放入常量池中並返回該引用地址(此處不同版本的 JDK 有不同的實現)。若存在則直接返回引用地址。

【JDK 8 注釋】
/**
 * 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();

 

(2)不同 JDK 版本中 intern() 使用的區別
  JDK 6:嘗試將該字符串對象 放入 字符串常量池中(字符串常量池位於 方法區中),
    若字符串常量池中已經存在 該對象,則返回字符串常量池 當前對象的引用地址。
    若沒有該對象,則將 當前對象值 復制一份放入字符串常量池,並返回此時對象的引用地址。

  JDK 7 之后:嘗試將該字符串對象 放入 字符串常量池中(字符串常量池位於 堆中),
    若字符串常量池中已經存在 該對象,則返回字符串常量池 當前對象的引用地址。
    若沒有該對象,則將 當前對象的 引用地址 復制一份放入字符串常量池,並返回引用地址。

(3)使用 JDK8 演示 intern()
  此處使用 JDK8 演示 intern() 方法,有興趣可以自行研究 JDK6 的操作。

【例一:】
public class JVMDemo {
    public static void main(String[] args) {
        String a = new String("hello"); // 此時常量池存在 "hello"
        String b = "hello"; // 直接引用常量池中 "hello"
        String c = a.intern(); // 直接引用常量池中 "hello"
        System.out.println(System.identityHashCode(a));
        System.out.println(System.identityHashCode(b));
        System.out.println(System.identityHashCode(c));
        System.out.println(b == a); // false,a 指向 堆 內對象,b 指向 字符串常量池
        System.out.println(b == c); // true,b,c 均指向字符串常量池
    }
}

【例二:(b,c 互換位置)】
public class JVMDemo {
    public static void main(String[] args) {
        String a = new String("hello"); // 此時常量池存在 "hello"
        String c = a.intern(); // 直接引用常量池中 "hello"
        String b = "hello"; // 直接引用常量池中 "hello"
        System.out.println(System.identityHashCode(a));
        System.out.println(System.identityHashCode(b));
        System.out.println(System.identityHashCode(c));
        System.out.println(b == a); // false,a 指向 堆 內對象,b 指向 字符串常量池
        System.out.println(b == c); // true,b,c 均指向字符串常量池
    }
}

【分析:】
    例一 與 例二 的區別在於 intern() 執行時機不同,且兩者輸出結果相同。
    JDK 8 中 intern() 執行時,若字符串常量池中 equals 未比較出相同數據,則將當前對象的引用地址 復制一份並放入常量池。
    若存在數據,則返回常量池中數據的引用地址。
    
    即 new String() 操作后,若常量池中不存在 數據,則調用 intern() 后,會復制 堆的地址 並存入 常量池中。后續獲得的均為 堆的地址。也即上述 例一、例二 中 a、b、c 操作后,均相同且指向 堆。
  若常量池存在數據,則調用 intern() 后,返回常量池引用,后續獲得的均為 常量池引用。也即上述 例一、例二 中 a 為指向堆 的引用地址,b,c 均為指向常量池的引用地址。

    通過輸出結果可以看到,上述 例一、例二 中 a、b、c 操作后,b, c 相同且不同於 a(即 b、c 指向常量池),從側面也反映出 new String("hello") 執行后 常量池中存在 "hello"。

 

 

 

 

【例四:】
public class JVMDemo {
    public static void main(String[] args) {
        char[] a = new char[]{'h', 'e', 'l', 'l', 'o'};
        String b = new String(a, 0 , a.length); // 此時常量池中不存在 "hello"
        String c = "hello"; // 此時常量池中存在 "hello"
        String d = b.intern(); // 直接引用常量池中的 "hello"

        System.out.println(System.identityHashCode(b));
        System.out.println(System.identityHashCode(c));
        System.out.println(System.identityHashCode(d));
        System.out.println(c == b); // false, b 指向堆對象, c 指向常量池
        System.out.println(c == d); // true,c,d 均指向常量池
    }
}

【例五:】
public class JVMDemo {
    public static void main(String[] args) {
        char[] a = new char[]{'h', 'e', 'l', 'l', 'o'};
        String b = new String(a, 0 , a.length); // 此時常量池中不存在 "hello"
        String d = b.intern(); // 常量池不存在 "hello",復制 b 的引用到常量池中(指向堆的引用)
        String c = "hello"; // 直接獲取常量池中的引用(指向堆的引用)

        System.out.println(System.identityHashCode(b));
        System.out.println(System.identityHashCode(c));
        System.out.println(System.identityHashCode(d));
        System.out.println(c == b); // true, c, b 均指向堆
        System.out.println(c == d); // true, c, d 均指向堆
    }
}

【分析:】
    例四 與 例五 的區別在於 intern() 執行時機不同,且兩者輸出結果相同。
    JDK 8 中 intern() 執行時,若字符串常量池中 equals 未比較出相同數據,則將當前對象的引用地址 復制一份並放入常量池。
    若存在數據,則返回常量池中數據的引用地址。
    
    例四中,new String() 執行后,常量池中不存在 "hello",
    但 String c = "hello" 執行后,常量池中存在 "hello",從而 intern() 獲取的是常量池中的引用地址。
    也即 b 為指向 堆的引用,c,d 均為指向常量池的引用。
    
    例五中,new String() 執行后,常量池中不存在 "hello",
    intern() 執行后會將當前對象地址(指向堆的引用)復制並放入常量池,從而 String c = "hello" 獲取的是常量池的引用地址。
    也即 b,c,d 獲取的均是指向 堆 的引用。

 

 

 

對例四、例五進行一下延伸。

【例六:】
public class JVMDemo {
    public static void main(String[] args) {
        String a = new String("hello") + new String("world");
        String b = "helloworld";
        String c = a.intern();
        System.out.println(System.identityHashCode(a));
        System.out.println(System.identityHashCode(b));
        System.out.println(System.identityHashCode(c));
        System.out.println(b == a); // false, a 指向堆, b 指向常量池
        System.out.println(b == c); // true, b、c 均指向常量池
    }
}

【例七:】
public class JVMDemo {
    public static void main(String[] args) {
        String a = new String("hello") + new String("world");
        String c = a.intern();
        String b = "helloworld";
        System.out.println(System.identityHashCode(a));
        System.out.println(System.identityHashCode(b));
        System.out.println(System.identityHashCode(c));
        System.out.println(b == a); // true, a、b 均指向堆,
        System.out.println(b == c); // true, b、c 均指向堆
    }
}

【分析:】
    涉及到變量字符串拼接,會觸發 StringBuilder 進行相關操作。
    最終觸發 toString() 轉為 String,其內部調用的是 String(char value[], int offset, int count) 構造方法,
    此方法在堆中創建 字符串 但不會向常量池中添加數據(與 例四、例五 是同樣的場景)。

 

 

 


免責聲明!

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



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