GC原理

GC原理

本文中的名词均可查看深入JVM虚拟机进行理解

在内存中,涉及到“内存分配”和“内存释放”两个概念,当我们创建一个对象时,那么就会对该对象进行内存分配,当对象不再使用的时候,如果不对对象进行回收,那么就会一直占用着内存,造成资源浪费, 因此就需要对对象进行回收,也就是内存释放。那么怎么判断我们的对象是否可以回收呢?

引用计数法(reference counting)

当我们创建一个对象之后,每有一个业务使用该对象那么该对象的引用就+1,每个也是使用完,该对象的引用就-1,如果一个对象的引用计数降为0了,就说明该对象不再使用可以回收了,就可以随时在方便的时候去释放该对象占用的内存(注意:不是当引用计数降为0之后就立刻进行回收,出于效率考虑,系统总是会等一批对象一起处理,这样更加高效。)

但是,如果业务变得更加复杂了,可能各个对象之间有了互相引用的关系,这样就会出现循环依赖的问题,和死锁有点相似,这样两个对象的引用都不可能降为0,也就无法被回收。这种情况在计算机中被称为“内存泄漏”,该释放的没释放,该回收的没回收。
当依赖关系更加复杂的时候,计算机的内存资源很可能用满,导致“内存溢出”。

那怎么解决这种情况呢,“引用追踪(reference tracing)” 。JVM使用的各种垃圾收集算法都是基于引用追踪方式的算法。

标记清除算法(Mark and Sweep)

为了遍历所有对象,JVM明确定义了什么是对象的可达性(reachability)。
有一类很明确的对象,称为垃圾收集根元素(Garbage Collection Roots),包括:

  • 局部变量(Loacl variables)
  • 活动线程(Active threads)
  • 静态域(Static fields)
  • JNI引用(JNI references)
  • 其他对象

JVM使用标记-清除算法(Mark and Sweep algorithm),来跟踪所有可达对象(即存活对象),确保所有不可达对象(non-reachable objects)占用的内存都能被重用。包含两步:

  • Marking(标记):遍历所有的可达对象,并在本地内存(native)中分门别类记下。
  • Sweeping(清除):这一步保证了,不可达对象所占用的内存,在之后进行内存分配时可以重用。

JVM中包含了多种GC算法,如Parallel Scavenge(并行清除),Parallel Mark+Copy(并行标记+复制)一级CMS,他们在实现上略有不同,但理论上都采用了以上两个步骤。
标记清除算法最重要的优势,就是不再因为循环引用而导致内存泄漏。
而这种处理方式不好的地方在于:垃圾收集过程中,需要暂停应用程序的所有线程。假如不暂停,则对象间的引用关系会一直不停地发生变化,那样就没法进行统计了。这种情况叫做STW停顿(Stop The World,全线暂停),让应用程序暂时停止,让JVM进行内存清理工作。有很多原因会触发STW停顿,其中垃圾收集是最主要的原因。

碎片整理

执行JVM执行清除之后,内存中就会产生一些零散的空位置/不连续的内存空间,会引发两个问题:

  • 写入操作越来越耗时,因为寻找一块足够大的空间内存会变得困难(内存中没有一整片的空地方);
  • 在创建新对象时,JVM在连续的快中分配内存。如果碎片问题很严重,直至没有空闲片段能存放下新创建的对象,就会发生内存分配错误(allocation error)。

JVM必须确保碎片问题不失控。异常在垃圾收集过程中,不仅仅是标记和清除,还需要执行“内存碎片整理”过程,这个过程让所有可达对象依次排列,以消除(或减少)碎片。
GC原理_第1张图片

JVM中的引用是一个抽象的概念,如果GC移动某个对象,就会修改(栈和堆中)所有指向该对象的引用。
移动/拷贝/提升/压缩 一般来说是一个STW的过程,所以修改对象引用是一个安全的行为。但要是更新所有引用,可能会影响应用程序性能。

分代假设

由于执行垃圾收集需要STW,对象越多则收集所有垃圾消耗的时间就越长,可不可以只处理一个较小的内存区域呢?为了探究这种可能性,研究人员发现,程序中的大多数可回收的内存可归为两类:

  • 大部分对象很快就不再使用,生命周期较短;
  • 还有一部分不会立即无用,但也不会持续太长时间;

