點滴:Java 虛擬機詳解

1   Java技術與Java虛擬機

說起Java,人們首先想到的是Java編程語言,然而事實上,Java是一種技術,它由四方面組成: Java編程語言、Java類文件格式、Java虛擬機和Java應用程序接口(Java API)。它們的關係如下圖所示:

圖1   Java四個方面的關係

運行期環境代表着Java平臺,開發人員編寫Java代碼(.java文件),然後將之編譯成字節碼(.class文件)。最後字節碼被裝入內存,一旦字節碼進入虛擬機,它就會被解釋器解釋執行,或者是被即時代碼發生器有選擇的轉換成機器碼執行。從上圖也可以看出Java平臺由Java虛擬機和 Java應用程序接口搭建,Java語言則是進入這個平臺的通道,用Java語言編寫並編譯的程序可以運行在這個平臺上。這個平臺的結構如下圖所示:

在Java平臺的結構中, 可以看出,Java虛擬機(JVM) 處在覈心的位置,是程序與底層操作系統和硬件無關的關鍵。它的下方是移植接口,移植接口由兩部分組成:適配器和Java操作系統, 其中依賴於平臺的部分稱爲適配器;JVM 通過移植接口在具體的平臺和操作系統上實現;在JVM 的上方是Java的基本類庫和擴展類庫以及它們的API, 利用Java API編寫的應用程序(application) 和小程序(Java applet) 可以在任何Java平臺上運行而無需考慮底層平臺, 就是因爲有Java虛擬機(JVM)實現了程序與操作系統的分離,從而實現了Java 的平臺無關性。

那麼到底什麼是Java虛擬機(JVM)呢?通常我們談論JVM時,我們的意思可能是:

1.     對JVM規範的的比較抽象的說明;

2.     對JVM的具體實現;

3.     在程序運行期間所生成的一個JVM實例。

對JVM規範的的抽象說明是一些概念的集合,它們已經在書《The Java Virtual Machine Specification》(《Java虛擬機規範》)中被詳細地描述了;對JVM的具體實現要麼是軟件,要麼是軟件和硬件的組合,它已經被許多生產廠商所實現,並存在於多種平臺之上;運行Java程序的任務由JVM的運行期實例單個承擔。在本文中我們所討論的Java虛擬機(JVM)主要針對第三種情況而言。它可以被看成一個想象中的機器,在實際的計算機上通過軟件模擬來實現,有自己想象中的硬件,如處理器、堆棧、寄存器等,還有自己相應的指令系統。

JVM在它的生存週期中有一個明確的任務,那就是運行Java程序,因此當Java程序啓動的時候,就產生JVM的一個實例;當程序運行結束的時候,該實例也跟着消失了。下面我們從JVM的體系結構和它的運行過程這兩個方面來對它進行比較深入的研究。

2   Java虛擬機的體系結構

剛纔已經提到,JVM可以由不同的廠商來實現。由於廠商的不同必然導致JVM在實現上的一些不同,然而JVM還是可以實現跨平臺的特性,這就要歸功於設計JVM時的體系結構了。

我們知道,一個JVM實例的行爲不光是它自己的事,還涉及到它的子系統、存儲區域、數據類型和指令這些部分,它們描述了JVM的一個抽象的內部體系結構,其目的不光規定實現JVM時它內部的體系結構,更重要的是提供了一種方式,用於嚴格定義實現時的外部行爲。每個JVM都有兩種機制,一個是裝載具有合適名稱的類(類或是接口),叫做類裝載子系統;另外的一個負責執行包含在已裝載的類或接口中的指令,叫做運行引擎。每個JVM又包括方法區、堆、 Java棧、程序計數器和本地方法棧這五個部分,這幾個部分和類裝載機制與運行引擎機制一起組成的體系結構圖爲:

圖3   JVM的體系結構

JVM的每個實例都有一個它自己的方法域和一個堆,運行於JVM內的所有的線程都共享這些區域;當虛擬機裝載類文件的時候,它解析其中的二進制數據所包含的類信息,並把它們放到方法域中;當程序運行的時候,JVM把程序初始化的所有對象置於堆上;而每個線程創建的時候,都會擁有自己的程序計數器和 Java棧,其中程序計數器中的值指向下一條即將被執行的指令,線程的Java棧則存儲爲該線程調用Java方法的狀態;本地方法調用的狀態被存儲在本地方法棧,該方法棧依賴於具體的實現。

下面分別對這幾個部分進行說明。

執行引擎處於JVM的核心位置,在Java虛擬機規範中,它的行爲是由指令集所決定的。儘管對於每條指令,規範很詳細地說明了當JVM執行字節碼遇到指令時,它的實現應該做什麼,但對於怎麼做卻言之甚少。Java虛擬機支持大約248個字節碼。每個字節碼執行一種基本的CPU運算,例如,把一個整數加到寄存器,子程序轉移等。Java指令集相當於Java程序的彙編語言。

Java指令集中的指令包含一個單字節的操作符,用於指定要執行的操作,還有0個或多個操作數,提供操作所需的參數或數據。許多指令沒有操作數,僅由一個單字節的操作符構成。

虛擬機的內層循環的執行過程如下:

do{

取一個操作符字節;

根據操作符的值執行一個動作;

}while(程序未結束)

由於指令系統的簡單性,使得虛擬機執行的過程十分簡單,從而有利於提高執行的效率。指令中操作數的數量和大小是由操作符決定的。如果操作數比一個字節大,那麼它存儲的順序是高位字節優先。例如,一個16位的參數存放時佔用兩個字節,其值爲:

第一個字節*256+第二個字節字節碼。

指令流一般只是字節對齊的。指令tableswitch和lookup是例外,在這兩條指令內部要求強制的4字節邊界對齊。

對於本地方法接口,實現JVM並不要求一定要有它的支持,甚至可以完全沒有。Sun公司實現Java本地接口(JNI)是出於可移植性的考慮,當然我們也可以設計出其它的本地接口來代替Sun公司的JNI。但是這些設計與實現是比較複雜的事情,需要確保垃圾回收器不會將那些正在被本地方法調用的對象釋放掉。

Java的堆是一個運行時數據區,類的實例(對象)從中分配空間,它的管理是由垃圾回收來負責的:不給程序員顯式釋放對象的能力。Java不規定具體使用的垃圾回收算法,可以根據系統的需求使用各種各樣的算法。

Java方法區與傳統語言中的編譯後代碼或是Unix進程中的正文段類似。它保存方法代碼(編譯後的java代碼)和符號表。在當前的Java實現中,方法代碼不包括在垃圾回收堆中,但計劃在將來的版本中實現。每個類文件包含了一個Java類或一個Java界面的編譯後的代碼。可以說類文件是 Java語言的執行代碼文件。爲了保證類文件的平臺無關性,Java虛擬機規範中對類文件的格式也作了詳細的說明。其具體細節請參考Sun公司的Java 虛擬機規範。

Java虛擬機的寄存器用於保存機器的運行狀態,與微處理器中的某些專用寄存器類似。Java虛擬機的寄存器有四種:

1.     pc: Java程序計數器;

2.     optop: 指向操作數棧頂端的指針;

3.     frame: 指向當前執行方法的執行環境的指針;。

4.     vars: 指向當前執行方法的局部變量區第一個變量的指針。

在上述體系結構圖中,我們所說的是第一種,即程序計數器,每個線程一旦被創建就擁有了自己的程序計數器。當線程執行Java方法的時候,它包含該線程正在被執行的指令的地址。但是若線程執行的是一個本地的方法,那麼程序計數器的值就不會被定義。

Java虛擬機的棧有三個區域:局部變量區、運行環境區、操作數區。

局部變量區

每個Java方法使用一個固定大小的局部變量集。它們按照與vars寄存器的字偏移量來尋址。局部變量都是32位的。長整數和雙精度浮點數佔據了兩個局部變量的空間,卻按照第一個局部變量的索引來尋址。(例如,一個具有索引n的局部變量,如果是一個雙精度浮點數,那麼它實際佔據了索引n和n+1所代表的存儲空間)虛擬機規範並不要求在局部變量中的64位的值是64位對齊的。虛擬機提供了把局部變量中的值裝載到操作數棧的指令,也提供了把操作數棧中的值寫入局部變量的指令。

運行環境區

在運行環境中包含的信息用於動態鏈接,正常的方法返回以及異常捕捉。

動態鏈接

運行環境包括對指向當前類和當前方法的解釋器符號表的指針,用於支持方法代碼的動態鏈接。方法的class文件代碼在引用要調用的方法和要訪問的變量時使用符號。動態鏈接把符號形式的方法調用翻譯成實際方法調用,裝載必要的類以解釋還沒有定義的符號,並把變量訪問翻譯成與這些變量運行時的存儲結構相應的偏移地址。動態鏈接方法和變量使得方法中使用的其它類的變化不會影響到本程序的代碼。

正常的方法返回

如果當前方法正常地結束了,在執行了一條具有正確類型的返回指令時,調用的方法會得到一個返回值。執行環境在正常返回的情況下用於恢復調用者的寄存器,並把調用者的程序計數器增加一個恰當的數值,以跳過已執行過的方法調用指令,然後在調用者的執行環境中繼續執行下去。

異常捕捉

異常情況在Java中被稱作Error(錯誤)或Exception(異常),是Throwable類的子類,在程序中的原因是:①動態鏈接錯,如無法找到所需的class文件。②運行時錯,如對一個空指針的引用。程序使用了throw語句。

當異常發生時,Java虛擬機採取如下措施:

·        檢查與當前方法相聯繫的catch子句表。每個catch子句包含其有效指令範圍,能夠處理的異常類型,以及處理異常的代碼塊地址。

·        與異常相匹配的catch子句應該符合下面的條件:造成異常的指令在其指令範圍之內,發生的異常類型是其能處理的異常類型的子類型。如果找到了匹配的catch子句,那麼系統轉移到指定的異常處理塊處執行;如果沒有找到異常處理塊,重複尋找匹配的catch子句的過程,直到當前方法的所有嵌套的 catch子句都被檢查過。

·        由於虛擬機從第一個匹配的catch子句處繼續執行,所以catch子句表中的順序是很重要的。因爲Java代碼是結構化的,因此總可以把某個方法的所有的異常處理器都按序排列到一個表中,對任意可能的程序計數器的值,都可以用線性的順序找到合適的異常處理塊,以處理在該程序計數器值下發生的異常情況。

·        如果找不到匹配的catch子句,那麼當前方法得到一個"未截獲異常"的結果並返回到當前方法的調用者,好像異常剛剛在其調用者中發生一樣。如果在調用者中仍然沒有找到相應的異常處理塊,那麼這種錯誤將被傳播下去。如果錯誤被傳播到最頂層,那麼系統將調用一個缺省的異常處理塊。

操作數棧區

機器指令只從操作數棧中取操作數,對它們進行操作,並把結果返回到棧中。選擇棧結構的原因是:在只有少量寄存器或非通用寄存器的機器(如 Intel486)上,也能夠高效地模擬虛擬機的行爲。操作數棧是32位的。它用於給方法傳遞參數,並從方法接收結果,也用於支持操作的參數,並保存操作的結果。例如,iadd指令將兩個整數相加。相加的兩個整數應該是操作數棧頂的兩個字。這兩個字是由先前的指令壓進堆棧的。這兩個整數將從堆棧彈出、相加,並把結果壓回到操作數棧中。

每個原始數據類型都有專門的指令對它們進行必須的操作。每個操作數在棧中需要一個存儲位置,除了long和double型,它們需要兩個位置。操作數只能被適用於其類型的操作符所操作。例如,壓入兩個int類型的數,如果把它們當作是一個long類型的數則是非法的。在Sun的虛擬機實現中,這個限制由字節碼驗證器強制實行。但是,有少數操作(操作符dupe和swap),用於對運行時數據區進行操作時是不考慮類型的。

本地方法棧,當一個線程調用本地方法時,它就不再受到虛擬機關於結構和安全限制方面的約束,它既可以訪問虛擬機的運行期數據區,也可以使用本地處理器以及任何類型的棧。例如,本地棧是一個C語言的棧,那麼當C程序調用C函數時,函數的參數以某種順序被壓入棧,結果則返回給調用函數。在實現Java虛擬機時,本地方法接口使用的是C語言的模型棧,那麼它的本地方法棧的調度與使用則完全與C語言的棧相同。

3   Java虛擬機的運行過程

上面對虛擬機的各個部分進行了比較詳細的說明,下面通過一個具體的例子來分析它的運行過程。

虛擬機通過調用某個指定類的方法main啓動,傳遞給main一個字符串數組參數,使指定的類被裝載,同時鏈接該類所使用的其它的類型,並且初始化它們。例如對於程序:

class HelloApp

{

 public static void main(String[] args)

 {

 System.out.println("Hello World!");

 for (int i = 0; i < args.length; i++ )

  {

  System.out.println(args[i]);

  }

 }

}

編譯後在命令行模式下鍵入: java HelloApp run virtual machine

將通過調用HelloApp的方法main來啓動java虛擬機,傳遞給main一個包含三個字符串"run"、"virtual"、"machine"的數組。現在我們略述虛擬機在執行HelloApp時可能採取的步驟。

開始試圖執行類HelloApp的main方法,發現該類並沒有被裝載,也就是說虛擬機當前不包含該類的二進制代表,於是虛擬機使用 ClassLoader試圖尋找這樣的二進制代表。如果這個進程失敗,則拋出一個異常。類被裝載後同時在main方法被調用之前,必須對類 HelloApp與其它類型進行鏈接然後初始化。鏈接包含三個階段:檢驗,準備和解析。檢驗檢查被裝載的主類的符號和語義,準備則創建類或接口的靜態域以及把這些域初始化爲標準的默認值,解析負責檢查主類對其它類或接口的符號引用,在這一步它是可選的。類的初始化是對類中聲明的靜態初始化函數和靜態域的初始化構造方法的執行。一個類在初始化之前它的父類必須被初始化。整個過程如下:

圖4:虛擬機的運行過程

4   結束語

本文通過對JVM的體系結構的深入研究以及一個Java程序執行時虛擬機的運行過程的詳細分析,意在剖析清楚Java虛擬機的機理。

1 JVM簡介

JVM是我們Javaer的最基本功底了,剛開始學Java的時候,一般都是從“Hello World”開始的,然後會寫個複雜點class,然後再找一些開源框架,比如Spring,Hibernate等等,再然後就開發企業級的應用,比如網站、企業內部應用、實時交易系統等等,直到某一天突然發現做的系統咋就這麼慢呢,而且時不時還來個內存溢出什麼的,今天是交易系統報了StackOverflowError,明天是網站系統報了個OutOfMemoryError,這種錯誤又很難重現,只有分析Javacore和dump文件,運氣好點還能分析出個結果,運行遭的點,就直接去廟裏燒香吧!每天接客戶的電話都是戰戰兢兢的,生怕再出什麼幺蛾子了。我想Java做的久一點的都有這樣的經歷,那這些問題的最終根結是在哪呢?—— JVM。

JVM全稱是Java VirtualMachine,Java虛擬機,也就是在計算機上再虛擬一個計算機,這和我們使用 VMWare不一樣,那個虛擬的東西你是可以看到的,這個JVM你是看不到的,它存在內存中。我們知道計算機的基本構成是:運算器、控制器、存儲器、輸入和輸出設備,那這個JVM也是有這成套的元素,運算器是當然是交給硬件CPU還處理了,只是爲了適應“一次編譯,隨處運行”的情況,需要做一個翻譯動作,於是就用了JVM自己的命令集,這與彙編的命令集有點類似,每一種彙編命令集針對一個系列的CPU,比如8086系列的彙編也是可以用在8088上的,但是就不能跑在8051上,而JVM的命令集則是可以到處運行的,因爲JVM做了翻譯,根據不同的CPU,翻譯成不同的機器語言。

JVM中我們最需要深入理解的就是它的存儲部分,存儲?硬盤?NO,NO, JVM是一個內存中的虛擬機,那它的存儲就是內存了,我們寫的所有類、常量、變量、方法都在內存中,這決定着我們程序運行的是否健壯、是否高效,接下來的部分就是重點介紹之。

2 JVM的組成部分

我們先把JVM這個虛擬機畫出來,如下圖所示:


 

 

從這個圖中可以看到,JVM是運行在操作系統之上的,它與硬件沒有直接的交互。我們再來看下JVM有哪些組成部分,如下圖所示:



 該圖參考了網上廣爲流傳的JVM構成圖,大家看這個圖,整個JVM分爲四部分:

q  Class Loader 類加載器

類加載器的作用是加載類文件到內存,比如編寫一個HelloWord.java程序,然後通過javac編譯成class文件,那怎麼才能加載到內存中被執行呢?Class Loader承擔的就是這個責任,那不可能隨便建立一個.class文件就能被加載的,Class Loader加載的class文件是有格式要求,在《JVM Specification》中式這樣定義Class文件的結構:

    ClassFile {

      u4 magic;

      u2 minor_version;

      u2 major_version;

      u2 constant_pool_count;

      cp_infoconstant_pool[constant_pool_count-1];

      u2 access_flags;

      u2 this_class;

      u2 super_class;

      u2 interfaces_count;

      u2 interfaces[interfaces_count];

      u2 fields_count;

      field_info fields[fields_count];

      u2 methods_count;

      method_info methods[methods_count];

      u2 attributes_count;

      attribute_infoattributes[attributes_count];

    }

需要詳細瞭解的話,可以仔細閱讀《JVM Specification》的第四章“The class File Format”,這裏不再詳細說明。

友情提示:Class Loader只管加載,只要符合文件結構就加載,至於說能不能運行,則不是它負責的,那是由Execution Engine負責的。

q  Execution Engine 執行引擎

執行引擎也叫做解釋器(Interpreter),負責解釋命令,提交操作系統執行。

q  Native Interface本地接口

