1. 程式人生 > >從0到1起步-跟我進入堆外記憶體的奇妙世界

從0到1起步-跟我進入堆外記憶體的奇妙世界

堆外記憶體一直是Java業務開發人員難以企及的隱藏領域,究竟他是幹什麼的,以及如何更好的使用呢?那就請跟著我進入這個世界吧。

一、什麼是堆外記憶體

1、堆內記憶體(on-heap memory)回顧
堆外記憶體和堆內記憶體是相對的二個概念,其中堆內記憶體是我們平常工作中接觸比較多的,我們在jvm引數中只要使用-Xms,-Xmx等引數就可以設定堆的大小和最大值,理解jvm的堆還需要知道下面這個公式:

堆內記憶體 = 新生代+老年代+持久代

如下面的圖所示:
這裡寫圖片描述

在使用堆內記憶體(on-heap memory)的時候,完全遵守JVM虛擬機器的記憶體管理機制,採用垃圾回收器(GC)統一進行記憶體管理,GC會在某些特定的時間點進行一次徹底回收,也就是Full GC,GC會對所有分配的堆內記憶體進行掃描,在這個過程中會對JAVA應用程式的效能造成一定影響,還可能會產生Stop The World。

常見的垃圾回收演算法主要有:

  • 引用計數器法(Reference Counting)
  • 標記清除法(Mark-Sweep)
  • 複製演算法(Coping)
  • 標記壓縮法(Mark-Compact)
  • 分代演算法(Generational Collecting)
  • 分割槽演算法(Region)
    注:在這裡我們不對各個演算法進行深入介紹,感興趣的同學可以關注我的下一篇關於垃圾回收演算法的介紹分享。

2、堆外記憶體(off-heap memory)介紹

和堆內記憶體相對應,堆外記憶體就是把記憶體物件分配在Java虛擬機器的堆以外的記憶體,這些記憶體直接受作業系統管理(而不是虛擬機器),這樣做的結果就是能夠在一定程度上減少垃圾回收對應用程式造成的影響。

作為JAVA開發者我們經常用java.nio.DirectByteBuffer物件進行堆外記憶體的管理和使用,它會在物件建立的時候就分配堆外記憶體。

DirectByteBuffer類是在Java Heap外分配記憶體,對堆外記憶體的申請主要是通過成員變數unsafe來操作,下面介紹構造方法

DirectByteBuffer(int cap) {                 

        super(-1, 0, cap, cap);
        //記憶體是否按頁分配對齊
        boolean pa = VM.isDirectMemoryPageAligned();
        //獲取每頁記憶體大小
int ps = Bits.pageSize(); //分配記憶體的大小,如果是按頁對齊方式,需要再加一頁記憶體的容量 long size = Math.max(1L, (long)cap + (pa ? ps : 0)); //用Bits類儲存總分配記憶體(按頁分配)的大小和實際記憶體的大小 Bits.reserveMemory(size, cap); long base = 0; try { //在堆外記憶體的基地址,指定記憶體大小 base = unsafe.allocateMemory(size); } catch (OutOfMemoryError x) { Bits.unreserveMemory(size, cap); throw x; } unsafe.setMemory(base, size, (byte) 0); //計算堆外記憶體的基地址 if (pa && (base % ps != 0)) { // Round up to page boundary address = base + ps - (base & (ps - 1)); } else { address = base; } cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); att = null; }

注:在Cleaner 內部中通過一個列表,維護了一個針對每一個 directBuffer 的一個回收堆外記憶體的 執行緒物件(Runnable),回收操作是發生在 Cleaner 的 clean() 方法中。

private static class Deallocator implements Runnable  {
    private static Unsafe unsafe = Unsafe.getUnsafe();
    private long address;
    private long size;
    private int capacity;
    private Deallocator(long address, long size, int capacity) {
        assert (address != 0);
        this.address = address;
        this.size = size;
        this.capacity = capacity;
    }

    public void run() {
        if (address == 0) {
            // Paranoia
            return;
        }
        unsafe.freeMemory(address);
        address = 0;
        Bits.unreserveMemory(size, capacity);
    }
}

二、使用堆外記憶體的優點

1、減少了垃圾回收
因為垃圾回收會暫停其他的工作。

2、加快了複製的速度
堆內在flush到遠端時,會先複製到直接記憶體(非堆記憶體),然後在傳送;而堆外記憶體相當於省略掉了這個工作。

同樣任何一個事物使用起來有優點就會有缺點,堆外記憶體的缺點就是記憶體難以控制,使用了堆外記憶體就間接失去了JVM管理記憶體的可行性,改由自己來管理,當發生記憶體溢位時排查起來非常困難。

三、使用DirectByteBuffer的注意事項

java.nio.DirectByteBuffer物件在建立過程中會先通過Unsafe介面直接通過os::malloc來分配記憶體,然後將記憶體的起始地址和大小存到java.nio.DirectByteBuffer物件裡,這樣就可以直接操作這些記憶體。這些記憶體只有在DirectByteBuffer回收掉之後才有機會被回收,因此如果這些物件大部分都移到了old,但是一直沒有觸發CMS GC或者Full GC,那麼悲劇將會發生,因為你的實體記憶體被他們耗盡了,因此為了避免這種悲劇的發生,通過-XX:MaxDirectMemorySize來指定最大的堆外記憶體大小,當使用達到了閾值的時候將呼叫System.gc來做一次full gc,以此來回收掉沒有被使用的堆外記憶體。

四、DirectByteBuffer使用測試

我們在寫NIO程式經常使用ByteBuffer來讀取或者寫入資料,那麼使用ByteBuffer.allocate(capability)還是使用ByteBuffer.allocteDirect(capability)來分配快取了?第一種方式是分配JVM堆記憶體,屬於GC管轄範圍,由於需要拷貝所以速度相對較慢;第二種方式是分配OS本地記憶體,不屬於GC管轄範圍,由於不需要記憶體拷貝所以速度相對較快。

程式碼如下:

package com.stevex.app.nio;

import java.nio.ByteBuffer;
import java.util.concurrent.TimeUnit;

public class DirectByteBufferTest {
    public static void main(String[] args) throws InterruptedException{
            //分配128MB直接記憶體
        ByteBuffer bb = ByteBuffer.allocateDirect(1024*1024*128);

        TimeUnit.SECONDS.sleep(10);
        System.out.println("ok");
    }
}

測試用例1:設定JVM引數-Xmx100m,執行異常,因為如果沒設定-XX:MaxDirectMemorySize,則預設與-Xmx引數值相同,分配128M直接記憶體超出限制範圍。

Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
    at java.nio.Bits.reserveMemory(Bits.java:658)
    at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
    at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:306)
    at com.stevex.app.nio.DirectByteBufferTest.main(DirectByteBufferTest.java:8)

