- 为每个对象添加一个 引用计数器 ,用来统计 指向该对象的引用个数
- 如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器+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
- 借助 卡表 , 无需全堆扫描
原文
http://zhongmingmao.me/2018/12/26/jvm-basic-gc/