在使用Java之前,我在大学使用过两年多的C++.刚接触Java时,肯定会下意识的与cpp去比较.譬如说:指针,单继承,值传递引用传递和垃圾回收.
众所周知,在写cpp时最重要的工作之一就是delete ptr
,稍有不慎就会造成内存泄漏,出现大的问题.也正因为此,对java的GC机制感到十分神奇.Java是怎么做到垃圾回收的,它的垃圾回收机制是如何实现的,Java有了GC机制之后是否就不可能发生内存泄漏了呢?
在去回答这几个问题之前,我们先想一下以下三个问题:
- Java GC 一般说不依靠程序员手动调用,那它是什么时候发生的?
- 对于cpp来说,可以通过
delete
或free
的方式释放指定区域内存,那Java GC怎么知道哪些内存是可以被释放掉的呢,换言之,Java GC是如何发现’垃圾’的? - 在发现’垃圾’之后,Java GC又是通过哪些操作/算法去’清除’掉垃圾的呢?
GC 发生时机
程序调用System.gc时可以触发
系统自身决定,一般无法精准预测
那么问题就来了,系统是怎么决定的呢?
再说这个之前,你必须要清楚的知道Jvm的内存结构.
当代主流虚拟机(Hotspot VM)的垃圾回收都采用“分代回收”的算法.“分代回收”是基于这样一个事实:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率.
Hotspot VM将堆内存划分为不同的物理区,就是“分代”思想的体现.如图所示,JVM堆内存主要由新生代、老年代、永久代构成.
简单了解了jvm内存结构之后,继续说何时发生GC.
其实在主流jvm(HotSpot Vm)中,GC分为两种:
- Minor GC: 发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快.
- Full GC: 发生在老年代的GC,出现一次Full GC尝尝伴随至少一次Minor GC.
知道了Java GC 包含两种之后,我们再看下分别他们发生的时机:
- Minor GC: 当Eden区满了之后,触发MinorGC
- Full GC:
- 调用System.gc时,系统建议执行Full GC,但是不必然执行
- 老年代空间不足
- 持久代空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
虽然Full GC发生的场景看着比Minor GC多不少,但实际它发生的频率是要大大少于Minor GC的.最重要的原因是:Java 里绝大多数对象都是’朝生夕死’,这导致老年代一般情况根本不会占多大空间.而一旦老年代或持久代空间占用过多,Full GC频繁,影响生产环境.那99%是程序有问题,需要通过排查gc日志去解决问题了
Java GC 是如何发现’垃圾’的呢?
笼统的说,Java GC 通过GC Roots将无法搜索到的对象进行标记,经过一次标记后又经过可达性分析之后的对象就会被认为是’垃圾’.
可达性分析
每次回收都需要经历两次标记过程:
第一次标记
对象进行根搜索之后,如果发现没有与GC Roots 相连接的引用链,就会被第一次标记并进行筛选.
所谓筛选,就是检查此对象是否有必要执行finalize方法,如果对象定义了该方法并且没有执行过。那么该对象就会被放入到一个队列F-Queue,随后会有一个低优先级的线程去执行这个队列里面对象的finalize方法
第二次标记
JVM 将对F-Queue队列里面的对象进行第二次标记。如果还是没有与GC Roots相连接,那么就要悲剧了.
如果对象不想被回收,那么就得在finalize方法里面拯救自己,否则,这些对象就真的会被回收.
下面有个小例子,说明了finalize
是怎么拯救自己的.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41public class GCRootsTest {
private static GCRootsTest obj;
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize方法被执行!");
obj = this;
System.out.println("finalize "+ this);
}
public static void main(String[] args) throws InterruptedException {
obj = new GCRootsTest();
System.out.println("main " + obj);
obj = null;
System.gc();
Thread.sleep(500);
/**
* 第一次调用时,通过finalize拯救了new出来的这个对象
*/
if (null != obj) {
System.out.println("1-obj还存活着!");
} else {
System.out.println("1-obj已经死了");
}
obj = null;
System.gc();
Thread.sleep(500);
/**
* 第二次调用时,gc不再调用finalize.也就无法拯救自己
*/
if (null != obj) {
System.out.println("2-obj还存活着!");
} else {
System.out.println("2-obj已经死了");
}
}
}
何为GC Roots
上面的内容多次提到了GC Roots,然而并没有说明什么对象才会被Java JVM认定为GC Roots.
- 虚拟机栈中引用的对象:比如方法里面定义这种局部变量
User user= new User();
- 方法区中静态属性引用的对象:比如
private static User user = new User();
- 方法区中常量引用的对象:比如
private static final User user = new User();
- 本地方法栈JNI(Native方法)中引用的对象
Java JVM通过哪些神奇操作清理掉’垃圾’的呢
最初是通过三种算法来实现清理’垃圾’的操作,而在实际中又会更复杂些:
- 标记-清除
- 复制
- 标记-整理
对于标记过程其实和上述的标记过程是一样的,通过GC Roots和两次标记来标记出需要被清理的垃圾.下面具体点介绍三种算法:
标记-清除(Mark-Sweep)算法
清除就只是释放掉被标记的对象占用的内存资源而已.可以看到,清除之后很容易产生内存碎片,导致无法为大对象分配空间,而很快触发下次GC
复制(Copying)算法
为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:
这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。
很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。
标记-整理(Mark-Compact)算法
为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:
当然标记-整理算法也不是没有缺点.将村话对象移动是需要耗费系统资源的.如果存活的对象多的话,无疑是不能被接受的.
Generational Collection(分代收集)算法
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为新生代(Young Generation)和老年代(Tenured Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。
目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,一般Eden空间,S0空间,S1空间的比率是8:1:1.每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。
而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。
注意,在堆区之外还有一个代就是永久代(Permanet Generation),它用来存储class类、常量、方法描述等。对永久代的回收主要回收两部分内容:废弃常量和无用的类。
总结
通过上面的三个问题和答案,其实已经解决了绝大部分有关Java GC的问题.至于一些细节的问题和没解答的问题,希望读者也能去找到答案.我这里也留几个问题:
- Java有了上述说的GC机制就万无一失,不会发生内存泄露了吗?
System.gc()
引起Full GC,那么它的使用场景是啥呢- 只介绍了GC算法,其实它们都有具体的实现.叫做垃圾收集器.那么有哪些垃圾收集器,我们该怎么根据生产场景选择合适的垃圾收集器呢?
上面算是留了三个坑,未来会在java-GC是如何做的(二)中一一解答,敬请期待.
引用
https://tech.meituan.com/2017/12/29/jvm-optimize.html
https://blog.csdn.net/wk51920/article/details/51550470
https://www.zhihu.com/question/35164211/answer/68265045
https://www.jianshu.com/p/5261a62e4d29
https://blog.csdn.net/gzu_imis/article/details/38376429
http://www.cnblogs.com/dolphin0520/p/3783345.html