关于垃圾回收
关于垃圾收集有三个经典问题:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
垃圾收集,不是Java语言的伴生产物。
Java 和 C++语言的区别,就在于垃圾收集技术和内存动态分配上;
C语言没有垃圾收集技术,需要我们手动的收集。
垃圾收集机制是Java的招牌能力,极大地提高了开发效率。
什么是垃圾
1、垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
2、如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
为什么需要GC
1、一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完。
2、除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象(尤其是一些大的对象)。
GC 的作用区域
1、垃圾收集器可以对年轻代回收(Minor GC),也可以对老年代回收(Major GC),甚至是全栈和方法区的回收(Full GC),其中,Java堆是垃圾收集器的工作重点。
2、从次数上讲:
- 频繁收集Young区
- 较少收集Old区
- 基本不收集方法区
3、GC主要关注于方法区和堆中的垃圾收集
垃圾回收相关算法
确定哪些是垃圾——标记阶段
清理垃圾——清除阶段
标记阶段
标记阶段的目的:判断对象是否存活。当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
方法:引用计数器算法和可达性分析算法
引用计数算法
Java不使用此方法
1、引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
2、对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;
当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
引用计数算法特点
优点:
1、实现简单,垃圾对象便于辨识;
2、判定效率高,回收没有延迟性。
缺点:
1、它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
2、每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
3、引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。
循环引用举例:
当p的指针断开的时候,内部的引用形成一个循环,这就是循环引用。
p指向null,后面三个对象都不再使用了,但是引用计数都不是0,就没法GC回收。从而造成内存泄漏。
内存泄露:这个对象不再使用,但是GC没法回收。
可达性分析算法(根搜索算法、追踪性垃圾收集)
该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
可达性分析算法基本思路
1、可达性分析算法是以根对象集合(GCRoots)为起始点,"GC Roots”根集合就是一组必须活跃的引用,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
2、使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
3、如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
4、在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。所以可达性分析算法标记的是存活对象
GC Roots可以是哪些元素?
GC Roots,就是一组必须活跃的引用。
1、虚拟机栈中引用的对象,比如:各个线程被调用的方法中使用到的参数、局部变量等。
2、本地方法栈内JNI(通常说的本地方法)引用的对象
3、方法区中类静态属性引用的对象,比如:Java类的引用类型静态变量 (JDK7之后是在堆里面)
4、方法区中常量引用的对象,比如:字符串常量池(StringTable)里的引用 (JDK7之后字符串常量池在堆中)
5、所有被同步锁synchronized持有的对象(synchronize持有的对象要是被销毁,同步就失效了)
总之,如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。
栈、方法区、常量池 结构引用堆空间里面的对象,图里面蓝色的,可达对象。红色不可达,是垃圾。
可达性分析算法的注意事项
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
这点也是导致GC进行时必须“Stop The World”的一个重要原因。
对象的 finalization 机制
对象销毁前的回调函数:finalize()
1、Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
2、当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。
3、finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
Object 类中 finalize() 源码:
// 等待被重写
protected void finalize() throws Throwable { }
及时重写了这个方法,永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。
因为:
1、调用finalize()时可能会导致对象复活。
2、finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
3、因为优先级比较低,即使主动调用该方法,也不会因此就直接进行回收。
清除阶段
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
目前在JVM中比较常见的三种垃圾收集算法是:
1、标记-清除算法(Mark-Sweep)
2、复制算法(Copying)
3、标记-压缩算法(Mark-Compact)
标记-清除算法(Mark-Sweep)
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
STW:要把用户线程停下来,因为用户线程运行就又会产生垃圾,要保持一致性,就将用户线程先停下来。
1、标记:Collector从引用根节点开始遍历,标记所有被引用的对象。
- 一般是在对象的Header中记录为可达对象。标记阶段标记的是可达对象,非垃圾对象,非可达对象也没法标记。
- 注意:标记的是引用的对象,不是垃圾!!
2、清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
标记-清除算法的缺点
1、标记清除算法的效率不算高 (需要进行遍历)
2、这种方式清理出来的空闲内存是不连续的,产生内碎片,需要维护一个空闲列表
3、在进行GC的时候,需要停止整个应用程序,用户体验较差(这个问题是普遍问题,是都有的,原因在上面)。
复制算法
将活着的内存空间分为两块,每次只使用其中一块,没有标记的过程,把可达的对象,直接复制到内存大小一样的另外一个区域中,而且是连续存放, 复制完成后,A区里面的对象就没有用了,
下一次从B区复制到A区,这样交换使用。
类似于新生代S0和S1。
复制算法的优缺点
优点:
1、没有标记和清除过程,实现简单,运行高效;
2、复制过去以后保证空间的连续性,不会出现“碎片”问题。
缺点 :
1、此算法的缺点也是很明显的,就是需要两倍的内存空间。
2、复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
复制算法的应用场景
即特别适合垃圾对象很多,存活对象很少的场景;例如:Young区的Survivor0和Survivor1区
标记-压缩算法(标记-清除-压缩(Mark-Sweep-Compact)算法)
标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。
标记-压缩(Mark-Compact)算法由此诞生。
标记-压缩算法的执行流程
1、第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
2、第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。
标记-压缩算法的优缺点
优点:
1、消除了标记-清除算法当中缺点,不分散,无碎片。我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
2、消除了复制算法当中,内存减半的高额代价。
缺点:
1、效率低,标记-整理算法的效率要低于复制算法,因为有碎片的整理过程
2、移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
3、暂停时间长,移动中需要全程暂停用户应用程序,时间要长一些。即:STW
标记-压缩算法与标记-清除算法的比较
1、标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。
2、二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
3、可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。
如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销(标记-清除算法需要空闲列表)。
对比三种清除阶段的算法
分层收集算法
不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。
一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
目前几乎所有的GC都采用分代收集算法执行垃圾回收的。
在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。
1、年轻代(Young Gen)
- 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
- 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。
而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
2、老年代(Tenured Gen)
- 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
- 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
内存溢出(OOM)、内存泄露
内存溢出(OOM)
1、造成垃圾回收跟不上内存消耗的速度,容易出现OOM的情况。
2、在抛出OutofMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间,供应用程序继续使用。Full GC后仍然不行,OOM。
3、当然,也不是在任何情况下垃圾收集器都会被触发的
比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接抛出OutofMemoryError。
内存溢出(OOM)原因
Java虚拟机的堆内存不够、堆内存设置不够。
1、内存泄漏可能导致内存溢出;
2、堆的大小不合理,我们可以通过参数-Xms 、-Xmx来调整。
内存泄漏(Memory Leak)
1、只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。
2、但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。
一些静态变量和类的生命周期一样,所以不要过多地使用静态变量。
3、尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutofMemory异常,导致程序崩溃。
示例
可能导致第二种情况:
单例模式
单例的生命周期和应用程序是一样长的,所以在单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
一些提供close()的资源未关闭,导致内存泄漏
数据库连接 dataSourse.getConnection(),网络连接socket和io连接必须手动close,否则是不能被回收的。