深入理解JVM之垃圾回收详解


一、 垃圾收集的意义

在C++中,对象所占的内存在程序结束运行之前一直被占用,在明确释放之前不能分配给其它对象;而在Java中,当没有对象引用指向原先分配给某个对象的内存时,该内存便成为垃圾。JVM的一个系统级线程会自动释放该内存块。垃圾收集意味着程序不再需要的对象是"无用信息",这些信息将被丢弃。当一个对象不再被引用的时候,内存回收它占领的空间,以便空间被后来的新对象使用。事实上,除了释放没用的对象,垃圾收集也可以清除内存记录碎片。由于创建对象和垃圾收集器释放丢弃对象所占的内存空间,内存会出现碎片。碎片是分配给对象的内存块之间的空闲内存洞。碎片整理将所占用的堆内存移到堆的一端,JVM将整理出的内存分配给新的对象。
  垃圾收集能自动释放内存空间,减轻编程的负担。这使Java 虚拟机具有一些优点。首先,它能使编程效率提高。在没有垃圾收集机制的时候,可能要花许多时间来解决一个难懂的存储器问题。在用Java语言编程的时候,靠垃圾收集机制可大大缩短时间。其次是它保护程序的完整性, 垃圾收集是Java语言安全性策略的一个重要部份。
  垃圾收集的一个潜在的缺点是它的开销影响程序性能。Java虚拟机必须追踪运行程序中有用的对象,而且最终释放没用的对象。这一个过程需要花费处理器的时间。其次垃圾收集算法的不完备性,早先采用的某些垃圾收集算法就不能保证100%收集到所有的废弃内存。当然随着垃圾收集算法的不断改进以及软硬件运行效率的不断提升,这些问题都可以迎刃而解。
  一般来说,Java开发人员可以不重视JVM中堆内存的分配和垃圾处理收集,但是,充分理解Java的这一特性可以让我们更有效地利用资源。同时要注意finalize()方法是Java的缺省机制,有时为确保对象资源的明确释放,可以编写自己的finalize方法。

 

二、对象的判定

Java堆中存放着几乎所有的对象实例,垃圾收集器对堆中的对象进行回收前,要先确定这些对象是否还有用,判定对象是否为垃圾对象有如下算法:

1、  引用计数法

引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象(不是引用)都有一个引用计数。对于一个对象 A,只要有任何一个对象引用了 A,则A 的引用计数器就加 1,当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,则对象 A就不可能再被使用。

引用计数法实现简单,判定效率也很高。但是这个算法有明显的缺陷,对于循环引用的情况下,循环引用的对象就不会被回收。如A=B,B=A, 此时,对象 A 和对象B 的引用计数器都不为 0。但是在系统中却不存在任何第 3 个对象引用了 A 或 B。也就是说,A 和 B 是应该被回收的垃圾对象,但由于垃圾对象间相互引用,从而使垃圾回收器无法识别,引起内存泄漏。

2、  根搜索算法

这种算法的基本思路是通过一系列名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,就证明此对象是不可用的。在Java语言里,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中的常量引用的对象。
  • 本地方法栈中JNI(Native方法)的引用对象。

三、引用的类型

无论是通过引用计数算法判断对象的引用数量,还是通过根搜索算法判断对象的引用链是否可达,判断对象是否存活都与“引用有关”。一般的引用类型分为强引用( Strong Reference)、软引用( Soft Reference)、弱引用( Weak Reference)、虚引用( Phantom Reference)四种,这四种引用强度依次逐渐减弱。

1、强引用就是指在程序代码之中普遍存在的,类似“Objectobj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。如果不使用时,可以赋值obj=null,显示的设置ob为null,则gc认为该对象不存在引用,这时候就可以回收此对象。

强引用在实际应用中非常常见,集合类中的clear()方法就用到了强引用,下面看一下hashmapclear()方法的源代码:

transient Node<K,V>[] table;
public void clear() {
        Node<K,V>[] tab;
        modCount++;
        if ((tab = table) != null && size > 0) {
            size = 0;
            for (int i = 0; i < tab.length; ++i)
                tab[i] = null;
        }
}

         HashMap类中定义了一个table数组,在调用clear()方法清空数组时可以看到为每个数组内容赋值为null。不同于table=null,强引用仍然存在,避免在其他方法用到数组时重新的内存分配。使用如clear()方法中释放内存的方法对数组中存放的引用类型特别适用,这样就可以及时释放内存

 

