在Java中我们new对象的时候是放在堆内存当中。当对象不再使用的时候,就需要被回收掉,否则就会一直占据着内存空间导致无法为新的对象分配空间。幸运的是Java是自带垃圾回收机制的,一般情况下不需要我们手动去回收,而是有个专门的线程,也就是GC线程来专门回收那些没有用的对象。
GC全称是Garbage Collection,直译就是垃圾收集。GC更多的是发生在堆内存中,方法区/元空间也是有需要回收的,但不像堆内存当中那么频繁。因此堆是重点的关注对象。
关于GC,可以从三个方面来思考一下:
1—什么需要回收?
public static void main(String[] args) {
List<Object> list = new LinkedList<>();
while (true) {
list.add(new Object());
}
}
运行上面的代码,经过一段时间之后,控制台就会输出如下日志:
日志
从上图中我们可以看到Heap,指的就是堆。Metaspace就是元空间。Heap分为PSYoungGen(新生代)和ParOldGen(老年代)。其中PSYoungGen又划分为了eden空间,from survivor空间,to survivor空间三个部分。上图中在这三个空间后面的是每个空间的大小。可以看出三个空间的比例为Eden:From:To = 8:1:1。而PSYoungGen和ParOldGen的比例大约是1:2。这些都是默认的比例,其实可以JVM中进行配置来更改大小的:
-Xms 堆内存初始内存分配的大小
-Xmx 堆内存可被分配空间的最大上限
-Xmn 设置堆内存新生代的初始大小和最大
-XX:SurvivorRatio = 8 表示eden和survivor的比值 8:1:1 (如果设置为2则比例为2:1:1,对应的大小则为:5120K:2560K:2560K)
另外从图片中可以看到在Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded之上在疯狂的进行FullGC,可是内存还是没有被释放。这其中就包含了一个问题就是:怎么判断一个对象是垃圾?
(1)在JVM早期的时候使用的是引用计数法。所谓引用计数就是假如a引用了b,那么就在b上记录一次,假如为1。c也引用了b,就在b上再增加一次,那么此时b的引用数就变成了2。反之失去一个引用就减去1。当引用数为0的时候,b就变成了一个可回收的对象。但引用计数存在一个很大的缺点就是相互引用,相互引用会导致无法回收。
(2)可达性分析。可达性分析首先需要确定GCRoot。GCRoot是一些对象,包括有类静态属性的对象,常量的对象,虚拟机栈中(本地方法表)的对象,本地方法native栈中的对象。
public class Test {
static Object root1 = new Object(); // 静态属性的对象
public final static Object root2 = new Object(); //常量的对象
public void root() {
Object root3 = new Object(); //虚拟机栈中的对象 至少在方法执行完之前是作为gc root
}
}
与GC有着密不可分的还有经常提到的四种引用:强引用(StrongRefrence),软引用(SoftRefrence),弱引用(WeakRefrence),虚引用(PhantomRefrence)。
StrongRefrence:所谓强引用就是指直接通过new关键字创建的对象。只要与GC root产生关联,即时产生OOM也不会被回收。
SoftRefrence:软引用的特点是当程序即将发生OOM的时候,会把软引用的对象给回收掉。
WeakRefrence:弱引用指的是当GC线程回收垃圾时,如果只有弱引用存在,那么GC线程就会进行回收。
PhantomRefrence:虚引用则很容易被回收,只是在回收的时候会发出通知。
2—什么时间回收?
当新生代Eden区空间不够了就会触发一次普通GC,当老年代空间不够了则会触发Full GC
3—怎么回收?
关于如何回收的问题需要先了解垃圾一下回收算法。
(1)复制算法。特点是实现简单,运行高效,没有内存碎片,需要复制内存缺点是内存利用率只有一半。
复制算法
假设上图中的整个内存是20M,分成两份之后就是各10M。在需要回收的时候会把不可回收的对象从左边复制到右边,并把左边的整个区域格式化。反之亦然。这种复制算法是应用在PSYoungGen区。说的具体一点就是发生在From区和To区。这也就是为什么From区和To区的比例总是1:1。至于为什么在From区和To区采用复制算法是因为程序中绝大多数的对象是不需要考虑回收的,它们都是朝生夕死的。那么只需要拿出很少一部分内存采用复制算法。假设整个PSYoungGen的大小是100M,如果整个PSYoungGen采用复制算法的话,那么需要被分为两个50M。假如红色方块数量较多,那么来回复制的话效率就比较低了。按照PSYoungGen区的默认比例8:1:1,那么Eden区占据80M,From和To分别占据10M。这样复制的话也只是在From或者To区进行复制,并且多数情况也只是复制From和To区的一部分,还不到10M,那么在这么小的一块区域采用复制算法的话,效率就不会有什么影响了。
(2)标记清除。特点是利用率百分之百,不需要复制,缺点是有内存碎片。
标记清除算法
具体的清除过程就是在可回收的对象上打上标记,GC回收之后就是下面的结果。但是可以看到内存不连续,有很多碎片。当需要分配一个较大的对象,例如一个需要占据5个小方块的对象时就无法分配了。
(3)标记整理。特点是利用率百分之百,没有碎片,缺点是需要复制。
标记整理算法
这种算法相较于标记清除就是多了一个复制的过程。在清除的时候会把不可回收的对象整齐的排列起来。
其次需要了解一下堆内存的分配策略:
堆内存分配
(1)对象优先分配在Eden区:按照上图来说,当创建一个小于8M的对象时,会首先分配在Eden区。
(2)大对象直接进入老年代:假设Eden区已经存在了一个6M的对象A,当我再new一个3M的对象B时,此时需要的空间为6+3=9大于Eden区的大小了,From和To同样放不下,这是就会直接把B分配到老年代。也就是所谓的空间担保。
(3)长期存活的对象将进入老年代:假设Eden区现在存在一个0.5M的对象A,此刻发生了一次普通GC,但是A没有被回收掉,那么A就会从Eden区进入From区,同时A的对象头上会存放一个age来标记自己的年龄。如果再发生一次普通GC就从From移动到To区,并且年龄加1。以后每发生一次普通GC就会在From和To中间来回的移动(因为采用的是复制算法)。假如A有幸在age=15的时候(一般来讲是15次)还没有被回收,那么就会把A放入老年代。
(4)动态年龄判断:假设Eden区现在存在一个大小为0.7M的对象A,From区存在一个大小为0.5M,age=6的对象。此刻发生了一次普通GC且没被回收,就需要把对象A从Eden区移动到From区。但是From区的大小只有1M,放不下A和B了,A和B就会被同时放入老年区。尽管B的年龄只有6,A的年龄只有1。这种也是基于空间担保。
最后来了解一下收集器:
可以看到收集器分为单线程收集器和多线程收集器。
单线程收集器:
Serial 新生代 复制算法
SerialOld 老年代 标记整理算法
单线程清除过程
由于每次进行GC都需要暂停所有用户线程,如果垃圾回收时间过长,则会造成程序卡顿。
多线程收集器:
ParNew 新生代 复制算法 并行
CMS 老年代 标记清除算法 并行 并发
ParallelScavenge 新生代 复制算法 并行
ParallelOld 老年代 标记整理算法 并行
普通多线程清除过程
和上图中的区别就是多个GC线程并行收集。清理速度肯定是快于单线程收集器的。
CMS清理过程
这个是CMS收集器清理的示意图。首先会做一次初始标记,然后跟随用户线程一起并发标记,随后暂停用户线程去重新标记,接着并发清理。虽然也经历了两次暂停,但相比暂停去清理的话,初始标记和重新标记暂停的时间是短的多的。另外由于CMS采用的是标记清除算法,速度快。所以CMS多应用于移动互联网。由于并发收集的时候,用户线程也在运行,所以当收集结束的时候,其他的线程可能会产生浮动垃圾,那么就需要下一次GC的时候去进行回收。
G1 跨新生代和老年代 标记整理+化整为零
jdk1.7才引入,采用分区回收的思维,把新生代以及新生代里的Eden,From,To和老年代全部打散,所以才可以在新生代和老年代中都是用同一个垃圾收集器。
网友评论