1. 程式人生 > 實用技巧 >JVM_執行時資料區4-物件的例項化

JVM_執行時資料區4-物件的例項化

物件的例項化

物件的例項化

建立物件的方式

  • new
    最常見的方式變
    1 : Xxx的靜態方法變形
    2 : XxBuilder/XxoxFactory的靜態方法
  • Class的newInstance():反射的方式,只能呼叫空參的構造器,許可權必須是public
  • Constructor的newInstance(Xxx):反射的方式,可以呼叫空參、帶參的構造器,許可權沒有要求
  • 使用clone() :不呼叫任何構造器,當前類需要實現Cloneable介面,實現clone()
  • 使用反序列化:從檔案中、從網路中獲取一個物件的二進位制流
  • 第三方庫Objenesis

建立物件的步驟

  1. 判斷物件對應的類是否載入、連結、初始化
    虛擬機器遇到一條new指令,首先去檢查這個指令的引數能否在Metaspace的常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已經被載入、解析和初始化。( 即判斷類元資訊是否存在)。如果沒有,那麼在雙親委派模式下,使用當前類載入器以ClassLoader+包名+類名為Key進行查詢對應的.class檔案。如果沒有找到檔案,則丟擲ClassNotFoundException異常,如果找到,則進行類載入,並生成對應的Class類物件

  2. 為物件分配記憶體
    首先計算物件佔用空間大小,接著在堆中劃分一塊記憶體給新物件。 如果例項成員變數是引用變數,僅分配引用變數空間即可,即4個位元組大小。

  • 如果記憶體規整,使用指標碰撞
    如果記憶體是規整的,那麼虛擬機器將採用的是指標碰撞法(BumpThePointer)來為物件分配記憶體。意思是所有用過的記憶體在一邊,空閒的記憶體在另外一邊,中間放著一個指標作為分界點的指示器,分配記憶體就僅僅是把指標向空閒那邊挪動一段與物件大小相等的距離罷了。如果垃圾收集器選擇的是Serial、ParNew這種基於壓縮演算法的,虛擬機器採用這種分配方式。一般使用帶有compact (整理)過程的收集器時,使用指標碰撞。

  • 如果記憶體不規整,虛擬機器需要維護一個列表,使用空閒列表分配
    如果記憶體不是規整的,已使用的記憶體和未使用的記憶體相互交錯,那麼虛擬機器將採用的是空閒列表法來為物件分配記憶體。意思是虛擬機器維護了一個列表,記錄上哪些記憶體塊是可用的,再分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的內容。這種分配方式成為“空閒列表(Free List) ”。
    說明:選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。

給物件的屬性賦值的操作:
① 屬性的預設初始化
② 顯式初始化
③ 程式碼塊中初始化
④ 構造器中初始化
  1. 處理併發安全問題
    在分配記憶體空間時,另外一個問題是及時保證new物件時候的執行緒安全性:建立物件是非常頻繁的操作,虛擬機器需要解決併發問題。虛擬機器採用 了兩種方式解決併發問題:
    CAS ( Compare And Swap )失敗重試、區域加鎖:保證指標更新操作的原子性;
    TLAB把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在Java堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝區,(TLAB ,Thread Local Allocation Buffer) 虛擬機器是否使用TLAB,可以通過一XX:+/一UseTLAB引數來 設定。

  2. 初始化分配到的空間
    記憶體分配結束,虛擬機器將分配到的記憶體空間都初始化為零值(不包括物件頭)。這一步保證了物件的例項欄位在Java程式碼中可以不用賦初始值就可以直接使用,程式能訪問到這些欄位的資料型別所對應的零值。

  3. 設定物件的物件頭

  • 將物件的所屬類(即類的元資料資訊)、物件的HashCode和物件的GC資訊、鎖資訊等資料儲存在物件的物件頭中。這個過程的具體設定方式取決於JVM實現。

  • 考慮到虛擬機器的空間效率,Mark Word被設計成一個有著動態定義的資料結構,以便在極小的空間記憶體儲儘量多的資料,根據物件的狀態複用自己的儲存空間。例如在32位的HotSpot虛擬機器中,如物件未被同步鎖鎖定的狀態下,Mark Word的32個位元儲存空間中的25個位元用於儲存物件雜湊碼,4個位元用於儲存物件分代年齡,2個位元用於儲存鎖標誌位,1個位元固定為0,在其他狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)[1]下物件的儲存內容如下所示。

