當咱們在談論內存的時候,咱們在談論什麼?

前言

內存,是程序員繞不過的一道坎。寫過C和C++的人想必都會對內存的手動分配和釋放難以忘懷,在Java中,得益於JVM的自動垃圾回收(GC)機制,大部分狀況下編程並不須要關心內存的分配與回收。固然,有了GC並不意味着就完事大吉了,若是不瞭解其中的原理,以錯誤的姿式濫用GC,頗有可能翻車釀成不可估量的損失。html

在經歷過一次嚴重的線上故障以後,本文試圖深刻分析JVM的內存管理機制,探索如何監控和避免內存使用量太高的場景出現。不免有錯誤之處,還請各位指正java

內存是什麼?

這個問題看似很好回答:內存不就是一塊存放運行時數據的空間麼。但,真的只是這麼簡單嗎?git

當你在編寫代碼時,你是否真正感覺到過它的存在?當你不知不覺建立出一個巨大的緩存對象時,是否思考過它會佔用多少內存,又將在什麼時候被回收?我相信大多數的Java程序員在編寫代碼時不會思考這些問題,這一方面證實了JVM自動內存管理機制設計的成功,但另外一方面也說明GC正在不知不覺中被濫用程序員

對於程序員而言,內存到底是什麼呢?在編寫代碼時(開發態),內存是指針,是引用,也是偏移地址,這是咱們在代碼中可以直接與內存打交道的三種最多見的方式;在代碼運行時(運行態),內存是GC頻率,是GC時長,也是機器的水位,這是實際運維過程當中最須要關注的三個指標。這些即是內存在實際開發中的存在形式,無論你是否注意的到,都必須認可,內存無處不在。github

基礎:Java內存結構

回到Java自己,要想真正瞭解內存,必須先從JVM自己的內存機制入手,首先簡單地回顧下JVM內存結構。算法

JVM內存分區

JVM中將運行時數據(內存)劃分爲五個區域:apache

  1. 程序計數器編程

    程序計數器是一塊線程私有的內存區域,它是當前線程所執行的字節碼的行號指示器。簡單來講,它記錄的是當前線程正在執行的虛擬機字節碼指令(若是是Native方法,該值爲空)。數組

    通常咱們不多關心這個區域緩存

  2. Java虛擬機棧

    Java虛擬機棧是一塊線程私有的內存區域,它老是和某個線程關聯在一塊兒,每當建立一個線程時,JVM就會爲其建立一個對應的Java虛擬機棧,用於存儲Java方法執行時用到的局部變量表、操做數棧、動態連接、方法出口等信息

    通常咱們也不怎麼須要關心這個區域

  3. 本地方法棧

    本地方法棧是爲JVM運行Native方法使用的空間,它也是線程私有的內存區域,它的做用與上一小節的Java虛擬機棧的做用是相似的。除了代碼中包含的常規的Native方法會使用這個存儲空間,在JVM利用JIT技術時會將一些Java方法從新編譯爲NativeCode代碼,這些編譯後的本地方法代碼也是利用這個棧來跟蹤方法的執行狀態。

    這也是一個不怎麼須要關注的區域

  4. Java堆

    JVM管理內存中最大的一塊,也是JVM中最最最核心的儲存區域,被全部線程所共享。咱們在Java中建立的對象實例就儲存在這裏,堆區也是GC主要發生的地區。

    這是咱們最核心關注的內存區域

  5. 方法區

    用於儲存類信息、常量、靜態變量等能夠被多個對象實例共享的數據,這塊區域儲存的信息相對穩定,所以不多發生GC。在GC機制中稱其爲「永生區」(Perm,Java 8以後改稱元空間Meta Space)。

    因爲方法區的內存很難被GC,所以若是使用不當,頗有可能致使內存過載。

    這是一塊經常被忽略,但卻很重要的內存區域

  6. 堆外內存*

    堆外內存不是由JVM管理的內存,但它也是Java中很是重要的一種內存使用方式,NIO等包中都頻繁地使用了堆外內存來實現「零拷貝」的效果(在網絡IO處理中,若是須要傳輸儲存在JVM內存區域中的對象,須要先將它們拷貝到堆外內存再進行傳遞,會形成額外的空間和性能浪費),主要經過ByteBufferUnsafe兩種方式來進行分配和使用。

    可是在使用時必定要注意,堆外內存是徹底不受GC控制的,也就是說和C++同樣,須要咱們手動去分配和回收內存。

