深入理解JVM--垃圾回收器
1、垃圾回收主要对象
- 堆(Heap) :Java虚拟机管理内存中最大的一块
- 堆里面存放对象的实例
- GC的主要工作区域
- 线程共享
- 堆主要由分代算法(Generational)分为新生代和老年代
- 新生代(Young Generation)
- 新生成的对象都放在新生代,新生代用复制算法进行GC(理论上,新生代对象生命周期非常短,所以适合复制算法)
- 新生代分为3个区:一个Eden区,两个Survivor区(可以通过参数设置Survivor个数),对象在Eden区生成,当Eden区满时,进行GC,把还存活的对象放入一个Survivor区(From Survivor),而这个Survivor区满了之后,把存活的对象放入另一个Survivor区(To Survivor),第二个Survivor区也满了之后,JVM可能会将从第一个Survivor区复制过来的且还存活的对象复制到老年代(根据对象年龄决定,可用参数进行设置),2个Survivor完全对称,轮流替换
- 一般情况下,Eden区和2个Survivor区的内存占比是8:1:1,也就是10%的空间会浪费,可以根据GC Log的信息调整比例的大小
- 老年代(Old Generation) :在新生代中经历了多次(具体看虚拟机配置的阀值)GC后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率比较高,老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢,所以老年代一般使用标记-清除(Mark-Sweep)或标记-整理(Mark-Compact)算法
- 新生代(Young Generation)
- 方法区:
- 由于GC“性价比”较低,所以JVM虚拟机规范表示可以不在方法区实现GC,但是主流商业JVM都有在方法区实现GC,一般使用一般使用标记-清除(Mark-Sweep)或标记-整理(Mark-Compact) 算法
- 主要回收两部分内容:废弃常量与无用类
- 类回收需要满足以下条件:
- 该类的所有实例都已被GC,也就是JVM种不存在该类的任何实例
- 加载该类的ClassLoader已被GC
- 该类对应的Java.lang.Class对象没有任何地方被引用,如不能在任何地方通过反射访问该类的方法
- tips:由于JVM会一直引用3个类加载器(根,拓展,系统),而类加载器又会一直引用自身所加载的类,所以JVM只可能回收由我们自己定义的类加载器所加载的类!
2、垃圾判断算法
- 引用计数算法(Reference Counting):
- 给对象添加一个引用计数器,当有一个地方引用他,计数器加1,当引用失效,计数器减1,任何时刻计数器为0的对象就不可能再被使用
- 引用计数算法无法解决对象循环引用的问题
- 根搜索算法(GC Roots Tracing):
- 算法的基本思路就是通过一系列的称为“GC Roots”的点作为起始向下搜索,当一个对象到GC Roots没有任何引用链(Reference Chain)相连,则证明此对象是不可用的
- 在Java语言中,GC Roots包括:
- 在VM栈(帧中的本地变量)中的引用
- 方法区中的静态引用
- JNI(即一般所说的Native方法)中的引用
- 进行枚举根节点时,并不需要一个不漏的检查完所有执行上下文和全局的引用位置,在HotSpot的虚拟机中,虚拟机使用一组称为OopMap的数据结构来存放对象引用
- 在实际的生产语言(Java、C#等)中,都是使用根搜索算法来判断对象是否存活
3、GC算法
- 标记-清除算法(Mark-Sweep):
- 算法分为“标记”和“清除”2个阶段,首先标记处所有需要回收的对象,然后清除所有需要回收的对象
- 存在的问题:效率问题,因为需要扫描所有对象,堆越大,效率越慢,所以标记和清除效率都不高
- 存在的问题:空间问题,标记清理之后会产生大量不连续的内存碎片,空间碎片太多可能导致后续使用过程中无法找到连续内存而提前出发下一次GC,GC次数越多,碎片越严重
- 标记-整理算法(Mark-Compact):
- 与标记-清除算法过程相似,只是不再是直接清理,而是将所有存活对象往一端移动,最后清理掉这端边界以外的内存
- 与标记-清除算法相比,优化了空间问题,不会再产生内存碎片,但是效率降低了
- 复制算法(Copying):
- 将可用内存区域划分为2块,每次只使用其中的一块,当半区内存用完了,仅将还存活的对象复制到另一块上面,然后将原半区整块内存空间一次性清理掉
- 复制算法使得每次内存回收都是对整个半区的回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针(指针碰撞),按需分配内存即可,实现简单,运行高效。
- 复制算法的代价是将内存缩小为原来的一半,代价高昂,而且复制算法在对象存活率高的时候,效率有所下降,所以一般使用在新生代而不是老年代
- 分代算法(Generational): 综合前面几种算法的优缺点,针对不同的生命周期对象采用不同的算法
- 分代算法将堆中的内存分为年轻代和老年代,然后在不同的内存中使用上诉所讲的不同算法实现的垃圾回收器!
- 当前商业虚拟机都是采用分代算法
4、垃圾回收器
可以看作GC算法的具体实现
- GC的时机: GC从时机上分为2种,Scavenge GC和Full GC
- Scavenge GC(Minor GC)
- 触发时机:新对象生成时,Eden空间满了
- 理论上Eden上大多数对象会在Scavenge GC中回收,复制算法执行效率会很高,Scavenge GC执行时间较短
- Full GC:
- 对整个JVM进行整理,包括新生代,老年代和元空间
- 主要触发时机:1)Old满了,2)meta space满了,3)System.gc()
- 效率很低,尽量减少Full GC
- Scavenge GC(Minor GC)
- 内存回收的概念: GC要做的就是将那些dead的对象所占用的内存回收掉
- HotSpot认为没有引用的对象是dead的,HotSpot将引用分为4种:Strong(例如我们自己new的对象),Soft,Weak,Phantom
- 在Full GC时会对Reference类型的引用做特殊处理
- Soft:内存不够一定会被GC,长时间不用也会被GC
- Weak:一定会被GC,当mark为dead时,会在ReferenceQueue中通知
- Phantom:本来就没引用,当从JVM Heap释放时会通知
- GC的“并行”和“并发”
- 并行(Parallel):指多个收集器线程同时工作,但是用户线程处于等待状态
- 并发(Concurrent):指收集器在工作的同时,可以允许用户线程工作
- tips:并发并不代表解决了GC停顿的问题,在关键的步骤还是要停顿。比如收集器在标记垃圾的时候,但在清除垃圾的时候,GC线程和用户线程可以一起执行
- 安全点(Safepoint)与安全区域(SafeRegion)
- 安全点(Safepoint)
- 在OopMap的协助下,HotSpot可以快速且准确的完成GC Roots枚举,但一个问题随之而来,可能导致引用关系变化,或者说使OopMap内容变化的指令非常多,为每一条指令都生成对应的OopMap需要大量的额外空间,这样GC的成本会更高。
- 实际上,HotSpot并没有为每条指令都生成OopMap,而只是在特定位置记录这些信息,这些地方就称为安全点,即程序执行时并不是在所有地方都能停顿下来进行GC,只有在到达安全点时才能暂停
- 另一个需要考虑的问题就是如何让GC时让所有线程都“跑”到最近的安全点再停顿下来,JVM有2种解决方法
- 抢占式中断(Preemptive Suspension): 不需要线程的执行代码主动区配合,在GC发生时,首先把所有线程暂停,如果有线程不在安全点上,则恢复该线程让他跑到安全点上
- 主动式中断(Voluntary Suspension): 当GC需要中断线程的时候,不直接对线程操作,仅仅设置一个标志,各个线程执行时主动区轮询这个标志,发现中断标志时则自己挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的方法。
- 简单的概括: 抢占式中断就是先暂停所有线程,然后让没到安全点的线程跑到安全点上,而主动式中断则是线程每到一个安全点就判断一下GC是否在执行,如果GC在执行则自动挂起。
- tips:现在几乎没有虚拟机采用抢占式中断来暂停线程从而响应GC事件
- 安全区域(SafeRegion)
- 虽然表面上Safepoint能完美解决线程如何进入GC的问题,但实际情况却不一定,如果当线程处于不执行状态(如sleep或blocked)时,线程不能自己进入安全点,所以这种情况,就需要安全区域来解决了
- 当线程执行到SafeRegion的代码时,会标识自己进入了SafeRegion,这样当GC时,就不用管那些进入SafeRegion状态的线程,当线程要离开时,会检查系统是否已经完成了根节点枚举(或是整个GC过程),如果完成,则正常离开,否则原地等待
- 安全点(Safepoint)
- Serial收集器
- 最早的收集器,单线程进行GC
- 收集时会暂停所有工作进程(Stop the World,简称STW)
- 新生代和老年代都可以使用
- 在新生代使用复制算法,在老年代使用标记-整理算法
- 因为是单线程GC,没有多线程切换的额外开销,简单实用
- Hotspot Client模式缺省的收集器
- Serial Old收集器: 单线程收集器,使用标记-整理算法,是老年代的收集器
- ParNew收集器:
- Serial收集器在新生代的多线程版本
- 使用复制算法(因为只针对新生代)
- 只有在多CPU的环境下,效率才会比Serial高
- 可以通过 -XX:ParallelGCThreads 来控制GC线程数的多少。需要结合具体CPU个数
- Server模式下新生代的缺省收集器
- Parallel Scavenge收集器
- 与ParNew相同的是,都是多线程处理器,也使用复制算法
- 与ParNew不同的是,它是以吞吐量最大化(GC时间占总运行时间最小)为目标实现,它允许较长时间的STW以获得最大吞吐量
- Parallel Old收集器: 老年代版本吞吐量优先处理器,使用多线程和标记-整理算法
- CMS(Concurrent Mark-Sweep)收集器
- 追求最短停顿时间,非常适合Web应用
- 使用标记-清除算法(Mark-Sweep),只针对老年代,一般结合ParNew使用
- 只有在多CPU环境下才有意义
- Concurrent,GC线程和用户线程并发工作(尽量并发)
- 使用CMS并不能使GC效率最高,但它能尽可能降低服务停顿时间
- 使用参数 -XX:+UseConcMarkSweepGC告诉JVM使用CMS收集器
- CMS收集器步骤
- 初始标记(Initial Mark): 会触发STW,标记老年代中那些直接被GC root引用或者被年轻代存活对象所引用的所有对象
- 并发标记(Concurrent Mark): 根据上个阶段找到的GC roots遍历老年代,标记所有存活的对象,由于与用户线程并发执行,所以在标记期间用户线程可能会改变一些引用
- 并发预清理(Concurrent Preclean): 同样与用户线程并发执行,查找前一阶段执行过程中,从新生代晋升或新分配或被更新的对象。通过并发地重新扫描这些对象,预清理阶段可以减少下一个stop-the-world 重新标记阶段的工作量
- 可终止预清理(Concurrent Abortable Preclean): 并发可中止的预清理阶段。这个阶段其实跟上一个阶段做的东西一样,也是为了减少下一个STW重新标记阶段的工作量。增加这一阶段是为了让我们可以控制这个阶段的结束时机,比如扫描多长时间(默认5秒)或者Eden区使用占比达到期望比例(默认50%)就结束本阶段
- 重标记(Final Remark): 第二个触发STW的阶段,由于之前的阶段是并发执行的,GC可能跟不上用户线程的变化,所以需要重新标记老年代中存活的对象
- 并发清理(Concurrent Sweep): 并发,清除前几个阶段标记的对象
- 并发重置(Concurrent Reset): 并发,重设CMS内部的数据结构,为下次的GC做准备
- CMS优点: 并发收集,低停顿,Oracle公司的一些官方文档中也称之为并发低停顿收集器(Concurrent Low Pause Collector)
- CMS缺点:
- CMS以牺牲CPU资源为代价来减少用户线程的停顿。当CPU个数少于4时,可能对吞吐量影响非常大
- CMS在并发清理过程中,用户线程还在跑,此时需要预留一部分空间给用户进程
- CMS用Mark-Sweep,会带来空间碎片问题,碎片过多可能会导致频繁触发Full GC
- CMS无法处理浮动垃圾,可能出现“Concurrnet Mode Failure”失败而导致Full GC,要是CMS运行期间预留的内存无法满足程序需要,虚拟机会启动后背预案:临时启用Serial Old收集器重新进行老年代收集,这样停顿时间就很长了
- G1收集器:内容比较多,下一篇博客
5、常用JVM启动参数