Netty 中的記憶體分配淺析-資料容器

rickiyang發表於2020-07-06

本篇接續前一篇繼續講 Netty 中的記憶體分配。上一篇 先簡單做一下回顧:

Netty 為了更高效的管理記憶體,自己實現了一套記憶體管理的邏輯,借鑑 jemalloc 的思想實現了一套池化記憶體管理的思路:

  • Arena 作為記憶體分配器,可以被多個競爭獲取記憶體的執行緒公用。
  • Arena 將從作業系統中申請的記憶體塊命名為 Chunk,每個 Chunk 為16M,後續所有的操作都是在 Chunk 內進行;
  • Chunk 內部以 Page 為單位,一個 Page 大小為 8K;
  • 有的時候8K對於待申請的資源來說還是很大,所以 Page 內部又做了進一步的劃分,有了 SubPage 的概念,SubPage 並沒有固定大小,取決於用於的需要。即在 Page 內部只要不超出 Page 大小,你需要多大就劃分出多大的 SubPage 空間。

以上 4 個模組: Arena, Chunk,Page, SubPage 構成了 Netty 記憶體儲存的基本概念。

Netty 記憶體分塊的最小單位是 SubPage ,那麼資料是以什麼樣的方式儲存在 SubPage 中呢?這裡就不得不說到 Netty 物件儲存的最小單位:ByteBuf。

1. 為什麼Netty 要自己實現資料容器

Netty 底層基於 NIO實現,NIO 的標準三件套:Selector,Channel,Buffer 因為使用比較複雜已經被 Netty 封裝好同時提供更多擴充套件性功能對外用自定義的物件暴露相關操作。Buffer 的功能就是資料容器,Channel 讀到資料先儲存到 Buffer 中然後進行傳輸。今天我們要討論的是 Netty 中的資料容器:ByteBuf,注意不是 java.nio.ByteBuffer

Netty 為什麼要重新寫一套資料容器呢?眾所周知 Netty 全面封裝了 NIO 的核心 API,對外暴露的全都是自己封裝的介面,很重要的原因就在於 NIO 的 API 使用起來太複雜,既然要封裝,那就封裝的徹底一些把該有的功能都補齊。NIO 的 Buffer 有以下缺點:

  1. 當呼叫 allocate() 方法分配記憶體時,Buffer 的長度就固定了,不能動態擴充套件和收縮,當寫入資料大於緩衝區的 capacity 時會發生陣列越界錯誤;
  2. Buffer只有一個位置標誌位屬性 position,讀寫切換時必須先呼叫 flip()rewind()方法;
  3. Buffer只提供了存取、翻轉、釋放、標誌、比較、批量移動等緩衝區的基本操作,想使用高階的功能(比如池化),就得自己手動進行封裝及維護,使用非常不方便。

另外很重要的一點就是,JDK 是基於堆的記憶體管理,Netty 出發點作為一款高效能的 RPC 框架必然涉及到頻繁的記憶體分配銷燬操作,如果是在堆上分配記憶體空間將會觸發頻繁的GC,JDK 在1.4之後提供的 NIO 也已經提供了直接直接分配堆外記憶體空間的能力,但是也僅僅是提供了基本的能力,建立、回收相關的功能和效率都很簡陋。基於此,在堆外記憶體使用方面,Netty 自己實現了一套建立、回收堆外記憶體池的相關功能。

所以基於上面這些或多或少的缺點 Netty 自己封裝了新的資料容器 ByteBuf,要解決的事情就是提供 更高效能,更多能力,API 更加簡明 地運算元據記憶體分配的能力。

2. ByteBuf 整體結構

作為儲存位元組碼的容器,大概的功能不外乎是位元組資料的寫入,讀取,擴容,收縮等等相關的功能。ByteBuf 提供了 讀指標 和 寫指標 分別提示當前讀取位置 和 可寫入的位置。這些定義我們可以在 AbstractByteBuf 中看到,ByteBuf 作為一個介面,AbstractByteBuf 是它的預設實現類。

1

上圖顯示了 ByteBuf 的結構,主要由已讀位元組、可讀位元組、可寫位元組三部分組成,使用readerIndexwriterIndex分隔,三部分加起來稱為容量 capacity。readerIndex 表示可讀位元組的起始位置,writerIndex 表示可寫位元組的起始位置。

  • readerIndex(讀指標):讀取的起始位置,每讀取一個位元組就加 1,當它等於 writerIndex 時說明可讀資料已讀完;
  • writerIndex(寫指標):寫入的起始位置,每寫入一個位元組就加 1,當它等於 capacity() 時說明當前容量已滿。此時會做擴容操作,如果不能擴容表示當前寫操作結束;
  • maxCapacity(最大容量):可以擴容的最大容量,當前容量等於這個值時說明不能再擴容。

AbstractByteBuf 中的方法可分為三類:

讀取資料、寫入資料、操作遊標。

2.1 讀取資料:readByte()

首先檢查當前緩衝區是否有可讀的位元組,如果要讀取的位元組數等於0,或者大於已寫入的位元組長度則拋異常。

@Override
public ByteBuf readBytes(byte[] dst, int dstIndex, int length) {
  checkReadableBytes(length);
  getBytes(readerIndex, dst, dstIndex, length);
  readerIndex += length;
  return this;
}

