原本準備把記憶體模型單獨放到某一篇文章的某個章節裡面講解,後來查閱了國外很多文檔才發現其實JVM記憶體模型的内容還蠻多的,是以直接作為一個章節 的基礎知識來講解,可能該章節概念的東西比較多。一個開發Java的開發者,一旦了解了JVM記憶體模型就能夠更加深入地了解該語言的語言特性,可能這個章 節更多的是概念,沒有太多代碼執行個體, 本文盡量涵蓋所有Java語言可以碰到的和記憶體相關的内容,同樣也會提到一些和記憶體相關的計算機語言的一些知識,為草案。因為平時開發的時候沒有特殊情況 不會進行記憶體管理,是以有可能有筆誤的地方比較多,我用的是Windows平台,是以本文涉及到的與作業系統相關的隻是僅僅局限于Windows平台。不 僅僅如此,這一個章節牽涉到的多線程和另外一些内容并沒有講到,這裡主要是結合JVM内部特性把本章節作為核心的概念性章節來講解,這樣友善初學者深入以 及徹底了解Java語言)
本文章節:
1.JMM簡介
2.堆和棧
3.本機記憶體
4.防止記憶體洩漏
i.記憶體模型概述
Java平台自動內建了線程以及多處理器技術,這種內建程度比Java以前誕生的計算機語言要厲害很多,該語言針對多種異構平台的平台獨立性而使用的多線程技術支援也是具有開拓性的一面,有時候在開發Java同步和線程安全要求很嚴格的程式時,往往容易混淆的一個概念就是記憶體模型。究竟什麼是記憶體模型?記憶體模型描述了程式中各個變量(執行個體域、靜态域和數組元素)之間的關系,以及在實際計算機系統中将變量存儲到記憶體和從記憶體中取出變量這樣的底層細節,對象最終是存儲在記憶體裡面的,這點沒有錯,但是編譯器、運作庫、處理器或者系統緩存可以有特權在變量指定記憶體位置存儲或者取出變量的值。【JMM】(Java Memory Model的縮寫)允許編譯器和緩存以資料在處理器特定的緩存(或寄存器)和主存之間移動的次序擁有重要的特權,除非程式員使用了final或synchronized明确請求了某些可見性的保證。
1)JSR133:
在Java語言規範裡面指出了JMM是一個比較開拓性的嘗試,這種嘗試視圖定義一個一緻的、跨平台的記憶體模型,但是它有一些比較細微而且很重要的缺點。其實Java語言裡面比較容易混淆的關鍵字主要是synchronized和volatile,也因為這樣在開發過程中往往開發者會忽略掉這些規則,這也使得編寫同步代碼比較困難。
JSR133本身的目的是為了修複原本JMM的一些缺陷而提出的,其本身的制定目标有以下幾個:
保留目前JVM的安全保證,以進行類型的安全檢查:
提供(out-of-thin-air safety)無中生有安全性,這樣“正确同步的”應該被正式而且直覺地定義
程式員要有信心開發多線程程式,當然沒有其他辦法使得并發程式變得很容易開發,但是該規範的釋出主要目标是為了減輕程式員了解記憶體模型中的一些細節負擔
提供大範圍的流行硬體體系結構上的高性能JVM實作,現在的處理器在它們的記憶體模型上有着很大的不同,JMM應該能夠适合于實際的盡可能多的體系結構而不以性能為代價,這也是Java跨平台型設計的基礎
提供一個同步的習慣用法,以允許釋出一個對象使他不用同步就可見,這種情況又稱為初始化安全(initialization safety)的新的安全保證
對現有代碼應該隻有最小限度的影響
2)同步、異步【這裡僅僅指概念上的了解,不牽涉到計算機底層基礎的一些操作】:
在系統開發過程,經常會遇到這幾個基本概念,不論是網絡通訊、對象之間的消息通訊還是Web開發人員常用的Http請求都會遇到這樣幾個概念,經常有人提到Ajax是異步通訊方式,那麼究竟怎樣的方式是這樣的概念描述呢?
同步:同步就是在發出一個功能調用的時候,在沒有得到響應之前,該 調用就不傳回,按照這樣的定義,其實大部分程式的執行都是同步調用的,一般情況下,在描述同步和異步操作的時候,主要是指代需要其他部件協作處理或者需要 協作響應的一些任務處理。比如有一個線程A,在A執行的過程中,可能需要B提供一些相關的執行資料,當然觸發B響應的就是A向B發送一個請求或者說對B進 行一個調用操作,如果A在執行該操作的時候是同步的方式,那麼A就會停留在這個位置等待B給一個響應消息,在B沒有任何響應消息回來的時候,A不能做其他 事情,隻能等待,那麼這樣的情況,A的操作就是一個同步的簡單說明。
異步:異步就是在發出一個功能調用的時候,不需要等待響應,繼續進 行它該做的事情,一旦得到響應了過後給予一定的處理,但是不影響正常的處理過程的一種方式。比如有一個線程A,在A執行的過程中,同樣需要B提供一些相關 資料或者操作,當A向B發送一個請求或者對B進行調用操作過後,A不需要繼續等待,而是執行A自己應該做的事情,一旦B有了響應過後會通知A,A接受到該 異步請求的響應的時候會進行相關的處理,這種情況下A的操作就是一個簡單的異步操作。
3)可見性、可排序性
Java記憶體模型的兩個關鍵概念:可見性(Visibility)和可排序性(Ordering)
開發過多線程程式的程式員都明白,synchronized關鍵字強制實施一個線程之間的互斥鎖(互相排斥),該互斥鎖防止每次有多個線程進入一個給定監控器所保護的同步語句塊,也就是說在該情況下,執行程式代碼所獨有的某些記憶體是獨占模式,其他的線程是不能針對它執行過程所獨占的記憶體進行通路的,這種情況稱為該記憶體不可見。但是在該模型的同步模式中,還有另外一個方面:JMM中指出了,JVM在處理該強制實施的時候可以提供一些記憶體的可見規則,在該規則裡面,它確定當存在一個同步塊時,緩存被更新,當輸入一個同步塊時,緩存失效。是以在JVM内部提供給定監控器保護的同步塊之中,一個線程所寫入的值對于其餘所有的執行由同一個監控器保護的同步塊線程來說是可見的,這就是一個簡單的可見性的描述。這種機器保證編譯器不會把指令從一個同步塊的内部移到外部,雖然有時候它會把指令由外部移動到内部。JMM在預設情況下不做這樣的保證——隻要有多個線程通路相同變量時必須使用同步。簡單總結:
可見性就是在多核或者多線程運作過程中記憶體的一種共享模式,在JMM模型裡面,通過并發線程修改變量值的時候,必須将線程變量同步回主存過後,其他線程才可能通路到。
【*:簡單講,記憶體的可見性使記憶體資源可以共享,當一個線程執行的時候它所占有的記憶體,如果它占有的記憶體資源是可見的,那麼這時候其他線程在一定規則内是可以通路該記憶體資源的,這種規則是由JMM内部定義的,這種情況下記憶體的該特性稱為其可見性。】
可排序性提供了記憶體内部的通路順序,在不同的程式針對不同的記憶體塊進行通路的時候,其通路不是無序的, 比如有一個記憶體塊,A和B需要通路的時候,JMM會提供一定的記憶體配置設定政策有序地配置設定它們使用的記憶體,而在記憶體的調用過程也會變得有序地進行,記憶體的折中 性質可以簡單了解為有序性。而在Java多線程程式裡面,JMM通過Java關鍵字volatile來保證記憶體的有序通路。
ii.JMM結構:
1)簡單分析:
Java語言規範中提到過,JVM中存在一個主存區(Main Memory或Java Heap Memory),Java中所有變量都是存在主存中的,對于所有線程進行共享,而每個線程又存在自己的工作記憶體(Working Memory),工作記憶體中儲存的是主存中某些變量的拷貝,線程對所有變量的操作并非發生在主存區,而是發生在工作記憶體中,而線程之間是不能直接互相通路,變量在程式中的傳遞,是依賴主存來完成的。而在多核處理器下,大部分資料存儲在高速緩存中, 如果高速緩存不經過記憶體的時候,也是不可見的一種表現。在Java程式中,記憶體本身是比較昂貴的資源,其實不僅僅針對Java應用程式,對作業系統本身而 言記憶體也屬于昂貴資源,Java程式在性能開銷過程中有幾個比較典型的可控制的來源。synchronized和volatile關鍵字提供的記憶體中模型 的可見性保證程式使用一個特殊的、存儲關卡(memory barrier)的指令,來重新整理緩存,使緩存無效,重新整理硬體的寫緩存并且延遲執行的傳遞過程,無疑該機制會對Java程式的性能産生一定的影響。