Java對象的內存結構

進一步的,咱們須要瞭解一下在JVM中,一個對象在內存中是如何存放的,以下圖:

img

能夠看到,一個Java對象在內存中被分爲4個部分:

  1. Mark Word(標記字段):

    對象的Mark Word部分佔4個字節,其內容是一系列的標記位,好比輕量級鎖的標記位,偏向鎖標記位等等。

  2. Class Pointer(Class對象指針):

    指向對象所屬的Class對象,也是佔用4個字節(32位JVM)

  3. 對象實際數據:

    包括對象的全部成員變量(注意static變量並不包含在內,由於它是屬於class的),其大小由具體的成員變量大小決定,如byte和boolean是一個字節,int和float是4個字節,對象的引用則是4個字節(32位JVM)

  4. 對齊填充:

    爲了對齊8個字節而增設的填充區域,這是爲了提高CPU讀取內存的效率,詳細請看:什麼是字節對齊,爲何須要字節對齊?

下面來舉一個簡單的例子:

public class Int {
	public int val;
}
複製代碼

這個類實際佔用的內存是 4 (mark word) + 4 (class ref)+ 4(int)+ 4(padding)= 16字節。這其實正是Integer自動裝箱的對象所佔用的內存空間大小,能夠看到封裝成對象後,其佔用的內存體積相比原來增長了4倍。

有關Java對象的內存結構,更詳細地能夠參考這篇文章,在此不過多贅述了。

在瞭解了這些知識以後,讓咱們來思考一個問題:

議題:如何計算一個對象佔用的內存大小?

在編寫代碼的過程當中咱們會建立大量的對象,但你是否考慮過某個對象到底佔用了多少內存呢?

在C/C++中,咱們能夠經過sizeof()函數方便地計算一個變量或者類型所佔用的內存大小,不過在Java中並無這樣的系統調用,但這並不意味着在Java中就沒法實現相似的效果,結合上一節中分析的Java對象內存結構,只要可以按照順序計算出各個區域所佔用的內存並求和就能夠了。固然這裏面是有很是多的細節問題要考慮的,咱們一個一個來分析。

首先須要說明的一點是,在不一樣位數的JRE中,引用的大小是不同的(這個很好理解,由於引用儲存的就是地址偏移量),32Bit的JRE中一個引用佔用4個字節,而64Bit的JRE中則是8個字節。

先看對象頭,在不開啓JVM對象頭壓縮的狀況下,32Bit JRE中一個對象頭的大小是8個字節(4+4),64Bit的JRE中則是16個字節(8+8)。

接下來就是實例數據,這裏包括全部非靜態成員變量所佔用的數據,成員變量主要包括兩種:基本類型和引用類型。在肯定的JRE運行環境中,基本類型變量和引用類型佔用的內存大小都是肯定的,所以只須要簡單的經過反射作個加法彷佛就能夠了。不過實際狀況並無這麼簡單,讓咱們作一個簡單的實驗來看一看:

實驗:對象的實際內存佈局

經過jol工具能夠查看到一個對象的實際內存佈局,如今咱們建立了一個以下所示的類:

class Pojo {
  public int a;
  public String b;
  public int c;
  public boolean d;
  private long e; // e設置爲私有的,後面講解爲何
  public Object f;
  Pojo() { e = 1024L;}
}
複製代碼

使用jol工具查看其內存佈局以下:

OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0    12                    (object header)                           N/A
     12     4                int Pojo.a                                    N/A
     16     8               long Pojo.e                                    N/A
     24     4                int Pojo.c                                    N/A
     28     1            boolean Pojo.d                                    N/A
     29     3                    (alignment/padding gap)                  
     32     4   java.lang.String Pojo.b                                    N/A
     36     4   java.lang.Object Pojo.f                                    N/A
複製代碼

這裏因爲個人本地環境開啓了對象頭壓縮,所以對象頭所佔用的大小爲(4+8)=12字節。從這個內存佈局表上不難看出,成員變量在實際分配內存時,並非按照聲明的順序來儲存的,此外在變量d以後,還出現了一塊用於對齊內存的padding gap,這說明計算對象實際數據所佔用的內存大小時,並非簡單的求和就能夠的。

考慮到這些細節問題,咱們須要一些更有力的工具來幫助咱們精確的計算。

Unsafe & 變量偏移地址

在上面的內存佈局表中,能夠看到OFFSET一列,這即是對應變量的偏移地址,若是你瞭解C/C++中的指針,那這個概念就很好理解,它實際上是告訴了CPU要從什麼位置取出對應的數據。舉個例子,假設Pojo類的一個對象p存放在以0x0010開始的內存空間中,咱們須要獲取它的成員變量b,因爲其偏移地址是32(轉換成十六進制爲20),佔用大小是4,那麼實際儲存變量b的內存空間就是0x0030 ~ 0x0033,根據這個CPU就能夠很容易地獲取到變量了。

實際上在反射中,正是經過這樣的方式來獲取指定屬性值的,具體實現上則須要藉助強大的Unsafe工具。Unsafe在Java的世界中可謂是一個「神龍不見首」的存在,藉助它你能夠操做系統底層,實現許多不可意思的操做(好比修改變量的可見性,分配和回收堆外內存等),用起來簡直像在寫C++。不過也正由於其功能的強大性,隨意使用極有可能引起程序崩潰,所以官方不建議在除系統實現(如反射等)之外的場景使用,網上也很難找到Unsafe的詳細使用指南(一些參考資料),固然這並不影響咱們揭開它的神祕面紗,接下來就看看如何經過變量偏移地址來獲取一個變量。

@Test
public void testUnsafe() throws Exception {
  Class<?> unsafeClass = null;
  Unsafe unsafe = null;
  try {
    unsafeClass = Class.forName("sun.misc.Unsafe");
    final Field unsafeField = unsafeClass.getDeclaredField("theUnsafe");
    unsafeField.setAccessible(true);
    unsafe = (Unsafe) unsafeField.get(null);
  } catch (Exception e) {
    // Ignore.
  }
  Pojo p = new Pojo();
  Field f = Pojo.class.getDeclaredField("e");
  long eOffset = unsafe.objectFieldOffset(f); // eOffset = 16
  if (eOffset > 0L) {
    long eVal = unsafe.getLong(p, eOffset);
    System.out.println(eVal); // 1024
  }
}
複製代碼

出於安全起見,通常狀況下在正常的代碼中是沒法直接獲取Unsafe的實例的,這裏咱們經過反射的方式hack了一把來拿到unsafe實例。接着經過調用objectFieldOffset方法獲取到成員變量e的地址偏移爲16(和jol中的結果一致),最終咱們經過getLong()方法,傳入e的地址偏移量,便獲取到了e的值。能夠看到儘管Pojo類中e是一個私有屬性,經過這種方法依然是能夠獲取到它的值的。

有了objectFieldOffset這個工具,咱們就能夠經過代碼精確的計算一個對象在內存中所佔用的空間大小了,代碼以下(參考自apache luence)

計算shallowSize