本地接口的作用是融合不同的編程語言爲Java所用,它的初衷是融合C/C++程序,Java誕生的時候是C/C++橫行的時候,要想立足,必須有一個聰明的、睿智的調用C/C++程序,於是就在內存中專門開闢了一塊區域處理標記爲native的代碼,它的具體做法是Native Method Stack中登記native方法,在Execution Engine執行時加載native libraies。目前該方法使用的是越來越少了,除非是與硬件有關的應用,比如通過Java程序驅動打印機,或者Java系統管理生產設備,在企業級應用中已經比較少見,因爲現在的異構領域間的通信很發達,比如可以使用Socket通信,也可以使用Web Service等等,不多做介紹。

q  Runtime data area運行數據區

運行數據區是整個JVM的重點。我們所有寫的程序都被加載到這裏,之後纔開始運行,Java生態系統如此的繁榮,得益於該區域的優良自治,下一章節詳細介紹之。

 

整個JVM框架由加載器加載文件,然後執行器在內存中處理數據,需要與異構系統交互是可以通過本地接口進行,瞧,一個完整的系統誕生了!

2 JVM的內存管理

所有的數據和程序都是在運行數據區存放,它包括以下幾部分:

q  Stack 棧

棧也叫棧內存,是Java程序的運行區,是在線程創建時創建,它的生命期是跟隨線程的生命期,線程結束棧內存也就釋放,對於棧來說不存在垃圾回收問題,只要線程一結束,該棧就Over。問題出來了:棧中存的是那些數據呢?又什麼是格式呢?

棧中的數據都是以棧幀(Stack Frame)的格式存在,棧幀是一個內存區塊,是一個數據集,是一個有關方法(Method)和運行期數據的數據集,當一個方法A被調用時就產生了一個棧幀F1,並被壓入到棧中,A方法又調用了B方法,於是產生棧幀F2也被壓入棧,執行完畢後,先彈出F2棧幀,再彈出F1棧幀,遵循“先進後出”原則。

那棧幀中到底存在着什麼數據呢?棧幀中主要保存3類數據:本地變量(LocalVariables),包括輸入參數和輸出參數以及方法內的變量;棧操作(Operand Stack),記錄出棧、入棧的操作;棧幀數據(FrameData),包括類文件、方法等等。光說比較枯燥,我們畫個圖來理解一下Java棧,如下圖所示:



 圖示在一個棧中有兩個棧幀,棧幀2是最先被調用的方法,先入棧,然後方法2又調用了方法1,棧幀1處於棧頂的位置,棧幀2處於棧底,執行完畢後,依次彈出棧幀1和棧幀2,線程結束,棧釋放。

q  Heap 堆內存

一個JVM實例只存在一個堆類存,堆內存的大小是可以調節的。類加載器讀取了類文件後,需要把類、方法、常變量放到堆內存中,以方便執行器執行,堆內存分爲三部分:

Permanent Space 永久存儲區

永久存儲區是一個常駐內存區域,用於存放JDK自身所攜帶的Class,Interface的元數據,也就是說它存儲的是運行環境必須的類信息,被裝載進此區域的數據是不會被垃圾回收器回收掉的,關閉JVM纔會釋放此區域所佔用的內存。

Young Generation Space 新生區

新生區是類的誕生、成長、消亡的區域,一個類在這裏產生,應用,最後被垃圾回收器收集,結束生命。新生區又分爲兩部分:伊甸區(Eden space)和倖存者區(Survivor pace),所有的類都是在伊甸區被new出來的。倖存區有兩個: 0區(Survivor 0 space)和1區(Survivor 1 space)。當伊甸園的空間用完時,程序又需要創建對象,JVM的垃圾回收器將對伊甸園區進行垃圾回收,將伊甸園區中的不再被其他對象所引用的對象進行銷燬。然後將伊甸園中的剩餘對象移動到倖存0區。若倖存0區也滿了,再對該區進行垃圾回收,然後移動到1區。那如果1區也滿了呢?再移動到養老區。

Tenure generation space養老區

養老區用於保存從新生區篩選出來的JAVA對象,一般池對象都在這個區域活躍。   三個區的示意圖如下:



 q  Method Area 方法區

方法區是被所有線程共享,該區域保存所有字段和方法字節碼,以及一些特殊方法如構造函數,接口代碼也在此定義。

q  PC Register 程序計數器

每個線程都有一個程序計數器,就是一個指針,指向方法區中的方法字節碼,由執行引擎讀取下一條指令。

q  Native Method Stack 本地方法棧

 

 

 

3 JVM相關問題

問:堆和棧有什麼區別

答:堆是存放對象的,但是對象內的臨時變量是存在棧內存中,如例子中的methodVar是在運行期存放到棧中的。

棧是跟隨線程的,有線程就有棧,堆是跟隨JVM的,有JVM就有堆內存。

 

問:堆內存中到底存在着什麼東西?

答:對象,包括對象變量以及對象方法。

 

問:類變量和實例變量有什麼區別?

答:靜態變量是類變量,非靜態變量是實例變量,直白的說,有static修飾的變量是靜態變量,沒有static修飾的變量是實例變量。靜態變量存在方法區中,實例變量存在堆內存中。

 

問:我聽說類變量是在JVM啓動時就初始化好的,和你這說的不同呀!

答:那你是道聽途說,信我的,沒錯。

 

問:Java的方法(函數)到底是傳值還是傳址?

答:都不是,是以傳值的方式傳遞地址,具體的說原生數據類型傳遞的值,引用類型傳遞的地址。對於原始數據類型,JVM的處理方法是從Method Area或Heap中拷貝到Stack,然後運行frame中的方法,運行完畢後再把變量指拷貝回去。

 

問:爲什麼會產生OutOfMemory產生?

答:一句話:Heap內存中沒有足夠的可用內存了。這句話要好好理解,不是說Heap沒有內存了,是說新申請內存的對象大於Heap空閒內存,比如現在Heap還空閒1M,但是新申請的內存需要1.1M,於是就會報OutOfMemory了,可能以後的對象申請的內存都只要0.9M,於是就只出現一次OutOfMemory,GC也正常了,看起來像偶發事件,就是這麼回事。       但如果此時GC沒有回收就會產生掛起情況,系統不響應了。

 

問:我產生的對象不多呀,爲什麼還會產生OutOfMemory?

答:你繼承層次忒多了,Heap中 產生的對象是先產生 父類,然後才產生子類,明白不?

 

問:OutOfMemory錯誤分幾種?

答:分兩種,分別是“OutOfMemoryError:java heap size”和”OutOfMemoryError: PermGen space”,兩種都是內存溢出,heap size是說申請不到新的內存了,這個很常見,檢查應用或調整堆內存大小。

“PermGen space”是因爲永久存儲區滿了,這個也很常見,一般在熱發佈的環境中出現,是因爲每次發佈應用系統都不重啓,久而久之永久存儲區中的死對象太多導致新對象無法申請內存,一般重新啓動一下即可。

 

問:爲什麼會產生StackOverflowError?

答:因爲一個線程把Stack內存全部耗盡了,一般是遞歸函數造成的。

 

問:一個機器上可以看多個JVM嗎?JVM之間可以互訪嗎?

答:可以多個JVM,只要機器承受得了。JVM之間是不可以互訪,你不能在A-JVM中訪問B-JVM的Heap內存,這是不可能的。在以前老版本的JVM中,會出現A-JVM Crack後影響到B-JVM,現在版本非常少見。

 

問:爲什麼Java要採用垃圾回收機制,而不採用C/C++的顯式內存管理?

答:爲了簡單,內存管理不是每個程序員都能折騰好的。

 

問:爲什麼你沒有詳細介紹垃圾回收機制?

答:垃圾回收機制每個JVM都不同,JVM Specification只是定義了要自動釋放內存,也就是說它只定義了垃圾回收的抽象方法,具體怎麼實現各個廠商都不同,算法各異,這東西實在沒必要深入。

 

問:JVM中到底哪些區域是共享的?哪些是私有的?

答:Heap和Method Area是共享的,其他都是私有的,

 

問:什麼是JIT,你怎麼沒說?

答:JIT是指Just In Time,有的文檔把JIT作爲JVM的一個部件來介紹,有的是作爲執行引擎的一部分來介紹,這都能理解。Java剛誕生的時候是一個解釋性語言,別噓,即使編譯成了字節碼(byte code)也是針對JVM的,它需要再次翻譯成原生代碼(native code)才能被機器執行,於是效率的擔憂就提出來了。Sun爲了解決該問題提出了一套新的機制,好,你想編譯成原生代碼,沒問題,我在JVM上提供一個工具,把字節碼編譯成原生碼,下次你來訪問的時候直接訪問原生碼就成了,於是JIT就誕生了,就這麼回事。

 

問:JVM還有哪些部分是你沒有提到的?

答:JVM是一個異常複雜的東西,寫一本磚頭書都不爲過,還有幾個要說明的:

常量池(constant pool):按照順序存放程序中的常量,並且進行索引編號的區域。比如int i =100,這個100就放在常量池中。

安全管理器(Security Manager):提供Java運行期的安全控制,防止惡意攻擊,比如指定讀取文件,寫入文件權限,網絡訪問,創建進程等等,Class Loader在Security Manager認證通過後才能加載class文件的。

方法索引表(Methods table),記錄的是每個method的地址信息,Stack和Heap中的地址指針其實是指向Methodstable地址。

      

問:爲什麼不建議在程序中顯式的生命System.gc()?

答:因爲顯式聲明是做堆內存全掃描,也就是Full GC,是需要停止所有的活動的(Stop  TheWorld Collection),你的應用能承受這個嗎?

 

問:JVM有哪些調整參數?

答:非常多,自己去找,堆內存、棧內存的大小都可以定義,甚至是堆內存的三個部分、新生代的各個比例都能調整。

Java與C++之間有一堵由內存動態分配和垃圾收集技術所圍成的高牆,牆外面的人想進去,牆裏面的人卻想出來。

 

概述:

對於從事C、C++程序開發的開發人員來說,在內存管理領域,他們即是擁有最高權力的皇帝又是執行最基礎工作的勞動人民——擁有每一個對象的“所有權”,又擔負着每一個對象生命開始到終結的維護責任。

 

對於Java程序員來說,不需要在爲每一個new操作去寫配對的delete/free,不容易出現內容泄漏和內存溢出錯誤,看起來由JVM管理內存一切都很美好。不過,也正是因爲Java程序員把內存控制的權力交給了JVM,一旦出現泄漏和溢出,如果不瞭解JVM是怎樣使用內存的,那排查錯誤將會是一件非常困難的事情。

 

VM運行時數據區域

JVM執行Java程序的過程中,會使用到各種數據區域,這些區域有各自的用途、創建和銷燬時間。根據《Java虛擬機規範(第二版)》(下文稱VM Spec)的規定,JVM包括下列幾個運行時數據區域:

 

1.程序計數器(Program CounterRegister):

 

每一個Java線程都有一個程序計數器來用於保存程序執行到當前方法的哪一個指令,對於非Native方法,這個區域記錄的是正在執行的VM原語的地址,如果正在執行的是Natvie方法,這個區域則爲空(undefined)。此內存區域是唯一一個在VM Spec中沒有規定任何OutOfMemoryError情況的區域。

 

2.Java虛擬機棧(Java Virtual Machine Stacks)

與程序計數器一樣,VM棧的生命週期也是與線程相同。VM棧描述的是Java方法調用的內存模型:每個方法被執行的時候,都會同時創建一個幀(Frame)用於存儲本地變量表、操作棧、動態鏈接、方法出入口等信息。每一個方法的調用至完成,就意味着一個幀在VM棧中的入棧至出棧的過程。在後文中,我們將着重討論VM棧中本地變量表部分。

經常有人把Java內存簡單的區分爲堆內存(Heap)和棧內存(Stack),實際中的區域遠比這種觀點複雜,這樣劃分只是說明與變量定義密切相關的內存區域是這兩塊。其中所指的“堆”後面會專門描述,而所指的“棧”就是VM棧中各個幀的本地變量表部分。本地變量表存放了編譯期可知的各種標量類型(boolean、byte、char、short、int、float、long、double)、對象引用(不是對象本身,僅僅是一個引用指針)、方法返回地址等。其中long和double會佔用2個本地變量空間(32bit),其餘佔用1個。本地變量表在進入方法時進行分配,當進入一個方法時,這個方法需要在幀中分配多大的本地變量是一件完全確定的事情,在方法運行期間不改變本地變量表的大小。

在VM Spec中對這個區域規定了2中異常狀況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出StackOverflowError異常;如果VM棧可以動態擴展(VM Spec中允許固定長度的VM棧),當擴展時無法申請到足夠內存則拋出OutOfMemoryError異常。

3.本地方法棧(Native Method Stacks)

本地方法棧與VM棧所發揮作用是類似的,只不過VM棧爲虛擬機運行VM原語服務,而本地方法棧是爲虛擬機使用到的Native方法服務。它的實現的語言、方式與結構並沒有強制規定,甚至有的虛擬機(譬如Sun Hotspot虛擬機)直接就把本地方法棧和VM棧合二爲一。和VM棧一樣,這個區域也會拋出StackOverflowError和OutOfMemoryError異常。


4.Java堆(Java Heap)

對於絕大多數應用來說,Java堆是虛擬機管理最大的一塊內存。Java堆是被所有線程共享的,在虛擬機啓動時創建。Java堆的唯一目的就是存放對象實例,絕大部分的對象實例都在這裏分配。這一點在VM Spec中的描述是:所有的實例以及數組都在堆上分配(原文:The heap is the runtime data area from whichmemory for all class instances and arrays is allocated),但是在逃逸分析和標量替換優化技術出現後,VM Spec的描述就顯得並不那麼準確了。

Java堆內還有更細緻的劃分:新生代、老年代,再細緻一點的:eden、from survivor、to survivor,甚至更細粒度的本地線程分配緩衝(TLAB)等,無論對Java堆如何劃分,目的都是爲了更好的回收內存,或者更快的分配內存,在本章中我們僅僅針對內存區域的作用進行討論,Java堆中的上述各個區域的細節,可參見本文第二章《JVM內存管理:深入垃圾收集器與內存分配策略》。

根據VM Spec的要求,Java堆可以處於物理上不連續的內存空間,它邏輯上是連續的即可,就像我們的磁盤空間一樣。實現時可以選擇實現成固定大小的,也可以是可擴展的,不過當前所有商業的虛擬機都是按照可擴展來實現的(通過-Xmx和-Xms控制)。如果在堆中無法分配內存,並且堆也無法再擴展時,將會拋出OutOfMemoryError異常。

5.方法區(Method Area)

叫“方法區”可能認識它的人還不太多,如果叫永久代(Permanent Generation)它的粉絲也許就多了。它還有個別名叫做Non-Heap(非堆),但是VM Spec上則描述方法區爲堆的一個邏輯部分(原文:themethod area is logically part of the heap),這個名字的問題還真容易令人產生誤解,我們在這裏就不糾結了。

方法區中存放了每個Class的結構信息,包括常量池、字段描述、方法描述等等。VMSpace描述中對這個區域的限制非常寬鬆,除了和Java堆一樣不需要連續的內存,也可以選擇固定大小或者可擴展外,甚至可以選擇不實現垃圾收集。相對來說,垃圾收集行爲在這個區域是相對比較少發生的,但並不是某些描述那樣永久代不會發生GC(至少對當前主流的商業JVM實現來說是如此),這裏的GC主要是對常量池的回收和對類的卸載,雖然回收的“成績”一般也比較差強人意,尤其是類卸載,條件相當苛刻。

6.運行時常量池(Runtime Constant Pool)

Class文件中除了有類的版本、字段、方法、接口等描述等信息外,還有一項信息是常量表(constant_pool table),用於存放編譯期已可知的常量,這部分內容將在類加載後進入方法區(永久代)存放。但是Java語言並不要求常量一定只有編譯期預置入Class的常量表的內容才能進入方法區常量池,運行期間也可將新內容放入常量池(最典型的String.intern()方法)。

運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法在申請到內存時會拋出OutOfMemoryError異常。

 

7.本機直接內存(Direct Memory)

直接內存並不是虛擬機運行時數據區的一部分,它根本就是本機內存而不是VM直接管理的區域。但是這部分內存也會導致OutOfMemoryError異常出現,因此我們放到這裏一起描述。

在JDK1.4中新加入了NIO類,引入一種基於渠道與緩衝區的I/O方式,它可以通過本機Native函數庫直接分配本機內存,然後通過一個存儲在Java堆裏面的DirectByteBuffer對象作爲這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因爲避免了在Java對和本機堆中來回複製數據。

顯然本機直接內存的分配不會受到Java堆大小的限制,但是即然是內存那肯定還是要受到本機物理內存(包括SWAP區或者Windows虛擬內存)的限制的,一般服務器管理員配置JVM參數時,會根據實際內存設置-Xmx等參數信息,但經常忽略掉直接內存,使得各個內存區域總和大於物理內存限制(包括物理的和操作系統級的限制),而導致動態擴展時出現OutOfMemoryError異常。

 

實戰OutOfMemoryError

上述區域中,除了程序計數器,其他在VM Spec中都描述了產生OutOfMemoryError(下稱OOM)的情形,那我們就實戰模擬一下,通過幾段簡單的代碼,令對應的區域產生OOM異常以便加深認識,同時初步介紹一些與內存相關的虛擬機參數。下文的代碼都是基於Sun Hotspot虛擬機1.6版的實現,對於不同公司的不同版本的虛擬機,參數與程序運行結果可能結果會有所差別。

 

Java堆

 

Java堆存放的是對象實例,因此只要不斷建立對象,並且保證GC Roots到對象之間有可達路徑即可產生OOM異常。測試中限制Java堆大小爲20M,不可擴展,通過參數-XX:+HeapDumpOnOutOfMemoryError讓虛擬機在出現OOM異常的時候Dump出內存映像以便分析。(關於Dump映像文件分析方面的內容,可參見本文第三章《JVM內存管理:深入JVM內存異常分析與調優》。)

清單1:Java堆OOM測試

/**

 * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

 * @author zzm

 */

public class HeapOOM {

 

       static class OOMObject {

       }

 

