JAVA GC(Garbage Collection,垃圾收集,垃圾回收)机制,是Java区别于c的主要标志之一。开发者不需要专注于内存回收和垃圾清理,从而空出精力来专注于功能开发。因为内存回收和垃圾清理JVM已经替开发做到了。
我将通过下面几点来讲述什么是Java GC:
1. JAVA内存区域
2. JAVA对象的引用方式
3. JAVA内存分配方式
4. JAVA GC算法
5. 垃圾收集器
Java运行时的数据区里,由JVM管理的内存区域划分如下:
1.程序计数器(Program Counter Register):一块较小的内存区域,用于指示当前线程所执行的字节码执行到了哪一行。字节码解释器通过改变计数器的值来获取下一条语句指令。
每个程序计数器只用来记录一个线程的行号,故它是线程私有的。
如果执行的是一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令地址;若正在执行的是一个native方法,则计数器的值为Undefined。因为程序计数器只是记录当前指令地址,对内存的影响十分微小,所以不存在内存溢出的情况。故而,程序计数器成为了JVM所有内存区域中唯一没有定义OutOfMemoryError的区域。
2.虚拟机栈(JVM Stack):一个线程的每个方法在执行的同时,都会创建一个栈帧(StackFrame),栈帧中存储着局部变量表、操作栈、动态链接、方法出口等。当方法被调用时,栈帧在JVM栈中入栈,随着方法执行完成,栈帧出栈。
局部变量表中存储着方法的相关局部变量,包括各种基本数据类型,对象的引用,返回地址等。局部变量只有long和double类型会占用2个局部变量空间(Slot,对于32位机器,1Slot=32bit,64位就是64bit),其他都是一个Slot.局部变量表在编译时就生成,方法运行所需要分配的空间在栈帧中是分配好的,在方法的生命周期内都不会改变。
虚拟机栈定义了两种异常,StackOverFlowError(栈溢出)和OutOfMemoryError(内存溢出)。如果线程调用的栈深度大于虚拟机允许的最大深度,则会报栈溢出异常;多数Java虚拟机允许动态扩展虚拟机栈的大小,线程可以一直申请栈,当内存不足时,则会抛出内存溢出异常。
3.本地方法栈(Native Method Stack):本地方法栈与虚拟机栈相同,唯一不同的就是:虚拟机栈是执行Java方法的,本地方法栈是执行native方法。在大部分的虚拟机中,会将本地方法栈与虚拟机栈一起使用。
4.堆区(Heap):堆区是Java运行时内存最大的区域,由所有线程共享,在虚拟机启动时创建。堆区里存储的是对象的实例,原则上讲,所有的对象在堆区上分配内存。
一般来说,根据Java虚拟机规范规定,堆内存需要在逻辑上是连续的,可以是固定大小,也可以是扩展的。主流的虚拟机都是可扩展的。如果在执行垃圾回收后,任然没有足够的内存分配,也无法扩展,则会抛出OutOfMemoryError.Java heap space 异常。
5.方法区(Method Area):线程共享。主要用于存储类的信息、常量池、方法数据、方法代码等。逻辑上属于堆的一部分,但为了与堆区分,又称为‘非堆’。Java GC的分代收集机制分为三个代:年轻代、老年代和永久代。其中永久代指的就是方法区。在jdk1.8已经完全移除了永久代,转而用元空间来替代永久代。元空间的本质还是和永久代类似,都是对JVM规范中方法区的实现。不过最大的区别就是:元空间并不在虚拟机中,而是使用本地内存。在默认情况下,元空间的大小仅受本地内存限制,可以用以下参数来进行设置。
-XX:MetaspaceSize 初始空间大小,到达这个值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:若释放了大量的空间,则适当降低该值;若释放了较少的空间,则在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize,最大空间,默认没有限制。
-XX:MinMetaspaceFreeRatio 在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
-XX:MaxMetaspaceFreeRatio 在GC之后,最小的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集
同时在jdk1.8也废弃了-XX:PermSize 和-XX:MaxPermSize这两个命令。
方法区在物理上不需要连续的,可以选择固定大小或可扩展大小,并且方法区比堆还多了一个限制:可以选择是否执行垃圾收集。一般来说,在方法去上执行的垃圾收集是比较少的,这也是为什么方法区被称为永久代的原因之一。在永久代上垃圾收集主要你是针对常量池的内存回收和堆已加载类的卸载。当方法区内存不足时,会抛出OutOfMemoryError:PermGen space异常。
运行时常量池(RuntimeConstant Pool)是方法区的一部分,用于存储编译器就生成的字面常量、符号引用、翻译出来的直接引用;运行时常量池处理存储编译期常量外,还可以存储在运行时产生的常量(比如String类的intern()方法,作用是String维护了一个常量池,如果调用的字符“abc”已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址)。在JDK1.7到1.8已经将字符串常量有永久代转移到堆中了。
6.直接内存(Direct Memory):机器内存除去JVM所占用以外的内存就是直接内存。
一个Java的引用访问涉及到3个内存区域:JVM栈、堆和方法区。
举个例子: Object obj = new Object();
Objectobj表示一个本地引用,存储在JVM栈的本地变量表中,表示一个reference类型数据;
newObject()作为实例对象数据存储在堆中;
Object类的类型信息地址都记录在堆中,地址所执行的数据存储在方法区中。
在Java虚拟机规范中,通过引用类型访问具体对象的主流方式有两种
1.通过句柄访问
通过句柄访问,JVM堆中会有专门一块区域用来做句柄池,存储相关句柄所执行的实数据地址。用句柄表示地址,十分稳定。
2.通过指针访问
通过指针访问的方式中,reference中存储的就是对象在堆中的实际地址,在队中存储的对象信息包含在方法区中的相应类型数据。特点:速度快。
一般来说,对象的内存分配都是基于堆上进行的。Java内存分配和回收的机制总的来说,就是:分代分配,分代回收。对象将根据存活的时间被分为:年轻代(Young Generation)、老年代(Old Generation)、永久代(Permanent Generation,方法区)。
年轻代:对象被创建时,内存首先被分配在年轻代(大对象可以直接被创建在老年代),大部分对象在创建后很快就不再被使用,因此很快变得不可达,就会被年轻代的GC机制清理掉,这个GC机制被称为MinorGC。
年轻代上的内存分配划分成了3个区域:Eden区和两个存活区(Survivor0,Survivor1)。
运作流程:
1.大部分创建的对象会被分配在Eden区,其中大部分很快就die掉了。
2.当Eden区满的时候,执行Minor GC,将die掉的对象清理掉,并将剩余对象复制到一个存活区Survivor0。此时另外一个存活区是空着的。
3.此后,每当Eden区满了,就执行一次Minor GC,并将剩余的对象添加到Survivor0中;
4.当Survivor0要满的时候,将其中依然存活的对象复制到Survivor1中,并清空Survivor0;然后Eden区执行Minor GC后,将剩余对象添加到Survivor1中。
5.当两个存活区切换了X(HotSpot虚拟机默认X=15 ,用命令-XX:MaxTenuringThreshold来设置X数值)次后,将仍然存活的对象复制到老年代中去。
Eden区是连续的空间,且Survivor总有一个为空。经过一次GC和复制,一个Survivor中保存着当前还存活的对象,Eden区和另外一个Survivor区则直接清空。下一次GC时,两个Survivor的角色再互换。这个方法是著名的“停止-复制(Stop-and-Copy)”清理法。
在Eden区,HotSpot虚拟机使用了两种技术来加快内存分配。分别是bump-the-pointer和TLAB(Thread Local Allocation Buffers)。Bump-the-Pointer:跟踪最后创建的一个对象,在对象创建时,只需要检查最后一个对象后面是否有足够的内存即可,有就创建,没有就GC;TLAB:通过使用多线程,将Eden分为若干段,每段用一个线程来支配,避免互相干扰。
HotSpot虚拟机默认Edenhe和Survivor 的大小比例是8:1:1,也就是说每次有10%内存是”浪费“的。(可以用-XX:SurivivorRatio参数来配置Eden和Survivor区域比值,默认是8,表示:Eden:Survivor0:Survivor=8:1:1)在98%的对象可回收只是普通场景下的数据,没有办法保证每次回收都只有10%以内的对象存活,当Survivor空间不够用的时候,需要依赖其他内存进行分配担保(HandlePromotion)。
老年代:对象如果在年轻代存活足够长的时间也没被清理掉,则会被复制到老年代。老年代的空间一般比年轻代大,能存放更多对象,在老年代上发生的GC次数也会比较少。当老年代内存不足的时候,就会执行Major GC,也称Full GC。
如果对象比较大,年轻代空间不足,则大对象会直接分配到老年代上。用-XX:PertenureSizeThreshold来控制直接进入老年代的对象大小,大于这个值的对象会直接分配在老年代上。
可能存在老年代对象引用年轻代对象的情况,如果需要执行Minor GC,则可能需要查询整个老年代以及确定是否可以清理回收,这样的话是低效的。为了解决效率问题,在老年代中会去维护一个512byte的块”Card Table“,所有的老年代对象引用新生对象的记录在这里。Minor GC时,只要查询Card Table就可以,不需要查全部的老年代,以此来提高效率。
基本算法核心:分代收集。
标记-清除(Mark-Sweep)算法是最基础的算法:算法分为标记和清除两个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。缺点:标记和清除过程效率不够高;清除后会产生大量不连续的内存碎片,碎片过多会导致后续需要分配较大对象时无法找到足够连续的内存空间而不得不提前发起另一次垃圾收集动作。
不可避免的碎片还是产生了。
为了解决效率问题,一种称之为复制的收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只是用其中一块,当着一块内存用完时,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中一块进行内存回收,内存分配时不需要考虑内存碎片,只需要移动堆顶指针,按顺序分配内存即可。该算法优点是效率高,缺点是牺牲了一半的内存。(停止复制算法在回收内存时,需要暂停其他所有线程的执行。这个是比较低效的,各种新生代收集器越来越优化这点,只是将暂停的时间变短,并未彻底取消停止。)
复制收集算法在对象存活率较高的时候需要执行较多的复制操作,效率则降低。而且还要浪费掉一半的空间。在老年代,一般不适用这种算法。
根据老年代的特点,有种新的标记算法被提出来了。这就是标记-整理算法:标记过程与标记-清除算法一样,当时后续步骤不是直接对可回收对象进行清理,而是将所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
分代收集指的就是根据不同情况而采用不同的收集算法。使得效率达到最高。年轻代采用复制算法,因为每次垃圾收集时都有大量的对象死去,只用少量存活,选用复制算法,只需要付出少量存活对象的复制成本就可完成收集;而老年代中因为对象存活率高、没有额外空间对它进行分配担保,则必须使用标记-清理或标记-整理算法来进行回收。
永久代的回收有两种:常量池中的常量,无用的类信息。常量的回收很简单,只要没有引用了就可以回收。无用的类信息回收则要保证3点:1,类的所有势力都已经被回收;2,加载类的ClassLoader已经被回收;3,类对象的Class对象没有被引用。
永久代的回收不是必须的,可以通过通过参数来设置是否对类进行回收。HotSpot提供-Xnoclassgc进行控制
使用-verbose,-XX:+TraceClassLoading、-XX:+TraceClassUnLoading可以查看类加载和卸载信息
-verbose、-XX:+TraceClassLoading可以在Product版HotSpot中使用;
-XX:+TraceClassUnLoading需要fastdebug版HotSpot支持
垃圾收集器是收集算法的具体实现,不同的虚拟机提供不同的垃圾收集器。本文以Sun HotSpot虚拟机1.6版为基础来学习。
图中展示了多种作用于不同年的收集器。连线表示他们可以搭配使用。首先明确一个观点:没有最好的收集器,只有最好的搭配。
1.Serial收集器
单线程收集器,收集时会暂停所有工作线程(StopThe World,STW),使用复制收集算法,虚拟机运行在Client模式时默认的年轻代收集器。使用-XX:+UseSerialGC 可以使用Serial+Serial Old模式运行进行内存回收。
2.ParNew收集器
ParNew收集器就是Serial的多线程版本,除了使用多条收集线程外,其余行为包括算法、STW、对象分配规则、回收策略等都与Serial收集器一摸一样。对应的这种收集器是虚拟机运行在Server模式的默认新生代收集器,在单CPU的环境中,ParNew收集器并不会比Serial收集器有更好的效果。 使用-XX:+UseParNewGC开关来控制用ParNew+Serial Old收集器组合收集内存;使用-XX:ParallelGCThreads来设置执行内存回收的线程数。
3.Parallel Scavenge 收集器:
ParallelScavenge收集器(下称PS收集器)也是一个多线程收集器,也是使用复制算法,但它的对象分配规则与回收策略都与ParNew收集器有所不同,它是以吞吐量最大化(即GC时间占总运行时间最小)为目标的收集器实现,它允许较长时间的STW换取总吞吐量最大化。 (JVM运行100分钟,其中运行用户代码99分钟,垃 圾收集1分钟,则吞吐量是99%)使用-XX:+UseParallelGC开关控制使用 Parallel Scavenge+Serial Old收集器组合回收垃圾(这也是在Server模式下的默认值);使用-XX:GCTimeRatio来设置用户执行时间占总时间的比例,默认99,即 1%的时间用来进行垃圾回收。使用-XX:MaxGCPauseMillis设置GC的最大停顿时间(这个参数只对Parallel Scavenge有效)
4.Serial Old收集器:
老年代收集器,单线程收集器,使用标记整理(整理的方法是Sweep(清理)和Compact(压缩),清理是将废弃的对象干掉,只留幸存 的对象,压缩是将移动对象,将空间填满保证内存分为2块,一块全是对象,一块空闲)算法,使用单线程进行GC,其它工作线程暂停(注意,在老年代中进行标 记整理算法清理,也需要暂停其它线程),在JDK1.5之前,Serial Old收集器与ParallelScavenge搭配使用。
5.Parallel Old收集器:
老年代收集器,多线程,多线程机制与Parallel Scavenge差不多,使用标记整理(与Serial Old不同,这里的整理是Summary(汇总)和Compact(压缩),汇总的意思就是将幸存的对象复制到预先准备好的区域,而不是像Sweep(清 理)那样清理废弃的对象)算法,在Parallel Old执行时,仍然需要暂停其它线程。Parallel Old在多核计算中很有用。Parallel Old出现后(JDK 1.6),与Parallel Scavenge配合有很好的效果,充分体现Parallel Scavenge收集器吞吐量优先的效果。使用-XX:+UseParallelOldGC开关控制使用Parallel Scavenge +Parallel Old组合收集器进行收集。
6.CMS(Concurrent Mark Sweep)收集器:
老年代收集器,致力于获取最短回收停顿时间,使用标记清除算法,多线程,优点是并发收集(用户线程可以和GC线程同时工作),停顿小。使用-XX:+UseConcMarkSweepGC进行ParNew+CMS+Serial Old进行内存回收,优先使用ParNew+CMS(原因见后面),当用户线程内存不足时,采用备用方案Serial Old收集。
CMS收集的方法是:先3次标记,再1次清除,3次标记中前两次是初始标记和重新标记(此时仍然需要停止(stop the world)),初始标记(Initial Remark)是标记GC Roots能关联到的对象(即有引用的对象),停顿时间很短;并发标记(Concurrentremark)是执行GC Roots查找引用的过程,不需要用户线程停顿;重新标记(Remark)是在初始标记和并发标记期间,有标记变动的那部分仍需要标记,所以加上这一部分标记的过程,停顿时间比并发标记小得多,但比初始标记稍长。在完成标记之后,就开始并发清除,不需要用户线程停顿。
所以在CMS清理过程中,只有初始标记和重新标记需要短暂停顿,并发标记和并发清除都不需要暂停用户线程,因此效率很高,很适合高交互的场合。
CMS也有缺点,它需要消耗额外的CPU和内存资源,在CPU和内存资源紧张,CPU较少时,会加重系统负担(CMS默认启动线程数为(CPU数量+3)/4)。
另外,在并发收集过程中,用户线程仍然在运行,仍然产生内存垃圾,所以可能产生“浮动垃圾”,本次无法清理,只能下一次Full GC才清理,因此在GC期间,需要预留足够的内存给用户线程使用。所以使用CMS的收集器并不是老年代满了才触发Full GC,而是在使用了一大半(默认68%,即2/3,使用-XX:CMSInitiatingOccupancyFraction来设置)的时候就要进行Full GC,如果用户线程消耗内存不是特别大,可以适当调高-XX:CMSInitiatingOccupancyFraction以降低GC次数,提高性能,如果预留的用户线程内存不够,则会触发Concurrent Mode Failure,此时,将触发备用方案:使用Serial Old 收集器进行收集,但这样停顿时间就长了,因此-XX:CMSInitiatingOccupancyFraction不宜设的过大。
还有,CMS采用的是标记清除算法,会导致内存碎片的产生,可以使用-XX:+UseCMSCompactAtFullCollection来设置是否在Full GC之后进行碎片整理,用-XX:CMSFullGCsBeforeCompaction来设置在执行多少次不压缩的Full GC之后,来一次带压缩的Full GC。
7. G1收集器
G1收集器是Java虚拟机的垃圾收集器理论进一步发展的产物,它与前面的CMS收集器相比有两个显著的改进:一是G1收集器是基于“标记-整理”算法实现的收集器,也就是说它不会产生空间碎片,这对于长时间运行的应用系统来说非常重要。二是它可以非常精确地控制停顿,既能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,具备了一些实时Java(RTSJ)的垃圾收集器的特征。
G1收集器可以实现在基本不牺牲吞吐量的前提下完成低停顿的内存回收,这是由于它能够极力地避免全区域的垃圾收集,之前的收集器进行收集的范围都是整个新生代或老年代,而G1将整个Java堆(包括新生代、老年代)划分为多个大小固定的独立区域(Region),并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(这就是Garbage First名称的来由)。区域划分及有优先级的区域回收,保证了G1收集器在有限的时间内可以获得最高的收集效率。
G1收集器是JDK1.7才引入进来的,有很大的变动。我会在另外一篇文章中详细的介绍。
后记:
文中大多笔记来自于网上以及《深入理解 Java虚拟机:JVM高级特效与最佳实现》一书。由于本人能力有限,如有纰漏,望留言指正。
参考博客: