Node Buffer 對象的探究與內存分配代碼挖掘

參照 Node 官方文檔 Buffer API

作用

在前端開發過程中,一般也只有字符串級別的操作,很少接觸字節這樣的底層操作。而在後端的領域裏,操作網絡協議、圖片和文件 I/O 十分常見二進制數據,爲了讓 JS 能處理,node 封裝了一個 Buffer 類。

簡言之:Buffer 類是專用來操作二進制數據流並與之交互的,也叫緩衝區。

基本使用

創建 Buffer,其中第二個參數是字符編碼格式,參照 Node 官方文檔-Buffer 與字符編碼

// <Buffer 31>
const buf = Buffer.from('1');
// <Buffer 31 30>
const buf1 = Buffer.from('10');
// <Buffer 31 30>
const buf2 = Buffer.from('10', 'utf8');
// <Buffer 0a>
const buf3 = Buffer.from([10]);
// <Buffer 0a>
const buf4 = Buffer.from(buf3);

返回一個已經初始化的 Buffer,可以保證新創建的 Buffer 永遠不會包含舊數據。

// 創建一個長度爲 10 的 Buffer,
// 其中填充了全部值爲 \u0000,也就是空字符串的字節。
const b = Buffer.alloc(10);

console.log(b); // <Buffer 00 00 00 00 00 00 00 00 00 00>

字符串與 Buffer 互轉化

const buf = Buffer.from('JS語言', 'utf8');

console.log(buf); // <Buffer 4a 53 e8 af ad e8 a8 80>
console.log(buf.length); // 6,前兩個字節是 "JS",後四個字節是 "語言"

console.log(buf.toString('utf8')); // JS語言

Buffer 的應用場景

文件與網絡 I/O,與流 Stream 密不可分,只是 Stream 包裝了一些東西,不需要開發者手動去創建緩衝區。當然下面直接這樣讀文件,是不能讀超過 2GB 的,得需要用流,這裏只是個例子。

var fs = require('fs')

fs.readFile('./1080p.mp4', function(err, data) {
  if (err) {
    console.log(err);
  }
  console.log(data)
})


$ node test.js
<Buffer 00 00 00 20 66 74 79 70 69 73 6f 6d 00 00 02 00 69 73 6f 6d 69 73 6f 32 61 76 63 31 6d 70 34 31 00 58 95 99 6d 6f 6f 76 00 00 00 6c 6d 76 68 64 00 00 ... 1877454492 more bytes>

此外 zlib.js 是 Node 核心庫之一,也利用了緩衝區 Buffer 的功能來操作二進制數據流來提供壓縮或者解壓的功能,參照 zlib.js 源碼

加解密 crypto 也使用了 Buffer 做二進制操作。

內存機制

Buffer 是一個類 Array 的對象,它的元素都是 16 進制的兩位數。是一個典型的 JavaScript 與 C++ 結合的模塊,設計性能的相關部分採用了 C++ 實現,而非性能部分採用了 JavaScript 實現。

由於 Buffer 需要處理大量的二進制數據,如果用一點就要向系統申請,則會造成頻繁的向系統申請內存調用。所以 Buffer 所佔用的內存不再由 V8 分配,而是在 Node.js 的 C++ 層面完成申請,在 JavaScript 中進行內存分配。因此,這部分內存我們稱之爲堆外內存。由此,v8 的垃圾回收影響不了堆外內存。

圖片來自 Node.js之Buffer對象淺析

Buffer 內存分配原理

Node.js 中採用了 slab 機制進行預先申請、事後分配。slab 是一種動態的內存管理機制,它就是一塊申請好的固定大小的內存區域,有 3 種狀態

  • full: 完全分配
  • partial: 部分分配
  • empty: 沒有被分配

這種機制以 8kb 爲界限決定當前分配的對象是大對象還是小對象。

Node 12.x buffer.js 源碼 裏就寫上了以下代碼,所以才能明白“爲什麼說 Buffer 在創建時大小就已經被確定的且無法調整”。

Buffer.poolSize = 8 * 1024;

創建緩衝區的函數如下 v12.x/lib/buffer.js#L156,加載時調用 createPool() 相當於初始化了一個 8kb 的內存空間,這樣第一次內存分配也會變得高效,初始化的同時還用偏移量 poolOffset 來記錄使用了多少字節。

Buffer.poolSize = 8 * 1024;
let poolSize, poolOffset, allocPool;

... // 中間代碼省略

function createPool() {
  poolSize = Buffer.poolSize;
  allocPool = createUnsafeArrayBuffer(poolSize);
  setHiddenValue(allocPool, arraybuffer_untransferable_private_symbol, true);
  poolOffset = 0;
}
createPool();

如果分配了一個 2048 字節的 Buffer 對象,當前 slab 內存應該如下

Buffer.alloc(2 * 1024)

分配的過程見 v12.x/lib/buffer.js#L408

// L147
function createUnsafeBuffer(size) {
  zeroFill[0] = 0;
  try {
    return new FastBuffer(size);
  } finally {
    zeroFill[0] = 1;
  }
}

// .....

// L408
function allocate(size) {
  if (size <= 0) {
    return new FastBuffer();
  }
  // 8096 右移 1 爲 4096,即要分配的空間小於 4kb
  if (size < (Buffer.poolSize >>> 1)) {
    // 當此 slab 剩餘空間不夠分配,則 createPool 再申請一塊 slab 的內存。
    if (size > (poolSize - poolOffset))
      createPool();
    // 夠分配那就直接分配,偏移量加上。
    const b = new FastBuffer(allocPool, poolOffset, size);
    poolOffset += size;
    alignPool();
    return b;
  }
  // 要分配的空間大於 4kb,直接去創建新的內存區吧
  return createUnsafeBuffer(size);
}

FastBuffer 在v12.x/lib/internal/buffer.js#L945,就簡短的一行。可以看作 Buffer 繼承自 Uint8Array。

class FastBuffer extends Uint8Array {}

內存分配總結

  • 在初次加載時就會初始化 1 個 8KB 的內存空間,v12.x/lib/buffer.js#L156 源碼有體現
  • 根據申請的內存大小分爲 小 Buffer 對象 和 大 Buffer 對象
  • 小 Buffer (小於 4kb )情況,判斷這個 slab 剩餘空間是否足夠容納
    • 若足夠就去使用剩餘空間分配,偏移量會增加
    • 若不足,就調用 createPool 創建一個新的 slab 空間用來分配
  • 大 Buffer (大於 4kb )情況,直接 createUnsafeBuffer(size) 創建。

之所以要判斷區別大對象還是小對象,就只是希望小對象不要每次申請時都去向系統申請內存調用。

不論是小 Buffer 對象還是大 Buffer 對象,內存分配是在 C++ 層面完成,內存管理在 JavaScript 層面,最終還是可以被 V8 的垃圾回收標記所回收,回收的是 Buffer 對象本身,堆外內存的那些部分只能交給 C++。

參考

Node.js v14.4.0 文檔

Github-Node-buffer.js 源碼

Node.js 中的緩衝區(Buffer)究竟是什麼?

Node.js之Buffer對象淺析

How to use stream.pipe

認識node核心模塊--從Buffer、Stream到fs

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章