記憶體管理#

記憶體模組包含 Arrow 用於分配和釋放記憶體的所有功能。本文檔分為兩部分:第一部分,記憶體基礎,提供高階介紹。接下來的部分,Arrow 記憶體深入探討,填補細節。

記憶體基礎#

本節將向您介紹 Java 記憶體管理中的主要概念

它還提供了一些在 Arrow 中使用記憶體的指南,並描述了在出現記憶體問題時如何除錯。

開始使用#

Arrow 的記憶體管理是圍繞欄狀格式的需求和使用堆外記憶體而構建的。Arrow Java 有其獨立的實作。它沒有包裝 C++ 實作,儘管該框架足夠靈活,可以與 C++ 中分配的記憶體一起使用,供 Java 代碼使用。

Arrow 提供了多個模組:核心介面和介面的實作。使用者需要核心介面,以及恰好其中一個實作。

  • memory-core:提供 Arrow 函式庫和應用程式使用的介面。

  • memory-netty:基於 Netty 函式庫的記憶體介面實作。

  • memory-unsafe:基於 sun.misc.Unsafe 函式庫的記憶體介面實作。

ArrowBuf#

ArrowBuf 代表 直接記憶體的單個連續區域。它由地址和長度組成,並提供用於處理內容的低階介面,類似於 ByteBuffer。

與 (Direct)ByteBuffer 不同,它內建了參考計數,稍後將討論。

為何 Arrow 使用直接記憶體#

  • 當使用直接記憶體/直接緩衝區時,JVM 可以最佳化 I/O 操作;它將嘗試避免將緩衝區內容複製到/從中間緩衝區。這可以加速 Arrow 中的 IPC。

  • 由於 Arrow 始終使用直接記憶體,因此 JNI 模組可以直接包裝原生記憶體位址,而不是複製資料。我們在 C 資料介面等模組中使用它。

  • 相反地,在 JNI 邊界的 C++ 端,我們可以無需複製資料即可直接存取 ArrowBuf 中的記憶體。

BufferAllocator#

BufferAllocator 主要是一個用於緩衝區(ArrowBuf 實例)記帳的競技場或育兒室。顧名思義,它可以分配與自身關聯的新緩衝區,但它也可以處理在其他地方分配的緩衝區的記帳。例如,它處理在 C++ 中分配並使用 C-Data Interface 與 Java 共享的記憶體的 Java 端記帳。在下面的代碼中,它執行分配

import org.apache.arrow.memory.ArrowBuf;
import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.memory.RootAllocator;

try(BufferAllocator bufferAllocator = new RootAllocator(8 * 1024)){
    ArrowBuf arrowBuf = bufferAllocator.buffer(4 * 1024);
    System.out.println(arrowBuf);
    arrowBuf.close();
}
ArrowBuf[2], address:140363641651200, length:4096

BufferAllocator 介面的具體實作是 RootAllocator。應用程式通常應在程式開始時建立一個 RootAllocator,並透過 BufferAllocator 介面使用它。分配器實作 AutoCloseable,並且在應用程式完成使用後必須關閉;這將檢查所有未完成的記憶體是否已釋放(請參閱下一節)。

Arrow 為記憶體分配提供了一個基於樹狀結構的模型。RootAllocator 首先建立,然後透過 newChildAllocator 將更多分配器建立為現有分配器的子項。建立 RootAllocator 或子分配器時,會提供記憶體限制,並且在分配記憶體時,會檢查該限制。此外,當從子分配器分配記憶體時,這些分配也會反映在所有父分配器中。因此,RootAllocator 有效地設定了程式範圍的記憶體限制,並充當所有記憶體分配的主簿記員。

子分配器不是嚴格要求的,但可以幫助更好地組織程式碼。例如,可以為程式碼的特定部分設定較低的記憶體限制。子分配器可以在該部分完成時關閉,此時它會檢查該部分是否沒有洩漏任何記憶體。子分配器也可以命名,這使得在除錯期間更容易判斷 ArrowBuf 的來源。

參考計數#

由於直接記憶體的分配和釋放成本很高,因此分配器可能會共享直接緩衝區。為了確定性地管理共享緩衝區,我們使用手動參考計數而不是垃圾收集器。這僅僅意味著每個緩衝區都有一個計數器,用於追蹤對緩衝區的參考數量,並且使用者負責在使用緩衝區時正確地遞增/遞減計數器。

