將記憶體頁面傳回 OS

釋放應用程式程式碼中的物件或關閉暫存串流,不一定會將實體 RAM 傳回作業系統。自訂原生配置器、垃圾收集執行階段和開放定址雜湊表,通常會保留虛擬記憶體區域,在邏輯資料消失後,仍由實體頁面支援。同樣地,讀取或記憶體對應大型資產封存檔會填入核心頁面快取,這會繼續向應用程式的記憶體控制群組 (memcg) 收費。

本頁說明如何使用 madvise 和 posix_fadvise 系統呼叫,將未使用的匿名和檔案支援頁面傳回 OS,同時保留虛擬位址保留和檔案描述元,以及如何驗證 adb shell 和 Perfetto 的影響。

為何邏輯取消分配會保留實體 RAM

Linux 核心會以硬體頁面的精細程度追蹤實體記憶體,在 Android 裝置上為 4 KB 或 16 KB。當程序使用 mmap 保留虛擬位址空間區域時,核心會建立虛擬記憶體區域 (VMA),但不會分配實體 RAM。只有在程序首次接觸到實體頁面時,核心才會分配並收取該頁面的費用。如果是匿名記憶體,第一次讀取會對應共用的零頁,因此只有寫入會分配 RAM。

頁面錯誤進入 RAM 後,核心不會知道該頁面中的位元組是否仍代表應用程式執行階段中的即時物件。應用程式使用完畢後,有兩種常見模式會讓實體頁面常駐:

  • 匿名 arena 和集區:自訂原生配置器 (例如 slab、集區或框架 arena 配置器)、具有非移動堆積空間的代管執行階段,以及大型雜湊表,會使用 MAP_PRIVATE | MAP_ANONYMOUS 保留 mmap 的連續虛擬位址範圍。當應用程式重設 arena、將區塊釋放回自訂集區,或透過將中繼資料歸零來清除雜湊表時,基礎頁面仍會保持髒頁狀態。如果沒有明確提示核心,這些頁面會繼續計入程序的匿名常駐集大小 (RSS) 和比例集大小 (PSS),以及 memory.current cgroup 費用。在記憶體壓力下,核心會將這些頁面壓縮至 ZRAM,但仍會占用計入 memory.swap.current 的交換空間。
  • 資產串流中的檔案支援頁面快取:當應用程式使用 read、pread 或檔案支援的 mmap 讀取大型資產套件、關卡封存、媒體容器或機器學習模型時,核心會快取頁面快取中的每個讀取頁面,並將這些頁面計入程序 cgroup 的 memory.stat 檔案中的 file 計數器。如果應用程式將該資產解壓縮或複製到匿名執行階段緩衝區,或上傳到 GPU,則將原始檔案頁面保留在頁面快取中,會複製 RAM 中的資產。
使用 madvise 釋出髒資料 arena 頁面,並使用 posix_fadvise 移除快取的素材資源頁面,同時虛擬範圍和檔案描述元保持不變。
圖 1. 從虛擬區域和頁面快取回收實體頁面。

發布匿名頁面

madvise 系統呼叫會向核心提供建議,說明程序預期如何使用虛擬位址範圍。如果是私人匿名對應 (MAP_PRIVATE | MAP_ANONYMOUS),傳遞 MADV_DONTNEED 會指示核心立即捨棄實體頁面,以及任何支援指定頁面對齊範圍的交換 ZRAM 項目。

madvise 搭配 MADV_DONTNEED 傳回:

  • 虛擬位址範圍在程序位址空間中仍保留且有效 (VmSize 不會變更),因此指標和 arena 邊界會保持不變,不會呼叫 munmap 或 mmap。
  • 系統會立即取消對應並釋出實體頁面,減少 RssAnon、Pss、memory.stat 中的 anon 計數器,以及 ZRAM 中任何頁面的 memory.swap.current。
  • 後續對該範圍內位址的任何讀取或寫入,都會視需要產生新的零填補頁面。
