垃圾回收机制

对象的大小

我们创建的那些对象,到底在Java堆内存里会占用多少内存空间呢?
这个其实很简单,一个对象对内存空间的占用,大致分为两块:
  • 一个是对象的实例变量作为数据占用的空间
比如对象头,如果在64位的linux操作系统上,会占用16字节,然后如果你的实例对象内部有个int类型的实例变量,他会占用4个字节,如果是long类型的实例变量,会占用8个字节。如果是数组、Map之类的,那么就会占用更多的内存了。

引用关系

狭义的定义:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用
广义的引用分类

强引用

 
在程序代码中普遍存在的,类似"Object obj = new Object()",把一个对象赋给一个引用变量,这个引用就是强引用.
当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,
即该对象以后永远都不会被用到jvm也不会回收,因此,强引用是造成java内存泄漏的主要原因之一

软引用

notion image
就是把“ReplicaManager”实例对象用一个“SoftReference”软引用类型的对象给包裹起来了,此时这个“replicaManager”变量对“ReplicaManager”对象的引用就是软引用了。
正常情况下垃圾回收是不会回收软引用对象的,但是如果你进行垃圾回收之后,发现内存空间还是不够存放新的对象,内存都快溢出
此时就会把这些软引用对象给回收掉,哪怕他被变量引用了,但是因为他是软引用,所以还是要回收。

弱引用

notion image
如果发生垃圾回收,就会把这个对象回收掉。

虚引用

最弱的引用关系,需要用PhantomReference类来实现,一个对象是否有虚引用的存在,完全不影响生存时间,也无法通过虚引用获得实例对象.无法单独使用,
必须和引用队列联合使用,一个对象设置虚引用关联的目的是希望在回收时收到系统通知

垃圾回收的概念

回顾一下上面的代码。
notion image
假设有一个ReplicaManager对象要被垃圾回收了,那么假如这个对象重写了Object类中的finialize()方法 此时会先尝试调用一下他的finalize()方法,看是否把自己这个实例对象给了某个GC Roots变量,比如说代码中就给了ReplicaManager 类的静态变量。 如果重新让某个GC Roots变量引用了自己,那么就不用被垃圾回收了。
如果这行代码执行结束了,此时会怎么样?
一旦你的loadReplicasFromDisk()方法执行完毕,此时就会把loadReplicasFromDisk()方法对应的栈帧从main线程的Java虚拟机栈里出栈
此时一旦loadReplicasFromDisk()方法的栈帧出栈,那么大家会发现那个栈帧里的局部变量replicaManager,也就没有了。
也就是说,没有任何一个变量指向Java堆内存里的ReplicaManager实例对象了。
notion image
既然“ReplicaManager”对象实例是不需要使用的,已经没有任何方法的局部变量在引用这个实例对象了,而且他还空占着内存资源,那么我们应该怎么处理呢? JVM的垃圾回收机制
 
JVM本身是有垃圾回收机制的,他是一个后台自动运行的线程你只要启动一个JVM进程,他就会自带这么一个垃圾回收的后台线程。
这个线程会在后台不断检查JVM堆内存中的各个实例对象
 
如果某个实例对象没有任何一个方法的局部变量指向他,也没有任何一个类的静态变量,包括常量等地方在指向他。
那么这个垃圾回收线程,就会把这个没人指向的“ReplicaManager”实例对象给回收掉,从内存里清除掉,让他不再占用任何内存资源。
这样的话,这些不再被人指向的对象实例,即JVM中的“垃圾”,就会定期的被后台垃圾回收线程清理掉,不断释放内存资源

类对象回收

  • 首先该类的所有实例对象都已经从Java堆内存里被回收
  • 其次加载这个类的ClassLoader已经被回收
  • 最后,对该类的Class对象没有任何引用
满足上面三个条件就可以回收该类了。