       public static void main(String[] args) {

              List<OOMObject> list = new ArrayList<OOMObject>();

 

              while (true) {

                     list.add(new OOMObject());

              }

       }

}

 

運行結果:

java.lang.OutOfMemoryError: Java heap space

Dumping heap to java_pid3404.hprof ...

Heap dump file created [22045981 bytes in 0.663 secs]

 

 

VM棧和本地方法棧

 

Hotspot虛擬機並不區分VM棧和本地方法棧,因此-Xoss參數實際上是無效的,棧容量只由-Xss參數設定。關於VM棧和本地方法棧在VM Spec描述了兩種異常:StackOverflowError與OutOfMemoryError,當棧空間無法繼續分配分配時,到底是內存太小還是棧太大其實某種意義上是對同一件事情的兩種描述而已,在筆者的實驗中,對於單線程應用嘗試下面3種方法均無法讓虛擬機產生OOM,全部嘗試結果都是獲得SOF異常。

 

1.使用-Xss參數削減棧內存容量。結果:拋出SOF異常時的堆棧深度相應縮小。

2.定義大量的本地變量,增大此方法對應幀的長度。結果:拋出SOF異常時的堆棧深度相應縮小。

3.創建幾個定義很多本地變量的複雜對象,打開逃逸分析和標量替換選項,使得JIT編譯器允許對象拆分後在棧中分配。結果:實際效果同第二點。

 

清單2:VM棧和本地方法棧OOM測試(僅作爲第1點測試程序)

/**

 * VM Args:-Xss128k

 * @author zzm

 */

public class JavaVMStackSOF {

 

       private int stackLength = 1;

 

       public void stackLeak() {

              stackLength++;

              stackLeak();

       }

 

       public static void main(String[] args) throws Throwable {

              JavaVMStackSOF oom = new JavaVMStackSOF();

              try {

                     oom.stackLeak();

              } catch (Throwable e) {

                     System.out.println("stack length:" + oom.stackLength);

                     throw e;

              }

       }

}

 

運行結果:

stack length:2402

Exception in thread "main" java.lang.StackOverflowError

        at org.fenixsoft.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:20)

        at org.fenixsoft.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:21)

        at org.fenixsoft.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:21)

 

如果在多線程環境下,不斷建立線程倒是可以產生OOM異常,但是基本上這個異常和VM棧空間夠不夠關係沒有直接關係,甚至是給每個線程的VM棧分配的內存越多反而越容易產生這個OOM異常。

 

原因其實很好理解,操作系統分配給每個進程的內存是有限制的,譬如32位Windows限制爲2G,Java堆和方法區的大小JVM有參數可以限制最大值,那剩餘的內存爲2G(操作系統限制)-Xmx(最大堆)-MaxPermSize(最大方法區),程序計數器消耗內存很小,可以忽略掉,那虛擬機進程本身耗費的內存不計算的話,剩下的內存就供每一個線程的VM棧和本地方法棧瓜分了,那自然每個線程中VM棧分配內存越多,就越容易把剩下的內存耗盡。

 

清單3:創建線程導致OOM異常

/**

 * VM Args:-Xss2M (這時候不妨設大些)

 * @author zzm

 */

public class JavaVMStackOOM {

 

       private void dontStop() {

              while (true) {

              }

       }

 

       public void stackLeakByThread() {

              while (true) {

                     Thread thread = new Thread(new Runnable() {

                            @Override

                            public void run() {

                                   dontStop();

                            }

                     });

                     thread.start();

              }

       }

 

       public static void main(String[] args) throws Throwable {

              JavaVMStackOOM oom = new JavaVMStackOOM();

              oom.stackLeakByThread();

       }

}

 

特別提示一下,如果讀者要運行上面這段代碼,記得要存盤當前工作,上述代碼執行時有很大令操作系統卡死的風險。

 

運行結果:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread

 

運行時常量池

 

要在常量池裏添加內容,最簡單的就是使用String.intern()這個Native方法。由於常量池分配在方法區內,我們只需要通過-XX:PermSize和-XX:MaxPermSize限制方法區大小即可限制常量池容量。實現代碼如下:

 

清單4:運行時常量池導致的OOM異常

/**

 * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M

 * @author zzm

 */

public class RuntimeConstantPoolOOM {

 

       public static void main(String[] args) {

              // 使用List保持着常量池引用,壓制Full GC回收常量池行爲

              List<String> list = new ArrayList<String>();

              // 10M的PermSize在integer範圍內足夠產生OOM了

              int i = 0;

              while (true) {

                     list.add(String.valueOf(i++).intern());

              }

       }

}

 

運行結果:

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

       at java.lang.String.intern(Native Method)

       at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18)

 

 

方法區

 

上文講過,方法區用於存放Class相關信息,所以這個區域的測試我們藉助CGLib直接操作字節碼動態生成大量的Class,值得注意的是,這裏我們這個例子中模擬的場景其實經常會在實際應用中出現:當前很多主流框架,如Spring、Hibernate對類進行增強時,都會使用到CGLib這類字節碼技術,當增強的類越多,就需要越大的方法區用於保證動態生成的Class可以加載入內存。

 

清單5:藉助CGLib使得方法區出現OOM異常

/**

 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M

 * @author zzm

 */

public class JavaMethodAreaOOM {

 

       public static void main(String[] args) {

              while (true) {

                     Enhancer enhancer = new Enhancer();

                     enhancer.setSuperclass(OOMObject.class);

                     enhancer.setUseCache(false);

                     enhancer.setCallback(new MethodInterceptor() {

                            public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {

                                   return proxy.invokeSuper(obj, args);

                            }

                     });

                     enhancer.create();

              }

       }

 

       static class OOMObject {

 

       }

}

 

運行結果:

Caused by: java.lang.OutOfMemoryError: PermGen space

       at java.lang.ClassLoader.defineClass1(Native Method)

       at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632)

       at java.lang.ClassLoader.defineClass(ClassLoader.java:616)

       ... 8 more

 

本機直接內存

 

DirectMemory容量可通過-XX:MaxDirectMemorySize指定,不指定的話默認與Java堆(-Xmx指定)一樣,下文代碼越過了DirectByteBuffer,直接通過反射獲取Unsafe實例進行內存分配(Unsafe類的getUnsafe()方法限制了只有引導類加載器纔會返回實例,也就是基本上只有rt.jar裏面的類的才能使用),因爲DirectByteBuffer也會拋OOM異常,但拋出異常時實際上並沒有真正向操作系統申請分配內存,而是通過計算得知無法分配既會拋出,真正申請分配的方法是unsafe.allocateMemory()。

 

/**

 * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M

 * @author zzm

 */

public class DirectMemoryOOM {

 

       private static final int _1MB = 1024 * 1024;

 

       public static void main(String[] args) throws Exception {

              Field unsafeField = Unsafe.class.getDeclaredFields()[0];

              unsafeField.setAccessible(true);

              Unsafe unsafe = (Unsafe) unsafeField.get(null);

              while (true) {

                     unsafe.allocateMemory(_1MB);

              }

       }

}

 

運行結果:

Exception in thread "main" java.lang.OutOfMemoryError

       at sun.misc.Unsafe.allocateMemory(Native Method)

       at org.fenixsoft.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:20)

 

 

總結

到此爲止,我們弄清楚虛擬機裏面的內存是如何劃分的,哪部分區域,什麼樣的代碼、操作可能導致OOM異常。雖然Java有垃圾收集機制,但OOM仍然離我們並不遙遠,本章內容我們只是知道各個區域OOM異常出現的原因,下一章我們將看看Java垃圾收集機制爲了避免OOM異常出現,做出了什麼樣的努力。

最近想將java基礎的一些東西都整理整理,寫下來,這是對知識的總結,也是一種樂趣。已經擬好了提綱,大概分爲這幾個主題: java線程安全,java垃圾收集,java併發包詳細介紹,java profile和jvm性能調優 。慢慢寫吧。本人jameswxx原創文章,轉載請註明出處,我費了很多心血,多謝了。關於java線程安全,網上有很多資料,我只想從自己的角度總結對這方面的考慮,有時候寫東西是很痛苦的,知道一些東西,但想用文字說清楚,卻不是那麼容易。我認爲要認識java線程安全,必須瞭解兩個主要的點:java的內存模型,java的線程同步機制。特別是內存模型,java的線程同步機制很大程度上都是基於內存模型而設定的。後面我還會寫java併發包的文章,詳細總結如何利用java併發包編寫高效安全的多線程併發程序。暫時寫得比較倉促,後面會慢慢補充完善。

 

淺談java內存模型 
       不同的平臺,內存模型是不一樣的,但是jvm的內存模型規範是統一的。其實java的多線程併發問題最終都會反映在java的內存模型上,所謂線程安全無非是要控制多個線程對某個資源的有序訪問或修改。總結java的內存模型,要解決兩個主要的問題:可見性和有序性。我們都知道計算機有高速緩存的存在,處理器並不是每次處理數據都是取內存的。JVM定義了自己的內存模型,屏蔽了底層平臺內存管理細節,對於java開發人員,要清楚在jvm內存模型的基礎上,如果解決多線程的可見性和有序性。
       那麼,何謂可見性? 多個線程之間是不能互相傳遞數據通信的,它們之間的溝通只能通過共享變量來進行。Java內存模型(JMM)規定了jvm有主內存,主內存是多個線程共享的。當new一個對象的時候,也是被分配在主內存中,每個線程都有自己的工作內存,工作內存存儲了主存的某些對象的副本,當然線程的工作內存大小是有限制的。當線程操作某個對象時,執行順序如下:
 (1) 從主存複製變量到當前工作內存 (read and load)
 (2)
執行代碼,改變共享變量值 (use and assign)
 (3)
用工作內存數據刷新主存相關內容 (store and write)

JVM規範定義了線程對主存的操作指令:read,load,use,assign,store,write。當一個共享變量在多個線程的工作內存中都有副本時,如果一個線程修改了這個共享變量,那麼其他線程應該能夠看到這個被修改後的值,這就是多線程的可見性問題。
        那麼,什麼是有序性呢 ?線程在引用變量時不能直接從主內存中引用,如果線程工作內存中沒有該變量,則會從主內存中拷貝一個副本到工作內存中,這個過程爲read-load,完成後線程會引用該副本。當同一線程再度引用該字段時,有可能重新從主存中獲取變量副本(read-load-use),也有可能直接引用原來的副本(use),也就是說 read,load,use順序可以由JVM實現系統決定。
        線程不能直接爲主存中中字段賦值,它會將值指定給工作內存中的變量副本(assign),完成後這個變量副本會同步到主存儲區(store-write),至於何時同步過去,根據JVM實現系統決定.有該字段,則會從主內存中將該字段賦值到工作內存中,這個過程爲read-load,完成後線程會引用該變量副本,當同一線程多次重複對字段賦值時,比如:

Java代碼 

1. for(int i=0;i<10;i++)  

2.  a++;  

 


線程有可能只對工作內存中的副本進行賦值,只到最後一次賦值後才同步到主存儲區,所以assign,store,weite順序可以由JVM實現系統決定。假設有一個共享變量x,線程a執行x=x+1。從上面的描述中可以知道x=x+1並不是一個原子操作,它的執行過程如下:
1 從主存中讀取變量x副本到工作內存
2
x1
3
x1後的值寫回主 存
如果另外一個線程b執行x=x-1,執行過程如下:
1 從主存中讀取變量x副本到工作內存
2
x1
3
x1後的值寫回主存 
那麼顯然,最終的x的值是不可靠的。假設x現在爲10,線程a加1,線程b減1,從表面上看,似乎最終x還是爲10,但是多線程情況下會有這種情況發生:
1:線程a從主存讀取x副本到工作內存,工作內存中x值爲10
2
:線程b從主存讀取x副本到工作內存,工作內存中x值爲10
3
:線程a將工作內存中x1,工作內存中x值爲11
4
:線程ax提交主存中,主存中x11
5
:線程b將工作內存中x值減1,工作內存中x值爲9
6
:線程bx提交到中主存中,主存中x9 
同樣,x有可能爲11,如果x是一個銀行賬戶,線程a存款,線程b扣款,顯然這樣是有嚴重問題的,要解決這個問題,必須保證線程a和線程b是有序執行的,並且每個線程執行的加1或減1是一個原子操作。看看下面代碼:

Java代碼 

1. public class Account {  

2.   

3.     private int balance;  

4.   

5.     public Account(int balance) {  

6.         this.balance = balance;  

7.     }  

8.   

9.     public int getBalance() {  

10.        return balance;  

11.    }  

12.  

13.    public void add(int num) {  

14.        balance = balance + num;  

15.    }  

16.  

17.    public void withdraw(int num) {  

18.        balance = balance - num;  

19.    }  

20.  

21.    public static void main(String[] args) throws InterruptedException {  

22.        Account account = new Account(1000);  

23.        Thread a = new Thread(new AddThread(account, 20), "add");  

24.        Thread b = new Thread(new WithdrawThread(account, 20), "withdraw");  

25.        a.start();  

26.        b.start();  

27.        a.join();  

28.        b.join();  

29.        System.out.println(account.getBalance());  

30.    }  

31.  

32.    static class AddThread implements Runnable {  

33.        Account account;  

34.        int     amount;  

35.  

36.        public AddThread(Account account, int amount) {  

37.            this.account = account;  

38.            this.amount = amount;  

39.        }  

40.  

41.        public void run() {  

42.            for (int i = 0; i < 200000; i++) {  

43.                account.add(amount);  

44.            }  

45.        }  

46.    }  

47.  

48.    static class WithdrawThread implements Runnable {  

49.        Account account;  

50.        int     amount;  

51.  

52.        public WithdrawThread(Account account, int amount) {  

53.            this.account = account;  

54.            this.amount = amount;  

55.        }  

56.  

57.        public void run() {  

58.            for (int i = 0; i < 100000; i++) {  

59.                account.withdraw(amount);  

60.            }  

61.        }  

62.    }  

63.}  

 


第一次執行結果爲10200,第二次執行結果爲1060,每次執行的結果都是不確定的,因爲線程的執行順序是不可預見的。這是java同步產生的根源,synchronized關鍵字保證了多個線程對於同步塊是互斥的,synchronized作爲一種同步手段,解決java多線程的執行有序性和內存可見性,而volatile關鍵字之解決多線程的內存可見性問題。後面將會詳細介紹。

 


synchronized關鍵字 
        上面說了,java用synchronized關鍵字做爲多線程併發環境的執行有序性的保證手段之一。當一段代碼會修改共享變量,這一段代碼成爲互斥區或臨界區,爲了保證共享變量的正確性,synchronized標示了臨界區。典型的用法如下:

Java代碼 

1. synchronized(鎖){  

2.      臨界區代碼  

3. }   

 


爲了保證銀行賬戶的安全,可以操作賬戶的方法如下:

Java代碼 

1. public synchronized void add(int num) {  

2.      balance = balance + num;  

3. }  

4. public synchronized void withdraw(int num) {  

5.      balance = balance - num;  

6. }  

 


剛纔不是說了synchronized的用法是這樣的嗎:

Java代碼 

1. synchronized(鎖){  

2. 臨界區代碼  

3. }  

 


那麼對於publicsynchronized void add(int num)這種情況,意味着什麼呢?其實這種情況,鎖就是這個方法所在的對象。同理,如果方法是public  static synchronized voidadd(int num),那麼鎖就是這個方法所在的class。
        理論上,每個對象都可以做爲鎖,但一個對象做爲鎖時,應該被多個線程共享,這樣才顯得有意義,在併發環境下,一個沒有共享的對象作爲鎖是沒有意義的。假如有這樣的代碼:

Java代碼 

1. public class ThreadTest{  

2.   public void test(){  

3.      Object lock=new Object();  

4.      synchronized (lock){  

5.         //do something  

6.      }  

7.   }  

8. }  

 


lock變量作爲一個鎖存在根本沒有意義,因爲它根本不是共享對象,每個線程進來都會執行Object lock=new Object();每個線程都有自己的lock,根本不存在鎖競爭。
        每個鎖對象都有兩個隊列,一個是就緒隊列,一個是阻塞隊列,就緒隊列存儲了將要獲得鎖的線程,阻塞隊列存儲了被阻塞的線程,當一個被線程被喚醒(notify)後,纔會進入到就緒隊列,等待cpu的調度。當一開始線程a第一次執行account.add方法時,jvm會檢查鎖對象account的就緒隊列是否已經有線程在等待,如果有則表明account的鎖已經被佔用了,由於是第一次運行,account的就緒隊列爲空,所以線程a獲得了鎖,執行account.add方法。如果恰好在這個時候,線程b要執行account.withdraw方法,因爲線程a已經獲得了鎖還沒有釋放,所以線程b要進入account的就緒隊列,等到得到鎖後纔可以執行。
一個線程執行臨界區代碼過程如下:
1 獲得同步鎖
2 清空工作內存
3 從主存拷貝變量副本到工作內存
4 對這些變量計算
5 將變量從工作內存寫回到主存
6 釋放鎖
可見,synchronized既保證了多線程的併發有序性,又保證了多線程的內存可見性。


生產者/消費者模式 
        生產者/消費者模式其實是一種很經典的線程同步模型,很多時候,並不是光保證多個線程對某共享資源操作的互斥性就夠了,往往多個線程之間都是有協作的。
        假設有這樣一種情況,有一個桌子,桌子上面有一個盤子,盤子裏只能放一顆雞蛋,A專門往盤子裏放雞蛋,如果盤子裏有雞蛋,則一直等到盤子裏沒雞蛋,B專門從盤子裏拿雞蛋,如果盤子裏沒雞蛋,則等待直到盤子裏有雞蛋。其實盤子就是一個互斥區,每次往盤子放雞蛋應該都是互斥的,A的等待其實就是主動放棄鎖,B等待時還要提醒A放雞蛋。
如何讓線程主動釋放鎖
很簡單,調用鎖的wait()方法就好。wait方法是從Object來的,所以任意對象都有這個方法。看這個代碼片段:

Java代碼 

1. Object lock=new Object();//聲明瞭一個對象作爲鎖  