在 Arrow 中,每個 ArrowBuf 都有一個關聯的 ReferenceManager,用於追蹤參考計數。您可以使用 ArrowBuf.getReferenceManager() 檢索它。參考計數使用 ReferenceManager.release 遞減計數,並使用 ReferenceManager.retain 遞增計數。

當然,這既繁瑣又容易出錯,因此我們通常不直接使用緩衝區,而是使用更高階的 API,例如 ValueVector。此類類別通常實作 Closeable/AutoCloseable,並會在關閉時自動遞減參考計數。

分配器也實作 AutoCloseable。在這種情況下,關閉分配器將檢查從分配器獲得的所有緩衝區是否已關閉。如果沒有,close() 方法將引發異常;這有助於追蹤來自未關閉緩衝區的記憶體洩漏。

參考計數需要謹慎處理。為了確保獨立的程式碼部分已完全清理所有已分配的緩衝區,請使用新的子分配器。

開發指南#

應用程式通常應

  • 在 API 中使用 BufferAllocator 介面,而不是 RootAllocator。

  • 在程式開始時建立一個 RootAllocator,並在需要時顯式傳遞它。

  • 在使用後 close() 分配器(無論它們是子分配器還是 RootAllocator),手動或最好透過 try-with-resources 語句。

除錯記憶體洩漏/分配#

DEBUG 模式下,分配器和支援類別將記錄額外的除錯追蹤資訊,以更好地追蹤記憶體洩漏和問題。若要啟用 DEBUG 模式,請在啟動 -Darrow.memory.debug.allocator=true 時將以下系統屬性傳遞給 VM。

啟用 DEBUG 後,將保留分配日誌。配置 SLF4J 以查看這些日誌(例如,透過 Logback/Apache Log4j)。考慮以下範例,了解它如何幫助我們追蹤分配器

import org.apache.arrow.memory.ArrowBuf;
import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.memory.RootAllocator;

try (BufferAllocator bufferAllocator = new RootAllocator(8 * 1024)) {
    ArrowBuf arrowBuf = bufferAllocator.buffer(4 * 1024);
    System.out.println(arrowBuf);
}

在未啟用除錯模式的情況下,當我們關閉分配器時,我們會得到這個

11:56:48.944 [main] INFO  o.apache.arrow.memory.BaseAllocator - Debug mode disabled.
ArrowBuf[2], address:140508391276544, length:4096
16:28:08.847 [main] ERROR o.apache.arrow.memory.BaseAllocator - Memory was leaked by query. Memory leaked: (4096)
Allocator(ROOT) 0/4096/4096/8192 (res/actual/peak/limit)

啟用除錯模式,我們會獲得更多詳細資訊

11:56:48.944 [main] INFO  o.apache.arrow.memory.BaseAllocator - Debug mode enabled.
ArrowBuf[2], address:140437894463488, length:4096
Exception in thread "main" java.lang.IllegalStateException: Allocator[ROOT] closed with outstanding buffers allocated (1).
Allocator(ROOT) 0/4096/4096/8192 (res/actual/peak/limit)
  child allocators: 0
  ledgers: 1
    ledger[1] allocator: ROOT), isOwning: , size: , references: 1, life: 261438177096661..0, allocatorManager: [, life: ] holds 1 buffers.
        ArrowBuf[2], address:140437894463488, length:4096
  reservations: 0

此外,在除錯模式下,可以使用 ArrowBuf.print() 來取得除錯字串。這將包含有關緩衝區分配操作的資訊以及堆疊追蹤,例如緩衝區何時/何處分配。

import org.apache.arrow.memory.ArrowBuf;
import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.memory.RootAllocator;