2、 软引用用来描述一些还有用,但并非必需的对象。对于软引用关联着的对象,如果内存充足,则垃圾回收器不会回收该对象,如果内存不够了,就会回收这些对象的内存。在 JDK 1.2 之后,提供了 SoftReference 类来实现软引用。软引用可用来实现内存敏感的高速缓存。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。

       软引用主要应用于内存敏感的高速缓存,在android系统中经常使用到。一般情况下,Android应用会用到大量的默认图片,这些图片很多地方会用到。如果每次都去读取图片,由于读取文件需要硬件操作,速度较慢,会导致性能较低。所以我们考虑将图片缓存起来,需要的时候直接从内存中读取。但是,由于图片占用内存空间比较大,缓存很多图片需要很多的内存,就可能比较容易发生OutOfMemory异常。这时,我们可以考虑使用软引用技术来避免这个问题发生。SoftReference可以解决oom的问题,每一个对象通过软引用进行实例化,这个对象就以cache的形式保存起来,当再次调用这个对象时,那么直接通过软引用中的get()方法,就可以得到对象中中的资源数据,这样就没必要再次进行读取了,直接从cache中就可以读取得到,当内存将要发生OOM的时候,GC会迅速把所有的软引用清除,防止oom发生。

下面看一段简单的代码:


public class BitMapManager {
	private Map<String, SoftReference<Bitmap>> imageCache = new HashMap<String, SoftReference<Bitmap>>();

	//保存Bitmap的软引用到HashMap
	public void saveBitmapToCache(String path) {
		// 强引用的Bitmap对象
		Bitmap bitmap = BitmapFactory.decodeFile(path);
		// 软引用的Bitmap对象
		SoftReference<Bitmap> softBitmap = new SoftReference<Bitmap>(bitmap);
		// 添加该对象到Map中使其缓存
		imageCache.put(path, softBitmap);
		// 使用完后手动将位图对象置null
        bitmap = null;
	}

	public Bitmap getBitmapByPath(String path) {

		// 从缓存中取软引用的Bitmap对象
		SoftReference<Bitmap> softBitmap = imageCache.get(path);
		// 判断是否存在软引用
		if (softBitmap == null) {
			return null;
		}
		// 取出Bitmap对象,如果由于内存不足Bitmap被回收,将取得空
		Bitmap bitmap = softBitmap.get();
		return bitmap;
	}
}


3、弱引用也是用来描述非必需对象的,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在jdk1.2后,用WeakReference类来实现弱引用。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。下面看一个简单例子:

import java.lang.ref.WeakReference;
public class WeakReferenceTest {
	public static void main(String[] args) {
        WeakReference<String> sr ;
        sr= new WeakReference<String>(new String("弱引用"));
        System.out.println(sr.get());//输出弱引用
        System.gc();                //通知JVM的gc进行垃圾回收
        System.out.println(sr.get());//输出null
        //如果存在强引用同时与之关联,则进行垃圾回收时也不会回收该对象
        String str = new String (new String("弱引用"));
        sr = new WeakReference<String>(str);
        System.gc();                //通知JVM的gc进行垃圾回收
        System.out.println(sr.get()); //输出弱引用         
    }
}

4、 虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。在 java 中用 java.lang.ref.PhantomReference 类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。要注意的是,虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之 关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。下面看一个简单的例子:

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceTest {
	public static void main(String[] args) {
        ReferenceQueue<String> queue = new ReferenceQueue<String>();
        PhantomReference<String> pr = new PhantomReference<String>(new String("虚引用"), queue);
        System.out.println(pr.get());//输出null
    }
}

四、垃圾收集算法

