天天看點

nio 之HeapByteBuffer 和DirectByteBuffer

轉載自 Unyieding

前面介紹了buffer 相關的概念以及如何使用ByteBuffer 進行讀寫nio 之 buffer

堆外記憶體和堆上記憶體

首先來講一下什麼是堆上記憶體,在java 中我們經常會編寫類似下面一段代碼

代碼清單1-1

public class HeapByteBufferDemo {
    public static void main(String[] args) {
        Demo demo = new Demo();//1
        demo.print();
    }
}
class Demo{
     void print() {
        System.out.println("i am a demo");
    }
}
           

1 處 使用new 關鍵字 去建立Demo對象,具體分為三步:

  • 在jvm 運作時資料區中的堆上申請一塊記憶體空間
  • 初始化執行個體對象
  • 把引用demo 指向配置設定的記憶體空間

此時 demo 引用指向的是堆上的一塊記憶體空間,它由jvm管理的,同樣也是gc 的主要工作區域。預設我們使用new 關鍵字,以及newInstance 都是在堆上申請記憶體。使用堆上的記憶體在多數情況下都是大家的首選,但是有些情況下我們使用堆外的記憶體就比較合适,什麼是堆外記憶體呢,就是不被jvm 所管理的其他記憶體,簡稱堆外記憶體。

堆外記憶體也稱直接記憶體,下面來說下使用直接記憶體的兩個好處:

  • 直接記憶體是在堆外,申請過多不會引起gc;例:申請一塊堆外空間,當記憶體池去使用,netty 就是這種機制,有興趣的同志可以去研究下,這也是我這個專欄将要涉及的地方。
  • 在我們寫資料的時候,若資料在堆上,則需要從堆上拷貝到堆外,作業系統才可以去操作這個拷貝的資料;若資料在堆外,就少了一次從堆上拷貝到堆外這個階段了,節省的時間是非常明顯的。大家可能比較納悶為啥我們的作業系統屬于核心态 在ring0級别按理說可以通路所有記憶體,為啥不直接操作堆上的資料,因為Java 有gc,gc 可能不會回收要被寫的資料,但是可能會移動它(把已用記憶體壓縮在一邊,清除記憶體碎片),作業系統是通過記憶體位址去操作記憶體的,記憶體位址變了,這些寫到檔案或者網絡裡的資料可能并不是我們想要寫的資料,也有可能産生很多未知的錯誤。

如何使用直接記憶體(這裡使用上節的小例子)

代碼清單2-1

/**
 * @author lzq
 */
public class DirectBufferDemo {
    public static void main(String[] args) {
        ByteBuffer demoDirectByteBuffer = ByteBuffer.allocateDirect(8);//1
        printBufferProperties("write to demoDirectByteBuffer before ", demoDirectByteBuffer);
        //put to buffer 5 bytes utf-8 編碼
        demoDirectByteBuffer.put("hello".getBytes());
        printBufferProperties("after write to demoDirectByteBuffer ", demoDirectByteBuffer);
        //invoke flip
        demoDirectByteBuffer.flip();
        printBufferProperties("after invoke flip ", demoDirectByteBuffer);

        byte[] temp = new byte[demoDirectByteBuffer.limit()];
        int index = 0;
        while (demoDirectByteBuffer.hasRemaining()) {
            temp[index] = demoDirectByteBuffer.get();
            index++;
        }
        printBufferProperties("after read from demoDirectByteBuffer", demoDirectByteBuffer);

        System.out.println(new String(temp));
    }
    private static void printBufferProperties(String des, ByteBuffer target) {
        System.out.println(String.format("%s--position:%d,limit:%d,capacity:%s",des,
                target.position(), target.limit(), target.capacity()));
    }
}
           

1 處 隻有這裡與nio 之 buffer 的demo 不一樣,ByteBuffer.allocate(int) 如果加了Direct 則生成的是DirectByteBuffer 執行個體也就是堆外記憶體,如果不加則是HeapByteBuffer。

大家可以看到下面調用的方法都是一緻的而且輸出的都是一緻的,這種封裝對于使用者無疑是一種釋放,面向接口程式設計真好。但是操作堆外記憶體和操作堆上記憶體的實作可能有點不一樣,下面來看下如何操作堆外記憶體。

DirectByteBuffer put(byte)(很簡單)

源碼清單3-1

public ByteBuffer put(byte x) {
      //unsafe 是一個可以操作堆外記憶體的對象(也可以做其他的),以後有機會可能會和大家讨論一波
      unsafe.putByte(ix(nextPutIndex()), ((x)));
      return this;
  }
final int nextPutIndex() { 
     //這裡和操作堆上記憶體一樣先判斷position 是否大于等于limit ,若是大于等于抛出相應異常                      
      if (position >= limit)
          throw new BufferOverflowException();
      //自增position ,将要寫值到buffer 中
      return position++;
  }
private long ix(int i) {
      return address + ((long)i << 0);
   //return address+((long)i); 和上面表達的意思一樣 
   // return address+((long)i<<1); DirectShortBufferU裡的實作方式
  }
           

向DirectByteBuffer 上寫資料:

  • 自增position(将要寫或讀的下一個索引)
  • 然後通過 ix 方法計算出堆外的記憶體位址

    address 就是建立DirectByteBuffer 執行個體時申請的堆外記憶體首位址

  • unsafe.putByte(long,byte) 表示将值存儲到給定的位址處,有興趣大家可以翻看相關native 方法實作