public long shallowSizeOf(Object o) {
  Clazz<?> c = o.getClass(); // 對應的類
  // 初始大小:對象頭
  long shallowInstanceSize = NUM_BYTES_OBJECT_HEADER;
  for (Class<?> c = clazz; c != null; c = c.getSuperclass()) {
    // 須要循環獲取對象所繼承的全部類以遍歷其包含的全部成員變量
    final Field[] fields = c.getDeclaredFields();
    for (final Field f : fields) {
      // 注意,f的遍歷順序是按照聲明順序,而不是實際儲存順序
      if (!Modifier.isStatic(f.getModifiers())) {
        // 靜態變量不用考慮
        final Class<?> type = f.getType();
        // 成員變量佔用的空間,若是是基本類型(int,long等),直接是其所佔空間,不然就是當前JRE環境下引用的大小
        final int fsize = type.isPrimitive() ? primitiveSizes.get(type) : NUM_BYTES_OBJECT_REF;
        // 經過unsafe方法獲取當前變量的偏移地址,並加上成員變量的大小,獲得最終成員變量的偏移地址結束值(注意不是開始值)
        final long offsetPlusSize =
          ((Number) objectFieldOffsetMethod.invoke(theUnsafe, f)).longValue() + fsize;
        // 由於儲存順序和遍歷順序不一致,因此不能直接相加,直接取最大值便可,最終循環結束完獲得的必定是最後一個成員變量的偏移地址結束值,也就是全部成員變量的總大小
        shallowInstanceSize = Math.max(shallowInstanceSize, offsetPlusSize);
      }
    }
  }
  // 最後進行內存對齊,NUM_BYTES_OBJECT_ALIGNMENT是須要對齊的位數(通常是8)
  shallowInstanceSize += (long) NUM_BYTES_OBJECT_ALIGNMENT - 1L;
  return shallowInstanceSize - (shallowInstanceSize % NUM_BYTES_OBJECT_ALIGNMENT);
}
複製代碼

到這裏咱們計算出了一個對象在內存佈局上所佔用的空間大小,但這並非這個對象所佔用的實際大小,由於咱們尚未考慮對象內部的引用所指向的那些變量的大小。類比java中深淺拷貝的概念,咱們能夠稱這個內存大小爲 shallowSize,即「淺內存佔用」。

計算deepSize

計算出一個對象佔用的shallowSize以後,想要計算它的deepSize就很容易了,咱們須要作的即是遞歸遍歷對象中全部的引用並計算他們指向的實際對象的shallowSize,最終求和便可。考慮到會有大量重複的類出現,可使用一個數組來緩存已經計算過shallowSize的class,避免重複計算。

特別地,若是引用指向了數組或者集合類型,那麼只須要計算其基本元素的大小,而後乘以數組長度/集合大小便可。

具體實現代碼在此不過多贅述,能夠直接參考源代碼(from Apache luence,入口方法爲sizeOf(Object))。

須要注意的是,這種計算對象內存的方法並非毫無代價的,因爲使用了遞歸、反射和緩存,在性能和空間上都會有必定的消耗。

基礎:JVM GC

研究完了開發態的內存,咱們再來看看運行態的內存,對於Java程序員而言,運行態咱們核心關注的就是JVM的GC了,先來回顧一些基本知識:

可回收對象的標記

GC的第一步是須要搞明白,當前究竟有哪些對象是能夠被回收的。因爲引用計數法在存在循環引用時沒法正常標記,因此通常是採用 可達性分析算法 來標記究竟有哪些對象能夠被回收,以下圖所示:

垃圾回收器會從一系列的 GC Root 對象出發,向下搜索全部的對象,那些沒法經過 GC Root對象達到的對象就是須要被回收的對象。 GC Root 對象主要包括如下幾種:

  • 方法中局部變量區中的對象引用
  • Java操做棧中對象引用
  • 常量池中的對象引用
  • 本地方法棧中的對象引用
  • 類的Class對象

垃圾收集算法

