vlambda博客
学习文章列表

Java虚拟机和GC知识点,看这一篇就够了

    虚拟机、堆栈,和GC,始终是Java相关面试的一大考点。笔者在此整理一下相关知识点,力求最全面、最准确。

    参考文献:

[1]《深入理解Java虚拟机》周志明著

[2] https://blog.csdn.net/weixin_28760063/article/details/81271611

[3] https://blog.csdn.net/high2011/article/details/80177473

[4] https://www.zhihu.com/question/41922036/answer/93079526

1 Java虚拟机

    Java虚拟机会在执行Java程序的过程中把其所管理的内存区域划分为如下几个模块:

JavaRuntime

1.1 程序计数器

    程序计数器(Program Counter Register),占用内存空间比较小,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时,通过改变这个计数器的值,来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器完成。

    由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,而又因为一个处理器(内核)同一时间只会执行一个线程的指令,所以为了线程切换后能恢复到正确的位置,Java虚拟机里的每个线程都有一个独立的程序计数器。

    此内存区域是唯一一个在Java虚拟机规范中没有规定任何OOM的区域。

1.2 Java虚拟机栈

    Java虚拟机栈(Java Virtual Machine Stacks),也是线程私有的。它描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。 每一个方法从调用到完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

    Java内存的堆栈其中的,就是虚拟机栈,或者说是虚拟机栈中局部变量表部分。它的存储比堆快得多,只比CPU里的寄存器慢。

    Java虚拟机栈规范规定了两种异常:线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够内存,就会抛出OOM异常。

    栈内存在JVM中默认是1M,可以通过下面的参数进行设置:

-Xss