jdk 如何申請DirectByteBuffer

下面我們來看看jdk 到底是怎麼申請堆外記憶體的,有哪些注意點

首先來看下DirectByteBuffer 構造器的實作

源碼清單3-2

DirectByteBuffer(int cap) {                   // package-private

        super(-1, 0, cap, cap); //①
        boolean pa = VM.isDirectMemoryPageAligned(); //②
        int ps = Bits.pageSize();
        long size = Math.max(1L, (long)cap + (pa ? ps : 0));//③
        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;
    }

           
  • ① 處 調用父類的構造方法 初始化 mark,position,limit,capacity 四個屬性
  • ② 處和③處聯合起來看 表示是否要記憶體對齊,預設是不對齊的
  • ④ 處 才是真正申請記憶體(預定記憶體)的地方

    具體方法如下 源碼清單3-3

static void reserveMemory(long size, int cap) {
       //判斷是否設定了最大記憶體限制,若沒有則設定下 。 
       //直接記憶體的最大記憶體可以通過 -XX:MaxDirectMemorySize 參數設定
      //預設直接記憶體的最大值為堆空間最大值
        if (!memoryLimitSet && VM.isBooted()) {
            maxMemory = VM.maxDirectMemory();
            memoryLimitSet = true;
        }
        //樂觀的擷取記憶體(準确得說是預定記憶體)裡面就是通過cas 去修改 
      // totalCapacity 的值,若修改成功,則傳回true 如果剩餘記憶體容量小于size 直接傳回false 
        if (tryReserveMemory(size, cap)) {
            return;
        }
      //
        final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
        // retry while helping enqueue pending Reference objects
        // which includes executing pending Cleaner(s) which includes
        // Cleaner(s) that free direct buffer memory
        while (jlra.tryHandlePendingReference()) {
            if (tryReserveMemory(size, cap)) {
                return;
            }
        }
        // trigger VM's Reference processing
        //顯式調用gc,觸發虛拟機處理 引用(這個待會再講),前面确實說過直接記憶體
       //與gc沒有關系,但是如果一個DirectByteBuffer對象擁有一個強引用,則該對象 
       //占用的堆外記憶體,别的DirectByteBuffer 對象無法使用,是以使用堆外記憶體也
      //要有節制
        System.gc();
     // 這裡就是給虛拟機一點時間去執行gc,建構堆外記憶體除了我們定義的
     //一個強引用外,還有一個虛引用 Cleaner 對象(源碼清單3-2的⑤處),建構該
    //對象需要傳入目前對象也就this,以及一個Deallocator 對象,該類實作Runnable 
   //接口,run 方法裡就是 就是調用 Bits.unreserveDirectory,unsafe.freeMemory()
        boolean interrupted = false;
        try {
            long sleepTime = 1;
            int sleeps = 0;
            while (true) {
                if (tryReserveMemory(size, cap)) {
                    return;
                }
                if (sleeps >= MAX_SLEEPS) {
                    break;
                }
                if (!jlra.tryHandlePendingReference()) {
                    try {
                        Thread.sleep(sleepTime);
                        sleepTime <<= 1;
                        sleeps++;
                    } catch (InterruptedException e) {
                        interrupted = true;
                    }
                }
            }
            // 這裡所有堆外記憶體不能釋放,則直接抛出OOM異常
            throw new OutOfMemoryError("Direct buffer memory");
        } finally {
            if (interrupted) {
                // don't swallow interrupts
                Thread.currentThread().interrupt();
            }
        }
    }
           

釋放堆外記憶體

首先我還是強調一下堆外記憶體使用過多不會引起gc,但是剛才在源碼清單3-3裡也看到若是申請不到堆外記憶體則會顯式調用System.gc(); 這是為啥呢,多說一句可以禁用它,使用jvm 參數-XX:-+DisableExplicitGC 來禁用,對于有些大型項目都是直接禁用的。使用完DirectByteBuffer對象隻要把對象引用置為空,等待垃圾回收器去回收該對象即可,DirectByteBuffer對象被回收的時候 它的 虛引用也是Cleaner 對象會被放到 ReferenceQueue 中,然後專門有一個ReferenceHandle 線程去處理這個對列 若發現從對列裡取出的是Cleaner 對象則會執行 clean()方法,clean 方法會調用 thunk 屬性的run方法 對于DirectByteBuffer來說這個thunk就是其嵌套類對象,嵌套類代碼如下:

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);
        }
    }
           

主要看run 方法 就是釋放記憶體。總而言之,就是使用直接記憶體時也需要時刻注意使用完就把引用置為空,當然也可以申請一塊比較大的直接記憶體自己管理來做一個記憶體池的玩意,這樣更好,但是造輪子需要功力啊。

總結

直接記憶體即堆外記憶體,使用時我們需要設定下堆外記憶體的大小,因為預設為堆的最大大小,還有堆外記憶體也不是無止境的,申請外還是需要釋放的,要不然在使用超過堆外記憶體的最大大小的時候也是會抛出OOM 的,還有若沒有記憶體可以使用,jdk 顯式觸發gc,這個在使用較多直接記憶體的應用中還是禁止掉最好,或者直接禁止掉最好。另外堆外記憶體和堆内記憶體調用api 都是一樣的。