
引用计数算法的实现很简单,就是为每个对象维护一个引用计数器,当该对象被引用时,计数器会加1;当引用结束或失效时,计数器减1;如果计数器为 0 就代表对象不再存活可以被垃圾回收掉。
// 引用后计数器加一Object newObject = new Object();

// 取消引用后计数器减一Object newObject = null;

虽然引用计数算法会占用一点额外空间,但是实现简单且判定的效率高,在很多情况下这是一种不错的算法。但是简单靠引用计数算法来判断对象存活也会有别的问题,比如循环引用。
public class CircularReference {public Object object;public void circularReference() {CircularReference a = new CircularReference();CircularReference b = new CircularReference();// 循环引用a.object = b;b.object = a;a = null;b = null;}}
按照正常逻辑,我们将 a, b 引用置空后,a, b 两个引用指向的对象实例应该被判断为死亡的对象,但实际上,如果引用计数算法,两个实例还存在循环引用,所以需要先将 a,b 两个引用内的属性对象置空,然后将 a, b 两个引用置空,引用计数器才会归零;所以如果 Java 使用引用计数算法来判断对象存活,开发中稍有不慎就会造成内存泄漏。
目前基本上所有的主流 JVM 虚拟机都是通过可达性分析算法来判断对象存活的,这个算法的基本思路就是遍历一系列根对象(GC Roots),根据引用关系从 GC Root 往下搜索,搜索过程的路径称为“引用链”,如果对象到根对象上没有引用链相连(GC Root 到对象不可达),就会判断为可回收对象。

上图中灰色代表对象存活,白色代表可以回收的对象;可以看到,如果对象到根对象上没有引用链相连,不管这么循环引用都算作可回收对象,所以可达性分析算法就不会有循环引用导致的内存泄漏问题。
虚拟机栈(栈帧的局部变量表)里的引用的对象,比如方法中用到的参数,局部变量等;
方法区中类静态属性引用的变量,比如类的引用类型静态变量;
方法区中常量引用的对象,比如字符串常量池里的引用、类中final修饰的引用变量等;
本地方法栈中本地方法引用的对象;
Java虚拟机内部的引用,比如的class对象(类在第一次加载时会生成一个class对象作为访问类信息的入口)及一些异常类对象;
被同步锁(synchronized)持有的对象;
强引用就是最原始的引用,比如“Object = xxx”;只要有强引用存在,垃圾回收器就不会回收掉被引用的对象。
软引用用来描述有些用处但是非必需的对象,级别次于强引用,只有在将要发生内存泄漏时,才会去将这些软引用回收掉;Java中提供了SoftReference包装类来实现软引用;可以来做本地缓存,不过现在都有功能强大的开源本地缓存框架:Caffeine和Guava等,所以SoftReference应用场景应该比较少了。
弱应用级别低于软引用,这类引用会在发生垃圾收集时被清除不管内存是否够用;可以用来解决内存泄漏问题,比如 Jdk 中的 ThreadLocal 里 map 的key;Java 中提供了 WeakReference 包装类来实现弱引用。
虚引用是级别最低的引用,基本上等于没有引用,因为通过虚引用无法获取到对象;可以用来管理堆外内存。
但可达性分析算法判断一个对象不可达,去进行垃圾回收之前会做一些特殊操作。当一个对象被判断不可达后,会再次进行一次筛选判断,判断对象是否实现 finalized() 方法并该对象没有被虚拟机执行过 finalized() 方法,如果是,就该对象放进一个名为 F-Queue 的队列里,并在稍后由一个虚拟机创建,低调度优先级的 Finalizer 线程去执行他们的 finalized() 方法,但是这个执行只能保证开始执行,并不能保证执行完,因为不能因为一个对象的执行缓慢或者死循环而耽误主流程。finalized() 方法可以用作对象的自救(不过基本上没看到有人用过),实现 finalized() 方法并将当前对象再次引用,当前对象就能逃脱被回收的命运,但是一个对象只能被“自救”一次。
常量的回收就和堆里的回收非常类似,比如常量池里的某个常量没有被外部引用,进行垃圾回收的时候就可能会将该常量回收掉。
类的回收也可以叫做类的卸载,判断类型是否需要回收的条件需要满足以下三个:
标记 - 清除是最早的垃圾收集算法,主要思想就是标记存活的对象或标记需要回收的对象进行清除。缺点主要是两点:第一点是效率随着对象数量的增长而下降;第二点是会产生内存碎片问题。

标记 - 复制算法是为了解决 标记 - 清除 需要回收大量对象时候效率低下的问题,主要思想是将可用内存分为两个部分(只是举个例子分为两个部分,具体实现可以按照不同大小分为多个部门),每次只使用其中一块,当这块内存使用完了,会将存活的对象复制到另一个部分并把之前的部分一次性清空掉;如果存活的对象较多,就会有较大的复制开销,所以标记复制算法适用于朝生夕灭的对象。但是标记 - 复制还有个缺点是需要消耗一定的空间,如果把内存分为两个大小相同的部分进行复制算法,就类似阿东在杭州花了400w买了120平的房子却只能用60平!

标记-整理算法它来了!标记 - 整理算法的思想是:和标记 - 清除算法一样先标记对象,然后将存活的对象移动到内存空间的一端,最后一次性清理掉边界以外的内存。标记-整理算法和标记-清除算法的唯一区别就是标记-整理会在标记后移动对象。而这个移动对象的操作也是一种重量级操作。

首先在堆中移动对象,需要改变实例对象的地址,而现在主流虚拟机采用的直接指针访问对象,所以在虚拟机栈的局部变量表中的引用类型存储的地址就要被更新;
第二点是移动对象的过程需要暂停用户线程;因为如果不停顿,新对象在内存中进行分配时就会有并发问题,这类停顿就是大名鼎鼎的“Stop The World”。
新生代默认按照 8:1:1 为 Eden,Survivor0,Survivor1 三个部分,为对象分配内存时大部分是分配在 Eden 区(大对象可能直接分配到 old 区减轻复制压力),当Eden区满了会发生Minor GC(新生代GC),将存活对象复制到一个空的 Survivor 区,并清空Eden区和另一个Survivor区,后面对象继续分配在Eden区,循环往复,当Survivor放不下存活对象时会直接复制到老年代,如果老年代也没有空间了就会触发Full GC。
# 找到当前java进程idjps# 查看gc状态统计,每2000毫秒查询一次jstat -gcutil 1 2000

我们看到红线处因为Eden区满了发生了一次Young GC,E 和 S0区域的对象复制到了S1,部分对象由新生代移动到了老年代O区,YGC次数由76变为77。
JVM在新生代上建立了一个全局的数据结构 - 记忆集,记忆集把老年代划分多个小块,并标识出哪块存在跨代引用,当发生 Minor GC 时,将记忆集里存在跨代引用区域的对象加到 GC Roots 进行扫描,就能保证判断对象存活的准确性。虽然说在改变对象引用时需要额外维护记忆集的内容,但是相比Minor GC收集时去扫描整个堆还是好很多的。
第一种情况就是上面说的,在新生代进行标记复制时 Survivor 区放不下存活对象;
第二种情况是大对象会直接在老年代分配空间,因为大对象如果也要在新生代间来回复制,会有很高的复制开销;所以虚拟机提供了 -XX:PretenureSizeThreshold 参数,指定大于该值的对象直接在老年代分配空间。
之前我们讲过在对象头的 Mark Word里用4 bit来存储 GC年龄,而对象每经历过一次 Minor GC 都会将 GC 年龄加一,当年龄到达一定程度(默认是 15)就会进入老年代,这个年龄阈值可以通过参数 -XX:MaxTenuringThreshold 设置,默认是15。
当 Survivor 区里所有相同年龄的对象的大小总和大于 Survivor 区空间大小的一半时,大于该年龄的对象就会直接进入老年代。
但是如果在字节码中每个导致引用变化的指令位置都存到 OopMap,那也会占用很大的空间,所以 Hot Spot 没有让每条指令都生成 OopMap,而是在特定的“安全点”才会生成,安全点的选取一般在程序执行时间较长并且不会发生引用变化的指令处,比如方法调用,循环跳转,异常跳转等,有了安全点,进行垃圾收集时所有线程就会在最近的安全点停下来(主动式中断)。
针对运行中的线程这种方案已经没问题了,但是没有抢到 CPU 时间片的线程还处在阻塞状态,无法主动式执行到安全点,针对这种情况,Hot Spot又引入了安全区域,这个安全区域代表的是引用关系不会发生变化,只要线程执行到这个安全区域后,会标记自己进入安全区域,垃圾回收时不会去管理这些已经声明进入安全区域的线程;当线程要离开安全区域时,会去判断虚拟机是否完成GC,如果完成,线程会继续执行;如果没有完成会等待可以离开的信号。
!
文章转载自阿东编程之路,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。