2.    synchronized (lock) {  

3.        balance = balance - num;  

4.        //這裏放棄了同步鎖,好不容易得到,又放棄了  

5.        lock.wait();  

6. }  

 


如果一個線程獲得了鎖lock,進入了同步塊,執行lock.wait(),那麼這個線程會進入到lock的阻塞隊列。如果調用lock.notify()則會通知阻塞隊列的某個線程進入就緒隊列。
聲明一個盤子,只能放一個雞蛋

Java代碼 

1. package com.jameswxx.synctest;  

2. public class Plate{  

3.   List<Object> eggs=new ArrayList<Object>();  

4.   public synchronized  Object getEgg(){  

5.      if(eggs.size()==0){  

6.         try{  

7.             wait();  

8.         }catch(InterruptedException e){  

9.         }  

10.     }  

11.  

12.    Object egg=eggs.get(0);  

13.    eggs.clear();//清空盤子  

14.    notify();//喚醒阻塞隊列的某線程到就緒隊列  

15.    return egg;  

16.}  

17.  

18. public synchronized  void putEgg(Object egg){  

19.    If(eggs.size()>0){  

20.      try{  

21.         wait();  

22.      }catch(InterruptedException e){  

23.      }  

24.    }  

25.    eggs.add(egg);//往盤子裏放雞蛋  

26.    notify();//喚醒阻塞隊列的某線程到就緒隊列  

27.  }  

28.}  

 


聲明一個Plate對象爲plate,被線程A和線程B共享,A專門放雞蛋,B專門拿雞蛋。假設
1 開始,A調用plate.putEgg方法,此時eggs.size()0,因此順利將雞蛋放到盤子,還執行了notify()方法,喚醒鎖的阻塞隊列的線程,此時阻塞隊列還沒有線程。
2
又有一個A線程對象調用plate.putEgg方法,此時eggs.size()不爲0,調用wait()方法,自己進入了鎖對象的阻塞隊列。
3
此時,來了一個B線程對象,調用plate.getEgg方法,eggs.size()不爲0,順利的拿到了一個雞蛋,還執行了notify()方法,喚醒鎖的阻塞隊列的線程,此時阻塞隊列有一個A線程對象,喚醒後,它進入到就緒隊列,就緒隊列也就它一個,因此馬上得到鎖,開始往盤子裏放雞蛋,此時盤子是空的,因此放雞蛋成功。
4
假設接着來了線程A,就重複2;假設來料線程B,就重複3 
整個過程都保證了放雞蛋,拿雞蛋,放雞蛋,拿雞蛋。

 


volatile關鍵字 
       volatile是java提供的一種同步手段,只不過它是輕量級的同步,爲什麼這麼說,因爲volatile只能保證多線程的內存可見性,不能保證多線程的執行有序性。而最徹底的同步要保證有序性和可見性,例如synchronized。任何被volatile修飾的變量,都不拷貝副本到工作內存,任何修改都及時寫在主存。因此對於Valatile修飾的變量的修改,所有線程馬上就能看到,但是volatile不能保證對變量的修改是有序的。什麼意思呢?假如有這樣的代碼:

Java代碼 

1. public class VolatileTest{  

2.   public volatile int a;  

3.   public void add(int count){  

4.        a=a+count;  

5.   }  

6. }  

 


        當一個VolatileTest對象被多個線程共享,a的值不一定是正確的,因爲a=a+count包含了好幾步操作,而此時多個線程的執行是無序的,因爲沒有任何機制來保證多個線程的執行有序性和原子性。volatile存在的意義是,任何線程對a的修改,都會馬上被其他線程讀取到,因爲直接操作主存,沒有線程對工作內存和主存的同步。所以,volatile的使用場景是有限的,在有限的一些情形下可以使用 volatile 變量替代鎖。要使 volatile 變量提供理想的線程安全,必須同時滿足下面兩個條件:
1)對變量的寫操作不依賴於當前值。
2)
該變量沒有包含在具有其他變量的不變式中 
volatile只保證了可見性,所以Volatile適合直接賦值的場景,如

Java代碼 

1. public class VolatileTest{  

2.   public volatile int a;  

3.   public void setA(int a){  

4.       this.a=a;  

5.   }  

6. }  

 


在沒有volatile聲明時,多線程環境下,a的最終值不一定是正確的,因爲this.a=a;涉及到給a賦值和將a同步回主存的步驟,這個順序可能被打亂。如果用volatile聲明瞭,讀取主存副本到工作內存和同步a到主存的步驟,相當於是一個原子操作。所以簡單來說,volatile適合這種場景:一個變量被多個線程共享,線程直接給這個變量賦值。這是一種很簡單的同步場景,這時候使用volatile的開銷將會非常小。

 

 

Java與C++之間有一堵由內存動態分配和垃圾收集技術所圍成的高牆,牆外面的人想進去,牆裏面的人卻想出來。 

概述: 

  說起垃圾收集(Garbage Collection,下文簡稱GC),大部分人都把這項技術當做Java語言的伴生產物。事實上GC的歷史遠遠比Java來得久遠,在1960年誕生於MIT的Lisp是第一門真正使用內存動態分配和垃圾收集技術的語言。當Lisp還在胚胎時期,人們就在思考GC需要完成的3件事情:哪些內存需要回收?什麼時候回收?怎麼樣回收? 

  經過半個世紀的發展,目前的內存分配策略與垃圾回收技術已經相當成熟,一切看起來都進入“自動化”的時代,那爲什麼我們還要去了解GC和內存分配?答案很簡單:當需要排查各種內存溢出、泄漏問題時,當垃圾收集成爲系統達到更高併發量的瓶頸時,我們就需要對這些“自動化”的技術有必要的監控、調節手段。 

  把時間從1960年撥回現在,回到我們熟悉的Java語言。本文第一章中介紹了Java內存運行時區域的各個部分,其中程序計數器、VM棧、本地方法棧三個區域隨線程而生,隨線程而滅;棧中的幀隨着方法進入、退出而有條不紊的進行着出棧入棧操作;每一個幀中分配多少內存基本上是在Class文件生成時就已知的(可能會由JIT動態晚期編譯進行一些優化,但大體上可以認爲是編譯期可知的),因此這幾個區域的內存分配和回收具備很高的確定性,因此在這幾個區域不需要過多考慮回收的問題。而Java堆和方法區(包括運行時常量池)則不一樣,我們必須等到程序實際運行期間才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,我們本文後續討論中的“內存”分配與回收僅僅指這一部分內存。 

對象已死? 

  在堆裏面存放着Java世界中幾乎所有的對象,在回收前首先要確定這些對象之中哪些還在存活,哪些已經“死去”了,即不可能再被任何途徑使用的對象。 

引用計數算法(Reference Counting) 

  最初的想法,也是很多教科書判斷對象是否存活的算法是這樣的:給對象中添加一個引用計數器,當有一個地方引用它,計數器加1,當引用失效,計數器減1,任何時刻計數器爲0的對象就是不可能再被使用的。 

  客觀的說,引用計數算法實現簡單,判定效率很高,在大部分情況下它都是一個不錯的算法,但引用計數算法無法解決對象循環引用的問題。舉個簡單的例子:對象A和B分別有字段b、a,令A.b=B和B.a=A,除此之外這2個對象再無任何引用,那實際上這2個對象已經不可能再被訪問,但是引用計數算法卻無法回收他們。 