建議標記 目標對應 Android 核心行為
MADV_DONTNEED 私人匿名 (MAP_PRIVATE | MAP_ANONYMOUS) 立即捨棄實體頁面和 ZRAM 交換槽。後續存取會導致零填補頁面發生錯誤。RssAnon 和 memory.current 的確定性下降。
MADV_DONTNEED 檔案支援 (MAP_PRIVATE 或 MAP_SHARED) 取消對應程序頁面表格中的頁面 (RssFile 捨棄)。系統會捨棄 MAP_PRIVATE 對應中的私人寫入時複製頁面,因此會遺失修改內容,下次存取時會重新讀取檔案。Dirty MAP_SHARED 頁面稍後會寫回儲存空間。乾淨頁面會保留在頁面快取中,直到遭到逐出。
MADV_FREE 私人匿名 (MAP_PRIVATE | MAP_ANONYMOUS) 將頁面標示為延遲可重複使用。如果沒有記憶體壓力,核心會保留 RssAnon 中的頁面,且只有在全域或 memcg 回收執行時才會回收。
MADV_WILLNEED 檔案備份或交換匿名 針對檔案支援的對應啟動非同步預讀,或提前將交換的匿名頁面從 ZRAM 讀回交換快取。

將內部範圍與執行階段頁面大小對齊

madvise 會對整個頁面執行操作。這項指令需要與頁面對齊的起始位址,並將長度向上捨入至下一個頁面邊界。如果您在共用區域內釋放一個 span,而相鄰的位元組仍保留使用中的物件,則向外捨入範圍可能會將共用邊界頁面的使用中分配歸零。

請一律使用 sysconf(_SC_PAGESIZE) 查詢執行階段頁面大小,以支援 4 KB 和 16 KB 頁面裝置,並將內部起始位址向上捨入至下一個頁面邊界,內部結束位址向下捨入至上一個頁面邊界。如需更多關於頁面大小無關記憶體配置器的指引,請參閱「最佳化自訂記憶體配置器」。

#include <sys/mman.h>
#include <unistd.h>
#include <cstdint>

// Reclaims physical pages strictly inside [region_start, region_start + length)
// without disturbing live objects on partially occupied boundary pages.
void ReleaseUnusedArenaSpan(void* region_start, size_t length) {
  const uintptr_t page_size = static_cast<uintptr_t>(sysconf(_SC_PAGESIZE));
  const uintptr_t page_mask = ~(page_size - 1);

  const uintptr_t raw_start = reinterpret_cast<uintptr_t>(region_start);
  const uintptr_t raw_end = raw_start + length;

  // Round start UP and end DOWN so only fully unused pages are discarded.
  const uintptr_t aligned_start = (raw_start + page_size - 1) & page_mask;
  const uintptr_t aligned_end = raw_end & page_mask;

  if (aligned_end > aligned_start) {
    madvise(reinterpret_cast<void*>(aligned_start),
            aligned_end - aligned_start,
            MADV_DONTNEED);
  }
}

使用 mmap 建立自訂匿名對應時,請使用 PR_SET_VMA_ANON_NAME 以 prctl 標記 VMA,以便在 showmap 和 /proc/<pid>/smaps 中依名稱追蹤其常駐位置。dumpsys meminfo 會計算 Unknown 下自訂名稱的匿名區域。某些舊版核心會保留名稱的指標,而不是複製名稱,因此請傳遞在對應生命週期內保持有效的字串,例如字串常值:

#include <sys/mman.h>
#include <sys/prctl.h>

#ifndef PR_SET_VMA
#define PR_SET_VMA 0x53564d41
#define PR_SET_VMA_ANON_NAME 0
#endif

void* ReserveNamedArena(size_t capacity_bytes, const char* arena_name) {
  void* ptr = mmap(nullptr, capacity_bytes, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
  if (ptr != MAP_FAILED) {
    prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME,
          reinterpret_cast<unsigned long>(ptr), capacity_bytes,
          reinterpret_cast<unsigned long>(arena_name));
  }
  return ptr;
}

