△Hollis, 一个对Coding有着独特追求的人△
这是Hollis的第 363 篇原创分享
作者 l zyz1992
作为 Java 程序员,我们是幸福的,因为我们不需要管理系统中的垃圾。我们只需要将重点放在业务中就可以了。至于垃圾什么的就交给天生的垃圾收集器就可以了。
那既然都这么说了,我们干嘛还要花心思来学习这些呢?我们学习这些肯定是为了更好的理解我们系统的底层运行原理啊,这样才能有针对性的写出 “更适合” JVM的代码。才能让我们的代码更健壮和安全,更关键的是能根据 JVM 的特性有针对性的进行调节和优化,最终写出执行效率高的代码。
到底谁是垃圾?
什么样的对象可以称为垃圾对象?换句话说:在垃圾收集器工作的时候,哪些对象是可以被回收的,哪些对象是不可以被回收的?判断的标准是什么?系统中的对象千千万,怎么才能准确无误的找出来并“杀”掉就显得尤为重要。
为了解决上面的问题。JVM 专门设计一套判断对象是的是垃圾的算法——可达性分析。
可达性分析的原理是:根据每一个对象,一层一层的引用往上找,说白了就是看看那些地方在引用着这个对象。直到找到能被称之为GC Roots的对象在引用这个这个对象,那么这个时候 JVM 就认为这个对象是不是垃圾对象。
也就是在垃圾回收的时候是不会去回收这部分对象的。反之,这样的对象就可以被称为垃圾对象。也就意味着是会被在垃圾收集器工作的时候就会回收这部分对象。
GC Roots
说到这里,哪些是垃圾对象我们是可以判断了。那么刚刚提到的 GC Roots 又是什么鬼?简单的来讲,静态变量、局部变量、常量、本地方法栈中的对象都可以当做GC Roots。但是一般最常见的就是:静态变量、局部变量。
我们姑且先这个记住,也就是凡是被这些对象引用的对象,就是不能被回收的。换言之,系统是在某些地方还在使用这些对象,这些对象我们也称之为强引用。对应的还有软引用,弱引用和虚引用。
- 强引用(使用频率:☆☆☆☆☆)
我们平时开发时候通过 new 关键创建出来的对象就是强引用,这类对象在垃圾回收的时候只要是能找到 G CRoots,那么他们是不会被回收的。
- 软引用(使用频率:☆☆☆☆)
所谓软引用,就是表示该对象在垃圾回收期间,不软是否被其他对象引用,只要是内存空间不够了,那么该对象就会别垃圾收集器回收。(PS:这个也是大家很容易和弱引用搞混淆的一个术语。我相信你平时开发常用的一定是 SoftReference ,而很少使用 WeakReference 。也就是说,强引用下面的一个就是软引用。希望能帮助大家理解这两个之间的区别。)
- 弱引用(使用频率:☆)
这类引用存在的价值更容易被忽视,只要是在垃圾回收阶段,不管内存是否足够,该类型的对象都会被垃圾收集器回收。
- 虚引用(使用频率:程序员基本不会使用到)
虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用
JVM 内存结构
到此为止,我们已经知道了哪些对象是垃圾已经如何判断垃圾对象了。接下来就是要回收了。但是在学习回收之前,我们还需要知道JVM内存区域的划分。换句话说就是回收对象是在哪里进行的?我们先来看下 JVM 的内存结构(这种模型仅仅是人们为了更好的学习和理解 JVM 还虚拟出来的)
以上结构看起来并不复杂,主要由五大部分组成:方法区、堆内存、虚拟机栈(栈)、本地方法栈(一般不关注)、程序计数器。其中方法区和堆内存是线程共享的。其他三个是线程私有的。他们的主要作用如下:
方法区
被所有线程共享的区间,用来保存类信息、常量、静态变量、也就是被虚拟机编译后的代码。换句话说:静态变量、常量、类信息(版本信息、方法签名、属性等)和运行时常量池存在方法区中。其中常量池是方法区的一部分。
堆内存(垃圾回收的重中之重)
是 Java 虚拟机所管理的所有的内存区域中最大的一块内存区域。堆内存被所有线程的共享。
主要存放使用 new 关键字创建的对象。所有对象实例以及数组都要在堆上分配。
垃圾收集器就是根据GC算法,收集堆上对象所占用的内存空间(这也是垃圾回收的重点核心区域)。
虚拟机栈
虚拟栈,是由一个一个的栈帧(栈帧:理解为方法的标记即可),他是线程私有的,生命周期同线程一样。
每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
局部变量表存放了编译期可知的各种基本数据类型(8个基本数据类型)、对象引用(地址指针)、returnAddress类型。局部变量表所需的内存空间在编译期间完成分配。在运行期间不会改变局部变量表的大小。
这个区域规定了两种异常状态:如果线程请求的栈深度大于虚拟机所允许的深度,则抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,在扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
程序计数器
程序计数器是线程私有的,里面记录的就是即将要执行的一条的CPU指令,因为在多线程环境中,必然会存在线程之之间的切换,这样JVM就需要有一套方案来记录某个线程在之前执行到了哪里。这就是程序计数器的作用
本地方法栈
记录的就是本地的通过C/C++ 写的一些程序(PS:这个空间中没有规定 OOM,也就是不会发生OOM的情况。因为程序计数器存储的是字节码文件的行号,而这个范围是可知晓的,在一开始分配内存时就可以分配一个绝对不会溢出的内存)
堆内存的详细结构
上面是说到了,堆内存是垃圾回收的重中之重,但是这并不意味这对象就是很笼统的在堆内存中的,他们也会被安排和分配到堆的不同的区域中。而堆内存主要是这么划分的,堆首先被划分成两大部分:年轻代(新生代)和老年代。年轻代又划分为:Eden、From Survivor、To Survivor。
其中年轻代和老年代所占的内存口空间比例为:1:2。年轻代中的Eden、From Survivor、To Survivor 占比为:8:1:1。画个图来帮助大家更形象的理解下:
大家不要急,一步一步来。
垃圾回收算法
从本小节开始,就是本文的重点了。JVM 在垃圾回收的时候:
① 到底使用了哪些垃圾回收算法?
② 分别在什么场景下使用?
③ 各自的优缺点?
下面就来正式的介绍下垃圾回收算法
标记-清除
标记清除是最简单和干脆的一种垃圾回收算法,他的执行流程是这样子的:当 JVM 标记出内存中的垃圾以后,直接将其清除,但是这样有一个很明显的缺点,就是会导致内存空间的不连续,也就是会产生很多的内存碎片。先画个图来看下
我们使用上图左边的图来表示垃圾回收之前的样子,黑色的区域表示可以被回收的垃圾对象。这些对象在内存空间中不是连续的。右侧这张图表示是垃圾回收过后的内存的样子。可以很明显的看到里面缠身了断断续续的 内存碎片。
那说半天垃圾不是已经被回收了吗?内存碎片就内存碎片呗。又能咋地?
好,我来这么告诉你,现在假设这些内存碎片所占用的口空间之和是1 M,现在新创建了一个对象大小就是 1 M,但是很遗憾的是,此时内存空间虽然加起来有 1 M,但是并不是连续的,所以也就无法存放这大对象。也就是说这样势必会造成内存空间的浪费,这就是内存碎片的危害。
这么一说标记-清除
就没有优点了吗?优点还是有的:速度快
到此,我们来对标记-清除来做一个简单的优缺点小结:
- 优点
速度快,因为不需要移动和复制对象
- 缺点
会产生内存碎片,造成内存的浪费
标记-复制
上面的清除算法真的太差劲了。都不管后来人能不能存放的下,就直接啥也不管的去清除对象。所以升级后就来了复制算法。
复制算法的工作原理是这样子的:首先将内存划分成两个区域。新创建的对象都放在其中一块内存上面,当快满的时候,就将标记出来的存活的对象复制到另一块内存区域中(注意:这些对象在在复制的时候其内存空间上是严格排序且连续的),这样就腾出来一那一半就又变成了空闲空间了。依次循环运行。
在回收前将存活的对象复制到另一边去。然后再回收垃圾对象,回收完就类似下面的样子:
如果再来新对象被创建就会放在右边那块内存中,当内存满了,继续将存活对象复制到左边,然后清除掉垃圾对象。
标记-复制算法的明显的缺点就是:浪费了一半的内存,但是优点是不会产生内存碎片。所以我们在做技术的时候经常会走向一个矛盾点地方,那就是:一个新的技术的引入,必然会带来新的问题。
到这里我们来简单小结下标记-复制算法的优缺点:
- 优点
内存空间是连续的,不会产生内存碎片
- 缺点
1、浪费了一半的内存空间
2、复制对象会造成性能和时间上的消耗
说到底,似乎这两种垃圾回收回收算法都不是很好。而且在解决了原有的问题之后,所带来的新的问题也是无法接受的。所以又有了下面的垃圾回收算法。
标记-整理
标记-整理算法是结合了上面两者的特点进行演化而来的。具体的原理和执行流程是这样子的:我们将其分为三个阶段:
第一阶段为标记;
第二阶段为整理;
标记:它的第一个阶段与标记-清除算法是一模一样的,均是遍历 GC Roots,然后将存活的对象标记。
整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。
我们是画图说话,下面这张图是垃圾回收前的样子。
下图图表示的第一阶段:标记出存活对象和垃圾对象;并清除垃圾对象
白色空间表示被清理后的垃圾。
下面就开始进行整理:
可以看到,现在即没有内存碎片,也没有浪费内存空间。
但是这就完美了吗?他在标记和整理的时候会消耗大量的时间(微观上)。但是在大厂那种高并发的场景下,这似乎有点差强人意。
到此,我们将标记-整理的优缺点整理如下:
- 优点
1、不会产生内存碎片
2、不会浪费内存空间
- 缺点
太耗时间(性能低)
到此为止,我们已经了知道了标记-清除、标记-复制、标记-整理三大垃圾回收算法的优缺点。
单纯的从时间长短上面来看:标记-清除 < 标记-复制 < 标记-整理。
单纯从结果来看:标记-整理 > 标记-复制 >= 标记-清除
知道了垃圾回收算法,还有以下这些问题等着我们去分析:
① 垃圾收集器都有哪些呢?
② 年轻代和老年代又分别是哪些垃圾收集算法?
③ 不同的垃圾收集器对应哪些垃圾回收算法?
④ 年轻代和老年代分别使用哪些垃圾收集器?
带着这些问题,让我们继续往下看。
什么样的垃圾会进入到老年代
我们现在已经知道了什么是垃圾,那现在问题是:什么样的垃圾会进入到老年代?对象进入老年代的条件有三个,满足一个就会进入到老年代:
- 1、躲过15次GC。每次垃圾回收后,存活的对象的年龄就会加1,累计加到15次(jdk8默认的),也就是某个对象躲过了15次垃圾回收,那么JVM就认为这个是经常被使用的对象,就没必要再带着年轻代中了。具体的次数可以通过 -XX:MaxTenuringThreshold 来设置在躲过多少次垃圾收集后进去老年代。
- 2、动态对象年龄判断。规则:在某个 Survivor 中,如果有一批对象的大小总是大于该 Survivor 的 50%,那么此时大于等于该批对象年龄的对象机会会直接到老年代中。
- 3、大对象直接进入老年代。-XX:PretenureSizeThreshold 来设置大对象的临界值,大于该值的就被认为是大对象,就会直接进入老年代。
针对上面的三点来逐一分析。
躲过15次 GC
这个没啥好说的,最好理解,就是在执行了15次GC后,对象依旧存活,那么就将其移动到老年代中去,没执行一次垃圾回收,存活的对象的年龄就+1,具体的执行次数可以通过:-XX:PretenureSizeThreshold
参数来设置。
动态对象年龄判断
这就有点难理解了,不过一定会给你讲清楚的
再来看下这个规则:在某个 Survivor 中,如果有一批对象的大小总是大于该 Survivor 的 50%,那么此时大于等于该批对象年龄的对象机会会直接到老年代中。
o(╥﹏╥)o 还是没理解。。。我们画图来理解试试
假设现在 To 里面的如图两个对象大小总和50 M,且都是3岁了,因为 To 是100 M,所以这个时候我们就说在某个 Survivor 中,如果有一批对象的大小总是大于该Survivor 的 50%。这个时候大于等于该批对象年龄的对象机会会直接到老年代中。
再换换句话说就是:当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivor 修可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了。
例如Survivor区域里现在有一比对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了的多个年龄对象总和超过了区域的50%,此时就会把年龄n(含)以上的对象都放入老年代)。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在 Minor GC 之后触发的。
大对象直接进入老年代
这个就简单了,-XX:PretenureSizeThreshold 来设置大对象的临界值。如 -XX:PretenureSizeThreshold=1024 * 1024。即对象超过1M直接进入老年代。其实大对象直接进入到老年代还包含这种情况:那就是当 Eden 中执行了 Minor GC 后,存活的对象的大小是 超过了100M了(上图 from 和 to 都是100M)此时这些存活的对象也是直接进入到老年代。
说了半天对象都跑到老年代去了,那既然老年代这个牛逼,干嘛还分年轻代和老年代?年轻人,你不要急。后文我会全部道来。我们下面先来看看老年代空间如果不够用怎么办?
老年代空间分配担保
上面说到了,对象在哪些情况下会进入到老年代,年轻代倒是省心了,你不够了就放到老年代,那如果老年代也不够了呢?那又是如何处理呢?
实际上是这样的。在年轻代执行 Minor GC 之前,首先会检查老年代的可用空间的大小是否是大于新生代所有对象的大小。为什么是所有对象,不应该是存活的对象吗?
你想啊,假如年轻代经过一次 Minor GC 后所有的对象都是存活的,这是不是就尴尬了(PS:所以这还是我们需要考虑的“临界情况”。不要觉得一般情况或者是泛泛的说法,程序的严谨性就是在临界情况下体现出来的)
现在假设在 Minor GC之前,检查发现老年代空间还真不够了,那么首先会去检查-XX:HandlerPromotionFailure
的参数是否设置了,这个参数表示:是否设置空间分配担保。
- 是:就会判断老年代的剩余的空间的大小是否是大于之前的每一次 MinorGC 后进入老年代的对象的平均的大小
- 否:那么此时就会进行FULL GC来为老年代腾出一些空间
假设现在开启了空间分配担保,并且发现之前的每次 Minor GC 后的对象的平均大小(假设是10 M)是小于老年代可用空间的大小(假设现在是12 M)的,那么就会认为本次 Minor GC 后差不多也是10 M的对象进入到老年代。但是如果最终垃圾回收剩余存活对象大于13 M,那么就直接 OOM;
如果没有开启空间分配担保机制,那么就会触发一次 Full GC(老年代的垃圾回收称之为 Full GC),这样看看能不能在老年代中在腾出一些空间,因为一般老年代中的对象是长时间存活的,所以这招可能作用不是很大。
假设Full GC 结束了,再尝试进行 Minor GC ,此时又可能有好几种情况:
第一种情况:Minor GC 后,剩余的存活对象的大小是小于 from 区大小的,那么对象直接进入 from 区即可;
第二种情况:Minor GC 后,剩余的存活对象的大小是大于 from 区大小的,但是是小于老年区可用空间大小的,那么对象直接进入老年代;
第三种情况:Minor GC 后,剩余的存活的对象的大小是大于 from 区大小的,同时也大于老年区可用的空间的大小,这个时候就会根据XX:HandlerPromotionFailure的设置来触发一次 Full GC,如果此时 Full GC后老年代的空间还是不够存放 Minor GC 后剩下的对象。那么就 OOM。
上面说了这么多我们来画个图整理和理解下,以年轻代快满了为出发点(Minor GC前):
年轻代的垃圾回收算法
我们先来回头看下这张图(为了方便阅读,我直接复制下来)
对象在刚创建的时候(排除直接进入到老年代的情况)。我们认为都是被分配到年轻代的 Eden 中的,当 Eden 快满的时候,就会触发一次垃圾回收,称之为:Minor GC(一般情况下 Eden 中经过一次垃圾回收后存活的对象非常少,这就好像是一次请求创建了很多的临时变量和对象,请求结束这些基本就全是垃圾了,这就是为什么 from 和 to 比例这么小的原因)
将存活的对象移动到 from 区域,此时存活的对象的年龄就 +1 ,并且将 from 和 to 的指向交换位置。首先来看下刚刚回收完垃圾将对象转移到 from 的图
然后我们强调了一个词,将 from 和 to 的指向交换位置:
这样子其实就是下面的样子:
(PS:真正的内存空间的位置并没有变化,实际变化的是from 和 to 的指向,这样下次执行 Minor GC 的时候还是将存回的对象放在 from 区域,你懂了没?)
然后 Eden 区域继续存放新对象,当 Eden 再次快满的时候,又会技术出发 Yong GC(Minor GC 的另一个名字,为了让大家了解的更全面,故意都使用下),此时垃圾回收的是 Eden 和 to 区域中的垃圾,因为上一次存活了的对象到这一次不一定就存活了。然后将他们存活的对象在移动到 from 区域。然后交换 from 和 to 的位置指向。以此循环往复。
垃圾收集器
关于垃圾收集器其实现在更多关注的是 G1垃圾收集器,但是本文不会去介绍,这个会放在单独的一篇文章去介绍的。目前常见的垃圾收集器有:
①Serial 垃圾收集器
②Serial Old 垃圾收集器
③ParNew 垃圾收集器
④CMS 垃圾收集器
⑤Parallel Scavenge 垃圾收集器
⑥Parallel Old 垃圾收集器
⑦G1 垃圾收集器
他们具体工作在年轻代还是老年代我们来通过一张图说明:
箭头表示年轻代是 xxx 老年代可以是 xxx,表示一种对应关系。
通过java -XX:+PrintCommandLineFlags -version
命令可以查看当前 JVM 使用的垃圾收集器
新生代的垃圾收集器
Serial(Serial/Serial Copying)
最古老,最稳定,简单高效,GC时候需要暂停用户线程,限定单核CPU环境,是Client模式下的默认新生代收集器(基本不再使用)。
对应的 JVM 参数为 -XX:UseSerialGC
;开启后,会使用Serial(Young区使用)+Serial Old(Old区使用)组合收集器。新生代、老年代都会使用串行回收收集器,新生代使用【标记-复制算法】老年代使用【标记-整理算法】
ParNew(Parallel New Generation)
新生代是并行老年代是串行。
ParNew其实就是【Serial收集器新生代】的【并行多线程】版本。它是很多java虚拟机在运行Server模式下的新生代的默认的垃圾收集器。
最常见的场景的是配合老年代的CMS GC工作,其余的行为和Serial收集器一样 ParNew工作的时候同样需要暂停其他的所有的线程 对应的JVM参数为 -XX:UserParNewGC 启用ParNew收集器,只作用于新生代,不影响老年代 开启后,会使用ParNew(Young区使用)+Serial Old的收集器组合,新生代使用【复制算法】老年代 使用【标记-整理算法】
并行回收GC(Parallel/ParallelScavenge)(默认收集器)
新生代和老年代都是并行。
Parallel Scavenge 收集器类似ParNew也是一个新生代的垃圾收集器,使用的【复制算法】,是一个并行多线程的垃圾收集器。俗称吞吐量优先的收集器。一句话:串行收集器在新生代和老年的并行化。
- 可控的吞吐量(运行用户的的代码时间/(运行用户的代码时间+垃圾收集时间))。也即运行100分 钟,垃圾收集时间为1分钟,那么吞吐量就是99%。高吞吐量意味着高效的CPU利用率
- 自适应调节策略也是Parallel Scavenge 和 ParNew 的一个重要的区别(虚拟机会根据当前的 系统的运行情况手机性能监控信息,动态的调整这些参数以提供最合适的停顿时间(- XX:MaxGCPauseMillis)或最大的吞吐量)
- 如果新生区激活-XX:+UseParallelGC(或者是-XX:UseParallelOldGC他们可以互相激活)老 年区就自动使用Parallel Old,使用Parallel Scavenge收集器 - -XX:ParallelGCThreads=N 表示启动多少个线程 cpu>8 N=5/8 cpu<8 N=实际个数
老年代的垃圾收集器
串行GC(Serial Old/Serial MSC)
运行在Client模式(基本不再使用)
并行GC(Parallel Old/Parallel MSC)
是Parallel Scavenage的老年大版本,采用【标记-整理】算法;
并发标记清除GC(CMS)
是一种以获取最短回收停顿时间为目标的收集器。适用于重视服务器的相应速度,希望系统的停顿时间最短。
GC线程和用户线程一起执行(Serial Old将作为CMS出错的后备收集器),主要有4步过程(重要) - 初始标记 - 并发标记(和用户线程一起工作) - 重新标记 - 并发清除(和用户线程一起工作)
优点他的优点是并发收集低停顿、缺点是并发执行,对CPU压力大、采用的【标记-清除】算法会导致大量的碎片
目前最常见的组合是:ParNew + CMS 垃圾收集器。鉴于篇幅限制和为了大家更好的消化本文内容。更多关于 ParNew 和 CMS 的工作原理将放在下一篇文章分析。
本文小结
本文从【垃圾】到【垃圾回收算法】再到【垃圾收集器】,详细的介绍了 JVM 中最最基本的一些概念和原理。本文重点关注点是原理和流程,所以内容稍多,另外 JVM 的博大精深并非一朝一夕更不可能是一篇文章就能学会的。
但是本文希望能给大家在 JVM 在学习之路上添砖加瓦。
如果你喜欢本文,
请长按二维码,关注 Hollis.