JVM学习(四):基本原理之垃圾回收

1.垃圾回收的算法

**概念:**将已经分配出去的且不再使用的内存回收,垃圾在JVM中指的是死亡的对象所占用的堆内存

那么该如何辨别一个对象是否死亡呢?

1.1.引用计数法

**概念:**为每个对象都添加一个引用计数器,用来统计指向该对象的引用个数,如果该值为0,则代表该对象已死亡

**具体实现:**需要截获所有的引用更新操作,并且相应的增减所涉及的对象的计数器

缺点:

  • 需要额外的空间存储计数器,更新操作繁琐

  • 无法处理循环引用,例如类A引用类B,类B引用类A,但是此时已无其他类引用类A、类B,但是此时类A和类B本应该属于垃圾状态,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活着,从而造成内存泄漏

    img

1.2.可达性分析算法(目前主流JVM采取的)

**概念:**将一系列 GC Roots 作为初始的存活对象合集(live set),从该合集出发,探索能够被该合集引用到的对象,并将其加入到集合中,这个过程称之为标记(mark),最终未被探索到的对象便是死亡的,可以回收的

**GC Roots:**堆外指向堆内的引用,一般包括如下几种:

  • Java方法栈帧中的局部变量
  • 已加载类的静态变量
  • JNI handles:Java Native Interface的缩写,通过使用 Java本地接口书写程序,可以确保代码在不同的平台上方便移植
  • 已启动但未停止的Java线程

**缺点:**多线程环境下,可能更新已经访问过的对象中的引用,从而导致两种异常场景

  • 误报:如果将该引用设置为null,就代表之前所引用的对象可以回收了,但是此对象已被标记,导致的后果就是该对象未被回收
  • 漏报:将该引用设置为未被访问的对象,由于该对象未被访问,也就是未被标记,此时这个对象会被回收,导致的后果就是本被引用的对象被回收,此时可能会由于引用访问了已被回收的对象,导致JVM的崩溃

1.3.Stop-the-world 以及安全点

如何解决上述的误报和漏报的问题呢?并非任何时刻都可以随便GC的,要安全的回收需要满足两个条件:

  • 堆内存的变化是受控制的,最好所有线程都停止
  • 堆中的对象状态都是可知的,不存在不再使用的对象很难找到或者找不到

**Stop-the-world:**停止其他非垃圾回收线程的工作,直到完成垃圾回收,

安全点(safepoint)机制:

概念:当JVM接收到Stop-the-world请求时,会等待所有的线程都到安全点,才允许Stop-the-world的线程进行独占工作,安全点就是指程序执行时候能够停顿下来的位置,每个线程走到安全点都会检查当前是否是处于STW状态。

目的:找到一个稳定状态(上文的位置),在这个稳定状态下,Java 虚拟机的堆栈不会发生变化,这样垃圾回收器就可以安全的进行可达性分析

检测所有线程是否到达安全点:

  • 抢断式检测:(目前没有虚拟机采用了) 首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
  • 主动式中断:设置一个中断标志,每个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起

那么什么时候设置这个中断标志(安全点的设置):

由于程序要GC了需要等待所有线程到达安全点,如果线程长时间不进入安全点,会导致GC等待时间太长,因此考量标准为:是否具有让程序长时间执行的特征,比如:选择些执行时间较长的指令作为Safe Point, 如方法调用、循环跳转和异常跳转等。

  1. 循环的末尾

  2. 方法返回前

  3. 调用方法的call之后

  4. 抛出异常的位置

安全点可能出现的异常场景:

线程处于Sleep 状态或Blocked状态,这时候线程无法响应JVM的中断请求,“走” 到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决

安全区域:

概念:一段代码片中,引用关系不会发生变化,在这个区域任何地方GC都是安全的,线程执行到安全区域的代码时,首先标识自己进入了安全区域,这样GC时就不用管进入安全区域的线层了,线层要离开安全区域时就检查JVM是否完成了GC Roots枚举,如果完成就继续执行,如果没有完成就等待直到收到可以安全离开的信号

2.垃圾回收的三种方式

主流的基础回收分为三种:

2.1.清除(sweep)

概念:把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)中,当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象

优点:原理简单

缺点:

  • 会造成内存碎片
  • 分配给新对象使用的效率低,对于空闲列表JVM需要遍历查找能够插入新增对象的空间

img

2.2.压缩

概念:对象聚集到内存区域的起始位置,从而留下一段连续的内存空间

优点:解决了内存碎片的问题

缺点:压缩算法的性能开销

img

2.3.复制

概念:将内存区域两等分,分别用两个指针from和to来维护,当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容

优点:解决内存碎片问题