移除檔案快取頁面

應用程式從檔案描述元讀取資料時,核心會將檔案頁面快取至頁面快取,以便日後讀取。雖然這能加快重複存取小型設定檔或共用程式庫的速度,但如果串流大型資產封存檔、紋理套件或音訊和影片區塊,則會產生反效果,因為這些區塊會剖析一次,並以解碼形式儲存在其他位置。

posix_fadvise 系統呼叫可讓應用程式通知核心,在開啟的檔案描述元上預期的檔案存取模式:

  • POSIX_FADV_DONTNEED:指示核心,近期不會存取指定的位元組範圍。核心會立即從頁面快取中,清除該頁面對齊範圍內乾淨、未對應的快取頁面,減少 memory.stat 中的 file 計數器和 cgroup 的 memory.current 費用。
  • POSIX_FADV_SEQUENTIAL:將核心的檔案描述元預讀視窗加倍,提高從頭到尾串流大型封存檔時的處理量。在處理後續區塊時,請搭配使用 POSIX_FADV_DONTNEED,將頁面快取用量限制在小型滑動視窗內。
  • POSIX_FADV_RANDOM:停用檔案描述元的推測核心預讀功能。在大型封存檔或資料庫檔案中,依位元組偏移量執行稀疏、非連續的查詢時,請使用這個選項,這樣每次小規模讀取就不會將相鄰的未使用頁面提取到頁面快取中。如果是記憶體對應檔案,請改為在對應上呼叫 madvise 和 MADV_RANDOM。

以下範例會以區塊形式串流大型素材資源封存檔,預先啟用依序預讀功能,並從頁面快取中逐一移除已處理的頁面對齊範圍:

#include <fcntl.h>
#include <unistd.h>
#include <cstdint>
#include <vector>

void StreamAssetArchive(int fd, size_t total_size) {
  const size_t page_size = static_cast<size_t>(sysconf(_SC_PAGESIZE));
  constexpr size_t kChunkSize = 4 * 1024 * 1024;  // 4 MB streaming window
  std::vector<uint8_t> buffer(kChunkSize);

  // Hint sequential access so the kernel prefetches upcoming chunks.
  posix_fadvise(fd, 0, total_size, POSIX_FADV_SEQUENTIAL);

  off_t offset = 0;
  while (offset < static_cast<off_t>(total_size)) {
    ssize_t bytes_read = pread(fd, buffer.data(), buffer.size(), offset);
    if (bytes_read <= 0) break;

    // Decode or upload buffer[0 .. bytes_read) into runtime structures here.

    // Evict full pages that have been completely consumed.
    const size_t aligned_len =
        (static_cast<size_t>(bytes_read) / page_size) * page_size;
    if (aligned_len > 0) {
      posix_fadvise(fd, offset, static_cast<off_t>(aligned_len),
                    POSIX_FADV_DONTNEED);
    }
    offset += bytes_read;
  }
}

回收檔案支援的 mmap 區域

如果應用程式使用 mmap 對大型資產檔案進行記憶體對應,而不是使用 pread 讀取,則單獨使用 POSIX_FADV_DONTNEED 呼叫 posix_fadvise 不會移除仍對應至程序頁面表格的頁面。如要捨棄唯讀檔案對應中已使用的範圍,而不取消對應整個 VMA,請執行下列操作:

  1. 在對應的虛擬位址範圍上呼叫 madvise(addr, length, MADV_DONTNEED),從程序的頁面表格 (RssFile 捨棄) 取消對應這些頁面。
  2. 在對應的檔案偏移上呼叫 posix_fadvise(fd, offset, length, POSIX_FADV_DONTNEED),讓核心也從頁面快取中移除未對應的乾淨頁面 (memory.stat 中的 file 捨棄)。

遵循網頁建議指南