HotSpot直譯器程式碼片段

// 確保常量池中存放的是已解釋的類 
if (!constants->tag_at(index).is_unresolved_klass()) {
	// 斷言確保是klassOop和instanceKlassOop(這部分下一節介紹) 
	oop entry = (klassOop) *constants->obj_at_addr(index);
	assert(entry->is_klass(), "Should be resolved klass");
	klassOop k_entry = (klassOop) entry;
	assert(k_entry->klass_part()->oop_is_instance(), "Should be instanceKlass");
	instanceKlass* ik = (instanceKlass*) k_entry->klass_part();
	// 確保物件所屬型別已經經過初始化階段 
	if ( ik->is_initialized() && ik->can_be_fastpath_allocated() ) {
		// 取物件長度 
		size_t obj_size = ik->size_helper();
		oop result = NULL;
		// 記錄是否需要將物件所有欄位置零值 
		bool need_zero = !ZeroTLAB;
		// 是否在TLAB中分配物件 
		if (UseTLAB) {
			result = (oop) THREAD->tlab().allocate(obj_size);
		}
		if (result == NULL) {
			need_zero = true;
			// 直接在eden中分配物件 
			retry: HeapWord* compare_to = *Universe::heap()->top_addr();
			HeapWord* new_top = compare_to + obj_size;
			// cmpxchg是x86中的CAS指令,這裡是一個C++方法,通過CAS方式分配空間,併發失敗的 話,轉到retry中重試直至成功分配為止 
			if (new_top <= *Universe::heap()->end_addr()) {
				if (Atomic::cmpxchg_ptr(new_top, Universe::heap()->top_addr(), compare_to) != compare_to) {
					goto retry;
				}
				result = (oop) compare_to;
			}
		}
		if (result != NULL) {
			// 如果需要,為物件初始化零值 
			if (need_zero ) {
				HeapWord* to_zero = (HeapWord*) result + sizeof(oopDesc) / oopSize;
				obj_size -= sizeof(oopDesc) / oopSize;
				if (obj_size > 0 ) {
					memset(to_zero, 0, obj_size * HeapWordSize);
				}
			}
			// 根據是否啟用偏向鎖,設定物件頭資訊 
			if (UseBiasedLocking) {
				result->set_mark(ik->prototype_header());
			} else {
				result->set_mark(markOopDesc::prototype());
			}
			result->set_klass_gap(0);
			result->set_klass(k_entry);
			// 將物件引用入棧,繼續執行下一條指令
			SET_STACK_OBJECT(result, 0);
			UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
		}
	}
}
  1. 執行init方法進行初始化
    在Java程式的視角看來,初始化才正式開始。初始化成員變數,執行例項化程式碼塊,呼叫類的構造方法,並把堆內物件的首地址賦值給引用變數。 因此一般來說(由位元組碼中是否跟隨有invokespecial指令所決定),new指令之 後會接著就是執行方法,把物件按照程式設計師的意願進行初始化,這樣一個真正可用的物件才算完全創建出來。
  • 程式碼例項
/**
 * 測試物件例項化的過程
 *  ① 載入類元資訊 - ② 為物件分配記憶體 - ③ 處理併發問題  - ④ 屬性的預設初始化(零值初始化)
 *  - ⑤ 設定物件頭的資訊 - ⑥ 屬性的顯式初始化、程式碼塊中初始化、構造器中初始化
 *
 *  給物件的屬性賦值的操作:
 *  ① 屬性的預設初始化 - ② 顯式初始化 / ③ 程式碼塊中初始化 - ④ 構造器中初始化
 * 
 */
public class Customer{
	int id = 1001;
	String name;
	Account acct;
	{
		name = "匿名客戶";
	}
	public Customer(){
		acct = new Account();
	}
}
class Account{
}

物件的記憶體佈局

物件頭

  • 包含兩部分
  • 執行時元資料
    雜湊值( HashCode )
    GC分代年齡
    鎖狀態標誌
    執行緒持有的鎖
    偏向執行緒ID
    偏向時間戳
  • 型別指標:指向類元資料的InstanceKlass,確定該物件所屬的型別
  • 說明:如果是陣列,還需記錄陣列的長度

例項資料

