JVM基础 -- 垃圾回收基础

  1. 为每个对象添加一个 引用计数器 ,用来统计 指向该对象的引用个数
    • 如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器+1
    • 如果指向某一对象的引用,被赋值为其他值,那么该对象的引用计数器-1
  2. 一旦某个对象的引用计数器为 0 ,说明对象已经 死亡
  3. 缺点
    • 额外的空间来存储计数器 + 繁琐的更新操作
    • 无法处理 循环引用 的场景,造成 内存泄露

可达性分析

  1. 将一系列 GC Roots 作为 初识存活对象合集
    • 标记:从该集合出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中
    • 最终未被探索到的对象便是死亡,可以被回收
  2. GC Roots: 堆外指向堆内的引用 ,一般包括
    • Java方法栈帧中的局部变量
    • 已加载类的静态变量
    • 已启动且未停止的Java线程
    • JNI MethodHandles

STW + 安全点

  1. JVM中的 STW 是通过 安全点机制 来实现的
  2. 当JVM收到 STW请求 时,会等待 所有的线程都到达安全点 ,才允许请求STW的线程进行 独占地工作
  3. 安全点的初衷并不是让其他线程停下,而是找到一个 稳定的执行状态
    • 在这个执行状态下,JVM的 堆栈不会发生变化
    • 垃圾回收器能够 安全地执行可达性分析
  4. JNI:
    • Java进程通过JNI执行本地代码时,如果本地代码不 访问Java对象调用Java方法 或者 返回至Java方法
    • 那么JVM的堆栈是不会发生改变的,这段本地代码可以作为一个 安全点
    • 主要不离开这个安全点,JVM便能够在垃圾回收的 同时 ,继续运行这段本地代码
    • JVM仅需要在上述3个操作对应的 JNI API入口处 进行 安全点检测
      • 测试是否有其他线程请求停留在安全点,就可以在必要的时候挂起当前线程
  5. Java线程状态
    • 运行状态
      • 解释执行字节码
      • 执行即时编译生成的机器码
      • JVM需要 在可预见的时间内进入安全点 ,否则 垃圾回收线程可能长期处于等待所有线程进入安全点的状态 ,反而提高了垃圾回收的暂停时间
    • 线程阻塞
      • 阻塞的线程处于 JVM线程调度器的掌控之下 ,属于 安全点
  6. 解析执行
    • 字节码与字节码之间皆可作为安全点
    • 当有 安全点请求 时, 执行一条字节码便进行一次安全点检测
  7. 执行即时编译生成的机器码
    • 代码直接运行在底层硬件上, 不受JVM掌控
    • 在即时编译时,需要 插入安全点检测避免机器码长时间没有安全点检测的情况
    • 为什么不在 每一条机器码 或者 每一个机器码基本块 处插入安全点检测
      • 性能开销:安全点检测本身也有一定的 开销
      • 内存开销:即时编译器生成的机器码打乱了原本栈帧上的对象分布状况,为了方便垃圾回收器能够枚举GC Roots,需要不少的 额外空间 来存储额外信息

垃圾回收的方式

清除(Sweep)

  1. 把死亡对象所占据的内存标记为空闲内存,并记录在一个 空闲列表
  2. 当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象
  3. 缺点
    • 内存碎片:JVM堆中的对象必须是连续分布的
    • 分配效率低下:逐个访问列表中的项,来查找能够放入新建对象的空闲内存
JVM基础 -- 垃圾回收基础
JVM基础 -- 垃圾回收基础

压缩(Compact)

  1. 存活对象 聚集到内存区域的 起始位置 ,从而留下一段 连续的内存空间
  2. 解决内存碎片 的问题,代价为 压缩算法的性能开销
JVM基础 -- 垃圾回收基础
JVM基础 -- 垃圾回收基础