根搜索算法(GC Roots Tracing) 

  在實際生產的語言中(Java、C#、甚至包括前面提到的Lisp),都是使用根搜索算法判定對象是否存活。算法基本思路就是通過一系列的稱爲“GC Roots”的點作爲起始進行向下搜索,當一個對象到GC Roots沒有任何引用鏈(Reference Chain)相連,則證明此對象是不可用的。在Java語言中,GC Roots包括: 

  1.在VM棧(幀中的本地變量)中的引用 
  2.方法區中的靜態引用 
  3.JNI(即一般說的Native方法)中的引用 

生存還是死亡? 

  判定一個對象死亡,至少經歷兩次標記過程:如果對象在進行根搜索後,發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記,並在稍後執行他的finalize()方法(如果它有的話)。這裏所謂的“執行”是指虛擬機會觸發這個方法,但並不承諾會等待它運行結束。這點是必須的,否則一個對象在finalize()方法執行緩慢,甚至有死循環什麼的將會很容易導致整個系統崩潰。finalize()方法是對象最後一次逃脫死亡命運的機會,稍後GC將進行第二次規模稍小的標記,如果在finalize()中對象成功拯救自己(只要重新建立到GC Roots的連接即可,譬如把自己賦值到某個引用上),那在第二次標記時它將被移除出“即將回收”的集合,如果對象這時候還沒有逃脫,那基本上它就真的離死不遠了。 

  需要特別說明的是,這裏對finalize()方法的描述可能帶點悲情的藝術加工,並不代表筆者鼓勵大家去使用這個方法來拯救對象。相反,筆者建議大家儘量避免使用它,這個不是C/C++裏面的析構函數,它運行代價高昂,不確定性大,無法保證各個對象的調用順序。需要關閉外部資源之類的事情,基本上它能做的使用try-finally可以做的更好。 

關於方法區 

  方法區即後文提到的永久代,很多人認爲永久代是沒有GC的,《Java虛擬機規範》中確實說過可以不要求虛擬機在這區實現GC,而且這區GC的“性價比”一般比較低:在堆中,尤其是在新生代,常規應用進行一次GC可以一般可以回收70%~95%的空間,而永久代的GC效率遠小於此。雖然VM Spec不要求,但當前生產中的商業JVM都有實現永久代的GC,主要回收兩部分內容:廢棄常量與無用類。這兩點回收思想與Java堆中的對象回收很類似,都是搜索是否存在引用,常量的相對很簡單,與對象類似的判定即可。而類的回收則比較苛刻,需要滿足下面3個條件: 

  1.該類所有的實例都已經被GC,也就是JVM中不存在該Class的任何實例。 
  2.加載該類的ClassLoader已經被GC。 
  3.該類對應的java.lang.Class 對象沒有在任何地方被引用,如不能在任何地方通過反射訪問該類的方法。 

  是否對類進行回收可使用-XX:+ClassUnloading參數進行控制,還可以使用-verbose:class或者-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看類加載、卸載信息。 

  在大量使用反射、動態代理、CGLib等bytecode框架、動態生成JSP以及OSGi這類頻繁自定義ClassLoader的場景都需要JVM具備類卸載的支持以保證永久代不會溢出。 

垃圾收集算法 

  在這節裏不打算大量討論算法實現,只是簡單的介紹一下基本思想以及發展過程。最基礎的蒐集算法是“標記-清除算法”(Mark-Sweep),如它的名字一樣,算法分層“標記”和“清除”兩個階段,首先標記出所有需要回收的對象,然後回收所有需要回收的對象,整個過程其實前一節講對象標記判定的時候已經基本介紹完了。說它是最基礎的收集算法原因是後續的收集算法都是基於這種思路並優化其缺點得到的。它的主要缺點有兩個,一是效率問題,標記和清理兩個過程效率都不高,二是空間問題,標記清理之後會產生大量不連續的內存碎片,空間碎片太多可能會導致後續使用中無法找到足夠的連續內存而提前觸發另一次的垃圾蒐集動作。

  爲了解決效率問題,一種稱爲“複製”(Copying)的蒐集算法出現,它將可用內存劃分爲兩塊,每次只使用其中的一塊,當半區內存用完了,僅將還存活的對象複製到另外一塊上面,然後就把原來整塊內存空間一次過清理掉。這樣使得每次內存回收都是對整個半區的回收,內存分配時也就不用考慮內存碎片等複雜情況,只要移動堆頂指針,按順序分配內存就可以了,實現簡單,運行高效。只是這種算法的代價是將內存縮小爲原來的一半,未免太高了一點。 

  現在的商業虛擬機中都是用了這一種收集算法來回收新生代,IBM有專門研究表明新生代中的對象98%是朝生夕死的,所以並不需要按照1:1的比例來劃分內存空間,而是將內存分爲一塊較大的eden空間和2塊較少的survivor空間,每次使用eden和其中一塊survivor,當回收時將eden和survivor還存活的對象一次過拷貝到另外一塊survivor空間上,然後清理掉eden和用過的survivor。Sun Hotspot虛擬機默認eden和survivor的大小比例是8:1,也就是每次只有10%的內存是“浪費”的。當然,98%的對象可回收只是一般場景下的數據,我們沒有辦法保證每次回收都只有10%以內的對象存活,當survivor空間不夠用時,需要依賴其他內存(譬如老年代)進行分配擔保(Handle Promotion)。 

  複製收集算法在對象存活率高的時候,效率有所下降。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保用於應付半區內存中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用這種算法。因此人們提出另外一種“標記-整理”(Mark-Compact)算法,標記過程仍然一樣,但後續步驟不是進行直接清理,而是令所有存活的對象一端移動,然後直接清理掉這端邊界以外的內存。 

  當前商業虛擬機的垃圾收集都是採用“分代收集”(Generational Collecting)算法,這種算法並沒有什麼新的思想出現,只是根據對象不同的存活週期將內存劃分爲幾塊。一般是把Java堆分作新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集算法,譬如新生代每次GC都有大批對象死去,只有少量存活,那就選用複製算法只需要付出少量存活對象的複製成本就可以完成收集。 

垃圾收集器 

  垃圾收集器就是收集算法的具體實現,不同的虛擬機會提供不同的垃圾收集器。並且提供參數供用戶根據自己的應用特點和要求組合各個年代所使用的收集器。本文討論的收集器基於Sun Hotspot虛擬機1.6版。 

圖1.Sun JVM1.6的垃圾收集器 
 

  圖1展示了1.6中提供的6種作用於不同年代的收集器,兩個收集器之間存在連線的話就說明它們可以搭配使用。在介紹着些收集器之前,我們先明確一個觀點:沒有最好的收集器,也沒有萬能的收集器,只有最合適的收集器。 

1.Serial收集器 
  單線程收集器,收集時會暫停所有工作線程(我們將這件事情稱之爲Stop The World,下稱STW),使用複製收集算法,虛擬機運行在Client模式時的默認新生代收集器。 

2.ParNew收集器 
  ParNew收集器就是Serial的多線程版本,除了使用多條收集線程外,其餘行爲包括算法、STW、對象分配規則、回收策略等都與Serial收集器一摸一樣。對應的這種收集器是虛擬機運行在Server模式的默認新生代收集器,在單CPU的環境中,ParNew收集器並不會比Serial收集器有更好的效果。 

3.Parallel Scavenge收集器 
  Parallel Scavenge收集器(下稱PS收集器)也是一個多線程收集器,也是使用複製算法,但它的對象分配規則與回收策略都與ParNew收集器有所不同,它是以吞吐量最大化(即GC時間佔總運行時間最小)爲目標的收集器實現,它允許較長時間的STW換取總吞吐量最大化。 

4.Serial Old收集器 
  Serial Old是單線程收集器,使用標記-整理算法,是老年代的收集器,上面三種都是使用在新生代收集器。 

5.Parallel Old收集器 
  老年代版本吞吐量優先收集器,使用多線程和標記-整理算法,JVM 1.6提供,在此之前,新生代使用了PS收集器的話,老年代除Serial Old外別無選擇,因爲PS無法與CMS收集器配合工作。 

6.CMS(Concurrent MarkSweep)收集器 
  CMS是一種以最短停頓時間爲目標的收集器,使用CMS並不能達到GC效率最高(總體GC時間最小),但它能儘可能降低GC時服務的停頓時間,這一點對於實時或者高交互性應用(譬如證券交易)來說至關重要,這類應用對於長時間STW一般是不可容忍的。CMS收集器使用的是標記-清除算法,也就是說它在運行期間會產生空間碎片,所以虛擬機提供了參數開啓CMS收集結束後再進行一次內存壓縮。 
內存分配與回收策略 

  瞭解GC其中很重要一點就是了解JVM的內存分配策略:即對象在哪裏分配和對象什麼時候回收。 

  關於對象在哪裏分配,往大方向講,主要就在堆上分配,但也可能經過JIT進行逃逸分析後進行標量替換拆散爲原子類型在棧上分配,也可能分配在DirectMemory中(詳見本文第一章)。往細節處講,對象主要分配在新生代eden上,也可能會直接老年代中,分配的細節決定於當前使用的垃圾收集器類型與VM相關參數設置。我們可以通過下面代碼來驗證一下Serial收集器(ParNew收集器的規則與之完全一致)的內存分配和回收的策略。讀者看完Serial收集器的分析後,不妨自己根據JVM參數文檔寫一些程序去實踐一下其它幾種收集器的分配策略。 

清單1:內存分配測試代碼 

Java代碼 

1. public class YoungGenGC {  

2.   

3.     private static final int _1MB = 1024 * 1024;  

4.   

5.     public static void main(String[] args) {  

6.         // testAllocation();  

7.         testHandlePromotion();  

8.         // testPretenureSizeThreshold();  

9.         // testTenuringThreshold();  

10.        // testTenuringThreshold2();  

11.    }  

12.  

13.    /** 

14.     * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 

15.     */  

16.    @SuppressWarnings("unused")  

17.    public static void testAllocation() {  

18.        byte[] allocation1, allocation2, allocation3, allocation4;  

19.        allocation1 = new byte[2 * _1MB];  

20.        allocation2 = new byte[2 * _1MB];  

21.        allocation3 = new byte[2 * _1MB];  

22.        allocation4 = new byte[4 * _1MB];  // 出現一次Minor GC  

23.    }  

24.  

25.    /** 

26.     * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 

27.     * -XX:PretenureSizeThreshold=3145728 

28.     */  

29.    @SuppressWarnings("unused")  

30.    public static void testPretenureSizeThreshold() {  

31.        byte[] allocation;  

32.        allocation = new byte[4 * _1MB];  //直接分配在老年代中  

33.    }  

34.  

35.    /** 

36.     * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 

37.     * -XX:+PrintTenuringDistribution 

38.     */  

39.    @SuppressWarnings("unused")  

40.    public static void testTenuringThreshold() {  

41.        byte[] allocation1, allocation2, allocation3;  

42.        allocation1 = new byte[_1MB / 4];  // 什麼時候進入老年代決定於XX:MaxTenuringThreshold設置  

43.        allocation2 = new byte[4 * _1MB];  

44.        allocation3 = new byte[4 * _1MB];  

45.        allocation3 = null;  

46.        allocation3 = new byte[4 * _1MB];  

47.    }  

48.  

49.    /** 

50.     * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 

51.     * -XX:+PrintTenuringDistribution 

52.     */  

53.    @SuppressWarnings("unused")  

54.    public static void testTenuringThreshold2() {  

55.        byte[] allocation1, allocation2, allocation3, allocation4;  

56.        allocation1 = new byte[_1MB / 4];   // allocation1+allocation2大於survivo空間一半  

57.        allocation2 = new byte[_1MB / 4];    

58.        allocation3 = new byte[4 * _1MB];  

59.        allocation4 = new byte[4 * _1MB];  

60.        allocation4 = null;  

61.        allocation4 = new byte[4 * _1MB];  

62.    }  

63.  

64.    /** 

65.     * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-HandlePromotionFailure 

66.     */  

67.    @SuppressWarnings("unused")  

68.    public static void testHandlePromotion() {  

69.        byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7;  

70.        allocation1 = new byte[2 * _1MB];  

71.        allocation2 = new byte[2 * _1MB];  

72.        allocation3 = new byte[2 * _1MB];  

73.        allocation1 = null;  

74.        allocation4 = new byte[2 * _1MB];  

75.        allocation5 = new byte[2 * _1MB];  

76.        allocation6 = new byte[2 * _1MB];  

77.        allocation4 = null;  

78.        allocation5 = null;  

79.        allocation6 = null;  

80.        allocation7 = new byte[2 * _1MB];  

81.    }  

82.}  



規則一:通常情況下,對象在eden中分配。當eden無法分配時,觸發一次Minor GC。 

  執行testAllocation()方法後輸出了GC日誌以及內存分配狀況。-Xms20M -Xmx20M -Xmn10M這3個參數確定了Java堆大小爲20M,不可擴展,其中10M分配給新生代,剩下的10M即爲老年代。-XX:SurvivorRatio=8決定了新生代中eden與survivor的空間比例是1:8,從輸出的結果也清晰的看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代總可用空間爲9216K(eden+1個survivor)。 

  我們也注意到在執行testAllocation()時出現了一次Minor GC,GC的結果是新生代6651K變爲148K,而總佔用內存則幾乎沒有減少(因爲幾乎沒有可回收的對象)。這次GC是發生的原因是爲allocation4分配內存的時候,eden已經被佔用了6M,剩餘空間已不足分配allocation4所需的4M內存,因此發生Minor GC。GC期間虛擬機發現已有的3個2M大小的對象全部無法放入survivor空間(survivor空間只有1M大小),所以直接轉移到老年代去。GC後4M的allocation4對象分配在eden中。 

清單2:testAllocation()方法輸出結果 

[GC [DefNew: 6651K->148K(9216K), 0.0070106 secs]6651K->6292K(19456K), 0.0070426 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap 
def new generation   total 9216K, used 4326K[0x029d0000, 0x033d0000, 0x033d0000) 
  eden space 8192K,  51% used [0x029d0000,0x02de4828, 0x031d0000) 
  from space 1024K,  14% used [0x032d0000,0x032f5370, 0x033d0000) 
  to   space 1024K,   0% used[0x031d0000, 0x031d0000, 0x032d0000) 
tenured generation   total 10240K, used 6144K[0x033d0000, 0x03dd0000, 0x03dd0000) 
   the space 10240K,  60% used[0x033d0000, 0x039d0030, 0x039d0200, 0x03dd0000) 
compacting perm gen  total 12288K, used 2114K [0x03dd0000,0x049d0000, 0x07dd0000) 
   the space 12288K,  17% used[0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000) 
No shared spaces configured. 

規則二:配置了PretenureSizeThreshold的情況下,對象大於設置值將直接在老年代分配。 

  執行testPretenureSizeThreshold()方法後,我們看到eden空間幾乎沒有被使用,而老年代的10M控件被使用了40%,也就是4M的allocation對象直接就分配在老年代中,則是因爲PretenureSizeThreshold被設置爲3M,因此超過3M的對象都會直接從老年代分配。 

清單3: 

Heap 
def new generation   total 9216K, used 671K[0x029d0000, 0x033d0000, 0x033d0000) 
  eden space 8192K,   8% used [0x029d0000,0x02a77e98, 0x031d0000) 
  from space 1024K,   0% used [0x031d0000,0x031d0000, 0x032d0000) 
  to   space 1024K,   0% used[0x032d0000, 0x032d0000, 0x033d0000) 
tenured generation   total 10240K, used 4096K[0x033d0000, 0x03dd0000, 0x03dd0000) 
   the space 10240K,  40% used[0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000) 
compacting perm gen  total 12288K, used 2107K[0x03dd0000, 0x049d0000, 0x07dd0000) 
   the space 12288K,  17% used[0x03dd0000, 0x03fdefd0, 0x03fdf000, 0x049d0000) 
No shared spaces configured. 

規則三:在eden經過GC後存活,並且survivor能容納的對象,將移動到survivor空間內,如果對象在survivor中繼續熬過若干次回收(默認爲15次)將會被移動到老年代中。回收次數由MaxTenuringThreshold設置。 

  分別以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15兩種設置來執行testTenuringThreshold(),方法中allocation1對象需要256K內存,survivor空間可以容納。當MaxTenuringThreshold=1時,allocation1對象在第二次GC發生時進入老年代,新生代已使用的內存GC後非常乾淨的變成0KB。而MaxTenuringThreshold=15時,第二次GC發生後,allocation1對象則還留在新生代survivor空間,這時候新生代仍然有404KB被佔用。 

清單4: 
MaxTenuringThreshold=1 

[GC [DefNew 
Desired survivor size 524288 bytes, new threshold 1 (max1) 
- age   1:     414664bytes,     414664 total 
: 4859K->404K(9216K), 0.0065012 secs]4859K->4500K(19456K), 0.0065283 secs] [Times: user=0.02 sys=0.00, real=0.02secs] 
[GC [DefNew 
Desired survivor size 524288 bytes, new threshold 1 (max1) 
: 4500K->0K(9216K), 0.0009253 secs]8596K->4500K(19456K), 0.0009458 secs] [Times: user=0.00 sys=0.00, real=0.00secs] 
Heap 
def new generation   total 9216K, used 4178K[0x029d0000, 0x033d0000, 0x033d0000) 
  eden space 8192K,  51% used [0x029d0000,0x02de4828, 0x031d0000) 
  from space 1024K,   0% used [0x031d0000,0x031d0000, 0x032d0000) 
  to   space 1024K,   0% used[0x032d0000, 0x032d0000, 0x033d0000) 
tenured generation   total 10240K, used 4500K[0x033d0000, 0x03dd0000, 0x03dd0000) 
   the space 10240K,  43% used[0x033d0000, 0x03835348, 0x03835400, 0x03dd0000) 
compacting perm gen  total 12288K, used 2114K[0x03dd0000, 0x049d0000, 0x07dd0000) 
   the space 12288K,  17% used[0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000) 
No shared spaces configured. 

MaxTenuringThreshold=15 
[GC [DefNew 
Desired survivor size 524288 bytes, new threshold 15 (max15) 
- age   1:     414664bytes,     414664 total 
: 4859K->404K(9216K), 0.0049637 secs]4859K->4500K(19456K), 0.0049932 secs] [Times: user=0.00 sys=0.00, real=0.00secs] 
[GC [DefNew 
Desired survivor size 524288 bytes, new threshold 15 (max15) 
- age   2:     414520bytes,     414520 total 
: 4500K->404K(9216K), 0.0008091 secs]8596K->4500K(19456K), 0.0008305 secs] [Times: user=0.00 sys=0.00, real=0.00secs] 
Heap 
def new generation   total 9216K, used 4582K[0x029d0000, 0x033d0000, 0x033d0000) 
  eden space 8192K,  51% used [0x029d0000,0x02de4828, 0x031d0000) 
  from space 1024K,  39% used [0x031d0000,0x03235338, 0x032d0000) 
  to   space 1024K,   0% used [0x032d0000,0x032d0000, 0x033d0000) 
tenured generation   total 10240K, used 4096K[0x033d0000, 0x03dd0000, 0x03dd0000) 
   the space 10240K,  40% used[0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000) 
compacting perm gen  total 12288K, used 2114K[0x03dd0000, 0x049d0000, 0x07dd0000) 
   the space 12288K,  17% used[0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000) 
No shared spaces configured. 

規則四:如果在survivor空間中相同年齡所有對象大小的累計值大於survivor空間的一半,大於或等於個年齡的對象就可以直接進入老年代,無需達到MaxTenuringThreshold中要求的年齡。 

  執行testTenuringThreshold2()方法,並將設置-XX:MaxTenuringThreshold=15,發現運行結果中survivor佔用仍然爲0%,而老年代比預期增加了6%,也就是說allocation1、allocation2對象都直接進入了老年代,而沒有等待到15歲的臨界年齡。因爲這2個對象加起來已經到達了512K,並且它們是同年的,滿足同年對象達到survivor空間的一半規則。我們只要註釋掉其中一個對象new操作,就會發現另外一個就不會晉升到老年代中去了。 

清單5: 
[GC [DefNew 
Desired survivor size 524288 bytes, new threshold 1 (max15) 
- age   1:     676824bytes,     676824 total 
: 5115K->660K(9216K), 0.0050136 secs]5115K->4756K(19456K), 0.0050443 secs] [Times: user=0.00 sys=0.01, real=0.01secs] 
[GC [DefNew 
Desired survivor size 524288 bytes, new threshold 15 (max15) 
: 4756K->0K(9216K), 0.0010571 secs]8852K->4756K(19456K), 0.0011009 secs] [Times: user=0.00 sys=0.00, real=0.00secs] 
Heap 
def new generation   total 9216K, used 4178K[0x029d0000, 0x033d0000, 0x033d0000) 
  eden space 8192K,  51% used [0x029d0000,0x02de4828, 0x031d0000) 
  from space 1024K,   0% used [0x031d0000,0x031d0000, 0x032d0000) 
  to   space 1024K,   0% used[0x032d0000, 0x032d0000, 0x033d0000) 
tenured generation   total 10240K, used 4756K [0x033d0000,0x03dd0000, 0x03dd0000) 
   the space 10240K,  46% used[0x033d0000, 0x038753e8, 0x03875400, 0x03dd0000) 
compacting perm gen  total 12288K, used 2114K[0x03dd0000, 0x049d0000, 0x07dd0000) 
   the space 12288K,  17% used[0x03dd0000, 0x03fe09a0, 0x03fe0a00, 0x049d0000) 
No shared spaces configured. 

規則五:在Minor GC觸發時,會檢測之前每次晉升到老年代的平均大小是否大於老年代的剩餘空間,如果大於,改爲直接進行一次Full GC,如果小於則查看HandlePromotionFailure設置看看是否允許擔保失敗,如果允許,那仍然進行Minor GC,如果不允許,則也要改爲進行一次Full GC。 

  前面提到過,新生代纔有複製收集算法,但爲了內存利用率,只使用其中一個survivor空間來作爲輪換備份,因此當出現大量對象在GC後仍然存活的情況(最極端就是GC後所有對象都存活),就需要老年代進行分配擔保,把survivor無法容納的對象直接放入老年代。與生活中貸款擔保類似,老年代要進行這樣的擔保,前提就是老年代本身還有容納這些對象的剩餘空間,一共有多少對象在GC之前是無法明確知道的,所以取之前每一次GC晉升到老年代對象容量的平均值與老年代的剩餘空間進行比較決定是否進行Full GC來讓老年代騰出更多空間。 

  取平均值進行比較其實仍然是一種動態概率的手段,也就是說如果某次Minor GC存活後的對象突增,大大高於平均值的話,依然會導致擔保失敗,這樣就只好在失敗後重新進行一次Full GC。雖然擔保失敗時做的繞的圈子是最大的,但大部分情況下都還是會將HandlePromotionFailure打開,避免Full GC過於頻繁。 

清單6: 
HandlePromotionFailure = false 

[GC [DefNew: 6651K->148K(9216K), 0.0078936 secs]6651K->4244K(19456K), 0.0079192 secs] [Times: user=0.00 sys=0.02, real=0.02secs] 
[GC [DefNew: 6378K->6378K(9216K), 0.0000206secs][Tenured: 4096K->4244K(10240K), 0.0042901 secs]10474K->4244K(19456K), [Perm : 2104K->2104K(12288K)], 0.0043613 secs][Times: user=0.00 sys=0.00, real=0.00 secs] 

HandlePromotionFailure = true 

[GC [DefNew: 6651K->148K(9216K), 0.0054913 secs]6651K->4244K(19456K), 0.0055327 secs] [Times: user=0.00 sys=0.00, real=0.00secs] 
[GC [DefNew: 6378K->148K(9216K), 0.0006584 secs]10474K->4244K(19456K), 0.0006857 secs] [Times: user=0.00 sys=0.00, real=0.00secs] 

總結 

  本章介紹了垃圾收集的算法、6款主要的垃圾收集器,以及通過代碼實例具體介紹了新生代串行收集器對內存分配及回收的影響。 

  GC在很多時候都是系統併發度的決定性因素,虛擬機之所以提供多種不同的收集器,提供大量的調節參數,是因爲只有根據實際應用需求、實現方式選擇最優的收集方式才能獲取最好的性能。沒有固定收集器、參數組合,也沒有最優的調優方法,虛擬機也沒有什麼必然的行爲。筆者看過一些文章,撇開具體場景去談論老年代達到92%會觸發Full GC(92%應當來自CMS收集器觸發的默認臨界點)、98%時間在進行垃圾收集系統會拋出OOM異常(98%應該來自parallel收集器收集時間比率的默認臨界點)其實意義並不太大。因此學習GC如果要到實踐調優階段,必須瞭解每個具體收集器的行爲、優勢劣勢、調節參數。 

 

數據類型

   Java虛擬機中,數據類型可以分爲兩類:基本類型引用類型。基本類型的變量保存原始值,即:他代表的值就是數值本身;而引用類型的變量保存引用值。“引用值”代表了某個對象的引用,而不是對象本身,對象本身存放在這個引用值所表示的地址的位置。

基本類型包括:byte,short,int,long,char,float,double,Boolean,returnAddress

引用類型包括:類類型接口類型數組

堆與棧

  

堆和棧是程序運行的關鍵,很有必要把他們的關係說清楚。

 

   

    棧是運行時的單位,而堆是存儲的單位

   棧解決程序的運行問題,即程序如何執行,或者說如何處理數據;堆解決的是數據存儲的問題,即數據怎麼放、放在哪兒。

   在Java中一個線程就會相應有一個線程棧與之對應,這點很容易理解,因爲不同的線程執行邏輯有所不同,因此需要一個獨立的線程棧。而堆則是所有線程共享的。棧因爲是運行單位,因此裏面存儲的信息都是跟當前線程(或程序)相關信息的。包括局部變量、程序運行狀態、方法返回值等等;而堆只負責存儲對象信息。

    爲什麼要把堆和棧區分出來呢?棧中不是也可以存儲數據嗎

   第一,從軟件設計的角度看,棧代表了處理邏輯,而堆代表了數據。這樣分開,使得處理邏輯更爲清晰。分而治之的思想。這種隔離、模塊化的思想在軟件設計的方方面面都有體現。

   第二,堆與棧的分離,使得堆中的內容可以被多個棧共享(也可以理解爲多個線程訪問同一個對象)。這種共享的收益是很多的。一方面這種共享提供了一種有效的數據交互方式(如:共享內存),另一方面,堆中的共享常量和緩存可以被所有棧訪問,節省了空間。

   第三,棧因爲運行時的需要,比如保存系統運行的上下文,需要進行地址段的劃分。由於棧只能向上增長,因此就會限制住棧存儲內容的能力。而堆不同,堆中的對象是可以根據需要動態增長的,因此棧和堆的拆分,使得動態增長成爲可能,相應棧中只需記錄堆中的一個地址即可。

   第四,面向對象就是堆和棧的完美結合。其實,面向對象方式的程序與以前結構化的程序在執行上沒有任何區別。但是,面向對象的引入,使得對待問題的思考方式發生了改變,而更接近於自然方式的思考。當我們把對象拆開,你會發現,對象的屬性其實就是數據,存放在堆中;而對象的行爲(方法),就是運行邏輯,放在棧中。我們在編寫對象的時候,其實即編寫了數據結構,也編寫的處理數據的邏輯。不得不承認,面向對象的設計,確實很美。

    Java中,Main函數就是棧的起始點,也是程序的起始點

   程序要運行總是有一個起點的。同C語言一樣,java中的Main就是那個起點。無論什麼java程序,找到main就找到了程序執行的入口:)

    堆中存什麼?棧中存什麼

   堆中存的是對象。棧中存的是基本數據類型堆中對象的引用。一個對象的大小是不可估計的,或者說是可以動態變化的,但是在棧中,一個對象只對應了一個4btye的引用(堆棧分離的好處:))。

   爲什麼不把基本類型放堆中呢?因爲其佔用的空間一般是1~8個字節——需要空間比較少,而且因爲是基本類型,所以不會出現動態增長的情況——長度固定,因此棧中存儲就夠了,如果把他存在堆中是沒有什麼意義的(還會浪費空間,後面說明)。可以這麼說,基本類型和對象的引用都是存放在棧中,而且都是幾個字節的一個數,因此在程序運行時,他們的處理方式是統一的。但是基本類型、對象引用和對象本身就有所區別了,因爲一個是棧中的數據一個是堆中的數據。最常見的一個問題就是,Java中參數傳遞時的問題。

    Java中的參數傳遞時傳值呢?還是傳引用

   要說明這個問題,先要明確兩點:

        1. 不要試圖與C進行類比,Java中沒有指針的概念

        2. 程序運行永遠都是在棧中進行的,因而參數傳遞時,只存在傳遞基本類型和對象引用的問題。不會直接傳對象本身。

   明確以上兩點後。Java在方法調用傳遞參數時,因爲沒有指針,所以它都是進行傳值調用(這點可以參考C的傳值調用)。因此,很多書裏面都說Java是進行傳值調用,這點沒有問題,而且也簡化的C中複雜性。

    但是傳引用的錯覺是如何造成的呢?在運行棧中,基本類型和引用的處理是一樣的,都是傳值,所以,如果是傳引用的方法調用,也同時可以理解爲“傳引用值”的傳值調用,即引用的處理跟基本類型是完全一樣的。但是當進入被調用方法時,被傳遞的這個引用的值,被程序解釋(或者查找)到堆中的對象,這個時候纔對應到真正的對象。如果此時進行修改,修改的是引用對應的對象,而不是引用本身,即:修改的是堆中的數據。所以這個修改是可以保持的了。

   對象,從某種意義上說,是由基本類型組成的。可以把一個對象看作爲一棵樹,對象的屬性如果還是對象,則還是一顆樹(即非葉子節點),基本類型則爲樹的葉子節點。程序參數傳遞時,被傳遞的值本身都是不能進行修改的,但是,如果這個值是一個非葉子節點(即一個對象引用),則可以修改這個節點下面的所有內容。

 

   堆和棧中,棧是程序運行最根本的東西。程序運行可以沒有堆,但是不能沒有棧。而堆是爲棧進行數據存儲服務,說白了堆就是一塊共享的內存。不過,正是因爲堆和棧的分離的思想,才使得Java的垃圾回收成爲可能。

    Java中,棧的大小通過-Xss來設置,當棧中存儲數據比較多時,需要適當調大這個值,否則會出現java.lang.StackOverflowError異常。常見的出現這個異常的是無法返回的遞歸,因爲此時棧中保存的信息都是方法返回的記錄點。