1、标记-清除算法(Mark-Sweep

标记-清除算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,标记过程其实就是根搜索算法判断对象是否存活。该算法主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记-清除算法的执行过程如下图所示:


图一、“标记-清除”算法示意图

2、复制算法(Coping

复制算法是把内存分成大小相等的两块,每次使用其中一块,当垃圾回收的时候,把存活的对象复制到另一块上,然后把这块内存整个清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时就不用考虑内存碎片等复杂情况,实现简单,运行高效。这种方法适用于短生存期的对象,持续复制长生存期的对象则导致效率降低。复制算法的执行过程如下图所示:


图二、复制算法示意图

3、 标记-整理算法(Mark-Compact

   复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。老年代更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。标记-整理算法是一种老年代的回收算法,该算法与标记-清除算法的标记过程一样,但是之后不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,其性价比比较高。该算法示意图如下图所示:

 

图三、“标记-整理”算法示意图

4、 分代收集算法

       根据垃圾回收对象的特性,不同阶段最优的方式是使用合适的算法用于本阶段的垃圾回收,分代算法即是基于这种思想,它将内存区间根据对象的特点分成几块,根据每块内存区间的特点,使用不同的回收算法,以提高垃圾回收的效率。一般把java堆分为新生代和老年代,新生代采用复制算法,老年代采用标记-整理算法。

 

五、垃圾收集器

      垃圾收集算法是内存回收的理论基础,而垃圾收集器就是内存回收的具体实现。下面介绍一下HotSpotJDK 7)虚拟机提供的几种垃圾收集器,用户可以根据自己的需求组合出各个年代使用的收集器。HotSpot的虚拟机的垃圾收集器如下图所示:


图四、 HotSpot虚拟机的垃圾收集器

1、Serial收集器

      这个收集器是一个单线程收集器,使用复制收集算法,收集时会暂停所有工作线程,直到收集结束,虚拟机运行在Client模式时的默认新生代收集器。优点是:简单高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器没有现成交互的开销,做垃圾收集可以获得最高的单线程收集效率。如下图:


图五、Serial/SerialOld收集器运行示意图

 

2、 ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括算法、STW、对象分配规则、回收策略等都与Serial收集器一样。ParNew收集器是许多运行在server模式下的虚拟机中首选的新生代收集器,一个重要原因是在除了serial收集器外,目前只有它能与CMS收集器配合使用。ParNew收集器在单CPU环境中不比Serial效果好,甚至可能更差,两个CPU也不一定跑的过,但随着CPU数量的增加,性能会逐步增加。ParNew收集器的工作过程如下:


 

图六、ParNew/SerialOld收集器运行示意图

 

3、 Parallel Scavenge 收集器

ParallelScavenge 收集器是一个新生代收集器,它是使用复制算法的并行多线程的收集器。

ParallelScavenge的特点是它的关注点与其他收集器不同,CMS等收集器的关注点尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码时间与CPU总消耗时间的比值。吞吐量=运行用户代码时间/运行用户代码时间+垃圾收集时间。

高吞吐量和停顿时间短的策略相比,主要强调高效率地利用CPU时间,任务更快完成,适用于后台运算而不需要太多交互的任务;而后者强调用户交互体验。 

4、 Serial Old收集器

单线程收集器,是Serial收集器老年代版本,使用标记-整理算法,主要用在client模式下,如果在Server模式下,它主要有两大用途:一种用途是在JDK1.5以及之前的版本中与Parallel Scavenge 收集器搭配使用;另一用途是作为CMS收集器的后备预案,在并发手机发生CMF时使用。

5、 Parallel Old 收集器

Parallel OldParallelScavenge收集器的老年代版本,使用多线程和标记-整理算法。Parallel Old收集器的工作过程如下图:


图七、Parallel Scavenge/Parallel Old收集器运行示意图

6、 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于标记-清除算法实现的,整个收集过程大致分为4个步骤:

1)初始标记(CMS initial mark):标记GC Roots能直接关联到的对象,速度很快。

2)并发标记(CMS concurrent mark):进行GC ROOTS 根搜索算法阶段,会判定对象是否存活

3)重新标记(CMS remark):修正并发标记期间因用户程序继续运行而导致标记发生改变的那一部分对象的标记记录。

4)并发清除(CMS concurrent sweep

其中初始标记和重新标记两个阶段仍然需要Stop-The-World,整个过程中耗时最长的并发标记和并发清除过程中收集器都可以和用户线程一起工作。所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS收集器的优点:并发收集、低停顿,但是CMS还远远达不到完美,器主要有三个显著缺点:

1CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4

2CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure“,失败后而导致另一次Full  GC的产生。

3)最后一个缺点,CMS是基于标记-清除算法实现的收集器,使用标记-清除算法收集后,会产生大量碎片。空间碎片太多时,将会给对象分配带来很多麻烦,比如说大对象,内存空间找不到连续的空间来分配不得不提前触发一次Full  GC

 

图八、CMS收集器运行示意图

7、 G1收集器

G1收集器是一款面向服务端应用的垃圾收集器,用于替换CMS收集器。与其他GC收集器相比,G1具有以下几个特点:

1)并行与并发:充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿时间,在收集过程中用并发的方式让Java线程继续执行

2)分代收集:仍然有分代的概念,不需要其他收集器配合能独立管理整个GC堆,能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的就对象以获得更好的收集效果。

3)空间整合:G1从整体看,是基于标记-整理算法实现的,从局部(两个Region之间)看是基于复制算法的。在运行期间不会产生内存碎片,有利于程序长时间运行分配大对象时不会因为无法找到连续内存而提前出发下一次GC

4)可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型。

 

G1收集器运作大致可以分为以下几个步骤:

1)初始标记:只标记GC Roots能直接关联到的对象,并且修改TAMSNext Topat Mark Start)值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。此阶段需要停顿用户线程。

2)并发标记:从GC Roots开始对堆中对象进行可达性分析,找出存活对象;耗时较长,可与用户线程并发执行。

3)最终标记:修正在并发标记期间有变动的标记记录,这阶段需要停顿线程,可以并行执行。

4)筛选回收:对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间制定回收计划,进行垃圾回收。


图九、G1收集器运行示意图

 

  • 6
    点赞
  • 58
    收藏
    觉得还不错? 一键收藏
  • 14
    评论
评论 14
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值