复制(Copy)

  1. 把内存区域划分为 两等分 ,分别用from和to指针来维护, from指针 指向的内存区域用来 分配内存
  2. 当发生垃圾回收时,便 把存活的对象复制到to指针指向的内存区域 ,并且 交换from指针和to指针的内容
  3. 同样能 解决内存碎片 的问题,代价为 堆空间的使用效率极其低下
  4. 压缩也需要复制数据
    • 压缩:需要 复杂的算法 保证引用能够正确更新
    • 复制:可以在 复制完成后统一更新 引用
JVM基础 -- 垃圾回收基础
JVM基础 -- 垃圾回收基础

分代回收

  1. 分代回收的背景:大部分Java对象只存活一小段时间,而存活下来的小部分Java对象会存活很长时间
  2. 将堆空间划分为新生代和老年代,新生代用于存储新建对象,如果对象存活时间足够长,则会被移动到老年代
  3. 对应新生代,Java对象只存活很短时间,因此可以 频繁 地采用 耗时较短 的垃圾回收算法
  4. 对于老年代,由于在一般情况下大部分垃圾已经在新生代被回收,而在老年代的对象很大概率会继续存活,如果触发老年代回收,说明
    • 新生代并没有回收大部分本该回收的垃圾
    • 堆空间已经耗尽
  5. 对于老年代回收,JVM将做一次 全堆扫描耗时可能将不计成本

Minor GC

堆划分

  1. 新生代将分为Eden区和两个大小相同的Survivor区
  2. 默认情况下,JVM采取 动态分配 的策略( -XX:+UsePSAdaptiveSurvivorSizePolicy
    • 依据 生成对象的速率 ,以及 Survivor区的使用情况 动态调整Eden区和Survivor区的比例
    • 也可以通过 -XX:SurvivorRatio=8 来固定这个比例
    • 其中一个Survivor区会 一直为空 ,比例越低堆空间浪费越严重
  3. 调用 new 指令时,会在 Eden 区划出一块作为存储对象的内存
    • 由于 堆空间内存共享 的,因此需要 同步
    • JVM采用的技术为 TLAB (Thread Local Allocation Buffer),-XX:+UseTLAB, 默认开启
JVM基础 -- 垃圾回收基础
JVM基础 -- 垃圾回收基础

TLAB

  1. 每个 线程 可以向JVM申请一段 连续的内存 ,作为 线程私有的TLAB
    • 这个操作需要 加锁 ,线程需要维护两个指针,一个指向 TLAB中空余内存的起始位置 ,一个指向 TLAB的末尾
  2. new指令,直接通过 指针加法 来实现,即把指向空余内存位置的指针加上所请求的字节数
    • 如果加法后空余内存指针的值仍然小于等于指向末尾的指针,代表分配成功
    • 否则TLAB已经没有足够的空间来满足本次新建操作,这个时候需要当前线程 重新申请新的TLAB

Minor GC

  1. Eden区的空间被耗尽 ,JVM会触发一个 Minor GC ,来 回收新生代的垃圾
  2. 当发生Minor GC时, Eden区和from指向的Survivor区中的存活对象会被复制到to指向的Survivor区 ,然后交换from和to指针
  3. JVM会记录Survivor区中的对象一共被来回复制了几次
    • 当一个对象被复制的次数为-XX:+MaxTenuringThreshold=15时,那么该对象将被晋升到老年代
    • 15的原因是 对象年龄 (在对象头中)使用 4bit 记录
  4. 如果Survivor区已经被占用-XX:TargetSurvivorRatio=50%的时候,那么 较高复制次数的对象 也会被晋升到老年代
  5. 发生Minor GC时,采用 标记-复制 算法
    • 理想情况下,Eden区中的对象都 基本死亡 了,那么需要 复制的数据是非常少 的,效果将很好
  6. Minor GC 无需对整个堆进行回收
    • 老年代的对象可能引用新生代的对象
    • 在之前,在标记存活对象的时候,需要扫描整个老年代中的对象
    • 如果老年代的对象拥有对新生代对象的引用,那么这个引用也会被作为GC Roots
    • 借助 卡表无需全堆扫描