这些观测形成了弱代假设(Weak Generational Hypothesis),即我们可以根据对象的不同特点,把对象进行分类。基于这一假设,VM中的内存被分为年轻代(Yong Generation)和老年代(Old Generation)。老年代有时候也称为年老区(Tenured)。
GC原理_第2张图片
拆分为这样两个可清理的单独区域后,就可以根据对象的不同特点,允许采用不同的算法来大幅度提高GC性能。
但这种方法也会有问题,例如,在不同分代中的对象可能会互相引用,在手机某一个分代时就会成为“事实上的”GC root。
分代假设并不适用于所有程序,因为分代GC算法专门针对“要么死得快”,“否则活得长”这类特征的对象来进行优化,此时JVM管理那种存活时间半长不长的对象就显得非常尴尬了。

内存池划分

堆内存的划分与上边是类似的,但是各个内存池的垃圾收集方式是不同的。不同的GC算法在实现细节上可能会有所不同。
GC原理_第3张图片

新生代(Eden Space)

Eden Space又叫伊甸区,用来分配新创建的对象,通常会有多个线程同时创建对象,这时就产生了并发占用内存的情况,可能会出现正在给对象A分配内存,指针还没来得及修改,对象B又使用了原来的指针分配内存的情况,为了解决这种问题,有两种方案:

  • 一种是对分配内存空间的动作进行同步处理,实际上虚拟机是采用CAS配上失败重试的机制保证更新操作的原子性;
  • 另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程需要分配内存,就在哪个线程的本地线程缓冲区中分配,只有本地缓冲区用完了,分配新的缓冲区的时候才需要同步锁定。虚拟机是否启用TLAB,可以通过-XX:+/-UseTLAB参数来设定

如果TLAB中没有足够的内存空间, 就会在共享Eden区之中分配,如果共享Eden区中也没有足够的空间,就会触发一次年轻代GC来释放内存空间,如果GC后Eden区中依然没有足够的空闲内存,则对象会被分配到老年代空间(Old Generation)。

当Eden区进行垃圾收集时,GC将所有从root可达的对象过一遍,并标记为存活对象。
由于对象间可能存在跨代引用,所以需要一种方法来标记从其他分代中指向Eden的所有引用,这样做又会将所有分代中的引用全部扫描一遍。JVM在实现的时候采用了一些绝招:卡片标记(Card-Marking)

标记阶段完成后,Eden区中所有存活的对象,都会被复制到存活区(Survivor spaces)中,整个Eden区就可以被认为是空的,然后就能用来分配新对象,这种方式称为“标记-复制(Mark-Copy)”:存活的对象被标记,然后复制到另一个存活区中(复制而不是移动)

为什么是复制而不是移动?从清楚效率角度来讲,复制过去后,只需要清空原有的那块内存区域,而移动的话,需要赋值新的地址空间再清除那一小块地址空间。
**

存活区(Survivor Spaces)

Eden区旁边的两个是存活区,称为from空间to空间。任意一个时刻总有一个存活区是空的。

空的那个存活区用于在下一次年轻代GC时存放收集的对象。年轻代中所有存活的对象(包括Eden区和非空的那个“from”存活区)都会被复制到“to”存活区。GC完成后,“to”区有对象,“from”区没有对象,两者的角色进行交换,“to”变成“from”,“from”变成“to”。
GC原理_第4张图片
存活的对象会在两个存活区之间复制多次,直到对象的存活时间达到一定的阈值。分代理论假设,存活超过一定时间的对象很可能会继续存活更长时间。
这类“年老”的对象因此被提升(promoted)到老年代,GC模块跟踪记录每个存活区对象存活的次数。每次分代GC完成后,存活对象的年龄就会增长,当年龄超过提升阈值(tenuring threshold),就会被提升到老年代。

提升阈值,可以通过-XX:+MaxTenuringThreshold来指定上限,如果设置-XX:+MaxTenuringThreshold=0,则GC存活对象不在存活区之间复制,直接提升至老年代。现代JVM中这个阈值默认设置为15个GC周期。这也是HotSpot JVM允许的最大值。

如果存活区空间不够存放年轻代中的存活对象,提升(propmoted)也可能会更早的进行

老年代(Old Gen)

老年代内存会更大,里面的对象是垃圾的概率也更小。
老年代发生GC的概率比年轻代小很多。同时,因为预期老年代中的对象大部分是存活的,所以不再使用标记-复制算法,而是采用移动对象的方式来实现最小化内存碎片 。老年代空间的清理算法通常建立在不同的基础上。原则上会执行以下这些步骤:

  • 通过标志位(marked bit)标记所有通过GC Roots可达的对象
  • 删除所有不可达的对象
  • 整理老年代空间中的内容,方法是将所有的存活对象复制,从老年代空间开始的地方依次存放。