缺点:堆空间的使用效率低下

img

现代的垃圾回收器会综合上述几种回收方式,综合优点规避缺点,包括标记-清除法、标记-压缩法、标记-复制法等

3.Java 虚拟机的堆划分

JVM将堆划分为新生代和老年代:

  • 新生代:Eden区+两个大小相同的Survivors(新生代共有两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的)
  • 老年代

JVM采取的是动态分配策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及S区的使用情况动态调整Eden和S区的比例(可通过参数 -XX:SurvivorRatio 来固定这个比例),其中一个S区会一直为空

img

如果调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存,内存不足时该咋办?

TLAB(Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启):

TLAB是为了避免对象分配时对内存的竞争

每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB,这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。

Eden区的空间耗尽了该怎么办?

3.1.Minor GC

解决办法: Minor GC

标记 - 复制算法:

JVM触发一次Minor GC,收集新生代的垃圾,存活下来的对象送到S区。Eden区和from区指向的S区中的存活对象会复制到to指向的S区,交换from和to指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。JVM会记录对象来回复制的次数,如果该次数为15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象会晋升(promote)至老年代。另外单个S区已经被占用了50%对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会晋升到老年代

优点:不用对整个堆进行垃圾回收

缺点:老年代的对象可能引用新生代的对象,就是说在标记存活对象的时候,需要扫描老年代中的对象,如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。

那岂不是又做了一次全堆扫描?

3.2.卡表

用途:用于避免全堆扫描

概念:将堆划分为一个个大小为512字节的卡,维护一个卡表(byte 数组),用来存储每一个卡的标识位,这个标识位代表对应的卡是否存有指向新生代对象的引用,如果存在则认为这个卡是脏卡

流程:进行Minor GC时可以不扫描整个老年代,而是根据卡表将脏卡中的对象放置到GC ROOT中,完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

如何设置卡表的标识位:截获每个引用变量的写操作,并修改标识位,这个针对解释执行器是比较容易实现的,但是对于即时编译器生成的机器码中需要插入额外的逻辑,也就是所谓的写屏障(write barrier,注意不要和 volatile 字段的写屏障混淆)

写屏障:引用型实例变量的写指令,一律当成可能指向新生代对象的引用,虽然写屏障不可避免地带来一些开销,但是它能够加大 Minor GC 的吞吐率( 应用运行时间 /(应用运行时间 + 垃圾回收时间) )。总的来说还是值得的。不过,在高并发环境下,写屏障又带来了虚共享(false sharing)问题

3.3.Full GC(Major GC)

-XX:PretenureSizeThreshold:控制直升入老年代的对象大小,大于这个值的对象会直接分配到老年代上。其中Young空间不足,则大对象会直接分配到老年代上

由于老年代存储的对象比年轻代多的多,如果按照年轻代使用停止-复制算法,则相当低效。因此使用的是标记-整理算法

标记-整理算法:标记处仍然存活的对象(存在引用),将所有存活的对象向一端移动,以保证内存的连续,具体的标记算法可看上面可达性分析

清理时机:发送Minor GC时,虚拟机会每次检查晋升老年代的大小是否大于老年代的剩余空间,如果大于则会触发一次Full GC

4.Java虚拟机的垃圾回收器

新生代的垃圾回收器:都是采取的标记-复制算法

  • Serial:单线程
  • Parallel Scavenge :Parallel Scavenge 和 Parallel New 类似,但更加注重吞吐率。此外,Parallel Scavenge 不能与 CMS 一起使用。
  • Parallel New:可看作Serial的多线程版本

针对老年代的垃圾回收期,也有三种:Serial Old 和 Parallel Old 都是标记 - 压缩算法。同样,前者是单线程的,而后者可以看成前者的多线程版本

  • Serial Old
  • Parallel Old
  • CMS:标记-清除算法,且是并发的,除了少数几个操作需要 Stop-the-world 之外,它可以在应用程序运行过程中进行垃圾回收。在并发收集失败的情况下,Java 虚拟机会使用其他两个压缩型垃圾回收器进行一次垃圾回收。由于 G1 的出现,CMS 在 Java 9 中已被废弃

G1(Garbage First):

横跨新生代和老年代的垃圾回收器,打乱了之前所说的堆结构,直接将堆分为多个区域,。每个区域都可以充当 Eden 区、Survivor 区或者老年代中的一个。采取的标记-压缩算法,和CMS一样能够在应用程序运行过程中并发的垃圾回收。

其能够针对划分的每个区域来进行垃圾回收,在选择垃圾回收的区域时,会优先回收死亡对象较多的区域。

Java 11 引入了 ZGC

# java  JVM 

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×