JVM学习 之 垃圾收集器

作者: jwfy | 来源:发表于2018-06-24 22:55 被阅读6次

    本篇是JVM的第一篇学习笔记,主要学习书籍是《深入理解Java虚拟机 之JVM高级特性与最佳实践》,总结归纳自己的学习心得。

    目录

    JVM学习 之 垃圾收集器
    1、 Java 内存区域
    1.1、程序计数器
    1.2、虚拟机栈
    1.3、本地方法栈
    1.4、Java堆
    1.5、方法区
    1.6、运行时常量池
    1.7、直接内存
    2、垃圾回收器
    2.1、对象
    2.1.1、可达性分析算法
    2.1.2、引用类型
    2.1.3、方法区回收
    2.2、垃圾收集算法
    2.2.1、标记-清除算法
    2.2.2、复制算法
    2.2.3、标记-整理算法
    2.2.4、分代收集算法
    2.3、垃圾收集器
    2.3.1、Serial 收集器
    2.3.2、ParNew 收集器
    2.3.3、Parallel Scavenge 收集器
    2.3.4、Serial Old 收集器
    2.3.5、Parallel Old 收集器
    2.3.6、CMS 收集器
    2.3.7、G1 收集器
    2.4、常用参数
    3、后续

    1、 Java 内存区域

    在C、C++开发中,由开发者自行管理所有对象的内存申请与释放,但是Java有自己的内存管理机制,在绝大部分场景下可以自动完成内存的申请和内存释放,不太容易出现内存泄露和内存溢出的问题

    • 内存溢出Out Of Memory(OOM):直接导致的问题就是内存不够用,例如需要申请10M的内存,现在只有5M的可用内存,就会提示OOM错误,也就是内存溢出
    • 内存泄露Memory Leak:申请的内存空间没有很好的释放回收,经过多次内存泄露操作,就可能转变为OOM

    在Java程序执行过程中,虚拟机会把管理的内存根据不同的需求划分为若干不同的区域,如下图是《Java 虚拟机规范(Java SE 7版)》规定所包含的各种数据区域

    image

    1.1、程序计数器

    程序计数器(Program Counter Register)是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器就是通过修改该数据选取下一条需要执行的指令,包含循环、跳转、分支、异常处理等情况。

    每一个线程独有单独的程序计数器,每一个处理器在特定的时刻也只会运行一个线程。

    如果执行的是Java方法,记录的是正在执行的虚拟机字节码指令地址。如果是native方法,计数器为空(Undefined)。此内存区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

    1.2、虚拟机栈

    虚拟机栈(Java Virtual Machine Stacks)同样是线程私有,和线程有着同样的生命周期。

    每一个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、方法接口等信息,每一个方法从开始调用到完成的过程就是一个栈帧在虚拟机栈中入栈和出栈的过程。其中局部变量表存放了编译期可知的各种基本数据类型。局部变量表所需的空间在编译期完成分配,一个32位类型的数据占用一个局部变量空间(Slot)。

    • StackOverflowError:栈溢出错误,线程请求的栈深度超过了虚拟机所允许的深度,一般很难出现栈溢出错误,除非在代码中出现了循环申请栈的情况。
    • OutOfMemoryError:上面已经说了内存溢出是因为内存不够用导致的

    如何编写栈溢出错误的代码呢?很简单,我们上面已经说了每一个方法被调用就是入栈的操作,那么循环调用一个方法,不让其出栈,就可以出现栈溢出的错误了。具体如下图,栈深度达到了近1w8

    image

    1.3、本地方法栈

    本地方法栈(Native Method Stack)和虚拟机栈类似,但是本地方法栈则是服务于虚拟机使用到的native方法,例如一般的lib方法等,和虚拟机栈类似,同样会出现栈溢出和内存溢出的情况。

    1.4、Java堆

    Java堆是所有线程共享的内存区域,几乎所有的对象实例都是在这里分配内存。Java堆是垃圾收集器管理的主要区域,也被成为GC堆(Garbage Collected Heap),细分区域可以分为新生代和老年代;再细致一点则有Eden空间(伊甸区),Form Survivor、To Survivor等空间。

    从内存分配的角度出发,线程共享的Java堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)

    1.5、方法区

    方法区(Method Area)和Java堆一样,是由各个线程所共享的区域,被用来存储已经被虚拟机加载的类信息、常量、静态变量、即时编译的数据等信息。

    在HotSpot虚拟机上,更多人原因把方法去称为永久代(Permanent Generation),当内存不足时,则抛出OOM错误

    1.6、运行时常量池

    运行时常量池(Runtime Constant Pool)是方法区的一部分,用来存放各种生成的字面量,例如定义的一个String类型的数据,最后就被存在此处,同样的也会出现OOM错误

    1.7、直接内存

    直接内存(Direct Memory),不是虚拟机运行时区域内的数据,可以利用native方法,直接分配堆外内存,然后可以通过DirectoryByetBuffer对象对该数据进行读写操作,在NIO中利用该种方法可以极大的提高数据传输,大小不受虚拟机控制,但是会由机器的内存大小、处理器等因素印象。

    2、垃圾回收器

    了解和学习垃圾收集器,了解GC过程、内存分配便于我们实际解决各种内存溢出、内存泄露的问题,虽然绝大部分问题都已经由自动化的内存动态分配和内存回收机制解决掉了。

    GC过程需要考虑的三个问题

    • 哪些内存需要回收
    • 什么时候回收
    • 如何回收

    哪些内存需要回收,肯定是已经消亡的,生命周期结束了,已经不需要使用的对象。那么怎么判断对象是否真的是不需要使用的呢?

    2.1、对象

    2.1.1、可达性分析算法

    在堆中存放了JVM中几乎所有的对象实例,如何确认哪些对象是存活的还是消亡的,很多语言都是使用了引用计数器,当一个对象被引用一次就使得其对象的引用计数器+1,直到引用计数器为0,则认为当前对象没有被引用,可以被回收掉。

    之前我也是这样认为的,python也是类似的解决方案。但是java却没有使用该方法去管理内存,主要原因是其很难解决对象之间的相互引用的问题,例如ObjectA 引用了ObjectB,同时ObjectB也引用了ObjectA,那么就出现了相互持有的阶段,如果是引用计数器方法,意味着这两个对象永远不能被回收

    可达性分析算法

    在Java中使用的是叫可达性分析算法,这个算法是通过一系列成为GC Roots的对象作为起点,开始进行搜索操作,搜索的路径成为引用链,当一个对象到GC Roots没有任何引用链,也就是不可达,则证明此对象可以被回收。如下图,对象C和对象D是独立存在的,将会在搜索完成之后被判定为可回收对象

    image

    在Java中,可以作为GC Roots的对象包括为如下几种

    • 虚拟机栈中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法中JNI引用的对象

    2.1.2、引用类型

    在我们一般的理解中引用就是一个reference类型的数据所存储的值是另一块内存的起始地址,例如Object obj = new Object()这句代码,obj就是一个引用对象,指向着new Object()对象。在JDK1.2之后,Java对引用的概念进行了扩充,讲引用分为了强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),这四种引用强度依次逐渐减落。

    • 强引用:上面说的obj就是强引用,只要强已用一直在,垃圾收集器永远不会回收被引用的对象
    • 软引用:用来描述一些有用但不是必须的对象,在OOM之前,会将该类型对象放进回收范围内进行二次回收操作,如果这次回收之后还是没有足够的内存,才会发生OOM,现提供了SoftReference类提供使用
    • 弱引用:比软引用的强度更低,被关联的对象在下一次垃圾回收的时候就会被回收掉,提供了WeakReference类实现弱引用
    • 虚引用:也称为幽灵引用或者幻影引用,一个对象是否存在虚引用的存在完全不会对其生存时间产生影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能再这个对象被收集器回收的时候会收到一个系统通知,提供了PhantomReference类实现虚引用

    2.1.3、方法区回收

    在Java规范中确实说了不要气虚拟机在方法区实现垃圾回收,而且在方法区进行垃圾回收的性价比比较低。一般情况下,常规应用进行一次垃圾收集操作就会回收绝大部分的空间,在方法区进行垃圾收集的效率确实低于此。

    在永久代中垃圾收集主要是回收两部分内容:废弃变量和无用的类,那么如何判断一个类是否是无用的呢?

    • 该类所有的实例已经被回收,在JVM中不存在该类的任何实例
    • 加载该类的ClassLoader也已经被回收了
    • 该类对应的java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类方法

    2.2、垃圾收集算法

    2.2.1、标记-清除算法

    Mark-Sweep算法,包含了标记和清除两个阶段

    • 通过GC Roots方法,区分存活、将要被回收的对象,然后标记将要被回收的对象
    • 统一回收所有被标记的对象

    最基础的清除算法,后续的算法都是对其进行优化的结果

    主要的不足是

    • 效率问题:标记和清除操作的效率不高,需要暂停用户线程,而且需要递归遍历全堆的数据
    • 空间问题:完成标记清除后,会产生大量的不连续的内存碎片,如果后续需要插入连续的大块内存,没有足够的连续空间,则会引发新的垃圾手机操作

    2.2.2、复制算法

    复制算法的操作原理是标记出存活的对象,然后把其存活的对象拷贝至其他内存空间,然后对之前的整个内存空间进行回收,而且在内存分配的时候,不用考虑内存碎片的情况,移动指针,按顺序分配内存,不过这种算法对空间的使用率会偏低,他必须在空间使用到一定比率的时候就开始进行垃圾收集操作

    在HotSpot虚拟机中,将内存块分配为一块较大的Eden空间,还有两块较小的Survivor空间,每次都是使用Eden和其中的一块Survivor空间,最后把存活的对象拷贝至另一块未使用的Survivor空间,而对Eden和使用中的Survivor进行垃圾收集。默认情况Eden和Survivor的大小比率是8:1,这就意味着真正使用的内存空间是90%,剩下的10%就是被浪费掉的(处于不被使用的情况)

    那么这就存在一个问题了,如果存活的对象空间超过了10%,那么超出的对象讲通过分配担保机制存放到老年代(也就是上面说的方法区)

    该方法适用于朝生夕死的服务,对象的存活率较低的情况下,使得复制的工作最少,效率达到最高

    2.2.3、标记-整理算法

    Mark-Compact算法,标记过程和标记清除算法相同,但是后续步骤是把所有存活的对象移动到另一端,然后直接清除边界外的内存。

    2.2.4、分代收集算法

    现在的商业虚拟机的垃圾收集器都是采用分代收集(Generational Collection)算法,一般情况下把堆分为新生代和老年代,根据不同的年代特点使用最合适的收集算法。如果每次垃圾收集后的对象存活率很低,那使用复制算法效率最高,而老年代的对象存活率高,而且没有额外的空间对其进行分配担保,就必须使用标记-清除或者标记-整理算法进行回收操作。

    2.3、垃圾收集器

    垃圾收集器是垃圾回收算法的具体实现,一般情况下不同的厂商有着不同的垃圾收集器,主要学习的是HotSpot虚拟机的收集器关联关系,具体如下图。一个新生代的收集器和一个老年代的收集器相结合,用直线连接的收集器才能够配套使用,例如Serial不能和Parallel Old收集器一起使用。

    image

    不过有一个观点是必须接受的,目前不存在最好的收集器,更没有万能的收集器,只有在特定场景下的相对合适的收集器,需要结合具体应用场景。

    接下来就具体学习上面的几种收集器,学习他们的使用原理,优点和缺点,适用在什么样的场景下

    2.3.1、Serial 收集器

    早起最基础的新生代垃圾收集器,采用的是复制算法,是单一线程的收集器,在进行垃圾回收时,必须暂停其他所有工作线程的运行,直到收集结束。线程暂停也就是俗称的“Stop The World”,早起他是和老年代的Serial Old 一起工作完成收集的任务。如下图是Serial & Serial Old 收集器运行示意图

    image

    虚拟机在Client模式下默认的新生代收集器。对于限定单个CPU的环境来说,简单高效,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。所有Serial收集器对于运行在Client模式下的虚拟机是一个很好的选择

    2.3.2、ParNew 收集器

    Serial 收集器的多线程版本,同样是复制算法,在进行收集操作时,可以启用多个线程同时进行回收操作。

    image

    ParNew收集器在单CPU环境下绝对不会有比Serial收集器更好的效果,由于存在线程之间的同步切换等开销,该收集器在两个CPU环境中也未必就可以使得效率超过Serial收集器,可以使用-XX:parallelGCThreads参数来限制收集器线程数

    2.3.3、Parallel Scavenge 收集器

    同样是新生代的收集器,也是采用的复制算法,但是和ParNew收集器还是存在诸多不同的地方

    image

    Parallel Scavenge收集器主要的特点是达到一个可控制的吞吐量,吞吐量就是CPU用于运行用户代码的时间和CPU总耗时间的对比值,如果在100分钟内,垃圾回收花费了1分钟,则吞吐量就是99%,停顿时间越短越适合于需要与用户交互的程序,高吞吐量则可以充分的使用CPU时间,适合用于在后台运算而不需要太多的交互任务。

    Parallel Scavenge收集器提供了两个参数来精确控制吞吐量:

    • -XX:MaxGCPauseMillis 最大垃圾收集器停顿时间(大于0的毫秒数,停顿时间小了就要牺牲相应的吞吐量和新生代空间)
    • -XX:GCTimeRatio 吞吐量大小(大于0小于100的整数,默认99,也就是允许最大1%的垃圾回收时间)

    因为和吞吐量有着密切的关系,也被称为吞吐量优先收集器,除了上述两个参数外,还有一个开关参数-XX:+UseAdaptiveSizePolicy ,当这个参数打开后,不需要手工指定新生代的大小-Xmn和Eden与Survivor的比例-XX:SurvivorRatio、晋升老年代对象大小-XX:PretenureSizeThreshold等细节参数,虚拟机会根据当前的吞吐量自行适配以达到最合适的停顿时间或者最大的吞吐量

    自适应调节策略也是Parallel Scavenge 收集器和ParNew 收集器的区别之一

    2.3.4、Serial Old 收集器

    Serial收集器对应的老年代收集器,同样是单线程工作,使用标记-整理算法,具体如下图所示

    image

    2.3.5、Parallel Old 收集器

    是Parallel Savenge 收集器的老年代版本实现,使用多线程和整理-标记算法,从JDK1.6才开始提供的。

    image

    2.3.6、CMS 收集器

    CMS(Concurrent Mark Sweep)收集器是一种获取最短回收停顿时间为目标的收集器,主要应用在服务端,加快响应速度,基于标记-清除算法,他的运行主要是包含了4个步骤

    • 初始标记(CMS initial mark)
    • 并发标记(CMS concurrent mark)
    • 重新标记(CMS remark)
    • 并发清除(CMS concurrent sweep)

    其中初始标记、重新标记都是需要“Stop The World”,也就是暂停用户线程的执行。

    • 初始标记仅仅是标记通过GC Roots能直接关联的对象,速度很快
    • 并发标记就是进行GC Roots Tracing的过程,标记出所有的对象,会判断对象是否存活,可以和用户线程同步进行
    • 重新标记则是为了修正在并发标记阶段因用户线程执行导致标记产生变动的那一部分对象标记记录
    • 并发清除是耗时比较重要的操作,完成上述标记的需要被清除的对象的清除操作
    image

    CMS收集器的优点:并发收集低停顿,但是也有几个缺点

    • 对CPU资源非常敏感:默认的回收线程个数是(CPU数量+3) / 4,垃圾回收器始终不会占据低于25%的CPU资源,当CPU个数不足四个的时候,例如两个还需要分配近50%的CPU能力去执行收集器线程,这会极大的消耗CPU的有效资源
    • 无法处理浮动的垃圾:因为在并发清理阶段不会暂停用户线程,这其中就会产生新的额外的未回收垃圾,只能等到下一次GC时再清理,同时由于CMS收集器在垃圾收集阶段,用户线程还在运行,那还需要预留足够的内存空间提供给用户线程使用,因此CMS收集器不和其他收集器一般,等到没有内存了再进行收集操作,需要预留一部分空间提供给并发收集过程中的用户线程使用,在JDK1.5中,CMS收集器在老年代空间使用到了68%时,就会被激活,可以通过设置参数-XX:CMSInitiatingOccupancyFraction提供触发百分比,在JDK1.6中,该值默认修改为92%了,如果再CMS运行期间预留的内存无法满足用户线程的执行,会出现Concurrent Mode Failure错误,将会导致启动Full GC 操作。实际生产环境中需要注意该参数,如果该参数过大很容易导致大量的Concurrent Mode Failure失败,反而会影响性能
    • 标记清除算法本身的缺点:上文可知标记清除算法会导致大量的空间碎片,一旦没有足够的连续空间分配给大对象,则会提前出发Full GC 操作,为了解决该问题,CMS收集器提供了一个开关参数-XX:UseCMSCompactAtFullCollection,用户在CMS进行Full GC操作时进行的内存碎片合并整理的过程,这个阶段不是并发的,会使得停顿时间加长,还提供了另一个参数-XX:CMSFullGCBeforeCompaction,用来设置执行了多少次不压缩的Full GC后紧接着来一次带压缩的Full GC 操作,默认为0,表示每次进入Full GC操作时都进行碎片整理。

    2.3.7、G1 收集器

    G1(Garbage First)收集器是当前最完善的收集器,在JDK1.7中可用,未来可用替换掉CMS收集器,其特点包含了

    • 并行与并发:充分利用多CPU、多核环境下的硬件优势,使用多个CPU和缩短Stop The World停顿的时间
    • 分代收集:分代概念依旧保留,能够采用不同方式去处理新建对象和已经存活了一段时间、熬过多次GC的老年代对象以获取更好收集效果
    • 空间整合:整体是基于标记-整理的算法,但是局部来看则是采用了复制算法实现,这两种算法都不会产生内存空间碎片,收集后可提供规整的可用内存,有利于程序长时间运行,分配大对象不会因为没有可用内存而提前触发GC操作
    • 可预测的停顿:G1除了追求低停顿之外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间却不得超过N毫秒

    特点说明:

    • G1之前的收集器收集的范畴是整个新生代或者老年代,G1收集器则是把整个Java堆划分为多个大小相等的独立区域Region,新生代和老年代不再是物理概念上的隔离,都是一部分Region的集合
    • G1之所以可以建立可预测的停顿时间模型,是因为其可以有计划的避免在整个的Java堆中进行全区域的垃圾收集,他会跟踪各个Region里面的垃圾值大小,在后台维护一个优先队列,每次根据允许的时间,优先回收垃圾值最大的Region,这样可以有效的保证了G1收集器可以在有限的时间内获取尽可能高的收集效率
    • G1对内存化整为零的思路,虽然把内存划分为多个Region后,垃圾就完全按照Region为单位进行垃圾回收,Region本身是虚构的概念,和实际的物理区域没有太多的关系,必然存在某一个对象可能被多个毫无关联的Region所引用,这样就会导致对整个的Java堆的全局扫描,这明显是不能被接受的!为此在G1收集器中,Region之间的对象引用,以及其他收集器中新生代和老年代的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(分代的例子中就检查是否老年代对象引用了新生代的对象),如果是则通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中,当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可避免全堆扫描。

    其主要运行的步骤分为

    • 初始标记(Initial Marking)
    • 并发标记(Concurrenr Marking)
    • 最终标记(Final Marking)
    • 筛选回收(Live Data Counting And Evacution)
    image

    其中在最终标记阶段则是为了修正在并发标记阶段因用户线程执行导致标记发生变化的那一部分的标记情况,这段时间内的变化将记录在Remembered Set Logs中,最终标记的时候会把Remembered Set Logs的记录合并到Remembered Set中,需要暂停用户线程,但是可以并行执行

    最后的筛选回收阶段,G1收集器会评估各个Region的垃圾值,根据用户希望的GC停顿时间制定回收计划,因为停顿的只有一部分Region,所以可以大范围的提高回收效率。

    2.4、常用参数

    这些常见的参数以及使用特定还是需要熟悉的

    参数 描述
    -XX:+UseSerialGC Jvm运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收
    -XX:+UseParNewGC 打开此开关后,使用ParNew + Serial Old的收集器进行垃圾回收
    -XX:+UseConcMarkSweepGC 使用ParNew + CMS + Serial Old的收集器组合进行内存回收,Serial Old作为CMS出现“Concurrent Mode Failure”失败后的后备收集器使用
    -XX:+UseParallelGC Jvm运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old的收集器组合进行回收
    -XX:+UseParallelOldGC 使用Parallel Scavenge + Parallel Old的收集器组合进行回收
    -XX:SurvivorRatio 新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Subrvivor = 8:1
    -XX:PretenureSizeThreshold 直接晋升到老年代对象的大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
    -XX:MaxTenuringThreshold 晋升到老年代的对象年龄,每次Minor GC之后,年龄就加1,当超过这个参数的值时进入老年代
    -XX:UseAdaptiveSizePolicy 动态调整java堆中各个区域的大小以及进入老年代的年龄
    -XX:+HandlePromotionFailure 是否允许新生代收集担保,进行一次minor gc后, 另一块Survivor空间不足时,将直接会在老年代中保留
    -XX:ParallelGCThreads 设置并行GC进行内存回收的线程数
    -XX:GCTimeRatio GC时间占总时间的比列,默认值为99,即允许1%的GC时间,仅在使用Parallel Scavenge 收集器时有效
    -XX:MaxGCPauseMillis 设置GC的最大停顿时间,在Parallel Scavenge 收集器下有效
    -XX:CMSInitiatingOccupancyFraction 设置CMS收集器在老年代空间被使用多少后出发垃圾收集,默认值为68%,仅在CMS收集器时有效,-XX:CMSInitiatingOccupancyFraction=70
    -XX:+UseCMSCompactAtFullCollection 由于CMS收集器会产生碎片,此参数设置在垃圾收集器后是否需要一次内存碎片整理过程,仅在CMS收集器时有效
    -XX:+CMSFullGCBeforeCompaction 设置CMS收集器在进行若干次垃圾收集后再进行一次内存碎片整理过程,通常与UseCMSCompactAtFullCollection参数一起使用
    -XX:+UseFastAccessorMethods 原始类型优化
    -XX:+DisableExplicitGC 是否关闭手动System.gc
    -XX:+CMSParallelRemarkEnabled 降低标记停顿
    -XX:LargePageSizeInBytes 内存页的大小不可设置过大,会影响Perm的大小,-XX:LargePageSizeInBytes=128m

    3、后续

    在17年新推出的Java9,就开始把G1收集器作为默认的收集器,CMS不再被推荐使用
    在Java 7和Java8中的默认收集器是拥有高吞吐能力的Parallel GC收集器
    G1是一个拥有可以预测停顿时间模型的这么一个收集器,那么其优点必然就是低延迟了

    相关文章

      网友评论

        本文标题:JVM学习 之 垃圾收集器

        本文链接:https://www.haomeiwen.com/subject/zdteyftx.html