private void checkReadableBytes0(int minimumReadableBytes) {
  ensureAccessible();
  if (readerIndex > writerIndex - minimumReadableBytes) {
    throw new IndexOutOfBoundsException(String.format(
      "readerIndex(%d) + length(%d) exceeds writerIndex(%d): %s",
      readerIndex, minimumReadableBytes, writerIndex, this));
  }
}

getBytes() 是真正的讀取位元組資料的方法,由對應子類去實現。

2.2 寫資料:writeBytes()

寫入操作會伴隨著一個擴容操作。前面說過,最小寫入單位是SubPage,在ensureWritable0()方法中有如下判斷:

minWritableBytes <= capacity() - writerIndex ,當前要寫入的值 小於 還剩下的可寫入容量,不需要擴容;

minWritableBytes > maxCapacity - writerIndex,當前要寫入的值 大於 容量上限-寫入起始值座標,已經超了,拋異常;

排除這兩種情況,走擴容之路。

public ByteBuf writeBytes(byte[] src, int srcIndex, int length) {
  ensureAccessible();
  ensureWritable(length);
  setBytes(writerIndex, src, srcIndex, length);
  writerIndex += length;
  return this;
}


public ByteBuf ensureWritable(int minWritableBytes) {
  if (minWritableBytes < 0) {
    throw new IllegalArgumentException(String.format(
      "minWritableBytes: %d (expected: >= 0)", minWritableBytes));
  }
  ensureWritable0(minWritableBytes);
  return this;
}


private void ensureWritable0(int minWritableBytes) {
  if (minWritableBytes <= writableBytes()) {
    return;
  }

  if (minWritableBytes > maxCapacity - writerIndex) {
    throw new IndexOutOfBoundsException(String.format(
      "writerIndex(%d) + minWritableBytes(%d) exceeds maxCapacity(%d): %s",
      writerIndex, minWritableBytes, maxCapacity, this));
  }

  // Normalize the current capacity to the power of 2.
  int newCapacity = alloc().calculateNewCapacity(writerIndex + minWritableBytes, maxCapacity);

  // Adjust to the new capacity.
  capacity(newCapacity);
}

擴容呼叫了 AbstractByteBufAllocator 類 的 calculateNewCapacity()方法:

@Override
public int calculateNewCapacity(int minNewCapacity, int maxCapacity) {
  if (minNewCapacity < 0) {
    throw new IllegalArgumentException("minNewCapacity: " + minNewCapacity + " (expectd: 0+)");
  }
  if (minNewCapacity > maxCapacity) {
    throw new IllegalArgumentException(String.format(
      "minNewCapacity: %d (expected: not greater than maxCapacity(%d)",
      minNewCapacity, maxCapacity));
  }
  final int threshold = 1048576 * 4; // 4 MiB page

  if (minNewCapacity == threshold) {
    return threshold;
  }

  // If over threshold, do not double but just increase by threshold.
  if (minNewCapacity > threshold) {
    int newCapacity = minNewCapacity / threshold * threshold;
    if (newCapacity > maxCapacity - threshold) {
      newCapacity = maxCapacity;
    } else {
      newCapacity += threshold;
    }
    return newCapacity;
  }

  // Not over threshold. Double up to 4 MiB, starting from 64.
  int newCapacity = 64;
  while (newCapacity < minNewCapacity) {
    newCapacity <<= 1;
  }

  return Math.min(newCapacity, maxCapacity);
}

擴容設定首次遞增的閾值為:threshold = 1048576 * 4,即 1024 * 1024 * 4 = 4M。

如果待申請記憶體空間等於 4M,即返回。

如果待申請記憶體空間大於 4M,申請空間 = 待申請記憶體空間 / 4M * 4M,這個值應該是 4M 的一點幾倍的大小。

如果申請空間 > 容量上限 - 4M,那麼申請空間 = 容量上限,否則 申請空間 = 當前申請空間 + 4M。

2.3 指標操作

指標操作主要是對讀寫指標的位移操作,以及指定位置讀寫。

ByteBuf 的分類

下圖給出了 ByteBuf 下的分類,可以看到所有的子類都是繼承 AbstactBytebuf:

2

根據操作和儲存方式大概可分為3種大類:

Pooled:使用池化記憶體。從預先分配好的記憶體池中取出一段連續空間給應用使用;

Direct:使用堆外記憶體。不在 JVM 中管理這一部分記憶體的使用,由 Netty 來控制分配和釋放;

UnSafe:使用 JDK底層的 UnSafe api 基於物件的記憶體地址進行操作。

根據以上三個大的方向,對應的子類:

  • PooledHeapByteBuf :池化的堆內緩衝區;
  • PooledUnsafeHeapByteBuf :池化的 Unsafe 堆內緩衝區;
  • PooledDirectByteBuf :池化的直接(堆外)緩衝區;
  • PooledUnsafeDirectByteBuf :池化的 Unsafe 直接(堆外)緩衝區;
  • UnpooledHeapByteBuf :非池化的堆內緩衝區;
  • UnpooledUnsafeHeapByteBuf :非池化的 Unsafe 堆內緩衝區;
  • UnpooledDirectByteBuf :非池化的直接(堆外)緩衝區;
  • UnpooledUnsafeDirectByteBuf :非池化的 Unsafe 直接(堆外)緩衝區;

除了上面這些,另外Netty 的 Buffer 家族還有 CompositeByteBufReadOnlyByteBufferBufThreadLocalDirectByteBuf 等等。

使用 堆記憶體 和 堆外記憶體各自有各自的好處。