老年代GC必须明确的进行整理,以避免内存碎片过多。

永久代(Perm Gen)

在Java8之前有一个特殊的空间,称为“永久代”(Permanent Generation)。这是存储元数据(metadata)的地方,比如class信息等。此外这个区域也保存其他数据和信息,包括内部化的字符串(internalized strings)等。
实际上这给Java开发者造成了很多麻烦,因为很难去计算这块区域到底需要占用多少内存空间,永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小。预测失败将会导致java.lang.OutOfMemoryError: Permgen space这种形式的错误。除非OOM确实是内存泄漏导致的,否则就只能增加Perm gen的大小-XX:MaxPermSize=256m

元数据区(Metaspace)

既然估算元数据所需空间那么复杂,Java8直接删除了永久代(Permanent Generation),改用Metaspace。
Java中很多杂七杂八的东西都放到普通的堆内存中,像类定义之类的会被加载到元数据区中,元数据区位于本地内存(native memory),不再影响到普通的Java对象。默认情况下,Metaspace的大小只受限于Java进程可用的本地内存。这样程序就不会因为多加载几个类/Jar包就导致OOM。但是这种不受限制的空间也不是没有代价的,如果Metaspace失控,则可能会导致严重影响程序性能的内存交换(swapping),或者导致本地内存分配失败。

MetaSpace默认为无限大,可以通过-XX:MaxMetaspace=256m修改

垃圾收集

各种垃圾收集器的实现细节虽然各有不同,但总体而言,垃圾收集器都专注于两件事:

  • 查找所有存活的对象
  • 抛弃其他对象,即无用对象

标记可达对象(Marking Reachable Objects)

现代JVM中所有的GC算法,第一步都是查找出所有存活的对象。下面的示意图对此作了最好的诠释。
GC原理_第5张图片
通常,可作为GC Roots的包括:

  • 当前正在执行的方法里的局部变量和输入参数
  • 活动线程(Active Threads)
  • 内存中所有的类的静态字段(static  field)
  • JNI引用

从GC根元素开始扫描,到直接引用,以及其他对象(通过对象的属性域)。所有GC访问到的对象都被标记为存活对象。未标记到的对象,则是不可达的对象,GC会在接下来的阶段中清除他们。

注意:在标记阶段,需要暂停所有的应用线程,以遍历所对象的引用关系。因为不暂停就没法跟踪一直在变化的引用关系图。这中情景被称为Stop The World,而可以安全暂停线程的点叫做时间点(safe point),然后JVM就可以专心的执行清理工作,安全点可能有多种因素触发。

此阶段暂停的时间,与堆内存大小,对象的总数没有直接关系,而是由存活对象的数量来决定的,所以增加堆内存大小并不会直接影响标记阶段占用的时间。
标记阶段完成后,GC就要进行下一阶段,删除不可达对象。

删除不可达对象

各种GC算法在删除不可达对象时略有不同,但总体分为三类:清除(sweeping)、整理(compacting)和复制(copying)

清除

Mark and Sweep(标记-清除)算法的概念非常简单,直接忽略所有垃圾。也就是在标记阶段完成之后,所有不可达对象占用的内存空间,都被认为是空闲的,因此可以用来分配新对象。

这种算法需要使用空闲表(free-list),来记录所有的空闲区域,以及每个区域的大小。维护空闲表增加了对象分配时的开销。此外还有一个缺点就是,会产生内存碎片,明明还有足够的空闲内存,却可能没有一个区域的大小能存放下需要分配的对象,从而导致分配失败,在Java中就会导致OOM
GC原理_第6张图片

清理(Compacting)

标记-清除-整理(Mark-Sweep-Compact)将所有被标记的对象,迁移到内存空间的起始地处,消除了标记清除算法的缺点。
缺点:相应的缺点就是GC暂停时间会增加,因为需要将所有对象复制到另一个地方,然后修改这些对象的引用。
优点:此算法的优势也很明显,碎片整理后,分配新对象就很简单,只需要通过指针碰撞即可。
使用这种算法,内存空间剩余的容量一直是很清楚的,不会再导致内存碎片问题。

GC原理_第7张图片

复制(Copying)

标记-复制(mark and copy)和标记-整理算法十分相似,两者都会移动所有存活的对象。区别在于,标记-复制算法是将内存移动到另外一个空间:存活区。标记-复制算法的优点在于:标记和复制可以同时进行。缺点则是需要一个额外的内存区间,来存放所有的存活对象、
GC原理_第8张图片

本文由博客群发一文多发等运营工具平台 OpenWrite 发布

你可能感兴趣的:(java)