和C语言手动管理内存不同,JVM实现了自动内存管理机制,这也是Java语言的一大特点。
简而言之,JVM自动内存管理机制是JVM中面向堆(Heap)的内存管理机制,该机制包括两大部分
- 为对象分配合适的内存;
- 在合适的时机回收对象的内存;
本文以Java 7/8中JVM的设计为准来进行讲解。
本文较长,建议收藏后阅读。
如果对JVM内存分区结构不是很了解,建议先阅读JVM内存区域与多线程
1. 对象的生命周期
目前JVM HotSpot中的实现方法是“分别对待,因类而异”,即针对不同生命周期的对象分配在不同内存区域,采取不同的方法回收内存垃圾。
这样做是有数据支持的,下图为Oracle提供的对象生命周期分布图,其中横轴代表数据内存被分配的时长,纵轴标识该存活该时长对象的数目
从该图中我们发现,绝大部分的对象存活的时间很短的,只有少数对象需要长时间保存。因此可以针对这两类对象采取不同分配内存管理机制,以追求更高的性能。下文中将详细展开。
2. JVM堆中的分代内存管理
根据上面提到分区管理的思想,JVM的堆中,除了方法区之外,其余内存堆被分为:
- 新生代(The Young Generation)
- 老年代(The Old/Tenured Generation)
[图片上传中...(1513336330749.png-f3752c-1513438068985-0)]
新生代是新对象被创建时所分配的区域。该区域又被划分为一个eden区和两个survivor区。绝大多数对象被创建时都都会被分配到eden区。两个survivor区同一个时刻只会使用一个,另一个保持为空。这样的设计是为了在垃圾收集过程中具有更高的效率,下面的章节会具体介绍。
当新生代被充满时,会引发新生代垃收集(young GC) 因为新生代中对象生命周期都很短,所以young GC一般都能回收大量的空间,且会很快执行完毕。在young GC之后保留下来的对象将会保存在survivor区,这也是survivor名字的意义所在。
young GC是这一中Stop the World事件,也就是说当其发生时JVM中所有的线程都会停止至到该事件结束。这是为了保证垃圾收集的过程中保证数据一致性,但是频繁的发生Stop the World必然会影响JVM的执行效率。
老年代用来存储长时间存活的对象,JVM中会设定一个阈值,当新生代中的对象经过young GC而存活的次数达到该阈值后,这个对象就会被移动到老年代。同样老年代也会需要垃圾收集工作,引发老年代垃圾收集(old GC)
当然old GC也是Stop the World事件,但是old GC比young GC要慢很多,因为老年代中对象的存活率很高,要保存的对象很多。
通过对新生代和老年代的采用不同的垃圾收集算法, 就可以实现JVM内存管理的高效化。下面我们来谈谈其工作的细节。
2.1 新生代和复制算法
2.1.1 优先分配在Eden区
大部分情况下,对象都是分配在新生代的Eden区,如果开启了现场缓冲区,就会先分配到TLAB上(将上文)。如果Eden区不够的话,JVM就会发起young GC,如果GC之后还是内存不够,就将该对象直接放入老年区。
如下图所示,新创建对象时会有限放入到Eden区中,而两个Survivor区中总有一个是未使用。
当Eden区内存不够分配新对象时,会触发young GC以求回收内存资源。
2.1.2 使用复制算法在新生代收集内存垃圾
针对新生代垃圾收集发动相对频繁,对性能要求较高的情况,JVM使用复制算法在新生代收集内存垃圾。
复制算法的原型中将内存按容量划分为大小相等的两块,每次只使用其中一块。当此块内存用完之后,将还存活着的对象复制到另一块,然后把已使用过的内存空间一次清理掉。使得每次只对其中一块内存进行回收,内存分配时不用考虑内存碎片等情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。 但是这样做的代价就是将内存缩小为原来的一半,比较浪费空间。
依据这样的算法,HotSpot将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。回收时,将Eden和Survivor中还存活的对象一次性拷贝到另外一块Survivor空间,最后清理掉Eden和Survivor中空间。
HotSpot虚拟机默认Eden和Survivor比例为8:1,只有10%空间被“浪费”。当Survivor空间不够用时,需依赖其他内存(此处指老年代)进行分配担保。
如下图所示,young GC中存活的对象被移动到一块Survivor区中,Eden区被清空
在下一次 young GC时,存活下来的对象又被移动到另一个Survivor中。如果Survivor的空间不足以保存存活下来的对象,就需要老年区来保存对象。
2.2 老年代和标记整理算法
2.2.1 如何进入老年代
每次young GC后,存活的对象都会从一个Survivor复制到另一个Survivor,在复制的过程中JVM会记录下对象是经过多少次GC而存活的。
当对象存活的次数达到一定阈值之后(JVM默认为15,本例为8),JVM就会将该对象从新生代转移到老年代保存。用户可以使用-XX:MacTenuringThreshold
来设置该阈值
随着GC的不断进行,将会有更多的对象被移动到老年代。当老年代的内存被使用尽,也需要合适的算法来回收资源。
2.2.2 标记-整理算法
复制算法在对象存活率较高时要执行较多的复制操作,效率会变低,因此不适合在老年区使用。HotSpot使用标记—整理算法来回收老年代内存。
标记—整理算法的核心思想是标记出需要回收的对象之后,不是直接清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。 这样可以保证老年区有连续空间用以存储较大的数据。
标记—整理算法工作流程如下:
- Step 1: 标记 Marking
首先JVM标记处哪些对象是在使用中的,那些是无用的
图中蓝色为使用中的对象,橙色为无用待回收的对象。标记过程是一个较为耗时的操作。 - Step 2: 删除并整理 Deletion with Compacting
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,加快清理的效率。
2.2.3 空间分配担保
大多数情况下,old GC是由 young GC引起,这是因为JVM的空间分配担保机制的存在。
发生young GC时,JVM会检测之前每次晋升到老年代对象的平均大小是否大于老年代的剩余空间大小,若大于,说明对象晋级到老年代有内存不够的危险,则直接进行一次Full GC,也就是old GC和young GC都执行,同时回收老年代和新生代的内存资源。若小于,则只会进行young GC,在新生代中回收内存。
因为新生代中的对象“朝生夕死”,young GC发动更为频繁,因此常常是young GC来引发old GC。
2.3 如何确定可回收对象
到这里读者应该清楚JVM的堆中是如何为对象分配内部,并自动回收它们的了,但是还有一个疑问没有解决,那就是如何判定一个对象是无用的,应该被回收的。
最朴素和高效的方法莫过于引用计数算法,其算法原理为:给对象添加一个引用计数器,每有一个地方引用他时,计数器值加1;当引用失效时,计数器值减1;任何时刻计数器值都为0的对象就是不可能再被使用的,也就是无用的。可是,引用计数算法它难解决对象之间的相互循环引用问题。
2.3.1 可达性分析算法
JVM中所使用的算法是可达性分析算法,其基本思路为:通过一系列的名为GC Roots的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
换而言之,GC Roots对象是JVM一定在使用的对象,在其引用链上对象也就是在使用中的对象。 Java中可作为GC Roots的对象包括两大类:
- 栈中引用的对象
- 虚拟机栈(栈帧中的本地变量表)中的引用的对象;
- 本地方法栈中JNI(Native方法)的引用的对象。
- 方法区中的对象
- 方法区中的类静态属性引用的对象;
- 方法区中的常量引用的对象;
2.3.2 引用类别
因为是依据引用来判断对象是否在使用中,为了更高效的回收对象,Java设计了四种引用:
- 1)强引用: 在程序代码中普遍存在的,类似
Object obj = new Object( )
这类的引用。只要强引用还存在,则垃圾收集器永远不会回收掉被引用的对象。 - 2)软引用 SoftReference: 一些还有用,但并非必须的对象。对软引用关联着的对象,在系统将要发生内存溢出前,会把这些对象列进回收范围中并进行第二次回收。若这次回收还是没有足够的内存,才会抛出内存溢出异常。
- 3)弱引用 WeakReference: 非必须对象,强度比软引用更弱一些,被若引用关联的对象只能生存到下一次垃圾回收之前。
- 4)虚引用 PhantomReference: 最弱的一种引用关系。无法通过虚引用来取得一个对象实例。为对象设置虚引用的唯一目的是希望在对象被垃圾收集器回收时收到一个系统通知。
2.3.3 不被推荐的finalize()
对象中的finalize方法会影响对象的回收。GC中,如果对象没有在GC Roots的引用链中,将会被第一次标记,如果当对象没有覆盖finalize()方法,或finalize()方法已经被虚拟机调用过,则说明finalize()方法已经没必要执行了,JVM会直接将其回收掉。
反之,若对象被判定为有必要执行finalize( )方法,那么对象将会被放置在一个名为F-Queue的队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行;然后,JVM会对F-Queue的队列中再进行第二次标记,看该对象是否任然是无用的,如果该对象重新与引用链上的任何一个对象建立关联即可,那么第二次标记时将被移除出“即将回收”的集合,反之,则会回收该对象。由此可见finalize()只会被执行一次,第二次对象面临回收时就会被直接回收,而忽视finalize()方法。
finalize()方法是Java刚诞生时的一种妥协产物,已经不推荐使用了。
3. 垃圾收集器
现在我们已经知道了JVM内存分配和垃圾收集算法的工作原理,下面看看垃圾收集算法的实现,并根据使用场景选择合适垃圾收集器。
3.1 和对相关的配置参数
首先列举下和JVM堆相关配置参数,这些参数在下文的例子中会用到。
Switch | Description |
---|---|
-Xms | 设置JVM初始状态下堆的容量 |
-Xmx | 设置堆的最大容量 |
-Xmn | 设置堆中新生代的容量 |
-XX:PermSize | 设置JVM初始状态下永生代(方法区)的容量 |
-XX:MaxPermSize | 设置永生代(方法区)的最大容量 |
3.2 The Serial GC
Serial收集器是Java SE 5 /6中默认的客户端虚拟机的收集器。在Serial收集器中新生代和老年代都是通过线性过程(使用单个处理器)来收集垃圾。垃圾收集的过程是Stop the World事件,所有其他的进程都必须暂停,至到其结束。
使用场景
Serial收集器的特点是简单直接,适用于对于效率要求不高的客户端虚拟机上。就单个处理器的情况下,Serial收集器的效率是很高的,直到现在很多内存资源有限的嵌入式设备上都在使用Serial收集器。
此外如果物理机上存在大量的JVM在运行,使用Serial收集器让单个处理器收集垃圾效果会更高,因此这样会较少多个JVM之间的协调(因为所有的任务都停下了)。
启动Serial收集器
-XX:+UseSerialGC
使用例子
java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseSerialGC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar
3.3 The Parallel GC
Parallel收集器是Serial收集器的多线程版本,其使用多个线程在新生代收集垃圾。默认情况下,对于N核的处理器,Parallel收集器会使用N个线程来进行垃圾收集。收集器使用线程的数论可以通过如下命令配置:
-XX:ParallelGCThreads=<desired number>
如果是单核的处理器,即使配置为Parallel收集器,其实也是在采用单线程模式,通常双核以上的处理器才会使用Parallel收集器,以达到更好的性能。
使用场景
Parallel收集器是一种基于吞吐量(throughput)考虑的收集器,因为其可以使用多线程来加快垃圾回收效率,减少应用等待时间,提高来应用的吞吐量。因此其适用于服务器端的虚拟机
-XX:+UseParallelGC
使用该命令,JVM将为为新生代设置Parallel收集器来收集垃圾,而老年代会任然使用单线程模式。
java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseParallelGC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar
如果希望新生代和老年代都使用多线程模式请使用如下命令:
-XX:+UseParallelOldGC
Java2Demo:
java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseParallelOldGC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar
3.4 CMS 收集器
CMS收集器(Concurrent Mark Sweep collector)应用于老年代,是一种以获取最短回收停顿时间为目标的收集器。为了减少回收停顿事时间,CMS收集器没有复制或压缩对象数据,而是基于“标记-清除”算法实现。
运作过程可分为4步:
- 初始标记,仅标记一下GC Roots能直接关联到的对象,;
- 并发标记,进行GC Roots Tracing的过程,在引用链上查找对象;
- 重新标记,修正并发标记期间,因用户程序继续运行而导致标记产出变动的那一部分对象的标记记录;
- 并发清除,清除无用对象数据。
初始标记和重新标记两个步骤仍需Stop The World,且初始标较快速度很快,重新标记这阶段停顿时间一般比初始标记阶段长,但远比并发标记阶段时间短。
整个过程耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
在新生代,一般选择也选择并发式的收集器来配合CMS收集器的工作。
CMS收集器的缺点如下:
- 对CPU资源非常敏感,这是并发设计通病,虽然能贴和应用程序并发执行,但是会抢占资源;
- 无法处理浮动垃圾,因为是多次并发标记,所以会有一些无效对象不能被及时标记出来,从而无法回收;
- 会产出内存碎片,因为只是标记和清理,并不压缩整理存活对象,所以会出现很多内存碎片。如果没有足够大的连续空间分配对象,就不得不暂停执行 full GC,来压缩整理内存,效率就会降低。
使用场景
适用于对性能有高要求,要处理并发事件,交互性要求高的场景,如桌面UI应用,网站服务器等。
启动CMS收集器
-XX:+UseConcMarkSweepGC
设置CMS收集器所使用的线程数
-XX:ParallelCMSThreads=<n>
执行实例
java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseConcMarkSweepGC -XX:ParallelCMSThreads=2 -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar
3.5 G1 收集器
Garbage First or G1收集器作为CMS收集器的替代者而被设计,并在Java7中正式登场。
与CMS收集器相比的两个显著改进:
- G1收集器基于“标记-整理”算法,不会产生空间碎片;
- 可非常精确的控制停顿,能让使用者明确指定在一个长度为M毫秒的时间片内,消耗在垃圾收集上的时间不超过N毫秒,几乎是实时Java(RTSJ)的垃圾收集器特征。
由于能极力避免全区域垃圾收集,它可实现基本不牺牲吞吐量前提下完成低停顿的内存回收。之前收集器都是针对整个新生代或老年代,但是G1将Java堆划分为多个大小固定的独立区域,并跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间优先回收垃圾最多的区域。 区域划分以及有优先级的区域回收,保证了G1收集器在有限时间内可获得最高收集效率。
在Java8中G1收集器又被进一步加强,详情请见Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide
启动G1收集器
-XX:+UseG1GC
java -Xmx12m -Xms3m -XX:+UseG1GC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar
4. 方法区的垃圾回收
除了新生代和老年代之外,堆中还有方法区。方法区(HotSpot虚拟机中的永久代)的垃圾收集主要回收两部分内容:
- 废弃常量
- 无用的类。
其回收效率很低 ,不能回收多少内存资源,这里只是简单介绍。
判定常量是否是“废弃常量”比较简单, 但判断一个类是否是“无用的类”需同时满足下面3个条件:
- 类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
- 加载该类的ClassLoader已经被回收;
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
满足以上3个条件的无用类“可以”(不是一定会)被回收。
是否对类进行回收,HotSpot虚拟机提供了 -Xnoclassgc
参数进行控制,还可使用-verbose:class
及-XX:+TraceClassLoading
、-XX:+TraceClassUnLoading
查看类的加载和卸载信息。
在大量使用反射、动态代理等框架的场景中,及动态生成JSP这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,保证永久代不会溢出。