堆記憶體分配回收快,可被JVM自動管理,缺點是多一次複製,需要從核心緩衝區複製到堆緩衝區。

直接記憶體緩衝區需要自己處理回收相關的操作,但是減少了一次複製。

業務上來看,對於 I/O 操作比較頻繁的通訊操作,要求響應快這種情況下使用直接記憶體比較合適;對於業務的資料處理,對效能沒有什麼要求使用堆記憶體合適。

引用計數器:AbstractReferenceCountedByteBuf

由上面的類結構能看到所有的子類都是繼承 AbstractReferenceCountedByteBuf 類,這個類的主要功能是對引用進行計數,就是 Netty 自己實現的記憶體回收機制,類似於 JVM 的引用計數。非池化的 ByteBuf 每次 I/O 都會建立一個 ByteBuf,可由 JVM 管理其生命週期;池化的 ByteBuf 要手動進行記憶體回收和釋放。

AbstractReferenceCountedByteBuf 內部有兩個變數:

private static final AtomicIntegerFieldUpdater<AbstractReferenceCountedByteBuf> refCntUpdater;
private volatile int refCnt = 1;

static {
  AtomicIntegerFieldUpdater<AbstractReferenceCountedByteBuf> updater = PlatformDependent.newAtomicIntegerFieldUpdater(AbstractReferenceCountedByteBuf.class, "refCnt");
  if (updater == null) {
    updater = AtomicIntegerFieldUpdater.newUpdater(AbstractReferenceCountedByteBuf.class, "refCnt");
  }

  refCntUpdater = updater;
}

注意到在 AbstractReferenceCountedByteBuf 內部並不直接對 refCnt 進行操作,這裡必須要保證操作的原子性, Netty 包裝了一個 AtomicIntegerFieldUpdater, 原子性 int 型別欄位更新器,通過反射的方式拿到欄位,底層呼叫 UnSafe.compareAndSwapInt() 來實現原子更新。

refCnt 使用 volatile 修飾,保證各個執行緒之間可見。如果單獨使用原子操作面對併發情況並不一定能保證 refCnt 的值正確。

池化堆記憶體分析-PooledByteBuf

從上面的類圖中可以看到 PooledHeapByteBuf、PooledUnsafeHeapByteBuf、PooledDirectByteBuf都繼承自 PooledByteBuf。

abstract class PooledByteBuf<T> extends AbstractReferenceCountedByteBuf {
    // 物件池的物件引用,通過Recycler.Handle實現物件池的功能,執行緒級的快取
    private final Recycler.Handle<PooledByteBuf<T>> recyclerHandle;
    // PoolChunk
    protected PoolChunk<T> chunk;
    // chunk分配記憶體後的handle(位置)
    protected long handle;
    // 實際記憶體區域(byte[]或者ByteBuffer)
    protected T memory;
    // 實際記憶體區域的開始偏移量
    protected int offset;
    // 長度
    protected int length;
    // 最大長度
    int maxLength;
    // 執行緒快取
    PoolThreadCache cache;
    // 臨時的Nio緩衝區
    ByteBuffer tmpNioBuf;
    // ByteBuf分配器
    private ByteBufAllocator allocator;
  
  
  protected PooledByteBuf(Recycler.Handle<? extends PooledByteBuf<T>> recyclerHandle, int maxCapacity) {
    super(maxCapacity);
    this.recyclerHandle = (Handle<PooledByteBuf<T>>) recyclerHandle;
  }
}

池化的主要操作是物件管理, Netty 提供了 Recycler 類作為物件池管理員,先說結論,等會再分析:

  1. 每個執行緒都有一個當前執行緒的物件池,Recycler 類提供了一個類成員變數用來儲存各個執行緒曾經使用過的物件,當然不能無限新增,有一定的回收機制。
  2. 每個執行緒結束當前物件池即被回收。

物件池通過 Recycler 裡面定義以下物件來實現物件池功能:

物件名 作用
DefaultHandle Recycler 中快取的物件都會包裝成 DefaultHandle 類
WeakOrderQueue 儲存其它執行緒回收到當前執行緒 stack 的物件,每個執行緒的 Stack 擁有1個WeakOrderQueue 連結串列,連結串列每個節點對應1個其它執行緒的 WeakOrderQueue,其它執行緒回收到該 Stack 的物件就儲存在這個 WeakOrderQueue 裡。當某個執行緒從 Stack中獲取不到物件時會從 WeakOrderQueue 中獲取物件。
Stack 儲存當前執行緒回收的物件。Stack 會與執行緒繫結,即每個用到 Recycler 的執行緒都會擁有1個 Stack,在該執行緒中獲取物件都是在該執行緒的 Stack 中彈出出一個可用物件。物件的獲取和回收對應 Stack 的 pop 和 push,即獲取物件時從 Stack 中彈出1個DefaultHandle,回收物件時將物件包裝成 DefaultHandle push 到 Stack 中。
Link WeakOrderQueue 中包含1個 Link 連結串列,回收物件儲存在連結串列某個 Link 節點裡,當Link節點儲存的回收物件滿了時會新建1個 Link 放在 Link 連結串列尾。

子類繼承它時需要實現上面貼出程式碼中的構造方法, 因為不同的子類針對不同的物件進行池化,具體是什麼物件由子類自己實現。這個構造方法初始化了 Recycler.Handle,我們上面說物件池屬於當前執行緒,那如果在當前執行緒中 new 了多個 Recycler.Handle,這還是同一個物件池嗎?接著看 Recycler 的程式碼:

public abstract class Recycler<T> {

 	 /**
    *  表示一個不需要回收的包裝物件,用於在禁止使用Recycler功能時進行佔位的功能
    *  僅當io.netty.recycler.maxCapacityPerThread<=0時用到
    */
  @SuppressWarnings("rawtypes")
  private static final Handle NOOP_HANDLE = new Handle() {
    @Override
    public void recycle(Object object) {
      // NOOP
    }
  };
  //當前執行緒ID,WeakOrderQueue的id
  private static final AtomicInteger ID_GENERATOR = new AtomicInteger(Integer.MIN_VALUE);
  private static final int OWN_THREAD_ID = ID_GENERATOR.getAndIncrement();
  private static final int DEFAULT_INITIAL_MAX_CAPACITY_PER_THREAD = 32768; // Use 32k instances as default.
  /**
     * 每個Stack預設的最大容量
     * 注意:
     * 1、當io.netty.recycler.maxCapacityPerThread<=0時,禁用回收功能(在netty中,只有=0可以禁用,<0預設使用4k)
     * 2、Recycler中有且只有兩個地方儲存DefaultHandle物件(Stack和Link),
     * 最多可儲存MAX_CAPACITY_PER_THREAD + 最大可共享容量 = 4k + 4k/2 = 6k
     *
     * 實際上,在netty中,Recycler提供了兩種設定屬性的方式
     * 第一種:-Dio.netty.recycler.ratio等jvm啟動引數方式
     * 第二種:Recycler(int maxCapacityPerThread)構造器傳入方式
     */
  private static final int DEFAULT_MAX_CAPACITY_PER_THREAD;
	//每個Stack預設的初始容量,預設為256,後續根據需要進行擴容,直到<=MAX_CAPACITY_PER_THREAD
  private static final int INITIAL_CAPACITY;
  //最大可共享的容量因子= maxCapacity / maxSharedCapacityFactor,預設為2
  private static final int MAX_SHARED_CAPACITY_FACTOR;
  //每個執行緒可擁有多少個WeakOrderQueue,預設為2*cpu核數,實際上就是當前執行緒的Map<Stack<?>, WeakOrderQueue>的size最大值
  private static final int MAX_DELAYED_QUEUES_PER_THREAD;
  /**
     * WeakOrderQueue中的Link中的陣列DefaultHandle<?>[] elements容量,預設為16,
     * 當一個Link中的DefaultHandle元素達到16個時,會新建立一個Link進行儲存,這些Link組成連結串列,當然
     * 所有的Link加起來的容量要<=最大可共享容量。
     */
  private static final int LINK_CAPACITY;
  //回收因子,預設為8,即預設每8個物件,允許回收一次,直接扔掉7個,可以讓recycler的容量緩慢的增大,避免爆發式的請求
  private static final int RATIO;

  static {
    // In the future, we might have different maxCapacity for different object types.
    // e.g. io.netty.recycler.maxCapacity.writeTask
    //      io.netty.recycler.maxCapacity.outboundBuffer
    int maxCapacityPerThread = SystemPropertyUtil.getInt("io.netty.recycler.maxCapacityPerThread",
                                                         SystemPropertyUtil.getInt("io.netty.recycler.maxCapacity", DEFAULT_INITIAL_MAX_CAPACITY_PER_THREAD));
    if (maxCapacityPerThread < 0) {
      maxCapacityPerThread = DEFAULT_INITIAL_MAX_CAPACITY_PER_THREAD;
    }

    DEFAULT_MAX_CAPACITY_PER_THREAD = maxCapacityPerThread;

    MAX_SHARED_CAPACITY_FACTOR = max(2,
                                     SystemPropertyUtil.getInt("io.netty.recycler.maxSharedCapacityFactor",
                                                               2));

    MAX_DELAYED_QUEUES_PER_THREAD = max(0,
                                        SystemPropertyUtil.getInt("io.netty.recycler.maxDelayedQueuesPerThread",
                                                       NettyRuntime.availableProcessors() * 2));

    LINK_CAPACITY = safeFindNextPositivePowerOfTwo(
      max(SystemPropertyUtil.getInt("io.netty.recycler.linkCapacity", 16), 16));
    RATIO = safeFindNextPositivePowerOfTwo(SystemPropertyUtil.getInt("io.netty.recycler.ratio", 8));

   
    INITIAL_CAPACITY = min(DEFAULT_MAX_CAPACITY_PER_THREAD, 256);
  }

  private final int maxCapacityPerThread;
  private final int maxSharedCapacityFactor;
  private final int ratioMask;
  private final int maxDelayedQueuesPerThread;
	/**
     * 每一個執行緒包含一個Stack物件
     * 1、每個Recycler物件都有一個threadLocal
     * 原因:因為一個Stack要指明儲存的物件泛型T,而不同的Recycler<T>物件的T可能不同,所以此處的FastThreadLocal是物件級別
     * 2、每條執行緒都有一個Stack<T>物件
     */
  private final FastThreadLocal<Stack<T>> threadLocal = new FastThreadLocal<Stack<T>>() {
    @Override
    protected Stack<T> initialValue() {
      return new Stack<T>(Recycler.this, Thread.currentThread(), maxCapacityPerThread, maxSharedCapacityFactor,
                          ratioMask, maxDelayedQueuesPerThread);
    }
  };

