我们知道JVM具有动态分配内存和内存的回收功能,而内存回收的关键就在于JVM有垃圾收集(Garbage Collection,简称GC)存在。
那说到GC,我们就会疑问:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
下面我们就一起来了解一下GC的工作原理吧。
怎么判断对象死没死?
我们知道,堆中存放了java几乎所有的实例对象,垃圾收集器在对堆进行回收前,就需要判断对象是否“存活”还是“死去”?目前主要有引用计数算法和根搜索算法来判断。
引用计数算法
引用计数算法是给对象添加一个引用计数器,当对象被引用时计数器就 +1,当引用失效时计数器就 -1,也就是说当计数器为0的时候,这个对象就表示没有被引用,就可以回收这个对象。
引用计数算法存在一个很大的缺陷:当两个对象互相引用的时候,这两个对象的计数器就永远不可能为0,导致GC不会去回收这两个对象。
根搜索算法
根搜索算法是通过一系列的“GC Roots”对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(对象不可达),则表示这个对象是不可用的。
如图所示,对象Object 5、Object 6、Object 7虽然互相有关联,但是他们到GC Roots是不可达的,所以他们会被认为可回收的对象。
在java中,可作为GC Roots对象的有以下几种:
- 虚拟机栈(栈桢中的本地变量表)中的引用对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI(即Native方法)的引用对象
有哪些引用类型?
无论是通过引用计数算法判断对象的引用数量,还是通过根搜索算法判断对象的引用链是否可达,判定对象是否存活都与引用有关系。
在jdk1.2之前,如果Reference类型的数据中存储的是另一块内存的起始地址,就称这块内存代表着一个引用。这种情况下对象只有被引用或没被引用,内存不足时GC就无法判断哪些对象可以被强制回收。
在jdk1.2之后,引用扩充为四种类型:强引用,软引用,弱引用,虚引用。这四种引用强度依次由强逐渐减弱。
强引用(Strong Reference)
强引用是类似于 Object obj = new Object() 所创建的这类引用,只要强引用还在,GC就永远不会回收被引用的对象。
使用强引用只需要new一个对象:
String str = new String("Strong Reference test");
软引用(Soft Reference)
软引用是用来描述一些还有用,但是非必须的对象。对于被软引用关联的对象,在系统将要发生内存溢出异常之前,GC将会把这些对象列入回收范围,进行第二次回收。如果还是内存不足,才会抛出异常。
使用软引用需要借助 java.lang.ref.SoftReference 类:
SoftReference<String> softRef = new SoftReference<>(new String("Soft Reference test"));
弱引用(Weak Reference)
弱引用也是用来描述非必须对象的,强度比软引用更弱。被弱引用关联的对象,只能生存到下一次GC回收之前,无论内存是否够用,GC都会回收被弱引用关联的对象。常见的有WeakHashMap。
使用弱引用需要通过 java.lang.ref.WeakReference 类来实现:
WeakReference<String> weakRef = new WeakReference<>(new String("Weak Reference test"));
虚引用(Phantom Reference)
虚引用也称为幽灵引用或幻影引用,是强度最弱的一种引用。一个对象无论是否存在虚引用,都不会影响GC对其回收,也无法通过虚引用获取对象实例。为一个对象设置虚引用的唯一作用就是希望GC在回收这个对象的时候收到一个系统通知。
使用虚引用需要通过 java.lang.ref.PhantomReference 类,并与 ReferenceQueue 一起配合使用:
ReferenceQueue<String> queue = new ReferenceQueue<>();
PhantomReference<String> phantomRef = new PhantomReference<>(new String("Phantom Reference test"), queue);
finalize之前逃脱死亡?
在根搜索算法中不可达的对象,也并非都是“非死不可”,这时候他们暂时处于“缓刑”阶段,要宣告一个对象的死亡,则需要经过两次标记:如果对象在进行中可达性分析后发现没有与 GC Roots 相连接的引用链,那他将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象竟会放置在一个叫做 F-Queue 的队列中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会出发这个方法,并不承诺或等待他运行结束。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 —— 只要重新与引用链上的任何一个对象简历关联即可。
finalize() 方法只会被系统自动调用一次。
方法区怎么回收?
方法区主要回收包括:废弃的常量和无用的类
判断废弃的常量:即该常量在整个程序中未被引用。
判断无用的类:
- 该类所有的实例都已经回收,也就是 Java 堆中不存在该类的任何实例
- 加载该类的 ClassLoader 已经被回收
- 该类对应的 java.lang.Class 对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法
垃圾回收算法
垃圾收集算法各有不同,这里只介绍几种基础的算法实现。
标记-清除算法
标记-清除(Mark-Sweep)算法是最基础的垃圾收集算法,他分标记和清除两个阶段:首先标记出所有需要回收的对象,然后再统一回收所有被标记的对象。
缺点:
- 效率低
- 产生大量不连续的内存碎片
标记-复制算法
为了解决标记-清除算法的效率问题,就出现了标记-复制(Mark-Copying)算法,他将内存分为相等的两块,每次只使用其中一块,当这块内存使用完了,就将这块内存中存活的对象复制到另一块内存上,然后把之前那块内存全部清理。
缺点:
- 存活对象较多时,需复制移动大量对象,效率低
- 内存缩小为原来一半,内存的使用率太低。
优化方案:
现在大部分商业虚拟机都采用复制算法来回收新生代,因为IBM研究发现新生代中对象98%都是可回收的。因此可以将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次只使用Eden和一块Survivor空间。当GC回收时,将Eden和Survivor中存活的对象复制到另一块Survivor空间中,再清理之前使用的Eden和Survivor空间。
HotSpot虚拟机默认的分配比例为 8:1:1,这样内存空间每次只有10%会被浪费,但是又出现一个问题,就是我们无法保证每次回收的存活的对象都不大于10%,所以当Survivor空间不足时,我们就需要依赖其他的内存(这里指老年代)进行分配担保。
标记-整理算法
根据老年代特点,有人提出了更用于老年代内存区的回收算法:标记-整理算法(Mark-Compact),这种算法也分两个阶段:首先标记出所有需要回收的对象,然后让所有存活的对象向一端移动,然后直接清理掉端边界以外的所有内存。这种算法克服了标记-复制算法的低效问题,同时克服了标记-清除算法的内存碎片化的问题。
分代收集算法
当前虚拟机基本都使用了分代收集算法(Generational Collection),这种算法是根据对象的存活周期的不同将内存划分为几块。一般将java堆划分为新生代和老年代,这样就可以根据各个年代的特点采用最优的收集算法。新生代与老年代默认大小比例为1:2。
新生代(Young Generation):每次垃圾回收都有大量对象死去,只有少量存活,选用复制算法比较合理。
老年代(Tenured Generation):老年代中对象存活率较高,没有额外的空间分配对它进行担保。所以必须使用标记-清除或者标记-整理算法回收。
垃圾收集器
如果说垃圾收集算法是内存的方法论,那垃圾收集器就是内存回收的具体实现。由于jvm大有不同,所以垃圾收集器也有很多种。
如上图,我们能看到7种不同的垃圾收集器,他们分别适用于新生代和老年代内存中。如果两种垃圾收集器之间有连线,就代表可以搭配使用。
一般来说没有哪一种垃圾收集器是最好的这种说法,针对某一个java虚拟机只有最合适的垃圾收集器,因为每种垃圾收集器都有自己的特性。
Serial 收集器
Serial 收集器是一个单线程的收集器,但他的“单线程”的意义并不仅仅说明他只会使用一个CPU或一个收集线程去完成垃圾回收工作,更重要的是他在进行垃圾回收工作的时候,必须暂停其他所有的工作线程(Sun将这种行为称之为“Stop The World”),直到垃圾回收结束。
Serial 收集器的优势是简单而高效(相对于其他单线程收集器),但是他的缺点就很致命,出现Stop The World现象,让程序暂停服务,等待垃圾回收完成再恢复程序正常运行。
ParNew 收集器
ParNew 收集器其实就是Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,几乎和Serial 收集器一样。
Parallel Scanvenge 收集器
Parallel Scanvenge 收集器也属于新生代收集器,也使用的是复制算法,也是并行的多线程收集器,与ParNew 收集器类似,不同的是Parallel Scanvenge 收集器的关注点是达到一个可控制的吞吐量(Throughput),也被称为“吞吐量优先”收集器。
Parallel Scanvenge 收集器可以通过设置参数来控制吞吐量。
主要功能参数:
-XX:MaxGCPauseMillis 参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过设定值。并不是值越小越好,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的,停顿时间越小,新生代空间对应也越小,对应吞吐量也降低了。
-XX:GCTimeTatio 参数允许的值是1~99的整数,也就是垃圾收集时间占总时间的占比数,相当于吞吐量的倒数。默认值为99,也就是允许最大1%(即 1/(1+99))的垃圾回收时间。
-XX:UseAdaptiveSizePolicy 这是一个开关参数,开关打开之后就不需要配置新生代,老年代等细节参数,虚拟机会根据当前系统运行的情况收集性能监控信息,动态调整这些参数配置,以达到最合适的停顿时间或最高吞吐量,这种调节方式我们称之为GC自适应的调节策略(GC Ergonomics)。
补充说明:
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
吞吐量(Throughput):指CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=用户运行代码时间 / (用户运行代码时间 + 垃圾收集时间)。
Serial Old 收集器
Serial Old 收集器是Serial 收集器的老年代版本,是一个单线程收集器,使用了收集-整理算法。可与Parallel Scanvenge 收集器搭配使用,也可作为CMS 收集器的后备预案,在并发收集发生Concurrent Mode Failure错误时使用。
Parallel Old 收集器
Parallel Old 收集器是Parallel Scanvenge 收集器的老年代版本,是一个多线程收集器,使用标记-整理算法。
jdk1.6之后提供,在注重吞吐量及CPU资源敏感的场合都可以使用Parallel Scanvenge 收集器+Parallel Old 收集器组合,达到高吞吐量。
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间未目标的收集器,使用的是标记-清除算法。CMS 收集器适用于注重服务响应速度,希望系统停顿时间最短,以给用户带来更好的体验的系统。
CMS(Concurrent Mark Sweep)收集器收集过程相对复杂,分为4个阶段:
- 初始标记(CMS initial mark):只标记GC Roots能直接关联到的对象,速度很快。此过程需要Stop The World。
- 并发标记(CMS concurrent mark):进行GC Roots Tracing,判定对象是否可回收。
- 重新标记(CMS remark):修复因系统运行导致并发标记的对象产生变动的部分对象。速度比初始标记稍长,但远比并发标记时间短。此过程需要Stop The World。
- 并发清除(CMS concurrent sweep):回收对象。
- 重置:清理数据结构,为下一个并发收集做准备。
CMS 收集器又被称为并发低停顿收集器(Concurrent Low Pause Collector),因为其特性:并发收集,低停顿。但是他依然存在一些缺点:
- 对CPU资源非常敏感。
- 无法处理浮动垃圾,可能出现Concurrent Mode Failure失败而导致另一次Full GC的产生。
- 基于标记-清除算法,收集结束时会产生大量的空间碎片。
补充知识:
Minor GC:对新生代进行内存回收。
Major GC:对老年代进行内存回收。
Full GC:对整个堆进行内存回收。
可能导致Full GC触发的情况:
- 调用System.gc()。
- 堆空间内存不足时。
- 永久代或元空间内存不足。
- 内存担保机制触发。
G1 收集器
G1(Garbage First)收集器是一款面向服务器的收集器,相比于CMS收集器有两个显著的改进:
- 基于标记-整理算法,不会产生空间碎片。
- 可以非常精确的控制停顿时间,可以指定一个长度为M秒的时间片段内,消耗在垃圾收集上的时间不超过N秒。
G1收集器将整个java堆划分为多个大小固定的独立区域,并且跟踪这些区域的垃圾堆积程度,在后台维护一个优先级列表,每次根据允许的收集时间,优先回收垃圾最多的区域。
垃圾收集器常用参数
参数 | 描述 |
---|---|
UseSerialGC | 虚拟机运行在Client模式下的默认值,打开此开关后,使用 Serial+Serial Old 的收集器组合进行内存回收 |
UseParNewGC | 打开此开关后,使用 ParNew + Serial Old 的收集器组合进行内存回收 |
UseConcMarkSweepGC | 打开此开关后,使用 ParNew + CMS + Serial Old 的收集器组合进行内存回收。Serial Old 收集器将作为 CMS 收集器出现 Concurrent Mode Failure 失败后的后备收集器使用 |
UseParallelGC | 虚拟机运行在 Server 模式下的默认值,打开此开关后,使用 Parallel Scavenge + Serial Old(PS MarkSweep) 的收集器组合进行内存回收 |
UseParallelOldGC | 打开此开关后,使用 Parallel Scavenge + Parallel Old 的收集器组合进行内存回收 |
SurvivorRatio | 新生代中 Eden 区域与 Survivor 区域的容量比值,默认为8,代表 Eden : Survivor = 8 : 1 |
PretenureSizeThreshold | 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配 |
MaxTenuringThreshold | 晋升到老年代的对象年龄,每个对象在坚持过一次 Minor GC 之后,年龄就增加1,当超过这个参数值时就进入老年代 |
UseAdaptiveSizePolicy | 动态调整 Java 堆中各个区域的大小以及进入老年代的年龄 |
HandlePromotionFailure | 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个 Eden 和 Survivor 区的所有对象都存活的极端情况 |
ParallelGCThreads | 设置并行GC时进行内存回收的线程数 |
GCTimeRatio | GC 时间占总时间的比率,默认值为99,即允许 1% 的GC时间,仅在使用 Parallel Scavenge 收集器生效 |
MaxGCPauseMillis | 设置 GC 的最大停顿时间,仅在使用 Parallel Scavenge 收集器时生效 |
CMSInitiatingOccupancyFraction | 设置 CMS 收集器在老年代空间被使用多少后触发垃圾收集,默认值为 68%,仅在使用 CMS 收集器时生效 |
UseCMSCompactAtFullCollection | 设置 CMS 收集器在完成垃圾收集后是否要进行一次内存碎片整理,仅在使用 CMS 收集器时生效 |
CMSFullGCsBeforeCompaction | 设置 CMS 收集器在进行若干次垃圾收集后再启动一次内存碎片整理,仅在使用 CMS 收集器时生效 |
内存分配与回收策略
JVM自动管理内存其实主要做了两件事:自动分配内存和自动回收内存。自动回收内存的话主要是通过之前介绍的一些垃圾收集器去回收,那自动分配内存有什么规则?我们下面简单介绍几种常见的内存分配规则。
对象优先在Eden区分配
大部分情况,对象会优先在新生代Eden区域分配内存。当Eden区域内存不足时,会出发一次Minor GC。
大对象直接进入老年代
所谓大对象就是指需要比较大的连续内存空间的java对象,常见的有超长的字符串,数组。JVM提供了-XX:PretenureSizeThreshold参数设置值,超过这个值大小的对象将直接进入老年代。
长期存活的对象将进入老年代
JVM内部会给每个对象建立一个对象年龄计数器。当对象存入Eden区,经过一次Minor GC后仍然存活,并且能够复制到Survivor中,对象年龄计数器就会设为1。对象在Survivor空间中每经过一次Minor GC后,年龄就+1,当年龄达到某个值(默认15)的时候,这个对象就可以晋升到老年代。
对象晋升老年代的年龄值我们可以通过参数-XX:MaxTenuringThreshold来设置。例如当设置参数-XX:MaxTenuringThreshold=16时,对象年龄计数器达到16的时候,对象就会被保存到老年代。
动态对象年龄判断
为了能更好的适应不同的内存状况,当Survivor空间中相同年龄所有对象的内存大小总和大于Survivor空间一半,那么只要对象年龄大于或等于该年龄,就可以直接进入老年代,并不需要等到年龄达到-XX:MaxTenuringThreshold设置的年龄。
空间分配担保
在Minor GC的时候,当Survivor空间大小无法容纳所有存活的对象,就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代。
在发生Minor GC的时候,JVM会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余大小:
如果大于,则改为直接进行一次Full GC。
如果小于,则查看HandlePromotionFailure设置是否允许担保失败。如果允许则只进行一次Minor GC;如果不允许则进行一次Full GC。