說明:它是物件真正儲存的有效資訊,包括程式程式碼中定義的各種型別的欄位(包括從父類繼承下來的和本身擁有的欄位) 規則:

  • 相同寬度的欄位總被分配在一起
  • 父類中定義的變數會出現在子類之前
  • 如果CompactFields引數為true(預設為true),子類的窄變數可能插入到父類變數的空隙

對齊填充

這並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。由於HotSpot虛擬機器的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說就是任何物件的大小都必須是8位元組的整數倍。物件頭部分已經被精心設計成正好是8位元組的倍數(1倍或者2倍),因此,如果物件例項資料部分沒有對齊的話,就需要通過對齊填充來補全。

小結

public class CustomerTest {
    public static void main(String[] args) {
        Customer cust = new Customer();
    }
}

物件的訪問定位

JVM是如何通過棧幀中的物件引|用訪問到其內部的物件例項的呢?-> 定位,通過棧上reference訪問

控制代碼訪問

如果使用控制代碼訪問的話,Java堆中將可能會劃分出一塊記憶體來作為控制代碼池,reference中儲存的就是物件的控制代碼地址,而控制代碼中包含了物件例項資料與型別資料各自具體的地址資訊,其結構如圖所示。

使用控制代碼來訪問的最大好處就是reference中儲存的是穩定控制代碼地址,在物件被移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制代碼中的例項資料指標,而reference本身不需要被修改。

直接訪問

如果使用直接指標訪問的話,Java堆中物件的記憶體佈局就必須考慮如何放置訪問型別資料的相關資訊,reference中儲存的直接就是物件地址,如果只是訪問物件本身的話,就不需要多一次間接訪問的開銷,如圖所示

使用直接指標來訪問最大的好處就是速度更快,它節省了一次指標定位的時間開銷,由於物件訪問在Java中非常頻繁,因此這類開銷積少成多也是一項極為可觀的執行成本,就本書討論的主要虛擬機器HotSpot而言,它主要使用第二種方式進行物件訪問

直接記憶體

相關連結:https://juejin.im/post/6844903822091878408

概念

  • 不是虛擬機器執行時資料區的一部分,也不是《Java虛擬機器規範》中定義的記憶體區域
  • 直接記憶體是Java堆外的、直接向系統申請的記憶體區間
/**
 *  IO                  NIO (New IO / Non-Blocking IO)
 *  byte[] / char[]     Buffer
 *  Stream              Channel
 *
 * 檢視直接記憶體的佔用與釋放
 */
public class BufferTest {
	private static final int BUFFER = 1024 * 1024 * 1024;
	//1GB
	public static void main(String[] args){
		//直接分配本地記憶體空間
		ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
		System.out.println("直接記憶體分配完畢,請求指示!");
		Scanner scanner = new Scanner(System.in);
		scanner.next();
		System.out.println("直接記憶體開始釋放!");
		byteBuffer = null;
		System.gc();
		scanner.next();
	}
}
  • 來源於NIO,通過存在堆中的DirectByteBuffer操作Native記憶體

通常,訪問直接記憶體的速度會優於Java堆。即讀寫效能高因此出於效能考慮,讀寫頻繁的場合可能會考慮使用直接記憶體Java的NIO庫允許Java程式使用直接記憶體,用於資料緩衝區

異常

也可能導致OutOfMemoryError異常:OutOfMemoryError: Direct buffer memory

/**
 * 本地記憶體的OOM:  OutOfMemoryError: Direct buffer memory
 */
public class BufferTest2 {
	private static final int BUFFER = 1024 * 1024 * 20;
	//20MB
	public static void main(String[] args) {
		ArrayList<ByteBuffer> list = new ArrayList<>();
		int count = 0;
		try {
			while(true){
				ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
				list.add(byteBuffer);
				count++;
				try {
					Thread.sleep(100);
				}
				catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}
		finally {
			System.out.println(count);
		}
	}
}
  • 由於直接記憶體在Java堆外,因此它的大小不會直接受限於一Xmx指定的最大 堆大小,但是系統記憶體是有限的,Java堆和直接記憶體的總和依然受限於作業系統能給出的最大記憶體。
  • 缺點
    分配回收成本較高不受JVM記憶體回收管理
  • 直接記憶體大小可以通過MaxDirectMemorySize設定
  • 如果不指定,預設與堆的最大值一Xmx引數值一致