  protected Recycler() {
    this(DEFAULT_MAX_CAPACITY_PER_THREAD);
  }


}

在 PooledByteBuf 中通過持有 DefaultHandle: ecycler.Handle 呼叫 recycle()方法將物件轉為 DefaultHandle 存入 Recycler:

@Override
public void recycle(Object object) {
  if (object != value) {
    throw new IllegalArgumentException("object does not belong to handle");
  }
  stack.push(this);
}

將當前 DefaultHandle 存入 Stack,從這裡看:

static final class DefaultHandle<T> implements Handle<T> {
        private int lastRecycledId;
        private int recycleId;

        boolean hasBeenRecycled;

        private Stack<?> stack;
        private Object value;

        DefaultHandle(Stack<?> stack) {
            this.stack = stack;
        }
  ......
}

DefaultHandle 初始化的時候會帶過來一個 Stack 賦值給當前的 stack,那麼 Stack 是在什麼時候初始化的呢,看這個程式碼:

private final FastThreadLocal<Stack<T>> threadLocal = new FastThreadLocal<Stack<T>>() {
  @Override
  protected Stack<T> initialValue() {
    return new Stack<T>(Recycler.this, Thread.currentThread(), maxCapacityPerThread, maxSharedCapacityFactor,
                        ratioMask, maxDelayedQueuesPerThread);
  }
};

一個 final 型別的 FastThreadLocal 物件包著 Stack 完成了初始化。FastThreadLocal 是 Netty 自己實現的 ThreadLocal,主要優化了 ThreadLocal 的 訪問速度 和 記憶體洩漏 等問題,這裡可以說明每個 Recycler 物件中的 Stack 是當前執行緒內共享的。

WeakOrderQueue 的作用又是什麼呢?我們看到有這樣一行程式碼:

private static final FastThreadLocal<Map<Stack<?>, WeakOrderQueue>> DELAYED_RECYCLED =
  new FastThreadLocal<Map<Stack<?>, WeakOrderQueue>>() {
  @Override
  protected Map<Stack<?>, WeakOrderQueue> initialValue() {
    return new WeakHashMap<Stack<?>, WeakOrderQueue>();
  }
};

static final 表明當前 DELAYED_RECYCLED 物件是 Recycler 類變數,而不是 成員變數。這裡表示每一個 Stack 都對應一個 WeakOrderQueue。這裡還是沒有看懂到底有什麼用,我們看使用到它的地方:

void push(DefaultHandle<?> item) {
  Thread currentThread = Thread.currentThread();
  if (thread == currentThread) {
    // The current Thread is the thread that belongs to the Stack, we can try to push the object now.
    pushNow(item);
  } else {
    // The current Thread is not the one that belongs to the Stack, we need to signal that the push
    // happens later.
    pushLater(item, currentThread);
  }
}

private void pushNow(DefaultHandle<?> item) {
   // (item.recycleId | item.lastRecycleId) != 0 等價於 item.recycleId!=0 && item.lastRecycleId!=0
  // 當item開始建立時item.recycleId==0 && item.lastRecycleId==0
  // 當item被recycle時,item.recycleId==x,item.lastRecycleId==y 進行賦值
  // 當item被poll之後, item.recycleId = item.lastRecycleId = 0
  // 所以當item.recycleId 和 item.lastRecycleId 任何一個不為0,則表示回收過
  if ((item.recycleId | item.lastRecycledId) != 0) {
    throw new IllegalStateException("recycled already");
  }
  item.recycleId = item.lastRecycledId = OWN_THREAD_ID;

  int size = this.size;
  if (size >= maxCapacity || dropHandle(item)) {
    // Hit the maximum capacity or should drop - drop the possibly youngest object.
    return;
  }
  // 如果物件池已滿則擴容,擴充套件為當前 2 倍大小
  if (size == elements.length) {
    elements = Arrays.copyOf(elements, min(size << 1, maxCapacity));
  }

  elements[size] = item;
  this.size = size + 1;
}

private void pushLater(DefaultHandle<?> item, Thread thread) {
  // we don't want to have a ref to the queue as the value in our weak map
  // so we null it out; to ensure there are no races with restoring it later
  // we impose a memory ordering here (no-op on x86)
  Map<Stack<?>, WeakOrderQueue> delayedRecycled = DELAYED_RECYCLED.get();
  WeakOrderQueue queue = delayedRecycled.get(this);
  // 如果沒有獲取到 WeakOrderQueue,說明當前執行緒第一次幫該 Stack 回收物件
  if (queue == null) {
    // 每個執行緒最多能幫 maxDelayedQueues(2CPU)個外部 Stack 回收物件,超過數量回收失敗
    if (delayedRecycled.size() >= maxDelayedQueues) {
      // 插入一個特殊的 WeakOrderQueue,下次回收時看到 WeakOrderQueue.DUMMY 就說明該執行緒無法幫該 Stack 回收
      delayedRecycled.put(this, WeakOrderQueue.DUMMY);
      return;
    }
    // 別的執行緒最多幫這個 Stack 回收 2K 個物件,檢查是否超過數量,如果沒有超過,就向這個 Stack 頭插法新建 WeakOrderQueue 物件
    if ((queue = WeakOrderQueue.allocate(this, thread)) == null) {
      // drop object
      return;
    }
    delayedRecycled.put(this, queue);
    // 看到 WeakOrderQueue.DUMMY 就說明該執行緒無法幫該 Stack 回收,直接返回
  } else if (queue == WeakOrderQueue.DUMMY) {
    // drop object
    return;
  }
	// 向 WeakOrderQueue 對應的 Link 存放物件
  queue.add(item);
}