1.3 本地方法栈

    本地方法栈(Native Method Stack)与虚拟机栈的作用类似,只不过是为了Native方法。在虚拟机栈规范中对本地方法栈中使用的语言、使用方式和数据结构并没有强制规定。甚至有的虚拟机(如Sun Hotspot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。和虚拟机栈一样,同样也有两种异常。

1.4 堆

    Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。堆内存是所有线程共享的内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

    最小堆内存-Xms在JVM中默认物理内存的1/64,最大堆内存-Xmx在JVM中默认物理内存1/4,且建议最大堆内存不大于4G,并且设置-Xms=-Xmx避免每次GC后,调整堆的大小,减少系统内存分配开销。

-Xms 
-Xmx

    从内存回收的角度看,由于现在的收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细分有Eden空间、From Survivor空间、To Survivor空间(这些空间用于下文的复制算法)。从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

    使用参数配置新生代的大小:

-Xmn

    IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分Eden和Survivor空间。

    使用参数配置Eden和Survivor区的大小,使用比例配置,默认为8,就是Eden内存:From Survivor:To Survivor=8:1:1:

-XX:SurvivorRatio

    还可以配置新生代和老年代的比例:

-XX:NewRatio

    根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现上也可以扩展(通过-Xmx和-Xms来控制)。如果没有内存可分配、并且无法再扩展,就会抛出OOM异常。

1.5 方法区

    方法区(Method Area),也是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。有一个别名叫做Non-Heap(非堆)。

    HotSpot虚拟机选择把GC分代收集扩展至方法区,或者说用永久代来实现方法区,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存(即也可以GC,永久代里的常量池没有被引用以及一些无用的类信息和类的Class对象也会被回收)。对于其他虚拟机(如BEA JRockit、IBM J9)是不存在永久代的概念的。

    一般配置128M就够了,设置原则是预留30%空间,它可以通过如下参数进行大小配置:

-XX:PermSize
-XX:MaxPermSize

1.5.1 运行时常量池

    运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池中存放。

1.6 直接内存

    直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁使用,而且也有可能导致OOM异常。

    在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用来操作。在需要Java堆和Native堆中来回复制数据的场景中可以显著提高性能。

2 GC(Garbage Collection)

JavaGC

    上文提到,GC主要面向Java堆,并且Java堆又可以分为几代:

  • 新生代:一般来说新创建的对象都分配在这里。新生代又分为了Eden空间、From Survivor空间和To Survivor空间。后两者空间大小相同,且保证一个为Empty。三者比例为8:1:1。

  • 老年代:经过几次GC后还alive的对象,就会放在老年代里。老年代保存的对象更持久。一些比较大的对象直接分配到老年代。

  • 永久代:上文提到,HotSpot中是采用方法区实现的,里面存放的是class相关的信息,一般不会进行GC。

2.1 GC类别

    针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:

2.1.1 Partial GC:并不收集整个GC堆的模式

  • Young GC:只收集新生代的GC。当新生代中的Eden区分配满的时候触发。注意Young GC中有部分存活对象会晋升到老年代,所以Young GC后老年代的占用量通常会有所升高。

  • Old GC:只收集老年代的GC。只有CMS的Concurrent collection是这个模式。

  • Mixed GC:收集整个新生代以及部分老年代的GC。只有G1有这个模式。

2.1.2 Full GC

    收集整个堆,包括新生代、老年代、永久代(如果存在的话)等所有部分的模式。

2.1.3 关于Major GC必须要说的

    Major GC通常是跟Full GC是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了。网上有很多文章指出Major GC就是Full GC,这是不绝对的,当有人说“major GC”的时候一定要问清楚他想要指的是上面的Full GC还是单单针对老年代的GC。

2.2 判断对象是否“已死”的算法

2.2.1 引用计数器算法

    每个对象添加一个引用计数器,如果计数器为0则表示已经没有其他地方在引用它。如果有一个地方引用就+1,引用失效就-1。这会带来一个严重的问题——对象循环引用:如果对象A引用对象B,对象B又反过来引用对象A,此时它们的引用计数器都不为0,但是实际上已经没有任何地方指向它们。由此引入下面的算法。

2.2.2 可达性分析算法(标记算法)

    标记算法可以有效避免对象循环引用的情况出现。这个算法的基本思路是通过一系列的称为“GC Roots”的对象作为起点,从这些对象开始向下搜索并作标记,搜索所走过的路径称之为“引用链”(Reference Chain)。遍历完这棵树后,未被标记(用图论的话说就是GC Roots到这个对象不可达)的对象就会被判断为已死,即为可回收的对象。

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

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象。

  • 本地方法栈中JNI引用的对象。

  • 方法区中类静态属性引用的对象。

  • 方法区中常量引用的对象。

GCRoots

2.3 垃圾回收相关算法

2.3.1 标记-清除算法

    即上文标记过后,可以被回收的对象直接回收。这会带来的问题——内存碎片化。如果下次有比较大的对象实例需要在堆上分配较大的内存空间时,可能会出现无法找到足够的连续内存而不得不再次触发GC。

2.3.2 复制算法

    上文提到,此算法只用于新生代的GC。每次使用Eden和其中一块Survivor,当回收时,将Eden和Survivor中还存活的对象一次性地复制到另外一块Survivor空间上,然后清理Eden和刚才用过的Survivor空间。这种算法的缺点就是总会有一部分内存(一个Survivor空间)用于复制。

    没有办法保证每次回收时一个Survivor空间足够,当不够时,需要依赖其他内存(这里指老年代)进行分配担保。这种情况下,存活对象通过分配担保机制进入老年代。

2.3.3 标记-压缩算法(或称为标记-整理算法,Mark-Compact)

    复制算法在对象存活率较高时,会进行较多的复制操作。对于存活率较高的老年代,一般都不用复制算法。首先还是“标记”,标记过后,将不用回收的内存对象压缩到内存一端,此时即可直接清除边界处的内存,这样就能避免复制算法带来的效率问题,同时也能避免内存碎片化的问题。

3 垃圾回收器

3.1 新生代收集器

3.1.1 串行(Serial)收集器

    串行垃圾回收器在进行GC时,会持有并冻结所有应用程序的线程,使用单个垃圾回收线程来进行GC操作。它是为单线程环境设计的。到目前为止,它依然是虚拟机运行在Client模式下的默认新生代收集器。但其单线程的意义更重要的是在进行GC时,必须暂停其他工作线程直到收集结束(Stop the World)。

    串行回收器是最古老的收集器,在JDK1.3.1以前是虚拟机新生代收集的唯一选择。可能会产生较长时间的停顿。采用“复制”算法,垃圾收集的过程中会Stop The World(服务暂停)。使用方法:

-XX:+UseSerialGC

3.1.2 ParNew收集器

    是前者的多线程并行版本。没有太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器。其中一个很重要的原因是:除了串行收集器外,只有它能与CMS配合工作。它是使用CMS后的默认新生代收集器,也可以使用以下选项强制指定它:

-XX:+UseParNewGC

3.1.3 Parallel Scavenge收集器

    Parallel Scavenge收集器类似ParNew收集器,但它更关注系统的吞吐量,而其他收集器的关注点是尽可能缩短GC时用户线程的停顿时间。这里所谓吞吐量,是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即:吞吐量 = 运行用户代码时间/(运行用户代码时间+GC时间)。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行状况收集性能监控信息动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例:

-XX:MaxGCPauseMillis
-XX:GCTimeRatio

3.2 老年代收集器

3.2.1 Serial Old收集器

    串行收集器的老年代版本。使用“标记-整理”算法。这个收集器的主要意义也是在于给Client模式使用。如果在Server模式下,还有两个用途:

  1. 给JDK1.5或之前的版本中与Parallel Scavenge收集器搭配使用。

  2. 作为CMS的backup方案,在并发收集发生Concurrent Mode Failure时使用。

3.2.2 Parallel Old收集器

    Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-压缩”算法。这个收集器在JDK1.6中开始提供。在此之前,新生代收集器Parallel Scavenge收集器一直处于比较尴尬的状态。原因是:如果新生代选择了Parallel Scavenge,老年代除了Serial Old之外别无选择(因为CMS无法与Parallel Scavenge配合工作)。在老年代很大并且硬件配置很好的情况下,并不能获得很好的吞吐量。

3.2.3 并发标记扫描CMS收集器

    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视响应速度,希望停顿时间最短。

    Java官方介绍:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html

    从名字(Mark Sweep)上就可以看出是基于“标记-清除”算法实现的。整个过程分为4步:

  1. 初始标记(CMS initial mark)

  2. 并发标记(CMS concurrent mark)

  3. 重新标记(CMS remark)

  4. 并发清除(CMS concurrent sweep)

    其中,初始标记和重新标记,仍然需要“Stop the World”。
初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。

    并发标记就是进行GC Roots Tracing(遍历所有节点的可达性分析)的过程。

    重新标记阶段,是为了修正并发标记期间,因为程序继续运行而产生变动的那一部分对象。这个阶段停顿时间一般会比初始标记阶段稍长,但远比并发标记的时间短。

    由于整个过程中,耗时最长的并发标记和并发清除这两个过程的收集器线程,都可以与用户线程同时工作,所以总体来说,CMS的GC过程是与用户线程一起并发执行的。因为垃圾收集阶段用户线程还要运行,所以需要预留足够空间给用户线程使用。所以CMS不能像其他收集器那样等到老年代几乎填满再进行收集。在JDK1.5的默认设置下,CMS当老年代使用了68%的空间后就会被激活。也可以通过参数来提高触发百分比:

-XX:CMSInitiatingOccupancyFraction

    在JDK1.6中,CMS的启动阈值已经提升至92%。

    CMS的缺点:

  1. CMS收集器对CPU资源非常敏感(或者说面向并发设计的程序都对CPU资源比较敏感)。在并发阶段,虽然不会导致用户线程停顿,但是总会因为占用了一部分CPU资源,导致应用程序变慢,总吞吐量降低。来看《深入理解Java虚拟机》中的一个例子:

    CMS默认启动的回收线程数是(CPU数量+3)/4,也就是说,当CPU在4个以上时,并发回收时垃圾收集线程占永远不少于25%的CPU资源,并且随着CPU数量的增加而下降,无限趋近于25%。但是当CPU不足4个(譬如2个)时,CMS对用户的影响就变得很大。

    为了应对这种情况,虚拟机提供了一种名为“增量式CMS”(i-CMS)的CMS变种,所做的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记-清理的时候让GC线程、用户线程交替运行,尽量减少GC的独占时间,但是整个过程会变长。已经deprecated。

  1. CMS无法处理浮动垃圾。可能出现“Concurrent Mode Failure”导致另一次Full GC的产生。由于CMS是并发清除的,此时用户线程还在运行,伴随运行自然会有新垃圾产生,这一部分垃圾出现在标记之后,CMS只能等下次GC再处理。上文提到CMS需要预留空间来保证用户线程正常工作。如果在CMS运行期间预留内存无法满足需要,就会出现一次“Concurrent Mode Failure”。这时虚拟机将启动backup方案:临时启用Serial Old收集,会导致停顿时间变长。

  2. 由于CMS采用“标记-清除”,自然也会有其缺点,即产生大量空间碎片,可能会提前更多触发Full GC。为了解决这个问题,CMS提供了一个开关参数,用于在CMS顶不住要进行Full GC时,开启内存碎片的合并整理过程:

-XX:+UseCMSCompactAtFullCollection

    这个过程是无法并发的,会导致停顿时间变长。还有另外一个参数:

-XX:CMSFullGCsBeforeCompaction

    用于设置执行多少次不压缩的Full GC后,跟着来一次压缩的(默认为0,即每次都整理压缩)。

    CMS是默认的老年代垃圾收集器。当其失败后,就会使用老年代的Serial Old回收器,上文也有所提及。指定:

-XX:+UseConcMarkSweepGC

    设置Full GC后进行一次碎片整理(整理过程是独占的,停顿时间会变长):

-XX:+UseCMSCompactAtFullCollection

    设置进行几次Full GC后,进行一次碎片整理:

-XX:+CMSFullGCsBeforeCompaction

    设置CMS的线程数量(一般情况可约等于可用CPU数量):

-XX:ParallelCMSThreads

3.2.4 G1收集器

    G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。G1相比CMS,有以下特点:

  • 空间整合:G1采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。

  • 可预测停顿:降低停顿时间是G1和CMS共同的关注点。但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定:在一个长度为N毫秒的时间片段内,消耗在GC上的时间不超过N毫秒,这几乎是实时Java(RTSJ)的垃圾收集器的特征了。G1手机的对象不再是整个新生代或老年代,从内存布局来说,它将整个Java堆划分为多个大小相等的独立Region,虽然还保有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。

    G1的新生代收集和ParNew类似,当新生代占用达到一定比例,触发收集,和CMS类似,G1收集老年代会有短暂停顿。收集步骤:

  1. 初始标记:初始标记是停顿的(Stop the World),并且会触发一次普通的Minor GC。GC log:GC pause (young) (inital-mark)

  2. Root Region Scanning:程序运行过程中会回收Survivor区(存活到老年代),这一过程必须在新生代GC之前完成。

  3. 并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被新生代GC中断。在并发标记阶段,如果发现某个区域中的所有对象都可以被回收,那么区域会被立刻回收。同时,并发标记过程中,会计算每个区域的对象活性(存活对象的比例)。

  4. 再标记(Remark),会有短暂停顿,是用来收集并发阶段产生的新垃圾。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。

  5. Copy/Clean up,多线程清除失活对象,会有停顿。G1将回收区域的存活对象拷贝到新区域,清除Remember Sets,并发清空回收区域并把它返回到空闲区域链表中。

    《深入理解Java虚拟机》中,有一段引用:“如果你现在采用的收集器没有任何问题,就没有任何理由去现在去选择G1,ruguo你的应用追求低停顿,那G1现在已经作为一个可以尝试的选择,如果你的应用追求吞吐量,那G1并不会为你带来特别的好处”。