JVM 內存結構
根據JVM虛擬機規范,對內存區域做了以下劃分:
方法區
方法區存著類的信息,常量和靜態變量,即類被編譯后的數據。這個說法其實是沒問題的,只是太籠統了。更加詳細一點的說法是方法區里存放著類的版本,字段,方法,接口和常量池。
常量池里存儲著編譯期間生成的各種字面量和符號引用。符號引用包括:1.類的全限定名,2.字段名和屬性,3.方法名和屬性。運行時常量池相對于Class文件常量池的另外一個重要特征是具備動態性,運行期間也可以將新的常量放入池中, 這種特性被開發人員利用得比較多的便是String類的intern()方法。
運行時常量池是方法區的一部分, 自然受到方法區內存的限制, 當常量池無法再申請到內存時會拋出OutOfMemoryError異常。
堆 Heap:
堆也是屬于線程共享的內存區域,它在虛擬機啟動時創建,是Java 虛擬機所管理的內存中最大的一塊,主要用于存放對象實例(引用數據類型)。堆是垃圾收集器GC管理的主要區域。
所有線程共享的Java堆中可以劃分出多個線程私有的分配緩沖區,以提升對象分配時的效率。
當前主流Java虛擬機的堆空間都是可擴展的,如果在Java堆中沒有內存完成實例分配, 并且堆也無法再擴展時, Java虛擬機將會拋出OutOfMemoryError異常。
虛擬機棧 Stack:
虛擬機棧是線程私有的,它的生命周期與線程相同。虛擬機棧為虛擬機執行Java方法(也就是字節碼)服務,描述的是Java方法執行的內存。
每個方法在執行的同時都會創建一個棧幀(Stack Frame)用于存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。局部變量表存放用來存放方法參數,以及方法內定義的局部變量,其中包括各種基本數據類型、引用對象類型(即存放對象在堆中的地址)和returnAddress類型(返回地址類型,指向了一條字節碼指令的地址)。這些數據類型在局部變量表中的存儲空間以局部變量槽(Slot) 來表示, 其中64位長度的long和double類型的數據會占用兩個變量槽, 其余的數據類型只占用一個。 局部變量表所需的內存空間在編
譯期間完成分配, 當進入一個方法時, 這個方法需要在棧幀中分配多大的局部變量空間是完全確定的, 在方法運行期間不會改變局部變量表的大小。
每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程。
?
程序計數器
屬于線程私有的數據區域,是一小塊內存空間,主要代表當前線程所執行的字節碼行號指示器。字節碼解釋器工作時,通過改變這個計數器的值來選取下一條需要執行的字節碼指令。
本地方法棧
本地方法棧屬于線程私有的數據區域,虛擬機棧為虛擬機執行Java方法(也就是字節碼) 服務, 而本地方法棧則是為虛擬機使用到的本地(Native)方法服務。
對象的創建過程:
當虛擬機遇到一條字節碼new指令時,會先去檢查這條指令的參數能否在常量池中定位到一個類的符號引用,并檢查這個類是否已被加載過,如果沒有,則執行相應的類加載過程。
創建對象:
1. 在堆中給對象劃分內存
2. 接下來虛擬機要對對象進行必要的設計,例如這個對象是哪個類的實例,如何才能找到類的元數據信息,對象的哈希嗎,對象的GC分代年齡等信息。
3. 此時對象的所有字段值都為零,把對象按照程序員的意愿進行初始化,這樣一個對象才算完全生產出來。
垃圾回收
1. 判斷對象是否“已死”
1.1 引用計數算法
對象中添加一個引用計數器,如果引用計數器為0則表示沒有其它地方在引用它。如果有一個地方引用就+1,引用失效時就-1。
大部分虛擬機中沒有采用這種算法,出現的問題:對象間的循環引用。
1.2 可達性分析算法
通過一系列“GC Roots”根對象作為起始節點, 從這些節點開始, 根據引用關系在對象之間建立連接。如果某個對象到GC Roots不可達, 則說明此對象不可能再被使用。
?
2. 垃圾回收算法
在了解具體的垃圾回收算法之前,先明白堆的分代模型。
2.1 分代收集理論
分代收集質是一套符合大多數程序運行實際情況的經驗法則, 它建立在兩個分代假說之上:
弱分代假說(Weak Generational Hypothesis) : 絕大多數對象都是朝生夕滅的。
強分代假說(Strong Generational Hypothesis) : 熬過越多次垃圾收集過程的對象就越難以消亡。
基于這兩個假說,將堆空間劃分成了“新生代”和“老年代”。在新生代中, 每次垃圾收集時都發現有大批對象死去, 而每次回收后存活的少量對象, 將會逐步晉升到老年代中存放。
分代之后有了一個明顯額困難:對象不是孤立的,對象之間會存在跨代引用。于是有了第三條假說:
跨代引用假說(Intergenerational Reference Hypothesis) : 跨代引用相對于同代引用來說僅占極少數。
這條假說可根據前兩條假說邏輯推理得出的隱含推論: 存在互相引用關系的兩個對象, 是應該傾向于同時生存或者同時消亡的。
垃圾回收分類:
新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
老年代收集(Major GC/Old GC ):只是老年代的垃圾收集
整堆收集(Full GC):收集整個java堆和方法區的垃圾收集。
2.2 垃圾回收算法
2.2.1 標記-清除算法
標記-清除算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,標記完成后統一回收所有被標記的對象。這種算法的不足主要體現在效率和空間,從效率的角度講,標記和清除兩個過程的效率都不高;從空間的角度講,標記清除后會產生大量不連續的內存碎片, 內存碎片太多可能會導致以后程序運行過程中在需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發一次垃圾收集動作。
?
2.2.2 標記-復制算法:
也稱為復制算法。復制算法是為了解決效率問題而出現的,它將可用的內存分為兩塊,每次只用其中一塊,當這一塊內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已經使用過的內存空間一次性清理掉。這樣每次只需要對整個半區進行內存回收,內存分配時也不需要考慮內存碎片等復雜情況,只需要移動指針,按照順序分配即可。
?
針對具備“朝生夕滅”特點的對象, 提出了一種更優化的半區復制分代策略, 現在稱為“Appel式回收”。
Appel式回收的具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間, 每次分配內存只使用Eden和其中一塊Survivor。 發生垃圾搜集時, 將Eden和Survivor中仍然存活的對象一次性復制到另外一塊Survivor空間上, 然后直接清理掉Eden和已用過的那塊Survivor空間(Eden和Survivor的大小比例是8∶1)。
如果Survivor空間已滿,會有老年代分配擔保,放不下的對象會被放入老年代。
如果反復復制多次之后對象仍然存活,則該對象將會被移至老年代。
?
Appel式回收垃圾的流程:
1、新建的對象,大部分存儲在Eden中
2、發生垃圾收集時,就進行Minor GC釋放掉不活躍對象;然后將部分活躍對象復制到Survivor中(如Survivor1),同時清空Eden區.
3、再次發生垃圾收集時,將Survivor1中不能清除的對象存放到另一個Survivor中(如Survivor0),同時將Eden區中的不能清空的對象,復制到Survivor1,清空Eden區。
4、重復多次(默認15次):Survivor中沒有被清理的對象就會復制到老年區(Old)
5、當Old達到一定比例,則會觸發Major GC釋放老年代
6、當Old區滿了,則觸發一個一次完整的垃圾回收(Full GC)
7、如果內存還是不夠,JVM會拋出內存不足,發生oom,內存泄漏。
2.2.3?標記-整理算法
是一種針對老年代的回收算法。
標記-復制算法在對象存活率較高時就要進行較多的復制操作, 效率將會降低。 更關鍵的是, 如果不想浪費50%的空間, 就需要有額外的空間進行分配擔保, 以應對被使用的內存中所有對象都100%存活的極端情況, 所以在老年代一般不能直接選用這種算法。
標記-整理算法和標記-清除算法類似,不過不是直接對可回收對象進行清理,而是讓所有存活對象都向一端移動,然后直接清理掉邊界以外的內存。
?
3.?常見的垃圾收集器
3.1 CMS收集器
CMS基于標記-清除算法,暫時容忍內存碎片的存在, 直到內存空間的碎片化程度已經大到影響對象分配時, 再采用標記-整理算法收集一次, 以獲得規整的內存空間。
CMS是一種并發收集器,讓垃圾收集線程與用戶線程同時運行。
CMS只會回收老年代和永久代(1.8開始為元數據區),不會收集年輕代;年輕代只能配合Parallel New或Serial回收器;
3.2 G1收集器
G1,即?Garbage-First。基于標記-復制算法,原理是將整個堆空間劃分為一塊塊空間(Region),對各個Region的回收價值和成本進行排序,根據用戶所期望的GC停頓時間來制定回收計劃。可以理解為,排序后,哪塊Region的垃圾多,就優先清除哪塊。縮小了回收垃圾時的停頓時間。
雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但是還是保留了分代的概念。
?
Java 內存模型概述
Java內存模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,并不真實存在,它描述的是一組規則或規范。
計算機在執行 Java 程序時,所有指令都是在 JVM 中被執行,JVM執行指令也可以看作是 cpu 處理數據。
但是隨著cpu的發展,內存的讀寫速度也遠遠趕不上cpu。因此cpu廠商在每顆cpu上加上高速緩存,用于緩解這種情況。在cpu處理數據之前,先將要處理的數據加載到工作內存中,再對數據進行處理。
Java 內存模型中涉及到的概念有:(主內存和工作內存是邏輯上的概念,如果硬要往物理方面靠,工作內存就相當于cpu的高速緩存,主內存就相當于內存條)
主內存:java虛擬機規定所有的變量都必須在主內存中產生。主內存是整個機器的內存,而虛擬機的內存是主內存中的一部分。
工作內存:java虛擬機中每個線程都有自己的工作內存,該內存是線程私有的。虛擬機規定,線程對主內存變量的修改必須在線程的工作內存中進行,不能直接讀寫主內存中的變量。不同的線程之間也不能相互訪問對方的工作內存。如果線程之間需要傳遞變量的值,必須通過主內存來作為中介進行傳遞。
?
存在的問題:
cpu上加入了高速緩存這樣做解決了處理器和內存的矛盾(一快一慢),但是引來的新的問題 ——緩存一致性。
當多個cpu上的線程對主存中的同一個共享變量進行讀取時,這個變量就會被緩存到每個線程的 工作內存中,變量被修改后,什么時候寫入主存是不確定的,可能其他線程去主存中讀取這個變量時,還是原來的舊值。
解決方法:
給變量加鎖 (阻礙高并發,程序效率低)
使用volatile修飾變量
volatile 修飾的變量
valatile類型的變量保證對所有線程的可見性
可見性指的是當一個線程A修改了某個共享變量的值,線程B能夠馬上得知這個修改的值,即使這個變量已經被線程B加載到了自己的工作內存中。對于串行程序來說,可見性是不存在的,因為我們在任何一個操作中修改了某個變量的值,后續的操作中都能讀取這個變量值,并且是修改過的新值。但在多線程環境中可就不一定了,由于線程對共享變量的操作都是線程拷貝到各自的工作內存進行操作后才寫回到主內存中的,這就可能存在一個線程A修改了共享變量x的值,還未寫回主內存時,另外一個線程B又對主內存中同一個共享變量x進行操作,但此時A線程工作內存中共享變量x對線程B來說并不可見,這種工作內存與主內存同步延遲現象就造成了可見性問題
volatile保證數據的可見性,不保證原子性;synchronized保證數據的可見性和原子性;
注意:valatile?只是保證它的值一旦被修改,其他線程就能立馬讀取到(對volatile變量的所有寫操作總是能立刻反應到其他線程中)。
publicclassTest{publicstaticvolatileintcount =0;publicstaticvoidmain(String[] args){for(inti =0;i<10;i++){newThread(newRunnable() {@Overridepublicvoidrun(){for(intj=0;j<10000;j++){? ? ? ? ? ? ? ? ? ? ? ? count++;? ? ? ? ? ? ? ? ? ? ? ? System.out.println(count);? ? ? ? ? ? ? ? ? ? }? ? ? ? ? ? ? ? }? ? ? ? ? ? }).start();? ? ? ? }? ? }}
程序運行結果為:(最后輸出的數字 不為100000)
?
上述例子中,count變量的任何改變都會立馬反應到其他線程中,但是如此存在多條線程同時進行,就會出現線程安全問題,畢竟count++;操作并不具備原子性,該操作是先讀取值,對值進行自增,然后寫回一個新值,分三步完成,如果第二個線程在第一個線程讀取舊值和寫回新值期間讀取i的域值,那么第二個線程就會與第一個線程一起看到同一個值,并執行相同值的加1操作,這也就造成了線程安全問題。因此對于這種情況必須使用synchronized修飾,以便保證線程安全。
對變量的修改,或者對變量的運算,卻不能保證是原子性的。即一次操作分解為多個子操作?有可能存在覆蓋的情況。
volatile變量禁止指令重排序優化
為了提高執行效率,編譯器和 JVM 會優化和調整語句的執行順序。
在單線程內部,我們看到的執行結果和代碼順序是一致的,但在多線程中,就可能出現代碼的執行順序和代碼順序不一致的情況。
publicstaticvolatilebooleanflag =false;publicstaticintnumber =0;publicstaticvoidmain(String[] args){// 線程1newThread(newRunnable() {@Overridepublicvoidrun(){? ? ? ? ? ? ? ? number++;? ? ? ? ? ? ? ? flag =true;? ? ? ? ? ? }? ? ? ? }).start();// 線程2newThread(newRunnable()){@Overridepublicvoidrun(){if(flag){? ? ? ? ? ? ? ? ? ? config(number);? ? ? ? ? ? ? ? }? ? ? ? ? ? }? ? ? ? }.start();? ? }
如果 flag 是普通變量,則在線程1有可能在程序重排序后,flag會先被賦值 true ,再執行線程中的其他程序。而線程2在判斷 flag為true后,會對number進行一些配置。這就導致了一個問題:線程 2 配置時,其中的參數可能還未初始化。
(這個簡單的程序用于舉例說明重排序是怎么回事,因為程序指令太少,跑起來后不會真正重排序)