在存放 DefaultHandle 到 Stack 的時候會判斷是否是當前執行緒,如果是就呼叫 pushNow()方法,如果不是則呼叫 pushLater() 方法。

pushNow() 方法中首先判斷一個 物件是否是被回收過,如果是則拋異常。如果沒有則存入 elements 陣列中。

pushLater() 方法則先把 DefaultHandle 放入 DELAYED_RECYCLED 持有的 WeakOrderQueue 中,後面再壓如 Stack。

這裡大概的意思就是如果是當前執行緒建立的物件就存入 Stack,如果不是當前執行緒建立的就放入WeakOrderQueue。我們看 WeakOrderQueue 類裡面有有一個子類 Link:

private static final class WeakOrderQueue {

        static final WeakOrderQueue DUMMY = new WeakOrderQueue();

        // Let Link extend AtomicInteger for intrinsics. The Link itself will be used as writerIndex.
        @SuppressWarnings("serial")
        private static final class Link extends AtomicInteger {
            private final DefaultHandle<?>[] elements = new DefaultHandle[LINK_CAPACITY];

            private int readIndex;
            private Link next;
        }

        // chain of data items
        private Link head, tail;
        // pointer to another queue of delayed items for the same stack
        private WeakOrderQueue next;
        private final WeakReference<Thread> owner;
        private final int id = ID_GENERATOR.getAndIncrement();
        private final AtomicInteger availableSharedCapacity;

        private WeakOrderQueue() {
            owner = null;
            availableSharedCapacity = null;
        }
......
}

Link 的結構是一個連結串列,存放了 DefaultHandle<?>[] 物件,放入的時機就是上面的 pushLater() 方法。

這裡我們已經全部接觸到了上面提到的 4 個物件,我用一張圖來表述他們之間的關係:

3

我們再來總結一下 4 者的關係:

  • 每一個 Recycler 物件 都包含一個 Stack;
  • 每一個 Stack 中都包含一個 DefaultHandle<?>[] 陣列,用於儲存 DefaultHandle;
  • Recyler 類包含一個類物件 FastThreadLocal<Map<Stack<?>, WeakOrderQueue>> DELAYED_RECYCLED,無論有多少個 Recyler 物件,都只會有一個 DELAYED_RECYCLED。它的作用是儲存除當前執行緒外別的執行緒建立的 DefaultHandle。
  • WeakOrderQueue 物件中儲存一個以 Head 為首的 Link 陣列,每個 Link 物件中儲存一個 DefaultHandle[] 陣列,用於存放回收物件。

同執行緒中是如何獲取物件的呢?

public final T get() {
    /**
     * 如果maxCapacityPerThread == 0,禁止回收功能
     * 建立一個物件,其Recycler.Handle<> handle屬性為NOOP_HANDLE,該物件的recycle(Object object)不做任何事情,即不做回收
     */
    if (MAX_CAPACITY_PER_THREAD == 0) {
        return newObject((Handle<T>) NOOP_HANDLE);
    }
    //獲取當前執行緒的Stack<T>物件
    Stack<T> stack = threadLocal.get();
    //從Stack<T>物件中獲取DefaultHandle<T>
    DefaultHandle<T> handle = stack.pop();
    if (handle == null) {
        //新建一個DefaultHandle物件 -> 然後新建T物件 -> 儲存到DefaultHandle物件
        //此處會發現一個DefaultHandle物件對應一個Object物件,二者相互包含。
        handle = stack.newHandle();
        handle.value = newObject(handle);
    }
    
    return handle.value;
}

呼叫 Stack 的 pop()方法獲取 DefaultHandle 物件:

DefaultHandle<T> pop() {
  int size = this.size;
  if (size == 0) {
    if (!scavenge()) {
      return null;
    }
    size = this.size;
  }
  size --;
  DefaultHandle ret = elements[size];
  elements[size] = null;
  if (ret.lastRecycledId != ret.recycleId) {
    throw new IllegalStateException("recycled multiple times");
  }
  ret.recycleId = 0;
  ret.lastRecycledId = 0;
  this.size = size;
  return ret;
}

當 Stack 中 DefaultHandle[] 的 size=0 時,需要從其他執行緒的 WeakOrderQueue 中轉移資料到 Stack 中的DefaultHandle[],即呼叫 scavenge() 方法。當 Stack 中的 DefaultHandle[] 中最終有了資料時直接獲取最後一個元素,並進行一些健康檢查。

假設最終確實無法從物件池中獲取到物件,則會首先建立一個 DefaultHandle 物件,之後呼叫 Recycler 的子類重寫的 newObject() 方法。

DirectBuffer-直接記憶體分配

Netty 中的堆外記憶體分配主要是呼叫 NIO 的 DirectByteBuffer 來操作。DirectByteBuffer 與 ByteBuffer 的區別在於底層沒有使用 byte[] hb 來承接資料,而是放在了堆外管理,DirectByteBuffer的建立就是使用了 malloc 申請的記憶體。

如果我們使用普通的 Buffer 來分配記憶體是這樣的:

ByteBuffer buf = ByteBuffer.allocate(1024);

這種方式分配的記憶體底層是一個 byte[] 陣列儲存在 JVM 堆上。

