蒼穹之邊,浩瀚之摯,眰恦之美; 悟心悟性,善始善終,惟善惟道! —— 朝槿《朝槿兮年說》 寫在開頭 從接觸 Java 開發到現在,大家對 Java 最直觀的印象是什麼呢?是它宣傳的 “Write once, run anywhere”,還是目前看已經有些過於形式主義的語法呢?有沒有靜下心來仔細想過, ...
蒼穹之邊,浩瀚之摯,眰恦之美; 悟心悟性,善始善終,惟善惟道! —— 朝槿《朝槿兮年說》
寫在開頭
從接觸 Java 開發到現在,大家對 Java 最直觀的印象是什麼呢?是它宣傳的 “Write once, run anywhere”,還是目前看已經有些過於形式主義的語法呢?有沒有靜下心來仔細想過,對於 Java 到底瞭解到什麼程度?
自從業以來,對於Java的那些紛紛擾擾的問題,我們或多或少都有些道不明,說不清的情緒,一直心有餘悸,甚至困惑著我們的,可曾入夢。
是不是有著,不論查閱了多少遍的資料,以及翻閱了多少技術大咖的書籍,也未能解開心裡那由來已久的疑惑,就像一個個未解之謎一般縈繞心扉,惶惶不可終日?
我一直都在問自己,一段Java代碼的中類,從編寫到編譯,經過一系列的步驟載入到JVM,再到運行的過程,它究竟是如何運作和流轉的,其機制是什麼?我們看到的結果究竟是如何呈現出來的,這其中發生了什麼?
雖然,從學習Java之初,我們都會瞭解和記憶,以及在後來大家在提及的時候,大多數都是一句“我們應該都不陌生”,甚至“我相信大家都瞭然於心”之類話“蜻蜓點水”般輕描淡寫。
但是,如果真的要問一問的話,能詳細說道一二的,想必都會以“夏蟲不可語冰“的悲劇上演了吧!作為一名Java Develioer來說,正確瞭解和掌握這些原理和機制,早已經不是什麼”不能說的秘密“。
帶著這些問題,今日我們便來扒一扒一個Java對象中的那些枝末細節,一個Java對象是如何被創建和執行的,我們又該如何理解和認識這些原理和機制,以及在日常開發工作中,我們需要註意些什麼?
關健術語
本文用到的一些關鍵詞語以及常用術語,主要如下:
- 指針壓縮(CompressedOops) : 全稱為Compressed Ordinary Object Pointer,在HotSpot VM 64位(bit)虛擬機為了提升記憶體使用率而提出的指針壓縮技術。主要是指將Java程式中的所有對象引用指針壓縮一半,主要闡述的是一個指針大小占用一個字寬單位大小,即就是HotSpot VM 64位(bit)虛擬機的一個字寬單位大小是64bit,在實際工作時,原本的指針會壓縮成32bit,Oracle JDK從6 update 23開始在64位系統上開始支持開啟壓縮指針,在JDK1.7版本之後預設開啟。
- 指針碰撞(Bump the Pointer), 指的Java對象為分配堆記憶體的一種記憶體分配方式,其分配過程是把記憶體分為已分配記憶體和空間記憶體分別處於不同的一側,主要通過一個指針指向分界點區分。一般JVM為一個新對象分配記憶體的時候,把指針往往空閑記憶體區域移動指向相同對象大小的距離即可。一般適用於Serial和ParNew等不會產生記憶體碎片,且堆記憶體完整的收集器。
- 空閑列表(Clear Free List): 指的Java對象為分配堆記憶體的一種記憶體分配方式,其分配過程是把記憶體分為已分配記憶體和空間記憶體相互交錯,JVM通過維護一張記憶體列表記錄的可用空間記憶體塊,創建新對象需要分配堆記憶體時,從列表中尋找一個足夠大的記憶體塊分配給對象實例,同步更新列表記錄情況,當GC收集器發生GC時,把已回收的記憶體更新到記憶體列表。一般適用於CMS等會產生記憶體碎片,且堆記憶體不完整的收集器。
- 逃逸分析(Escape Analysis): 在編程語言的編譯優化原理中,分析指針動態範圍的方法稱之為逃逸分析。主要是判斷變數的作用域是否存在於其他記憶體棧或者線程中,當一個對象的指針被多個方法或線程引用時,我們稱這個指針發生了逃逸。其用來分析這種逃逸現象的方法,就稱之為逃逸分析。跟靜態代碼分析技術中的指針分析和外形分析類似。
- 標量替換(Scalar Replacement):主要是指使用標量替換聚合量(Java中的對象實例),把一個對象進行分解成一個個的標量進行逃逸分析,不可選的對象才能進行標量替換。標量主要是指不可分割的量,一般來說主要是基本數據類型和引用類型。
- 棧上分配(Allocation on Stack): 一般Java對象創建出來會在棧上進行記憶體分配,不是所有的對象都可以實現棧上分配。要想實現棧上分配,需要進行逃逸分析和標量替換。
基本概述
Java 本身是一種面向對象的語言,最顯著的特性有兩個方面,一是所謂的“書寫一次,到處運行”(Write once, run anywhere),能夠非常容易地獲得跨平臺能力;另外就是垃圾收集(GC, Garbage Collection),Java 通過垃圾收集器(Garbage Collector)回收分配記憶體,大部分情況下,程式員不需要自己操心記憶體的分配和回收。
我們日常會接觸到 JRE(Java Runtime Environment)或者 JDK(Java Development Kit)。 JRE,也就是 Java 運行環境,包含了 JVM 和 Java 類庫,以及一些模塊等。而 JDK 可以看作是 JRE 的一個超集,提供了更多工具,比如編譯器、各種診斷工具等。
對於“Java 是解釋執行”這句話,這個說法不太準確。我們開發的 Java 的源代碼,首先通過 Javac 編譯成為位元組碼(bytecode),然後,在運行時,通過 Java 虛擬機(JVM)內嵌的解釋器將位元組碼轉換成為最終的機器碼。但是常見的 JVM,比如我們大多數情況使用的 Oracle JDK 提供的 Hotspot JVM,都提供了 JIT(Just-In-Time)編譯器,也就是通常所說的動態編譯器,JIT 能夠在運行時將熱點代碼編譯成機器碼,這種情況下部分熱點代碼就屬於編譯執行,而不是解釋執行。
眾所周知,我們通常把 Java 分為編譯期和運行時。這裡說的 Java 的編譯和 C/C++ 是有著不同的意義的,Javac 的編譯,編譯 Java 源碼生成“.class”文件裡面實際是位元組碼,而不是可以直接執行的機器碼。Java 通過位元組碼和 Java 虛擬機(JVM)這種跨平臺的抽象,屏蔽了操作系統和硬體的細節,這也是實現“一次編譯,到處執行”的基礎。
1.Java源碼分析
Java源碼依據JDK提供的API來組織有效的代碼實體,一般都是通過調用API來編織和組成代碼的。
對於一段Java源代碼(Source Code)來說,要想正確被執行,需要先編譯通過,最後托管給所承載JVM,最終才被運行。
Java是一個主要思想是面向對象的,其中的Java的數據類型主要有基本數據類型和包裝類類型,其中:
- 基本數據類型(8大數據類型,其中void):byte、short、int、long、float、double、char、boolean、void
- 包裝類類型:Byte、Short、Integer、Long、Float、Double、Character、Boolean、Void
其中,數據類型主要是用來描述對象的基本特征和賦予功能屬性的一套語義分析規則。
一般來說Java源碼的支持,會依據JDK提供的API來組織有效的代碼實體,對於源代碼的實現,通常我們都是通過調用API來編織和組成代碼的。
2.Java編譯機制
Java編譯機制主要可以分為編譯前端和編譯後端兩個階段,一般來說主要是指將源代碼翻譯為目標代碼的過程,稱為編譯過程。
編譯從一定意義上來說,根本上就是“翻譯”,指的電腦能否識別和認識,促成我們與電腦通信的工作機制。
Java整個編譯以及運行的過程相當繁瑣,總體來看主要有:詞法分析 --> 語法分析 --> 語義分析和中間代碼生成 --> 優化 --> 目標代碼生成。
具體來看,Java程式從源文件創建到程式運行要經過兩大步驟,其中:
- 編譯前端:Java文件會由編譯器編譯成class文件(位元組碼文件),會經過編譯原理簡單過程的前三步,屬於狹義的編譯過程,是將源代碼翻譯為中間代碼的過程。
- 編譯後端: 位元組碼由java虛擬機解釋運行,解釋執行即為目標代碼生成並執行。因此,Java程式既要編譯的同時也要經過JVM的解釋運行。屬於廣義的編譯過程,是將源代碼翻譯為機器代碼的過程。
從詳細分析來看,在編譯前端的階段,最重要的一個編譯器就是javac 編譯器, 在命令行執行javac命令,其實本質是運行了javac.exe這個應用。
而對於編譯後端的階段來說,最重要的是 運行期即時編譯器(JIT,Just in Time Compiler)和 靜態的提前編譯器(AOT,Ahead of Time Compiler)。
特別指出,在Oracle JDK 9之前, Hotspot JVM 內置了兩個不同的 JIT compiler,其中:
- C1模式:屬於輕量級的Client編譯器,對應client 模式,編譯時間短,占用記憶體少,適用於對於啟動速度敏感的應用,比如普通 Java GUI 桌面應用。
- C2模式:屬於重量級的Server編譯器,對應 server 模式,執行效率高,大量編譯優化,它的優化是為長時間運行的伺服器端應用設計的,適用於伺服器。
但是,我們需要註意的是,預設是採用所謂的分層編譯(TieredCompilation)。
在Oracle JDK 9之後,除了我們日常最常見的 Java 使用模式,其實還有一種新的編譯方式,即所謂的 AOT編譯,直接將位元組碼編譯成機器代碼,這樣就避免了 JIT 預熱等各方面的開銷,比如 Oracle JDK 9 就引入了實驗性的 AOT 特性,並且增加了新的 jaotc 工具。
3.Java類載入機制
Java類載入機制主要分為載入,驗證,準備,解析,初始化等5個階段。
當源代碼編譯完成之後,便是執行過程,其中需要一定的載入機制來幫助我們簡化流程,從Java HotSpot(TM)的執行模式上看,一般主要可以分為三種:
- 第一種:解析模式(Interpreted Mode)
Marklin:~ marklin$ java -Xint -version
java version "1.8.0_291"
Java(TM) SE Runtime Environment (build 1.8.0_291-b10)
Java HotSpot(TM) 64-Bit Server VM (build 25.291-b10, interpreted mode)
Marklin:~ marklin$
- 第二種:編譯模式(Compiled Mode)
Marklin:~ marklin$ java -Xcomp -version
java version "1.8.0_291"
Java(TM) SE Runtime Environment (build 1.8.0_291-b10)
Java HotSpot(TM) 64-Bit Server VM (build 25.291-b10, compiled mode)
Marklin:~ marklin$
- 第三種: 混合模式(Mixed Mode),主要是指編譯模式和解析模式的組合體
Marklin:~ marklin$ java -version
java version "1.8.0_291"
Java(TM) SE Runtime Environment (build 1.8.0_291-b10)
Java HotSpot(TM) 64-Bit Server VM (build 25.291-b10, mixed mode)
Marklin:~ marklin$
不論哪一種模式,只有在具體的使用場景上,Java HotSpot(TM)會依據系統環境自動選擇啟動參數。
在Java HotSpot(TM)中,JVM類載入機制分為五個部分:載入,驗證,準備,解析,初始化。其中:
- 載入:會在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的入口。
- 驗證: 確保Class文件的位元組流中包含的信息是否符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。
- 準備: 正式為類變數分配記憶體並設置類變數的初始值階段,即在方法區中分配這些變數所使用的記憶體空間。
- 解析: 虛擬機將常量池中的符號引用替換為直接引用的過程。
- 初始化: 前面的類載入階段之後,除了在載入階段可以自定義類載入器以外,其它操作都由JVM主導。到了初始階段,才開始真正執行類中定義的Java程式代碼。
對於解析階段,我們需要理解符號引用和直接引用,其中:
- 符號引用: 符號引用與虛擬機實現的佈局無關,引用的目標並不一定要已經載入到記憶體中。各種虛擬機實現的記憶體佈局可以各不相同,但是它們能接受的符號引用必須是一致的,因為符號引用的字面量形式明確定義在Java虛擬機規範的Class文件格式中。符號引用就是class文件中主要包括CONSTANT_Class_info,CONSTANT_Field_info,CONSTANT_Method_info 等類型的常量。
- 直接引用: 是指向目標的指針,相對偏移量或是一個能間接定位到目標的句柄。如果有了直接引用,那引用的目標必定已經在記憶體中存在。
對於初始化階段來說,是執行類構造器 client方法的過程。其方法是由編譯器自動收集類中的類變數的賦值操作和靜態語句塊中的語句合併而成的。虛擬機會保證子類構造器 client方法執行之前,父類的類構造器 client方法已經執行完畢,如果一個類中沒有對靜態變數賦值也沒有靜態語句塊,那麼編譯器可以不為這個類生成類構造器 client方法。
特別需要註意的是,以下幾種情況不會執行類初始化:
- 通過子類引用父類的靜態欄位,只會觸發父類的初始化,而不會觸發子類的初始化。
- 定義對象數組,不會觸發該類的初始化。
- 常量在編譯期間會存入調用類的常量池中,本質上並沒有直接引用定義常量的類,不會觸發定義常量所在的類。
- 通過類名獲取Class對象,不會觸發類的初始化。
- 通過Class.forName載入指定類時,如果指定參數initialize為false時,也不會觸發類初始化,其實這個參數是告訴虛擬機,是否要對類進行初始化。
- 通過ClassLoader預設的loadClass方法,也不會觸發初始化動作。
在Java HotSpot(TM)虛擬機中,其載入動作放到JVM外部實現,以便讓應用程式決定如何獲取所需的類,主要提供了3種類載入器,其中:
- 啟動類載入器(Bootstrap ClassLoader):負責載入 JAVA_HOME\lib 目錄中的,或通過-Xbootclasspath參數指定路徑中的,且被虛擬機認可(按文件名識別,如rt.jar)的類。
- 擴展類載入器(Extension ClassLoader):負責載入 JAVA_HOME\lib\ext 目錄中的,或通過java.ext.dirs系統變數指定路徑中的類庫。
- 應用程式類載入器(Application ClassLoader): 負責載入用戶路徑(classpath)上的類庫。 JVM通過雙親委派模型進行類的載入,當然我們也可以通過繼承java.lang.ClassLoader實現自定義的類載入器。
當一個類收到了類載入請求,首先不會嘗試自己去載入這個類,而是把這個請求委派給父類去完成,每一個層次類載入器都是如此,因此所有的載入請求都應該傳送到啟動類載入其中,只有當父類載入器反饋自己無法完成這個請求的時候,一般來說是指在它的載入路徑下沒有找到所需載入的Class,子類載入器才會嘗試自己去載入。
採用雙親委派的一個好處是比如載入位於rt.jar包中的類java.lang.Object,不管是哪個載入器載入這個類,最終都是委托給頂層的啟動類載入器進行載入,這樣就保證了使用不同的類載入器最終得到的都是同樣一個Object對象。
由此可見,使用雙親委派之後,外部類想要替換系統JDK的類時,或者篡改其實現時,父類載入器已經載入過的,系統JDK子類載入器便不會再次載入,從而一定程度上防止了危險代碼的植入。
4.Java對象組成結構
Java對象(Object實例)結構主要包括對象頭、對象體和對齊位元組三部分。
在一個Java對象(Object Instance)中,主要包含對象頭(Object Header),對象體(Object Entry),以及對齊位元組(Byte Alignment)等內容。
換句話說,一個JAVA對象在記憶體中的存儲分佈情況,其抽象成存儲結構,在Hotspot虛擬機中,對象在記憶體中的存儲佈局分為 3 塊區域,其中:
- 對象頭(Object Header):對象頭部信息,主要分為標記信息欄位,類對象指針,以及數組長度等三部分信息。
- 對象體(Object Entry):對象體信息,也叫作實例數據(Instance Data),主要包含對象的實例變數(成員變數),用於成員屬性值,包括父類的成員屬性值。這部分記憶體按4位元組對齊。
- 對齊位元組(Byte Alignment):也叫作填充對齊(Padding),其作用是用來保證Java對象所占記憶體位元組數為8的倍數HotSpot VM的記憶體管理要求對象起始地址必須是8位元組的整數倍。
一般來說,對象頭本身是填充對齊的參考指標是8的倍數,當對象的實例變數數據不是8的倍數時,便需要填充數據來保證8位元組的對齊。其中,對於對象頭來說:
- 標記信息欄位(Mark Word): 主要存儲自身運行時的數據,例如GC標誌位、哈希碼、鎖狀態等信息, 用於表示對象的線程鎖狀態,另外還可以用來配合GC存放該對象的hashCode。
- 類對象指針(Class Pointer): 用於存放方法區Class對象的地址,虛擬機通過這個指針來確定這個對象是哪個類的實例。是指向方法區中Class信息的指針,意味著該對象可隨時知道自己是哪個Class的實例。
- 數組長度(Array Length): 如果對象是一個Java數組,那麼此欄位必須有,用於記錄數組長度的數據;如果對象不是一個Java數組,那麼此欄位不存在,所以這是一個可選欄位。根據當前JVM的位數來決定,只有當本對象是一個數組對象時才會有這個部分。
其次,對於對象體來說,用於保存對象屬性值,是對象的主體部分,占用的記憶體空間大小取決於對象的屬性數量和類型。
而對於對齊位元組來說,並不一定是必然存在的,也沒有特別的含義,它僅僅起著占位符的作用。當對象實例數據部分沒有對齊(8位元組的整數倍)時,就需要通過對齊填充來補全。
特別指出,相對於對象結構中的欄位長度來說,其Mark Word、Class Pointer、Array Length欄位的長度都與JVM的位數息息相關。其中:
- 標記信息欄位(Mark Word):欄位長度為JVM的一個Word(字)大小,也就是說32位JVM的Mark Word為32位,64位JVM的Mark Word為64位。
- 類對象指針(Class Pointer):欄位長度也為JVM的一個Word(字)大小,即32位JVM的Mark Word為32位,64位JVM的Mark Word為64位。
也就是說,在32位JVM虛擬機中,Mark Word和Class Pointer這兩部分都是32位的;在64位JVM虛擬機中,Mark Word和Class Pointer這兩部分都是64位的。
對於對象指針而言,如果JVM中的對象數量過多,使用64位的指針將浪費大量記憶體,通過簡單統計,64位JVM將會比32位JVM多耗費50%的記憶體。
為了節約記憶體可以使用選項UseCompressedOops來開啟/關閉指針壓縮。
其中,UseCompressedOops中的Oop為Ordinary Object Pointer(普通對象指針)的縮寫。
如果開啟UseCompressedOops選項,以下類型的指針將從64位壓縮至32位:
- Class對象的屬性指針(靜態變數)
- Object對象的屬性指針(成員變數)
- 普通對象數組的元素指針。
當然,也不是所有的指針都會壓縮,一些特殊類型的指針不會壓縮,比如指向PermGen(永久代)的Class對象指針(JDK 8中指向元空間的Class對象指針)、本地變數、堆棧元素、入參、返回值和NULL指
針等。
在堆記憶體小於32GB的情況下,64位虛擬機的UseCompressedOops選項是預設開啟的,該選項表示開啟Oop對象的指針壓縮會將原來64位的Oop對象指針壓縮為32位。其中:
- 手動開啟Oop對象指針壓縮的Java指令為:
java -XX:+UseCompressedOops tagretClass<目標類>
- 手動關閉Oop對象指針壓縮的Java指令為:
java -XX:-UseCompressedOops tagretClass<目標類>
如果對象是一個數組,那麼對象頭還需要有額外的空間用於存儲數組的長度(Array Length)欄位。
這也就意味著,Array Length欄位的長度也隨著JVM架構的不同而不同:在32位JVM上,長度為32位;在64位JVM上,長度為64位。
需要註意的是,在64位JVM如果開啟了Oop對象的指針壓縮,Array Length欄位的長度也將由64位壓縮至32位。
5.Java對象創建流程
Java對象創建流程主要分為對象實例化,類載入檢測,對象記憶體分配,值初始化,設置對象頭,執行初始化等6個步驟。
在瞭解完一個Java對象組成結構之後,我們便開始進入Java對象創建流程的剖析,掌握其本質有利於我們在實際開發工作中,可參考分析一段Java代碼的執行後,其在JVM中的產生的結果和影響。
從大致工作流程來看,可以分為對象實例化,類載入檢測,對象記憶體分配,值初始化,設置對象頭,執行初始化等6個步驟。其中:
- 對象實例化:一般在Java領域中指通過new關鍵字來實例化一個對象,在此之前Java HotSpot(TM) VM需要進行類載入檢測。
- 類載入檢測:進行類載入檢測,主要是檢測對應的符號引用是否被載入和初始化,最後才決定類是否可以被載入。
- 對象記憶體分配: 主要是指當類被載入完成之後,Java HotSpot(TM) VM會為其分配記憶體並開闢記憶體空間,根據情況來確定最終記憶體分配方案。
- 值初始化:根據Java HotSpot(TM) VM為其分配記憶體並開闢記憶體空間,來進行零值初始化。
- 設置對象頭: 完成值初始化之後,設置對象頭標記對象實例。
- 執行初始化: 執行初始化函數,一般是指類構造函數,併為其設置相關屬性。
從Java對象創建流程的各個環節,具體詳細來看,其中:
首先,對於對象實例化來說,主要是看寫代碼時,用關鍵詞class定義一個類其實只是定義了一個類的模板,並沒有在記憶體中實際產生一個類的實例對象,也沒有分配記憶體空間。
而要想在記憶體中產生一個類的實例對象就需要使用相關方法申請分配記憶體空間,加上類的構造方法提供申請空間的大小規格,在記憶體中實際產生一個類的實例,一個類使用此類的構造方法,執行之後就在記憶體中分配了一個此類的記憶體空間,有了記憶體空間就可以向裡面存放定義的數據和進行方法的調用。
在Java領域中,常見的Java對象實例化方式主要有:
- JDK提供的New 關健字:可以調用任意的構造函數(無參的和帶參數的)創建對象。
- Class的newInstance()方法: 使用Class類的newInstance方法創建對象。其中,newInstance方法調用無參的構造函數創建對象。
- Constructor的newInstance()方法: java.lang.reflect.Constructor類里也有一個newInstance方法可以創建對象,從而可以通過newInstance方法調用有參數的和私有的構造函數。
- 實現Cloneable介面並實現其定義的clone()方法:調用一個對象的clone方法,jvm就會創建一個新的對象,將前面對象的內容全部拷貝進去。用clone方法創建對象並不會調用任何構造函數。
- 反序列化的方式:當我們序列化和反序列化一個對象,jvm會給我們創建一個單獨的對象。在反序列化時,Java HotSpot(TM) VM創建對象並不會調用任何構造函數。
其次,對於類載入檢測來說,當對象實例化之前,其Java HotSpot(TM) VM會自行進行檢測,主要是:
- 檢測對象實例化的指令是否在類的常量池信息中定位到類的符號引用。
- 檢測符號引用是否被載入和初始化,倘若沒有的話便對類進行載入。
然而,對於對象記憶體分配來說,創建一個對象所需要的記憶體大小其實類載入完成就已經確定,記憶體分配主要是在堆中划出一塊對象大小的對應記憶體。具體的分配方式依據堆記憶體的對齊方式來決定,而堆記憶體的對齊方式是根據當前程式的GC機制來決定的。
再者,對於值初始化來說,這隻是依據Java HotSpot(TM) VM自動分配的記憶體對其進行初始化,並設置為零值。
接著,對於設置對象頭來說,就是對於每一個進入Java HotSpot(TM) VM的對象實例進行對象頭信息設置。
最後,對於執行初始化來說,算是Java HotSpot(TM) VM真正意義上的執行。
6.Java對象記憶體分配機制
Java對象記憶體分配機制可以大致分為堆上分配,棧上分配,TLAB分配,以及年代區分配等方式。
一般來說,在理解Java對象記憶體分配機制之前,我們需要明確理解Java領域中的堆(Heap)與棧(Stack)概念,才能更好地掌握和清楚對應到相應的Java記憶體模型上去,主要是大多數時候,我們都是把這兩個結合起來講的,就是常說的“堆棧(Heap-Stack)“模型。其中:
- 堆(Heap): 用來存放程式動態生成的實例數據,是對象實例化(一般是指new)之後將其存儲,Java HotSpot(TM) VM會依據對象大小在Java Heap中為其開闢對應記憶體空間大小。
- 棧(Stack):用來存放基本數據類型和引用數據類型的實例。一般主要是指實例對象的在堆中的首地址,其中每一個線程都有自己的線程棧,被線程獨享。
因此,我們可以理解為堆記憶體和棧記憶體的概念,相對來說:
- 堆記憶體: 用於存儲java中的對象和數組,當我們new一個對象或者創建一個數組的時候,就會在堆記憶體中開闢一段空間給它,用於存放。堆記憶體的特點就是:先進先出,後進後出。堆可以動態地分配記憶體大小,生存期也不必事先告訴編譯器,因為它是在運行時動態分配記憶體的,但缺點是,由於要在運行時動態分配記憶體,存取速度較慢。由Java HotSpot(TM) VM虛擬機的自動垃圾回收器來管理。
- 棧記憶體: 主要是用來執行程式用的,棧記憶體的特點:先進後出,後進先出。存取速度比堆要快,僅次於寄存器,棧數據可以共用,但缺點是,存在棧中的數據大小與生存必須是確定的,缺乏靈活性。棧記憶體可以稱為一級緩存,由垃圾回收器自動回收。
Java程式在Java HotSpot(TM) VM中運行時,從數據在記憶體區域的分佈來看,大致可以分為線程私有區,線程共用區,直接記憶體等3大記憶體區域。其中 :
- 線程私有區(Thread Local): 線程私有數據主要是記憶體區域主要有程式計數器、虛擬機棧、本地方法區,該區域生命周期與線程相同, 依賴用戶線程的啟動/結束 而 創建/銷毀。
- 線程共用區(Thread Shared): 線程共用區的數據主要是JAVA 堆、方法區。其區域生命周期伴隨虛擬機的啟動/關閉而創建/銷毀。
- 直接記憶體(Direct Memory):非JVM運行時數據區的一部分, 但也會被頻繁的使用,不受Java HotSpot(TM) VM中GC控制。比如,在JDK 1.4引入的NIO提供了基於Channel與Buffer的IO方式, 它可以使用Native函數庫直接分配堆外記憶體, 然後使用DirectByteBuffer對象作為這塊記憶體的引用進行操作, 這樣就避免了在Java堆和Native堆中來回覆制數據, 因此在一些場景中可以顯著提高性能。
由此可見,Java堆(Java Heap)是虛擬機所管理的記憶體中最大的一塊。Java堆是被所 有線程共用的一塊記憶體區域,在虛擬機啟動時創建。此記憶體區域的唯一目的就是存放對象實例,Java 世界里“幾乎”所有的對象實例都在這裡分配記憶體。
對於對象記憶體分配來說,創建一個對象所需要的記憶體大小其實類載入完成就已經確定,記憶體分配主要是在堆中划出一塊對象大小的對應記憶體。具體的分配方式依據堆記憶體的對齊方式來決定,而堆記憶體的對齊方式是根據當前程式的GC機制來決定的。
對於線程共用區的數據來說,常見的對象在堆記憶體分配主要有:
- 指針碰撞: 主要針對堆記憶體對齊的情況
- 空閑列表: 主要針對堆記憶體無法對齊的情況,相互交錯
- CAS自旋鎖和TLAB本地記憶體: 主要針對分配出現併發情況的解決方案
對於線程私有區的數據來說,常見的對象在堆記憶體分配原則主要有:
- 嘗試棧上分配:滿足棧上分配條件,進行棧上分配,否則進行嘗試TLAB分配。
- 嘗試TLAB分配:滿足TLAB分配條件,進行TLAB分配,否則進行嘗試老年代分配。
- 嘗試老年代分配:滿足老年代分配條件,進行老年代分配,否則嘗試新生代分配。
- 嘗試新生代分配:滿足新生代分配條件,進行新生代分配。
需要特別註意的是,不論是否能進行分配都是在Eden區進行分配的,主要是當出現多個線程同時創建一個對象的時候,TLAB分配做了優化,Java HotSpot(TM) VM虛擬機會在Eden區為其分配一塊共用空間給其線程使用。
Java對象成員初始化順序大致順序為靜態代碼快/靜態變數->非靜態代碼快/普通變數->一般類構造方法,其中:
按照Java程式代碼執行的順序來看,被static修飾的變數和代碼塊肯定是優先初始化的,其次結合繼承的思想,父類要比子類優先初始化,最後才是一般構造方法。
寫在最後
Java源碼依據JDK提供的API來組織有效的代碼實體,一般都是通過調用API來編織和組成代碼的。
Java編譯機制主要可以分為編譯前端和編譯後端兩個階段,一般來說主要是指將源代碼翻譯為目標代碼的過程,稱為編譯過程。
Java類載入機制主要分為載入,驗證,準備,解析,初始化等5個階段。
Java對象(Object實例)結構主要包括對象頭、對象體和對齊位元組三部分。
Java對象記憶體分配機制可以大致分為堆上分配,棧上分配,TLAB分配,以及年代區分配等方式。
綜上所述,一個Java對象從創建到被托管給JVM時,會經歷和完成上面的一系列工作。
版權聲明:本文為博主原創文章,遵循相關版權協議,如若轉載或者分享請附上原文出處鏈接和鏈接來源。