將 madvise 和 posix_fadvise 整合至配置器、資產載入器和生命週期處理常式時,請採用下列做法:

  • 將呼叫批次處理至粗略區域:每個 madvise 呼叫都會取得程序的記憶體對應鎖定 (mmap_lock)、遍歷頁面表格,並使其他 CPU 核心上相符的轉譯查詢緩衝區 (TLB) 項目失效 (在 Arm 上使用廣播 TLB 維護指令,在 x86 上使用處理器間中斷)。請勿在每次小規模取消分配時呼叫 madvise。合併可用範圍,並以 64 KB 至 1 MB 或更大的區塊向核心提供建議,或在影格邊界和背景轉換期間清除可用範圍 (onStop 和 TRIM_MEMORY_UI_HIDDEN)。
  • 回收雜湊表和集區中的空白容量:在場景轉換或資料同步後,清除大型雜湊表、物件集區或暫存緩衝區時,請縮減其備份分配,或在清除的備份陣列中發出 MADV_DONTNEED。如果保留 32 MB 的開放定址資料表,並分配墓碑,則所有 32 MB 的實體頁面都會向您的程序收費。
  • 在清除髒檔案資料前先清除:POSIX_FADV_DONTNEED 只會清除乾淨的頁面快取頁面。如果應用程式寫入大型暫存檔或快取檔案,且不會立即讀回,請在 posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED) 之前呼叫 fdatasync,這樣系統就會將髒頁寫入儲存空間,然後從 RAM 中捨棄。
  • 請勿移除熱門共用程式碼或資產:請避免在 DEX、ODEX、VDEX、共用程式庫 (.so) 或重複存取的 UI 資產上呼叫 POSIX_FADV_DONTNEED。如壓力停滯資訊 (PSI) 所示,若移除使用中的程式碼或素材資源頁面,會強制同步儲存空間重新發生錯誤,導致執行緒停滯並增加 I/O 壓力。

在殼層中驗證頁面回收

使用 showmap 和應用程式的 cgroup v2 帳戶檔案,確認 madvise 和 posix_fadvise 會回收實體 RAM。

使用 showmap 檢查虛擬和實體大小

使用 PR_SET_VMA_ANON_NAME 命名匿名 arena 時,showmap 會顯示虛擬保留 (virtual size) 和常駐實體頁面 (RSS 和 PSS)。下列輸出範例來自 AOSP 中的 MemoryLab 範例應用程式,該應用程式將 arena 命名為 memorylab-arena。將 com.example.app 和 memorylab-arena 替換為套件名稱和競技場名稱。在觸及的 64 MB 競技場上,使用 MADV_DONTNEED 呼叫 madvise 之前,請先執行:

adb shell "showmap \$(pidof com.example.app) | grep -E '^ +virtual|^ +size|memorylab-arena' | head -3"
 virtual                     shared   shared  private  private                   Anon      Shmem     File      Shared   Private
    size      RSS      PSS    clean    dirty    clean    dirty     swap  swapPSS HugePages PmdMapped PmdMapped Hugetlb  Hugetlb    Locked    # object
   65536    65536    65536        0        0        0    65536        0        0         0         0         0        0        0        0    1 [anon:memorylab-arena]

呼叫 madvise(arena_ptr, 64 * 1024 * 1024, MADV_DONTNEED) 後,再次執行 showmap:

 virtual                     shared   shared  private  private                   Anon      Shmem     File      Shared   Private
    size      RSS      PSS    clean    dirty    clean    dirty     swap  swapPSS HugePages PmdMapped PmdMapped Hugetlb  Hugetlb    Locked    # object
   65536        0        0        0        0        0        0        0        0         0         0         0        0        0        0    1 [anon:memorylab-arena]

virtual size 保持在 65536 KB (64 MB),確認虛擬位址範圍仍保留,而 RSS、PSS 和 private dirty 則降至 0 KB。

檢查 cgroup memory.current 和 memory.stat

