認識JVM--第一篇-對象分配&回收算法
本來標題黨想寫成《深入JVM》,不過不太敢寫,我想一小篇博客我想還不足以說明JVM,在本文中,會就我所知給大家介紹JVM的很多內部知識,概念會相對較粗,因為太細的內容要寫,這裏肯定寫不出來;本文主要偏重理論,沒有什麼實踐,中間除一些官方資料外,還有部分自身的理解,所以請大家不要完全信任本文內容;另外本文會有一小部分糾正以前一篇文章對於intern()使用方法的錯誤,本文會在其中說明使用錯誤的原因,大致文章內容有以下幾個部分:
1、JVM虛擬內存組成及操作係統地址表
2、新生成對象在HeapSize是如何變化的
3、虛擬機如何定義回收算法
4、JVM占用的空間除HeapSize還會占用什麼,OutOfMemory種類!
5、糾正錯誤:intern()的使用上的錯誤
好,現在開始話題吧:
1、JVM虛擬內存組成及操作係統地址表
1.1.虛擬地址大致概念:在OS層麵一般是由邏輯地址映射到線性地址,如果線性地址管理,如果啟動了分頁,那麼線性地址就會轉換到相應的物理地址上,否則就直接認為是物理地址;程序設計中所用到的地址單元就是邏輯單元,如在C語言中的&表示指定的地址就是邏輯地址;而物理地址也並非我們所認為的RAM,還應該包括網卡、顯存、SWAP等相關內容,也就是由OS所管理所有可以通過頂層邏輯單元映射到的目標地點,不過絕大部分情況下隻需要考慮RAM即可,尤其是在服務器上;JVM的虛擬內存地址和操作係統的虛擬內存地址不是一個概念,操作係統的虛擬內存地址相當於在磁盤上劃分的一個SWAP交換區,用於內存,內存與之做page out和page in的操作,這種用於物理內存本身不夠,而地址空間夠用的情況,一旦程序出現page out這些情況的時候,程序將會變得非常緩慢,而JVM的虛擬內存是在有效的空間內分配一個連續的線性地址空間,因為JVM想要自己管理內存,分配的堆內存都是在自己的heapSize內部,因為它要實現一些脫離於存儲器本身對非連續堆處理的管理而導致的複雜性,也就是JVM去初始化的時候就會加載一塊很大的內存單元,然後內部的操作都是內部自己完成的。
1.2.內存分配:一般C語言分配內存是初始化將相應的基本內容和代碼段進行加載,但是不會加載運行時候的堆棧內存分配,也就是在運行到某個具體的函數時通過malloc、callloc、realloc等方方申請的區域,這些區域必須從操作係統中重新來分配,使用完成後必須進行free,C++中必須使用delete方法來釋放,大家發現沒有,OS的堆在內存不斷申請和釋放的過程中,必然會產生許多的內存碎片,從而導致你在申請一塊大內存的時候,需要進行邏輯連接,導致在申請的速度減小,當然LINUX采用了將內存塊劃分為多個不同大小的板塊,來較好的處理這個問題,不過片段還是存在的,不過這種思想的確是很好的,而JVM是如何完成碎片的處理的呢,後麵章節會說到;JVM在初始化的時候就會向OS申請一塊大內存,JVM要求這塊內存在地址空間上是連續的(物理上未必連續),讓所有的程序在這個內部區分配,由自己來管理,所以它內部相當於做了一個小的OS對內存的管理,所以JVM是想讓java程序員不用關心在哪一個平台上寫代碼,但是你一定要關心java怎麼管理內存的;
線性地址隨著實際物理內存的增加,將會導致頁表非常大,甚至於導致多層頁表,如內存達到96G這一類,那麼這樣管理起來將會非常麻煩(正常情況下一個頁隻有4K,可以自己算一下需要多少個管理地址來指向這個4K,這個管理地址太大的時候,又需要其他的管理地址來管理這個地址,就會導致多層地址,可能到最後,一個大內存有40%都是用於管理內存的,真正使用的可想而知),所以在LINUX高版本中對於內存尋址方麵做了改進,就是支持大頁麵來支持(其實是通過一個套件完成的,並非OS本身),如一個頁的大小為1M這樣的,但是有一些風險在裏麵,它要求大頁麵內存要麼放得下你的內存,但是你不能將你的進程一部分放在大頁麵內存中,一部分放在OS管理的小頁麵內存中,也就是說要麼這塊放得下,要麼就放在其他地方,可能會導致兩邊正好都差那麼一點點的問題,在OS這邊可以使用SWAP,但是係統會很慢,而且SWAP很多的情況下肯定會宕機掉。
1.3.內存分配狀態:一個大的進程如果初始化需要分配一塊大的內存空間,內存空間一般會經曆兩個狀態的轉換過程,首先內存必須是free狀態才可以被分配,如果的確是該狀態並且空間是夠用的,那麼它首先會占用那麼大一個坑,在java的heapSize中,就是-Xmx參數指定的,也就是JVM虛擬機最大的內存空間(注意這裏-Xmx並沒有包含PermSize的空間),這個坑是不允許其它進程所占用的,內存的狀態為:reserved的狀態,當需要使用的空間時,內存將會被commited狀態,在JVM初始化時也就是-Xms狀態的內存空間,處於這個狀態的內存如果發現不夠使用(物理內存),此時就會發生swap區域,程序將會變得非常緩慢,但是不會造成宕機,而很多時候在這個時候定位不出原因,所以我們為了讓物理內存不夠用的現象暴露出來可以被發現,至於可以定位不是程序代碼的問題,我們就直接將swap內存禁用掉;有個問題就是既然被reserved的內存就不能被其他進程所占用,為什麼要在這兩個狀態之間來回倒騰呢?這不是多一個開銷嗎?JVM在來回倒騰的過程中會導致每個區域的容量發生相應的變化,必然導致的是FullGC的過程,那麼JVM一般在服務器端如何設置呢?文章後麵逐步細化說明。
1.4.JVM內存組織:關於JVM內存組織方麵,前麵在講述Java垃圾回收的時候已經提及到了,但是講得不太細,有些部分可能算是有錯誤的,所以這裏根據上述操作係統知識以及官方部分資料繼續深入,不敢說完全正確,不過至少比以前要更加深入得多,首先來看下ORACLE官方給出來的一個JVM內存單元的組織圖形:
其實我看過很多次這個圖看得很暈,因為以前不了內存分配中commited與reserved的區別,以至於我當時認為這副圖是說java的HeapSize是由N多個部分組成的,並且還包含HeapSize的,其實在經過很多資料查閱後,尤其是看到一些監控工具後,才知道看官方資料也有誤區,嗬嗬,通過簡化,我自己畫的這副圖希望能夠幫助大家理解JVM的大致的內存劃分(這裏僅僅提及JVM自己的內存,也就是HeapSize和PermSize的部分,其餘的文章後麵說明),這裏僅僅將上麵的圖形立起來畫了,當時看起來要方便理解得很多(個人感覺):
也就是說,你首先需要將JVM的兩個大板塊分開,一個是HeapSize,也就是上圖左側的部分,右邊部分為PermSize的尺寸,HeapSize也劃分為大區域為Young和Old區域,Young區域內部劃分為三個部分,一個是Eden和兩個同樣尺寸大小的survivor區域,注意到的人會發現為什麼每個區域內部還有一個virtual區域,這就是我們上麵說的沒有經過commited當時已經占用了地址列表,它不能被其他進程所占用,當時操作係統一般的提示會認為這是塊剩餘空間,但是實際上是隻能被自己使用的,這部分上麵已經提及,至於為什麼我們後麵來解釋,這裏再提出一些問題,就是為什麼JVM要提出這麼多區域劃分來管理呢?如果一個區域可以管理為什麼還要搞得那麼麻煩呢?這麼多區域有什麼用處,我們在第二章對象的分配中將詳細說明這部分內容。
2、新生成對象在HeapSize是如何變化的
2.1.java新創建對象的方法有哪些:首先學習過java的人可能沒有人不知道new 這個關鍵字,也就是新創建一個對象的關鍵字,當發生new操作時,jvm為你做了什麼?我們先把這個問題放下,對於jvm初始化加載專門處理,這裏先說除了new之外還有什麼方式,就是通過java.lang.Class.forName進行動態狀態後,獲取一個新的實例,當然方法有重載,也通過通過ClassLoader進行動態狀態,什麼是動態裝載?為什麼有了new還要有動態裝載?而jvm初始化做了什麼?動態裝載和new的區別是什麼?這也是我們下麵要討論的問題,也是PermSize中內容的一大塊部分。
2.2.jvm初始化需要做什麼?Jvm在向OS請求了一塊地址列表後,然後就需要初始化了,初始化要做什麼呢?jvm啟動相當於一個進程,當然它可以再啟動子進程,這裏我我們隻考慮單個進程,進程啟動必然需要初始化一些內容,C語言或者C++它會將相應的全局變量以及代碼段等內容在內存中進行編譯為相應的指令集;而jvm做了什麼呢?jvm它也需要做一些操作;首先每一個進程都必須最少一個引導進程,也就是我們說的main,通過引導進程所關聯,以及關聯的關聯(也就是import),jvm會將這些關聯關係的內容形成一個大的jvm網狀結構用於關係於class之間並保證每一個class有一份自己的私有池,他們放在哪裏,他們就是放在PermSize,也就是很多中文翻譯中的永久代,每一個Class都有自己獨立的私有池去管理自身的結構,對一個java程序源文件,編寫的是對於程序的描述信息,生成class也就是描述信息的byte格式(在這個過程中會自動完成一些簡單邏輯合並工作),byte格式是字節碼格式,也就是按照每8個bit位組成的計算機基本格式,隻要字符集統一,則為每一個操作係統所認知的格式,JVM需要做的是將這些統一認知的格式信息翻譯為對應操作係統的指令或硬件指令,所以JVM真正的意義就是為每一個操作係統編寫了一個統一的JRE,即:java運行時環境,而編譯環境是所有係統都可以使用的;初始化將class的定義加載到內存中會進行相應的轉換和壓縮,總之會形成原有對類型描述和執行順序,而不會出現混亂,但並不是對應的操作係統指令(對應的操作係統指令是運行時知道的),如描述類型、作用域、訪問權限等等內容,這部分空間大小決定於class的多少,也就是你的工程的大小,PermSize還包含了其他的內容,並且隻是在一般情況下不會發生GC,但是有些時候還是會發生GC的,在後麵繼續說明;這個加載完成後,他們在池中自然有自己的內存首地址,要尋找他必然要有對應列表,列表的基礎肯定是屬於符號向量了,也就是基於名稱的一個符號向量,那麼當發生new時,它會在符號向量中尋找對應的class,找到後將符號地址轉換為對應的class地址,並且這個內容隻會被轉載一次,以後可以直接被利用,從中找到了class的定義,在堆中分配內存時將其定義部分的某些組織單元放置與對象的頭部,這些代碼段對於對象來說是彼此獨立,就像你在方法體前麵增加synchronize關鍵字,對於非靜態方法來說,不同的對象這個關鍵字是相互不會影響的,也就是說,如果多個線程調用的對象不是同一個,僅僅在方法(非靜態方法)體上麵增加synchronized這對於多線程同步是無效的(更多關於多線程的知識,如關鎖方麵的Lock、Atomic等方麵的知識不是本文的內容,這裏不再展開討論);注意,這裏還沒有談到申請對象以及動態裝載,動態裝載的class一般是不會JVM初始化的時候轉入Perm的,而是運行時動態裝載進去的,就像JDBC驅動一樣,大家幾乎都用動態裝載來實現動態加載不同數據庫連接的目的;也就是我們上一節提出的問題,動態裝載做什麼?它負責的是運行時裝載一些類的定義,而不是初始化,當然,當你通過全名去加載的時候,他們會從符號向量中尋找這個類是否已經加載,如果已經加載則直接使用,否則從相應的包中獲取這個class定義,然後裝載起來,裝載的單位也是以class為單位,並不是以jar包為單位,這裏請大家如果不要濫用動態加載,一個是造成Perm的不穩定,另一個是它的效率肯定沒有new高,因為它需要先去通過符號向量尋找是否存在,不存在再加載,然後再通過newInstance實例化一個或多個實例,當然在某些特殊的時候,利用它可以為你的程序帶來極高的靈活性。
2.2.內存申請時的指針與實例:內存申請時上一節已經說到地址空間的和符號引用得到對應數據結構的方法,這裏不再提及,這裏就將對象作為整體,在堆中;在JVM的初衷中,它希望新申請的內存是連續的,雖然堆的定義是讓內存是隨機分配的,但是對於整個JVM來說,它希望分配的內存是較為連續的,也就是按照較為條帶化的方式進行分配,好處有好幾個,一個是這樣非常的簡單,經過精簡後的情況目前一個new翻譯為機器碼隻需要10條左右的指令碼,近乎與C語言,所以在高版本的jdk中,new的開銷不再是java虛擬機慢的一個原因,大家也沒有必要去盡量減少new,但是也不要濫用,業績雖亂定義不必要的對象;其次,另一個好處,當內存較為連續後,內存在分配上就沒有類似的大量碎片的問題,造成運行一段時間後,大量碎片,當需要申請一個大內存的時候,需要尋找非常多的地方才能將其邏輯上組成,而導致分配空間上不必要的浪費;而一個簡單內存分配String a = new String("abc");,這樣一條代碼,會做什麼動作呢?a相當於是對象的一個指針一樣的東西,這個空間的大小為一個long的長度,也就是可以支持到可以想象的任何內存大小,它並不是存放在heapSize中的,而是放在stack中的,由OS來調度管理,也就是當a的作用區域完成,這個指針將會斷開,java中的String不再是C或者C++中的一個指針指向的一個字符數組,而是一個被包裝後的對象,也就是java為什麼說自己都是對象,因為它把原生態的內容進行了包裝,讓程序編寫更加簡單;這裏順便提及一下:在較早期的jdk中,jvm並不是由一個指針直接指向分配堆中的首地址,而是先有一個handle空間,這個空間存放了開始說的一些對象的定義和結構信息,也就是找到該位置,然後由該位置轉換到對應的對象上,但是那個時候的對象頭部信息就沒有現在的那麼全,也就是以前是將一部分handle內容放置在獨立的空間上,現在的jdk已經沒有那樣的了。
2.3.內存分配後放在哪裏,如何移動?
終於回到上麵的話題,內存分配後,在堆中的什麼位置?就是我們上麵說的heapSize中的Young區域的Eden區域中,也就是new的對象絕大部分會放在這裏(排除一種非常大的對象的特殊情況),在java設計的看來有一個特別有意思的地方,就是它在新生成的對象中它認為你絕大部分對象都是應該需要被銷毀掉的,就像在做java WEB應用上一樣,一個列表請求過來,可能請求的內容有2K的內容,請求完成後,這個內容一般說來自然就不需要了,也就是在他原始的考慮下它沒有考慮你自己在應用級別去做page cache的操作;好,那麼當內存不夠的時候,這裏指被commited的空間不夠的情況下,此時java就會做一個動作,就是會對Young空間進行回收,由於新生成的對象,java認為這塊空間不會很大,而且絕大部分應該是被幹掉的內容,所以很多時候java會采用單線程的複製算法(當然你也可以設置為多線程),關於算法的核心在第三章中會說到,這裏總之先理解找到了活著的對象,將其拷貝到其中一個survivor區域中,當下一次做操作時,就會將Eden中活著的以及前一個surivor活著的一起拷貝到另一個survivor中,這就是為什麼要設置兩個survivor區域,而拷貝後,Eden區域為空、另一個survivor也為空,可以完全直接整體清除掉,所以非常快速,而拷貝的目標也會被連續化,新生成的對象又從Eden的初始位置開始分配空間。
當對象每次(活著)被拷貝到一個survivor時,Java虛擬機就會記錄下來對象被移動的次數,當次數達到一定的程度,也就是官方文檔所說的足夠老的情況,這塊內存就認為它不太容易被注銷掉,此時就會被移動到第二個區域Tenured區域,這個次數也可以由自己來控製。
另外在一般默認的情況下當回收後的內存仍然占用實際目前commited內存的70%以上,那麼此時虛擬機將會開始擴展這些內存,而當回收後的內存小於40%後,虛擬機將會降低這部分內存,但是其他線程仍然不能使用(當然這個參數也是可配置的,在文章最後有說明),這樣收縮和擴展必然導致一些問題,但是java的初衷是想讓你再沒有使用這塊地址表的時候,回收內存的大小會小一些,因為young區域的一般是使用單線程的回收方式,這個時間段是會被暫停的,所以它認為內存使用較少的時候回收就內存的速度應該加快;但是,和實際相反的是,我們正好需要的是內存使用較大的時候,才希望加快回收的速度,內存使用小的時候,回收都是無所謂的;所以我們在很多時候建議將-Xms和-Xmx設置成一樣的大小,不用這麼來回倒騰。
在說明下,以下三種情況對象會被晉升到old區域:
1、在eden和survivor中可以來回被minor gc多次,這個次數超過了-XX:MaxTenuringThreshold
2、在發生minor gc時,發現to survivor無法放下這些對象,就會進入old。
3、在新申請對象,大於eden區域的一半大小時直接進入old,也可以專門設置參數-XX:PretenureSizeThreshold這個參數指定當超過這個值就直接進入old。
當上麵的對象被移動到了Tenured區域,這個區域一般非常大,占用了HeapSize的絕大部分空間,此時若它發生一次內存回收,就不能像剛才那樣來回拷貝了,那樣代價太大,而且這個區域可以說是經得起考驗的對象才會被移動過來,在概率上是不容易被銷毀掉的對象才會被移動過來;那麼,我們很此時想到的就是反過來計算,也就是找到需要銷毀的對象,將其銷毀,關於算法也是下麵第三章要說的內容,總之對象會在這裏存放著。
為什麼java不論在Young中的區域會來回倒騰,而在Tenured區域也會不斷去做壓縮,就是我們前麵說的,它希望內存相對較為連續而做的;java在Yong的區域,它認為可以剩下的內容不會很多,所以拷貝的代價並不大,所以它認為來回拷貝是一種合適的方法,而Tenured區域它采用了清除後,一定次數後進行壓縮的方式,當然這個次數你可以自己去設置,在文章的最後是有參數的;而它沒有采用類似操作係統一樣的按照板塊大小等一係列算法來完成,這也是我比較納悶的事情,不過總體說來這種算法還是可行的;希望在劃分區域一些策略上能有更大的靈活性,這樣可以在更多的應用中發揮得更加靈活,這樣就更好了;比較困惑的就是這樣的架構自己如果做頻繁度不高不低的page
cache,性能不好估量,也許比不做cache更低,這個要根據具體情況而定了。
2.3.Perm一般還會存放什麼內容?Perm除了存放上麵的Class定義外,還一般會存放的內容有靜態代碼段、final static類型的類變量、String常量以及String被intern後的內容,也是最後一章中所要提及以前我自己寫錯的內容;如何應對好常量池,以及常量池是否會被GC,也是我們所需要說明的內容;關於Perm永久代中存放的內容,應當如何配置以至於它可以去回收,在文章的最後有相應的說明,請自行查閱;不過對於Perm的大小,一般還是不建議去做GC的,也就是合理的去使用Perm,在程序運行中占用Perm最多的就是String常量,尤其是如果大量使用intern的時候,就會造成大量Perm膨脹,也是最後一部分需要說明的內容,不過intern也並非一無是處,因為你可以這樣說:如果它沒有用處的話,java沒有必要再把String的常量放在單獨的一個地方,它有很多好處,隻要在適當的時候利用好常量池這個區域在必要的時候可以提高性能,具體在最後一章有所講解。
3、虛擬機如何定義回收算法
3.1.首先虛擬的回收算法會分成兩個部分,一個部分是對象的查找算法,一個是真正如何回收的方法。一般對於查找有以下兩種:
a)引用計數:本來在本文中我不想提及引用計數,因為這是最原始也是最垃圾的算法,也是較低版本jdk慢得出奇的原因,但是為了說明後麵的問題不得不簡單說明一下,引用計數就是通過java虛擬機專門為每個對象記錄它被指針指向的個數,當發生指針指向它或者被賦值,計數器將會被加1,而但指向它的指針=null或者脫離了作用區域,jvm就會將相應的計數器減少1,這樣簡單,但是慢死了,不僅僅操作上出奇的慢,因為要做一個簡單的賦值操作要到多個地方去找一大堆東西;還有一個就會引起很難檢測到的內存泄露,那就是當兩個或者多個對象存在循環交叉引用的時候,此時他們的引用計數將永遠不會等於0(如使用雙向鏈表或使用複雜的集合類後,相互之間的引用),也就是垃圾收集器將永遠不會認為這是垃圾(當然要用複雜的算法可以解決,但是這個算法的確很複雜,可能垃圾回收會更加慢),最後就是這個垃圾回收方式必然導致內存的遍曆操作過程。引用計數的示意圖如下圖所示:
b)引用樹遍曆:其實是一個圖,隻是有根而已,它沿著對象的根句柄向下查找到活著的節點,並標記下來,其餘沒有被標記的節點就是死掉的節點,這些對象就是可以被回收的,或者說活著的節點就是可以被拷貝走的,具體要看所在heapSize中的區域以及算法,它的大致示意圖如下圖所示(對象:B、G、D、J、K、L、F都是垃圾對象,雖然他們也有相互指向,但是不是被根節點能遍曆到的,注意這裏是指針是單向的):
3.2.內存回收:上麵的方法我們可以找到內存可以被使用的,或者說那些內存是可以回收,更多的時候我們肯定願意做更少的事情達到同樣的目的,我們會根據一般的情況設置不同的算法來讓係統的性能達到較好的程度,首先來了解下內存回收的算法或者它的經曆有哪些?
a):標記清除算法,這算是比較原始的算法,也就是通過上麵的查找標記後,我們對沒有標記的對象進行空間釋放的過程,這個算法雖然很原始,但是是後來所有算法的基礎,好處的簡單,缺陷是造成和其他語言一樣的內存碎片,要通過更加複雜的算法來解決這些碎片;另一缺陷就是它這個過程如果用於較大的內存將會導致長時間的對外服務停止(當然這個停止也不是傳說中那麼長,隻是相對計算機來說比較長,至於多長是還和jdk的版本以及廠商有關係,BEA曾經在1G的JVM下麵測試,有300M空間屬於可用空間,據測試結果為30ms的停止服務時間,我想這個時間應該可以接受,不過它有自己的測試場景,不能完全說明問題,而一般情況下在單線程引用下,常規的回收起碼會比這個時間要長好幾倍甚至於10倍以上)。
b):標記清楚壓縮,這個算法是也是較為原始的,它的出現是為了解決上麵一種算法中不能壓縮空間的問題,但是並非取代,因為它導致的另一個問題就是更長時間的服務停止,因為壓縮就是空間拷貝到一個較為連續的地方,而並非對數據本身進行壓縮,所以很多時候他們是配合使用的,如多少次清除後進行一次壓縮。
c)複製回收:也就是在jvm發展的過程中出現的算法,現在基本都隻能看到一些思想影子在裏麵,但是沒有這個方式,也就是將其劃分為2個相同的大小,然後將活著的節點來回拷貝,這樣造成的內存浪費的非常大的,不僅僅是一半的浪費問題,而且每次拷貝的開銷也是非常大的,因為都是涉及到整個jvm活著節點的拷貝過程。
d)增量回收:這算是現代垃圾回收的一個前身,它做的事情就是為了解決複製回收算法中的一個問題,就是每次複製造成的空間開銷非常大的問題,此時它將內存中切分為逐個板塊,這些板塊,每個內部使用了複製算法,也就是並沒有解決空間浪費的問題,回收的過程中沒有進行細化,雖然回收速度較快速,而且隻會造成局部的停止服務,但是對於不同板塊大小、不同生命周期的對象還是沒有劃分開。
e)分代收集器:分代收集器是增量收集的另一個化身,或者說延續吧,它將板塊按照生命周期劃分為上麵所說的板塊,每一個板塊可以采用不同的算法進行回收,這也是和增量回收最大的區別,此時可以讓jvm的回收達到更好的效果,不過由於jvm按照生命周期劃分後都是指定板塊的,所以根據內存大小劃分自定義板塊是不可能的,至少現在好像還沒有,所以在回收過程中如果內存大了回收起來一樣很吃力,尤其是對Old區域的回收,所以並發回收不得不出現了。
f)並發回收:所謂並發回收是指外部在訪問的同時,java回收器依然在做著回收工作,原早我認為並發回收是不可能的,因為你需要知道內存是需要回收的,就不能讓內存繼續的被申請和釋放,但是SUN的人還是比較天才的,還是有辦法盡量讓他並發去做的;並發回收器其實也會暫停,但是時間非常短,它並不會在從開始回收尋找、標記、清楚、壓縮或拷貝等方式過程完全暫停服務,它發現有幾個時間比較長,一個就是標記,因為這個回收一般麵對的是老年代,這個區域一般很大,而一般來說絕大部分對象應該是活著的,所以標記時間很長,還有一個時間是壓縮,但是壓縮並不一定非要每一次做完GC都去壓縮的,而拷貝呢一般不會用在老年代,所以暫時不考慮;所以他們想出來的辦法就是:第一次短暫停機是將所有對象的根指針找到,這個非常容易找到,而且非常快速,找到後,此時GC開始從這些根節點標記活著的節點(這裏可以采用並行),然後待標記完成後,此時可能有新的 內存申請以及被拋棄(java本身沒有內存釋放這一概念),此時JVM會記錄下這個過程中的增量信息,而對於老年代來說,必須要經過多次在survivor倒騰後才會進入老年代,所以它在這段時間增量一般來說會非常少,而且它被釋放的概率前麵也說並不大(JVM如果不是完全做Cache,自己做pageCache而且發生概率不大不小的pageout和pagein是不適合的);JVM根據這些增量信息快速標記出內部的節點,也是非常快速的,就可以開始回收了,由於需要殺掉的節點並不多,所以這個過程也非常快,壓縮在一定時間後會專門做一次操作,有關暫停時間在Hotspot版本,也就是SUN的jdk中都是可以配置的,當在指定時間範圍內無法回收時,JVM將會對相應尺寸進行調整,如果你不想讓它調整,在設置各個區域的大小時,就使用定量,而不要使用比例來控製;當采用並發回收算法的時候,一般對於老年代區域,不會等待內存小於10%左右的時候才會發起回收,因為並發回收是允許在回收的時候被分配,那樣就有可能來不及了,所以並發回收的時候,JVM可能會在68%左右的時候就開始啟動對老年代GC了。
d)並行回收:並行回收指利用多個CPU對JVM進行並行垃圾回收的過程,並行度都是可以設置的,可以分別對年輕代和老年代配置是否使用並行回收。
好了,回收算法就說到這裏,那麼如何利用好回收算法,在看了上麵的介紹後,是否對JVM有了一個大致的了解,具體細節,可以慢慢實踐,在文章最後給出一些常用的java虛擬機內存設置參數的說明,不過並不權威,需要根據實際情況而定才可以。
下麵說下java虛擬機除了消耗基本內存外還會消耗什麼內存?
4、JVM占用的空間除HeapSize還會占用什麼?
一般來說,對於很多學了好幾年,甚至於很多年java人來說,一旦看到OutOfMemeory(簡稱OOM),就認為HeapSize不夠,然後瘋狂的增加-Xmx的值,但是HeapSize隻是其中一個部分,當你去做一個實驗,也就是java啟動時直接在程序中瘋狂的new 一些線程出來,直到內存溢出,當-Xms -Xmx設置得越大的時候,得到的線程個數會越少,為什麼呢?因為OOM並不是HeapSize不夠而導致的,而由很多種情況。
首先看下操作係統如何劃分內存給應用係統,其實在Win 32、Linux 32的係統中,地址總線為32位的理論上應該可以支持4G內存空間,但是當你在Win 32上設置初始化內存如果達到2G,就會報錯,說這個塊空間沒法做,首先默認的Win32係統,會按照50%比例給予給Kernel使用,而另一部分給應用內存,也就是說操作係統內核部分不論是否使用,這一半是不會給你的,而還有2G呢,它在係統擴展的部分,也就是並非Kernel的部分,有很多靜態區域和字典表的內容,所以要劃分一個連續的2G內存給JVM在Win 32上是不可能的,Win 32提出了一種Win 32 3G模式,貌似可以劃分3G空間,其實它隻是將內核部分縮小也就是管理部分縮小,也就是將一部分劃分到外部來使用,而且Win 32習慣在內存2G的位置做一些手腳,讓你分配連續2G沒有可能性,一般來說在Win 32平台上,在物理內存足夠的情況下給JVM劃分的空間一般是1.4~1.5G左右,具體數據沒有測試過;而Linux 32類似於Win 32 3G模式,但是它還是一般情況下分布不淩亂的情況下,一般可以給JVM劃分到2G的大小。Linux 32 Hugemem是一個擴展版本,可以劃分更大的空間,但是需要付出一些其他的代價,理論上可以支持到4G給應用,也就是Kenel是獨立的;Solaris x86-32和AIX 32等係統,也類似於Linux 32平台一樣。
為什麼還要預留一些空間出來呢?這些空間給誰?
當你申請一個線程的時候,它的除了線程內部對象的開銷外,線程本身的開銷,是需要OS來調度完成,一般來說,會在OS的線程與虛擬機內部有都有一個一一對應的,但是會根據操作係統不同有所變化,有些可能隻有一個,總之heapSize外的那部分空間是跑不掉的,它放在哪裏呢?就是放在Stack中的,所以上文中的-Xss就是設置這個的,在jdk 1.5以後,每個線程的大小被默認設置為1M的stack開銷,我們習慣將這個開銷降低。
好了知道了指針、線程是在heapSize外部的,還有什麼呢?
當你自己使用native方法,也就是JNI的時候,調用本地其他語言,如C、C++在程序中使用了malloc等類似方法開辟的內存,都不是在heapSize中的,而是在本地OS所掌控的,另外這部分空間如果沒有相應的釋放命令,就需要在對應finalize方法內部調用其他的native方法來完成對相應對象的釋放,否則這部分將成為OS級別的內存泄露,直到JVM進程重啟或者宕機為止(操作係統會記錄下進程和相應線程和堆內存的關聯關係,但是進程再沒有釋放前,OS也是不會回收這部分內存的)。
另外在使用JavaNIO以及JDBC、流等係列操作時,當形成與終端交互時,會在另一個位置形成一個內存區域,這些內存區域都不在HeapSize中。
所以常見的OOM現象有以下幾種:
1、heapSize溢出,這個需要設置Java虛擬機的內存情況
2、PermSize溢出,需要設置Perm相關參數以及檢查內存中的常量情況。
3、OS地址空間不夠,也就是沒有那麼多內存分配,這個一般是啟動時報錯。
4、Swap空間頻繁交互,進程直接被crash掉,在不同操作係統中會體現不同的情況。
5、native Thread溢出,注意線程Stack的大小,以及本身操作係統的限製。
6、DirectByteBuffer溢出,這一類一般是在做一些NIO操作的時候,或在某種情況下使用ByteBuffer,在分配內存時使用了allocateDirect以及使用一些框架間接調用了類似方法,導致直接內存的分配(如mina中使用IoByte去調用,當參數設置為true的時候就分配為直接內存,所謂直接內存就是又OS定義的內存,而不需要從程序間接拷貝一次再輸出的過程,提高性能,但是如果沒有手動回收是回收不掉的),導致的Buffer問題,如輸出大量的內容,輸入大量的內容,此時需要盡量去嚐試限製它的大小。
使用非常多的工具區檢測Java的內存如:jstat(隻能看HeapSize和PermSize)、jmap(很細的東西)、jps(java的ps -ef嗬嗬)、jdb(這個不是監控工具哈,這個是debug工具)、jprofile(圖形支持,但是可以遠程連接)等等;jconsole(可以看到heapsize、permsize+native mem size(這這裏叫做:non-heapsize)等等的使用的趨勢圖)、visualvm(極為推薦的東西,圖形化查看,你可以查看到內存單元分配、交換、回收、移動等等整個過程,非常清晰展現jvm的全局資源)、另外pmap可以展現非常清晰的資料,可以精確到某一個java進程內部的每一個細節,而且可以看到heapsize隻是其中很小一部分(在solaris操作係統上看得最齊全,LINUX下有些進程可能看不太懂);也可以在/proc/進程號/maps中查看(這裏可以看到內存地址單元的起始地址,包含了reserved的地址範圍和commited的地址範圍),全局資源使用操作係統top命令和free命令看;IBM有一個GCMV免費下載工具也很好;Win32有一個WMMap工具都是很好的工具
使用相應的工具觀察相應的內容,當觀察到內存的使用從無到有,上升,然後處於一個平穩趨勢,那麼這個JVM應該是較為穩定的;如果發現它經過一段平滑期後,又出現飆升,這個必然是有問題的,至於什麼問題,根據前麵的學下和實際情況我們可以去分析;當它開始後,平滑過程,出現緩慢上升的過程,但是始終會上升到極點,那麼一個是需要知道物理內存時候可用,另一個就是少量的內存泄露(JVM現代也有內存泄露,隻是它的內存泄露並非C、C++中的內存泄露)。
5、糾正錯誤:intern()的使用上的錯誤
最後一章節,我自己糾正一下我自己的錯誤,以前的文章中,也就是關於intern的使用,最近對他做了一些深入研究,因為以前也是和很多同學一樣,聽到別人推薦什麼就瘋狂的使用,知道點原理也是點大概,沒有深入研究內部的內容。
我曾經在文章中說到任何係統最多使用的數據類型必然是String,不管做什麼,所以在String的處理上很有研究,推薦使用java的朋友在大量使用對比的時候不要用equals,而推薦使用intern(),但是我最近發現我錯了,我這裏給大家道歉,因為可能會誤導很多朋友;下麵說明下這個東西為什麼?
首先我開始自己懷疑自己的時候是想說,如果intern可以做到高效,那麼equals是不是在String中就沒有存在的必要了呢,當時對於我理解僅僅為常量池的一個地址對比,好比是兩個數字的compare,僅僅需要CPU的單個指令即可完成;於是我開始做了兩個實驗,一個是最原始,最初級的方法采用單線程循環1000000次調用equals與intern等值對比,並且采用了不同長度的字符串去做比較,發現equals竟然比intern要快,而且隨著字符串長度的增加,equals會明顯快與intern,然後使用多線程測試也是得到一樣的效果,我首先很不敢相信自己堅持的理論被徹底和諧了,後來冷靜下來必須需要麵對,通過很多權威資料的閱讀,我發現我對JVM常量池的理解還隻是一點點皮毛而已,所以我做了更加深入的研究。
原來intern方法被調用時是在Perm中的String私有化常量池中尋找相應的內容,而尋找雖然可以通過hash定位到某些較小的鏈表中,但是還是需要在鏈表中逐個對比,對比的方法仍然是equals,也就是拋開hash的開銷,intern最少要與裏麵的0到多個對象進行equals操作,而且如果不存在,還要在常量池開辟一塊空間來記錄,如果存在則返回地址,也就是常量池保證每個String常量是唯一的,這個開銷當然大了,而且如果使用在業務代碼中將會導致Perm區域的不斷增加;
於是,我又反過來想了:既然equals比他效率高,為啥還要用intern呢?而且equals的那個算法對於長字符串逐個字符對比的過程我實在是難以入目;而且也實在是覺得不甘心自己的理論就這麼容易被和諧掉,因為自己已經在不少程序中這樣用過,這樣我豈不是犯下大錯了,因為自己參與過的項目的確太多了,而且有類似的代碼我寫入了框架中,最終發現我可能錯了一半,也就是曆史上的記錄可能我有一半類似的代碼是錯誤的;為什麼呢?intern還是有用的,我先做了一個測試,那就是,用一個已經intern好的對象,讓他與一個常量做等值,循環次數和上麵一樣,結果我預料的結果發生了,那就是比equals快出了N多倍數,隨著長度的增加,會體現出更加明顯的優勢,因為intern對比的始終是地址,和長度無關,於是我想到了如何使用它,就是在程序中返回通過字符串類似於數字一樣的類型判定時,如:做一個sqlparser的時候,經常根據數據類型做不同的動作,這樣如果用equals會在每次循環時付出很多開銷,尤其是很多數據庫的類型非常多,最壞的是從上到下每個字符串匹配一次,當然長度不等開銷很小,長度相等開銷就大了;intern我就將這些schema信息預先intern掉,也就是他們已經指向了常量池,當再真正匹配時,就不需要用intern了,而是直接匹配,也就是將這個開銷放在初始化的過程中,運行時我們不去增加它的開銷。
所以,個人是犯下一個錯誤,並且以前還很張揚的到處宣傳,嗬嗬,現在覺得有點傻,希望在看到某些推薦用什麼新東西的時候,千萬不要在沒有研究明白他就去用它,甚至於濫用它,至少要經過一些簡單的測試,不過對於現代很多複雜的東西,一些簡單的測試已經不足以說明問題,就像Lock與Synchronize的開銷一樣,如果采用簡單的循環的話,你會發現新版本的Lock的開銷將會比Synchronized的開銷更加大,它適合的是並發,讀寫的並發,所以真正要弄清楚還是研究內在。
最後說下,我個人對JVM的期望,JVM做到了很多個板塊之間使用不同的算法,而JVM不希望程序員去關心內存,但是有些特殊的應用需要JVM提供多的支持,當然有些公司對JVM內核進行了改造來適合特殊的應用,但是我們更加希望標準的JVM能夠提供更加靈活的內存管理機製,而不僅局限於配置,因為配置適中是死的,在很多時候會麵臨擴展性的限製;如很多時候我們認為可以判定很多的對象本身就是不會被回收或者根本不容易被回收的,就不用到Young的空間和其他的業務套在一起倒騰了;對於經常做page cache的係統,而page cache的命中率不是特別高(95%以上就很高),也不是很低(如80%以下),這個時候,置換到快不慢的,而會導致在老年代的回收的頻繁起來,就我個人希望這些空間都能獨立出來,甚至於可以由程序去控製和指定,當然JVM可以自身去默認;尤其是按照一些特殊的對象等級類型或者說對象的大小,這些細節都可以采用一些相應的默認GC手段來完成,也可以人工的指定,當然也在默認情況下可以按照原有的模式進行架構,這樣JVM的內存調節的靈活將會更加寬鬆,使得它能在各類場合下隻要使用相對應的手段配置和程序調整都是可以打到目的的。
本文包含大量個人見解,如有不是之處,請大家多多指教!本文到此完結,內容粗而不深入,細節問題,細節討論。
常見參數JVM參數配置(java vm Hotspot TM 1.6):
-XX:+UseParNewGC:對Yong區域啟用並行回收算法。
最後更新:2017-04-02 06:51:40