glibc malloc非主内存池内存归还至操作系统的问题问询
我在Java中模拟了一个触发glibc严重内存碎片的场景,具体如下:
- 模拟600线程的多线程环境;
- 每秒启动2个新线程,通过
java.nio.channels.FileChannel读取大文件; - 线程执行10秒后自动终止,交由JVM GC清理资源。
程序运行过程中持续占用**非主内存池(non-main arena)**的内存,直至达到内存池上限后用量趋于稳定。但当所有工作线程停止、仅保留主线程休眠时,进程的RSS(驻留集大小)始终处于高位,并未下降:
- JVM Native Memory Tracking(NMT)报告显示JVM本身并未占用这么多内存;
- Linux
pmap和smaps工具显示,非主内存池的每个64MB块中约有8MB物理内存被占用,且内存内容均为文件数据。
特别值得注意的是glibc参数的影响:设置MALLOC_TRIM_THRESHOLD_环境变量后,无论取值如何,RSS都会开始回收。由于读取文件使用的DirectBuffer为8MB,我分别设置了52428800(50MB)和8388608(8MB),每次进程RSS均回收约16MB。通过gdb查看ptmalloc2分配器的trim_threshold参数,默认值约为48MB,但设置该环境变量后,内存回收行为完全改变,尤其是非主内存池的内存。
补充验证:每次文件读取时,进程调用malloc申请堆外内存,线程销毁后由GC线程触发free操作;JVM dump确认所有对象(包括堆外DirectBuffer)均已被正确销毁。
- 为何
MALLOC_TRIM_THRESHOLD_参数会影响内存用量的下降? - 当设置为50MB时,理论上未达到50MB可回收阈值,glibc仍会将非主内存池的内存归还操作系统,是否设置该参数后glibc的
free行为发生了改变?
- JDK版本:21-openjdk
- glibc版本:2.39
- JVM运行参数:
-Xms6g -Xmx6g -XX:MaxDirectMemorySize=5g -XX:+AlwaysPreTouch -XX:+UseG1GC -Xlog:gc:file=gc.log:time,uptime,level,tags:filecount=5,filesize=100M
import java.io.RandomAccessFile; import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.util.ArrayList; import java.util.List; public class FileRead1 { private static volatile boolean stop = false; public static void main(String[] args) throws InterruptedException { liveThreadLoopWithoutMemory(); int count = 0; while (true) { if (count > (Integer.MAX_VALUE -1) ) { break; } count++; try { for (int i = 0; i < 2; i++) { newThread(); } for (int i = 0; i < 5; i++) { Thread.sleep(1000L); } } catch (InterruptedException e) { } } stop = true; while (true) { for (int i = 0; i < 5; i++) { Thread.sleep(1000L); } } } private static void liveThreadLoopWithoutMemory() { for (int i = 0; i < 600; i++) { new Thread(() -> { while (true) { try { Thread.sleep(100L); } catch (InterruptedException e) { throw new RuntimeException(e); } if (stop) { break; } } }).start(); } } private static void newThread() { Thread thread = new Thread(new Runnable() { private final List<String> strings = new ArrayList<>(); @Override public void run() { test(strings, "test.txt", "UTF-8", false); strings.clear(); } }); thread.setDaemon(true); thread.start(); } private static void test(List<String> strings, String src, String encoding, boolean hasTitle) { test1(strings, src, encoding, hasTitle); } private static void test1(List<String> strings, String src, String encoding, boolean hasTitle) { test2(strings, src, encoding, hasTitle); } private static void test2(List<String> strings, String src, String encoding, boolean hasTitle) { test3(strings, src, encoding, hasTitle); } private static void test3(List<String> strings, String src, String encoding, boolean hasTitle) { test4(strings, src, encoding, hasTitle); } private static void test4(List<String> strings, String src, String encoding, boolean hasTitle) { test5(strings, src, encoding, hasTitle); } private static void test5(List<String> strings, String src, String encoding, boolean hasTitle) { test6(strings, src, encoding, hasTitle); } private static void test6(List<String> strings, String src, String encoding, boolean hasTitle) { test7(strings, src, encoding, hasTitle); } private static void test7(List<String> strings, String src, String encoding, boolean hasTitle) { test8(strings, src, encoding, hasTitle); } private static void test8(List<String> strings, String src, String encoding, boolean hasTitle) { test9(strings, src, encoding, hasTitle); } private static void test9(List<String> strings, String src, String encoding, boolean hasTitle) { test10(strings, src, encoding, hasTitle); } private static void test10(List<String> strings, String src, String encoding, boolean hasTitle) { new TaskReadFile() { @Override public void process(String str) { super.process(str + "swk"); } }.readFile(strings, src, encoding, hasTitle); } static class TaskReadFile implements ReadFileInterface { private final List<String> stringList = new ArrayList<>(); @Override public void process(String str) { stringList.add(str + "123"); } @Override public void clear() { stringList.clear(); } } interface ReadFileInterface { void process(String str); void clear(); default void readFile(List<String> strings, String src, String encoding, boolean hasTitle) { int readLimit = 8 * 1024 * 1024; int startIndex = 0; try (RandomAccessFile raf = new RandomAccessFile(src, "r");) { FileChannel channel = raf.getChannel(); ByteBuffer rbuf = ByteBuffer.allocate(readLimit); synchronized (rbuf) { channel.position(startIndex); byte[] temp = new byte[0]; int LF = 10; long lineCount = 0; while (channel.read(rbuf) != -1) { int position = rbuf.position(); byte[] rbyte = new byte[position]; rbuf.flip(); rbuf.get(rbyte); int startnum = 0; for (int i = 0; i < rbyte.length; i++) { if (rbyte[i] == LF) { if (channel.position() == startIndex) { startnum = i + 1; } else { if (hasTitle && 0 == lineCount) { startnum = i + 1; lineCount++; continue; } int lineLen = i - startnum + 1; byte[] line = new byte[temp.length + lineLen]; System.arraycopy(temp, 0, line, 0, temp.length); System.arraycopy(rbyte, startnum, line, temp.length, lineLen); startnum = i + 1; temp = new byte[0]; String str = trimEndingCRLF(line, encoding); strings.add(str); process(str); } } } if (startnum < rbyte.length) { byte[] temp2 = new byte[temp.length + rbyte.length - startnum]; System.arraycopy(temp, 0, temp2, 0, temp.length); System.arraycopy(rbyte, startnum, temp2, temp.length, rbyte.length - startnum); temp = temp2; } rbuf.clear(); } } rbuf.clear(); try { Thread.sleep(10000L); } catch (InterruptedException e) { throw new RuntimeException(e); } clear(); } catch (Exception e) { throw new RuntimeException(); } } default String trimEndingCRLF(byte[] line, String encoding) throws UnsupportedEncodingException { if (line.length != 0 && line[0] != 10) { int lastIdx = line.length - 1; if (line[lastIdx] != 10) { ++lastIdx; } else if (line[lastIdx - 1] == 13) { --lastIdx; } return new String(line, 0, lastIdx, encoding); } else { return ""; } } } }
1. MALLOC_TRIM_THRESHOLD_影响内存回收的原因
glibc的ptmalloc2分配器中,trim_threshold控制着内存池(arena)何时向操作系统归还空闲内存。默认情况下,非主arena的trim逻辑和主arena存在差异:
- 主arena在
free操作后会检查空闲内存是否超过阈值,若超过则调用sbrk或mmap归还内存; - 非主arena(由多线程环境触发创建)默认的trim行为更保守,甚至可能不会主动触发trim——因为ptmalloc2默认认为多线程场景下内存会被频繁复用,避免频繁向OS申请/归还内存带来的开销。
当你显式设置MALLOC_TRIM_THRESHOLD_环境变量时,相当于强制为所有arena(包括非主arena)开启了主动trim的逻辑:每次free操作后,分配器都会检查当前arena的空闲内存总量是否超过阈值,若满足条件则会将顶部连续的空闲内存块归还给操作系统,从而降低RSS。
你的场景中,大量线程创建的非主arena在free后积累了空闲内存,但默认未触发trim;设置该参数后,分配器主动触发trim,将空闲内存归还,因此RSS下降。
2. 设置50MB阈值仍触发回收的原因
你观察到的现象并非阈值失效,而是ptmalloc2对非主arena的trim逻辑存在特殊处理:
- 显式设置
MALLOC_TRIM_THRESHOLD_后,非主arena的trim行为不再遵循默认的保守策略,即使空闲内存未达到阈值,某些情况下也会触发trim——比如当arena对应的线程退出后,分配器可能会对该arena进行更积极的内存回收(因为该arena后续被复用的概率降低); - 另外,ptmalloc2的trim操作并非严格只回收超过阈值的内存,而是会尝试归还顶部所有连续的空闲块。你的场景中每个非主arena有8MB被占用,剩余56MB空闲,当触发trim时,这56MB空闲块会被归还给OS,而你看到的RSS回收16MB是多个arena累计的结果;
- 还有一种可能:glibc 2.39中对
MALLOC_TRIM_THRESHOLD_的处理逻辑有调整,显式设置该参数会覆盖非主arena的默认trim策略,使其行为与主arena一致,即使空闲内存未达到阈值,也会在特定时机(如线程退出)触发trim。
总结来说,设置MALLOC_TRIM_THRESHOLD_不仅改变了阈值大小,还改变了非主arena的trim触发逻辑,使其从保守变为主动,因此即使设置为50MB,也会触发内存回收。
内容的提问来源于stack exchange,提问作者Forever

