**概念:**将已经分配出去的且不再使用的内存回收,垃圾在JVM中指的是死亡的对象所占用的堆内存
那么该如何辨别一个对象是否死亡呢?
**概念:**为每个对象都添加一个引用计数器,用来统计指向该对象的引用个数,如果该值为0,则代表该对象已死亡
**具体实现:**需要截获所有的引用更新操作,并且相应的增减所涉及的对象的计数器
缺点:
需要额外的空间存储计数器,更新操作繁琐
无法处理循环引用,例如类A引用类B,类B引用类A,但是此时已无其他类引用类A、类B,但是此时类A和类B本应该属于垃圾状态,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活着,从而造成内存泄漏
**概念:**将一系列 GC Roots 作为初始的存活对象合集(live set),从该合集出发,探索能够被该合集引用到的对象,并将其加入到集合中,这个过程称之为标记(mark),最终未被探索到的对象便是死亡的,可以回收的
**GC Roots:**堆外指向堆内的引用,一般包括如下几种:
**缺点:**多线程环境下,可能更新已经访问过的对象中的引用,从而导致两种异常场景
如何解决上述的误报和漏报的问题呢?并非任何时刻都可以随便GC的,要安全的回收需要满足两个条件:
**Stop-the-world:**停止其他非垃圾回收线程的工作,直到完成垃圾回收,
安全点(safepoint)机制:
概念:当JVM接收到Stop-the-world请求时,会等待所有的线程都到安全点,才允许Stop-the-world的线程进行独占工作,安全点就是指程序执行时候能够停顿下来的位置,每个线程走到安全点都会检查当前是否是处于STW状态。
目的:找到一个稳定状态(上文的位置),在这个稳定状态下,Java 虚拟机的堆栈不会发生变化,这样垃圾回收器就可以安全的进行可达性分析
检测所有线程是否到达安全点:
那么什么时候设置这个中断标志(安全点的设置):
由于程序要GC了需要等待所有线程到达安全点,如果线程长时间不进入安全点,会导致GC等待时间太长,因此考量标准为:是否具有让程序长时间执行的特征,比如:选择些执行时间较长的指令作为Safe Point, 如方法调用、循环跳转和异常跳转等。
循环的末尾
方法返回前
调用方法的call之后
抛出异常的位置
安全点可能出现的异常场景:
线程处于Sleep 状态或Blocked状态,这时候线程无法响应JVM的中断请求,“走” 到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决
安全区域:
概念:一段代码片中,引用关系不会发生变化,在这个区域任何地方GC都是安全的,线程执行到安全区域的代码时,首先标识自己进入了安全区域,这样GC时就不用管进入安全区域的线层了,线层要离开安全区域时就检查JVM是否完成了GC Roots枚举,如果完成就继续执行,如果没有完成就等待直到收到可以安全离开的信号
主流的基础回收分为三种:
概念:把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)中,当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象
优点:原理简单
缺点:
概念:对象聚集到内存区域的起始位置,从而留下一段连续的内存空间
优点:解决了内存碎片的问题
缺点:压缩算法的性能开销
概念:将内存区域两等分,分别用两个指针from和to来维护,当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容
优点:解决内存碎片问题
缺点:堆空间的使用效率低下
现代的垃圾回收器会综合上述几种回收方式,综合优点规避缺点,包括标记-清除法、标记-压缩法、标记-复制法等
JVM将堆划分为新生代和老年代:
JVM采取的是动态分配策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy
),根据生成对象的速率,以及S区的使用情况动态调整Eden和S区的比例(可通过参数 -XX:SurvivorRatio
来固定这个比例),其中一个S区会一直为空
如果调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存,内存不足时该咋办?
TLAB(Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启):
TLAB是为了避免对象分配时对内存的竞争
每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB,这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。
Eden区的空间耗尽了该怎么办?
解决办法: 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。
那岂不是又做了一次全堆扫描?
用途:用于避免全堆扫描
概念:将堆划分为一个个大小为512字节的卡,维护一个卡表(byte 数组),用来存储每一个卡的标识位,这个标识位代表对应的卡是否存有指向新生代对象的引用,如果存在则认为这个卡是脏卡
流程:进行Minor GC时可以不扫描整个老年代,而是根据卡表将脏卡中的对象放置到GC ROOT中,完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。
如何设置卡表的标识位:截获每个引用变量的写操作,并修改标识位,这个针对解释执行器是比较容易实现的,但是对于即时编译器生成的机器码中需要插入额外的逻辑,也就是所谓的写屏障(write barrier,注意不要和 volatile 字段的写屏障混淆)
写屏障:引用型实例变量的写指令,一律当成可能指向新生代对象的引用,虽然写屏障不可避免地带来一些开销,但是它能够加大 Minor GC 的吞吐率( 应用运行时间 /(应用运行时间 + 垃圾回收时间) )。总的来说还是值得的。不过,在高并发环境下,写屏障又带来了虚共享(false sharing)问题
-XX:PretenureSizeThreshold
:控制直升入老年代的对象大小,大于这个值的对象会直接分配到老年代上。其中Young空间不足,则大对象会直接分配到老年代上
由于老年代存储的对象比年轻代多的多,如果按照年轻代使用停止-复制算法,则相当低效。因此使用的是标记-整理算法
标记-整理算法:标记处仍然存活的对象(存在引用),将所有存活的对象向一端移动,以保证内存的连续,具体的标记算法可看上面可达性分析
清理时机:发送Minor GC时,虚拟机会每次检查晋升老年代的大小是否大于老年代的剩余空间,如果大于则会触发一次Full GC
新生代的垃圾回收器:都是采取的标记-复制算法
针对老年代的垃圾回收期,也有三种:Serial Old 和 Parallel Old 都是标记 - 压缩算法。同样,前者是单线程的,而后者可以看成前者的多线程版本
G1(Garbage First):
横跨新生代和老年代的垃圾回收器,打乱了之前所说的堆结构,直接将堆分为多个区域,。每个区域都可以充当 Eden 区、Survivor 区或者老年代中的一个。采取的标记-压缩算法,和CMS一样能够在应用程序运行过程中并发的垃圾回收。
其能够针对划分的每个区域来进行垃圾回收,在选择垃圾回收的区域时,会优先回收死亡对象较多的区域。
Java 11 引入了 ZGC
Update your browser to view this website correctly. Update my browser now