最近一直在看《深入理解Java虛擬機》第三版,無意中發現了第三版是最近才發行的,聽說講解的JDK版本升級,新增了近50%的內容。
這種神書,看懂了,看進去了,真的看的很快,並沒有想象中的晦澀難懂,畢竟是公認的經典,作者書面描述能力肯定了得。雖然這種書,不會讓你的代碼能力馬上提升,但是真正的讓你知其然,還知其所以然。等遇到了這方面的問題,肯定不會像無頭蒼蠅一樣,一頭霧水,起碼有一定的思路。更多Java、計算機方面的一些好書正在路上,今年一定要好好地提升一下內功。
不過,比如第五章的內容,調優實戰,沒有充足的實戰經驗和一些大型項目經驗,雖說一些地方能看懂作者在說什么,但是沒有一個自己有過經驗的實際場景去代入,理解的還是不夠充分。
當然看一次肯定不能消化完整,雖然在看的時候就在有道筆記上做了一些筆記,但是還是上傳到博客園吧,就當水一篇博客啦。
Java內存區域與內存溢出異常
2.2運行時數據區域
Java虛擬機所管理的內存包含以下幾個運行時數據區域:
1.程序計數器:是當前線程所執行的字節碼的行號指示器。就是通過改變這個行號指示器的值來選取下一個需要執行的字節碼指令,從而可以實現循環、跳轉、分支、異常處理等基礎功能。Java虛擬機的多線程是通過線程間的輪流切換、粉配處理器執行時間來實現的,所以為了讓線程切換后恢復到正確的執行位置,每個線程的計數器是獨立的,互不影響,包括主線程。如果線程執行的是Java代碼,計數器記錄的是字節碼的行號,如果執行的是本地方法,計數器為空。Undefined。這個區域不會報內存溢出異常。
2.Java虛擬機棧:其也是線程私有的,生命周期與線程相同,其描述的是Java執行的線程內存模型。每個方法被執行時會創建一個棧幀(一種數據結構),用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。(其中動態鏈接是:在類加載機制中,解析步驟會把符號引用轉為直接引用,還有一部分會在執行過程中才變成直接引用,這就是動態鏈接)。棧幀的入棧到出棧就是一個方法完整的執行過程。重點是虛擬機棧中的局部變量表,其存放的是基本數據類型、對象引用、returnAddress類型(一條字節碼的地址)。當線程請求的棧的深度大於虛擬機允許的深度,會報StackOverflowError。當棧拓展時無法申請到足夠的內存會報OutOfMemoryError。
3.本地方法棧:其與Java虛擬機棧作用相似,只是Java虛擬機棧為Java方法服務,而本地方法棧為本地方法服務。也有上面兩種異常。
4.Java堆:虛擬機管理的內存中最大的一塊。Java堆是所有線程共享的,其唯一目的就是存放對象實例。一個對象的創建,其引用放在棧,實例放在堆。Java堆是垃圾收集器管理的內存區域,因此有的人稱他為GC堆。GC相關內容后面再記。無論堆這么划分,其存儲的都只是對象的實例,細分的目的只是為了更好的回收內存和分配內存。當Java堆無法完成實例分配,堆也無法拓展,會報OutOfMemoryError。
5.方法區:與Java堆一樣,是線程共享的內存區域。用於存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯后的代碼緩存等數據。如果方法區無法滿足內存分配需求,將會報OutOfMemoryError異常。
2.3HotSpot虛擬機對象探秘
對象創建的基本過程:
- 檢查new這個指令的參數是否能在常量池中找到一個符號引用,並且檢查該引用代表的類是否已經被加載、解析、初始化過。
- 為新生對象分配內存。這個內存分配涉及到的東西很多,比如不同的垃圾收集器也會有不同的分配方法,如果是帶壓縮整理過程的收集器,其分配起來較為簡單,因為其內存空閑地址是連續的。但是在並發情況下,也是不安全的,這時候可能就要采用CAS。這里只講大概過程,具體細節后面章節會講。
- 內存分配完成后,虛擬機將分配到的內存空間都初始化為0值。
- 初始化后,虛擬機會進行一些必要的設置,比如這個對象是哪個類的實例、在GC中分代年齡信息等,這些信息會被放到對象頭中。
- 上面四步,對於虛擬機來說,一個對象的創建已經完成,但對於程序的角度來說,還差一步,就是類的初始化方法,構造函數。程序員可以按照自己的需求來寫這個初始化方法,到這里,這個對象完全被創建完成。
對象在內存當中可以被分為三個部分:對象頭、實例數據、對其填充
對象頭部分包括兩類信息,第一類信息存儲對象本身的運行時的數據,如:哈希碼、GC分代年齡、線程持有的鎖、鎖狀態、偏向時間戳、偏向線程ID等。
另一部分是類型指針,即通過這個指針確定這是哪個類的實例。如果是數組,還會存儲數組的長度。
實例數據部分是對象真正存儲的有效信息,即我們定義的各種類型的字段內容。
對其填充沒什么實際意義,HotSopt的內存管理要求任何對象的大小必須是8的整倍數,對象頭已經被設計為8的整倍數,但是實例數據就不一定了,這時候就需要對其填充來補全。
對象的訪問定位:
Java堆是存放實例數據的,Java棧上的reference數據是存放這個實例的引用的。而這個引用主流有兩種實現方法:
1.使用句柄,Java堆可能會划分一塊內存空間來作為句柄池,reference存儲的就是對象句柄的地址,這個句柄包含了對象的實例數據和具體地址信息。優點:不用改變reference,如果GC讓對象地址改變,只改變句柄中的地址就行了。
2.使用指針直接訪問,reference存儲的就是對象的地址,如果只是訪問對象本身的話,就不需要像句柄那樣再次訪問一個地址了。優點:不用二次指針定位。
垃圾收集器與內存分配策略
程序計數器、Java虛擬機棧、本地方法棧這三個區域都是隨着線程生而生、隨着線程滅而滅。這幾個區域不需要考慮太多垃圾回收的問題,方法執行完 了、線程結束了,內存自然就回收了。所以重點Java堆和方法區,他們是共享的區域,所以不會隨着線程生滅,也有着很多不確定性。
3.2對象已死?
垃圾收集器在對堆進行回收之前,肯定要判斷對象實例是否還有沒有用,如果在Java程序中一個對象沒有任何作用,其自然就需要被回收。下面兩種就是目前主流的方法
1.引用計數算法,在對象中添加一個引用計數器,每當一個對象被引用時,計數器加一,當引用失效時,計數器減一。計數器會0時,就是不可能再被使用了。這種算法很多應用都在使用,比如FlashPlayer、Python等。但是在Java中有個問題,引用計數算法卻不能解決,就是循環引用。比如兩個對象A和B互相引用,即使它們都為null了,或者都沒用了,但是它們的計數器依舊不會0。但是Java虛擬機任然可以回收它們,說明Java虛擬機不是使用的這種算法。
2.可達性分析算法,通過一系列稱為“GC Roots”的根對象作為起始節點集,注意是集,不只是一個節點。從這些節點出發,走過的路徑稱為“引用鏈”,而在這條引用鏈上的對象,都是不需要回收的存活對象,而不在引用鏈上的就需要被回收。能當作GC Root的對象有很多種,主要有:所有已經被加載的類、線程當前棧幀的引用、同步鎖持有的對象等。具體還要哪些可以看P70。當一個對象不在引用鏈上時,不代表一定就會被回收。一個對象被回收至少要被標記兩次,第一次被標記后,會進行一次篩選,判斷這個對象是否有必要執行finalize()方法,如果這個方法之前已經執行過一次,或者這個對象沒有重寫這個方法,那么就代表需要被回收。如果這個對象重寫了finalize方法,且之前沒有被執行過,他就會被放到F_Queue隊列中,等待執行這個方法,這個方法是有時間限制的,防止執行太過緩慢對系統造成威脅。這時候,這個對象可以在finalize方法里完成自救,即把自己重新和引用鏈上的某個對象連接起來。
3.2.5回收方法區
Java虛擬機規范沒有強制要求這一區域的垃圾收集工作,主要原因是性價比太低,即判斷條件高,回收空間少。方法區主要是回收常量和不再使用的類型。對於常量,如果任何對象都沒有引用這個常量,那么它就可以回收了。對於一個類型是否需要回收,判斷起來就要復雜許多:1.該類的所有實例被回收。2.加載該類的類加載器被回收。3.沒有通過反射使用該類。
3.3垃圾收集算法
上一節說到了該如何判斷哪些對象需要被回收,這一節講講該如何回收這些對象。在這之前需要了解一下分代收集理論,很多垃圾收集器就是基於這個理論去設計的。分代即把Java堆划分出幾個不同的區域,然后根據對象年齡分配到不同的區域(年齡是指熬過垃圾收集器的次數)。主要有兩個大區域,新生代和老年代。新生代區域的對象,會被頻繁回收,如果在新生代熬過一定回收次數后,就會被放到老年代。這樣划分的好處就是可以根據不同區域對象的消亡特征設計不同的垃圾收集算法。這就有了后面要說到的一些算法。但是還存在一個隱性的問題,就是跨代引用,即新生代和老年代之間存在引用,那么我們之前說到的GC Roots就不得不包含一些老年代的對象了,加大了一些額外的開銷。有一個跨代引用的假說,如果兩個對象之間存在引用,它們應該是共存亡的,出現不同代的引用是極少數的,所以也沒必要去為了那極少數,而加大一些額外開銷。
具體的算法描述可以看書,P77開始。
1.標記-清除算法:通過可達性分析后,找出需要回收的對象,並標記上,對標記的對象進行回收。也可以反過來,標記不需要回收的對象,對沒有標記的對象進行回收。主要有兩個缺點:一個是執行效率不穩定,其執行效率是隨着清楚對象的增加而降低的。一個是會產生空間碎片,因為回收的對象也許是零零散散的,導致空余內存空間也是零散的。
2.標記-復制算法:把內存空間分為兩個相等部分,一次只用其中一個部分,每次把存活對象依次規整的放到另一部分,然后再把已使用的那一部分整體回收。其最大的缺點就是將可用內存縮小了一半,空間浪費的太多了。后來IBM有項研究表明,新生代的對象98%熬不過第一次回收,所以針對這一特點,在標記-復制算法的基礎上,把新生代分為一個Eden區、兩個Survivor區。其內存占比為8:1:1。每次分配內存只使用一個Eden區和一個Survivor區。把這兩個區的存活對象復制到另一個Survivor區中,然后再清除Eden和已使用的Survivor。
3.標記-整理算法:這個算法一般用在老年代,標記過程和標記-清除算法一樣,但是在清除階段會把存活對象移到內存一端,然后直接清除到邊界以外的內存。但是在移動過程中會STW(即暫停用戶線程)。整理和不整理都有好處和壞處,所以側重點不同,就有了Parallel Scavenge收集器和CMS收集器。
經典垃圾收集器
Serial(Serial Old)收集器:如同其名字一樣,他是個單線程收集器,意味着它在工作時,用戶線程必須停止,也就是常說的STW。在新生代中它采用的是標記-復制算法,在老年代中采用的是標記-整理算法。雖然這個線程是最基礎、歷史最悠久的收集器,但是相比較於其他單線程的收集器,它依舊是非常優秀的,在HotSpot虛擬機客戶端模式下(Server啟動慢,編譯更完全,編譯器是自適應編譯器,效率高,針對服務端應用優化,在服務器環境中最大化程序執行速度而設計;Client啟動快速,內存占用少,編譯快,針對桌面應用程序優化,為在客戶端環境中減少啟動時間而優化),新生代的默認收集器就是它。
ParNew收集器:只在新生代中,其只是Serial的多線程版本,其他的與Serial沒有太多區別。依舊需要STW,它在新生代采用的是標記-復制算法。是服務端虛擬機新生代的首選收集器。
Parallel Scavenge(Parallel Old)收集器:Parallerl Scavenge是一款新生代收集器,采用的是標記-復制算法。它也是多線程的,但是與其他收集器不同的是它更關注吞吐量(用戶代碼運行時間/用戶代碼運行時間+垃圾收集時間)。適合在后台運算而不需要太多交互的任務。Parallel Old是老年代版本,采用的是標記-整理算法。也是注重吞吐量的多線程垃圾收集器。
CMS收集器:老年代的收集器,注重的是減少STW的時間,基於標記-清除算法。其過程相比其他收集器更為復雜,大概分為四步:1.初始標記,根據GC Roots找到直接關聯的對象。2.並發標記,根據初始標記階段的對象找到更為完整的關聯對象。3.重新標記,由於並發標記是和用戶線程並發的,在這個過程難免會出現一些新的可回收對象。4.並發清理,由於采用的是標記-清除算法,不需要移動對象,所以可以和用戶線程並發進行。雖然這是HotSpot追求低停頓時間的一次成功嘗試,但是也有一些缺點,比如:並發清除階段會產生新的垃圾、標記-清除算法產生的內存空間碎片、CMS默認的回收線程是(處理器核心數量+3)/4,對處理器敏感。
G1收集器:是一款主要面向服務端應用的垃圾收集器,作用於整個Java堆,是具有里程碑式意義的。雖然G1依舊保留新生代和老年代的概率,但它們不再是固定的,而是把Java堆划分成多個大小相等的Region區,每個Region區可以根據需要扮演新生代和老年代中的角色,整體來看,他采用的是標記-整理算法,但是在兩個Region之間,采用的是標記-復制算法。用戶可以設定收集停頓模型,會優先回收價值收益最大的那些Region。其工作過程分為初始標記、並發標記、最終標記、篩選回收。前三個階段與CMS類似,篩選回收階段會對各個Region的回收價值和成本排序,根據前面用戶的設定來制定回收計划。除了並發標記階段,其余三個階段也是需要STW的。G1被稱為里程碑式的設計一個重要原因就是,設計者的思想從原來一次性把垃圾收集干凈,到只是回收的速度比分配速度快就行了,這樣在滿足需求的情況下,性能也得到了很大的提升。
總結:從名字看,除了G1作用於整個Java堆,CMS作用於老年代,其余五款都可以根據名字判斷出作用於老年代還是新生代。根據之前垃圾收集算法的特點,老年代多用標記-整理算法,新生代多用標記-復制,除了CMS,其作用於老年代是標記-清除算法。而作用在服務端還是客戶端,由於服務器多核心CPU較為常見,所以多線程收集器用在服務端更好。
可達性分析
為了方便描述,首先定義一個三色標記,白色就是可達性分析中還未被標記的對象,如果從始至終都是白色那就是需要回收的對象。黑色就是已被標記,它的引用也被掃描過的對象。灰色就是已被標記,但是它的引用還未被掃描的對象。
在並發標記時,對象之間的引用可能會不停變動,當同時出現這兩種情況時,本來在引用鏈上的對象會丟失:1.黑色對象增加了一個到對象A的引用。2.灰色對象刪除了到對象A的引用。如果一個對象同時出現這兩個情況就會丟失。為什么要同時出現呢?因為已被標記的對象不會回頭去檢查,而正在被標記的對象如果引用發生變動會馬上生效。所以針對這兩種情況,只要解決其中一條就不會出現對象丟失的問題。1.增量更新,破壞的就是第一條,黑色對象新增一個引用就會被記錄下來,等並發標記結束后,再以記錄的對象為根重新掃描一邊。2.原始快照:破壞第二條。灰色對象刪除一條引用就將這個灰色對象記錄下來,並發標記結束后再以記錄的對象為根,掃描一邊。
低延遲垃圾收集器
垃圾收集器三個重要的指標:內存占用、吞吐量和延遲。內存占用和吞吐量隨着硬件性能的提升,幫助了軟件不少,不需要那么關注這兩點,隨着硬件的提升這兩項指標也會隨着提升。但是延遲不一樣,延遲也就是STW的時間,隨着內存條的容量越來越大,Java堆可用的內存也越來越大,意味着需要回收的空間也越來越大,那么STW也就越久。
Shenandoah收集器:是一款非官方的垃圾收集器,是由RedHat公司開發的項目,受到來自Sun公司的排斥,所以在正式商用版的JDK中是不支持這個收集器的,只有在OpenJDK才有。雖然沒有擁有正統血脈,但是在代碼上它相較於ZGC更像是G1的繼承者,在很多階段與G1高度一致,甚至共用了一部分源碼,但相較於G1又有一些改進。最主要有三個改進:
1.支持並發標記-整理算法。
2.默認不適用分代收集,Shennandoah和G1一樣使用Region分區,但是在Shennandoah中並沒有Region會去扮演新生代或者老年代。
3.G1中存儲引用關系的記憶集占用了大量的內存空間,在Shennandoah改用為連接矩陣,具體可以看P107。
Shennandoah收集工作過程大概可以分為9個步驟:
1.初識標記:與G1一樣,標記處與GC Roots直接關聯的對象,STW。
2.並發標記:與G1相同,根據上一步的對象,完整標記出可達對象。
3.最終標記:也與G1一樣,利用原始快照的方法標記出上個階段變動的對象,還會在這個階段統計出回收價值最高的Region,組成一個回收集。
4.並發清理:這個階段會清理整個Region區一個存活對象都沒有的區域,所以可以並發進行。
5.並發回收:將回收集中存活的對象復制一份到其他未被使用的Region區中。
6.初始引用更新:並發回收階段復制后,還需修正到復制后的新地址,但這個階段並未做什么具體操作,只是相當於一個集合點,確保並發回收階段所有線程都完成了自己的復制工作。
7.並發引用更新:這個階段才是真正修正引用的階段。
8.最終引用更新:上一步只是修正了Java堆中對象的引用,還要修正存在於GC Roots的引用,最后一次短暫的暫停,只與GC Roots數量有關。
9.並發清理:經過了並發回收的復制和引用修正,會收集中的Region就可以完全清理了。
再說說Shennandoah的一個特點,也就是前面說到的並發標記-整理算法。整理階段可以細分為5,6,7,8四個步驟。其最大的一個問題就是,在復制或者在修正引用的時候用戶線程可能正在使用這個對象。原來有個解決類似問題的方案,就是保護陷阱,大概過程就是當用戶線程訪問到對象就地址后,會進入一個異常處理器中,由該處理器轉發到新的地址。而在Shennandoah中用的是一種相對更好的方案:轉發指針,就是在每個對象前面加個新的引用字段,當不處於並發移動的情況下,該引用指向自己,並發移動了的話就指向新地址。
ZGC收集器:ZGC的目標和Shennandoah相似,都希望在不影響吞吐量的情況下,將停頓時間限制在10毫秒以內。ZGC也是基於Region布局的,還並未支持分代收集,但其Region有大中小三個類型:
1.小型Region容量固定為2MB,用於放置小於256KB的小對象。
2.中型Region固定容量為32MB,用於放置大於等於256KB,小於4MB的對象。
3.大型Region容量不固定,但一定是2的整倍數,用於存放大於4MB的對象。
ZGC在實現並發整理時用到了染色指針,之前的的收集器如果想在對象中額外存儲一些信息,大多會在對象頭里存儲,比如轉發指針。再就是之前說到的可達性分析中的三色標記,其只是表達了對象引用的情況,跟對象本身的數據沒任何關系,所以染色指針就是把這些標記信息記錄在引用對象的指針上。指針為什么還能存儲信息呢?這就要說到系統架構了,具體看P114,染色指針只支持64位系統,而AMD64架構中只支持到了52位,而各大操作系統又有自己的限制,染色指針在Linux支持的46位指針寬度中,拿出4位存儲這些標記信息,所以使用了ZGC進一步壓縮了原本46位的地址空間,從而導致了ZGC能管理的內存不能超過4TB,在今天看來,4TB的內存依舊非常充足。
染色指針的三大優勢:
1.一旦某個Region的存活對象被移走后,這個Region立即就能被回收重新利用,而Shennandoah需要一個初始引用更新,等待所有線程復制完畢。
2.染色指針可以大幅度減少在垃圾收集過程中內存屏障的使用數量(后面過程中的第五步提到),一部分功能就是因為染色指針把信息存儲在指針上了,還有一部分原因就是ZGC還並未支持分代收集,所以也不存在跨代引用。
3.染色指針在未來可以拓展,記錄更多信息,前面說到在64位系統中,Linux只用到了46位,還要18位未被開發。還有一個問題就是染色指針重新定義指針中的幾位,操作系統 是否支持,虛擬機也只是一個進程而已,這里就用到了虛擬內存映射,具體看P116。
ZGC工作過程大概可以分為以下幾步:
1.初始標記:與之前幾個收集器一樣,找到GC Roots的直接關聯對象。
2.並發標記:標記出完整的可達對象,與G1和Shennandoah不同的是,它是在指針上做更新而不是對象頭。
3.最終標記:和Shennandoah一樣。
4.並發預備重分配:這個階段需要根據特定的查詢條件統計出本次收集過程要清理哪些Region。這里的分配集不是像G1那樣按收益優先的回收集,分配集只是決定了里面的對象會被復制到新的Region,這里的Region要被釋放了。
5.並發重分配:這個過程要把分配集中的對象復制到新的Region中,並為分配集中的每個Region維護一個轉發表,得益於染色指針的幫助,可以僅從引用上就可以得知某個對象是否在分配集上,如果在復制時,一個用戶線程訪問了分配集中的對象就會被內存屏障截獲,然后根據轉發表將訪問轉發到新的對象上,並修正這個線程訪問該對象的引用,這個過程稱為指針的自愈。
6.並發重映射:這個階段要修正整個堆中指向重分配集中舊對象的所有引用。這個階段比較特殊,因為它不是迫切需要去執行的,上個階段的自愈過程就是針對某一對象的引用修正,所以即使沒有這一步也不會出現問題,只是第一次自愈有個轉發過程會稍慢一點,后面也都正常了。正因為這種不迫切,ZGC巧妙的把這步工作合並到了並發標記過程當中,因為並發標記也需要遍歷所有對象,這一步也需要修正所有舊對象的引用。
ZGC的一大問題就是其暫時還沒有分代收集,這限制了它能承受的對象分配速率不會太高。如果長時間的回收速率比不上分配速率,產生的浮動垃圾越來越多,可分配的空間也越來越小了。所以要從根本上解決這個問題還是要引入分代收集,讓新生代專門去存儲這些頻繁回收創建的對象。
虛擬機性能監控、故障處理
- jps:虛擬機進程狀況工具,可以列出正在運行的虛擬機進程。選擇參數:-l:進程主類全名;-v:虛擬機進程啟動時的參數。-m:進程啟動時傳給main函數的參數;
- jstat:監視虛擬機各種運行狀態信息的命令行工具。可以顯示本地或者遠程虛擬機進程中的類加載、內存、垃圾收集、即時編譯等運行時數據。選擇參數:-class:監視類加載、卸載數量、總空間等。-gc:監視Java堆狀況。更多選項看P142
- jinfo:實時查看和調整虛擬機的各項參數,上面說到的jps -v可以看虛擬機啟動時顯式指定的參數,虛擬機默認的參數可以用jinfo -flag查看。
- jmap:用於生成堆轉儲快照(是一個Java進程在某個時間點上的內存快照。Heap Dump是有着多種類型的。不過總體上heap dump在觸發快照的時候都保存了java對象和類的信息。通常在寫heap dump文件前會觸發一次FullGC,所以heap dump文件中保存的是FullGC后留下的對象信息)。還可以查詢finalize執行隊列、Java堆和方法區的詳細信息。選項參數看P144。常見如:-dump:生成轉儲快照;-finalizerinfo:查看finalize執行隊列;-heap:顯示Java堆詳細信息。
- jhat:這個命令與jmap搭配使用,用於分析jmap生成的堆轉儲快照。
- jstack:用於生成虛擬機當前時刻的線程快照(線程快照是虛擬機內每一條線程正在執行的方法堆棧的集合)。生成線程快照的目的通常是因為線程長時間停頓,如線程間死鎖、死循環、請求外部資源導致的長時間掛起等。選項:-F:當正常的輸出請求不被響應時,強制輸出線程堆棧。-l:顯示關於鎖的附加信息;-m:如果調用了本地方法,可以線程C/C++的堆棧。
虛擬機類加載機制
7.2類加載的時機
一個類從被加載到卸載出內存,一共要經過七個階段:加載-連接(包括:驗證-准備-解析)-初始化-使用-卸載。
一個類什么時候進行加載並沒有強制約束,但是初始化有且只有六種情況下才能進行,如使用new關鍵字實例化對象時、讀取一個靜態字段時、通過反射調用一個類時、子類要初始化時父類必須也初始化等,詳細的見P264.總結來說,就是對類型的主動引用,才會去進行初始化。用到的時候才去初始化,這也符合我們的正常思維。當然初始化前肯定要進行前面幾步,但是什么時候加載是沒有限制的。
7.3類加載的過程
1.加載:這個加載是整個流程的第一步,與標題的類加載不是同一個意思。這一步主要做三件事:1.1獲取此類的二進制字節流。1.2將字節流中代表靜態存儲結構轉化為方法區的運行時數據結構。1.3生成一個代表這個類的Class對象,作為方法區這個類各個數據的訪問入口。Java虛擬機規范並沒有對這三件事做很嚴格的要求,比如獲取二進制字節流,並沒有要求一定要從Class文件中獲取,所以有了現在的jar包、war包等從壓縮文件中讀取。也可以從其他文件里讀取,比如jsp文件。
2.驗證:這個階段非常重要,工作量也在整個流程當中占相當大一部分。這一階段要確保字節流中的信息符合規范要求,不存在危害虛擬機的代碼。如果僅在Java代碼層面,是很難做出不合規范的操作,比如訪問數組邊界外的數據等等,編譯器都會拋出異常,拒絕編譯。整個階段主要有以下四個檢驗動作:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。文件格式驗證:驗證字節流是否符合Class文件格式的規范,比如是否以魔數0xCAFEBABE開頭、代碼版本號是否在當前虛擬機接受范圍內等。元數據驗證:主要是對類的元數據信息進行語義驗證,保證要符合Java語言規范。比如這個類是否有父類,除了Object,每個類都應該有父類等。字節碼驗證:對方法體進行校驗,保證方法在運行時不會危害虛擬機,如保證在任何時候都不會跳到方法體以外的字節碼指令上等。符號引用驗證:這個驗證主要是確保解析行為能正常執行,這個驗證會發生在解析階段,即符號引用轉為直接引用,如檢查符號引用中通過全限定名是否能找到對應的類等。
3.准備:這個階段是為類變量分配內存(方法區)和設置初始值。注意這里說的是類變量,即被static修飾的,而不包括其他的變量,其他的變量會在這個類實例化時隨着這個類的對象一起分配。還有一點就是,初始值是零值,也就是一些基本數據類型的默認值,比如int是0,即使語句如下:static int value=123;初始化是0,而不是123.除非是常量,如static final int value=123,那么它的初始值就是123.
4.解析:這個階段是將常量池內的符號引用替換為直接引用。符號引用:用一組符號來描述所引用的目標,可以是任何形式的字面量,只要能定位到目標即可。直接引用:可以是直接指向目標的指針,或者一個句柄,總之就是能直接定位到目標,而且只要有了直接引用,那么虛擬機內存中一定就有該引用目標。
5.初始化:是類加載的最后一步,在准備階段時已經為變量設置了初始零值,這個階段會根據程序代碼初始化變量和其他資源。這個階段才從我們編碼角度進行真正的初始化。初始化階段其實就是執行類構造器<clinit>()方法的過程。這個方法並不是由程序員去編寫的,而是Javac編譯器自動生成的。是由編譯器自動收集類中所有類變量的賦值動作和靜態語句塊中的語句合並生成的,收集順序就是代碼的編寫順序。因此<clinit>()方法也不是類和接口必須的,如果一個類中沒有類變量的賦值語句和靜態語句塊,也就不會有這個方法了。如果一個類生成了這個方法,即使是多個線程同時調用了,執行<clinit>()也只能有一個線程,其他線程會被阻塞。
7.4類加載器
Java虛擬機的設計團隊有意把類加載階段中獲取一個類的二進制字節流這個動作放到Java虛擬機外部去實現。而實現這個動作的代碼被稱為“類加載器”。在Java虛擬機的角度看,只有兩種兩種不同的類加載器:1.啟動類加載器,使用C++實現,是Java虛擬機的一部分。2.其他所有類加載器,這個類加載器由Java語言實現,獨立存在Java虛擬機之外,並且要全部繼承啟動類加載器。但是在Java開發人員的角度看,類加載器應該被分的更細一點,自JDK1.2以來Java一直保持這三層類加載器、雙親委派的類加載架構。下面說到的是JDK8及之前版本的三層類加載器和雙親委派模型。
啟動類加載器:這個類加載器負責加載存放在JAVA_HOME\lib目錄、或者被-Xbootclasspath參數指定的路徑的類庫。並且是Java虛擬機能夠識別類庫,即按名稱識別,如果名稱不符合要求,即使在這個目錄中也不會被加載。
擴展類加載器:這個類加載器是Java代碼實現的。負責加載<JAVA_HOME>\lib\ext目錄中、或被java.ext.dirs系統變量指定的路徑中所有的類庫。就如其名,用戶可以將一些通用的類庫放到ext目錄中,以拓展JAVA SE的功能。
應用程序類加載器:負責加載ClassPath即用戶路徑下所有的類庫,如果沒有自定義的類加載器,一般情況下,這將是默認的類加載器。
加上用戶自定義的類加載器,各個類加載的協作關系通常如P283的圖所示,即從自定義類加載器——>應用程序類加載器——>擴展類加載——>啟動類加載。層層遞進。而這種關系,被稱為類的雙親委派模型。
雙親委派模型的工作過程如下:一個類加載器收到加載一個類的請求后,自己不會去加載,而是請求委派給父類加載器,如上箭頭一樣,層層遞進,直到父類無法完成這個加載請求時(它的執行目錄下沒找到該類)自己才會去加載。
這么做的好處就是,如果用戶自己編寫一個與Java類庫中重名的Java類,比如Object類,而各加載器都各自加載,那系統中就會出現多個不同的Object類,后果肯定是混亂的。有了雙親委派模型后,Object類會一直被委派到啟動類加載器中去執行,如果這個時候用戶再寫一個Object類,最后也到達啟動類加載器時,會先根據類名判斷這個類是否被加載過,如果被加載過就不再加載。可以看P284雙親委派模型的實現源碼,第一句就是根據name判斷是否加載過,很好的杜絕了上面的那個問題。
Java內存模型與線程
12.3內存模型
首先要明白一點,這里所說的Java內存模型與前面說到的Java堆、Java棧等不是一個層次的對內存的划分。Java堆等區域是Java虛擬機所管理的內存中運行時的數據區域。其實這所有的划分在物理機角度上是不存在的,只是邏輯上的划分,是Java虛擬機為了方便管理內存而設計的,就像Java堆里還分為老年代和新生代。看完前面的章節知道了運行時的幾個數據區域的作用非常多也非常重要,而Java內存模型的主要目的是為了定義程序中各種變量的訪問規則。不同的硬件和操作系統它們對內存的訪問規則都可能有所不同,為了屏蔽這種差異,就有了Java內存模型。
上面說到的對變量的訪問規則,這里的變量並不是指代碼里面的所有變量,而是包括成員變量、類變量和構成數組對象的元素。不包括局部變量、方法參數。因為后者是線程私有的,不存在競爭問題。Java內存模型主要分為主內存和工作內存,上面規定的變量都存在主內存中,每條線程有自己私有的工作內存。工作內存中保存着該線程當前操作的變量在主內存中的副本。線程操作變量時,會從主內存復制一份到自己的工作內存,修改完后再把新值賦值到主內存中的變量。
內存間的交互:Java內存模型定義了8種操作來完成,這8種操作都是原子性的。
1.lock:作用在主內存,標識一個變量為線程獨占。2.unlock:作用於主內存,把線程獨占的變量釋放出來。
3.read:作用於主內存,把一個變量的值傳輸到線程的工作內存。4.load:作用於工作內存,把read到的值放入到副本中。
5.use:作用於工作內存,把工作內存中變量的值傳給執行引擎。我們在獲取一個變量的值時就是這個操作。
6.assign:把執行引擎傳來的值賦值給工作內存中的變量。我們給一個變量賦值時就是這個操作。
7.store:把工作內存中變量的值傳給主內存。8.write:把store傳來的值放入到主內存對應的變量中。
除了這8種操作,還有一些對這8種操作的規定。如lock標識的變量其他線程不能使用。上面的原子操作往往是需要兩個一起配合才能完成一個完整的步驟的,所以還有些規則規定這些原子操作間的配合不能不合邏輯,有沖突。如read后不load,assign后就不管了等。詳情看P443。
針對volatile修飾的變量的特殊規則:
volatile有兩個作用:1.volatile變量對所有線程是立即可見的,即volatile變量的所有操作都能立即反映到其他線程之中。這是普通變量不具備的,普通變量被一個線程修改后,必須要被該線程傳回主內存,而其他線程必須讀取主內存中這個變量后才知道這個變量改變了。2.禁止指令重排序優化,指令重排序即處理器會把多條指令分發給不同的電路單元進行處理,有時候這種處理順序不一定是程序上的順序,但不會打亂有前后關聯的兩個指令。比如一個變量A,第一條指令是A+10,第二條指令是A*2,第三條指令是B-3,顯然第一條指令和第二條指令不能打亂順序,而第三條指令跟它們沒有任何關聯,所以是放在它們前面還是后面都沒有影響。
所以這些特殊規則也都是為了滿足上述的volatile的兩個作用。比如線程use一個變量前必須load,這就是為什么volatile變量是立即可見的;線程執行了assign,才能執行store,這是為了保證每次修改都能同步到主內存中,才能保證其他線程能立即看到改變。詳情見P449.
最后一點內容與線程有關,但提到的並不是很多。關於線程的筆記會在《Java並發編程的藝術》中再記。該篇筆記總計一萬兩千字左右,在看完整本書后,作為理論部分的復習筆記也是不錯的。如果以后對Java虛擬機有更深刻或者其他的理解,也會隨時更新到這個筆記中。