jvm 的内存分配与垃圾回收, 是 java 自动内存管理的一体两面, 我们不光需要了解垃圾回收的原理, 也需要对内存分配有对应的了解;
基本原理
内核支持
linux 中内存分配涉及的常用系统调用主要有:
- brk: 将数据段 (.data) 的最高地址指针 _edata 往高地址推;
- mmap: 在进程的虚拟地址空间中 (堆和栈中间, 称为文件映射区域) 根据指定的 addr 和 length (如 addr 为 null 则自动找一块空闲的区域) 分配虚拟内存;
1
2
3
4
5
6
7
8
9
10
11
/**
* 申请虚拟内存
* @param prot PROT_READ -- 指定内存可读; PROT_WRITE -- 指定内存可写
*/
void * mmap(void *addr,size_t length, int prot, int flags, int fd, off_t offset);
/**
* 释放内存
*/
int munmap(void *start, size_t length);
这两种方式分配的都是虚拟内存, 而不是直接分配物理内存, 只有在第一次访问已分配的虚拟地址空间的时候发生 minor 缺页中断, 操作系统才会真正分配物理内存, 然后建立虚拟内存和物理内存之间的映射关系;
C 标准库 malloc 函数的几种实现, 便是使用了上述系统调用实现内存分配/回收管理的, 只不过它们在更上层通过各自的优化手段, 造成了性能的差异及不同的适用场景;
jvm 实现
- 申请内存:
jvm 没有直接使用 C 库的 malloc 函数做内存分配, 而是直接使用 mmap (Windows 下则使用 VirtualAlloc API) 做的内存分配: 因为 java 是一门需要支持自动内存管理的编程语言, jvm 对内存的管理面临十分复杂的环境和性能挑战;
jvm 支持多种垃圾收集器, 不同的垃圾收集器对内存有不同的回收逻辑及特性, 那么内存分配也需要与垃圾收集器相配套, 这两者的实现通常是耦合在一起的, 并且由同一个子系统管理;
jvm 直接使用 mmap 可以更好地定制及优化内存的使用 & 管控; - 释放内存:
与申请内存对应的, jvm 释放内存使用的是 munmap; 需要注意的是, jvm 并不总是立即将回收的内存归还给操作系统, 通常情况下 jvm 会保留这些内存以供未来的对象分配使用, 只有在特定条件下 (如堆的空闲比例超过阈值) 才会归还;
实现逻辑
针对不同的使用场景以及不同的垃圾收集器, jvm 的内存分配实现不尽相同:
栈上分配
当 java 进程开启栈上分配 (jdk 6u23 后默认开启):1
-XX:+DoEscapeAnalysis
jvm 会做逃逸分析, 在满足条件的情况下优先做栈上分配 (jvm 利用 C++ 的局部变量实现), 分配在栈上的对象在方法调用结束后即自行销毁, 不需要垃圾回收;
指针碰撞
对于使用复制算法实现 gc 的收集器, jvm 能够保证内存区域的连续性, 通常使用指针碰撞 (Bump-The-Point) 算法:
- 保证堆中有一段连续的空闲内存;
- 维护一个指针, 指向当前可用内存的起始位置;
- 分配新对象时, 只需移动指针以分配所需的内存空间;
使用指针碰撞算法的场景举例:
- G1 将堆划分为多个固定大小的 Region, 每个 Region 内部的内存是连续的, 因此 G1 分配新对象时会在选定的 Region 内使用指针碰撞进行分配;
- 年轻代收集器普遍使用复制算法, 因此年轻代的 Eden / Survivor 区的内存分配也会使用指针碰撞;
多线程优化: TLAB
在多线程场景, 如果所有线程都去竞争同一个指针, 那么性能将会急剧下降, 为了解决这个问题, Hotspot jvm 使用了一种优化手段 TLAB(Thread-Local Allocation Buffers), 从而让多线程内存分配尽量无锁化, 提升性能:
- jvm 为每个线程分配一块独占的空间 (年轻代), 每个线程在自己的 TLAB 内做指针碰撞实现内存分配;
- 当 TLAB 内存耗尽, 线程会申请新的 TLAB, jvm 同样依赖指针碰撞为线程分配新的 TLAB;
- 当某次内存分配 TLAB 已不够用, 直接分配在 Eden 区;
引用更新
当垃圾收集器使用复制算法将对象从一个内存区域移动到另一个内存区域时, jvm 需要确保所有对该对象的引用仍然能够正确访问到该对象, 为了实现这一目标, jvm 在垃圾回收复制算法中需要对所有相关引用进行更新 (reference updating);
对于低延迟收集器, 收集过程中存在并发更新的步骤, 因此引用更新会和应用线程同时运行, 为了保证一致性, 收集器需要使用读屏障、写屏障或染色标记等技术来确保引用始终指向正确的对象, 以 G1 收集器为例:
空闲列表
对于使用标记清理算法实现 gc 的收集器, jvm 不能够保证内存区域的连续性, 通常使用空闲列表 (Free-List) 算法:
- 维护一个链表, 记录堆中所有可用的空闲内存块;
- 分配新对象时, 从链表中找到一块足够大的空闲内存块;
- 如果空闲块大于所需大小, 则将其分割为两部分: 一部分分配给新对象, 另一部分重新加入空闲列表;
使用空闲列表算法的场景举例:
- CMS
多线程优化: 分段锁
在多线程场景, 空闲列表也会面对资源竞争的问题, 与指针碰撞 TLAB 类似, 为了降低锁的粒度, jvm 使用了分段锁的方法, 将空闲列表分成多个段 (segments), 每个段使用独立的锁; 这样多个线程可以同时访问不同的段, 从而减少锁争用;
源码分布
逃逸分析
- src/hotspot/share/opto/escape.cpp: 分析对象的生命周期,判断其是否可以分配在栈上
- src/hotspot/share/opto/lcm.cpp 和 macro.cpp: 标量替换, 将对象字段提取出来并存储在栈上
内存分配
堆/元空间
- src/hotspot/share/memory/metaspace.cpp 和 metaspace.hpp: 元空间 (Metaspace) 的内存分配逻辑
- src/hotspot/share/memory/universe.cpp 和 universe.hpp: 定义了堆的整体结构和初始化逻辑
TLAB
- src/hotspot/share/gc/shared/threadLocalAllocBuffer.cpp 和 threadLocalAllocBuffer.hpp: TLAB 的核心实现, 包括分配、填充和回收逻辑
- src/hotspot/share/runtime/thread.cpp 和 thread.hpp: 线程对象中包含 TLAB 的管理逻辑
不同垃圾收集器对内存的管理:
- src/hotspot/share/gc/shared/collectedHeap.cpp 和 collectedHeap.hpp: 所有垃圾收集器的基类, 定义了堆的基本操作接口
G1
- src/hotspot/share/gc/g1/g1CollectedHeap.cpp
- src/hotspot/share/gc/g1/g1CollectedHeap.hpp
CMS
- src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp
- src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.hpp
PS
- src/hotspot/share/gc/parallel/parallelScavengeHeap.cpp
- src/hotspot/share/gc/parallel/parallelScavengeHeap.hpp
ParNew
- src/hotspot/share/gc/cms/parNewGeneration.cpp
- src/hotspot/share/gc/cms/parNewGeneration.hpp
对象分配 (补充)
对象分配的底层逻辑通常由运行时系统完成:
- src/hotspot/share/runtime/objectMonitor.cpp 和 objectMonitor.hpp: 对象头和锁的实现
- src/hotspot/share/oops/oop.inline.hpp: 对象的创建和初始化逻辑
- src/hotspot/share/runtime/vmStructs.cpp: 定义了 JVM 的内部结构, 包括对象布局