如要驗證匿名頁面回收和未對應頁面快取移除 (如果使用 pread 讀取,則不會顯示在程序頁面表格中),請檢查程序的 cgroup v2 目錄。在 Android 上,每個應用程式程序都有自己的 cgroup (例如 /sys/fs/cgroup/apps/uid_<UID>/pid_<PID>/,視裝置而定)。下列指令會從 /proc/<PID>/cgroup 的 0:: 行中找出目錄,並輸出計數器:

adb shell '
  PID=$(pidof com.example.app)
  CG=/sys/fs/cgroup$(sed -n "s/^0:://p" /proc/$PID/cgroup)
  echo "memory.current: $(cat $CG/memory.current)"
  echo "memory.swap.current: $(cat $CG/memory.swap.current)"
  grep -E "^(anon|file) " $CG/memory.stat
'

釋出 64 MB 的匿名區域,並從頁面快取中移除 128 MB 的記憶體對應資產檔案之前:

memory.current: 242962432
memory.swap.current: 16633856
anon 100671488
file 140009472

在對 arena 和資產檔案對應呼叫 madvise 和 MADV_DONTNEED 後,立即對資產檔案描述元呼叫 posix_fadvise 和 POSIX_FADV_DONTNEED:

memory.current: 41766912
memory.swap.current: 16502784
anon 33722368
file 5914624

anon 減少 64 MB,file 減少 128 MB,memory.current 減少約 192 MB,而無需關閉檔案描述元或取消對應 arena 的虛擬位址空間。memory.swap.current 幾乎沒有變化,因為幾乎沒有任何 arena 頁面被換到 ZRAM。實際值取決於裝置,以及核心自行剔除的檔案量。如要進一步瞭解核心如何針對每個 cgroup 收費及回收記憶體,請參閱「記憶體回收:收回和交換」。

在 Perfetto 中分析網頁回收

Perfetto 可讓您在統一的時間軸上,將 madvise 和 posix_fadvise 呼叫與核心頁面資料表和頁面快取計數器建立關聯。

錄製追蹤記錄

使用 linux.process_stats 擷取追蹤記錄,取得定期程序計數器,並使用 linux.ftrace 擷取即時 RSS 和頁面快取事件,然後將追蹤記錄提取到電腦:

adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/page_advice.perfetto-trace <<EOF
buffers: {
    size_kb: 65536
    fill_policy: RING_BUFFER
}
data_sources: {
    config {
        name: "linux.process_stats"
        process_stats_config {
            scan_all_processes_on_start: true
            proc_stats_poll_ms: 100
        }
    }
}
data_sources: {
    config {
        name: "linux.ftrace"
        ftrace_config {
            ftrace_events: "kmem/rss_stat"
            ftrace_events: "filemap/mm_filemap_add_to_page_cache"
            ftrace_events: "filemap/mm_filemap_delete_from_page_cache"
        }
    }
}
duration_ms: 20000
EOF
adb pull /data/misc/perfetto-traces/page_advice.perfetto-trace

在 Perfetto UI 中檢查追蹤記錄

在 Perfetto UI 中開啟追蹤記錄,展開應用程式的程序群組,並固定下列計數器軌跡,使其保持在時間軸頂端:

  • mem.virt:在 MADV_DONTNEED 呼叫中保持平穩,確認虛擬位址保留 (mmap) 未變更。
  • mem.rss.anon:在應用程式使用 MADV_DONTNEED 呼叫 madvise 的確切時間戳記,在髒匿名 arena 或堆積區域上急劇下降。
  • mem.rss.file:當應用程式在檔案支援的 mmap 區域上使用 MADV_DONTNEED 呼叫 madvise 時,系統會降低頻率,並在 posix_fadvise 使用 POSIX_FADV_DONTNEED 從頁面快取中移除乾淨頁面時,伴隨大量 mm_filemap_delete_from_page_cache ftrace 事件。
  • mem.swap:當 MADV_DONTNEED 捨棄先前已壓縮至 ZRAM 的匿名頁面時,這個值會下降。