當我們想脫離 JVM 的管理,直接在系統記憶體上去分配一塊連續空間的時候,Java 也提供了這種方式。DirectByteBuffer 並不是一個 public 型別的 class,所以我們無法直接使用,一般通過如下方式呼叫:

ByteBuffer buf = ByteBuffer.allocateDirect(1024);

的構造方法如下:

DirectByteBuffer(int cap) {                   // package-private

  super(-1, 0, cap, cap);
  //是否頁對齊
  boolean pa = VM.isDirectMemoryPageAligned();
  //頁的大小4K
  int ps = Bits.pageSize();
  //最小申請1K,若需要頁對齊,那麼多申請1頁,以應對初始地址的頁對齊問題
  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物件用於清理該DirectBuffer記憶體
  cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
  att = null;



}

首先 Bits.reserveMemory(size, cap) 方法用來判斷系統是否有足夠的空間可以申請,如果已經沒有空間可以申請,則丟擲 OOM:

static void reserveMemory(long size, int cap) {
	// 獲取最大可以申請的對外記憶體大小,預設值是64MB
  // 可以通過引數-XX:MaxDirectMemorySize=<size>設定這個大小
  if (!memoryLimitSet && VM.isBooted()) {
    maxMemory = VM.maxDirectMemory();
    memoryLimitSet = true;
  }
	
  //如果計算當前使用者申請的空間 小於使用者設定的最大堆外空間大小,且小於當前可用的
  //系統記憶體則表示申請通過
  // optimist!
  if (tryReserveMemory(size, cap)) {
    return;
  }

  final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

  //嘗試釋放那些正在正在清理中的堆外記憶體任務以釋放一些空間
  while (jlra.tryHandlePendingReference()) {
    if (tryReserveMemory(size, cap)) {
      return;
    }
  }

  // 如果經歷上面兩步空間還是不足,那就只好手動呼叫 System.gc()釋放記憶體
  System.gc();

  // a retry loop with exponential back-off delays
  // (this gives VM some time to do it's job)
  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;
        }
      }
    }

    // no luck
    throw new OutOfMemoryError("Direct buffer memory");

  } finally {
    if (interrupted) {
      // don't swallow interrupts
      Thread.currentThread().interrupt();
    }
  }
}



private static boolean tryReserveMemory(long size, int cap) {
	// -XX:MaxDirectMemorySize限制的是使用者申請的大小,而不考慮對齊情況
	// 所以使用兩個變數來統計:
	//     reservedMemory:真實的目前保留的空間
	//     totalCapacity:目前使用者申請的空間
  long totalCap;
  while (cap <= maxMemory - (totalCap = totalCapacity.get())) {
    if (totalCapacity.compareAndSet(totalCap, totalCap + cap)) {
      reservedMemory.addAndGet(size);
      count.incrementAndGet();
      return true;
    }
  }

  return false;
}

可以通過 -XX:+PageAlignDirectMemor 引數控制堆外記憶體分配是否需要按頁對齊,預設不對齊。

Bits#reserveMemory() 方法判斷是否有足夠記憶體不是判斷物理機是否有足夠記憶體,而是判斷 JVM 啟動時,指定的堆外記憶體空間大小是否有剩餘的空間。這個大小由引數 -XX:MaxDirectMemorySize=<size> 設定。

接著呼叫 base = unsafe.allocateMemory(size) 操作堆外記憶體, 返回的是該堆外記憶體的直接地址, 存放在 address 中, 以便通過 address 進行堆外資料的讀取與寫入。而 allocateMemory() 是一個 native 方法,會呼叫 malloc 方法。UnSafe 類底層是基於 C 語言的,所以在 Java 原始碼中看不到,我們可以下載 OpenJDK 的原始碼看看,原始碼連結:https://github.com/openjdk/jdk/blob/5a6954abbabcd644ad2639ea11e843da5b17a11d/src/hotspot/share/prims/unsafe.cpp#L359

UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory0(JNIEnv *env, jobject unsafe, jlong size)) {
  size_t sz = (size_t)size;

  assert(is_aligned(sz, HeapWordSize), "sz not aligned");

  void* x = os::malloc(sz, mtOther);

  return addr_to_java(x);
} UNSAFE_END

可以看到底層是使用系統的malloc()函式來申請記憶體。

