內存管理是計算機編程中的一個重要問題,一般來說,內存管理主要包括內存分配和內存回收兩個部分。不同的編程語言有不同的內存管理機制,本文在對比C++和Java語言內存管理機制的不同的基礎上,淺析java中的內存分配和內存回收機制,包括java對象初始化及其內存分配,內存回收方法及其注意事項等……
java與C++內存管理機制對比
在C++中,所有的對象都會被銷毀,局部對象的銷毀發生在以右花括號為界的對象作用域的末尾處,而程序猿new出來的對象則應該主動調用delete操作符從而調用析構函數去回收對象占用的內存。但是C++這種直接操作內存的方式存在很大內存泄露風險,而且人為管理內存復雜且困難。
在java中,內存管理由JVM完全負責,java中的“垃圾回收器”負責自動回收無用對象占據的內存資源,這樣可以大大減少程序猿在內存管理上花費的時間,可以更集中於業務邏輯和具體功能實現;但這並不是說java有了垃圾回收器程序猿就可以高枕無憂,將內存管理拋之腦外了!一方面,實際上java中還存在垃圾回收器沒法回收以某種“特殊方式”分配的內存的情況(這種特殊方式我們將在下文中進行詳細描述);另一方面,java的垃圾回收是不能保證一定發生的,除非JVM面臨內存耗盡的情況。所以java中部分對象內存還是需要程序猿手動進行釋放,合理地對部分對象進行管理可以減少內存占用與資源消耗。
java內存分配
java程序執行過程
-
首先Java源代碼文件(.java后綴)會被Java編譯器編譯為字節碼文件(.class后綴),然后由JVM中的類加載器加載各個類的字節碼文件,加載完畢之后,交由JVM執行引擎執行(執行過程還包括將字節碼編譯成機器碼),JVM執行引擎在執行字節碼時首先會掃描四趟class文件來保證定義的類型的安全性,再檢查空引用,數據越界,自動垃圾收集等。在整個程序執行過程中,JVM會用一段空間來存儲程序執行期間需要用到的數據和相關信息,這段空間一般被稱作為Runtime Data Area(運行時數據區),也就是我們常說的JVM內存
-
類加載器分為啟動類加載器(不繼承classLoader,屬於虛擬機的一部分;負責加載原生代碼實現的Java核心庫,包括加載JAVA_HOME中jre/lib/rt.jar里所有的 class);擴展類加載器(負責在JVM中擴展庫目錄中去尋找加載Java擴展庫,包括JAVA_HOME中jre/lib/ext/xx.jar或-Djava.ext.dirs指定目錄下的 jar 包);應用程序類加載器(ClassLoader.getSystemClassLoader()負責加載Java類路徑classpath中的類)
- 類加載機制的流程:包括了加載、連接(驗證、准備、解析)、初始化五個階段
- 加載:查找裝載二進制文件,通過一個類的全限定名獲取類的二進制字節流,並將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構;在 Java 堆中生成一個代表這個類的 java.lang.Class 對象,作為對方法區中這些數據的訪問入口。
- 驗證:為了確保Class文件中的字節流包含的信息符合當前虛擬機的要求,完成以下四個階段的驗證:文件格式的驗證、元數據的驗證、字節碼驗證和符號引用驗證。
- 准備:准備階段是正式為類變量分配內存並設置類變量初始值的階段,這些內存都將在方法區中分配
- 解析:解析階段是虛擬機將常量池中的符號引用轉化為直接引用的過程
- 初始化:初始化階段是根據程序員通過程序指定的主觀計划去初始化類變量和其他資源,也就是執行類構造器()方法的過程
現代硬件內存架構
- 一個有兩個或者多個 CPU 的現代計算機上同時運行多個線程是可能的,如果你的 Java 程序是多線程的,在你的 Java 程序中每個 CPU 上一個線程可能同時(並發)執行
- CPU在寄存器上的執行操作速度稍微大於CPU緩存層的執行速度,遠大於在主存上的執行速度
- Java內存模型中的堆棧分布在硬件內存結構中的CPU寄存器,CPU緩存層,CPU主存中,大部分分布在主存中
java內存模型划分
一般來講,我們將java內存划分為以下幾個區域, 如圖:
GC備注:
- 年輕對象存放在年輕代,采用Minor GC(指從年輕代空間(包括 Eden 和 Survivor 區域)回收內存); 長期存活的年老對象以及大對象直接存放在年老代,采用Full GC(Full GC == Major GC指的是對老年代/永久代的stop the world的GC),回收速度慢;JVM維護一個對象的年齡來進行對象的內存區域轉移,從Eden-Survivor-老年代
- 新生代包括一個Eden區,兩個survivor的from和to區(8:1:1),負責年輕小對象的回收;Eden區存放新創建的大量對象,回收頻繁,所以區域大;Survivor存放每次垃圾回收后存活的對象
- 一個對象的成員變量可能隨着這個對象自身存放在堆上
- 一個Object的大小計算方法:一個引用4byte+空Object本身占據8byte+其它數據類型占據自身大小byte(例如char占用2byte);然而由於系統分配以8byte為單位,所以每個Object占據的大小必須為8的倍數,比如一個空的Object應該占據4+8=12,也就是說需要占據16byte
下文中將要提到的內存分配與回收主要是指對象所占據的堆內存的釋放與回收。
java對象創建及初始化
java對象創建之后,就會在堆內存擁有自己的一塊區域,接着就是對象的初始化過程。對象一般通過構造器來進行初始化,構造器是一種與類名相同的沒有返回值的特殊方法;如果一個類中沒有定義構造函數,則系統會自動生成一個不接受任何參數的默認構造器;但是如果已經定義一個構造器(無論是否有參數),編譯器就不會再自動創建默認構造器了;我們可以對構造函數進行多次重載(即傳遞不同數目或不同順序的參數列表),也可以在一個構造器中調用另一個構造器,但是只能調用一次,並且必須將構造器放在最起始處,否則編譯器會報錯。
那么類成員初始化又是怎么做的呢?順序是怎樣的呢?java中所有變量在使用前都應該得到恰當的初始化,即使是方法的局部變量,如果不進行初始化就會發生編譯錯誤;而如果是類的成員變量,即使你不進行初始化賦值,系統也是會給與其一個初始值的,例如char、int類型的初始值都是0,對象引用不進行初始化則默認為null。
類成員初始化順序總結:先靜態后普通再構造, 先父類后子類,同級看書寫順序
1.先執行父類靜態變量和靜態代碼塊,再執行子類靜態變量和靜態代碼塊 2.先執行父類普通變量和代碼塊,再執行父類構造器(static方法) 3.先執行子類普通變量和代碼塊,再執行子類構造器(static方法) 4.static方法初始化先於普通方法,靜態初始化只有在必要時刻才進行且只初始化一次。 注意:子類的構造方法,不管這個構造方法帶不帶參數,默認的它都會先去尋找父類的不帶參數的構造方法。如果父類沒有不帶參數的構造方法,那么子類必須用supper關鍵子來調用父類帶參數的構造方法,否則編譯不能通過。
java內存回收
垃圾回收器(4種收集器)和finalize()方法
java中垃圾回收器可以幫助程序猿自動回收無用對象占據的內存,但它只負責釋放java中創建的對象所占據的所有內存,通過某種創建對象之外的方式為對象分配的內存空間則無法被垃圾回收器回收;而且垃圾回收本身也有開銷,GC的優先級比較低,所以如果JVM沒有面臨內存耗盡,它是不會去浪費資源進行垃圾回收以恢復內存的。最后我們會發現,只要程序沒有瀕臨存儲空間用完那一刻,對象占用的空間就總也得不到釋放。我們可以通過代碼System.gc()來主動啟動一個垃圾回收器(雖然JVM不會立刻去回收),在釋放new分配內存空間之前,將會通過finalize()釋放用其他方法分配的內存空間。
- Serial收集器:一個單線程的新生代收集器,它進行垃圾收集時,必須暫停其他所有的工作線程,直到它收集結束。簡單高效
- Parallel(並行)收集器:JVM缺省收集器,其最大的優點是使用多個線程來通過掃描並壓縮堆。串行收集器在GC時會停止其他所有工作線程(stop-the-world),CPU利用率是最高的,所以適用於要求高吞吐量(throughput)的應用,但停頓時間(pause time)會比較長,所以對web應用來說就不適合,因為這意味着用戶等待時間會加長。而並行收集器可以理解是多線程串行收集,在串行收集基礎上采用多線程方式進行GC,很好的彌補了串行收集的不足,可以大幅縮短停頓時間,因此對於空間不大的區域(如young generation),采用並行收集器停頓時間很短,回收效率高,適合高頻率執行。
- CMS收集器:基於“標記-清除”算法實現的,它使用多線程的算法去掃描老生代堆(標記)並對發現的待回收對象進行回收(清除),容易產生大量內存碎片使得大對象無法創建然后不得不提前觸發full GC。CPU資源占用過大,標記之后容易產生浮動垃圾只能留到下一次GC處理
- G1收集器:G1收集器是基於“標記-整理”算法實現的收集器,也就是說它不會產生空間碎片。G1是一個針對多處理器大容量內存的服務器端的垃圾收集器,其目標是在實現高吞吐量的同時,盡可能的滿足垃圾收集暫停時間的要求。它可以非常精確地控制停頓,既能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,具備了一些實時Java(RTSJ)的垃圾收集器的特征。垃圾收集器
finalize()方法的工作原理是:一旦垃圾回收器准備好釋放對象占用的存儲空間,將首先調用並且只能調用一次該對象的finalize()方法(通過代碼System.gc()實現),並且在下一次垃圾回收動作發生時,才會真正回收對象占用的內存。所以如果我們重載finalize()方法就能在垃圾回收時刻做一些重要的清理工作或者自救該對象一次(只要在finalize()方法中讓該對象重新和引用鏈上的任何一個對象建立關聯即可)。finalize()方法用於釋放用特殊方式分配的內存空間,這是因為我們可能在java中調用非java代碼來分配內存,比如Android開發中調用NDK。那么,當我們調用C中的malloc()函數分配了存儲空間,我們就只能用free()函數來釋放這些內存,這樣就需要我們在finalize()函數中用本地方法調用它。
對象內存狀態&&引用形式及回收時機
-
java對象內存狀態轉換圖
-
如何判斷java對象需要被回收?GC判斷方法
- 引用計數,引用計數法記錄着每一個對象被其它對象所持有的引用數,被引用一次就加一,引用失效就減一;引用計數器為0則說明該對象不再可用;當一個對象被回收后,被該對象所引用的其它對象的引用計數都應該相應減少,它很難解決對象之間的相互循環引用問題循環引用實例
- 可達性分析算法:從GC Root對象向下搜索其所走過的路徑稱為引用鏈,當一個對象不再被任何的GC root對象引用鏈相連時說明該對象不再可用,GC root對象包括四種:方法區中常量和靜態變量引用的對象,虛擬機棧中變量引用的對象,本地方法棧中引用的對象; 解決循環引用是因為GC Root通常是一組特別管理的指針,這些指針是tracing GC的trace的起點。它們不是對象圖里的對象,對象也不可能引用到這些“外部”的指針。
- 采用引用計數算法的系統只需在每個實例對象創建之初,通過計數器來記錄所有的引用次數即可。而可達性算法,則需要再次GC時,遍歷整個GC根節點來判斷是否回收
- java對象的四種引用
1.強引用 :創建一個對象並把這個對象直接賦給一個變量,eg :Person person = new Person(“sunny”); 不管系統資源有么的緊張,強引用的對象都絕對不會被回收,即使他以后不會再用到。
2.軟引用 :通過SoftReference類實現,eg : SoftReference p = new SoftReference(new Person(“Rain”));內存非常緊張的時候會被回收,其他時候不會被回收,所以在使用之前要判斷是否為null從而判斷他是否已經被回收了。
3.弱引用 :通過WeakReference類實現,eg : WeakReference p = new WeakReference(new Person(“Rain”));不管內存是否足夠,系統垃圾回收時必定會回收
4.虛引用 :不能單獨使用,主要是用於追蹤對象被垃圾回收的狀態,為一個對象設置虛引用關聯的唯一目的是希望能在這個對象被收集器回收時收到一個系統通知。通過PhantomReference類和引用隊列ReferenceQueue類聯合使用實現
常見垃圾回收算法參考圖
- 停止-復制算法
這是一種非后台回收算法,將可用內存按容量划分為大小相等的兩塊,每次只使用其中的一塊,內存浪費嚴重.它先暫停程序的運行,然后將所有存活的對象從當前堆復制到另外一個堆,沒被復制的死對象則全部是垃圾,存活對象被復制到新堆之后全部緊密排列,就可以直接分配新空間了。此方法耗費空間且效率低,適用於存活對象少。 - 標記-清掃算法
同樣是非后台回收算法,該算法從堆棧區和靜態域出發,遍歷每一個引用去尋找所有需要回收的對象,對每個找到需要回收對象都進行標記。標記結束之后,開始清理工作,被標記的對象都會被釋放掉,如果需要連續堆空間,則還需要對剩下的存貨對象進行整理;否則會產生大量內存碎片 -
標記-整理算法
先標記需要回收的對象,但是不會直接清理那些可回收的對象,而是將存活對象向內存區域的一端移動,然后清理掉端以外的內存。適用於存活對象多。 -
分代算法
在新生代中,每次垃圾收集時都會發現有大量對象死去,只有少量存活,因此可選用停止復制算法來完成收集,而老年代中因為對象存活率高、沒有額外空間對它進行分配擔保,就必須使用標記—清除算法或標記—整理算法來進行回收。
JVM性能調優
- JVM分配超大堆(前提是物理機的內存足夠大)來提升服務器的響應速度,但分配超大堆的前提是有把握把應用程序的 Full GC 頻率控制得足夠低,因為一次 Full GC 的時間造成比較長時間的停頓。控制 Full GC 頻率的關鍵是保證應用中絕大多數對象的生存周期不應太長,尤其不能產生批量的、生命周期長的大對象,這樣才能保證老年代的穩定
- 分配超大堆時,如果用到了 NIO 機制分配使用了很多的 Direct Memory,則有可能導致 Direct Memory 的 OutOfMemoryError 異常,這時可以通過-XX:MaxDirectMemorySize 參數調整 Direct Memory 的大小
- 調整線程堆棧,socket緩沖區,JNI占用的內存以及虛擬機、GC消耗的內存
- “-Xms and -Xmx (or: -XX:InitialHeapSize and -XX:MaxHeapSize)”參數:分別指定初始堆和最大堆大小,Xms一般代表着堆內存的最小值,JVM在運行時可以動態調整堆內存大小,如果我們 設置Xms=Xmx就相當於設置了一個固定大小的堆內存;例如:“java -Xms128m -Xmx2g MyApp”啟動一個初始化堆內存為 128M,最大堆內存為 2G,名叫 “MyApp” 的 Java 應用程序;當我們設置Xmx最大堆內存不恰當時就很容易發生內存溢出,這樣我們可以通過設置 - XX:+HeapDumpOnOutOfMemoryError 讓 JVM 在發生內存溢出時自動生成堆內存快照,默認保存在JVM的啟動目錄下名為 java_pid.hprof 的文件里,分析它可以很好地定位到溢出位置
Linux下面查看Jvm性能信息的命令
- jstat: 用於查看Jvm的堆棧信息,能夠查看eden,survivor,old,perm等堆區的的容量,利用率信息,對於查看系統是不是有內存泄漏以及參數設置是否合理有不錯的意義。例如’’’ jstat -gc 12538 5000 —- 即會每5秒一次顯示進程號為12538的java進成的GC情況 ‘’’
- jstack:用來查看Jvm當前的線程dump的,可以看到當前Jvm里面的線程狀況,對於查找blocked線程比較有意義
- jmap:用來查看Jvm當前的heap dump的,可以看出當前Jvm中各種對象的數量,所占空間等等;尤其值得一提的是這個命令可以導出一份binary heap dump的bin文件,這個文件能夠直接用Eclipse Memory Anayliser來分析,並找出潛在的內存泄漏的地方。
- 非jvm命令—netstat:通過這個命令可以看到Linux系統當前在各個端口的鏈接狀態,比如查看數據庫連接數等
內存相關問題
- 內存泄露是指分配出去的內存沒有被回收回來,由於失去了對該內存區域的控制(例如你把它的地址給弄丟了),因而造成了資源的浪費。Java 中一般不會產生內存泄露,因為有垃圾回收器自動回收垃圾,但這也不絕對,Java堆內也可能發生內存泄露(Memory Leak; 當我們 new 了對象,並保存了其引用,但是后面一直沒用它,而垃圾回收器又不會去回收它,這邊會造成內存泄露
- 內存溢出是指程序所需要的內存超出了系統所能分配的內存(包括動態擴展)的上限
- 符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標並不一定已經加載到了內存中。
- 直接引用:直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是與虛擬機實現的內存布局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那說明引用的目標必定已經存在於內存之中了。
- 雙親委派模型:表示類加載器之間的加載順序從頂至下的層次關系,加載器之間的父子關系一般都是通過組合來實現,而不是繼承。可以防止內存中出現多份同樣的字節碼,並確保加載順序
- 雙親委派模型的工作過程是:在loadClass函數中,首先會判斷該類是否被加載過,加載過則進行下一步—-解析,否則進行加載;如果一個類加載器收到了類加載器的請求,先不會自己嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有當父類加載器反饋自己無法完成這個加載請求(它的搜說范圍中沒有找到所需的類時,子加載類才會嘗試自己去加載)
- 靜態分派和動態分派:靜態分派發生在編譯階段,是指依據靜態類型(變量聲明時定義的變量類型)來決定方法的執行版本,例如方法重載中依據參數的定義類型來定位具體應該執行的方法;動態分派發生在運行期,根據變量實例化時的實際類型來決定方法的執行版本,例如方法重寫;目前的 Java 語言(JDK1.6)是一門靜態多分派、動態單分派的語言。
- 動態分派具體實現Java虛擬機是通過在方法區中建立一個虛方法表,通過使用方法表的索引來代替元數據查找以提高性能。虛方法表中存放着各個方法的實際入口地址,如果子類沒有覆蓋父類的方法,那么子類的虛方法表里面的地址入口與父類是一致的;如果重寫父類的方法,那么子類的方法表的地址將會替換為子類實現版本的地址。方法表是在類加載的連接階段(驗證、准備、解析)進行初始化,准備了子類的初始化值后,虛擬機會把該類的虛方法表也進行初始化。
- JDK7和8中內存模型變化:JDK7中把String常量池從永久代移到了堆中,並通過intern方法來保證不在堆中重復創建一個對象;JDK7開始使用G1收集器替代CMS收集器。JDK8使用元空間來替代原來的方法區,並且提供了字符串去重功能,也就是G1收集器可以識別出堆中那些重復出現的字符串並讓他們指向同一個內部char[]數組,而不是在堆中存在多份拷貝