为何需要了解GC和内存分配呢?当需要排查各种内存溢出、内存泄漏问题时;当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
解决三个问题:
哪些内存需要回收?
什么时候回收?
如何回收?
一、哪些内存需要回收
程序计数器、虚拟机栈、本地方法栈与线程生死相随,所以主要需考虑Java堆和方法区的内存回收。
二、什么时候回收
垃圾收集器在对堆进行回收前,第一件事就是确定这些对象之中哪些还“存活”着,哪些已经“死去”。如何判断对象是否存活呢?
1、引用计数算法
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
主流的Java虚拟机并没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。
2、可达性分析算法
通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,即为可回收对象。要真正宣告一个对象死亡,至少要经历两次标记过程。
在Java中,可作为GC Roots的对象包括以下几种:
a)虚拟机栈(栈帧中的本地变量表)中引用的对象
b)方法区中类静态属性引用的对象
c)方法区中常量引用的对象
d)本地方法栈中JNI引用的对象
引用分类
a)强引用:如Object obj = new Object(),只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
b)软引用:系统将要发生内存溢出异常之前,将会把软引用关联的对象列进回收范围之中进行第二次回收,如果这次回收还没有足够内存,才会抛出内存溢出异常
c)弱引用:当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。被弱引用关联的对象只能生存到下一次垃圾收集发生之前
d)虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例
内存回收的区域
在方法区中进行垃圾收集的性价比较低。在堆中,尤其在新生代中,常规应用进行一次垃圾收集一般可回收70%-95%的空间,而永久代的垃圾收集效率远低于此。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。常量没有被引用即为废弃常量,如何判断无用类呢?需满足以下三个条件:
a)该类的所有实例已被回收
b)加载该类的ClassLoader已经被回收
c)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
三、如何回收
垃圾收集算法
1、标记-清除算法
逻辑:先标记所需回收内存,标记完成后统一回收所有被标记的对象。
缺点:1)效率低:标记和清除两个过程的效率都不高;2)空间问题:标记清除后会有大量不连续的内存碎片,空间碎片太多可能导致以后在程序运行过程中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集动作。
2、复制算法
逻辑:将可用内存划分为大小相等的两块,每次只使用其中的一块,当这块内存用完了,就将这块还存活着的对象复制到另一块上面,然后再把已使用过的内存空间清理掉。
缺点:代价是将内存缩小为原来的一半。因大部分对象都会被清理,只需复制很少一部分对象,所以适用于新生代内存的清理。
tips:商业虚拟机的算法
现在的商业虚拟机都采用这种算法来回收新生代,由于新生代中对象朝生夕灭的特性,并不需要按照1:1来划分内存空间。而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,这样只有10%的内存会被浪费掉。
因没办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。即如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
3、标记-整理算法
逻辑:过程与标记-清除算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象向一端偏移,然后直接清理掉端边界以外的内存
特点:适用于老年代内存的清理。
4、分代收集算法
逻辑:只是根据对象存活周期的不同将内存划分为几块。一般将堆划分为新生代和老年代,这样就可根据各个年代的特点采用最适当的收集算法。
四、理解GC日志
每个收集器的日志格式都可以不一样,为方便理解也维护了一些共性。举例说明:
33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031690 secs]
100.677: [Full GC [Tenured: 0K->219K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm: 2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
a)最前面的数字“33.125”和“100.677”:代表GC发生的时间,这个数字的含义是从Java虚拟机启动以来经过的秒数
b)GC日志开头的“ [GC”和“[Full GC”:说明这次垃圾收集的停顿类型。如果有Full,说明这次GC是发生了Stop-The-World的。
c)接下来的“[DefNew”和“[Tenured”:表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的。如Serial收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。
d)后面方括号内部的“3324K->152K(3712K)”:GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)方括号之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”
e)0.0025925 secs:该内存区域GC所占用的时间。有的收集器会给出更具体的时间数据,如:[Times: user=0.01 sys=0.00, real=0.02 secs],这里的user、sys和real是Linux的time命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU时间和操作从开始到结束所经过的墙钟时间。墙钟时间与CPU时间区别是:墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程阻塞等,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以读者会看到user或sys时间超过real时间。
五、内存分配与回收策略
对象的内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况也可能直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。接下来讲解几条最普遍的内存分配规则:
1.对象优先在Eden分配
大多数情况,对象在新生代Eden区中分配。当Eden区中没有足够空间进行分配时,虚拟机将发起一次Minor GC。
新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多数具备朝生夕灭的特点,所以Minor GC非常频繁,一般回收速度也较快。
老年代GC(Major GC / Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC。Major GC的速速一般比Minor GC慢10倍以上。
private static final int _1MB = 1024 * 1024;
/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public static void testAllocation(){
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB]; //出现一次Minor GC
}
1)VM参数分析:
a)-XX:+PrintGCDetails:收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存的回收日志,且在进程退出时输出当前的内存各区域分配情况。实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析。
b)-Xms20M -Xmx20M -Xmn10M:这3个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老生代。
c)-XX:SurvivorRatio=8:决定了新生代中Eden区与一个Survivor区的空间比例是8:1
2)代码分析
执行allocation1,allocation2,allocation3会顺利分配进Eden区,由于剩余Eden区内存不足以放下allocation4,所以此时会发生一次Minor GC。GC期间发现已有的3个2M大小的对象无法全部放入Survivor空间,所以只好通过分配担保机制提取转移到老年代去。 GC结束后,allocation4顺利进入Eden区,Survivor空闲,老年代被占用6MB(allocation1,allocation2,allocation3)。
3)执行结果
对比下执行结果,看看以上分析对不对:
[GC [DefNew: 6651K->148K(921K), 0.0070106 secs] 661=51K->6292K(19456K), 0.0070246 secs] [Times: user=0.00 sys=0.00 real=0.00 secs]后面的省略
可看出GC后,新生代由6651K变为148K,但总内存占用量则几乎没有减少。
2.大对象直接进入老年代
大对象是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息,比遇到一个大对象更加坏的消息就是遇到一群朝生夕灭的短命大对象,写程序时应当避免。
-XX:PretenureSizeThreshold:大于这个设置值的对象直接在老年代分配。这样是为了避免在Eden区以及两个Survivor区之间发生大量的内存复制。仅对Serial和PartNew两款收集器有效,一般不需设置,且不能设置为3MB的形式,只能是3145728的形式。
private static final int _1MB = 1024 * 1024;
/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*-XX:PretenureSizeThreshold=3145728
*/
public static void testAllocation(){
byte[] allocation = new byte[4 * _1MB]; //直接分配在老年代中
}
3.长期存活的对象将进入老年代
既然虚拟机采用了分代收集思想来管理内存,那么内存回收时就必须能识别哪些对象存放在新生代,哪些对象存放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄计数器,其原理是:
a)如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄为1。
b)对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当她的年龄增加到一定程度(默认是15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可通过参数-XX:MaxTenuringThreshold设置。
4.动态对象年龄判断
为了能更好的适应不同程序的内存状况,虚拟机并不是永远要求对象年龄必须达到MaxTenuringHThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
5.空间分配担保
JDK6之后,在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。