在 C 語言的記憶體分配和釋放函式 malloc/free,必須要一一對應,否則就會出現記憶體洩露或者是野指標的非法訪問。Java 中 ByteBuffer 申請的堆外記憶體需要手動釋放嗎?ByteBuffer 申請的堆外記憶體也是由 GC 負責回收的,Hotspot 在 GC 時會掃描 Direct ByteBuffer 物件是否有引用,如沒有,當堆內的引用被 gc 回收時通過虛擬引用回收其佔用的堆外記憶體。(前提是沒有關閉 DisableExplicitGC

-XX:+DisableExplicitGC

這個引數作用是禁止顯式呼叫 GC,即通過 System.gc() 函式呼叫。如果加上了這個 JVM啟動引數,那麼程式碼中呼叫 System.gc() 沒有任何效果,相當於是沒有這行程式碼一樣。

上面貼出來而程式碼示例:DirectByteBuffer 的建構函式裡面:

Bits.reserveMemory(size, cap);

該方法去申請堆外記憶體是會顯式呼叫 System.gc()的。

也就是說使用了Java NIO 中的 Direct memory,那麼 -XX:+DisableExplicitGC一定要謹慎設定,存在潛在的記憶體洩露風險。

再說另一個問題:-XX:MaxDirectMemorySize=<size>引數用來限制能申請的最大堆外記憶體大小,那如果我忘記設定這個值預設能夠申請的堆記憶體大小是多少呢?我們還是要看 OpenJDK原始碼,這個引數的設定位於:https://github.com/openjdk/jdk/blob/847a3baca8a19b4f506dcaf23079e1b339e5321b/src/java.base/share/classes/jdk/internal/misc/VM.java

4

可以看到程式碼中預設是 64M。但是你好好看一下注釋:

The initial value of this field is arbitrary; during JRE initialization
it will be reset to the value specified on the command line, if any,
otherwise to Runtime.getRuntime().maxMemory().

這個值只是在初始化的時候的預設賦值。如果使用者有通過引數設定自己的值就會用設定的引數值取代,否則:就會使用 JVM 引數 -Xmx 最大堆的值取代。所以,64M 是沒有發揮到作用的。

堆外記憶體的回收

既然在 heap 外分配了記憶體空間給 Java 執行緒使用,JVM 也不管回收這事。那是怎麼觸發回收的呢?這裡要說明,JVM 並不是真的不管,堆外分配記憶體儲存物件這事兒板上釘釘,那 JVM 是怎麼著知道堆外哪哪塊是我這個物件的專屬空間,這個就要求在 JVM 中要儲存一個引用的關係。

在 DirectBuffer 建構函式最後面有這麼一句:

cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

使用 Cleaner 機制註冊記憶體回收處理函式。Java 本身提供了finalize()機制來進行垃圾回收,無賴它靠不住不到記憶體撐不住的最後時刻它是不會被觸發的,所以 Java 官方都不推薦你這樣用。Java 官方推薦使用虛引用-PhantomReference 來處理物件的回收,Cleaner 就是 PhantomReference 的子類,用來處理物件回收流程。

這裡create()方法傳入了一個引數 Deallocator 物件,Deallocator 繼承了Runnable,作為可執行的執行緒,看一下run() 方法:

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

這裡呼叫了 UnSafe 的 freeMemory()拿到堆外記憶體地址偏移量來釋放記憶體。

ByteBuf 的管理

在 Netty 中並不是通過 new 的方式來建立一個 Bytebuf 物件。常用的有三種方式:

  1. ByteBufAllocator 建立;
  2. ByteBufUtil:提供一些實用的靜態方法用於 記憶體分配 和 物件轉換;
  3. Unpooled 非池化記憶體分配。

ByteBufAllocator 是 Netty 中最頂層的記憶體分配介面,負責所有 Bytebuf 型別的分配,AbstractByteBufAllocator 是預設實現類。

我們看一下它是如何分配記憶體空間的:

@Override
public ByteBuf buffer(int initialCapacity) {
  if (directByDefault) {
    return directBuffer(initialCapacity);
  }
  return heapBuffer(initialCapacity);
}

首先會檢查是否支援分配直接記憶體,如果支援就優先分配堆外記憶體空間。

@Override
public ByteBuf directBuffer(int initialCapacity, int maxCapacity) {
  if (initialCapacity == 0 && maxCapacity == 0) {
    return emptyBuf;
  }
  validate(initialCapacity, maxCapacity);
  return newDirectBuffer(initialCapacity, maxCapacity);
}

protected abstract ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity);

newDirectBuffer() 是一個抽象方法,最終會交給它的子類去實現進行空間分配:

5

可以看到實現類其實就兩種:池化 和 非池化的 buffer 分配。上面的 Buffer 分配我們看到還有 Unsafe 型別的Buffer,那麼這裡為什麼沒有體現呢?既然找不到答案,就繼續往下看看,我們看一下PooledByteBufAllocator 類的實現:

@Override
protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
  PoolThreadCache cache = threadCache.get();
  PoolArena<ByteBuffer> directArena = cache.directArena;

  final ByteBuf buf;
  if (directArena != null) {
    buf = directArena.allocate(cache, initialCapacity, maxCapacity);
  } else {
    buf = PlatformDependent.hasUnsafe() ?
      UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity) :
    new UnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
  }

  return toLeakAwareBuffer(buf);
}

首先還是判斷是否支援直接記憶體分配,如果不支援,會判斷當前平臺是否支援使用 Unsafe 工具包,如果支援那自然優先使用 Unsafe 工具去直接分配記憶體。

這裡有一個 Unsafe 工具類:

UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity)

Unpooled 使用

一般來說 ByteBufAllocator 已經提供了池化和非池化記憶體分配的實現,但是 Netty 還是提供了一個簡單版的 非池化記憶體分配工具:Unpooled,以防在極端的情況下你無法使用 ByteBufAllocator 進行記憶體分配。

6

從原始碼上能看到底層還是引用了 UnpooledByteBufAllocator 類來實現非池化的記憶體分配。

ByteBufUtil

ByteBufUtil 就更厲害了,預設使用的記憶體分配方式取決於你的設定:

7

未設定預設會選擇池化的方式。

ByteBufUtil 主要提供一些靜態方法,其中 hexdump() 以十六進位制的表示形式列印ByteBuf 的內容。這在各種情況下都很有用,比如除錯的時候記錄ByteBuf 的內容,總比你看一堆二進位制的天書好吧。

還有 encodeString() 對字串進行編碼轉換為 ByteBuffer。

相關文章