單一程序的 Perfetto 計數器追蹤。當 arena 和資產檔案填入時,mem.rss.anon 和 mem.rss.file 會增加,並在 madvise 和 posix_fadvise 後減少,而 mem.swap 和 mem.virt 則保持不變。
圖 2. RSS dropping after page advice calls.

圖 2 顯示 MemoryLab 範例應用程式的這些軌跡。如要探索相同檢視畫面,請在 Perfetto UI 中開啟範例追蹤記錄。

使用 PerfettoSQL 查詢記憶體用量高峰和最終記憶體用量

在 Perfetto UI (「Query (SQL)」分頁) 中執行下列 PerfettoSQL 查詢,比較目標程序的 mem.virt、mem.rss.anon、mem.rss.file 和 mem.swap 峰值和最終值:

WITH counters AS (
  SELECT t.name AS counter_name, c.ts, c.value
  FROM counter c
  JOIN process_counter_track t ON c.track_id = t.id
  JOIN process p USING (upid)
  WHERE p.name = 'com.example.app'
    AND t.name IN ('mem.virt', 'mem.rss.anon', 'mem.rss.file', 'mem.swap')
)
SELECT
  counter_name,
  CAST(MAX(value) / 1048576 AS INT) AS peak_mb,
  CAST((
    SELECT value FROM counters AS last
    WHERE last.counter_name = counters.counter_name
    ORDER BY ts DESC LIMIT 1
  ) / 1048576 AS INT) AS final_mb
FROM counters
GROUP BY counter_name
ORDER BY counter_name;

圖 2 追蹤記錄的輸出內容範例:

counter_name  peak_mb  final_mb
mem.rss.anon      111        48
mem.rss.file      212        84
mem.swap           36        36
mem.virt        13952     13952

mem.rss.anon 從峰值下降 63 MB,mem.rss.file 則下降 128 MB,而 mem.virt 則在峰值結束,因為虛擬保留未變更。

解決遭剔除的網頁與檔案

mm_filemap_add_to_page_cache 和 mm_filemap_delete_from_page_cache 事件會依 inode 編號 (i_ino) 而非路徑識別檔案。如要確認遭剔除的分頁屬於您建議的檔案,請計算每個 inode 的剔除次數:

SELECT
  EXTRACT_ARG(arg_set_id, 'i_ino') AS inode,
  COUNT(*) AS evicted_pages
FROM ftrace_event
WHERE name = 'mm_filemap_delete_from_page_cache'
GROUP BY inode
ORDER BY evicted_pages DESC
LIMIT 5;

圖 2 追蹤記錄的輸出內容範例:

inode     evicted_pages
9761              35781
76792192            767
126                 668
0                   612
11587712            514

在記錄追蹤記錄的裝置仍處於連線狀態時,將每個 inode 解析為路徑。Inode 編號在單一檔案系統中是不重複的,因此請將搜尋範圍限制在存放應用程式資料的檔案系統:

INODE=<inode from the query>
adb shell "find /data -xdev -inum $INODE 2>/dev/null"

inode 9761 的輸出內容範例:

/data/user/0/com.android.memorylab/files/asset_pack.bin
/data/data/com.android.memorylab/files/asset_pack.bin

這兩個路徑都指向同一個檔案,因為 /data/user/0 和 /data/data 是同一個應用程式資料目錄的兩個名稱。資產檔案 (35,781 頁) 的剔除次數大於檔案本身 (32,768 頁,4 KB),因為在應用程式讀回檔案並呼叫 posix_fadvise 之前,kswapd 已在記憶體壓力下剔除部分檔案。

如要一次解決多個 inode 的指令碼,請參閱「解決 inode 錯誤,然後解決檔案錯誤」。


← kswapd 和 lmkd 互動 | ↑ 向上 | 精簡背景程序 →