GC的第二步是將全部標記爲可回收的對象所佔用的空間清理掉,這裏有幾種算法:

  1. 標記 - 清除法

    掃描一遍全部對象,並標記哪些可回收,而後清除,缺點是回收完會產生不少碎片空間,並且總體效率不高。

  2. 複製法

    將內存劃分爲相等的兩塊,每次只使用其中一塊。當這一塊內存用完時,就將還存活的對象複製到另外一塊上面,而後將已經使用過的內存空間一次清理掉。缺點是對內存空間消耗較大(實際只用了一半),而且當對象存活機率較高的時候,複製帶來的額外開銷也很高。

  3. 標記 - 整理法

    將原有標記-清除算法進行改造,不是直接對可回收對象進行清理,而是讓全部存活對象都向另外一端移動,而後直接清理掉端邊界之外的內存。

對象分代

在JVM,絕大多數的對象都是 朝生夕死 的短命對象,這是GC的一個重要假設。對於不一樣生命週期的對象,能夠採用不一樣的垃圾回收算法,好比對壽命較短的對象採用複製法,而對壽命比較長的對象採用標記-整理法。爲此,須要根據對象的生命週期將堆區進行一個劃分:

  1. 新生代(Young區)

    儲存被建立沒多久的對象,具體又分爲Eden和Survivor兩個區域。全部對象剛被建立時都存在Eden區,當Eden區滿後會觸發一次GC,並將剩餘存活的對象轉移到Survivor區。爲了採用複製法,會有兩個大小相同的Survivor區,而且始終有一個是空的。

    新生代發生的GC被稱爲 Young GC 或 Minor GC,是發生頻率最高的一種GC。

  2. 老年代(Old區)

    存放Young區Survivor滿後觸發minor GC後仍然存活的對象,當Eden區滿後會將存活的對象放入Survivor區域,若是Survivor區存不下這些對象,GC收集器就會將這些對象直接存放到Old區中,若是Survivor區中的對象足夠老,也直接存放到Old區中。

    若是Old區滿了,將會觸發Major GC回收老年代空間。

  3. 永生代(Perm區,Java 8後改成MetaSpace 元空間)

    主要存放類的Class對象和常量,以及靜態變量,這塊內存不屬於堆區,而是屬於方法區。Perm區的GC條件很是苛刻,以一個類的回收爲例,須要同時知足如下條件纔可以將其回收:

    • 該類全部的實例都已經被回收,也就是Java堆中不存在該類的任何實例
    • 加載該類的ClassLoader已經被回收
    • 該類對應的java.lang.Class對象沒有在任何地方被引用,沒法在任何地方經過反射訪問該類的方法。

GC指標

若是你查閱過JVM GC相關的文章,會發現GC常常被分爲三種:發生在新生代的 Minor GC(Young GC)、發生在老年代的 Major GC、和發生在整個內存區域的 Full GC。事實上JVM官方並無對Full GC和Major GC這兩種GC進行明確的定義,因此也沒有必要糾結。

不管是Minor GC仍是Full GC,絕大多數的GC算法都是會暫停全部應用線程的(STW),只不過Minor GC暫停的時間很短,而Full GC則比較長。

因爲GC對實際應用線程是存在影響的,因此在實際運維中,咱們須要一些外部指標來評估GC的狀況,以判斷當前應用是否「健康」。通常來講,GC的兩個重要指標是:

  • GC時間:因爲GC是STW的,因此在GC時間內整個應用是處於暫停狀態的。
  • GC頻率:單位時間內GC發生的次數。

那麼對於一個應用而言,GC時間和GC頻率處於什麼水平纔是正常的?這由應用自己的須要來決定,咱們能夠從下面三個維度來評估:

  1. 延遲(latency):一次完整操做完成的時間。

    好比某交易系統,要求全部的請求在1000ms內獲得響應。假設GC的時間佔比不超過總運行時間的10%,那就要求GC時間都不能超過100ms。

  2. 吞吐量(Throughput):單位時間內須要處理完成的操做數量。

    仍然以上面的交易系統爲例,要求每分鐘至少能夠處理1000個訂單,而且GC的時間佔比不能超過總運行時間的10%,那就意味着每分鐘的GC時間總和不能超過6s。假設單次GC的耗時爲50ms,進一步轉換便可獲得對GC頻率的要求爲每分鐘不超120次。

    由於每分鐘須要完成1000次操做,那就意味着平均每9次操做能夠觸發一次GC,這就進一步轉換成了對局部變量產生速率的要求。

  3. 系統容量(Capacity):是在達成吞吐量和延遲指標的狀況下,對硬件環境的額外約束。

    通常來講是硬件指標,好比某系統要求必須可以部署在2核4G的服務器實例上,且可以知足延遲和吞吐量的須要。結合具體的硬件指標和JVM特性能夠進一步估算獲得對GC的要求。

議題:如何回收對象佔用的內存?

這是一個頗有意思的問題,經過上面的分析不難看出,在JVM中內存的回收是自動的,並不受程序員手動控制,這是由GC自己的特性所決定的(有關這個問題能夠看下知乎上的討論:傳送門)。那麼在平常編程中,有什麼辦法可讓對象的內存被回收掉呢?

清除引用

根據GC算法的原則,只要一個對象處於「不可達」的狀態,就會被回收,所以想要回收一個對象,最好的辦法就是將指向它的引用都置爲空。固然,這意味着在編碼時你須要清晰地知道本身的對象都被哪些地方所引用了。

從這個角度出發,咱們在平常編寫代碼的時候要儘可能避免建立沒必要要的引用。

那麼,爲了達到清除引用的效果,是否是應該在不須要對象的後,手動將引用置爲null呢?讓咱們看下面這段代碼:

public void refTest() {
  Pojo p = new Pojo();
  // ... do something with p
  // help the gc (?)
  p = null;
}
複製代碼

實際上,因爲p是在refTest()域內聲明的局部變量,方法執行完畢後就會被自動回收了,並無必要將p特地設置爲null,這樣作對GC的幫助微乎其微。

儘可能使用局部變量

想要讓一個對象儘快被回收,那就須要儘量地縮短它的生命週期,最好讓它可以在Young區的Minor GC中被銷燬,而不是存活到suvivor區甚至是老年代。從這個角度出發,可以使用局部變量的時候就儘可能使用局部變量,縮小變量的做用域,以便其能被快速回收。

儘可能少用靜態變量

靜態變量是一種特殊的存在,由於它並不存放在堆區,而是被存放在方法區。經過上文的分析能夠看到方法區的GC條件是十分苛刻的,因此靜態變量一旦被聲明瞭,就 很難被回收,這要求咱們在代碼中儘可能剋制地使用靜態變量。

通常來講,靜態變量自己不會佔用不少的空間,但它可能包含不少指向非靜態變量的引用,這就會致使那些被引用的變量也沒法被回收,長此以往引起內存不足。若是你定義的靜態變量中包含了數組和集合類,那就要格外注意控制它的大小,由於這些內存都是很難被回收掉的。

System.gc() ?

這彷佛是目前Java中惟一一個能夠由代碼主動觸發GC的調用,不過這個調用並不必定會真的發起gc。來看一下官方對於System.gc()的定義:

Calling this method suggests that the Java virtual machine expend
effort toward recycling unused objects in order to make the memory
they currently occupy available for quick reuse. When control
returns from the method call, the virtual machine has made
its best effort to recycle all discarded objects.
複製代碼

expend effort說明這個調用並不會保證必定發生gc。此外,System.gc()調用所觸發的gc是一次Full GC,若是在代碼中頻繁調用Full GC,那麼後果可想而知。

所以,咱們的建議是,除非真的有必要,不然永遠不要使用System.gc()

結論

綜合上述內容,能夠分析獲得下面的結論:

  • Java的內存是被自動管理的
  • 沒法經過手動的方式回收內存,由於這違反了Java語言設計的初衷
  • 能夠經過減小變量做用域等方式幫助GC更好地工做
相關文章
相關標籤/搜索