測試用例2:設定JVM引數-Xmx256m,執行正常,因為128M小於256M,屬於範圍內分配。

測試用例3:設定JVM引數-Xmx256m -XX:MaxDirectMemorySize=100M,執行異常,分配的直接記憶體128M超過限定的100M。

Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
    at java.nio.Bits.reserveMemory(Bits.java:658)
    at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
    at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:306)
    at com.stevex.app.nio.DirectByteBufferTest.main(DirectByteBufferTest.java:8)

測試用例4:設定JVM引數-Xmx768m,執行程式觀察記憶體使用變化,會發現clean()後記憶體馬上下降,說明使用clean()方法能有效及時回收直接快取。
程式碼如下:

package com.stevex.app.nio;

import java.nio.ByteBuffer;
import java.util.concurrent.TimeUnit;
import sun.nio.ch.DirectBuffer;

public class DirectByteBufferTest {
    public static void main(String[] args) throws InterruptedException{
        //分配512MB直接快取
        ByteBuffer bb = ByteBuffer.allocateDirect(1024*1024*512);

        TimeUnit.SECONDS.sleep(10);

        //清除直接快取
        ((DirectBuffer)bb).cleaner().clean();

        TimeUnit.SECONDS.sleep(10);

        System.out.println("ok");
    }
}

五、細說System.gc方法

1、JDK裡的System.gc的實現

/**
 * Runs the garbage collector.
 * <p>
 * Calling the <code>gc</code> 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 Java Virtual
 * Machine has made a best effort to reclaim space from all discarded
 * objects.
 * <p>
 * The call <code>System.gc()</code> is effectively equivalent to the
 * call:
 * <blockquote><pre>
 * Runtime.getRuntime().gc()
 * </pre></blockquote>
 *
 * @see     java.lang.Runtime#gc()
 */
public static void gc() {
    Runtime.getRuntime().gc();
}

其實發現System.gc方法其實是呼叫的Runtime.getRuntime.gc(),我們再接著看。

/*
  執行垃圾收集器。
呼叫此方法表明,java虛擬機器擴充套件
努力回收未使用的物件,以便記憶體可以快速複用,
當控制從方法呼叫返回的時候,虛擬機器盡力回收被丟棄的物件
*/
public native void gc();

這裡看到gc方法是native的,在java層面只能到此結束了,程式碼只有這麼多,要了解更多,可以看方法上面的註釋,不過我們需要更深層次地來了解其實現,那還是準備好進入到jvm裡去看看。

2、System.gc的作用有哪些
說起堆外記憶體免不了要提及System.gc方法,下面就是使用了System.gc的作用是什麼?

  • 做一次full gc
  • 執行後會暫停整個程序。
  • System.gc我們可以禁掉,使用-XX:+DisableExplicitGC,
    其實一般在cms gc下我們通過-XX:+ExplicitGCInvokesConcurrent也可以做稍微高效一點的gc,也就是並行gc。
  • 最常見的場景是RMI/NIO下的堆外記憶體分配等

注:
如果我們使用了堆外記憶體,並且用了DisableExplicitGC設定為true,那麼就是禁止使用System.gc,這樣堆外記憶體將無從觸發極有可能造成記憶體溢位錯誤,在這種情況下可以考慮使用ExplicitGCInvokesConcurrent引數。

說起Full gc我們最先想到的就是stop thd world,這裡要先提到VMThread,在jvm裡有這麼一個執行緒不斷輪詢它的佇列,這個佇列裡主要是存一些VM_operation的動作,比如最常見的就是記憶體分配失敗要求做GC操作的請求等,在對gc這些操作執行的時候會先將其他業務執行緒都進入到安全點,也就是這些執行緒從此不再執行任何位元組碼指令,只有當出了安全點的時候才讓他們繼續執行原來的指令,因此這其實就是我們說的stop the world(STW),整個程序相當於靜止了。

六、開源堆外快取框架

關於堆外快取的開源實現。查詢了一些資料後瞭解到的主要有:

  • Ehcache 3.0:3.0基於其商業公司一個非開源的堆外元件的實現。
  • Chronical Map:OpenHFT包括很多類庫,使用這些類庫很少產生垃圾,並且應用程式使用這些類庫後也很少發生Minor GC。類庫主要包括:Chronicle Map,Chronicle Queue等等。
  • OHC:來源於Cassandra 3.0, Apache v2。
  • Ignite: 一個規模巨集大的記憶體計算框架,屬於Apache專案。