JMM的最初目的,就是為了能夠支援多線程程式設計的,每個線程可以認為是和其他線程不同的CPU上運作,或者對于多處理器的機器而言,該模型需要實作的就是使得每一個線程就像運作在不同的機器、不同的CPU或者本身就不同的線程上一樣,這種情況實際上在項目開發中是常見的。對于CPU本身而言,不能直接通路其他CPU的寄存器,模型必須通過某種定義規則來使得線程和線程在工作記憶體中進行互相調用而實作CPU本身對其他CPU、或者說線程對其他線程的記憶體中資源的通路,而表現這種規則的運作環境一般為運作該程式的運作宿主環境(作業系統、伺服器、分布式系統等),而程式本身表現就依賴于編寫該程式的語言特性,這裡也就是說用Java編寫的應用程式在記憶體管理中的實作就是遵循其部分原則,也就是前邊提及到的JMM定義了Java語言針對記憶體的一些的相關規則。然而,雖然設計之初是為了能夠更好支援多線程,但是該模型的應用和實作當然不局限于多處理器,而在JVM編譯器編譯Java編寫的程式的時候以及運作期執行該程式的時候,對于單CPU的系統而言,這種規則也是有效的,這就是是上邊提到的線程和線程之間的記憶體政策。JMM本身在描述過程沒有提過具體的記憶體位址以及在實作該政策中的實作方法是由JVM的哪一個環節(編譯器、處理器、緩存控制器、其他)提供的機制來實作的,甚至針對一個開發非常熟悉的程式員,也不一定能夠了解它内部對于類、對象、方法以及相關内容的一些具體可見的實體結構。相反,JMM定義了一個線程與主存之間的抽象關系,其實從上邊的圖可以知道,每一個線程可以抽象成為一個工作記憶體(抽象的高速緩存和寄存器),其中存儲了Java的一些值,該模型保證了Java裡面的屬性、方法、字段存在一定的數學特性,按照該特性,該模型存儲了對應的一些内容,并且針對這些内容進行了一定的序列化以及存儲排序操作, 這樣使得Java對象在工作記憶體裡面被JVM順利調用,(當然這是比較抽象的一種解釋)既然如此,大多數JMM的規則在實作的時候,必須使得主存和工作内 存之間的通信能夠得以保證,而且不能違反記憶體模型本身的結構,這是語言在設計之處必須考慮到的針對記憶體的一種設計方法。這裡需要知道的一點是,這一切的操 作在Java語言裡面都是依靠Java語言自身來操作的,因為Java針對開發人員而言,記憶體的管理在不需要手動操作的情況下本身存在記憶體的管理政策,這 也是Java自己進行記憶體管理的一種優勢。
[1]原子性(Atomicity):
這一點說明了該模型定義的規則針對原子級别的内容存在獨立的影響,對于模型設計最初,這些規則需要說明的僅僅是最簡單的讀取和存儲單元寫入的的一些操作,這種原子級别的包括——執行個體、靜态變量、數組元素,隻是在該規則中不包括方法中的局部變量。
[2]可見性(Visibility):
在該規則的限制下,定義了一個線程在哪種情況下可以通路另外一個線程或者影響另外一個線程,從JVM的操作上講包括了從另外一個線程的可見區域讀取相關資料以及将資料寫入到另外一個線程内。
[3]可排序性(Ordering):
該規則将會限制任何一個違背了規則調用的線程在操作過程中的一些順序,排序問題主要圍繞了讀取、寫入和指派語句有關的序列。
如果在該模型内部使用了一緻的同步性的時候,這些屬性中的每一個屬性都遵循比較簡單的原則:和所有同步的記憶體塊一樣,每個同步塊之内的任何 變化都具備了原子性以及可見性,和其他同步方法以及同步塊遵循同樣一緻的原則,而且在這樣的一個模型内,每個同步塊不能使用同一個鎖,在整個程式的調用過 程是按照編寫的程式指定指令運作的。即使某一個同步塊内的處理可能會失效,但是該問題不會影響到其他線程的同步問題,也不會引起連環失效。簡單講:當程式運作的時候使用了一緻的同步性的時候,每個同步塊有一個獨立的空間以及獨立的同步控制器和鎖機制,然後對外按照JVM的執行指令進行資料的讀寫操作。這種情況使得使用記憶體的過程變得非常嚴謹!
如果不使用同步或者說使用同步不一緻(這裡可以了解為異步,但不一定是異步操作),該程式執行的答案就 會變得極其複雜。而且在這樣的情況下,該記憶體模型處理的結果比起大多數程式員所期望的結果而言就變得十分脆弱,甚至比起JVM提供的實作都脆弱很多。因為 這樣是以出現了Java針對該記憶體操作的最簡單的語言規範來進行一定的習慣限制,排除該情況發生的做法在于:
JVM線程必須依靠自身來維持對象的可見性以及對象自身應該提供相對應的操作而實作整個記憶體操作的三個特性,而不是僅僅依靠特定的修改對象狀态的線程來完成如此複雜的一個流程。
【*:綜上所屬,JMM在JVM内部實作的結構就變得相對複雜,當然一般的Java初學者可以不用了解得這麼深入。】
[4]三個特性的解析(針對JMM内部):
原子性(Atomicity):
通路存儲單元内的任何類型的字段的值以及對其更新操作的時候,除開long類型和double類型,其他類型的字段是必須要保證其原子性的,這些字段也包括為對象服務的引用。此外,該原子性規則擴充可以延伸到基于long和double的另外兩種類型:volatile long和volatile double(volatile為java關鍵字), 沒有被volatile聲明的long類型以及double類型的字段值雖然不保證其JMM中的原子性,但是是被允許的。針對non-long/non- double的字段在表達式中使用的時候,JMM的原子性有這樣一種規則:如果你獲得或者初始化該值或某一些值的時候,這些值是由其他線程寫入,而且不是從兩個或者多個線程産生的資料在同一時間戳混合寫入的時候,該字段的原子性在JVM内部是必須得到保證的。也就是說JMM在定義JVM原子性的時候,隻要在該規則不違反的條件下,JVM本身不去理睬該資料的值是來自于什麼線程, 因為這樣使得Java語言在并行運算的設計的過程中針對多線程的原子性設計變得極其簡單,而且即使開發人員沒有考慮到最終的程式也沒有太大的影響。再次解 釋一下:這裡的原子性指的是原子級别的操作,比如最小的一塊記憶體的讀寫操作,可以了解為Java語言最終編譯過後最接近記憶體的最底層的操作單元,這種讀寫 操作的資料單元不是變量的值,而是本機碼,也就是前邊在講《Java基礎知識》中提到的由運作器解釋的時候生成的Native Code。
可見性(Visibility):
當一個線程需要修改另外線程的可見單元的時候必須遵循以下原則:
一個寫入線程釋放的同步鎖和緊随其後進行讀取的讀線程的同步鎖是同一個
從本質上講,釋放鎖操作強迫它的隸屬線程【釋放鎖的線程】從工作記憶體中的寫入緩存裡面重新整理(專業上講這裡不應該是重新整理,可以了解為提供)資料(flush操作),然後擷取鎖操作使得另外一個線程【獲得鎖的線程】直接讀取前一個線程可通路域(也就是可見區域)的字段的值。因為該鎖内部提供了一個同步方法或者同步塊,該同步内容具有線程排他性,這樣就使得上邊兩個操作隻能針對單一線程在同步内容内部進行操作,這樣就使得所有操作該内容的單一線程具有該同步内容(加鎖的同步方法或者同步塊)内的線程排他性,這種情況的交替也可以了解為具有“短暫記憶效應”。
這裡需要了解的是同步的雙重含義: 使用鎖機制允許基于高層同步協定進行處理操作,這是最基本的同步;同時系統記憶體(很多時候這裡是指基于機器指令的底層存儲關卡memory barrier,前邊提到過)在處理同步的時候能夠跨線程操作,使得線程和線程之間的資料是同步的。這樣的機制也折射出一點,并行程式設計相對于順序程式設計而 言,更加類似于分布式程式設計。後一種同步可以作為JMM機制中的方法在一個線程中運作的效果展示,注意這裡不是多個線程運作的效果展示,因為它反應了該線程 願意發送或者接受的雙重操作,并且使得它自己的可見區域可以提供給其他線程運作或者更新,從這個角度來看,使用鎖和消息傳遞可以視為互相之間的變量同步,因為相對其他線程而言,它的操作針對其他線程也是對等的。
一旦某個字段被申明為volatile,在任何一個寫入線程在工作記憶體中重新整理緩存的之前需要進行進一步的記憶體操作,也就是說針對這樣的字段進行立即重新整理,可以了解為這種volatile不會出現一般變量的緩存操作,而讀取線程每次必須根據前一個線程的可見域裡面重新讀取該變量的值,而不是直接讀取。
當某個線程第一次去通路某個對象的域的時候,它要麼初始化該對象的值,要麼從其他寫入線程可見域裡面去讀取該對象的值;這裡結合上邊了解,在滿足某種條件下,該線程對某對象域的值的讀取是直接讀取,有些時候卻需要重新讀取。
這裡需要小心一點的是,在并發程式設計裡面,不好的一 個實踐就是使用一個合法引用去引用不完全構造的對象,這種情況在從其他寫入線程可見域裡面進行資料讀取的時候發生頻率比較高。從程式設計角度上講,在構造函數 裡面開啟一個新的線程是有一定的風險的,特别是該類是屬于一個可子類化的類的時候。Thread.start由調用線程啟動,然後由獲得該啟動的線程釋放 鎖具有相同的“短暫記憶效應”,如果一個實作了Runnable接口的超類在子類構造子執行之前調用了Thread(this).start()方法,那麼就可能使得該對象線上程方法run執行之前并沒有被完全初始化,這樣就使得一個指向該對象的合法引用去引用了不完全構造的一個對象。同樣的,如果建立一個新的線程T并且啟動該線程,然後再使用線程T來建立對象X,這種情況就不能保證X對象裡面所有的屬性針對線程T都是可見的除非是在所有針對X對象的引用中進行同步處理,或者最好的方法是在T線程啟動之前建立對象X。
若一個線程終止,所有的變量值都必須從工作記憶體中刷到主存,比如,如果一個同步線程因為另一個使用Thread.join方法的線程而終止,那麼該線程的可見域針對那個線程而言其發生的改變以及産生的一些影響是需要保證可知道的。
注意:如果在同一個線程裡面通過方法調用去傳一個對象的引用是絕對不會出現上邊提及到的可見性問題的。JMM保證所有上邊的規定以及關于記憶體可見性特性的描述——一個特殊的更新、一個特定字段的修改都是某個線程針對其他線程的一個“可見性”的概念,最終它發生的場所在記憶體模型中Java線程和線程之間,至于這個發生時間可以是一個任意長的時間,但是最終會發生,也就是說,Java記憶體模型中的可見性的特性主要是針對線程和線程之間使用記憶體的一種規則和約定,該約定由JMM定義。
不僅僅如此,該模型還允許不同步的情況下可見性特性。比如針對一個線程提供一個對象或者字段通路域的原始值進行操作,而針對另外一個線程提 供一個對象或者字段重新整理過後的值進行操作。同樣也有可能針對一個線程讀取一個原始的值以及引用對象的對象内容,針對另外一個線程讀取一個重新整理過後的值或者 重新整理過後的引用。
盡管如此,上邊的可見性特性分析的一些特征在跨線程操作的時候是有可能失敗的,而且不能夠避免這些故障發生。這是一個不争的事實,使用同步多線程的代碼并不能絕對保證線 程安全的行為,隻是允許某種規則對其操作進行一定的限制,但是在最新的JVM實作以及最新的Java平台中,即使是多個處理器,通過一些工具進行可見性的 測試發現其實是很少發生故障的。跨線程共享CPU的共享緩存的使用,其缺陷就在于影響了編譯器的優化操作,這也展現了強有力的緩存一緻性使 得硬體的價值有所提升,因為它們之間的關系線上程與線程之間的複雜度變得更高。這種方式使得可見度的自由測試顯得更加不切實際,因為這些錯誤的發生極為罕 見,或者說在平台上我們開發過程中根本碰不到。在并行程開發中,不使用同步導緻失敗的原因也不僅僅是對可見度的不良把握導緻的,導緻其程式失敗的原因是多 方面的,包括緩存一緻性、記憶體一緻性問題等。
可排序性(Ordering):
可排序規則線上程與線程之間主要有下邊兩點:
從操作線程的角度看來,如果所有的指令執行都是按照普通順序進行,那麼對于一個順序運作的程式而言,可排序性也是順序的
從其他操作線程的角度看來,排序性如同在這個線程中運作在非同步方法中的一個“間諜”,是以任何事情都有可能發生。唯一有用的限制是同步方法和同步塊的相對排序,就像操作volatile字段一樣,總是保留下來使用
【*:如何了解這裡“間諜”的意思,可以這樣了解,排序規則在本線程裡面遵循了第一條法則,但是對其他線程而言,某個線程自身的排序特性可 能使得它不定地通路執行線程的可見域,而使得該線程對本身在執行的線程産生一定的影響。舉個例子,A線程需要做三件事情分别是A1、A2、A3,而B是另 外一個線程具有操作B1、B2,如果把參考定位到B線程,那麼對A線程而言,B的操作B1、B2有可能随時會通路到A的可見區域,比如A有一個可見區域 a,A1就是把a修改稱為1,但是B線程在A線程調用了A1過後,卻通路了a并且使用B1或者B2操作使得a發生了改變,變成了2,那麼當A按照排序性進 行A2操作讀取到a的值的時候,讀取到的是2而不是1,這樣就使得程式最初設計的時候A線程的初衷發生了改變,就是排序被打亂了,那麼B線程對A線程而 言,其身份就是“間諜”,而且需要注意到一點,B線程的這些操作不會和A之間存在等待關系,那麼B線程的這些操作就是異步操作,是以針對執行線程A而 言,B的身份就是“非同步方法中的‘間諜’。】
同樣的,這僅僅是一個最低限度的保障性質,在任何給定的程式或者平台,開發中有可能發現更加嚴格的排序,但是開發人員在設計程式的時候不能 依賴這種排序,如果依賴它們會發現測試難度會成指數級遞增,而且在複合規定的時候會因為不同的特性使得JVM的實作因為不符合設計初衷而失敗。
注意:第一點在JLS(Java Language Specification)的所有讨論中也是被采用的,例如算數表達式一般情況都是從上到下、從左到右的順序,但是這一點需要了解的是,從其他操作線程 的角度看來這一點又具有不确定性,對線程内部而言,其記憶體模型本身是存在排序性的。【*:這裡讨論的排序是最底層的記憶體裡面執行的時候的 NativeCode的排序,不是說按照順序執行的Java代碼具有的有序性質,本文主要分析的是JVM的記憶體模型,是以希望讀者明白這裡指代的讨論單元 是記憶體區。】
iii.原始JMM缺陷:
JMM最初設計的時候存在一定的缺陷,這種缺陷雖然現有的JVM平台已經修複,但是這裡不得不提及,也是為了讀者更加了解JMM的設計思路,這一個小節的概念可能會牽涉到很多更加深入的知識,如果讀者不能讀懂沒有關系先看了文章後邊的章節再傳回來看也可以。
1)問題1:不可變對象不是不可變的
學過Java的朋友都應該知道Java中的不可變對象,這一點在本文最後講解String類的時候也會提及,而JMM最初設計的時候,這個問題一直都存在,就是:不可變對象似乎可以改變它們的值(這種對象的不可變指通過使用final關鍵字來得到保證),(Publis Service Reminder:讓一個對象的所有字段都為final并不一定使得這個對象不可變——所有類型還必須是原始類型而不能是對象的引用。而不可變對象被認為不要求同步的。但是,因為在将記憶體寫方面的更改從一個線程傳播到另外一個線程的時候存在潛在的延遲,這樣就使得有可能存在一種競态條件,即允許一個線程首先看到不可變對象的一個值,一段時間之後看到的是一個不同的值。這種情況以前怎麼發生的呢?在JDK 1.4中的String實作裡,這兒基本有三個重要的決定性字段:對字元數組的引用、長度和描述字元串的開始數組的偏移量。String就是以這樣的方式在JDK 1.4中實作的,而不是隻有字元數組,是以字元數組可以在多個String和StringBuffer對象之間共享,而不需要在每次建立一個String的時候都拷貝到一個新的字元數組裡。假設有下邊的代碼:
String s1 = "/usr/tmp";
String s2 = s1.substring(4); // "/tmp"
這種情況下,字元串s2将具有大小為4的長度和偏移量,但是它将和s1共享“/usr/tmp”裡面的同一字元數組,在String構造函數運作之前,Object的構造函數将用它們預設的值初始化所有的字段,包括決定性的長度和偏移字段。當String構造函數運作的時候,字元串長度和偏移量被設定成所需要的值。但是在舊的記憶體模型中,因為缺乏同步,有可能另一個線程會臨時地看到偏移量字段具有初始預設值0,而後又看到正确的值4,結果是s2的值從“/usr”變成了“/tmp”,這并不是我們真正的初衷,這個問題就是原始JMM的第一個缺陷所在,因為在原始JMM模型裡面這是合理而且合法的,JDK 1.4以下的版本都允許這樣做。
2)問題2:重新排序的易失性和非易失性存儲
另一個主要領域是與volatile字段的記憶體操作重新排序有關,這個領域中現有的JMM引起了一些比較混亂的結果。現有的JMM表明易失性的讀和寫是直接和主存打交道的,這樣避免了把值存儲到寄存器或者繞過處理器特定的緩存, 這使得多個線程一般能看見一個給定變量最新的值。可是,結果是這種volatile定義并沒有最初想象中那樣如願以償,并且導緻了volatile的重大 混亂。為了在缺乏同步的情況下提供較好的性能,編譯器、運作時和緩存通常是允許進行記憶體的重新排序操作的,隻要目前執行的線程分辨不出它們的差別。(這就 是within-thread as-if-serial semantics[線程内似乎是串行]的解釋)但是,易失性的讀和寫是完全跨線程安排的,編譯器或緩存不能在彼此之間重新排序易失性的讀和寫。遺憾的是,通過參考普通變量的讀寫,JMM允許易失性的讀和寫被重排序,這樣以為着開發人員不能使用易失性标志作為操作已經完成的标志。比如:
Map configOptions;
char[] configText;
volatile boolean initialized = false;
// 線程1
configOptions = new HashMap();
configText = readConfigFile(filename);
processConfigOptions(configText,configOptions);
initialized = true;
// 線程2
while(!initialized)
sleep();
這裡的思想是使用易失性變量initialized擔任守衛來表明一套别的操作已經完成了,這是一個很好的思想,但是不能在JMM下工作, 因為舊的JMM允許非易失性的寫(比如寫到configOptions字段,以及寫到由configOptions引用Map的字段中)與易失性的寫一起 重新排序,是以另外一個線程可能會看到initialized為true,但是對于configOptions字段或它所引用的對象還沒有一個一緻的或者 說目前的針對記憶體的視圖變量,volatile的舊語義隻承諾在讀和寫的變量的可見性,而不承諾其他變量,雖然這種方法更加有效的實作,但是結果會和我們 設計之初大相徑庭。
i.Java記憶體管理簡介:
記憶體管理在Java語言中是JVM自動操作的,當JVM發現某些對象不再需要的時候,就會對該對象占用的記憶體進行重配置設定(釋放)操作,而且 使得配置設定出來的記憶體能夠提供給所需要的對象。在一些程式設計語言裡面,記憶體管理是一個程式的職責,但是書寫過C++的程式員很清楚,如果該程式需要自己來書寫 很有可能引起很嚴重的錯誤或者說不可預料的程式行為,最終大部分開發時間都花在了調試這種程式以及修複相關錯誤上。一般情況下在Java程式開發過程把手 動記憶體管理稱為顯示記憶體管理,而顯示記憶體管理經常發生的一個情況就是引用懸挂—— 也就是說有可能在重新配置設定過程釋放掉了一個被某個對象引用正在使用的記憶體空間,釋放掉該空間過後,該引用就處于懸挂狀态。如果這個被懸挂引用指向的對象試 圖進行原來對象(因為這個時候該對象有可能已經不存在了)進行操作的時候,由于該對象本身的記憶體空間已經被手動釋放掉了,這個結果是不可預知的。顯示記憶體 管理另外一個常見的情況是記憶體洩漏,當某些引用不再引用該記憶體對象的時候,而該對象原本占用的記憶體并沒有被釋放,這種 情況簡言為記憶體洩漏。比如,如果針對某個連結清單進行了記憶體配置設定,而因為手動配置設定不當,僅僅讓引用指向了某個元素所處的記憶體空間,這樣就使得其他連結清單中的元素 不能再被引用而且使得這些元素所處的記憶體讓應用程式處于不可達狀态而且這些對象所占有的記憶體也不能夠被再使用,這個時候就發生了記憶體洩漏。而這種情況一旦 在程式中發生,就會一直消耗系統的可用記憶體直到可用記憶體耗盡,而針對計算機而言記憶體洩漏的嚴重程度大了會使得本來正常運作的程式直接因為記憶體不足而中斷,并不是Java程式裡面出現Exception那麼輕量級。
在以前的程式設計過程中,手動記憶體管理帶了計算機程式不可避免的錯誤,而且這種錯誤對計算機程式是毀滅性的,是以記憶體管理就成為了一個很重要的話題,但是針對大多數純面向對象語言而言,比如Java,提供了語言本身具有的記憶體特性:自動化記憶體管理,這種語言提供了一個程式垃圾回收器(Garbage Collector[GC]),自動記憶體管理提供了一個抽象的接口以及更加可靠的代碼使得記憶體能夠在程式裡面進行合理的配置設定。最常見的情況就是垃圾回收器避免了懸挂引用的問題,因為一旦這些對象沒有被任何引用“可達”的時候,也就是這些對象在JVM的記憶體池裡面成為了不可引用對象,該垃圾回收器會直接回收掉這些對象占用的記憶體,當然這些對象必須滿足垃圾回收器回收的某些對象規則,而垃圾回收器在回收的時候會自動釋放掉這些記憶體。不僅僅如此,垃圾回收器同樣會解決記憶體洩漏問題。
ii.詳解堆和棧[圖檔以及部分内容來自《Inside JVM》]:
1)通用簡介
[編譯原理]學過編譯原理的人都明白,程式運作時有三種記憶體配置設定政策:靜态的、棧式的、堆式的
靜态存儲——是指在編譯時就能夠确定每個資料目标在運作時的存儲空間需求,因而在編譯時就可以給它們配置設定固定的記憶體空間。這種配置設定政策要求程式代碼中不允許有可變資料結構的存在,也不允許有嵌套或者遞歸的結構出現,因為它們都會導緻編譯程式無法計算準确的存儲空間。
棧式存儲——該配置設定可成為動态存儲配置設定,是由一個類似于堆棧的運作棧來實作的,和靜态存儲的配置設定方式相 反,在棧式存儲方案中,程式對資料區的需求在編譯時是完全未知的,隻有到了運作的時候才能知道,但是規定在運作中進入一個程式子產品的時候,必須知道該程式 子產品所需要的資料區的大小才能配置設定其記憶體。和我們在資料結構中所熟知的棧一樣,棧式存儲配置設定按照先進後出的原則進行配置設定。
堆式存儲——堆式存儲配置設定則專門負責在編譯時或運作時子產品入口處都無法确定存儲要求的資料結構的記憶體配置設定,比如可變長度串和對象執行個體,堆由大片的可利用塊或空閑塊組成,堆中的記憶體可以按照任意順序配置設定和釋放。
[C++語言]對比C++語言裡面,程式占用的記憶體分為下邊幾個部分:
[1]棧區(Stack):由編譯器自動配置設定釋放,存放函數的參數值,局部變量的值等。其操作方式類似于資料結構中的棧。我們在程式中定義的局部變量就是存放在棧裡,當局部變量的生命周期結束的時候,它所占的記憶體會被自動釋放。
[2]堆區(Heap):一般由程式員配置設定和釋放,若程式員不釋放,程式結束時可能由OS回收。注意它 與資料結構中的堆是兩回事,配置設定方式倒是類似于連結清單。我們在程式中使用c++中new或者c中的malloc申請的一塊記憶體,就是在heap上申請的,在 使用完畢後,是需要我們自己動手釋放的,否則就會産生“記憶體洩露”的問題。
[3]全局區(靜态區)(Static):全局變量和靜态變量的存儲是放在一塊的,初始化的全局變量和靜态變量在一塊區域,未初始化的全局變量和未初始化的靜态變量在相鄰的另一塊區域。程式結束後由系統釋放。
[4]文字常量區:常量字元串就是放在這裡的,程式結束後由系統釋放。在Java中對應有一個字元串常量池。
[5]程式代碼區:存放函數體的二進制代碼
2)JVM結構【堆、棧解析】:
在Java虛拟機規範中,一個虛拟機執行個體的行為主要描述為:子系統、記憶體區域、資料類型和指令, 這些元件在描述了抽象的JVM内部的一個抽象結構。與其說這些組成部分的目的是進行JVM内部結構的一種支配,更多的是提供一種嚴格定義實作的外部行為, 該規範定義了這些抽象組成部分以及互相作用的任何Java虛拟機執行所需要的行為。下圖描述了JVM内部的一個結構,其中主要包括主要的子系統、記憶體區域,如同以前在《Java基礎知識》中描述的:Java虛拟機有一個類加載器作為JVM的子系統,類加載器針對Class進行檢測以鑒定完全合格的類接口,而JVM内部也有一個執行引擎:
當JVM運作一個程式的時候,它的記憶體需要用來存儲很多内容,包括位元組碼、以及從類檔案中提取出來的一些附加資訊、以及程式中執行個體化的對象、方法參數、傳回值、局部變量以及計算的中間結果。JVM的記憶體組織需要在不同的運作時資料區進行以上的幾個操作,下邊針對上圖裡面出現的幾個運作時資料區進行詳細解析:一些運作時資料區共享了所有應用程式線程和其他特有的單個線程,每個JVM執行個體有一個方法區和一個記憶體堆,這些是共同在虛拟機内運作的線程。在Java程式裡面,每個新的線程啟動過後,它就會被JVM在内部配置設定自己的PC寄存器[PC registers](程式計數器器)和Java堆棧(Java stacks)。若該線程正在執行一個非本地Java方法,在PC寄存器的值訓示下一條指令執行,該線程在Java記憶體棧中儲存了非本地Java方法調用狀态,其狀态包括局部變量、被調用的參數、它的傳回值、以及中間計算結果。而本地方法調用的狀态則是存儲在獨立的本地方法記憶體棧裡面(native method stacks),這種情況下使得這些本地方法和其他記憶體運作時資料區的内容盡可能保證和其他記憶體運作時資料區獨立,而且該方法的調用更靠近作業系統,這些方法執行的位元組碼有可能根據作業系統環境的不同使得其編譯出來的本地位元組碼的結構也有一定的差異。JVM中的記憶體棧是一個棧幀的組合,一個棧幀包含了某個Java方法調用的狀态,當某個線程調用方法的時候,JVM就會将一個新的幀壓入到Java記憶體棧,當方法調用完成過後,JVM将會從記憶體棧中移除該棧幀。JVM裡面不存在一個可以存放中間計算資料結果值的寄存器,其内部指令集使用Java棧空間來存儲中間計算的資料結果值,這種做法的設計是為了保持Java虛拟機的指令集緊湊,使得與寄存器原理能夠緊密結合并且進行操作。
1)方法區(Method Area)
在JVM執行個體中,對裝載的類型資訊是存儲在一個邏輯方法記憶體區中,當Java虛拟機加載了一個類型的時候,它會跟着這個Class的類型去路徑裡面查找對應的Class檔案,類加載器讀取類檔案(線性二進制資料),然後将該檔案傳遞給Java虛拟機,JVM從二進制資料中提取資訊并且将這些資訊存儲在方法區,而類中聲明(靜态)變量就是來自于方法區中存儲的資訊。在JVM裡面用什麼樣的方式存儲該資訊是由JVM設計的時候決定的,例如:當資料進入方法的時候,多類檔案位元組的存儲量以Big-Endian(第一次最重要的位元組)的順序存儲,盡管如此,一個虛拟機可以用任何方式針對這些資料進行存儲操作,若它存儲在一個Little-Endian處理器上,設計的時候就有可能将多檔案位元組的值按照Little-Endian順尋存儲。
——【$Big-Endian和Little-Endian】——
程式存儲資料過程中,如果資料是跨越多個位元組對象就必須有一種約定:
它的位址是多少:對于跨越多個位元組的對象,一般它所占的位元組都是連續的,它的位址等于它所占位元組最低位址,這種情況連結清單可能存儲的僅僅是表頭
它的位元組在記憶體中是如何組織的
比如:int x,它的位址為0x100,那麼它占據了記憶體中的0x100、0x101、0x102、0x103四個位元組,是以一般情況我 們覺得int是4個位元組。上邊隻是記憶體組織的一種情況,多位元組對象在記憶體中的組織有兩種約定,還有一種情況:若一個整數為W位,它的表示如下:
每一位表示為:[Xw-1,Xw-2,...,X1,X0]
它的最高有效位元組MSB(Most Significant Byte)為:[Xw-1,Xw-2,...,Xw-8]
最低有效位元組LSB(Least Significant Byte)為:[X7,X6,...,X0]
其餘位元組則位于LSB和MSB之間
LSB和MSB誰位于記憶體的最低位址,即代表了該對象的位址,這樣就引出了Big-Endian和Little- Endian的問題,如果LSB在MSB前,LSB是最低位址,則該機器是小端,反之則是大端。DES(Digital Equipment Corporation,現在是Compaq公司的一部分)和Intel機器(x86平台)一般采用小端,IBM、Motorola(Power PC)、Sun的機器一般采用大端。當然這種不能代表所有情況,有的CPU既能工作于小端、又可以工作于大端,比如ARM、Alpha、摩托羅拉的 PowerPC,這些情況根據具體的處理器型号有所不同。但是大部分作業系統(Windows、FreeBSD、Linux)一般都是Little Endian的,少部分系統(Mac OS)是Big Endian的,是以用什麼方式存儲還得依賴宿主作業系統環境。
由上圖可以看到,映射通路(“寫32位位址的0”)主要是由寄存器到記憶體、由記憶體到寄存器的一種資料映射方式,Big-Endian在上圖可以看出的原子記憶體機關(Atomic Unit)在系統記憶體中的增長方向為從左到右,而Little-Endian的位址增長方向為從右到左。舉個例子:
若要存儲資料0x0A0B0C0D:
Big-Endian:
以8位為一個存儲機關,其存儲的位址增長為:
上圖中可以看出MSB的值存儲了0x0A,這種情況下資料的高位是從記憶體的低位址開始存儲的,然後從左到右開始增長,第二位0x0B就是存儲在第二位的,如果是按照16位為一個存儲機關,其存儲方式又為:
則可以看到Big-Endian的映射位址方式為:
MSB:在計算機中,最高有效位(MSB)是指位值的存儲位置為轉換為二進制資料後的最大值,MSB有時候在Big-Endian的架構中稱為最左最大資料位,這種情況下再往左邊的記憶體位則不是資料位了,而是有效位數位置的最高符号位,不僅僅如此,MSB也可以對應一個二進制符号位的符号位補碼标記:“1”的含義為負,“0”的含義為正。最高位代表了“最重要位元組”,也就是說當某些多位元組資料擁有了最大值的時候它就是存儲的時候最高位資料的位元組對應的記憶體位置:
Little-Endian:
與Big-Endian相對的就是Little-Endian的存儲方式,同樣按照8位為一個存儲機關上邊的資料0x0A0B0C0D存儲格式為:
可以看到LSB的值存儲的0x0D,也就是資料的最低位是從記憶體的低位址開始存儲的,它的高位是從右到左的順序逐漸增加記憶體配置設定空間進行存儲的,如果按照十六位為存儲機關存儲格式為:
從上圖可以看到最低的16位的存儲機關裡面存儲的值為0x0C0D,接着才是0x0A0B,這樣就可以看到按照資料從高位到低位在記憶體中存儲的時候是從右到左進行遞增存儲的,實際上可以從寫記憶體的順序來了解,實際上資料存儲在記憶體中無非在使用的時候是寫記憶體和讀記憶體,針對LSB的方式最好的書面解釋就是向左增加來看待,如果真正在進行記憶體讀寫的時候使用這樣的順序,其意義就展現出來了:
按照這種讀寫格式,0x0D存儲在最低記憶體位址,而從右往左的增長就可以看到LSB存儲的資料為0x0D,和初衷吻合,則十六位的存儲就可以按照下邊的格式來解釋:
實際上從上邊的存儲還會考慮到另外一個問題,如果按照這種方式從右往左的方式進行存儲,如果是遇到Unicode文字就和從左到右的語言顯示方式相反。比如一個單詞“XRAY”,使用Little-Endian的方式存儲格式為:
使用這種方式進行記憶體讀寫的時候就會發現計算機語言和語言本身的順序會有沖突,這種沖突主要是以使用語言的人的習慣有關,而書面化的語言從左到右就可 以知道其沖突是不可避免的。我們一般使用語言的閱讀方式都是從左到右,而低端存儲(Little-Endian)的這種記憶體讀寫的方式使得我們最終從計算 機裡面讀取字元需要進行倒序,而且考慮另外一個問題,如果是針對中文而言,一個字元是兩個位元組,就會出現整體順序和每一個位的順序會進行兩次倒序操作, 這種方式真正在制作處理器的時候也存在一種計算上的沖突,而針對使用文字從左到右進行閱讀的國家而言,從右到左的方式(Big-Endian)則會有這樣 的文字沖突,另外一方面,盡管有很多國家使用語言是從右到左,但是僅僅和Big-Endian的方式存在沖突,這些國家畢竟占少數,是以可以了解的是,為 什麼主流的系統都是使用的Little-Endian的方式
【*:這裡不解釋Middle-Endian的方式以及Mixed-Endian的方式】
LSB:在計算機中,最低有效位是一個二進制給予機關的整數,位的位置确定了該資料是一個偶數還是奇數,LSB有時被稱為最右位。在使用具體位二進制數之内,常見的存儲方式就是每一位存儲1或者0的方式,從0向上到1每一比特逢二進一的存儲方式。LSB的這種特性用來指定機關位,而不是位的數字,而這種方式也有可能産生一定的混亂。
——以上是關于Big-Endian和Little-Endian的簡單講解——
JVM虛拟機将搜尋和使用類型的一些資訊也存儲在方法區中以友善應用程式加載讀取該資料。設計者在設計過程也考慮到要友善JVM進行 Java應用程式的快速執行,而這種取舍主要是為了程式在運作過程中記憶體不足的情況能夠通過一定的取舍去彌補記憶體不足的情況。在JVM内部,所有的線程共享相同的方法區,是以,通路方法區的資料結構必須是線程安全的,如果兩個線程都試圖去調用去找一個名為Lava的類,比如Lava還沒有被加載,隻有一個線程可以加載該類而另外的線程隻能夠等待。方法區的大小在配置設定過程中是不固定的,随着Java應用程式的運作,JVM可以調整其大小,需要注意一點,方法區的記憶體不需要是連續的,因為方法區記憶體可以配置設定在記憶體堆中,即使是虛拟機JVM執行個體對象自己所在的記憶體堆也是可行的,而在實作過程是允許程式員自身來指定方法區的初始化大小的。
同樣的,因為Java本身的自動記憶體管理,方法區也會被垃圾回收的,Java程式可以通過類擴充動态加載器對象,類可以成為“未引用”向垃圾回收器進行申請,如果一個類是“未引用”的,則該類就可能被解除安裝,
而方法區針對具體的語言特性有幾種資訊是存儲在方法區内的:
【類型資訊】:
類型的完全限定名(java.lang.String格式)
類型的完全限定名的直接父類的完全限定名(除非這個父類的類型是一個接口或者java.lang.Object)
不論類型是一個類或者接口
類型的修飾符(例如public、abstract、final)
任何一個直接超類接口的完全限定名的清單
在JVM和類檔案名的内部,類型名一般都是完全限定名(java.lang.String)格式,在Java源檔案裡面,完全限定名必須加 入包字首,而不是我們在開發過程寫的簡單類名,而在方法上,隻要是符合Java語言規範的類的完全限定名都可以,而JVM可能直接進行解析,比如: (java.lang.String)在JVM内部名稱為java/lang/String,這就是我們在異常捕捉的時候經常看到的 ClassNotFoundException的異常裡面類資訊的名稱格式。
除此之外,還必須為每一種加載過的類型在JVM内進行存儲,下邊的資訊不存儲在方法區内,下邊的章節會一一說明
類型常量池
字段資訊
方法資訊
所有定義在Class内部的(靜态)變量資訊,除開常量
一個ClassLoader的引用
Class的引用
【常量池】
針對類型加載的類型資訊,JVM将這些存儲在常量池裡,常量池是一個根據類型定義的常量的有序常量集,包括字面量(String、 Integer、Float常量)以及符号引用(類型、字段、方法),整個長量池會被JVM的一個索引引用,如同數組裡面的元素集合按照索引通路一 樣,JVM針對這些常量池裡面存儲的資訊也是按照索引方式進行。實際上長量池在Java程式的動态連結過程起到了一個至關重要的作用。
【字段資訊】
針對字段的類型資訊,下邊的資訊是存儲在方法區裡面的:
字段名
字段類型
字段修飾符(public,private,protected,static,final,volatile,transient)
【方法資訊】
針對方法資訊,下邊資訊存儲在方法區上:
方法名
方法的傳回類型(包括void)
方法參數的類型、數目以及順序
方法修飾符(public,private,protected,static,final,synchronized,native,abstract)
針對非本地方法,還有些附加方法資訊需要存儲在方法區内:
方法位元組碼
方法中局部變量區的大小、方法棧幀
異常表
【類變量】
類變量在一個類的多個執行個體之間共享,這些變量直接和類相關,而不是和類的執行個體相關,(定義過程簡單了解為類裡面定義的static類型的變量),針對類變量,其邏輯部分就是存儲在方法區内的。在JVM使用這些類之前,JVM先要在方法區裡面為定義的non-final變量配置設定記憶體空間;常量(定義為final)則在JVM内部則不是以同樣的方式來進行存儲的,盡管針對常量而言,一個final的類變量是擁有它自己的常量池,作為常量池裡面的存儲某部分,類常量是存儲在方法區内的,而其邏輯部分則不是按照上邊的類變量的方式來進行記憶體配置設定的。雖然non-final類變量是作為這些類型聲明中存儲資料的某一部分,final變量存儲為任何使用它類型的一部分的資料格式進行簡單存儲。
【ClassLoader引用】
對于每種類型的加載,JVM必須檢測其類型是否符合了JVM的語言規範,對于通過類加載器加載的對象類型,JVM必須存儲對類的引用,而這些針對類加載器的引用是作為了方法區裡面的類型資料部分進行存儲的。
【類Class的引用】
JVM在加載了任何一個類型過後會建立一個java.lang.Class的執行個體,虛拟機必須通過一定的途徑來引用該類型對應的一個Class的執行個體,并且将其存儲在方法區内
【方法表】
為了提高通路效率,必須仔細的設計存儲在方法區中的資料資訊結構。除了以上讨論的結構,jvm的實作者還添加一些其他的資料結構,如方法表【下邊會說明】。
2)記憶體棧(Stack):
當一個新線程啟動的時候,JVM會為Java線程建立每個線程的獨立記憶體棧,如前所言Java的記憶體棧是由棧幀構成,棧幀本身處于遊離狀态,在JVM裡面,棧幀的操作隻有兩種:出棧和入棧。正在被線程執行的方法一般稱為目前線程方法,而該方法的棧幀就稱為目前幀,而在該方法内定義的類稱為目前類,常量池也稱為目前常量池。當執行一個方法如此的時候,JVM保留目前類和目前常量池的跟蹤,當虛拟機遇到了存儲在棧幀中的資料上的操作指令的時候,它就執行目前幀的操作。當一個線程調用某個Java方法時,虛拟機建立并且将一個新幀壓入到記憶體堆棧中,而這個壓入到記憶體棧中的幀成為目前棧幀, 當該方法執行的時候,JVM使用記憶體棧來存儲參數、局部變量、中間計算結果以及其他相關資料。方法在執行過程有可能因為兩種方式而結束:如果一個方法傳回 完成就屬于方法執行的正常結束,如果在這個過程抛出異常而結束,可以稱為非正常結束,不論是正常結束還是異常結束,JVM都會彈出或者丢棄該棧幀,則上一 幀的方法就成為了目前幀。
在JVM中,Java線程的棧資料是屬于某個線程獨有的,其他的線程不能夠修改或者通過其他方式來通路該線程的棧幀,正因為如此這種情況不 用擔心多線程同步通路Java的局部變量,當一個線程調用某個方法的時候,方法的局部變量是在方法内部進行的Java棧幀的存儲,隻有目前線程可以通路該 局部變量,而其他線程不能随便通路該記憶體棧裡面存儲的資料。記憶體棧内的棧幀資料和方法區以及記憶體堆一樣,Java棧的棧幀不需要配置設定在連續的堆棧内,或者說它們可能是在堆,或者兩者組合配置設定,實際資料用于表示Java堆棧和棧幀結構是JVM本身的設計結構決定的,而且在程式設計過程可以允許程式員指定一個用于Java堆棧的初始大小以及最大、最小尺寸。
【概念區分】
記憶體棧:這裡的記憶體棧和實體結構記憶體堆棧有點點差別,是記憶體裡面資料存儲的一種抽象資料結構。從作業系統上講,在程式執行過程對記憶體的使用本身常用的資料結構就是記憶體堆棧,而這裡的記憶體堆棧指代的就是JVM在使用記憶體過程整個記憶體的存儲結構,多指記憶體的實體結構,而Java記憶體棧不是指代的一個實體結構,更多的時候指代的是一個抽象結構, 就是符合JVM語言規範的記憶體棧的一個抽象結構。因為實體記憶體堆棧結構和Java記憶體棧的抽象模型結構本身比較相似,是以我們在學習過程就正常把這兩種結 構放在一起考慮了,而且二者除了概念上有一點點小的差別,了解成為一種結構對于初學者也未嘗不可,是以實際上也可以覺得二者沒有太大的本質差別。但是在學 習的時候最好厘清楚記憶體堆棧和Java記憶體棧的一小點細微的差距,前者是實體概念和本身模型,後者是抽象概念和本身模型的一個共同體。而記憶體堆棧更多的說 法可以了解為一個記憶體塊,因為記憶體塊可以通過索引和指針進行資料結構的組合,記憶體棧就是記憶體塊針對資料結構的一種表示,而記憶體堆則是記憶體塊的另外一種資料結構的表示,這樣了解更容易區分記憶體棧和記憶體堆棧(記憶體塊)的概念。
棧幀:棧幀是記憶體棧裡面的最小機關,指的是記憶體棧裡面每一個最小記憶體存儲單元,它針對記憶體棧僅僅做了兩個操作:入棧和出棧,一般情況下:所說的堆棧幀和棧幀倒是一個概念,是以在了解上記得加以區分
記憶體堆:這裡的記憶體堆和記憶體棧是相對應的,其實記憶體堆裡面的資料也是存儲在系統記憶體堆棧裡面的,隻是它使用了另外一種方式來進行堆裡面記憶體的管理,而本章題目要講到的就是Java語言本身的記憶體堆和記憶體棧,而這兩個概念都是抽象的概念模型,而且是相對的。
棧幀:棧幀主要包括三個部分:局部變量、操作數棧幀(操作幀)和幀資料(資料幀)。 本地變量和操作數幀的大小取決于需要,這些大小是在編譯時就決定的,并且在每個方法的類檔案資料中進行配置設定,幀的資料大小則不一樣,它雖然也是在編譯時就 決定的但是它的大小和本身代碼實作有關。當JVM調用一個Java方法的時候,它會檢查類的資料來确定在本地變量和操作方法要求的棧大小,它計算該方法所 需要的記憶體大小,然後将這些資料配置設定好記憶體空間壓入到記憶體堆棧中。
棧幀——局部變量:局部變量是以Java棧幀組合成為的一個以零為基的數組,使用局部變量的時候使用的實際上是一個包含了0的一個基于索引的數組結構。int類型、float、引用以及傳回值都占據了一個數組中的局部變量的條目,而byte、short、char則在存儲到局部變量的時候是先轉化成為int再進行操作的,則long和double則是在這樣一個數組裡面使用了兩個元素的空間大小,在局部變量裡面存儲基本資料類型的時候使用的就是這樣的結構。舉個例子:
class Example3a{
public static int runClassMethod(int i,long l,float f,double d,Object o,byte b)
{
return 0;
}
public int runInstanceMethod(char c,double d,short s,boolean b)
}
棧幀——操作幀:和局部變量一樣,操作幀也是一組有組織的數組的存儲結構,但是和局部變量不一樣的是這個不是通過數組的索引通路的,而是直接進行的入棧和出棧的操作,當操作指令直接壓入了操作棧幀過後,從棧幀裡面出來的資料會直接在出棧的時候被讀取和使用。除了程式計數器以外,操作幀也是可以直接被指令通路到的,JVM裡面沒有寄存器。 處理操作幀的時候Java虛拟機是基于記憶體棧的而不是基于寄存器的,因為它在操作過程是直接對記憶體棧進行操作而不是針對寄存器進行操作。而JVM内部的指 令也可以來源于其他地方比如緊接着操作符以及操作數的位元組碼流或者直接從常量池裡面進行操作。JVM指令其實真正在操作過程的焦點是集中在記憶體棧棧幀的操 作幀上的。JVM指令将操作幀作為一個工作空間,有許多指令都是從操作幀裡面出棧讀取的,對指令進行操作過後将操作幀的計算結果重新壓入記憶體堆棧内。比如 iadd指令将兩個整數壓入到操作幀裡面,然後将兩個操作數進行相加,相加的時候從記憶體棧裡面讀取兩個操作數的值,然後進行運算,最後将運算結果重新存入 到記憶體堆棧裡面。舉個簡單的例子:
begin
iload_0 //将整數類型的局部變量0壓入到記憶體棧裡面
iload_1 //将整數類型的局部變量1壓入到記憶體棧裡面
iadd //将兩個變量出棧讀取,然後進行相加操作,将結果重新壓入棧中
istore_2 //将最終輸出結果放在另外一個局部變量裡面
end
綜上所述,就是整個計算過程針對記憶體的一些操作内容,而整體的結構可以用下圖來描述:
棧幀——資料幀:除了局部變量和操作幀以外,Java棧幀還包括了資料幀,用于支援常量池、普通的方法傳回以及異 常抛出等,這些資料都是存儲在Java記憶體棧幀的資料幀中的。很多JVM的指令集實際上使用的都是常量池裡面的一些條目,一些指令,隻是把int、 long、float、double或者String從常量池裡面壓入到Java棧幀的操作幀上邊,一些指令使用常量池來管理類或者數組的執行個體化操作、字 段的通路控制、或者方法的調用,其他的指令就用來決定常量池條目中記錄的某一特定對象是否某一類或者常量池項中指定的接口。常量池會判斷類型、字段、方 法、類、接口、類字段以及引用是如何在JVM進行符号化描述,而這個過程由JVM本身進行對應的判斷。這裡就可以了解JVM如何來判斷我們通常說的:“原 始變量存儲在記憶體棧上,而引用的對象存儲在記憶體堆上邊。”除了常量池判斷幀資料符号化描述特性以外,這些資料幀必須在JVM正常執行或者異常執行過程輔助 它進行處理操作。如果一個方法是正常結束的,JVM必須恢複棧幀調用方法的資料幀,而且必須設定PC寄存器指向調用方法後邊等待的指令完成該調用方法的位 置。如果該方法存在傳回值,JVM也必須将這個值壓入到操作幀裡面以提供給需要這些資料的方法進行調用。不僅僅如此,資料幀也必須提供一個方法調用的異常表,當JVM在方法中抛出異常而非正常結束的時候,該異常表就用來存放異常資訊。
3)記憶體堆(Heap):
當一個Java應用程式在運作的時候在程式中建立一個對象或者一個數組的時候,JVM會針對該對象和數組配置設定一個新的記憶體堆空間。但是在JVM執行個體内部,隻存在一個記憶體堆執行個體,所有的依賴該JVM的Java應用程式都需要共享該堆執行個體,而Java應用程式本身在運作的時候它自己包含了一個由JVM虛拟機執行個體配置設定的自己的堆空間,而在應用程式啟動的時候,任何一個Java應用程式都會得到JVM配置設定的堆空間,而且針對每一個Java應用程式,這些運作Java應用程式的堆空間都是互相獨立的。這裡所提及到的共享堆執行個體是指JVM在初始化運作的時候整體堆空間隻有一個,這個是Java語言平台直接從作業系統上能夠拿到的整體堆空間,是以的依賴該JVM的程式都可以得到這些記憶體空間,但是針對每一個獨立的Java應用程式而言,這些堆空間是互相獨立的,每一個Java應用程式在運作最初都是依靠JVM來進行堆空間的配置設定的。即使是兩個相同的Java應用程式,一旦在運作的時候處于不同的作業系統程序(一般為java.exe)中,它們各自配置設定的堆空間都是獨立的,不能互相通路,隻是兩個Java應用程序初始化拿到的堆空間來自JVM的配置設定,而JVM是從最初的記憶體堆執行個體裡面配置設定出來的。在同一個Java應用程式裡面如果出現了不同的線程,則是可以共享每一個Java應用程式拿到的記憶體堆空間的,這也是為什麼在開發多線程程式的時候,針對同一個Java應用程式必須考慮線程安全問題,因為在一個Java程序裡面所有的線程是可以共享這個程序拿到的堆空間的資料的。但是Java記憶體堆有一個特性,就是JVM擁有針對新的對象配置設定記憶體的指令,但是它卻不包含釋放該記憶體空間的指令,當然開發過程可以在Java源代碼中顯示釋放記憶體或者說在JVM位元組碼中進行顯示的記憶體釋放,但是JVM僅僅隻是檢測堆空間中是否有引用不可達(不可以引用)的對象,然後将接下來的操作交給垃圾回收器來處理。
對象表示:
JVM規範裡面并沒有提及到Java對象如何在堆空間中表示和描述,對象表示可以了解為設計JVM的工程師在最初考慮到對象調用以及垃圾回收器針對對象的判斷而獨立的一種Java對象在記憶體中的存儲結構, 該結構是由設計最初考慮的。針對一個建立的類執行個體而言,它内部定義的執行個體變量以及它的超類以及一些相關的核心資料,是必須通過一定的途徑進行該對象内部存 儲以及表示的。當開發過程給定了一個對象引用的時候,JVM必須能夠通過這個引用快速從對象堆空間中去拿到該對象能夠通路的資料内容。也就是說,堆空間内 對象的存儲結構必須為外圍對象引用提供一種可以通路該對象以及控制該對象的接口使得引用能夠順利地調用該對象以及相關操作。是以,針對堆空間的對象,配置設定 的記憶體中往往也包含了一些指向方法區的指針,因為從整體存儲結構上講,方法區似乎存儲了很多原子級别的内容,包括方法區内最原始最單一的一些變量:比如類字段、字段資料、類型資料等等。而JVM本身針對堆空間的管理存在兩種設計結構:
【1】設計一:
堆空間的設計可以劃分為兩個部分:一個處理池和一個對象池,一個對象的引用可以拿到處理池的一個本地指針,而處理池主要分為兩個部分:一個指向對象池裡面的指針以及一個指向方法區的指針。 這種結構的優勢在于JVM在處理對象的時候,更加能夠友善地組合堆碎片以使得所有的資料被更加友善地進行調用。當JVM需要将一個對象移動到對象池的時 候,它僅僅需要更新該對象的指針到一個新的對象池的記憶體位址中就可以完成了,然後在處理池中針對該對象的内部結構進行相對應的處理工作。不過這樣的方法也 會出現一個缺點就是在處理一個對象的時候針對對象的通路需要提供兩個不同的指針,這一點可能不好了解,其實可以這樣講,真正在對象處理過程存在一個根據時間戳有差別的對象狀态,而對象在移動、更新以及建立的整個過程中,它的處理池裡面總是包含了兩個指針,一個指針是指向對象内容本身,一個指針是指向了方法區,因為一個完整的對外的對象是依靠這兩部分被引用指針引用到的,而我們開發過程是不能夠操作處理池的兩個指針的,隻有引用指針我們可以通過外圍程式設計拿到。如果Java是按照這種設計進行對象存儲,這裡的引用指針就是平時提及到的“Java的引用”,隻是JVM在引用指針還做了一定的封裝,這種封裝的規則是JVM本身設計的時候做的,它就通過這種結構在外圍進行一次封裝,比如Java引用不具備直接操作記憶體位址的能力就是該封裝的一種限制規則。這種設計的結構圖如下:
【2】設計二:
另外一種堆空間設計就是使用對象引用拿到的本地指針,将該指針直接指向綁定好的對象的執行個體資料,這些資料裡面僅僅包含了一個指向方法區原子級别的資料去拿到該執行個體相關資料,這種情況下隻需要引用一個指針來通路對象執行個體資料,但是這樣的情況使得對象的移動以及對象的資料更新變得更加複雜。當JVM需要移動這些資料以及進行堆記憶體碎片的整理的時候,就必須直接更新該對象所有運作時的資料區,這種情況可以用下圖進行表示:
JVM需要從一個對象引用來獲得該引用能夠引用的對象資料存在多個原因,當一個程式試圖将一個對象的引用轉換成為另外一個類型的時候,JVM就會檢查兩個引用指向的對象是否存在父子類關系,并且檢查兩個引用引用到的對象是否能夠進行類型轉換, 而且所有這種類型的轉換必須執行同樣的一個操作:instanceof操作,在上邊兩種情況下,JVM都必須要去分析引用指向的對象内部的資料。當一個程 序調用了一個執行個體方法的時候,JVM就必須進行動态綁定操作,它必須選擇調用方法的引用類型,是一個基于類的方法調用還是一個基于對象的方法調用,要做到 這一點,它又要擷取該對象的唯一引用才可以。不管對象的實作是使用什麼方式來進行對象描述,都是在針對記憶體中關于該對象的方法表進行操作,因為使用這樣的方式加快了執行個體針對方法的調用,而且在JVM内部實作的時候這樣的機制使得其運作表現比較良好,是以方法表的設計在JVM整體結構中發揮了極其重要的作用。關于方法表的存在與否,在JVM規範裡面沒有嚴格說明,也有可能真正在實作過程隻是一個抽象概念,實體層它根本不存在,針對放發表實作對于一個建立的執行個體而言,它本身具有不太高的記憶體需要求,如果該實作裡面使用了方法表,則對象的方法表應該是可以很快被外圍引用通路到的。
有一種辦法就是通過對象引用連接配接到方法表的時候,如下圖:
該圖表明,在每個指針指向一個對象的時候,實際上是使用的一個特殊的資料結構,這些特殊的結構包括幾個部分:
一個指向該對象類所有資料的指針
該對象的方法表
實際上從圖中可以看出,方法表就是一個指針數組,它的每一個元素包含了一個指針,針對每個對象的方法都可以直接通過該指針在方法區中找到比對的資料進行相關調用,而這些方法表需要包括的内容如下:
方法記憶體堆棧段空間中操作棧的大小以及局部變量
一個方法的異常表
這些資訊使得JVM足夠針對該方法進行調用,在調用過程,這種結構也能夠友善子類對象的方法直接通過指針引用到父類的一些方法定義,也就是 說指針在記憶體空間之内通過JVM本身的調用使得父類的一些方法表也可以同樣的方式被調用,當然這種調用過程避免不了兩個對象之間的類型檢查,但是這樣的方 式就使得繼承的實作變得更加簡單,而且方法表提供的這些資料足夠引用對對象進行帶有任何OO特征的對象操作。
另外一種資料在上邊的途中沒有顯示出來,也是從邏輯上講記憶體堆中的對象的真實資料結構——對象的鎖。這一點可能需要關聯到JMM模型中講的進行了解。JVM中的每一個對象都是和一個鎖(互斥)相關聯的,這種結構使得該對象可以很容易支援多線程通路,而且該對象的對象鎖一次隻能被一個線程通路。 當一個線程在運作的時候具有某個對象的鎖的時候,僅僅隻有這個線程可以通路該對象的執行個體變量,其他線程如果需要通路該執行個體的執行個體變量就必須等待這個線程将 它占有的對象鎖釋放過後才能夠正常通路,如果一個線程請求了一個被其他線程占有的對象鎖,這個請求線程也必須等到該鎖被釋放過後才能夠拿到這個對象的對象 鎖。一旦這個線程擁有了一個對象鎖過後,它自己可以多次向同一個鎖發送對象的鎖請求,但是如果它要使得被該線程鎖住的對象可以被其他鎖通路到的話就需要同樣的釋放鎖的次數,比如線程A請求了對象B的對象鎖三次,那麼A将會一直占有B對象的對象鎖,直到它将該對象鎖釋放了三次。
很多對象也可能在整個生命周期都沒有被對象鎖鎖住過,在這樣的情況下對象鎖相關的資料是不需要對象内部實作的,除非有線程向該對象請求了對象鎖,否則這個對象就沒有該對象鎖的存儲結構。是以上邊的實作圖可以知道,很多實作不包括指向對象鎖的“鎖資料”,鎖資料的實作必須要等待某個線程向該對象發送了對象鎖請求過後,而且是在第一次鎖請求過後才會被實作。 這個結構中,JVM卻能夠間接地通過一些辦法針對對象的鎖進行管理,比如把對象鎖放在基于對象位址的搜尋樹上邊。實作了鎖結構的對象中,每一個Java對 象邏輯上都在記憶體中成為了一個等待集,這樣就使得所有的線程在鎖結構裡面針對對象内部資料可以獨立操作,等待集就使得每個線程能夠獨立于其他線程去完成一 個共同的設計目标以及程式執行的最終結果,這樣就使得多線程的線程獨享資料以及線程共享資料機制很容易實作。
不僅僅如此,針對記憶體堆對象還必須存在一個對象的鏡像,該鏡像的主要目的是提供給垃圾回收器進行監控操作,垃圾回收器是通過對象的狀态來判斷該對象是否被應用,同樣它需要針對堆内的對象進行監控。而當監控過程垃圾回收器收到對象回收的事件觸發的時候,雖然使用了不同的垃圾回收算法,不論使用什麼算法都需要通過獨有的機制來判斷對象目前處于哪種狀态, 然後根據對象狀态進行操作。開發過程程式員往往不會去仔細分析當一個對象引用設定成為null了過後虛拟機内部的操作,但實際上Java裡面的引用往往不 像我們想像中那麼簡單,Java引用中的虛引用、弱引用就是使得Java引用在顯示送出可回收狀态的情況下對記憶體堆中的對象進行的反向監控,這些引用可以監視到垃圾回收器回收該對象的過程。垃圾回收器本身的實作也是需要記憶體堆中的對象能夠提供相對應的資料的。其實這個位置到底JVM裡面是否使用了完整的Java對象的鏡像還是使用的一個鏡像索引我沒有去仔細分析過,總之是在堆結構裡面存在着堆内對象的一個類似拷貝的鏡像機制,使得垃圾回收器能夠順利回收不再被引用的對象。
4)記憶體棧和記憶體堆的實作原理探測【該部分為不确定概念】:
實際上不論是記憶體棧結構、方法區還是記憶體堆結構,歸根到底使用的是作業系統的記憶體,作業系統的記憶體結構可以了解為記憶體塊,常用的抽象方式就是一個記憶體堆棧,而JVM在OS上邊安裝了過後,就在啟動Java程式的時候按照配置檔案裡面的内容向作業系統申請記憶體空間,該記憶體空間會按照JVM内部的方法提供相應的結構調整。
記憶體棧應該是很容易了解的結構實作,一般情況下,記憶體棧是保持連續的,但是不絕對, 記憶體棧申請到的位址實際上很多情況下都是連續的,而每個位址的最小機關是按照計算機位來算的,該計算機位裡面隻有兩種狀态1和0,而記憶體棧的使用過程就是 典型的類似C++裡面的普通指針結構的使用過程,直接針對指針進行++或者--操作就修改了該指針針對記憶體的偏移量,而這些偏移量就使得該指針可以調用不 同的記憶體棧中的資料。至于針對記憶體棧發送的指令就是常見的計算機指令,而這些指令就使得該指針針對記憶體棧的棧幀進行指令發送,比如發送操作指令、變量讀取等等,直接就使得記憶體棧的調用變得更加簡單,而且棧幀在接受了該資料過後就知道到底針對棧幀内部的哪一個部分進行調用,是操作幀、資料幀還是局部變量。
記憶體堆實際上在作業系統裡面使用了雙向連結清單的資料結構,雙向連結清單的結構使得即使記憶體堆不具有連續性,每一個堆空間裡面的連結清單也可以進入下一 個堆空間,而作業系統本身在整理記憶體堆的時候會做一些簡單的操作,然後通過每一個記憶體堆的雙向連結清單就使得記憶體堆更加友善。而且堆空間不需要有序,甚至說有序不影響堆空間的存儲結構,因為它歸根到底是在記憶體塊上邊進行實作的,記憶體塊本身是一個堆棧結構,隻是該記憶體堆棧裡面的塊如何配置設定不由JVM決定,是由作業系統已經最開始配置設定好了,也就是最小存儲機關。然後JVM拿到從作業系統申請的堆空間過後,先進行初始化操作,然後就可以直接使用了。
常見的對程式有影響的記憶體問題主要是兩種:溢出和記憶體洩漏,上邊已經講過了記憶體洩漏,其實從記憶體的結構分析,洩漏這種情況很難甚至說不可能發生在棧空間裡面,其主要原因是棧空間本身很難出現懸停的記憶體,因為棧空間的存儲結構有可能是記憶體的一個位址數組,是以在通路棧空間的時候使用的都是索引或者下标或者就是最原始的出棧和入棧的操作,這些操作使得棧裡面很難出現像堆空間一樣的記憶體懸停(也就是引用懸挂)問 題。堆空間懸停的記憶體是因為棧中存放的引用的變化,其實引用可以了解為從棧到堆的一個指針,當該指針發生變化的時候,堆記憶體碎片就有可能産生,而這種情況 下在原始語言裡面就經常發生記憶體洩漏的情況,因為這些懸停的堆空間在系統裡面是不能夠被任何本地指針引用到,就使得這些對象在未被回收的時候脫離了可操作 區域并且占用了系統資源。
棧溢出問題一直都是計算機領域裡面的一個安全性問題,這裡不做深入讨論,說多了就偏離主題了,而記憶體洩漏是程式員最容易了解的記憶體問題,還有一個問題來自于我一個黑客朋友就是:堆溢出現象,這種現象可能更加複雜。
其實Java裡面的記憶體結構,最初看來就是堆和棧的結合,實際上可以這樣了解,實際上對象的實際内容才存在對象池裡面,而有關對象的其他東 西有可能會存儲于方法區,而平時使用的時候的引用是存在記憶體棧上的,這樣就更加容易了解它内部的結構,不僅僅如此,有時候還需要考慮到Java裡面的一些 字段和屬性到底是對象域的還是類域的,這個也是一個比較複雜的問題。
二者的差別簡單總結一下:
管理方式:JVM自己可以針對記憶體棧進行管理操作,而且該記憶體空間的釋放是編譯器就可以操作的内容,而堆空間在Java中JVM本身執行引擎不會對其進行釋放操作,而是讓垃圾回收器進行自動回收
空間大小:一般情況下棧空間相對于堆空間而言比較小,這是由棧空間裡面存儲的資料以及本身需要的資料特性決定的,而堆空間在JVM堆執行個體進行配置設定的時候一般大小都比較大,因為堆空間在一個Java程式中需要存儲太多的Java對象資料
碎片相關:針對堆空間而言,即使垃圾回收器能夠進行自動堆記憶體回收,但是堆空間的活動量相對棧空間而言比較 大,很有可能存在長期的堆空間配置設定和釋放操作,而且垃圾回收器不是實時的,它有可能使得堆空間的記憶體碎片主鍵累積起來。針對棧空間而言,因為它本身就是一 個堆棧的資料結構,它的操作都是一一對應的,而且每一個最小機關的結構棧幀和堆空間内複雜的記憶體結構不一樣,是以它一般在使用過程很少出現記憶體碎片。
配置設定方式:一般情況下,棧空間有兩種配置設定方式:靜态配置設定和動态配置設定,靜态配置設定是本身由編譯器配置設定好了,而動态 配置設定可能根據情況有所不同,而堆空間卻是完全的動态配置設定的,是一個運作時級别的記憶體配置設定。而棧空間配置設定的記憶體不需要我們考慮釋放問題,而堆空間即使在有垃 圾回收器的前提下還是要考慮其釋放問題。
效率:因為記憶體塊本身的排列就是一個典型的堆棧結構,是以棧空間的效率自然比起堆空間要高很多,而且計算機底 層記憶體空間本身就使用了最基礎的堆棧結構使得棧空間和底層結構更加符合,它的操作也變得簡單就是最簡單的兩個指令:入棧和出棧;棧空間針對堆空間而言的弱 點是靈活程度不夠,特别是在動态管理的時候。而堆空間最大的優勢在于動态配置設定,因為它在計算機底層實作可能是一個雙向連結清單結構,是以它在管理的時候操作比 棧空間複雜很多,自然它的靈活度就高了,但是這樣的設計也使得堆空間的效率不如棧空間,而且低很多。
3.本機記憶體[部分内容來源于IBM開發中心]
Java堆空間是在編寫Java程式中被我們使用得最頻繁的記憶體空間,平時開發過程,開發人員一定遇到過OutOfMemoryError,這種結果有可能來源于Java堆空間的記憶體洩漏,也可能是因為堆的大小不夠而導緻的,有時候這些錯誤是可以依靠開發人員修複的,但是随着Java程式需要處理越來越多的并發程式,可能有些錯誤就不是那麼容易處理了。有些時候即使Java堆空間沒有滿也可能抛出錯誤,這種情況下需要了解的就是JRE(Java Runtime Environment)内部到底發生了什麼。Java本身的運作宿主環境并不是作業系統,而是Java虛拟機,Java虛拟機本身是用C編寫的本機程式,自然它會調用到本機資源,最常見的就是針對本機記憶體的調用。本機記憶體是可以用于運作時程序的,它和Java應用程式使用的Java堆記憶體不一樣,每一種虛拟化資源都必須存儲在本機記憶體裡面,包括虛拟機本身運作的資料,這樣也意味着主機的硬體和作業系統在本機記憶體的限制将直接影響到Java應用程式的性能。
i.Java運作時如何使用本機記憶體:
1)堆空間和垃圾回收
Java運作時是一個作業系統程序(Windows下一般為java.exe),該環境提供的功能會受一些位置的使用者代碼驅動,這雖然提高了運作時在處理資源的靈活性,但是無法預測每種情況下運作時環境需要何種資源,這一點Java堆空間講解中已經提到過了。在Java指令行可以使用-Xmx和-Xms來控制堆空間初始配置,mx表示堆空間的最大大小,ms表示初始化大小,這也是上提到的啟動Java的配置檔案可以配置的内容。盡管邏輯記憶體堆可以根據堆上的對象數量和在GC上花費的時間增加或者減少,但是使用本機記憶體的大小是保持不變的,而且由-Xms的值指定,大部分GC算法都是依賴被配置設定的連續記憶體塊的堆空間,是以不能在堆需要擴大的時候配置設定更多的本機記憶體,所有的堆記憶體必須保留下來,請注意這裡說的不是Java堆記憶體空間是本機記憶體。
本機記憶體保留和本機記憶體配置設定不一樣,本機記憶體被保留的時候,無法使 用實體記憶體或者其他存儲器作為備用記憶體,盡管保留位址空間塊不會耗盡實體資源,但是會阻止記憶體用于其他用途,由保留從未使用過的記憶體導緻的洩漏和洩漏配置設定 的記憶體造成的問題其嚴重程度差不多,但使用的堆區域縮小時,一些垃圾回收器會回收堆空間的一部分内容,進而減少實體記憶體的使用。對于維護Java堆的記憶體 管理系統,需要更多的本機記憶體來維護它的狀态,進行垃圾收集的時候,必須配置設定資料結構來跟蹤空閑存儲空間和進度記錄,這些資料結構的确切大小和性質因實作的不同而有所差異。
2)JIT
JIT編譯器在運作時編譯Java位元組碼來優化本機可執行代碼,這樣極大提高了Java運作時的速度,并且支援Java應用程式與本地代碼相當的速度運作。位元組碼編譯使用本機記憶體,而且JIT編譯器的輸入(位元組碼)和輸出(可執行代碼)也必須存儲在本機記憶體裡面,包含了多個經過JIT編譯的方法的Java程式會比一些小型應用程式使用更多的本機記憶體。
3)類和類加載器
Java 應用程式由一些類組成,這些類定義對象結構和方法邏輯。Java 應用程式也使用 Java 運作時類庫(比如 java.lang.String)中的類,也可以使用第三方庫。這些類需要存儲在記憶體中以備使用。存儲類的方式取決于具體實作。 Sun JDK 使用永久生成(permanent generation,PermGen)堆區域,從最基本的層面 來看,使用更多的類将需要使用更多記憶體。(這可能意味着您的本機記憶體使用量會增加,或者您必須明确地重新設定 PermGen 或共享類緩存等區域的大小,以裝入所有類)。記住,不僅您的應用程式需要加載到記憶體中,架構、應用伺服器、第三方庫以及包含類的 Java 運作時也會按需加載并占用空間。Java 運作時可以解除安裝類來回收空間,但是隻有在非常嚴酷的條件下才會這樣做,不能解除安裝單個類,而是解除安裝類加載器,随其加載的所有類都會被解除安裝。隻有在以下情況下才能解除安裝類加載器
Java 堆不包含對表示該類加載器的 java.lang.ClassLoader 對象的引用。
Java 堆不包含對表示類加載器加載的類的任何 java.lang.Class 對象的引用。
在 Java 堆上,該類加載器加載的任何類的所有對象都不再存活(被引用)。
需要注意的是,Java 運作時為所有 Java 應用程式建立的 3 個預設類加載器( bootstrap、extension 和 application )都不可能滿足這些條件,是以,任何系統類(比如 java.lang.String)或通過應用程式類加載器加載的任何應用程式類都不能在運作時釋放。 即使類加載器适合進行收集,運作時也隻會将收集類加載器作為 GC 周期的一部分。一些實作隻會在某些 GC 周期中解除安裝類加載器,也可能在運作時生成類,而不去釋放它。許多 Java EE 應用程式使用 JavaServer Pages (JSP) 技術來生成 Web 頁面。使用 JSP 會為執行的每個 .jsp 頁面生成一個類,并且這些類會在加載它們的類加載器的整個生存期中一直存在 —— 這個生存期通常是 Web 應用程式的生存期。另一種生成類的常見方法是使用 Java 反射。反射的工作方式因 Java 實作的不同而不同,當使用 java.lang.reflect API 時,Java 運作時必須将一個反射對象(比如 java.lang.reflect.Field)的方法連接配接到被反射到的對象或類。這可以通過使用 Java 本機接口(Java Native Interface,JNI)訪 問器來完成,這種方法需要的設定很少,但是速度緩慢,也可以在運作時為您想要反射到的每種對象類型動态建構一個類。後一種方法在設定上更慢,但運作速度更 快,非常适合于經常反射到一個特定類的應用程式。Java 運作時在最初幾次反射到一個類時使用 JNI 方法,但當使用了若幹次 JNI 方法之後,通路器會膨脹為位元組碼通路器, 這涉及到建構類并通過新的類加載器進行加載。執行多次反射可能導緻建立了許多通路器類和類加載器,保持對反射對象的引用會導緻這些類一直存活,并繼續占用 空間,因為建立位元組碼通路器非常緩慢,是以 Java 運作時可以緩存這些通路器以備以後使用,一些應用程式和架構還會緩存反射對象,這進一步增加了它們的本機記憶體占用。
4)JNI
JNI支援本機代碼調用Java方法,反之亦然,Java運作時本身極大依賴于JNI代碼來實作類庫功能,比如檔案和網絡I/O,JNI應用程式可以通過三種方式增加Java運作時對本機記憶體的使用:
JNI應用程式的本機代碼被編譯到共享庫中,或編譯為加載到程序位址空間中的可執行檔案,大型本機應用程式可能僅僅加載就會占用大量程序位址空間
本機代碼必須與Java運作時共享位址空間,任何本機代碼配置設定或本機代碼執行的記憶體映射都會耗用Java運作時記憶體
某些JNI函數可能在它們的正常操作中使用本機記憶體,GetTypeArrayElements和GetTypeArrayRegion函數可以将Java堆複制到本機記憶體緩沖區中,提供給本地代碼使用,是否複制資料依賴于運作時實作,通過這種方式通路大量Java堆資料就可能使用大量的本機記憶體堆空間
5)NIO
JDK 1.4開始添加了新的I/O類,引入了一種基于通道和緩沖區執行I/O的新方式,就像Java堆上的記憶體支援I/O緩沖區一樣,NIO添加了對直接 ByteBuffer的支援,ByteBuffer受本機記憶體而不是Java堆的支援,直接ByteBuffer可以直接傳遞到本機作業系統庫函數,以執 行I/O,這種情況雖然提高了Java程式在I/O的執行效率,但是會對本機記憶體進行直接的記憶體開銷。ByteBuffer直接操作和非直接操作的差別如 下:
對于在何處存儲直接 ByteBuffer 資料,很容易産生混淆。應用程式仍然在 Java 堆上使用一個對象來編排 I/O 操作,但持有該資料的緩沖區将儲存在本機記憶體中,Java 堆對象僅包含對本機堆緩沖區的引用。非直接 ByteBuffer 将其資料儲存在 Java 堆上的 byte[] 數組中。直接ByteBuffer對象會自動清理本機緩沖區,但這個過程隻能作為Java堆GC的一部分執行,它不會自動影響施加 在本機上的壓力。GC僅在Java堆被填滿,以至于無法為堆配置設定請求提供服務的時候,或者在Java應用程式中顯示請求它發生。
6)線程:
應用程式中的每個線程都需要記憶體來存儲器堆棧(用于在調用函數時持有局部變量并維護狀态的記憶體區域)。每個 Java 線程都需要堆棧空間來運作。根據實作的不同,Java 線程可以分為本機線程和 Java 堆棧。除了堆棧空間,每個線程還需要為線程本地存儲(thread-local storage)和内部資料結構提供一些本機記憶體。盡管每個線程使用的記憶體量非常小,但對于擁有數百個線程的應用程式來說,線程堆棧的總記憶體使用量可能非常大。如果運作的應用程式的線程數量比可用于處理它們的處理器數量多,效率通常很低,并且可能導緻糟糕的性能和更高的記憶體占用。
ii.本機記憶體耗盡:
Java運作時善于以不同的方式來處理Java堆空間的耗盡和本機堆空間的耗盡, 但是這兩種情形具有類似症狀,當Java堆空間耗盡的時候,Java應用程式很難正常運作,因為Java應用程式必須通過配置設定對象來完成工作,隻要 Java堆被填滿,就會出現糟糕的GC性能,并且抛出OutOfMemoryError。相反,一旦 Java 運作時開始運作并且應用程式處于穩定狀态,它可以在本機堆完全耗盡之後繼續正常運作,不一定會發生奇怪的行為,因為需要配置設定本機記憶體的操作比需要配置設定 Java 堆的操作少得多。盡管需要本機記憶體的操作因 JVM 實作不同而異,但也有一些操作很常見:啟動線程、加載類以及執行某種類型的網絡和檔案 I/O。本機記憶體不足行為與 Java 堆記憶體不足行為也不太一樣,因為無法對本機堆配置設定進行控制,盡管所有 Java 堆配置設定都在 Java 記憶體管理系統控制之下,但任何本機代碼(無論其位于 JVM、Java 類庫還是應用程式代碼中)都可能執行本機記憶體配置設定,而且會失敗。嘗試進行配置設定的代碼然後會處理這種情況,無論設計人員的意圖是什麼:它可能通過 JNI 接口抛出一個 OutOfMemoryError,在螢幕上輸出一條消息,發生無提示失敗并在稍後再試一次,或者執行其他操作。
iii.例子:
這篇文章一緻都在講概念,這裡既然提到了ByteBuffer,先提供一個簡單的例子示範該類的使用:
——[$]使用NIO讀取txt檔案——
package org.susan.java.io;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class ExplicitChannelRead {
public static void main(String args[]){
FileInputStream fileInputStream;
FileChannel fileChannel;
long fileSize;
ByteBuffer byteBuffer;
try{
fileInputStream = new FileInputStream("D://read.txt");
fileChannel = fileInputStream.getChannel();
fileSize = fileChannel.size();
byteBuffer = ByteBuffer.allocate((int)fileSize);
fileChannel.read(byteBuffer);
byteBuffer.rewind();
for( int i = 0; i < fileSize; i++ )
System.out.print((char)byteBuffer.get());
fileChannel.close();
fileInputStream.close();
}catch(IOException ex){
ex.printStackTrace();
}
在讀取檔案的路徑放上該txt檔案裡面寫入:Hello World,上邊這段代碼就是使用NIO的方式讀取檔案系統上的檔案,這段程式的輸入就為:
Hello World
——[$]擷取ByteBuffer上的位元組轉換為Byte數組——
public class ByteBufferToByteArray {
public static void main(String args[]) throws Exception{
// 從byte數組建立ByteBuffer
byte[] bytes = new byte[10];
ByteBuffer buffer = ByteBuffer.wrap(bytes);
// 在position和limit,也就是ByteBuffer緩沖區的首尾之間讀取位元組
bytes = new byte[buffer.remaining()];
buffer.get(bytes, 0, bytes.length);
// 讀取所有ByteBuffer内的位元組
buffer.clear();
bytes = new byte[buffer.capacity()];
上邊代碼就是從ByteBuffer到byte數組的轉換過程,有了這個過程在開發過程中可能更加友善,ByteBuffer的詳細講解我保留到IO部分,這裡僅僅是涉及到了一些,是以提供兩段執行個體代碼。
iv.共享記憶體:
在Java語言裡面,沒有共享記憶體的概念,但是在某些引用中,共享記憶體卻很受用,例如Java語言的分 布式系統,存着大量的Java分布式共享對象,很多時候需要查詢這些對象的狀态,以檢視系統是否運作正常或者了解這些對象目前的一些統計資料和狀态。如果 使用的是網絡通信的方式,顯然會增加應用的額外開銷,也增加了不必要的應用程式設計,如果是共享記憶體方式,則可以直接通過共享記憶體檢視到所需要的對象的資料和 統計資料,進而減少一些不必要的麻煩。
1)共享記憶體特點:
可以被多個程序打開通路
讀寫操作的程序在執行讀寫操作的時候其他程序不能進行寫操作
多個程序可以交替對某一個共享記憶體執行寫操作
一個程序執行了記憶體寫操作過後,不影響其他程序對該記憶體的通路,同時其他程序對更新後的記憶體具有可見性
在程序執行寫操作時如果異常退出,對其他程序的寫操作禁止自動解除
相對共享檔案,資料通路的友善性和效率
2)出現情況:
獨占的寫操作,相應有獨占的寫操作等待隊列。獨占的寫操作本身不會發生資料的一緻性問題;
共享的寫操作,相應有共享的寫操作等待隊列。共享的寫操作則要注意防止發生資料的一緻性問題;
獨占的讀操作,相應有共享的讀操作等待隊列;
共享的讀操作,相應有共享的讀操作等待隊列;
3)Java中共享記憶體的實作:
JDK 1.4裡面的MappedByteBuffer為開發人員在Java中實作共享記憶體提供了良好的方法,該緩沖區實際上是一個磁盤檔案的記憶體映象, 二者的變化會保持同步,即記憶體資料發生變化過後會立即反應到磁盤檔案中,這樣會有效地保證共享記憶體的實作,将共享檔案和磁盤檔案履歷聯系的是檔案通道 類:FileChannel,該類的加入是JDK為了統一外圍裝置的通路方法,并且加強了多線程對同一檔案進行存取的安全性,這裡可以使用它來建立共享内 存用,它建立了共享記憶體和磁盤檔案之間的一個通道。打開一個檔案可使用RandomAccessFile類的getChannel方法,該方法直接傳回一 個檔案通道,該檔案通道由于對應的檔案設為随機存取,一方面可以進行讀寫兩種操作,另外一個方面使用它不會破壞映象檔案的内容。這裡,如果使用 FileOutputStream和FileInputStream則不能理想地實作共享記憶體的要求,因為這兩個類同時實作自由讀寫很困難。
下邊代碼段實作了上邊提及的共享記憶體功能
// 獲得一個隻讀的随機存取檔案對象
RandomAccessFile RAFile = new RandomAccessFile(filename,"r");
// 獲得相應的檔案通道
FileChannel fc = RAFile.getChannel();
// 取得檔案的實際大小
int size = (int)fc.size();
// 獲得共享記憶體緩沖區,該共享記憶體隻讀
MappedByteBuffer mapBuf = fc.map(FileChannel.MAP_RO,0,size);
// 獲得一個可讀寫的随機存取檔案對象
RAFile = new RandomAccessFile(filename,"rw");
// 獲得相應的檔案通道
fc = RAFile.getChannel();
// 取得檔案的實際大小,以便映像到共享記憶體
size = (int)fc.size();
// 獲得共享記憶體緩沖區,該共享記憶體可讀寫
mapBuf = fc.map(FileChannel.MAP_RW,0,size);
// 擷取頭部消息:存取權限
mode = mapBuf.getInt();
如果多個應用映象使用同一檔案名的共享記憶體,則意味着這多個應用共享了同一記憶體資料,這些應用對于檔案可以具有同等存取權限,一個應用對數 據的重新整理會更新到多個應用中。為了防止多個應用同時對共享記憶體進行寫操作,可以在該共享記憶體的頭部資訊加入寫操作标記,該共享檔案的頭部基本資訊至少有:
共享記憶體長度
共享記憶體目前的存取模式
共享檔案的頭部資訊是私有資訊,多個應用可以對同一個共享記憶體執行寫操作,執行寫操作和結束寫操作的時候,可以使用如下方法:
public boolean startWrite()
{
if(mode == 0) // 這裡mode代表共享記憶體的存取模式,為0代表可寫
mode = 1; // 意味着别的應用不可寫
mapBuf.flip();
mapBuf.putInt(mode); //寫入共享記憶體的頭部資訊
return true;
else{
return false; //表明已經有應用在寫該共享記憶體了,本應用不能夠針對共享記憶體再做寫操作
public boolean stopWrite()
mode = 0; // 釋放寫權限
mapBuf.flip();
mapBuf.putInt(mode); //寫入共享記憶體頭部資訊
return true;
【*:上邊提供了對共享記憶體執行寫操作過程的兩個方法,這兩個方法其實了解起來很簡單,真正需要思考的是一個針對存取模式的設定,其實這種機制和最前面提到的記憶體的鎖模式有 點類似,一旦當mode(存取模式)設定稱為可寫的時候,startWrite才能傳回true,不僅僅如此,某個應用程式在向共享記憶體寫入資料的時候還 會修改其存取模式,因為如果不修改的話就會導緻其他應用同樣針對該記憶體是可寫的,這樣就使得共享記憶體的實作變得混亂,而在停止寫操作stopWrite的 時候,需要将mode設定稱為1,也就是上邊注釋段提到的釋放寫權限。】
關于鎖的知識這裡簡單做個補充【*:上邊代碼的這種模式可以了解為一種簡單的鎖模式】:一般情況下,計算機程式設計中會經常遇到鎖模式,在整個鎖模式過程中可以将鎖分為兩類(這裡隻是輔助了解,不是嚴格的鎖分類)——共享鎖和排他鎖(也稱為獨占鎖),鎖的定位是定位于針對所有與計算機有關的資源比如記憶體、檔案、存儲空間等,針對這些資源都可能出現鎖模式。在上邊堆和棧一節講到了Java對象鎖,其實不僅僅是對象,隻要是計算機中會出現寫入和讀取共同操作的資源,都有可能出現鎖模式。
共享鎖——當應用程式獲得了資源的共享鎖的時候,那麼應用程式就可以直接通路該資源,資源的共享鎖可以被多個應用程式拿到,在Java裡面線程之間有時候也存在對象的共享鎖,但是有一個很明顯的特征,也就是記憶體共享鎖隻能讀取資料,不能夠寫入資料,不論是什麼資源,當應用程式僅僅隻能拿到該資源的共享鎖的時候,是不能夠針對該資源進行寫操作的。
獨占鎖——當應用程式獲得了資源的獨占鎖的時候,應用程式通路該資源在共享鎖上邊多了一個權限就是寫權限,針對資源本身而言,一個資源隻有一把獨占鎖,也就是說一個資源隻能同時被一個應用或者一個執行代碼程式允許寫操作,Java線程中的對象寫操作也是這個道理,若某個應用拿到了獨占鎖的時候,不僅僅可以讀取資源裡面的資料,而且可以向該資源進行資料寫操作。
資料一緻性——當資源同時被應用進行讀寫通路的時候,有可能會出現資料一緻性問題,比如A應用拿到了資 源R1的獨占鎖,B應用拿到了資源R1的共享鎖,A在針對R1進行寫操作,而兩個應用的操作——A的寫操作和B的讀操作出現了一個時間差,s1的時候B讀 取了R1的資源,s2的時候A寫入了資料修改了R1的資源,s3的時候B又進行了第二次讀,而兩次讀取相隔時間比較短暫而且初衷沒有考慮到A在B的讀取過 程修改了資源,這種情況下針對鎖模式就需要考慮到資料一緻性問題。獨占鎖的排他性在這裡的意思是該鎖隻能被一個應用擷取,擷取過程隻能由這個應用寫入資料 到資源内部,除非它釋放該鎖,否則其他拿不到鎖的應用是無法對資源進行寫入操作的。
按照上邊的思路去了解代碼裡面實作共享記憶體的過程就更加容易了解了。
如果執行寫操作的應用異常中止,那麼映像檔案的共享記憶體将不再能執行寫操作。為了在應用異常中止後,寫操作禁止标志自動消除,必須讓運作的 應用獲知退出的應用。在多線程應用中,可以用同步方法獲得這樣的效果,但是在多程序中,同步是不起作用的。方法可以采用的多種技巧,這裡隻是描述一可能的 實作:采用檔案鎖的方式。寫共享記憶體應用在獲得對一個共享記憶體寫權限的時候,除了判斷頭部資訊的寫權限标志外,還要判斷一個臨時的鎖檔案是否可以得到,如果可以得到,則即使頭部資訊的寫權限标志為1(上述),也可以啟動寫權限,其實這已經表明寫權限獲得的應用已經異常退出,這段代碼如下:
// 打開一個臨時檔案,注意統一共享記憶體,該檔案名必須相同,可以在共享檔案名後邊添加“.lock”字尾
RandomAccessFile files = new RandomAccessFile("memory.lock","rw");
// 擷取檔案通道
FileChannel lockFileChannel = files.getChannel();
// 擷取檔案的獨占鎖,該方法不産生任何阻塞直接傳回
FileLock fileLock = lockFileChannel.tryLock();
// 如果為空表示已經有應用占有了
if( fileLock == null ){
// ...不可寫
}else{
// ...可以執行寫操作
4)共享記憶體的應用:
在Java中,共享記憶體一般有兩種應用:
[1]永久對象配置——在java伺服器應用中,使用者可能會在運作過程中配置一些參數,而這些參數需要 永久 有效,當伺服器應用重新啟動後,這些配置參數仍然可以對應用起作用。這就可以用到該文 中的共享記憶體。該共享記憶體中儲存了伺服器的運作參數和一些對象運作特性。可以在應用啟動時讀入以啟用以前配置的參數。
[2]查詢共享資料——一個應用(例 sys.java)是系統的服務程序,其系統的運作狀态記錄在共享記憶體中,其中運作狀态可能是不斷變化的。為了随時了解系統的運作狀态,啟動另一個應用(例 mon.java),該應用查詢該共享記憶體,彙報系統的運作狀态。
v.小節:
提供本機記憶體以及共享記憶體的知識,主要是為了讓讀者能夠更順利地了解JVM内部記憶體模型的實體原理,包括JVM如何和作業系統在記憶體這個級 别進行互動,了解了這些内容就讓讀者對Java記憶體模型的認識會更加深入,而且不容易遺忘。其實Java的記憶體模型遠不及我們想象中那麼簡單,而且其結構 極端複雜,看過《Inside JVM》的朋友應該就知道,結合JVM指令集去寫點小代碼測試.class檔案的裡層結構也不失為一種好玩的學習方法。
Java中會有記憶體洩漏,聽起來似乎是很不正常的,因為Java提供了垃圾回收器針對記憶體進行自動回收,但是Java還是會出現記憶體洩漏的。
i.什麼是Java中的記憶體洩漏:
在Java語言中,記憶體洩漏就是存在一些被配置設定的對象,這些對象有兩個特點:這些對象可達,即在對象記憶體的有向圖中存在通路可以與其相連;其次,這些對象是無用的,即程式以後不會再使用這些對象了。如果對象滿足這兩個條件,該對象就可以判定為Java中的記憶體洩漏,這些對象不會被GC回收,然而它卻占用記憶體,這就是Java語言中的記憶體洩漏。 Java中的記憶體洩漏和C++中的記憶體洩漏還存在一定的差別,在C++裡面,記憶體洩漏的範圍更大一些,有些對象被配置設定了記憶體空間,但是卻不可達,由于 C++中沒有GC,這些記憶體将會永遠收不回來,在Java中這些不可達對象則是被GC負責回收的,是以程式員不需要考慮這一部分的記憶體洩漏。二者的圖如 下:
是以按照上邊的分析,Java語言中也是存在記憶體洩漏的,但是其記憶體洩漏範圍比C++要小很多,因為Java裡面有個特殊程式回收所有的不可達對象:垃圾回收器。對于程式員來說,GC基本是透明的,不可見的。雖然,我們隻有幾個函數可以通路GC,例如運作GC的函數System.gc(),但是根據Java語言規範定義,該函數不保證JVM的垃圾收集器一定會執行。因為,不同的JVM實作者可能使用不同的算法管理GC。通常,GC的線程的優先級别較低,JVM調用GC的政策也有很多種,有的是記憶體使用到達一定程度時,GC才開始工作,也有定時執行的,有的是平緩執行GC,有的是中斷式執行GC。 但通常來說,我們不需要關心這些。除非在一些特定的場合,GC的執行影響應用程式的性能,例如對于基于Web的實時系統,如網絡遊戲等,使用者不希望GC突 然中斷應用程式執行而進行垃圾回收,那麼我們需要調整GC的參數,讓GC能夠通過平緩的方式釋放記憶體,例如将垃圾回收分解為一系列的小步驟執行,Sun提 供的HotSpot JVM就支援這一特性。
舉個例子:
——[$]記憶體洩漏的例子——
package org.susan.java.collection;
import java.util.Vector;
public class VectorMemoryLeak {
Vector<String> vector = new Vector<String>();
for( int i = 0; i < 1000; i++ ){
String tempString = new String();
vector.add(tempString);
tempString = null;
從上邊這個例子可以看到,循環申請了String對象,并且将申請的對象放入了一個Vector中,如果僅僅是釋放對象本身,因為 Vector仍然引用了該對象,是以這個對象對CG來說是不可回收的,是以如果對象加入到Vector後,還必須從Vector删除才能夠回收,最簡單的 方式是将Vector引用設定成null。實際上這些對象已經沒有用了,但是還是被代碼裡面的引用引用到了,這種情況GC拿它就沒有了任何辦法,這樣就可以導緻了記憶體洩漏。
【*:Java語言因為提供了垃圾回收器,照理說是不會出現記憶體洩漏的,Java裡面導緻記憶體洩漏的主要原因就是,先前申請了記憶體空間而忘 記了釋放。如果程式中存在對無用對象的引用,這些對象就會駐留在記憶體中消耗記憶體,因為無法讓GC判斷這些對象是否可達。如果存在對象的引用,這個對象就被 定義為“有效的活動狀态”,同時不會被釋放,要确定對象所占記憶體被回收,必須要确認該對象不再被使用。典型的做法就是把對象資料成員設定成為null或者 中集合中移除,當局部變量不需要的情況則不需要顯示聲明為null。】
ii.常見的Java記憶體洩漏
1)全局集合:
在大型應用程式中存在各種各樣的全局資料倉庫是很普遍的,比如一個JNDI樹或者一個Session table(會話表),在這些情況下,必須注意管理存儲庫的大小,必須有某種機制從存儲庫中移除不再需要的資料。
[$]解決:
[1]常用的解決方法是周期運作清除作業,該作業會驗證倉庫中的資料然後清楚一切不需要的資料
[2]另外一種方式是反向連結計數,集合負責統計集合中每個入口的反向連結資料,這要求反向連結告訴集合合适會退出入口,當反向連結數目為零的時候,該元素就可以移除了。
2)緩存:
緩存一種用來快速查找已經執行過的操作結果的資料結構。是以,如果一個操作執行需要比較多的資源并會多次被使用,通常做法是把常用的輸入數 據的操作結果進行緩存,以便在下次調用該操作時使用緩存的資料。緩存通常都是以動态方式實作的,如果緩存設定不正确而大量使用緩存的話則會出現記憶體溢出的 後果,是以需要将所使用的記憶體容量與檢索資料的速度加以平衡。
[1]常用的解決途徑是使用java.lang.ref.SoftReference類堅持将對象放入緩存,這個方法可以保證當虛拟機用完記憶體或者需要更多堆的時候,可以釋放這些對象的引用。
3)類加載器:
Java類裝載器的使用為記憶體洩漏提供了許多可乘之機。一般來說類裝載器都具有複雜結構,因為類裝載器不僅僅是隻與"正常"對象引用有關,同時也和對象内部的引用有關。比如資料變量,方法和各種類。這意味着隻要存在對資料變量,方法,各種類和對象的類裝載器,那麼類裝載器将駐留在JVM中。既然類裝載器可以同很多的類關聯,同時也可以和靜态資料變量關聯,那麼相當多的記憶體就可能發生洩漏。
iii.Java引用【摘錄自前邊的《Java引用總結》】:
Java中的對象引用主要有以下幾種類型:
1)強可及對象(strongly reachable):
可以通過強引用通路的對象,一般來說,我們平時寫代碼的方式都是使用的強引用對象,比如下邊的代碼段:
StringBuilder builder= new StringBuilder();
上邊代碼部分引用obj這個引用将引用記憶體堆中的一個對象,這種情況下,隻要obj的引用存在,垃圾回收器就永遠不會釋放該對象的存儲空間。這種對象我們又成為強引用(Strong references), 這種強引用方式就是Java語言的原生的Java引用,我們幾乎每天程式設計的時候都用到。上邊代碼JVM存儲了一個StringBuilder類型的對象的 強引用在變量builder呢。強引用和GC的互動是這樣的,如果一個對象通過強引用可達或者通過強引用鍊可達的話這種對象就成為強可及對象,這種情況下 的對象垃圾回收器不予理睬。如果我們開發過程不需要垃圾回器回收該對象,就直接将該對象賦為強引用,也是普通的程式設計方法。
2)軟可及對象(softly reachable):
不通過強引用通路的對象,即不是強可及對象,但是可以通過軟引用通路的對象就成為軟可及對象,軟可及對象就需要使用類SoftReference(java.lang.ref.SoftReference)。此種類型的引用主要用于記憶體比較敏感的高速緩存,而且此種引用還是具有較強的引用功能,當記憶體不夠的時候GC會回收這類記憶體,是以如果記憶體充足的時候,這種引用通常不會被回收的。不僅僅如此,這種引用對象在JVM裡面保證在抛出OutOfMemory異常之前,設定成為null。 通俗地講,這種類型的引用保證在JVM記憶體不足的時候全部被清除,但是有個關鍵在于:垃圾收集器在運作時是否釋放軟可及對象是不确定的,而且使用垃圾回收 算法并不能保證一次性尋找到所有的軟可及對象。當垃圾回收器每次運作的時候都可以随意釋放不是強可及對象占用的記憶體,如果垃圾回收器找到了軟可及對象過 後,可能會進行以下操作:
将SoftReference對象的referent域設定成為null,進而使該對象不再引用heap對象。
SoftReference引用過的記憶體堆上的對象一律被生命為finalizable。
當記憶體堆上的對象finalize()方法被運作而且該對象占用的記憶體被釋放,SoftReference對象就會被添加到它的ReferenceQueue,前提條件是ReferenceQueue本身是存在的。
既然Java裡面存在這樣的對象,那麼我們在編寫代碼的時候如何建立這樣的對象呢?建立步驟如下:
先建立一個對象,并使用普通引用方式【強引用】,然後再建立一個SoftReference來引用該對象,最後将普通引用設定為null, 通過這樣的方式,這個對象就僅僅保留了一個SoftReference引用,同時這種情況我們所建立的對象就是SoftReference對象。一般情況 下,我們可以使用該引用來完成Cache功能,就是前邊說的用于高速緩存,保證最大限度使用記憶體而不會引起記憶體洩漏的情況。下邊的代碼段:
public static void main(String args[])
{
//建立一個強可及對象
A a = new A();
//建立這個對象的軟引用SoftReference
SoftReference sr = new SoftReference(a);
//将強引用設定為空,以遍垃圾回收器回收強引用
a = null;
//下次使用該對象的操作
if( sr != null ){
a = (A)sr.get();
}else{
//這種情況就是由于記憶體過低,已經将軟引用釋放了,是以需要重新裝載一次
a = new A();
sr = new SoftReference(a);
}
}
軟引用技術使得Java系統可以更好地管理記憶體,保持系統穩定,防止記憶體洩漏,避免系統崩潰,是以在處理一些記憶體占用大而且生命周期長使用不頻繁的對象可以使用該技術。
3)弱可及對象(weakly reachable):
不是強可及對象同樣也不是軟可及對象,僅僅通過弱引用WeakReference(java.lang.ref.WeakReference)通路的對象,這種對象的用途在于規範化映射(canonicalized mapping),對于生存周期相對比較長而且重新建立的時候開銷少的對象,弱引用也比較有用,和軟引用對象不同的是,垃圾回收器如果碰到了弱可及對象,将釋放WeakReference對象的記憶體,但是垃圾回收器需要運作很多次才能夠找到弱可及對象。弱引用對象在使用的時候,可以配合ReferenceQueue類使用,如果弱引用被回收,JVM就會把這個弱引用加入到相關的引用隊列中去。最簡單的弱引用方法如以下代碼:
WeakReference weakWidget = new WeakReference(classA);
在上邊代碼裡面,當我們使用weakWidget.get()來擷取classA的時候,由于弱引用本身是無法阻止垃圾回收的,是以我們也許會拿到一個null為傳回。【*:這裡提供一個小技巧,如果我們希望取得某個對象的資訊,但是又不影響該對象的垃圾回收過程,我們就可以使用WeakReference來記住該對象,一般我們在開發調試器和優化器的時候使用這個是很好的一個手段。】
如果上邊的代碼部分,我們通過weakWidget.get()傳回的是null就證明該對象已經被垃圾回收器回收了,而這種情況下弱引用對象就失去了使用價值,GC就會定義為需要進行清除工作。這種情況下弱引用無法引用任何對象,是以在JVM裡面就成為了一個死引用, 這就是為什麼我們有時候需要通過ReferenceQueue類來配合使用的原因,使用了ReferenceQueue過後,就使得我們更加容易監視該引 用的對象,如果我們通過一ReferenceQueue類來構造一個弱引用,當弱引用的對象已經被回收的時候,系統将自動使用對象引用隊列來代替對象引 用,而且我們可以通過ReferenceQueue類的運作來決定是否真正要從垃圾回收器裡面将該死引用(Dead Reference)清除。
弱引用代碼段:
//建立普通引用對象
MyObject object = new MyObject();
//建立一個引用隊列
ReferenceQueue rq = new ReferenceQueue();
//使用引用隊列建立MyObject的弱引用
WeakReference wr = new WeakReference(object,rq);
這裡提供兩個實在的場景來描述弱引用的相關用法:
[1]你想給對象附加一些資訊,于是你用一個 Hashtable 把對象和附加資訊關聯起來。你不停的把對象和附加資訊放入 Hashtable 中,但是當對象用完的時候,你不得不把對象再從 Hashtable 中移除,否則它占用的記憶體變不會釋放。萬一你忘記了,那麼沒有從 Hashtable 中移除的對象也可以算作是記憶體洩漏。理想的狀況應該是當對象用完時,Hashtable 中的對象會自動被垃圾收集器回收,不然你就是在做垃圾回收的工作。
[2]你想實作一個圖檔緩存,因為加載圖檔的開銷比較大。你将圖檔對象的引用放入這個緩存,以便以後能 夠重新使用這個對象。但是你必須決定緩存中的哪些圖檔不再需要了,進而将引用從緩存中移除。不管你使用什麼管理緩存的算法,你實際上都在處理垃圾收集的工 作,更簡單的辦法(除非你有特殊的需求,這也應該是最好的辦法)是讓垃圾收集器來處理,由它來決定回收哪個對象。
當Java回收器遇到了弱引用的時候有可能會執行以下操作:
将WeakReference對象的referent域設定成為null,進而使該對象不再引用heap對象。
WeakReference引用過的記憶體堆上的對象一律被生命為finalizable。
當記憶體堆上的對象finalize()方法被運作而且該對象占用的記憶體被釋放,WeakReference對象就會被添加到它的ReferenceQueue,前提條件是ReferenceQueue本身是存在的。
4)清除:
當引用對象的referent域設定為null,并且引用類在記憶體堆中引用的對象聲明為可結束的時候,該對象就可以清除,清除不做過多的講述
5)虛可及對象(phantomly reachable):
不是強可及對象,也不是軟可及對象,同樣不是弱可及對象,之是以把虛可及對象放到最後來講,主要也是因為它的特殊性,有時候我們又稱之為“幽靈對象”, 已經結束的,可以通過虛引用來通路該對象。我們使用類 PhantomReference(java.lang.ref.PhantomReference)來通路,這個類隻能用于跟蹤被引用對象進行的收集, 同樣的,可以用于執行per-mortern清除操作。PhantomReference必須與ReferenceQueue類一起使用。需要使用 ReferenceQueue是因為它能夠充當通知機制,當垃圾收集器确定了某個對象是虛可及對象的時候,PhantomReference對象就被放在 了它的ReferenceQueue上,這就是一個通知,表明PhantomReference引用的對象已經結束,可以收集了,一般情況下我們剛好在對 象記憶體在回收之前采取該行為。這種引用不同于弱引用和軟引用,這種方式通過get()擷取到的對象總是傳回null,僅僅當這些對象在 ReferenceQueue隊列裡面的時候,我們可以知道它所引用的哪些對對象是死引用(Dead Reference)。而這種引用和弱引用的差別在于:
弱引用(WeakReference)是在對象不可達的時候盡快進入ReferenceQueue隊列的,在finalization方法執行和垃圾回收之前是确實會發生的,理論上這類對象是不正确的對象,但是WeakReference對象可以繼續保持Dead狀态,
虛引用(PhantomReference)是在對象确實已經從實體記憶體中移除過後才進入的ReferenceQueue隊列,而且get()方法會一直傳回null
當垃圾回收器遇到了虛引用的時候将有可能執行以下操作:
PhantomReference引用過的heap對象聲明為finalizable;
虛引用在堆對象釋放之前就添加到了它的ReferenceQueue裡面,這種情況使得我們可以在堆對象被回收之前采取操作【*:再次提醒,PhantomReference對象必須經過關聯的ReferenceQueue來建立,就是說必須和ReferenceQueue類配合操作】
看似沒有用處的虛引用,有什麼用途呢?
首先,我們可以通過虛引用知道對象究竟什麼時候真正從記憶體裡面移除的,而且這也是唯一的途徑。
虛引用避過了finalize()方法,因為對于此方法的執行而言,虛引用真正引用到的對象是異常對象,若在該方法内要使用對象隻能重建。一般情況垃圾回收器會輪詢兩次, 一次标記為finalization,第二次進行真實的回收,而往往标記工作不能實時進行,或者垃圾回收其會等待一個對象去标記 finalization。這種情況很有可能引起MemoryOut,而使用虛引用這種情況就會完全避免。因為虛引用在引用對象的過程不會去使得這個對象 由Dead複活,而且這種對象是可以在回收周期進行回收的。
在JVM内部,虛引用比起使用finalize()方法更加安全一點而且更加有效。而finaliaze()方法回收在虛拟機裡面實作起來 相對簡單,而且也可以處理大部分工作,是以我們仍然使用這種方式來進行對象回收的掃尾操作,但是有了虛引用過後我們可以選擇是否手動操作該對象使得程式更 加高效完美。
iv.防止記憶體洩漏[來自IBM開發中心]:
1)使用軟引用阻止洩漏:
[1]在Java語言中有一種形式的記憶體洩漏稱為對象遊離(Object Loitering):
——[$]對象遊離——
// 注意,這段代碼屬于概念說明代碼,實際應用中不要模仿
public class LeakyChecksum{
private byte[] byteArray;
public synchronized int getFileCheckSum(String filename)
int len = getFileSize(filename);
if( byteArray == null || byteArray.length < len )
byteArray = new byte[len];
readFileContents(filename,byteArray);
// 計算該檔案的值然後傳回該對象
上邊的代碼是類LeakyChecksum用來說明對象遊離的概念,裡面有一個getFileChecksum()方法用來計算檔案内容校驗和,getFileCheckSum方法将檔案内容讀取到緩沖區中計算校驗和,更加直覺的實作就是簡單地将緩沖區作為getFileChecksum中的本地變量配置設定,但是上邊這個版本比這種版本更加“聰明”,不是将緩沖區緩沖在執行個體中字段中減少記憶體churn。該“優化”通 常不帶來預期的好處,對象配置設定比很多人期望的更加便宜。(還要注意,将緩沖區從本地變量提升到執行個體變量,使得類若不帶有附加的同步,就不再是線程安全的 了。直覺的實作不需要将 getFileChecksum() 聲明為 synchronized,并且會在同時調用時提供更好的可伸縮性。)
這個類存在很多的問題,但是我們着重來看記憶體洩漏。緩存緩沖區的決定很可能是根據這樣的假設得出的,即該類将在一個程式中被調用許多次,因 此它應該更加有效,以重用緩沖區而不是重新配置設定它。但是結果是,緩沖區永遠不會被釋放,因為它對程式來說總是可及的(除非LeakyChecksum對象 被垃圾收集了)。更壞的是,它可以增長,卻不可以縮小,是以 LeakyChecksum 将永久保持一個與所處理的最大檔案一樣大小的緩沖區。退一萬步說,這也會給垃圾收集器帶來壓力,并且要求更頻繁的收集;為計算未來的校驗和而保持一個大型 緩沖區并不是可用記憶體的最有效利用。LeakyChecksum 中問題的原因是,緩沖區對于 getFileChecksum() 操作來說邏輯上是本地的,但是它的生命周期已經被人為延長了,因為将它提升到了執行個體字段。是以,該類必須自己管理緩沖區的生命周期,而不是讓 JVM 來管理。
這裡可以提供一種政策就是使用Java裡面的軟引用:
弱引用如何可以給應用程式提供當對象被程式使用時另一種到達該對象的方法,但是不會延長對象的生命周期。Reference 的另一個子類——軟引用——可滿足一個不同卻相關的目的。其中弱引用允許應用程式建立不妨礙垃圾收集的引用,軟引用允 許應用程式通過将一些對象指定為 “expendable” 而利用垃圾收集器的幫助。盡管垃圾收集器在找出哪些記憶體在由應用程式使用哪些沒在使用方面做得很好,但是确定可用記憶體的最适當使用還是取決于應用程式。如 果應用程式做出了不好的決定,使得對象被保持,那麼性能會受到影響,因為垃圾收集器必須更加辛勤地工作,以防止應用程式消耗掉所有記憶體。高速緩存是 一種常見的性能優化,允許應用程式重用以前的計算結果,而不是重新進行計算。高速緩存是 CPU 利用和記憶體使用之間的一種折衷,這種折衷理想的平衡狀态取決于有多少記憶體可用。若高速緩存太少,則所要求的性能優勢無法達到;若太多,則性能會受到影響, 因為太多的記憶體被用于高速緩存上,導緻其他用途沒有足夠的可用記憶體。因為垃圾收集器比應用程式更适合決定記憶體需求,是以應該利用垃圾收集器在做這些決定方 面的幫助,這就是件引用所要做的。如果一個對象惟一剩下的引用是弱引用或軟引用,那麼該對象是軟可及的(softly reachable)。垃圾收集器并不像其收集弱可及的對象一樣盡量地收集軟可及的對象,相反,它隻在真正 “需要” 内 存時才收集軟可及的對象。軟引用對于垃圾收集器來說是這樣一種方式,即 “隻要記憶體不太緊張,我就會保留該對象。但是如果記憶體變得真正緊張了,我就會去收 集并處理這個對象。” 垃圾收集器在可以抛出OutOfMemoryError 之前需要清除所有的軟引用。通過使用一個軟引用來管理高速緩存的緩沖區, 可以解決 LeakyChecksum中的問題,如上邊代碼所示。現在,隻要不是特别需要記憶體,緩沖區就會被保留,但是在需要時,也可被垃圾收集器回收:
——[$]使用軟引用修複上邊代碼段——
public class CachingChecksum
private SoftReference<byte[]> bufferRef;
public synchronized int getFileChecksum(String filename)
byte[] byteArray = bufferRef.get();
{
byteArray = new byte[len];
bufferRef.set(byteArray);
一種廉價緩存:
CachingChecksum使用一個軟引用來緩存單個對象,并讓 JVM 處理從緩存中取走對象時的細節。類似地,軟引用也經常用于 GUI 應用程式中,用于緩存位圖圖形。 是否可使用軟引用的關鍵在于,應用程式是否可從大量緩存的資料恢複。如果需要緩存不止一個對象,您可以使用一個 Map,但是可以選擇如何使用軟引用。您 可以将緩存作為 Map<K, SoftReference<V>> 或SoftReference<Map<K,V>> 管理。後一種選項 通常更好一些,因為它給垃圾收集器帶來的工作更少,并且允許在特别需要記憶體時以較少的工作回收整個緩存。弱引用有時會錯誤地用于取代軟引用,用于建構緩 存,但是這會導緻差的緩存性能。在實踐中,弱引用将在對象變得弱可及之後被很快地清除掉——通常是在緩存的對象再次用到之前——因為小的垃圾收集運作得很 頻繁。對于在性能上非常依賴高速緩存的應用程式來說,軟引用是一個不管用的手段,它确實不能取代能夠提供靈活終止期、複制和事務型高速緩存的複雜的高速緩存架構。但是作為一種 “廉價(cheap and dirty)” 的高速緩存機制,它對于降低價格是很有吸引力的。正如弱引用一樣,軟引用也可建立為具有一個相關的引用隊列,引用在被垃圾收集器清除時進入隊列。引用隊列對于軟引用來說,沒有對弱引用那麼有用,但是它們可以用于發出管理警報,說明應用程式開始缺少記憶體。
2)垃圾回收對引用的處理:
弱引用和軟引用都擴充了抽象的 Reference 類虛引用(phantom references),引 用對象被垃圾收集器特殊地看待。垃圾收集器在跟蹤堆期間遇到一個 Reference 時,不會标記或跟蹤該引用對象,而是在已知活躍 的 Reference 對象的隊列上放置一個 Reference。在跟蹤之後,垃圾收集器就識别軟可及的對象——這些對象上除了軟引用外,沒有任何強 引用。垃圾收集器然後根據目前收集所回收的記憶體總量和其他政策考慮因素,判斷軟引用此時是否需要被清除。将被清除的軟引用如果具有相應的引用隊列,就會進 入隊列。其餘的軟可及對象(沒有清除的對象)然後被看作一個根集(root set),堆跟蹤繼續使用這些新的根,以便通過活躍的軟引用而可及的對象能夠被标記。處理軟引用之後,弱可及對象的集合被識别 —— 這樣的對象上不存在強引用或軟引用。這些對象被清除和加入隊列。所有 Reference 類型在加入隊列之前被清除, 是以處理事後檢查(post-mortem)清除的線程永遠不會具有 referent 對象的通路權,而隻具有Reference 對象的通路權。是以,當 References 與引用隊列一起使用時,通常需要細分适當的引用類型,并将它 直接用于您的設計中(與 WeakHashMap 一樣,它的 Map.Entry 擴充了 WeakReference)或者存儲對需要清除的實體的引 用。
3)使用弱引用堵住記憶體洩漏:
[1]全局Map造成的記憶體洩漏:
無意識對象保留最常見的原因是使用 Map 将中繼資料與臨時對象(transient object)相 關聯。假定一個對象具有中等生命周期,比配置設定它的那個方法調用的生命周期長,但是比應用程式的生命周期短,如客戶機的套接字連接配接。需要将一些中繼資料與這個 套接字關聯,如生成連接配接的使用者的辨別。在建立 Socket 時是不知道這些資訊的,并且不能将資料添加到 Socket 對象上,因為不能控 制 Socket 類或者它的子類。這時,典型的方法就是在一個全局 Map 中存儲這些資訊:
public class SocketManager{
private Map<Socket,User> m = new HashMap<Socket,User>();
public void setUser(Socket s,User u)
m.put(s,u);
public User getUser(Socket s){
return m.get(s);
public void removeUser(Socket s){
m.remove(s);
SocketManager socketManager;
//...
socketManager.setUser(socket,user);
這種方法的問題是中繼資料的生命周期需要與套接字的生命周期挂鈎,但是除非準确地知道什麼時候程式不再需要這個套接字,并記住從 Map 中删除相應的映射,否則,Socket 和 User 對象将會永遠留在 Map 中,遠遠超過響應了請求和關閉套接字的時間。這會阻止 Socket 和User 對象被垃圾收集,即使應用程式不會再使用它們。這些對象留下來不受控制,很容易造成程式在長時間運作後記憶體爆滿。除了最簡單的情況,在幾乎所有情況下找出 什麼時候 Socket 不再被程式使用是一件很煩人和容易出錯的任務,需要人工對記憶體進行管理。
[2]弱引用記憶體洩漏代碼:
程式有記憶體洩漏的第一個迹象通常是它抛出一個 OutOfMemoryError,或者因為頻繁的垃圾收集而表現出糟糕的性能。幸運的是, 垃圾收集可以提供能夠用來診斷記憶體洩漏的大量資訊。如果以 -verbose:gc 或者 -Xloggc 選項調用 JVM,那麼每次 GC 運作時在控制台上或者日志檔案中會列印出一個診斷資訊,包括它所花費的時間、目前堆使用情況以及恢複了多少記憶體。記錄 GC 使用情況并不具有幹擾性,是以如果需要分析記憶體問題或者調優垃圾收集器,在生産環境中預設啟用 GC 日志是值得的。有工具可以利用 GC 日志輸出并以圖形方式将它顯示出來,JTune 就是這樣的一種工具。觀察 GC 之後堆大小的圖,可以看到程式記憶體使用的趨勢。對于大多數程式來說,可以将記憶體使用分為兩部分:baseline 使用和 current load 使用。對于伺服器應用程式,baseline 使用就是應用程式在沒有任何負荷、但是已經準備好接受請求時的記憶體使用,current load 使用是在處理請求過程中使用的、但是在請求處理完成後會釋放的記憶體。隻要負荷大體上是恒定的,應用程式通常會很快達到一個穩定的記憶體使用水準。 如果在應用程式已經完成了其初始化并且負荷沒有增加的情況下,記憶體使用持續增加,那麼程式就可能在處理前面的請求時保留了生成的對象。
public class MapLeaker{
public ExecuteService exec = Executors.newFixedThreadPool(5);
public Map<Task,TaskStatus> taskStatus
= Collections.synchronizedMap(new HashMap<Task,TaskStatus>());
private Random random = new Random();
private enum TaskStatus { NOT_STARTED, STARTED, FINISHED };
private class Task implements Runnable{
private int[] numbers = new int[random.nextInt(200)];
public void run()
int[] temp = new int[random.nextInt(10000)];
taskStatus.put(this,TaskStatus.STARTED);
doSomework();
taskStatus.put(this,TaskStatus.FINISHED);
public Task newTask()
Task t = new Task();
taskStatus.put(t,TaskStatus.NOT_STARTED);
exec.execute(t);
return t;
[3]使用弱引用堵住記憶體洩漏:
SocketManager 的問題是 Socket-User 映射的生命周期應當與 Socket 的生命周期相比對,但是語言沒有提供任何容易的方法實施這項規則。這使得程式不得不使用人工記憶體管理的老技術。幸運的是,從 JDK 1.2 開始,垃圾收集器提供了一種聲明這種對象生命周期依賴性的方法,這樣垃圾收集器就可以幫助我們防止這種記憶體洩漏——利用弱引用。弱引用是對一個對象(稱為 referent)的 引用的持有者。使用弱引用後,可以維持對 referent 的引用,而不會阻止它被垃圾收集。當垃圾收集器跟蹤堆的時候,如果對一個對象的引用隻有弱引用,那麼這個 referent 就會成為垃圾收集的候選對象,就像沒有任何剩餘的引用一樣,而且所有剩餘的弱引用都被清除。(隻有弱引用的對象稱為弱可及(weakly reachable))WeakReference 的 referent 是在構造時設定的,在沒有被清除之前,可以用 get() 擷取它的值。如果弱引用被清除了(不管是 referent 已經被垃圾收集了,還是有人調用了 WeakReference.clear()),get() 會傳回 null。相應地,在使用其結果之前,應當總是檢查get() 是否傳回一個非 null 值, 因為 referent 最終總是會被垃圾收集的。用一個普通的(強)引用拷貝一個對象引用時,限制 referent 的生命周期至少與被拷貝的引用的生命周期一樣長。如果不小心,那麼它可能就與程式的生命周期一樣——如果将一個對象放入一個全局集合中的話。另一方面,在 建立對一個對象的弱引用時,完全沒有擴充 referent 的生命周期,隻是在對象仍然存活的時候,保持另一種到達它的方法。弱引用對于構造弱集合最有用,如那些在應用程式的其餘部分使用對象期間存儲關于這些對象 的中繼資料的集合——這就是 SocketManager 類所要做的工作。因為這是弱引用最常見的用法,WeakHashMap 也被添加到 JDK 1.2 的 類庫中,它對鍵(而不是對值)使用弱引用。如果在一個普通 HashMap 中用一個對象作為鍵,那麼這個對象在映射從 Map 中删除之前不能被回收,WeakHashMap 使您可以用一個對象作為 Map 鍵,同時不會阻止這個對象被垃圾收集。下邊的代碼給出了 WeakHashMap 的 get() 方法的一種可能實作,它展示了弱引用的使用:
public class WeakHashMap<K,V> implements Map<K,V>
private static class Entry<K,V> extends WeakReference<K> implements Map.Entry<K,V>
private V value;
private final int hash;
private Entry<K,V> next;
// ...
public V get(Object key)
int hash = getHash(key);
Entry<K,V> e = getChain(hash);
while(e != null)
k eKey = e.get();
if( e.hash == hash && (key == eKey || key.equals(eKey)))
return e.value;
e = e.next;
return null;
調用 WeakReference.get() 時,它傳回一個對 referent 的強引用(如果 它仍然存活的話),是以不需要擔心映射在 while 循環體中消失,因為強引用會防止它被垃圾收集。WeakHashMap 的實作展示了弱引用的一種 常見用法——一些内部對象擴充 WeakReference。其原因在下面一節讨論引用隊列時會得到解釋。在向 WeakHashMap 中添加映射時, 請記住映射可能會在以後“脫離”,因為鍵被垃圾收集了。在這種情況下,get() 傳回 null,這使得測試 get() 的傳回值是否為 null 變得比平時更重要了。
[4]使用WeakHashMap堵住洩漏
在 SocketManager 中防止洩漏很容易,隻要用 WeakHashMap 代替 HashMap 就行了,如下邊代碼所示。(如果 SocketManager 需要線程安全,那麼可以用 Collections.synchronizedMap() 包裝 WeakHashMap)。當映射的生命周期必須與鍵的生命周期聯系在一起時,可以使用這種方法。不過,應當小心不濫用這種技術,大多數時候還是應當使用普通的 HashMap 作為 Map 的實作。
private Map<Socket,User> m = new WeakHashMap<Socket,User>();
public void setUser(Socket s, User s)
public User getUser(Socket s)
引用隊列:
WeakHashMap 用弱引用承載映射鍵,這使得應用程式不再使用鍵對象時它們可以被垃圾收集,get() 實作可以根 據 WeakReference.get() 是否傳回 null 來區分死的映射和活的映射。但是這隻是防止 Map 的記憶體消耗在應用程式的生命周期 中不斷增加所需要做的工作的一半,還需要做一些工作以便在鍵對象被收集後從 Map 中删除死項。否則,Map 會充滿對應于死鍵的項。雖然這對于應用程 序是不可見的,但是它仍然會造成應用程式耗盡記憶體,因為即使鍵被收集了,Map.Entry 和值對象也不會被收集。可以通過周期性地掃描 Map,對每 一個弱引用調用 get(),并在 get() 傳回 null 時删除那個映射而消除死映射。但是如果 Map 有許多活的項,那麼這種方法的效率很低。如果有一種方法可以在弱引用的 referent 被垃圾收集時發出通知就好了,這就是引用隊列的作用。引用隊列是垃圾收集器向應用程式傳回關于對象生命周期的資訊的主要方法。弱引用有兩個構造函數:一個 隻取 referent 作為參數,另一個還取引用隊列作為參數。如果用關聯的引用隊列建立弱引用,在 referent 成為 GC 候選對象時,這個引用對象(不是referent)就在引用清除後加入 到引用隊列中。之後,應用程式從引用隊列提取引用并了解到它的 referent 已被收集,是以可以進行相應的清理活動,如去掉已不在弱集合中的對象的項。(引用隊列提供了與 BlockingQueue 同樣的出列模式 ——polled、timed blocking 和 untimed blocking。)WeakHashMap 有一個名為 expungeStaleEntries() 的私有方法,大多數 Map 操作中會調用它,它去掉引用隊列中所有失效的引用,并删除關聯的映射。
4)關于Java中引用思考:
先觀察一個清單:
級别
回收時間
用途
生存時間
強引用
從來不會被回收
對象的一般狀态
JVM停止運作時終止
軟引用
在記憶體不足時
在用戶端移除對象引用過後,除非再次激活,否則就放在記憶體敏感的緩存中
記憶體不足時終止
弱引用
在垃圾回收時,也就是用戶端已經移除了強引用,但是這種情況下記憶體還是用戶端引用可達的
阻止自動删除不需要用的對象
GC運作後終止
虛引用[幽靈引用]
對象死亡之前,就是進行finalize()方法調用附近
特殊的清除過程
不定,當finalize()函數運作過後再回收,有可能之前就已經被回收了。
可以這樣了解:
SoftReference:假定垃圾回收器确定在某一時間點某個對象是軟可到達對 象。這時,它可以選擇自動清除針對該對象的所有軟引用,以及通過強引用鍊,從其可以到達該對象的針對任何其他軟可到達對象的所有軟引用。在同一時間或晚些 時候,它會将那些已經向引用隊列注冊的新清除的軟引用加入隊列。 軟可到達對象的所有軟引用都要保證在虛拟機抛出 OutOfMemoryError 之前已經被清除。否則,清除軟引用的時間或者清除不同對象的一組此類 引用的順序将不受任何限制。然而,虛拟機實作不鼓勵清除最近通路或使用過的軟引用。 此類的直接執行個體可用于實作簡單緩存;該類或其派生的子類還可用于更大 型的資料結構,以實作更複雜的緩存。隻要軟引用的訓示對象是強可到達對象,即正在實際使用的對象,就不會清除軟引用。例如,通過保持最近使用的項的強訓示 對象,并由垃圾回收器決定是否放棄剩餘的項,複雜的緩存可以防止放棄最近使用的項。一般來說,WeakReference我們用來防止記憶體洩漏,保證記憶體 對象被VM回收。
WeakReference:弱引用對象,它們并不禁止其訓示對象變得可終結,并被終結,然後被回收。弱引用最常用于實作規範化的映射。假定垃圾回收器确定在某一時間點上某個對象是弱可到達對象。這時,它将自動清除針對此對象的所有弱引用,以及通過強引用鍊和軟引用, 可以從其到達該對象的針對任何其他弱可到達對象的所有弱引用。同時它将聲明所有以前的弱可到達對象為可終結的。在同一時間或晚些時候,它将那些已經向引用 隊列注冊的新清除的弱引用加入隊列。 SoftReference多用作來實作cache機制,保證cache的有效性。
PhantomReference:虛引用對象,在回收器确定其訓示對象可另外回收之後,被加入隊列。 虛引用最常見的用法是以某種可能比使用 Java 終結機制更靈活的方式來指派 pre-mortem 清除操作。如果垃圾回收器确定在某一特定時間點上虛引用的訓示對象是虛可到達對象,那麼在那時或者在以後的某一時間,它會将該引用加入隊列。為了確定可回 收的對象仍然保持原狀,虛引用的訓示對象不能被檢索:虛引用的 get 方法總是傳回 null。與軟引用和弱引用不同,虛引用在加入隊列時并沒有通過垃圾回收器自動清除。通過虛引用可到達的對象将仍然保持原狀,直到所有這類引用都被清除,或者它們都變得不可到達。
以下是不确定概念
【*:Java引用的深入部分一直都是讨論得比較多的話題,上邊大部分為摘錄整理,這裡再談談我個人的一些看法。從整個JVM架構結構來看,Java的引用和垃圾回收器形成了針對Java記憶體堆的一個對象的“閉包管理集”,其中在基本代碼裡面常用的就是強引用,強引用主要使用目的是就是程式設計的正常邏輯,這是所有的開發人員最容易了解的,而弱引用和軟引用的作用是比較耐人尋味的。按照引用強弱,其排序可以為:強引用——軟引用——弱引用——虛引用,為什麼這樣寫呢,實際上針對垃圾回收器而言,強引用是它絕對不會随便去動的區域,因為在記憶體堆裡面的對象,隻有目前對象不是強引用的時候,該對象才會進入垃圾回收器的目标區域。
軟引用又可以了解為“記憶體應急引用”,也就是說它和GC是完整地配合操作的,為了防止記憶體洩漏,當GC在回收過程出現記憶體不足的時候,軟引用會被優先回收,從垃圾回收算法上講,軟引用在設計的時候是很容易被垃圾回收器發現的。為什麼軟引用是處理告訴緩存的優先選擇的,主要有兩個原因:第一,它對記憶體非常敏感,從抽象意義上講,我們甚至可以任何它和記憶體的變化緊緊綁定到一起操作的,因為記憶體一旦不足的時候,它會優先向垃圾回收器報警以提示記憶體不足;第二,它會盡量保證系統在OutOfMemoryError之前将對象直接設定成為不可達,以保證不會出現記憶體溢出的情況;是以使用軟引用來處理Java引用裡面的高速緩存是很不錯的選擇。其實軟引用不僅僅和記憶體敏感,實際上和垃圾回收器的互動也是敏感的,這點可以這樣了解,因為當記憶體不足的時候,軟引用會報警,而這種報警會提示垃圾回收器針對目前的一些記憶體進行清除操作,而在有軟引用存在的記憶體堆裡面,垃圾回收器會第一時間反應,否則就會MemoryOut了。按照我們正常的思維來考慮,垃圾回收器針對我們調用System.gc()的時候,是不會輕易理睬的,因為僅僅是收到了來自強引用層代碼的請求,至于它是否回收還得看JVM内部環境的條件是否滿足,但是如果是軟引用的方式去申請垃圾回收器會優先反應,隻是我們在開發過程不能控制軟引用對垃圾回收器發送垃圾回收申請,而JVM規範裡面也指出了軟引用不會輕易發送申請到垃圾回收器。這裡還需要解釋的一點的是軟引用發送申請不是說軟引用像我們調用System.gc()這樣直接申請垃圾回收,而是說軟引用會設定對象引用為null,而垃圾回收器針對該引用的這種做法也會優先響應,我們可以了解為是軟引用對象在向垃圾回收器發送申請。反應快并不代表垃圾回收器會實時反應,還是會在尋找軟引用引用到的對象的時候遵循一定的回收規則,反應快在這裡的解釋是相對強引用設定對象為null,當軟引用設定對象為null的時候,該對象的被收集的優先級比較高。
弱引用是一種比軟引用相對複雜的引用,其實弱引用和軟引用都是Java程式可以控制的,也就是說可以通過代碼直接使得引用針對弱可及對象以及軟可及對象是可引用的,軟引用和弱引用引用的對象實際上通過一定的代碼操作是可重新激活的,隻是一般不會做這樣的操作,這樣的用法違背了最初的設計。弱引用和軟引用在垃圾回收器的目标範圍有一點點不同的就是,使用垃圾回收算法是很難找到弱引用的,也就是說弱引用用來監控垃圾回收的整個流程也是一種很好的選擇,它不會影響垃圾回收的正常流程,這樣就可以規範化整個對象從設定為null了過後的一個生命周期的代碼監控。而且因為弱引用是否存在對垃圾回收整個流程都不會造成影響,可以這樣認為,垃圾回收器找得到弱引用,該引用的對象就會被回收,如果找不到弱引用,一旦等到GC完成了垃圾回收過後,弱引用引用的對象占用的記憶體也會自動釋放,這就是軟引用在垃圾回收過後的自動終止。
最後談談虛引用,虛引用應該是JVM裡面最厲害的一種引用,它的厲害在于它可以在對象的記憶體從實體記憶體中清除掉了過後再引用該對象,也就是說當虛引用引用到對象的時候,這個對象實際已經從實體記憶體堆中清除掉了,如果我們不用手動對對象死亡或者瀕臨死亡進行處理的話,JVM會預設調用finalize函數,但是虛引用存在于該函數附近的生命周期内,是以可以手動對對象的這個範圍的周期進行監控。它之是以稱為“幽靈引用”就是因為該對象的實體記憶體已經不存在的,我個人覺得JVM儲存了一個對象狀态的鏡像索引,而這個鏡像索引裡面包含了對象在這個生命周期需要的所有内容,這裡的所需要就是這個生命周期内需要的對象資料内容,也就是對象死亡和瀕臨死亡之前finalize函數附近,至于強引用所需要的其他對象附加内容是不需要在這個鏡像裡面包含的,是以即使實體記憶體不存在,還是可以通過虛引用監控到該對象的,隻是這種情況是否可以讓對象重新激活為強引用我就不敢說了。因為虛引用在引用對象的過程不會去使得這個對象由Dead複活,而且這種對象是可以在回收周期進行回收的。】
【當你用心寫完每一篇部落格之後,你會發現它比你用代碼實作功能更有成就感!】