try (final BufferAllocator allocator = new RootAllocator()) {
  try (final ArrowBuf buf = allocator.buffer(1024)) {
    final StringBuilder sb = new StringBuilder();
    buf.print(sb, /*indent*/ 0);
    System.out.println(sb.toString());
  }
}
ArrowBuf[2], address:140433199984656, length:1024
 event log for: ArrowBuf[2]
   675959093395667 create()
      at org.apache.arrow.memory.util.HistoricalLog$Event.<init>(HistoricalLog.java:175)
      at org.apache.arrow.memory.util.HistoricalLog.recordEvent(HistoricalLog.java:83)
      at org.apache.arrow.memory.ArrowBuf.<init>(ArrowBuf.java:96)
      at org.apache.arrow.memory.BufferLedger.newArrowBuf(BufferLedger.java:271)
      at org.apache.arrow.memory.BaseAllocator.bufferWithoutReservation(BaseAllocator.java:300)
      at org.apache.arrow.memory.BaseAllocator.buffer(BaseAllocator.java:276)
      at org.apache.arrow.memory.RootAllocator.buffer(RootAllocator.java:29)
      at org.apache.arrow.memory.BaseAllocator.buffer(BaseAllocator.java:240)
      at org.apache.arrow.memory.RootAllocator.buffer(RootAllocator.java:29)
      at REPL.$JShell$14.do_it$($JShell$14.java:10)
      at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(NativeMethodAccessorImpl.java:-2)
      at jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
      at jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
      at java.lang.reflect.Method.invoke(Method.java:566)
      at jdk.jshell.execution.DirectExecutionControl.invoke(DirectExecutionControl.java:209)
      at jdk.jshell.execution.RemoteExecutionControl.invoke(RemoteExecutionControl.java:116)
      at jdk.jshell.execution.DirectExecutionControl.invoke(DirectExecutionControl.java:119)
      at jdk.jshell.execution.ExecutionControlForwarder.processCommand(ExecutionControlForwarder.java:144)
      at jdk.jshell.execution.ExecutionControlForwarder.commandLoop(ExecutionControlForwarder.java:262)
      at jdk.jshell.execution.Util.forwardExecutionControl(Util.java:76)
      at jdk.jshell.execution.Util.forwardExecutionControlAndIO(Util.java:137)
      at jdk.jshell.execution.RemoteExecutionControl.main(RemoteExecutionControl.java:70)

BufferAllocator 還提供了 BufferAllocator.toVerboseString(),可以在 DEBUG 模式下使用,以取得與各種分配器行為相關的廣泛堆疊追蹤資訊和事件。

最後,啟用 TRACE 日誌記錄層級將在關閉分配器時自動提供此堆疊追蹤

// Assumes use of Logback; adjust for Log4j, etc. as appropriate
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import org.apache.arrow.memory.ArrowBuf;
import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.memory.RootAllocator;
import org.slf4j.LoggerFactory;

// Set log level to TRACE to get tracebacks
((Logger) LoggerFactory.getLogger("org.apache.arrow")).setLevel(Level.TRACE);
try (final BufferAllocator allocator = new RootAllocator()) {
  // Leak buffer
  allocator.buffer(1024);
}
|  Exception java.lang.IllegalStateException: Allocator[ROOT] closed with outstanding buffers allocated (1).
Allocator(ROOT) 0/1024/1024/9223372036854775807 (res/actual/peak/limit)
  child allocators: 0
  ledgers: 1
    ledger[1] allocator: ROOT), isOwning: , size: , references: 1, life: 712040870231544..0, allocatorManager: [, life: ] holds 1 buffers.
        ArrowBuf[2], address:139926571810832, length:1024
     event log for: ArrowBuf[2]
       712040888650134 create()
              at org.apache.arrow.memory.util.StackTrace.<init>(StackTrace.java:34)
              at org.apache.arrow.memory.util.HistoricalLog$Event.<init>(HistoricalLog.java:175)
              at org.apache.arrow.memory.util.HistoricalLog.recordEvent(HistoricalLog.java:83)
              at org.apache.arrow.memory.ArrowBuf.<init>(ArrowBuf.java:96)
              at org.apache.arrow.memory.BufferLedger.newArrowBuf(BufferLedger.java:271)
              at org.apache.arrow.memory.BaseAllocator.bufferWithoutReservation(BaseAllocator.java:300)
              at org.apache.arrow.memory.BaseAllocator.buffer(BaseAllocator.java:276)
              at org.apache.arrow.memory.RootAllocator.buffer(RootAllocator.java:29)
              at org.apache.arrow.memory.BaseAllocator.buffer(BaseAllocator.java:240)
              at org.apache.arrow.memory.RootAllocator.buffer(RootAllocator.java:29)
              at REPL.$JShell$18.do_it$($JShell$18.java:13)
              at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(NativeMethodAccessorImpl.java:-2)
              at jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
              at jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
              at java.lang.reflect.Method.invoke(Method.java:566)
              at jdk.jshell.execution.DirectExecutionControl.invoke(DirectExecutionControl.java:209)
              at jdk.jshell.execution.RemoteExecutionControl.invoke(RemoteExecutionControl.java:116)
              at jdk.jshell.execution.DirectExecutionControl.invoke(DirectExecutionControl.java:119)
              at jdk.jshell.execution.ExecutionControlForwarder.processCommand(ExecutionControlForwarder.java:144)
              at jdk.jshell.execution.ExecutionControlForwarder.commandLoop(ExecutionControlForwarder.java:262)
              at jdk.jshell.execution.Util.forwardExecutionControl(Util.java:76)
              at jdk.jshell.execution.Util.forwardExecutionControlAndIO(Util.java:137)

  reservations: 0