Java對象的大小

   基本數據的類型的大小是固定的,這裏就不多說了。對於非基本類型的Java對象,其大小就值得商榷。

   在Java中,一個空Object對象的大小是8byte,這個大小隻是保存堆中一個沒有任何屬性的對象的大小。看下面語句:

Objectob = new Object();

   這樣在程序中完成了一個Java對象的生命,但是它所佔的空間爲:4byte+8byte。4byte是上面部分所說的Java棧中保存引用的所需要的空間。而那8byte則是Java堆中對象的信息。因爲所有的Java非基本類型的對象都需要默認繼承Object對象,因此不論什麼樣的Java對象,其大小都必須是大於8byte。

  有了Object對象的大小,我們就可以計算其他對象的大小了。

ClassNewObject {

   int count;

   boolean flag;

   Object ob;

}

   其大小爲:空對象大小(8byte)+int大小(4byte)+Boolean大小(1byte)+空Object引用的大小(4byte)=17byte。但是因爲Java在對對象內存分配時都是以8的整數倍來分,因此大於17byte的最接近8的整數倍的是24,因此此對象的大小爲24byte。

   這裏需要注意一下基本類型的包裝類型的大小。因爲這種包裝類型已經成爲對象了,因此需要把他們作爲對象來看待。包裝類型的大小至少是12byte(聲明一個空Object至少需要的空間),而且12byte沒有包含任何有效信息,同時,因爲Java對象大小是8的整數倍,因此一個基本類型包裝類的大小至少是16byte。這個內存佔用是很恐怖的,它是使用基本類型的N倍(N>2),有些類型的內存佔用更是誇張(隨便想下就知道了)。因此,可能的話應儘量少使用包裝類。在JDK5.0以後,因爲加入了自動類型裝換,因此,Java虛擬機會在存儲方面進行相應的優化。

引用類型

   對象引用類型分爲強引用、軟引用、弱引用和虛引用

 

強引用:就是我們一般聲明對象是時虛擬機生成的引用,強引用環境下,垃圾回收時需要嚴格判斷當前對象是否被強引用,如果被強引用,則不會被垃圾回收

 

軟引用:軟引用一般被做爲緩存來使用。與強引用的區別是,軟引用在垃圾回收時,虛擬機會根據當前系統的剩餘內存來決定是否對軟引用進行回收。如果剩餘內存比較緊張,則虛擬機會回收軟引用所引用的空間;如果剩餘內存相對富裕,則不會進行回收。換句話說,虛擬機在發生OutOfMemory時,肯定是沒有軟引用存在的。

 

弱引用:弱引用與軟引用類似,都是作爲緩存來使用。但與軟引用不同,弱引用在進行垃圾回收時,是一定會被回收掉的,因此其生命週期只存在於一個垃圾回收週期內。

 

   強引用不用說,我們系統一般在使用時都是用的強引用。而“軟引用”和“弱引用”比較少見。他們一般被作爲緩存使用,而且一般是在內存大小比較受限的情況下做爲緩存。因爲如果內存足夠大的話,可以直接使用強引用作爲緩存即可,同時可控性更高。因而,他們常見的是被使用在桌面應用系統的緩存。

可以從不同的的角度去劃分垃圾回收算法:

按照基本回收策略分

