JVM基础 -- 垃圾回收基础
判定对象存亡
垃圾回收标记的是非垃圾
引用计数法
- 为每个对象添加一个引用计数器,用来统计指向该对象的引用个数
- 如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器+1
- 如果指向某一对象的引用,被赋值为其他值,那么该对象的引用计数器-1
- 一旦某个对象的引用计数器为0,说明对象已经死亡
- 缺点
- 额外的空间来存储计数器 + 繁琐的更新操作
- 无法处理循环引用的场景,造成内存泄露
可达性分析
- 将一系列GC Roots作为初识存活对象合集
- 标记:从该集合出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中
- 最终未被探索到的对象便是死亡,可以被回收
- GC Roots:堆外指向堆内的引用,一般包括
- Java方法栈帧中的局部变量
- 已加载类的静态变量
- 已启动且未停止的Java线程
- JNI MethodHandles
STW + 安全点
- JVM中的STW是通过安全点机制来实现的
- 当JVM收到STW请求时,会等待所有的线程都到达安全点,才允许请求STW的线程进行独占地工作
- 安全点的初衷并不是让其他线程停下,而是找到一个稳定的执行状态
- 在这个执行状态下,JVM的堆栈不会发生变化
- 垃圾回收器能够安全地执行可达性分析
- JNI:
- Java程序通过JNI执行本地代码时,如果本地代码不访问Java对象、调用Java方法或者返回至Java方法
- 那么JVM的堆栈是不会发生改变的,这段本地代码可以作为一个安全点
- 主要不离开这个安全点,JVM便能够在垃圾回收的同时,继续运行这段本地代码
- JVM仅需要在上述3个操作对应的JNI API入口处进行安全点检测
- 测试是否有其他线程请求停留在安全点,就可以在必要的时候挂起当前线程
- Java线程状态
- 运行状态
- 解释执行字节码
- 执行即时编译生成的机器码
- JVM需要在可预见的时间内进入安全点,否则垃圾回收线程可能长期处于等待所有线程进入安全点的状态,反而提高了垃圾回收的暂停时间
- 线程阻塞
- 阻塞的线程处于JVM线程调度器的掌控之下,属于安全点
- 运行状态
- 解析执行
- 字节码与字节码之间皆可作为安全点
- 当有安全点请求时,执行一条字节码便进行一次安全点检测
- 执行即时编译生成的机器码
- 代码直接运行在底层硬件上,不受JVM掌控
- 在即时编译时,需要插入安全点检测,避免机器码长时间没有安全点检测的情况
- 为什么不在每一条机器码或者每一个机器码基本块处插入安全点检测
- 性能开销:安全点检测本身也有一定的开销
- 内存开销:即时编译器生成的机器码打乱了原本栈帧上的对象分布状况,为了方便垃圾回收器能够枚举GC Roots,需要不少的额外空间来存储额外信息
垃圾回收的方式
清除(Sweep)
- 把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表中
- 当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象
- 缺点
- 内存碎片:JVM堆中的对象必须是连续分布的
- 分配效率低下:逐个访问列表中的项,来查找能够放入新建对象的空闲内存
压缩(Compact)
- 把存活对象聚集到内存区域的起始位置,从而留下一段连续的内存空间
- 能解决内存碎片的问题,代价为压缩算法的性能开销
复制(Copy)
- 把内存区域划分为两等分,分别用from和to指针来维护,from指针指向的内存区域用来分配内存
- 当发生垃圾回收时,便把存活的对象复制到to指针指向的内存区域,并且交换from指针和to指针的内容
- 同样能解决内存碎片的问题,代价为堆空间的使用效率极其低下
- 压缩也需要复制数据
- 压缩:需要复杂的算法保证引用能够正确更新
- 复制:可以在复制完成后统一更新引用
分代回收
- 分代回收的背景:大部分Java对象只存活一小段时间,而存活下来的小部分Java对象会存活很长时间
- 将堆空间划分为新生代和老年代,新生代用于存储新建对象,如果对象存活时间足够长,则会被移动到老年代
- 对应新生代,Java对象只存活很短时间,因此可以频繁地采用耗时较短的垃圾回收算法
- 对于老年代,由于在一般情况下大部分垃圾已经在新生代被回收,而在老年代的对象很大概率会继续存活,如果触发老年代回收,说明
- 新生代并没有回收大部分本该回收的垃圾
- 堆空间已经耗尽
- 对于老年代回收,JVM将做一次全堆扫描,耗时可能将不计成本
Minor GC
堆划分
- 新生代将分为Eden区和两个大小相同的Survivor区
- 默认情况下,JVM采取动态分配的策略(**-XX:+UsePSAdaptiveSurvivorSizePolicy**)
- 依据生成对象的速率,以及Survivor区的使用情况动态调整Eden区和Survivor区的比例
- 也可以通过**-XX:SurvivorRatio=8**来固定这个比例
- 其中一个Survivor区会一直为空,比例越低堆空间浪费越严重
- 调用new指令时,会在Eden区划出一块作为存储对象的内存
- 由于堆空间是内存共享的,因此需要同步
- JVM采用的技术为TLAB(Thread Local Allocation Buffer),-XX:+UseTLAB,默认开启
TLAB
- 每个线程可以向JVM申请一段连续的内存,作为线程私有的TLAB
- 这个操作需要加锁,线程需要维护两个指针,一个指向TLAB中空余内存的起始位置,一个指向TLAB的末尾
- new指令,直接通过指针加法来实现,即把指向空余内存位置的指针加上所请求的字节数
- 如果加法后空余内存指针的值仍然小于等于指向末尾的指针,代表分配成功
- 否则TLAB已经没有足够的空间来满足本次新建操作,这个时候需要当前线程重新申请新的TLAB
Minor GC
- 当Eden区的空间被耗尽,JVM会触发一个Minor GC,来回收新生代的垃圾
- 当发生Minor GC时,Eden区和from指向的Survivor区中的存活对象会被复制到to指向的Survivor区,然后交换from和to指针
- JVM会记录Survivor区中的对象一共被来回复制了几次
- 当一个对象被复制的次数为-XX:+MaxTenuringThreshold=15时,那么该对象将被晋升到老年代
- 15的原因是对象年龄(在对象头中)使用4bit记录
- 如果Survivor区已经被占用-XX:TargetSurvivorRatio=50%的时候,那么较高复制次数的对象也会被晋升到老年代
- 发生Minor GC时,采用标记-复制算法
- 理想情况下,Eden区中的对象都基本死亡了,那么需要复制的数据是非常少的,效果将很好
- Minor GC无需对整个堆进行回收
- 老年代的对象可能引用新生代的对象
- 在之前,在标记存活对象的时候,需要扫描整个老年代中的对象
- 如果老年代的对象拥有对新生代对象的引用,那么这个引用也会被作为GC Roots
- 借助卡表,无需全堆扫描
卡表(HotSpot)
- 将整个堆划分为大小为512Bytes的卡,并且维护一个卡表,用来存储每张卡的标识位
- 标识位:对应的卡是否可能存在有指向新生代对象的引用
- 如果可能存在,即认为这张卡是脏的
- 在进行Minor GC的时候,便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里
- 当完成所有脏卡的扫描后,JVM会将所有的脏卡的标识位清零
- Minor GC伴随着存活对象的复制,而在复制的同时需要更新指向被复制对象的引用
- 在更新引用的同时,又可以设置引用所在卡的标识位
- 在这个时候,可以确保脏卡中必定包含指向新生代对象的引用
- 在(下一次)Minor GC之前,并不能确保脏卡中包含指向新生代对象的引用
- 如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,JVM需要截获每个引用类型实例变量的写操作,并作出对应的写标识位操作
- 在解释执行器中比较容易实现,在即时编译器生成的字节码中,需要插入额外的逻辑,即写屏障
- 写屏障需要尽可能保持简洁,因为不能在每条引用型实例变量的写指令后注入大量的指令
- 写屏障并不会判断更新后的引用是否真的指向新生代中的对象,而是一律当成可能指向新生代对象的引用
- 原则:宁杀错,勿放过
- 伪代码:
CARD_TABLE [this address >> 9] = DIRTY;
- 虽然写屏障存在一定开销,但能够加大Minor GC的吞吐量 – AppTime/(AppTime+GcTime)
- 在高并发环境下,写屏障会出现伪共享的问题
- 卡表中不同卡的标识位之间的伪共享
- 在HotSpot中,使用-XX:+UseCondCardMark来尽量减少写卡表的操作
参考资料
All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.