|        at BaseAllocator.close (BaseAllocator.java:405)
|        at RootAllocator.close (RootAllocator.java:29)
|        at (#8:1)

有時,顯式傳遞分配器很困難。例如,很難透過現有應用程式或框架程式碼的層層傳遞額外狀態,例如分配器。全域或單例分配器實例在這裡可能很有用,儘管它不應是您的首選。

這是如何運作的

  1. 在單例類別中設定全域分配器。

  2. 提供從全域分配器建立子分配器的方法。

  3. 為子分配器提供適當的名稱,以便在發生錯誤時更容易找出分配發生在哪裡。

  4. 確保資源已正確關閉。

  5. 在某些合適的點(例如在程式關閉之前)檢查全域分配器是否為空。

  6. 如果它不為空,請查看上述分配錯誤。

//1
private static final BufferAllocator allocator = new RootAllocator();
private static final AtomicInteger childNumber = new AtomicInteger(0);
...
//2
public static BufferAllocator getChildAllocator() {
    return allocator.newChildAllocator(nextChildName(), 0, Long.MAX_VALUE);
}
...
//3
private static String nextChildName() {
    return "Allocator-Child-" + childNumber.incrementAndGet();
}
...
//4: Business code
try (BufferAllocator allocator = GlobalAllocator.getChildAllocator()) {
    ...
}
...
//5
public static void checkGlobalCleanUpResources() {
    ...
    if (!allocator.getChildAllocators().isEmpty()) {
      throw new IllegalStateException(...);
    } else if (allocator.getAllocatedMemory() != 0) {
      throw new IllegalStateException(...);
    }
}

Arrow 記憶體深入探討#

設計原則#

Arrow 的記憶體模型基於以下基本概念

  • 記憶體可以分配到某個限制。該限制可能是實際限制 (OS/JVM) 或本地強加的限制。

  • 分配操作分為兩個階段:記帳,然後是實際分配。分配可能會在任一點失敗。

  • 分配失敗應該是可恢復的。在所有情況下,分配器基礎結構都應將記憶體分配失敗(OS 或基於內部限制)公開為 OutOfMemoryException

  • 任何分配器都可以在建立時保留記憶體。應保留此記憶體,以便此分配器始終能夠分配該數量的記憶體。

  • 特定的應用程式組件應努力使用本地分配器來了解本地記憶體使用情況,並更好地除錯記憶體洩漏。

  • 相同的物理記憶體可以由多個分配器共享,並且分配器必須為此目的提供記帳範例。

保留記憶體#

Arrow 提供了兩種不同的方法來保留記憶體

  • BufferAllocator 記帳保留:當初始化新的分配器(RootAllocator 以外)時,它可以預留記憶體,這些記憶體將在其生命週期內本地保留。這是永遠不會釋放回其父分配器的記憶體,直到分配器關閉。

  • AllocationReservation 透過 BufferAllocator.newReservation():允許短期預分配策略,以便特定子系統可以確保未來記憶體可用於支援特定請求。

參考計數細節#

通常,使用的 ReferenceManager 實作是 BufferLedger 的實例。BufferLedger 是一個 ReferenceManager,它還維護 AllocationManagerBufferAllocator 和一個或多個個別 ArrowBuf 之間的關係

與單個 BufferLedger/BufferAllocator 組合相關的所有 ArrowBuf(直接或切片)共享相同的參考計數,並且它們將全部有效或全部無效。為了簡化記帳,我們將該記憶體視為由與該記憶體關聯的 BufferAllocator 之一使用。當該分配器釋放其對該記憶體的聲明時,記憶體所有權隨後將轉移到屬於同一 AllocationManager 的另一個 BufferLedger。

分配細節#

Arrow Java 中有幾種類型的分配器

  • BufferAllocator - 應用程式使用者應利用的公共介面

  • BaseAllocator - 記憶體分配的基本實作,包含 Arrow 分配器實作的核心

  • RootAllocator - 根分配器。通常每個 JVM 只建立一個。它充當子分配器的父項/祖先

  • ChildAllocator - 從根分配器派生的子分配器

許多 BufferAllocator 可以同時參考同一塊物理記憶體。AllocationManager 的責任是確保在這種情況下,從 Root 的角度來看,所有記憶體都得到準確的記帳,並確保一旦所有 BufferAllocator 停止使用該記憶體,記憶體就會被正確釋放。

為了簡化記帳,我們將該記憶體視為由與該記憶體關聯的 BufferAllocator 之一使用。當該分配器釋放其對該記憶體的聲明時,記憶體所有權隨後將轉移到屬於同一 AllocationManager 的另一個 BufferLedger。請注意,由於 ArrowBuf.release() 是實際導致記憶體所有權轉移發生的原因,因此我們始終繼續進行所有權轉移(即使這違反了分配器限制)。應用程式有責任頻繁確認擁有特定分配器的應用程式是否超出其記憶體限制 (BufferAllocator.isOverLimit()),如果是,則嘗試積極釋放記憶體以改善情況。

物件階層#

人們可以透過兩種主要方式查看 Arrow 記憶體管理方案的物件階層。第一個是基於記憶體的視角,如下所示

記憶體視角#

+ AllocationManager
|
|-- UnsignedDirectLittleEndian (One per AllocationManager)
|
|-+ BufferLedger 1 ==> Allocator A (owning)
| ` - ArrowBuf 1
|-+ BufferLedger 2 ==> Allocator B (non-owning)
| ` - ArrowBuf 2
|-+ BufferLedger 3 ==> Allocator C (non-owning)
  | - ArrowBuf 3
  | - ArrowBuf 4
  ` - ArrowBuf 5

在此圖中,一塊記憶體由分配器管理器擁有。無論分配器管理器正在使用哪個或哪些分配器,分配器管理器都對該塊記憶體負責。分配器管理器將與一塊原始記憶體(透過其對 UnsignedDirectLittleEndian 的參考)以及對其有關係的每個 BufferAllocator 的參考建立關係。

分配器視角#

+ RootAllocator
|-+ ChildAllocator 1
| | - ChildAllocator 1.1
| ` ...
|
|-+ ChildAllocator 2
|-+ ChildAllocator 3
| |
| |-+ BufferLedger 1 ==> AllocationManager 1 (owning) ==> UDLE
| | `- ArrowBuf 1
| `-+ BufferLedger 2 ==> AllocationManager 2 (non-owning)==> UDLE
|   `- ArrowBuf 2
|
|-+ BufferLedger 3 ==> AllocationManager 1 (non-owning)==> UDLE
| ` - ArrowBuf 3
|-+ BufferLedger 4 ==> AllocationManager 2 (owning) ==> UDLE
  | - ArrowBuf 4
  | - ArrowBuf 5
  ` - ArrowBuf 6

在此圖中,RootAllocator 擁有三個 ChildAllocator。第一個 ChildAllocator (ChildAllocator 1) 擁有後續的 ChildAllocator。ChildAllocator 有兩個 BufferLedger/AllocationManager 參考。巧合的是,這些 AllocationManager 中的每一個也與 RootAllocator 關聯。在這種情況下,其中一個 AllocationManager 由 ChildAllocator 3 (AllocationManager 1) 擁有,而另一個 AllocationManager (AllocationManager 2) 由 RootAllocator 擁有/記帳。請注意,在這種情況下,ArrowBuf 1 與 ArrowBuf 3 共享底層記憶體。但是,該記憶體的子集(例如,透過切片)可能不同。另請注意,ArrowBuf 2 和 ArrowBuf 4、5 和 6 也共享相同的底層記憶體。另請注意,ArrowBuf 4、5 和 6 都共享相同的參考計數和命運。