判断对象存活算法

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1:当引用失效时,计数器值就减;任何时刻计数器都为0的对象就是不可能再引用的
引用计数法的局限性(两个对象循环引用)
public class Class1 { public Object instance = null; private static final int MEMORY = 1024 * 1024; private byte[] bigSize = new byte[2 * MEMORY]; public static void testGC() { Class1 o1 = new Class1();//step1 Class1 o2 = new Class1();//step2 o1.instance = o2;//step3 o2.instance = o1;//step4 o1=null;//step5 o2=null;//step6 System.gc(); } public static void main(String[] args) { testGC(); } }
使用jvm参数:-XX:+PrintGCDetails 打印GC日志
[GC (System.gc()) [PSYoungGen: 8028K->4824K(76288K)] 8028K->4832K(251392K), 0.0026914 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [Full GC (System.gc()) [PSYoungGen: 4824K->0K(76288K)] [ParOldGen: 8K->4666K(175104K)] 4832K->4666K(251392K), [Metaspace: 3376K->3376K(1056768K)], 0.0053168 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] Heap PSYoungGen total 76288K, used 1966K [0x000000076ae00000, 0x0000000770300000, 0x00000007c0000000) eden space 65536K, 3% used [0x000000076ae00000,0x000000076afeb9e0,0x000000076ee00000) from space 10752K, 0% used [0x000000076ee00000,0x000000076ee00000,0x000000076f880000) to space 10752K, 0% used [0x000000076f880000,0x000000076f880000,0x0000000770300000) ParOldGen total 175104K, used 4666K [0x00000006c0a00000, 0x00000006cb500000, 0x000000076ae00000) object space 175104K, 2% used [0x00000006c0a00000,0x00000006c0e8ead8,0x00000006cb500000) Metaspace used 3387K, capacity 4496K, committed 4864K, reserved 1056768K class space used 325K, capacity 388K, committed 512K, reserved 1048576K
GC日志中8028K->4832K说明jvm仍然回收了这两个互相引用的对象,说明虚拟机并不是通过引用计数法判断是否存活的
当程序进行到Step 1 时 obj1 的计数器=1;当程序进行到Step 2 时 obj2 的计数器=1;
程序进行到Step 3 时 obj1 的计数器=2;程序进行到Step 4 时 obj2 的计数器=2;
程序执行到step5,栈帧中obj1不再指向java堆,obj1的引用计数减1,结果为1;程序执行到step6,栈帧中obj2不再指向java堆,obj2的引用计数减1,结果为1
那么如果采用的引用计数算法的话 实例1和实例2的计数引用都不为0,两个实例所占内存不到释放,于是就产生了内存泄露(OOM)。

可达性算法(根搜索法)

'通过一些列的名为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,
当一个对象到GC Roots没有任何引用链相连(用图论的话来说就是从GC Roots到这个对象不可达)时,
则证明此对象是不可引用的.
作为GC Roots的对象有下面几种
  1. 虚拟机栈(栈帧中的本地变量表)中的引用的对象
  1. 方法区中的类静态属性引用的对象
  1. 方法区中的常量引用的对象
  1. 本地方法栈中JNI(一般说的Native)的引用的对象
 
案例
notion image
上面的代码其实就是在一个方法中创建了一个对象,然后有一个局部变量引用了这个对象,这种情况是最常见的此时如下图所示。“main()”方法的栈帧入栈,然后调用“loadReplicasFromDisk()”方法,栈帧入栈,接着让局部变量“replicaManager”引用堆内存里的“ReplicaManager”实例对象。
notion image
假设现在上图中“ReplicaManager”对象被局部变量给引用了,那么此时一旦新生代快满了,发生垃圾回收,会去分析这个“ReplicaManager”对象的可达性
这时,发现他是不能被回收的,因为他被人引用了,而且是被局部变量“replicaManager”引用的。
在JVM规范中,局部变量就是可以作为GC Roots的 只要一个对象被局部变量引用了,那么就说明他有一个GC Roots,此时就不能被回收了。
案例2
notion image
在JVM的规范里,静态变量也可以看做是一种GC Roots,此时只要一个对象被GC Roots引用了,就不会去回收他。
所以说,一句话总结:只要你的对象被方法的局部变量、类的静态变量给引用了,就不会回收他们。

finalize()方法

Java中假定finalize的工作原理为:一旦垃圾回收器准备回收内存而释放对象所占内存的时候,会先调用该对象的finalize方法,然后在下一次再需要垃圾回收的时候才真正的回收对象!
finalize()的作用:finalize用于在GC发生前事先调用去回收JNI调用中申请的特殊内存,下次GC发生时候保证GC后所有该对象的内存都释放了。
假设没有GC Roots引用的对象,是一定立马被回收吗?其实不是的,这里有一个finalize()方法可以拯救他自己,看下面的代码。
notion image
假设有一个ReplicaManager对象要被垃圾回收了,那么假如这个对象重写了Object类中的finialize()方法
此时会先尝试调用一下他的finalize()方法,看是否把自己这个实例对象给了某个GC Roots变量,比如说代码中就给了ReplicaManager类的静态变量。
如果重新让某个GC Roots变量引用了自己,那么就不用被垃圾回收了。

JVM堆内存分代模型

堆中对象的特点

  1. 大部分对象都是存活周期极短的 : 朝生夕死
    1. 在上面代码中,那个ReplicaManager对象,实际上属于短暂存活的这么一个对象
  1. 少数对象是长期存活的
    1. notion image
      上面那段代码的意思,就是给Kafka这个类定义一个静态变量,也就是“replicaManager”,这个Kafka类是在JVM的方法区里的
      然后让“replicaManager”引用了一个在Java堆内存里创建的ReplicaManager实例对象,如下图
      notion image
      接着在main()方法中,就会在一个while循环里,不停的调用ReplicaManager对象的load()方法,做成一个周期性运行的模式。 这个ReplicaManager实例对象,他是会一直被Kafka的静态变量引用的,然后会一直驻留在Java堆内存里,是不会被垃圾回收掉的。
      因为这个实例对象他需要长期被使用,周期新的被调用load()方法,所以他就成为了一个长时间存在的对象。
      类似这种被类的静态变量长期引用的对象,他需要长期停留在Java堆内存里,这这种对象就是生存周期很长的对象,他是轻易不会被垃圾回收的,他需要长期存在,不停的去使用他。

分代模型

JVM将Java堆内存划分为了两个区域,一个是年轻代,一个是老年代。
其中年轻代,顾名思义,就是把第一种代码示例中的那种,创建和使用完之后立马就要回收的对象放在里面 然后老年代呢,就是把第二种代码示例中的那种,创建之后需要一直长期存在的对象放在里面

分代原因

因为这跟垃圾回收有关,对于年轻代里的对象,他们的特点是创建之后很快就会被回收,所以需要用一种垃圾回收算法
对于老年代里的对象,他们的特点是需要长期存在,所以需要另外一种垃圾回收算法,所以需要分成两个区域来放不同的对象。
 
大部分的正常对象,都是优先在新生代分配内存的。包括那些静态变量指向的对象。

分代年龄

长期存活的对象会躲过多次垃圾回收,比如类变量指向的对象
如果一个实例对象在新生代中,成功的在15次垃圾回收之后,还是没被回收掉,就说明他已经15岁了。
这是对象的年龄,每垃圾回收一次,如果一个对象没被回收掉,他的年龄就会增加1。
会被转移到Java堆内存的老年代中去,顾名思义,老年代就是放这些年龄很大的对象。

新生代对象到老年代的时机

Minor GC (Young GC)

新生代预先分配的内存空间几乎都被全部对象给占满,而此时又需要创建新对象的时候会触发一次垃圾回收。

分代年龄到指定次数

notion image
只要这个“Kafka”类还存在,那么他的静态变量“replicaManager”就会长期引用“ReplicaManager”对象,所以你无论新生代怎么垃圾回收,类似这种对象都不会被回收掉的。
此时这类对象每次在新生代里躲过一次GC被转移到一块Survivor区域中,此时他的年龄就会增长一岁
默认的设置下,当对象的年龄达到15岁的时候,也就是躲过15次GC的时候,他就会转移到老年代里去。
这个具体是多少岁进入老年代,可以通过JVM参数-XX:MaxTenuringThreshold来设置,默认是15岁

动态对象年龄判断

如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于等或等于该年龄的对象就可以直接进入老年代
 
假如说当前放对象的Survivor区域里,一批对象的总大小大于了这块Survivor区域的内存大小的50%,那么此时大于等于这批对象年龄的对象,就可以直接进入老年代了。
notion image
假设这个图里的Survivor2区有两个对象,这俩对象的年龄一样,都是2岁
然后俩对象加起来对象超过了50MB,超过了Survivor2区的100MB内存大小的一半了,这个时候,Survivor2区里的大于等于2岁的对象,就要全部进入老年代里去。
这就是所谓的动态年龄判断的规则,这条规则也会让一些新生代的对象进入老年代。
实际这个规则运行的时候是如下的逻辑:年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n以上的对象都放入老年代。

大对象直接进入老年代

有一个JVM参数,就是-XX:PretenureSizeThreshold,可以把他的值设置为字节数,比如“1048576”字节,就是1MB。
他的意思就是,如果你要创建一个大于这个大小的对象,比如一个超大的数组,或者是别的啥东西,此时就直接把这个大对象放到老年代里去。
压根儿不会经过新生代。之所以这么做,就是要避免新生代里出现那种大对象,然后屡次躲过GC,还得把他在两个Survivor区域里来回复制多次之后才能进入老年代,

Minor GC后的对象太多无法放入Survivor区

现在有一个比较大的问题,就是如果在Minor GC之后发现剩余的存活对象太多了,没办法放入另外一块Survivor区怎么办?如下图。
notion image
比如上面这个图,假设在发生GC的时候,发现Eden区里超过150MB的存活对象,此时没办法放入Survivor区中,此时该怎么办呢?
这个时候就必须得把这些对象直接转移到老年代去,如下图所示。
notion image

老年代空间分配担保规则

jdk 6 Update 24之后,-XX:HandlePromtionFailure 参数不会在影响虚拟机的分配担保策略,默认会开启分配担保策略。

触发分配担保策略的时机

当出现大量对象在MinorGC后仍存活的情况(极端情况MinorGC后所有新生代对象都存活)需要老年代分配担保,把Survivor放不下的对象送入老年代。
只要老年代的剩余连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行MinorGC,否则出发FullGC。

担保过程

  1. 在执行任何一次Minor GC之前,JVM会先检查一下老年代可用的内存空间,是否大于新生代所有对象的总大小。 为啥检查这个呢?因为最极端的情况下,可能新生代Minor GC过后,所有对象都存活下来了,那岂不是新生代所有对象全部要进入老年代?如下图。
    1. 如果说发现老年代的内存大小是大于新生代所有对象的,此时就可以放心大胆的对新生代发起一次Minor GC了,因为即使Minor GC之后所有对象都存活,Survivor区放不下了,也可以转移到老年代去。
      notion image
      假如执行Minor GC之前,发现老年代的可用内存已经小于了新生代的全部对象大小了 那么这个时候是不是有可能在Minor GC之后新生代的对象全部存活下来,然后全部需要转移到老年代去,但是老年代空间又不够?理论上,是有这种可能的。
  1. 下一步判断,就是看看老年代的内存大小,是否大于之前每次Minor GC后进入老年代的对象的平均大小
    1. 举个例子,之前每次Minor GC后,平均都有10MB左右的对象会进入老年代,那么此时老年代可用内存大于10MB。
      这就说明,很可能这次Minor GC过后也是差不多10MB左右的对象会进入老年代,此时老年代空间是够的,看下图。
      notion image
  1. 如果上面那个步骤失败了,此时就会直接触发一次“FullGC”,就是对老年代进行垃圾回收,尽量腾出来一些内存空间,然后再执行Minor GC。
    1. 如果上面两个步骤都判断成功了,那么就是说可以冒点风险尝试一下Minor GC。此时进行Minor GC有几种可能。
      第一种可能,Minor GC过后,剩余的存活对象的大小,是小于Survivor区的大小的,那么此时存活对象进入Survivor区域即可。
      第二种可能,Minor GC过后,剩余的存活对象的大小,是大于 Survivor区域的大小,但是是小于老年代可用内存大小的,此时就直接进入老年代即可。
      第三种可能,很不幸,Minor GC过后,剩余的存活对象的大小,大于了Survivor区域的大小,也大于了老年代可用内存的大小。此时老年代都放不下这些存活对象了,就会发生“Handle Promotion Failure”的情况,这个时候就会触发一次“Full GC”。Full GC就是对老年代进行垃圾回收,同时也一般会对新生代进行垃圾回收
      因为这个时候必须得把老年代里的没人引用的对象给回收掉,然后才可能让Minor GC过后剩余的存活对象进入老年代里面。
  1. 如果要是Full GC过后,老年代还是没有足够的空间存放Minor GC过后的剩余存活对象,那么此时就会导致所谓的“OOM”内存溢出了
 
全流程总结
全流程总结

垃圾收集算法

复制算法

 
新生代内存区域划分为三块: 1个Eden区,2个Survivor区,其中Eden区占80%内存空间,每一块Survivor区各占10%内存空间,比如说Eden区有800MB内存,每一块Survivor区就100MB内存 ( Oracle的HotSpot虚拟机默认Eden和Survivor的比例为8:1),
notion image
平时可以使用的,就是Eden区和其中一块Survivor区,那么相当于就是有900MB的内存是可以使用的,如下图所示。
notion image
刚开始对象都是分配在Eden区内的,如果Eden区快满了,此时就会触发垃圾回收
此时就会把Eden区中的存活对象都一次性转移到一块空着的Survivor区。接着Eden区就会被清空,然后再次分配新对象到Eden区里,
然后就会如上图所示,Eden区和一块Survivor区里是有对象的,其中Survivor区里放的是上一次Minor GC后存活的对象。
如果下次再次Eden区满,那么再次触发Minor GC,就会把Eden区和放着上一次Minor GC后存活对象的Survivor区内的存活对象,转移到另外一块Survivor区去。
 
为什么分为Eden,S0和S1 ?
每次垃圾回收可能存活下来的对象就1%,所以在设计的时候就留了一块100MB的内存空间来存放垃圾回收后转移过来的存活对象
比如Eden区+一块Survivor区有900MB的内存空间都占满了,但是垃圾回收之后,可能就10MB的对象是存活的。
此时就把那10MB的存活对象转移到另外一块Survivor区域就可以,然后再一次性把Eden区和之前使用的Survivor区里的垃圾对象全部回收掉
接着新对象继续分配在Eden区和另外那块开始被使用的Survivor区,然后始终保持一块Survivor区是空着的,就这样一直循环使用这三块内存区域
从而减少了内存碎片,提供了内存利用率。

标记-整理算法

老年代采取的是标记整理算法
首先标记出来老年代当前存活的对象,这些对象可能是东一个西一个的。
接着会让这些存活对象在内存里进行移动,把存活对象尽量都挪动到一边去,让存活对象紧凑的靠在一起,避免垃圾回收过后出现过多的内存碎片
然后再一次性把垃圾对象都回收掉,大家看下图。
notion image
这个老年代的垃圾回收算法的速度至少比新生代的垃圾回收算法的速度慢10倍。如果系统频繁出现老年代的Full GC垃圾回收,会导致系统性能被严重影响,出现频繁卡顿的情况。