引用計數(ReferenceCounting:

比較古老的回收算法。原理是此對象有一個引用,即增加一個計數,刪除一個引用則減少一個計數。垃圾回收時,只用收集計數爲0的對象。此算法最致命的是無法處理循環引用的問題。

 

標記-清除(Mark-Sweep:

 

 

此算法執行分兩階段。第一階段從引用根節點開始標記所有被引用的對象,第二階段遍歷整個堆,把未標記的對象清除。此算法需要暫停整個應用,同時,會產生內存碎片。

 

複製(Copying:

 

 

此算法把內存空間劃爲兩個相等的區域,每次只使用其中一個區域。垃圾回收時,遍歷當前使用區域,把正在使用中的對象複製到另外一個區域中。次算法每次只處理正在使用中的對象,因此複製成本比較小,同時複製過去以後還能進行相應的內存整理,不會出現“碎片”問題。當然,此算法的缺點也是很明顯的,就是需要兩倍內存空間。

 

標記-整理(Mark-Compact:

 

 

此算法結合了“標記-清除”和“複製”兩個算法的優點。也是分兩階段,第一階段從根節點開始標記所有被引用對象,第二階段遍歷整個堆,把清除未標記對象並且把存活對象“壓縮”到堆的其中一塊,按順序排放。此算法避免了“標記-清除”的碎片問題,同時也避免了“複製”算法的空間問題。

按分區對待的方式分

增量收集(IncrementalCollecting:實時垃圾回收算法,即:在應用進行的同時進行垃圾回收。不知道什麼原因JDK5.0中的收集器沒有使用這種算法的。

 

分代收集(GenerationalCollecting:基於對對象生命週期分析後得出的垃圾回收算法。把對象分爲年青代、年老代、持久代,對不同生命週期的對象使用不同的算法(上述方式中的一個)進行回收。現在的垃圾回收器(從J2SE1.2開始)都是使用此算法的。

 

按系統線程分

串行收集:串行收集使用單線程處理所有垃圾回收工作,因爲無需多線程交互,實現容易,而且效率比較高。但是,其侷限性也比較明顯,即無法使用多處理器的優勢,所以此收集適合單處理器機器。當然,此收集器也可以用在小數據量(100M左右)情況下的多處理器機器上。

 

並行收集:並行收集使用多線程處理垃圾回收工作,因而速度快,效率高。而且理論上CPU數目越多,越能體現出並行收集器的優勢。

 

併發收集:相對於串行收集和並行收集而言,前面兩個在進行垃圾回收工作時,需要暫停整個運行環境,而只有垃圾回收程序在運行,因此,系統在垃圾回收時會有明顯的暫停,而且暫停時間會因爲堆越大而越長。

如何區分垃圾

 

   上面說到的“引用計數”法,通過統計控制生成對象和刪除對象時的引用數來判斷。垃圾回收程序收集計數爲0的對象即可。但是這種方法無法解決循環引用。所以,後來實現的垃圾判斷算法中,都是從程序運行的根節點出發,遍歷整個對象引用,查找存活的對象。那麼在這種方式的實現中,垃圾回收從哪兒開始的呢?即,從哪兒開始查找哪些對象是正在被當前系統使用的。上面分析的堆和棧的區別,其中棧是真正進行程序執行地方,所以要獲取哪些對象正在被使用,則需要從Java棧開始。同時,一個棧是與一個線程對應的,因此,如果有多個線程的話,則必須對這些線程對應的所有的棧進行檢查。

   同時,除了棧外,還有系統運行時的寄存器等,也是存儲程序運行數據的。這樣,以棧或寄存器中的引用爲起點,我們可以找到堆中的對象,又從這些對象找到對堆中其他對象的引用,這種引用逐步擴展,最終以null引用或者基本類型結束,這樣就形成了一顆以Java棧中引用所對應的對象爲根節點的一顆對象樹,如果棧中有多個引用,則最終會形成多顆對象樹。在這些對象樹上的對象,都是當前系統運行所需要的對象,不能被垃圾回收。而其他剩餘對象,則可以視爲無法被引用到的對象,可以被當做垃圾進行回收。

因此,垃圾回收的起點是一些根對象(java棧, 靜態變量, 寄存器...)。而最簡單的Java棧就是Java程序執行的main函數。這種回收方式,也是上面提到的“標記-清除”的回收方式

 

 

如何處理碎片

  由於不同Java對象存活時間是不一定的,因此,在程序運行一段時間以後,如果不進行內存整理,就會出現零散的內存碎片。碎片最直接的問題就是會導致無法分配大塊的內存空間,以及程序運行效率降低。所以,在上面提到的基本垃圾回收算法中,“複製”方式和“標記-整理”方式,都可以解決碎片的問題。

 

 

如何解決同時存在的對象創建和對象回收問題

   垃圾回收線程是回收內存的,而程序運行線程則是消耗(或分配)內存的,一個回收內存,一個分配內存,從這點看,兩者是矛盾的。因此,在現有的垃圾回收方式中,要進行垃圾回收前,一般都需要暫停整個應用(即:暫停內存的分配),然後進行垃圾回收,回收完成後再繼續應用。這種實現方式是最直接,而且最有效的解決二者矛盾的方式。

但是這種方式有一個很明顯的弊端,就是當堆空間持續增大時,垃圾回收的時間也將會相應的持續增大,對應應用暫停的時間也會相應的增大。一些對相應時間要求很高的應用,比如最大暫停時間要求是幾百毫秒,那麼當堆空間大於幾個G時,就很有可能超過這個限制,在這種情況下,垃圾回收將會成爲系統運行的一個瓶頸。爲解決這種矛盾,有了併發垃圾回收算法,使用這種算法,垃圾回收線程與程序運行線程同時運行。在這種方式下,解決了暫停的問題,但是因爲需要在新生成對象的同時又要回收對象,算法複雜性會大大增加,系統的處理能力也會相應降低,同時,“碎片”問題將會比較難解決。

爲什麼要分代

   分代的垃圾回收策略,是基於這樣一個事實:不同的對象的生命週期是不一樣的。因此,不同生命週期的對象可以採取不同的收集方式,以便提高回收效率。

 

   在Java程序運行的過程中,會產生大量的對象,其中有些對象是與業務信息相關,比如Http請求中的Session對象、線程、Socket連接,這類對象跟業務直接掛鉤,因此生命週期比較長。但是還有一些對象,主要是程序運行過程中生成的臨時變量,這些對象生命週期會比較短,比如:String對象,由於其不變類的特性,系統會產生大量的這些對象,有些對象甚至只用一次即可回收。

 

   試想,在不進行對象存活時間區分的情況下,每次垃圾回收都是對整個堆空間進行回收,花費時間相對會長,同時,因爲每次回收都需要遍歷所有存活對象,但實際上,對於生命週期長的對象而言,這種遍歷是沒有效果的,因爲可能進行了很多次遍歷,但是他們依舊存在。因此,分代垃圾回收採用分治的思想,進行代的劃分,把不同生命週期的對象放在不同代上,不同代上採用最適合它的垃圾回收方式進行回收。

 

如何分代

 

如圖所示:

 

   虛擬機中的共劃分爲三個代:年輕代(YoungGeneration)、年老點(Old Generation)和持久代(PermanentGeneration)。其中持久代主要存放的是Java類的類信息,與垃圾收集要收集的Java對象關係不大。年輕代和年老代的劃分是對垃圾收集影響比較大的。

 

 

年輕代:

   所有新生成的對象首先都是放在年輕代的。年輕代的目標就是儘可能快速的收集掉那些生命週期短的對象。年輕代分三個區。一個Eden區,兩個Survivor區(一般而言)。大部分對象在Eden區中生成。當Eden區滿時,還存活的對象將被複制到Survivor區(兩個中的一個),當這個Survivor區滿時,此區的存活對象將被複制到另外一個Survivor區,當這個Survivor去也滿了的時候,從第一個Survivor區複製過來的並且此時還存活的對象,將被複制“年老區(Tenured)”。需要注意,Survivor的兩個區是對稱的,沒先後關係,所以同一個區中可能同時存在從Eden複製過來 對象,和從前一個Survivor複製過來的對象,而複製到年老區的只有從第一個Survivor去過來的對象。而且,Survivor區總有一個是空的。同時,根據程序需要,Survivor區是可以配置爲多個的(多於兩個),這樣可以增加對象在年輕代中的存在時間,減少被放到年老代的可能。

 

年老代:

   在年輕代中經歷了N次垃圾回收後仍然存活的對象,就會被放到年老代中。因此,可以認爲年老代中存放的都是一些生命週期較長的對象。

 

持久代:

   用於存放靜態文件,如今Java類、方法等。持久代對垃圾回收沒有顯著影響,但是有些應用可能動態生成或者調用一些class,例如Hibernate等,在這種時候需要設置一個比較大的持久代空間來存放這些運行過程中新增的類。持久代大小通過-XX:MaxPermSize=<N>進行設置。

 

什麼情況下觸發垃圾回收

   由於對象進行了分代處理,因此垃圾回收區域、時間也不一樣。GC有兩種類型:Scavenge GCFull GC

 

Scavenge GC

   一般情況下,當新對象生成,並且在Eden申請空間失敗時,就會觸發ScavengeGC,對Eden區域進行GC,清除非存活對象,並且把尚且存活的對象移動到Survivor區。然後整理Survivor的兩個區。這種方式的GC是對年輕代的Eden區進行,不會影響到年老代。因爲大部分對象都是從Eden區開始的,同時Eden區不會分配的很大,所以Eden區的GC會頻繁進行。因而,一般在這裏需要使用速度快、效率高的算法,使Eden去能儘快空閒出來。

 

Full GC

   對整個堆進行整理,包括Young、Tenured和Perm。Full GC因爲需要對整個對進行回收,所以比ScavengeGC要慢,因此應該儘可能減少FullGC的次數。在對JVM調優的過程中,很大一部分工作就是對於FullGC的調節。有如下原因可能導致Full GC:

·年老代(Tenured)被寫滿

·持久代(Perm)被寫滿 

·System.gc()被顯示調用 

·上一次GC之後Heap的各域分配策略動態變化

分代垃圾回收流程示意

 

 

選擇合適的垃圾收集算法

串行收集器

 

用單線程處理所有垃圾回收工作,因爲無需多線程交互,所以效率比較高。但是,也無法使用多處理器的優勢,所以此收集器適合單處理器機器。當然,此收集器也可以用在小數據量(100M左右)情況下的多處理器機器上。可以使用-XX:+UseSerialGC打開。

 

 

 

並行收集器

 

 

對年輕代進行並行垃圾回收,因此可以減少垃圾回收時間。一般在多線程多處理器機器上使用。使用-XX:+UseParallelGC.打開。並行收集器在J2SE5.0第六6更新上引入,在Java SE6.0中進行了增強--可以對年老代進行並行收集。如果年老代不使用併發收集的話,默認是使用單線程進行垃圾回收,因此會制約擴展能力。使用-XX:+UseParallelOldGC打開。

使用-XX:ParallelGCThreads=<N>設置並行垃圾回收的線程數。此值可以設置與機器處理器數量相等。

此收集器可以進行如下配置:

最大垃圾回收暫停:指定垃圾回收時的最長暫停時間,通過-XX:MaxGCPauseMillis=<N>指定。<N>爲毫秒.如果指定了此值的話,堆大小和垃圾回收相關參數會進行調整以達到指定值。設定此值可能會減少應用的吞吐量。

吞吐量:吞吐量爲垃圾回收時間與非垃圾回收時間的比值,通過-XX:GCTimeRatio=<N>來設定,公式爲1/(1+N)。例如,-XX:GCTimeRatio=19時,表示5%的時間用於垃圾回收。默認情況爲99,即1%的時間用於垃圾回收。

 

 

 

併發收集器

可以保證大部分工作都併發進行(應用不停止),垃圾回收只暫停很少的時間,此收集器適合對響應時間要求比較高的中、大規模應用。使用-XX:+UseConcMarkSweepGC打開。

   併發收集器主要減少年老代的暫停時間,他在應用不停止的情況下使用獨立的垃圾回收線程,跟蹤可達對象。在每個年老代垃圾回收週期中,在收集初期併發收集器 會對整個應用進行簡短的暫停,在收集中還會再暫停一次。第二次暫停會比第一次稍長,在此過程中多個線程同時進行垃圾回收工作。

   併發收集器使用處理器換來短暫的停頓時間。在一個N個處理器的系統上,併發收集部分使用K/N個可用處理器進行回收,一般情況下1<=K<=N/4。

   在只有一個處理器的主機上使用併發收集器,設置爲incrementalmode模式也可獲得較短的停頓時間。

 

    浮動垃圾:由於在應用運行的同時進行垃圾回收,所以有些垃圾可能在垃圾回收進行完成時產生,這樣就造成了“FloatingGarbage”,這些垃圾需要在下次垃圾回收週期時才能回收掉。所以,併發收集器一般需要20%的預留空間用於這些浮動垃圾。

 

    Concurrent Mode Failure:併發收集器在應用運行時進行收集,所以需要保證堆在垃圾回收的這段時間有足夠的空間供程序使用,否則,垃圾回收還未完成,堆空間先滿了。這種情況下將會發生“併發模式失敗”,此時整個應用將會暫停,進行垃圾回收。

 

    啓動併發收集器:因爲併發收集在應用運行時進行收集,所以必須保證收集完成之前有足夠的內存空間供程序使用,否則會出現“ConcurrentMode Failure”。通過設置-XX:CMSInitiatingOccupancyFraction=<N>指定還有多少剩餘堆時開始執行併發收集

 

 

小結

串行處理器:

--適用情況:數據量比較小(100M左右);單處理器下並且對響應時間無要求的應用。 
--缺點:只能用於小型應用

 

並行處理器:

--適用情況:“對吞吐量有高要求”,多CPU、對應用響應時間無要求的中、大型應用。舉例:後臺處理、科學計算。 
--缺點:垃圾收集過程中應用響應時間可能加長

 

併發處理器:

--適用情況:“對響應時間有高要求”,多CPU、對應用響應時間有較高要求的中、大型應用。舉例:Web服務器/應用服務器、電信交換、集成開發環境。

 

以下配置主要針對分代垃圾回收算法而言。

 

堆大小設置

年輕代的設置很關鍵

JVM中最大堆大小有三方面限制:相關操作系統的數據模型(32-bt還是64-bit)限制;系統的可用虛擬內存限制;系統的可用物理內存限制。32位系統下,一般限制在1.5G~2G;64爲操作系統對內存無限制。在WindowsServer 2003 系統,3.5G物理內存,JDK5.0下測試,最大可設置爲1478m。

典型設置:

java -Xmx3550m -Xms3550m -Xmn2g –Xss128k

-Xmx3550m:設置JVM最大可用內存爲3550M。

-Xms3550m:設置JVM促使內存爲3550m。此值可以設置與-Xmx相同,以避免每次垃圾回收完成後JVM重新分配內存。

-Xmn2g:設置年輕代大小爲2G。整個堆大小=年輕代大小 + 年老代大小 + 持久代大小。持久代一般固定大小爲64m,所以增大年輕代後,將會減小年老代大小。此值對系統性能影響較大,Sun官方推薦配置爲整個堆的3/8。

-Xss128k:設置每個線程的堆棧大小。JDK5.0以後每個線程堆棧大小爲1M,以前每個線程堆棧大小爲256K。更具應用的線程所需內存大小進行調整。在相同物理內存下,減小這個值能生成更多的線程。但是操作系統對一個進程內的線程數還是有限制的,不能無限生成,經驗值在3000~5000左右。

 

java-Xmx3550m -Xms3550m -Xss128k -XX:NewRatio=4-XX:SurvivorRatio=4 -XX:MaxPermSize=16m -XX:MaxTenuringThreshold=0

-XX:NewRatio=4:設置年輕代(包括Eden和兩個Survivor區)與年老代的比值(除去持久代)。設置爲4,則年輕代與年老代所佔比值爲1:4,年輕代佔整個堆棧的1/5

-XX:SurvivorRatio=4:設置年輕代中Eden區與Survivor區的大小比值。設置爲4,則兩個Survivor區與一個Eden區的比值爲2:4,一個Survivor區佔整個年輕代的1/6

-XX:MaxPermSize=16m:設置持久代大小爲16m。

-XX:MaxTenuringThreshold=0:設置垃圾最大年齡。如果設置爲0的話,則年輕代對象不經過Survivor區,直接進入年老代。對於年老代比較多的應用,可以提高效率。如果將此值設置爲一個較大值,則年輕代對象會在Survivor區進行多次複製,這樣可以增加對象再年輕代的存活時間,增加在年輕代即被回收的概論。

 

回收器選擇

JVM給了三種選擇:串行收集器、並行收集器、併發收集器,但是串行收集器只適用於小數據量的情況,所以這裏的選擇主要針對並行收集器和併發收集器。默認情況下,JDK5.0以前都是使用串行收集器,如果想使用其他收集器需要在啓動時加入相應參數。JDK5.0以後,JVM會根據當前系統配置進行判斷。

吞吐量優先的並行收集器

如上文所述,並行收集器主要以到達一定的吞吐量爲目標,適用於科學技術和後臺處理等。

典型配置:

java-Xmx3800m -Xms3800m -Xmn2g -Xss128k -XX:+UseParallelGC-XX:ParallelGCThreads=20

-XX:+UseParallelGC:選擇垃圾收集器爲並行收集器。此配置僅對年輕代有效。即上述配置下,年輕代使用併發收集,而年老代仍舊使用串行收集。

-XX:ParallelGCThreads=20:配置並行收集器的線程數,即:同時多少個線程一起進行垃圾回收。此值最好配置與處理器數目相等。

java-Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseParallelGC -XX:ParallelGCThreads=20 -XX:+UseParallelOldGC

-XX:+UseParallelOldGC:配置年老代垃圾收集方式爲並行收集。JDK6.0支持對年老代並行收集。

java-Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseParallelGC  -XX:MaxGCPauseMillis=100

-XX:MaxGCPauseMillis=100:設置每次年輕代垃圾回收的最長時間,如果無法滿足此時間,JVM會自動調整年輕代大小,以滿足此值。

njava -Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseParallelGC -XX:MaxGCPauseMillis=100 -XX:+UseAdaptiveSizePolicy

-XX:+UseAdaptiveSizePolicy:設置此選項後,並行收集器會自動選擇年輕代區大小和相應的Survivor區比例,以達到目標系統規定的最低相應時間或者收集頻率等,此值建議使用並行收集器時,一直打開。

 

響應時間優先的併發收集器

如上文所述,併發收集器主要是保證系統的響應時間,減少垃圾收集時的停頓時間。適用於應用服務器、電信領域等。

典型配置:

java-Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC

-XX:+UseConcMarkSweepGC:設置年老代爲併發收集。測試中配置這個以後,-XX:NewRatio=4的配置失效了,原因不明。所以,此時年輕代大小最好用-Xmn設置。

-XX:+UseParNewGC: 設置年輕代爲並行收集。可與CMS收集同時使用。JDK5.0以上,JVM會根據系統配置自行設置,所以無需再設置此值。

java-Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseConcMarkSweepGC -XX:CMSFullGCsBeforeCompaction=5-XX:+UseCMSCompactAtFullCollection

-XX:CMSFullGCsBeforeCompaction:由於併發收集器不對內存空間進行壓縮、整理,所以運行一段時間以後會產生“碎片”,使得運行效率降低。此值設置運行多少次GC以後對內存空間進行壓縮、整理。

-XX:+UseCMSCompactAtFullCollection:打開對年老代的壓縮。可能會影響性能,但是可以消除碎片

 

輔助信息

JVM提供了大量命令行參數,打印信息,供調試使用。主要有以下一些:

-XX:+PrintGC輸出形式:[GC 118250K->113543K(130112K),0.0094143 secs] [Full GC 121376K->10414K(130112K), 0.0650971 secs]

-XX:+PrintGCDetails輸出形式:[GC [DefNew:8614K->781K(9088K), 0.0123035 secs] 118250K->113543K(130112K), 0.0124633secs] [GC [DefNew: 8614K->8614K(9088K), 0.0000665 secs][Tenured:112761K->10414K(121024K), 0.0433488 secs] 121376K->10414K(130112K),0.0436268 secs]

-XX:+PrintGCTimeStamps -XX:+PrintGCPrintGCTimeStamps可與上面兩個混合使用 
輸出形式:11.851:[GC 98328K->93620K(130112K), 0.0082960 secs]

-XX:+PrintGCApplicationConcurrentTime打印每次垃圾回收前,程序未中斷的執行時間。可與上面混合使用。輸出形式:Applicationtime: 0.5291524 seconds

-XX:+PrintGCApplicationStoppedTime打印垃圾回收期間程序暫停的時間。可與上面混合使用。輸出形式:Total timefor which application threads were stopped: 0.0468229 seconds

-XX:PrintHeapAtGC: 打印GC前後的詳細堆棧信息。輸出形式:

34.702:[GC {Heap before gc invocations=7:

defnew generation   total 55296K, used 52568K [0x1ebd0000, 0x227d0000,0x227d0000)

edenspace 49152K,  99% used [0x1ebd0000, 0x21bce430, 0x21bd0000)

fromspace 6144K,  55% used [0x221d0000, 0x22527e10, 0x227d0000)

to  space 6144K,   0% used [0x21bd0000, 0x21bd0000, 0x221d0000)

tenuredgeneration   total 69632K, used 2696K [0x227d0000, 0x26bd0000,0x26bd0000)

thespace 69632K,   3% used [0x227d0000, 0x22a720f8, 0x22a72200,0x26bd0000)

compactingperm gen  total 8192K, used 2898K [0x26bd0000, 0x273d0000, 0x2abd0000)

  the space 8192K,  35% used [0x26bd0000, 0x26ea4ba8, 0x26ea4c00,0x273d0000)

rospace 8192K,  66% used [0x2abd0000, 0x2b12bcc0, 0x2b12be00, 0x2b3d0000)

rwspace 12288K,  46% used [0x2b3d0000, 0x2b972060, 0x2b972200, 0x2bfd0000)

34.735:[DefNew: 52568K->3433K(55296K), 0.0072126 secs]55264K->6615K(124928K)Heap after gc invocations=8:

defnew generation   total 55296K, used 3433K [0x1ebd0000, 0x227d0000,0x227d0000)

edenspace 49152K,   0% used [0x1ebd0000, 0x1ebd0000, 0x21bd0000)

 from space 6144K,  55% used [0x21bd0000, 0x21f2a5e8, 0x221d0000)

 to   space 6144K,   0% used [0x221d0000, 0x221d0000,0x227d0000)

tenuredgeneration   total 69632K, used 3182K [0x227d0000, 0x26bd0000,0x26bd0000)

thespace 69632K,   4% used [0x227d0000, 0x22aeb958, 0x22aeba00,0x26bd0000)

compactingperm gen  total 8192K, used 2898K [0x26bd0000, 0x273d0000, 0x2abd0000)

  the space 8192K,  35% used [0x26bd0000, 0x26ea4ba8, 0x26ea4c00,0x273d0000)

  ro space 8192K,  66% used [0x2abd0000, 0x2b12bcc0, 0x2b12be00, 0x2b3d0000)

  rw space 12288K,  46% used [0x2b3d0000, 0x2b972060, 0x2b972200,0x2bfd0000)

}

,0.0757599 secs]

-Xloggc:filename:與上面幾個配合使用,把相關日誌信息記錄到文件以便分析。

常見配置彙總

 

堆設置

  -Xms:初始堆大小

  -Xmx:最大堆大小

  -XX:NewSize=n:設置年輕代大小

  -XX:NewRatio=n:設置年輕代和年老代的比值。如:爲3,表示年輕代與年老代比值爲1:3,年輕代佔整個年輕代年老代和的1/4

  -XX:SurvivorRatio=n:年輕代中Eden區與兩個Survivor區的比值。注意Survivor區有兩個。如:3,表示Eden:Survivor=3:2,一個Survivor區佔整個年輕代的1/5

  -XX:MaxPermSize=n:設置持久代大小

收集器設置

  -XX:+UseSerialGC:設置串行收集器

  -XX:+UseParallelGC:設置並行收集器

  -XX:+UseParalledlOldGC:設置並行年老代收集器

  -XX:+UseConcMarkSweepGC:設置併發收集器

垃圾回收統計信息

  -XX:+PrintGC

 -XX:+PrintGCDetails

 -XX:+PrintGCTimeStamps

 -Xloggc:filename

並行收集器設置

  -XX:ParallelGCThreads=n:設置並行收集器收集時使用的CPU數。並行收集線程數。

  -XX:MaxGCPauseMillis=n:設置並行收集最大暫停時間

  -XX:GCTimeRatio=n:設置垃圾回收時間佔程序運行時間的百分比。公式爲1/(1+n)

併發收集器設置

  -XX:+CMSIncrementalMode:設置爲增量模式。適用於單CPU情況。

  -XX:ParallelGCThreads=n:設置併發收集器年輕代收集方式爲並行收集時,使用的CPU數。並行收集線程數。

 

調優總結

年輕代大小選擇

響應時間優先的應用:儘可能設大,直到接近系統的最低響應時間限制(根據實際情況選擇)。在此種情況下,年輕代收集發生的頻率也是最小的。同時,減少到達年老代的對象。

吞吐量優先的應用:儘可能的設置大,可能到達Gbit的程度。因爲對響應時間沒有要求,垃圾收集可以並行進行,一般適合8CPU以上的應用。

 

 

年老代大小選擇

 

響應時間優先的應用:年老代使用併發收集器,所以其大小需要小心設置,一般要考慮併發會話率會話持續時間等一些參數。如果堆設置小了,可以會造成內存碎片、高回收頻率以及應用暫停而使用傳統的標記清除方式;如果堆大了,則需要較長的收集時間。最優化的方案,一般需要參考以下數據獲得:

 1. 併發垃圾收集信息

 2. 持久代併發收集次數

 3. 傳統GC信息

 4. 花在年輕代和年老代回收上的時間比例

減少年輕代和年老代花費的時間,一般會提高應用的效率

 

 

吞吐量優先的應用

一般吞吐量優先的應用都有一個很大的年輕代和一個較小的年老代。原因是,這樣可以儘可能回收掉大部分短期對象,減少中期的對象,而年老代盡存放長期存活對象。

 

 

較小堆引起的碎片問題

因爲年老代的併發收集器使用標記、清除算法,所以不會對堆進行壓縮。當收集器回收時,他會把相鄰的空間進行合併,這樣可以分配給較大的對象。但是,當堆空間較小時,運行一段時間以後,就會出現“碎片”,如果併發收集器找不到足夠的空間,那麼併發收集器將會停止,然後使用傳統的標記、清除方式進行回收。如果出現“碎片”,可能需要進行如下配置:

   1. -XX:+UseCMSCompactAtFullCollection:使用併發收集器時,開啓對年老代的壓縮。

   2. -XX:CMSFullGCsBeforeCompaction=0:上面配置開啓的情況下,這裏設置多少次FullGC後,對年老代進行壓縮

垃圾回收的瓶頸

   傳統分代垃圾回收方式,已經在一定程度上把垃圾回收給應用帶來的負擔降到了最小,把應用的吞吐量推到了一個極限。但是他無法解決的一個問題,就是FullGC所帶來的應用暫停。在一些對實時性要求很高的應用場景下,GC暫停所帶來的請求堆積和請求失敗是無法接受的。這類應用可能要求請求的返回時間在幾百甚至幾十毫秒以內,如果分代垃圾回收方式要達到這個指標,只能把最大堆的設置限制在一個相對較小範圍內,但是這樣有限制了應用本身的處理能力,同樣也是不可接收的。

   分代垃圾回收方式確實也考慮了實時性要求而提供了併發回收器,支持最大暫停時間的設置,但是受限於分代垃圾回收的內存劃分模型,其效果也不是很理想。

   爲了達到實時性的要求(其實Java語言最初的設計也是在嵌入式系統上的),一種新垃圾回收方式呼之欲出,它既支持短的暫停時間,又支持大的內存空間分配。可以很好的解決傳統分代方式帶來的問題。

 

 

增量收集的演進

   增量收集的方式在理論上可以解決傳統分代方式帶來的問題。增量收集把對堆空間劃分成一系列內存塊,使用時,先使用其中一部分(不會全部用完),垃圾收集時把之前用掉的部分中的存活對象再放到後面沒有用的空間中,這樣可以實現一直邊使用邊收集的效果,避免了傳統分代方式整個使用完了再暫停的回收的情況。

   當然,傳統分代收集方式也提供了併發收集,但是他有一個很致命的地方,就是把整個堆做爲一個內存塊,這樣一方面會造成碎片(無法壓縮),另一方面他的每次收集都是對整個堆的收集,無法進行選擇,在暫停時間的控制上還是很弱。而增量方式,通過內存空間的分塊,恰恰可以解決上面問題。

 

 

Garbage Firest(G1)

這部分的內容主要參考這裏,這篇文章算是對G1算法論文的解讀。我也沒加什麼東西了。

 

 

目標

從設計目標看G1完全是爲了大型應用而準備的。

支持很大的堆

高吞吐量

 --支持多CPU和垃圾回收線程

 --在主線程暫停的情況下,使用並行收集

 --在主線程運行的情況下,使用併發收集

實時目標:可配置在N毫秒內最多隻佔用M毫秒的時間進行垃圾回收

當然G1要達到實時性的要求,相對傳統的分代回收算法,在性能上會有一些損失。

 

 

算法詳解

   G1可謂博採衆家之長,力求到達一種完美。他吸取了增量收集優點,把整個堆劃分爲一個一個等大小的區域(region)。內存的回收和劃分都以region爲單位;同時,他也吸取了CMS的特點,把這個垃圾回收過程分爲幾個階段,分散一個垃圾回收過程;而且,G1也認同分代垃圾回收的思想,認爲不同對象的生命週期不同,可以採取不同收集方式,因此,它也支持分代的垃圾回收。爲了達到對回收時間的可預計性,G1在掃描了region以後,對其中的活躍對象的大小進行排序,首先會收集那些活躍對象小的region,以便快速回收空間(要複製的活躍對象少了),因爲活躍對象小,裏面可以認爲多數都是垃圾,所以這種方式被稱爲GarbageFirst(G1)的垃圾回收算法,即:垃圾優先的回收。

 

 

回收步驟:

 

初始標記(Initial Marking)

   G1對於每個region都保存了兩個標識用的bitmap,一個爲previousmarking bitmap,一個爲nextmarking bitmap,bitmap中包含了一個bit的地址信息來指向對象的起始點。

   開始InitialMarking之前,首先併發的清空nextmarking bitmap,然後停止所有應用線程,並掃描標識出每個region中root可直接訪問到的對象,將region中top的值放入next topat mark start(TAMS)中,之後恢復所有應用線程。

   觸發這個步驟執行的條件爲:

   G1定義了一個JVM Heap大小的百分比的閥值,稱爲h,另外還有一個H,H的值爲(1-h)*HeapSize,目前這個h的值是固定的,後續G1也許會將其改爲動態的,根據jvm的運行情況來動態的調整,在分代方式下,G1還定義了一個u以及soft limit,soft limit的值爲H-u*HeapSize,當Heap中使用的內存超過了soft limit值時,就會在一次clean up執行完畢後在應用允許的GC暫停時間範圍內儘快的執行此步驟;

   在pure方式下,G1將marking與clean up組成一個環,以便clean up能充分的使用marking的信息,當clean up開始回收時,首先回收能夠帶來最多內存空間的regions,當經過多次的clean up,回收到沒多少空間的regions時,G1重新初始化一個新的marking與clean up構成的環。

 

併發標記(ConcurrentMarking)

   按照之前InitialMarking掃描到的對象進行遍歷,以識別這些對象的下層對象的活躍狀態,對於在此期間應用線程併發修改的對象的以來關係則記錄到rememberedset logs中,新創建的對象則放入比top值更高的地址區間中,這些新創建的對象默認狀態即爲活躍的,同時修改top值。

 

 

最終標記暫停(Final MarkingPause)

   當應用線程的rememberedset logs未滿時,是不會放入filledRS buffers中的,在這樣的情況下,這些remeberedset logs中記錄的card的修改就會被更新了,因此需要這一步,這一步要做的就是把應用線程中存在的rememberedset logs的內容進行處理,並相應的修改rememberedsets,這一步需要暫停應用,並行的運行。

 

 

存活對象計算及清除(Live DataCounting and Cleanup)

   值得注意的是,在G1中,並不是說FinalMarking Pause執行完了,就肯定執行Cleanup這步的,由於這步需要暫停應用,G1爲了能夠達到準實時的要求,需要根據用戶指定的最大的GC造成的暫停時間來合理的規劃什麼時候執行Cleanup,另外還有幾種情況也是會觸發這個步驟的執行的:

   G1採用的是複製方法來進行收集,必須保證每次的”tospace”的空間都是夠的,因此G1採取的策略是當已經使用的內存空間達到了H時,就執行Cleanup這個步驟;

   對於full-young和partially-young的分代模式的G1而言,則還有情況會觸發Cleanup的執行,full-young模式下,G1根據應用可接受的暫停時間、回收youngregions需要消耗的時間來估算出一個youndregions的數量值,當JVM中分配對象的youngregions的數量達到此值時,Cleanup就會執行;partially-young模式下,則會盡量頻繁的在應用可接受的暫停時間範圍內執行Cleanup,並最大限度的去執行non-youngregions的Cleanup。

 

 

展望

   以後JVM的調優或許跟多需要針對G1算法進行調優了。

JVM調優工具

Jconsole,jProfile,VisualVM

Jconsole : jdk自帶,功能簡單,但是可以在系統有一定負荷的情況下使用。對垃圾回收算法有很詳細的跟蹤。詳細說明參考這裏

 

JProfiler:商業軟件,需要付費。功能強大。詳細說明參考這裏

 

VisualVM:JDK自帶,功能強大,與JProfiler類似。推薦。

 

如何調優

觀察內存釋放情況、集合類檢查、對象樹

上面這些調優工具都提供了強大的功能,但是總的來說一般分爲以下幾類功能

 

堆信息查看

 

可查看堆空間大小分配(年輕代、年老代、持久代分配)

提供即時的垃圾回收功能

垃圾監控(長時間監控回收情況)

 

 

查看堆內類、對象信息查看:數量、類型等

 

 

對象引用情況查看

 

有了堆信息查看方面的功能,我們一般可以順利解決以下問題:

 --年老代年輕代大小劃分是否合理

 --內存泄漏

 --垃圾回收算法設置是否合理

 

線程監控

 

線程信息監控:系統線程數量。

線程狀態監控:各個線程都處在什麼樣的狀態下

 

 

Dump線程詳細信息:查看線程內部運行情況

死鎖檢查

 

熱點分析

 

 

 

   CPU熱點:檢查系統哪些方法佔用的大量CPU時間

   內存熱點:檢查哪些對象在系統中數量最大(一定時間內存活對象和銷燬對象一起統計)

 

   這兩個東西對於系統優化很有幫助。我們可以根據找到的熱點,有針對性的進行系統的瓶頸查找和進行系統優化,而不是漫無目的的進行所有代碼的優化。

 

 

快照

   快照是系統運行到某一時刻的一個定格。在我們進行調優的時候,不可能用眼睛去跟蹤所有系統變化,依賴快照功能,我們就可以進行系統兩個不同運行時刻,對象(或類、線程等)的不同,以便快速找到問題

   舉例說,我要檢查系統進行垃圾回收以後,是否還有該收回的對象被遺漏下來的了。那麼,我可以在進行垃圾回收前後,分別進行一次堆情況的快照,然後對比兩次快照的對象情況。

 

內存泄漏檢查

   內存泄漏是比較常見的問題,而且解決方法也比較通用,這裏可以重點說一下,而線程、熱點方面的問題則是具體問題具體分析了。

   內存泄漏一般可以理解爲系統資源(各方面的資源,堆、棧、線程等)在錯誤使用的情況下,導致使用完畢的資源無法回收(或沒有回收),從而導致新的資源分配請求無法完成,引起系統錯誤。

   內存泄漏對系統危害比較大,因爲他可以直接導致系統的崩潰。

   需要區別一下,內存泄漏和系統超負荷兩者是有區別的,雖然可能導致的最終結果是一樣的。內存泄漏是用完的資源沒有回收引起錯誤,而系統超負荷則是系統確實沒有那麼多資源可以分配了(其他的資源都在使用)。

 

 

年老代堆空間被佔滿

異常: java.lang.OutOfMemoryError: Javaheap space

說明:

 

   這是最典型的內存泄漏方式,簡單說就是所有堆空間都被無法回收的垃圾對象佔滿,虛擬機無法再在分配新空間。

   如上圖所示,這是非常典型的內存泄漏的垃圾回收情況圖。所有峯值部分都是一次垃圾回收點,所有谷底部分表示是一次垃圾回收後剩餘的內存。連接所有谷底的點,可以發現一條由底到高的線,這說明,隨時間的推移,系統的堆空間被不斷佔滿,最終會佔滿整個堆空間。因此可以初步認爲系統內部可能有內存泄漏。(上面的圖僅供示例,在實際情況下收集數據的時間需要更長,比如幾個小時或者幾天)

 

解決:

   這種方式解決起來也比較容易,一般就是根據垃圾回收前後情況對比,同時根據對象引用情況(常見的集合對象引用)分析,基本都可以找到泄漏點。

 

 

持久代被佔滿

異常:java.lang.OutOfMemoryError:PermGen space

說明:

   Perm空間被佔滿。無法爲新的class分配存儲空間而引發的異常。這個異常以前是沒有的,但是在Java反射大量使用的今天這個異常比較常見了。主要原因就是大量動態反射生成的類不斷被加載,最終導致Perm區被佔滿。

   更可怕的是,不同的classLoader即便使用了相同的類,但是都會對其進行加載,相當於同一個東西,如果有N個classLoader那麼他將會被加載N次。因此,某些情況下,這個問題基本視爲無解。當然,存在大量classLoader和大量反射類的情況其實也不多。

解決:

   1. -XX:MaxPermSize=16m

   2. 換用JDK。比如JRocket。

 

 

堆棧溢出

異常:java.lang.StackOverflowError

說明:這個就不多說了,一般就是遞歸沒返回,或者循環調用造成

 

 

線程堆棧滿

異常:Fatal:Stack size too small

說明:java中一個線程的空間大小是有限制的。JDK5.0以後這個值是1M。與這個線程相關的數據將會保存在其中。但是當線程空間滿了以後,將會出現上面異常。

解決:增加線程棧大小。-Xss2m。但這個配置無法解決根本問題,還要看代碼部分是否有造成泄漏的部分。

 

系統內存被佔滿

異常:java.lang.OutOfMemoryError:unable to create new native thread

說明

   這個異常是由於操作系統沒有足夠的資源來產生這個線程造成的。系統創建線程時,除了要在Java堆中分配內存外,操作系統本身也需要分配資源來創建線程。因此,當線程數量大到一定程度以後,堆中或許還有空間,但是操作系統分配不出資源來了,就出現這個異常了。

分配給Java虛擬機的內存愈多,系統剩餘的資源就越少,因此,當系統內存固定時,分配給Java虛擬機的內存越多,那麼,系統總共能夠產生的線程也就越少,兩者成反比的關係。同時,可以通過修改-Xss來減少分配給單個線程的空間,也可以增加系統總共內生產的線程數。

解決:

   1. 重新設計系統減少線程數量。

2. 線程數量不能減少的情況下,通過-Xss減小單個線程大小。以便能生產更多的線程。

<本文提供的設置僅僅是在高壓力, 多CPU, 高內存環境下設置> 

最近對JVM的參數重新看了下, 把應用的JVM參數調整了下。  幾個重要的參數

-server -Xmx3g -Xms3g -XX:MaxPermSize=128m 
-XX:NewRatio=1  eden/old 的比例
-XX:SurvivorRatio=8  s/e的比例 
-XX:+UseParallelGC 
-XX:ParallelGCThreads=8  
-XX:+UseParallelOldGC  這個是JAVA 6出現的參數選項 
-XX:LargePageSizeInBytes=128m 內存頁的大小, 不可設置過大, 會影響Perm的大小。 
-XX:+UseFastAccessorMethods 原始類型的快速優化 
-XX:+DisableExplicitGC  關閉System.gc()



另外 -Xss 是線程棧的大小, 這個參數需要嚴格的測試, 一般小的應用, 如果棧不是很深, 應該是128k夠用的, 不過,我們的應用調用深度比較大, 還需要做詳細的測試。 這個選項對性能的影響比較大。 建議使用256K的大小.

例子:

-server -Xmx3g -Xms3g -Xmn=1g-XX:MaxPermSize=128m -Xss256k  -XX:MaxTenuringThreshold=10-XX:+DisableExplicitGC -XX:+UseParallelGC -XX:+UseParallelOld GC -XX:LargePageSizeInBytes=128m-XX:+UseFastAccessorMethods -XX:+AggressiveOpts -XX:+UseBiasedLocking 

 

-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCTimeStamps-XX:+PrintGCDetails 打印參數

=================================================================

另外對於大內存設置的要求:

Linux : 
Large page support is included in 2.6 kernel. Somevendors have backported the code to their 2.4 based releases. To check if yoursystem can support large page memory, try the following:   

# cat /proc/meminfo | grep Huge
HugePages_Total: 0
HugePages_Free: 0
Hugepagesize: 2048 kB
#

If the output shows the three"Huge" variables then your system can support large page memory, butit needs to be configured. If the command doesn't print out anything, thenlarge page support is not available. To configure the system to use large pagememory, one must log in as root, then:

1.     Increase SHMMAXvalue. It must be larger than the Java heap size. On a system with 4 GB ofphysical RAM (or less) the following will make all the memory sharable:

# echo 4294967295 > /proc/sys/kernel/shmmax

2.     Specify the numberof large pages. In the following example 3 GB of a 4 GB system are reserved forlarge pages (assuming a large page size of 2048k, then 3g = 3 x 1024m = 3072m =3072 * 1024k = 3145728k, and 3145728k / 2048k = 1536): 

# echo 1536 > /proc/sys/vm/nr_hugepages

Note the /proc values will reset afterreboot so you may want to set them in an init script (e.g. rc.local orsysctl.conf).

=============================================
這個設置, 目前觀察下來的結果是EDEN區域收集明顯速度比較快, 最多幾個ms, 但是,對於FGC, 大約需要0。9, 但是發生時間非常的長, 應該是影響不大。 但是對於非web應用的中間件服務, 這個設置很要不得, 可能導致很嚴重延遲效果. 因此, CMS必然需要被使用, 下面是CMS的重要參數介紹

關於CMS的設置:

使用CMS的前提條件是你有比較的長生命對象, 比如有200M以上的OLD堆佔用。 那麼這個威力非常猛, 可以極大的提高的FGC的收集能力。 如果你的OLD佔用非常的少, 別用了, 絕對降低你性能, 因爲CMS收集有2個STOP WORLD的行爲。 OLD少的清情況, 根據我的測試, 使用並行收集參數會比較好。


-XX:+UseConcMarkSweepGC   使用CMS內存收集
-XX:+AggressiveHeap 特別說明下:(我感覺對於做java cache應用有幫助)

·        試圖是使用大量的物理內存

·        長時間大內存使用的優化,能檢查計算資源(內存, 處理器數量)

·        至少需要256MB內存

·        大量的CPU/內存, (在1.4.1在4CPU的機器上已經顯示有提升)

-XX:+UseParNewGC 允許多線程收集新生代
-XX:+CMSParallelRemarkEnabled  降低標記停頓

-XX+UseCMSCompactAtFullCollection  在FULL GC的時候, 壓縮內存, CMS是不會移動內存的, 因此, 這個非常容易產生碎片, 導致內存不夠用, 因此, 內存的壓縮這個時候就會被啓用。 增加這個參數是個好習慣。 

 

 

壓力測試下合適結果:

-server -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xmx2g-Xms2g -Xmn256m -XX:PermSize=128m -Xss256k -XX:MaxTenuringThreshold=31-XX:+DisableExplicitGC  -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+CMSParallelRemarkEnabled -XX:+UseCMSCompactAtFullCollection-XX:LargePageSizeInBytes=128m  -XX:+UseFastAccessorMethods

 

由於Jdk1.5.09及之前的bug, 因此, CMS下的GC, 在這些版本的表現是十分糟糕的。  需要另外2個參數來控制cms的啓動時間:

-XX:+UseCMSInitiatingOccupancyOnly   僅僅使用手動定義初始化定義開始CMS收集

-XX:CMSInitiatingOccupancyFraction=70  CMS堆上, 使用70%後開始CMS收集。

 

使用CMS的好處是用盡量少的新生代、,我的經驗值是128M-256M, 然後老生代利用CMS並行收集, 這樣能保證系統低延遲的吞吐效率。 實際上cms的收集停頓時間非常的短,2G的內存, 大約20-80ms的應用程序停頓時間。

 

=========系統情況介紹========================

這個例子是測試系統12小時運行後的情況:

$uname -a

2.4.21-51.EL3.customsmp #1 SMP Fri Jun 27 10:44:12 CST2008 i686 i686 i386 GNU/Linux

 

$ free -m
            total      used       free     shared   buffers     cached
Mem:         3995      3910        85         0        162      1267
-/+ buffers/cache:      2479       1515
Swap:        2047         0       2047

 

$ jstat -gcutil 23959 1000

 S0    S1     E     O      P    YGC     YGCT    FGC   FGCT     GCT   
 59.06   0.00  45.77  44.45  56.88 15204  324.023    66    1.668  325.691
  0.00  39.66  27.53  44.73  56.88  15205 324.046    66    1.668  325.715
 53.42   0.00  22.80  44.73  56.88 15206  324.073    66    1.668  325.741
  0.00  44.90  13.73  44.76  56.88  15207 324.094    66    1.668  325.762
 51.70   0.00  19.03  44.76  56.88 15208  324.118    66    1.668  325.786
  0.00  61.62  19.44  44.98  56.88  15209 324.148    66    1.668  325.816
 53.03   0.00  14.00  45.09  56.88 15210  324.172    66    1.668  325.840
 53.03   0.00  87.87  45.09  56.88 15210  324.172    66    1.668  325.840
  0.00  50.49  72.00  45.22  56.88  15211 324.198    66    1.668  325.866

 

GC參數配置:

JAVA_OPTS=" -server -XX:+PrintGCApplicationStoppedTime-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xmx2g -Xms2g -Xmn256m-XX:PermSize=128m -Xss256k -XX:MaxTenuringThreshold=31-XX:+DisableExplicitGC  -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+CMSParallelRemarkEnabled -XX:+UseCMSCompactAtFullCollection -XX:LargePageSizeInBytes=128m -XX:+UseFastAccessorMethods -XX:+UseCMSInitiatingOccupancyOnly-XX:CMSInitiatingOccupancyFraction=70 "

實際上我們可以看到並行young gc執行時間是: 324.198s/15211=20ms, cms的執行時間是 1.668/66=25ms. 當然嚴格來說, 這麼算是不對的, 世界停頓的時間要比這是數據稍微大5-10ms. 對我們來說如果不輸出日誌, 對我們是有參考意義的。

 

32位系統下, 設置成2G, 非常危險, 除非你確定你的應用佔用的native內存很少, 不然可能導致jvm直接crash。

 

-XX:+AggressiveOpts 加快編譯

-XX:+UseBiasedLocking 鎖機制的性能改善。

 

 

 

 

能整理出上面一些東西,也是因爲站在巨人的肩上。下面是一些參考資料,供大家學習,大家有更好的,可以繼續完善:)

 

· Java 理論與實踐: 垃圾收集簡史

 

· Java SE 6 HotSpot[tm] Virtual MachineGarbage Collection Tuning

 

· Improving Java Application Performance andScalability by Reducing Garbage Collection Times and Sizing Memory Using JDK1.4.1

 

· Hotspot memorymanagement whitepaper

 

· Java Tuning White Paper

 

· Diagnosing a GarbageCollection problem

 

· Java HotSpot VM Options

 

· A Collection of JVMOptions

 

· Garbage-First GarbageCollection

 

· Frequently AskedQuestions about Garbage Collection in the HotspotTM JavaTM Virtual Machine

· JProfiler試用手記

 

· Java6 JVM參數選項大全

 

· 《深入Java虛擬機》。雖然過去了很多年,但這本書依舊是經典。

 

 

   這裏是本系列的最後一篇了,很高興大家能夠喜歡這系列的文章。期間也提了很多問題,其中有些是我之前沒有想到的或者考慮欠妥的,感謝提出這些問題的朋友,我也學到的不少東西。

 

 

發佈了5 篇原創文章 · 獲贊 1 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章