其他分享
首页 > 其他分享> > 全面阐述JVM原理

全面阐述JVM原理

作者:互联网

一 JVM入门

1. 什么是JVM

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。 JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台(不同的操作系统)上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

2. JDK/JRE/JVM是什么关系

JDK(Java Development Kit)是程序开发者用来来编译、调试Java程序用的开发工具包。JDK的工具也是Java程序,也需要JRE才能运行。为了保持JDK的独立性和完整性,在JDK的安装过程中,JRE也是安装的一部分。所以在JDK的安装目录下有一个名为jre的目录,用于存放JRE文件。

JRE(Java Runtime Environment,Java运行环境),也就是Java平台。所有的Java 程序都要在JRE下才能运行。普通用户只需要运行已开发好的java程序,安装JRE即可。

JVM(Java Virtual Machine,Java虚拟机)是JRE的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。

3. Java运行原理

JVM是Java的核心和基础,在Java编译器和OS平台之间的虚拟处理器。它是一种利用软件方法实现的抽象的计算机基于下层的操作系统和硬件平台,可以在上面执行Java的字节码程序。Java编译器只要面向JVM,生成JVM能理解的代码或字节码文件。Java源文件经编译成字节码程序,通过JVM将每一条指令翻译成不同平台机器码,运行在不同的OS平台,Java也是因JVM而实现了跨平台。
在这里插入图片描述

二 JVM规范

根据《Java虚拟机规范》规定,目前实现虚拟机规范的虚拟机有三种:Sun公司的HotSpot虚拟机、Oracle公司的JRockit虚拟机、IBM公司的J9虚拟机。其中Sun公司的HotSpot虚拟机是我们最常用的虚拟机。
JVM的基本结构一般如下图所示:
    在这里插入图片描述
由上图可知JVM基本机构包括:类加载器、JVM运行时数据区、执行引擎、本地库接口 。
这里我们说明一下JVM运行时数据区,运行时内存区主要可以划分为5个区域:
   虚拟机栈 ( Java Virtual Machine Stacks )
   本地方法栈 ( Native Method Stacks )
   程序计数器 ( Program Counter (PC) Register )
   方法区 ( Method Area )
   堆内存 ( Heap )

接下里就详细说明一下这些区域的区别:

1. 虚拟机栈 ( Java Virtual Machine Stacks )

Java虚拟机栈是线程私有的,生命周期与线程相同。
Java虚拟机栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池(运行时常量池的概念在方法区部分会谈到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。讲到这里,大家就应该会明白为什么 在 使用 递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了(当然在Java中,程序员基本不用关系到内存分配和释放的事情,因为Java有自己的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。
由于栈帧的进出栈,显而易见的带来了空间分配上的问题。如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlowError异常;如果虚拟机栈可以扩展,扩展时无法申请到足够的内存,将会抛出OutOfMemoryError,这种情况大多数是由于循环调用或递归调用带来的。
下图表示了一个Java栈的模型:
      在这里插入图片描述

   局部变量表:就是用来存储方法中的局部变量(包括在方法中声明的非静 态变量以及函数形参)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。
   操作数栈:想必学过数据结构中的栈的朋友想必对表达式求值问题不会陌生,栈最典型的一个应用就是用来对表达式求值。想想一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。
   指向运行时常量池的引用:因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。
   方法返回地址:当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。

  由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰。

2. 本地方法栈 ( Native Method Stacks )

本地方法栈是线程私有的。
Java官方对于本地方法的定义为methods written in a language other than the Java programming language,就是使用非Java语言实现的方法,但是通常我们指的一般为C或者C++,因此这个栈也有着C栈这一称号。一个不支持本地方法执行的JVM没有必要实现这个数据区域。本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java虚拟机栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方发展的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。其大小也是可以设置为固定值或者动态增加,因此也会对应抛出StackOverflowError和OutOfMemoryError错误。

3. 程序计数器 ( Program Counter (PC) Register )

程序计数器是线程私有的,生命周期与线程相同。
程序计数器(Program Counter Register),也有称作为PC寄存器。想必学过汇编语言的朋友对程序计数器这个概念并不陌生,在汇编语言中,程序计数器是指CPU中的寄存器,它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。
虽然JVM中的程序计数器并不像汇编语言中的程序计数器一样是物理概念上的CPU寄存器,但是JVM中的程序计数器的功能跟汇编语言中的程序计数器的功能在逻辑上是等同的,也就是说是用来指示 执行哪条指令的。
由于在JVM中,多线程是通过线程轮流切换来获得CPU执行时间的,因此,在任一具体时刻,一个CPU的内核只会执行一条线程中的指令,因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。
在JVM规范中规定,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是native方法,则程序计数器中的值是undefined。
由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。

4. 方法区 ( Method Area )

方法区是线程共享区域,生命周期与JVM相同。
  方法区在JVM中也是一个非常重要的区域,它与堆一样,是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。
  在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。
  在方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。
  在JVM规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为“永久代”,是因为HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。不过自从JDK7之后,Hotspot虚拟机便将运行时常量池从永久代移除了。HotSpot虚拟机中JDK1.8开始使用元空间取代永久代。

① 方法区的特点:
  • 1、方法区是线程安全的,由于所有的线程都共享方法区,所以方法区里的数据访问必须被设计成线程安全的。
  • 2、方法区的大小不必是固定的,JVM可根据应用需要动态调整。
  • 3、方法区也可被垃圾收集,当某个类不在被使用(不可触及)时,JVM将卸载这个类,进行垃圾收集。
HotSpot 虚拟机,很多人愿意把方法区称为“永久代”(Permanent Generation)。本质上两者并不等价,仅仅是因为HotSpot 虚拟机的设计团队选择把GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机来说是不存在永久代的概念的。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
③ 方法区内存大小设置:
JDK8中永久代被移除了,取而代之的是元数据区。
不同的是元数据区是堆外直接内存,与永久代区不同,在不指定大小的情况下,虚拟机会耗尽所有可用的系统内存。
元数据区发生溢出,虚拟机一样抛出异常:java.lang.OutOfMemoryError Metaspace

5. 堆 ( Heap )

堆是线程共享区域,生命周期与JVM相同。
对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;新生代又可以细分为Eden空间、From Survivor空间(S0)和To Survivor空间(S1)。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。
默认情况下,老年代 ( Old ) 与新生代 ( Young ) 的比例的值为 2 :1( 该值可以通过参数 –XX:NewRatio来指定 ),即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小。
在这里插入图片描述
通过-XX:NewRatio参数指定老年代/新生代的堆内存比例。在HotSpot虚拟机中,堆内存 = 新生代 + 老年代。如果 -XX:NewRatio = 4,表示老年代与新生代所占比值为 4 :1,新生代占整个堆内存的1/5,老年代占整个堆内存的4/5。在设置了-XX:MaxNewSize的情况下,-XX:NewRatio的值会被忽略,老年代的内存 = 堆内存 - 新生代内存。老年代的最大内存 = 堆内存 - 新生代最大内存。

5.1 新生代
程序新创建的对象都是从新生代分配内存(新生代中绝大部分对象都是“朝生夕死”),新生代由Eden Space和两块相同大小的Survivor Space(通常又称S0和S1或From和To)构成。
可通过-Xmn参数来指定新生代的大小;也可以通过-XX:SurvivorRation来调整Eden Space及SurvivorSpace的大小。

5.2 老年代
老年代主要存放比较大的对象或者JVM认为生命周期比较长的对象(经过几次新生代的垃圾回收后仍然存在),垃圾回收也相对没有那么频繁。老年代的垃圾回收(又称Major GC)通常使用“标记-清除”或“标记-整理”算法(依据垃圾回收器而定)。整个堆空间(新生代和老年代)的垃圾回收称为Full GC。

6. JVM虚拟机规范的小结

名称 特征 存储对象 配置参数 异常
程序计数器
Program Counter (PC) Register
线程私有
生命周期与线程相同
占用内存小
存储字节码行号指示器
虚拟机栈
Java Virtual Machine Stacks
线程私有
生命周期与线程相同
使用连续的内存空间
局部变量表
操作树栈
动态链接
方法出口等信息
-Xss StackOverflowError
OutOfMemoryError
本地方法栈
Native Method Stacks
线程私有 Native方法信息 StackOverflowError
OutOfMemoryError
方法区
Method Area
线程共享
生命周期与JVM相同
可不使用连续的内存空间
已被虚拟机加载的类信息
常量
静态变量
编译器编译后的代码等信息
JDK7:
-XX:PermSize
-XX:MaxPermSize
OutOfMemoryError

Heap
线程共享
生命周期与JVM相同
可不使用连续的内存空间
保存对象实例
所有对象实例,包括数组
-Xms
-Xmx
-Xmn
OutOfMemoryError

三 JVM分代垃圾回收

1. 分代垃圾回收过程

堆是垃圾回收的主要区域。GC (Garbage Collection)的基本原理:将内存中不再被使用的对象进行回收,GC中用于回收的方法称为收集器,由于GC需要消耗一些资源和时间,Java在对对象的生命周期特征进行分析后,按照新生代、老年代的方式来对对象进行收集,以尽可能的缩短GC对应用造成的暂停。
不同的对象引用类型, GC会采用不同的方法进行回收,JVM对象的引用分为了四种类型:
(1)强引用:默认情况下,对象采用的均为强引用(这个对象的实例没有其他对象引用,GC时才会被回收);
(2)软引用:软引用是Java中提供的一种比较适合于缓存场景的应用(只有在内存不够用的情况下才会被GC);
(3)弱引用:在GC时一定会被GC回收;
(4)虚引用:由于虚引用只是用来得知对象是否被GC;
GC主要分为两种:Minor GC 和 Full GC(或称为Major GC,一般人们总是习惯把老年代的GC称之为Full GC)。

Minor GC、Major GC、Full GC区别
Minor GC : 针对新生代,从新生代回收内存
Major GC : 针对老年代,从老年代回收内存
Full GC : 针对真个堆空间( 包括新生代、老年代、方法区(永久代) ),清理整个堆空间

1.1 新生代垃圾回收过程
新生代的垃圾回收称为Minor GC,采用复制算法。新创建的对象优先进去新生代的Eden区,当Eden区满了之后再使用From Survivor区,当From Survivor也满了之后就进行Minor GC(新生代GC),将Eden和From Survivor中存活的对象复制到To Survivor,然后清空Eden和From Survivor,这个时候原来的From Survivor成了新的To Survivor,原来的To Survivor成了新的From Survivor。复制的时候,如果To Survivor无法容纳全部存活的对象,则根据老年代的分配担保(类似于银行的贷款担保)将对象copy进去老年代,如果老年代也无法容纳,则进行Full GC(老年代GC)。
如果创建的对象比较大,则直接进入老年代:JVM中有个参数配置-XX:PretenureSizeThreshold,令大于这个设置值的对象直接进入老年代,目的是为了避免在Eden和Survivor区之间发生大量的内存复制。

Minor GC触发的条件:
1、Eden区域满;
2、新创建的对象大小大于Eden区所剩空间大小;

1.2 老年代垃圾回收过程
长期存活的对象进入老年代:JVM给每个对象定义一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳,将被移入Survivor并且年龄设定为1。每熬过一次Minor GC,年龄就加1,当他的年龄到一定程度(默认为15岁,可以通过XX:MaxTenuringThreshold来设定),就会移入老年代。但是JVM并不是永远要求年龄必须达到最大年龄才会晋升老年代,如果Survivor 空间中相同年龄(如年龄为x)所有对象大小的总和大于Survivor的一半,年龄大于等于x的所有对象直接进入老年代,无需等到最大年龄要求。

Full GC触发条件:
1、老年代所剩空间不足;
2、方法区空间不足;
3、调用System.gc( ) 方法;
4、通过Minor GC后进入老年代的平均大小大于老年代的可用内存;
5、由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小;

2. 对象“已死”的判定算法

由于程序计数器、Java虚拟机栈、本地方法栈都是线程私有的,其占用的内存也是随线程生而生、随线程结束而回收。而Java堆和方法区则不同,线程共享的,是GC的所关注的部分。
在堆中几乎存在着所有对象,GC之前需要考虑哪些对象还活着不能回收,哪些对象已经死去可以回收。
有两种算法可以判定对象是否存活:
   (1)引用计数算法:给对象中添加一个引用计数器,每当一个地方应用了对象,计数器加1;当引用失效,计数器减1;当计数器为0表示该对象已死、可回收。但是它很难解决两个对象之间相互循环引用的情况。
   (2)可达性分析算法:通过一系列称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(即对象到GC Roots不可达),则证明此对象已死、可回收。Java中可以作为GC Roots的对象包括如下几种:

JVM是通过可达性分析算法来判定对象是否存活的。

3. 垃圾回收算法

JVM的垃圾回收算法有三种:标记-清除、复制、标记-整理
3.1 标记-清除
这种算法是最简单最直接的算法,也是其它算法的一些最初思路。标记清除算法其实就是对内存中的对象依次的进行判断,如果对象需要回收那么就打一个标记,如果对象仍然需要使用,那么就保留下来。这样经过一次扫描之后,所有的对象都会被筛选判断一次。紧接着会对内存中已经标记的对象依次进行清除。 这个算法比较简单粗暴,实现起来比较简单。
标记-清除算法有两个缺点:
(1). 效率问题:标记和清除需要两遍循环内存中的对象,标记和清除操作都是比较麻烦的工作,因此这种算法的效率不高。
(2). 空间问题:对于分配的内存来说,往往是连续的比较好,因为这样有利于分配大数据的对象。标记清除之后会产生大量不连续的内存碎片(类似于我们电脑的磁盘碎片),空间碎片太多导致需要分配大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作,也就是空间不足而导致频繁GC和性能下降。
在这里插入图片描述

3.2 复制
为了解决效率问题,出现了“复制”算法,他将可用内存按容量划分为大小相等的两块,每次只需要使用其中一块。当一块内存用完了,将还存活的对象复制到另一块上面,然后再把刚刚用完的内存空间一次清理掉。这样就解决了内存碎片问题,但是代价就是可以用内容就缩小为原来的一半。
算法使用了空间换取时间的思想,因此需要一块空白的区域作为内存对象要粘贴的区域。这无疑会造成一种浪费。只有在有效对象占据总回收内存是非常小的时候,这种算法的性价比才会达到最高。否则大量的复制操作所浪费的时间可能要远远大于空间换取时间得到的收益。因此这种算法在JVM中,也只被用来作为初级的对象回收。因为这时的有效对象比例最低,算法的性价比是最高的。
复制算法的优点:

  1. 如果垃圾对象较多的情况下,该算法效率比较高;
  2. 垃圾清理之后,内存不会出现碎片化;

复制算法的缺点:

  1. 并不适用在垃圾较少的情况下适用,例如老年代中;
  2. 分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低;
    在这里插入图片描述
    3.3 标记-整理
    复制算法在对象存活率较高时就会进行频繁的复制操作,效率将降低。因此又有了标记-整理算法,标记过程同标记-清除算法,但是在后续步骤不是直接对对象进行清理,而是让所有存活的对象都向一侧移动,然后直接清理掉边界以外的内存。
    复制算法需要一块额外的内存空间,用于存放幸存的内存对象。这无疑造成了内存的浪费。我们还可以在原有的标记清除算法的基础上,提出了优化方案。也就是标记到的可用对象整体向一侧移动,然后直接清除掉可用对象边界意外的内存。这样既解决了内存碎片的问题。又不需要原有的空间换时间的硬件浪费。由于老年代中的幸存对象较多,而且对象内存占用较大。这就使得一旦出现内存回收,需要被回收的对象并不多,碎片也就相对的比较少。所以不需要太多的复制和移动步骤。因此这种方法常常被应用到老年代中。
    标记整理算法的缺点:
    标记整理算法由于需要不断的移动对象到另外一侧,而这种不断的移动其实是非常不适合杂而多的小内存对象的。每次的移动和计算都是非常复杂的过程。因此在使用场景上,就注定限制了标记整理算法的使用不太适合频繁创建和回收对象的内存中。
    在这里插入图片描述

4. 垃圾收集器

垃圾收集算法是方法论,垃圾收集器是具体实现。JVM规范对于垃圾收集器的应该如何实现没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器差别较大,这里只看HotSpot虚拟机。
JDK7/8后,HotSpot虚拟机所有收集器及组合(连线)如下:
      在这里插入图片描述

4.1 Serial收集器
Serial收集器是最基本、历史最久的收集器,曾是新生代手机的唯一选择。它是单线程的,只会使用一个CPU或一条收集线程去完成垃圾收集工作,并且它在收集的时候,必须暂停其它所有的用户线程,直到它结束,即“Stop the World”。停掉所有的用户线程,对很多应用来说难以接受。比如你在做一件事情,被别人强制停掉,用户的体验非常差
尽管如此,它仍然是虚拟机运行在client模式下的默认新生代收集器:简单而高效(与其它收集器的单个线程相比,因为没有线程切换的开销)。
工作示意图:
      在这里插入图片描述
4.2 ParNew收集器
ParNew收集器是Serial收集器的多线程版本,除了使用了多线程之外,其他的行为(收集算法、stop the world、对象分配规则、回收策略等)同Serial收集器一样。
是许多运行在Server模式下的JVM中首选的新生代收集器,其中一个很重还要的原因就是除了Serial之外,只有他能和老年代的CMS收集器配合工作。
工作示意图:
      在这里插入图片描述
4.3 Parallel Scavenge收集器
新生代收集器,并行的多线程收集器。它的目标是达到一个可控的吞吐量(就是CPU运行用户代码的时间与CPU总消耗时间的比值,即 吞吐量=行用户代码的时间/[行用户代码的时间+垃圾收集时间]),这样可以高效率的利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。
工作示意图:
      在这里插入图片描述
4.4 Serial Old收集器
Serial 收集器的老年代版本,单线程,采用“标记-整理”算法,主要是给Client模式下的虚拟机使用。
另外还可以在Server模式下:
JDK 1.5之前的版本中与Parallel Scavenge 收集器搭配使用
可以作为CMS的后背方案,在CMS发生Concurrent Mode Failure是使用
工作示意图:
      在这里插入图片描述
4.5 Parallel Old收集器
Parallel Scavenge的老年代版本,多线程,采用“标记-整理”算法,JDK 1.6才出现。在此之前Parallel Scavenge只能同Serial Old搭配使用,由于Serial Old的性能较差导致Parallel Scavenge的优势发挥不出来。
Parallel Old收集器的出现,使“吞吐量优先”收集器终于有了名副其实的组合。在吞吐量和CPU敏感的场合,都可以使用Parallel Scavenge/Parallel Old组合。
工作示意图:
      在这里插入图片描述
4.6 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。
基于“标记-清除”算法,并发收集、低停顿,运作过程复杂。CMS收集器的垃圾回收过程分为如下四步:
   1. 初始标记:仅仅标记GC Roots能直接关联到的对象,速度快,但是需要“Stop The World”;
   2. 并发标记:就是进行追踪引用链的过程,可以和用户线程并发执行;
   3. 重新标记:修正并发标记阶段因用户线程继续运行而导致标记发生变化的那部分对象的标记记录,比初始标记时间长但远比并发标记时间短,需要“Stop The World”;
   4. 并发清除:清除标记为可以回收对象,可以和用户线程并发执行;
由于整个过程耗时最长的并发标记和并发清除都可以和用户线程一起工作,所以总体上来看,CMS收集器的内存回收过程和用户线程是并发执行的。
工作示意图:
    在这里插入图片描述
CMS是一款优秀的收集器,来分别看看它的优点和缺点。
优点:
1. 并发收集
2. 低停顿
缺点:
1. 对CPU资源非常敏感
并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。
CMS的默认收集线程数量是 = (CPU数量 + 3) / 4;当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。
2. 无法处理浮动垃圾(在并发清除时,用户线程新产生的垃圾叫浮动垃圾)
并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败;这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生;
3. 产生大量内存碎片
CMS收集器基于"标记-清除"算法,清除后不进行整理压缩操作而产生大量不连续的内存碎片,这样会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。

CMS收集器的适用场景:
如果你的应用程序对停顿比较敏感,并且在应用程序运行的时候可以提供更大的内存和更多的CPU(也就是硬件牛逼),那么使用CMS来收集会给你带来好处。还有,如果在JVM中,有相对较多存活时间较长的对象(老年代比较大)会更适合使用CMS。

CMS收集器的总结:
CMS回收器减少了回收的停顿时间,但是降低了堆空间的利用率。

4.7 G1收集器
G1(Garbage-First)是JDK7-u4才正式推出商用的收集器。G1是面向服务端应用的垃圾收集器(JDK9默认的收集器是G1)。它的使命是未来可以替换掉CMS收集器。
G1收集器特性:
   1. 并行与并发:G1收集器能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop The World停顿时间。部分其他收集器原本需要暂停Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。【能充分利用多CPU、多核环境的硬件优势,缩短停顿时间;能和用户线程并发执行】
   2. 分代收集:虽然G1收集器可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。它能够采用不同的方式去处理新创建的对象和已经存活了一段时间的对象,熬过多次GC的旧对象以获取更好的收集效果。
   3. 空间整合:与CMS收集器的“标记-清除”算法不同,G1收集器整体上看采用“标记-整理“算法,局部看采用“复制”算法(两个Region之间),不会有内存碎片,不会因为大对象找不到足够的连续空间而提前触发GC,这点优于CMS收集器;
   4. 可预测的停顿:这是G1收集器相对于CMS收集器的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超N毫秒,这点优于CMS收集器。

为什么能做到可预测的停顿 ?
是因为可以有计划的避免在整个Java堆中进行全区域的垃圾收集。
G1收集器将内存分大小相等的独立区域(Region),新生代和老年代概念保留,但是已经不再物理隔离。
G1跟踪各个Region获得其收集价值大小,在后台维护一个优先列表;
每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来);
这就保证了在有限的时间内可以获取尽可能高的收集效率。

对象被其他Region的对象引用了怎么办?
判断对象存活时,是否需要扫描整个Java堆才能保证准确?在其他的分代收集器,也存在这样的问题(而G1更突出):新生代回收的时候不得不扫描老年代?
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:
每个Region都有一个对应的Remembered Set;
每次Reference类型数据写操作时,都会产生一个Write Barrier 暂时中断操作;
然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的 Region(其他收集器:检查老年代对象是否引用了新生代对象);
如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;
进行垃圾收集时,在GC根节点的枚举范围加入 Remembered Set ,就可以保证不进行全局扫描,也不会有遗漏。

不计算维护Remembered Set的操作,G1收集器回收过程可以分为四个步骤(与CMS较为相似):
   1. 初始标记:仅仅标记GC Roots能直接关联到的对象,并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时能在正确可用的Region中创建新对象,需要“Stop The World”;
   2. 并发标记:从GC Roots开始进行可达性分析,找出存活对象,耗时长,可与用户线程并发执行;
   3. 最终标记:修正并发标记阶段因用户线程继续运行而导致标记发生变化的那部分对象的标记记录。并发标记时虚拟机将对象变化记录在线程Remember Set Logs里面,最终标记阶段将Remember Set Logs整合到Remember Set中,比初始标记时间长但远比并发标记时间短,需要“Stop The World”;
   4. 筛选回收:首先对各个Region的回收价值和成本进行排序,然后根据用户期望的GC停顿时间来定制回收计划,最后按计划回收一些价值高的Region中垃圾对象。回收时采用复制算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;可以并发进行,降低停顿时间,并增加吞吐量;

工作示意图:
    在这里插入图片描述

G1收集器相对于CMS收集器的区别在:

  • G1在压缩空间方面有优势
  • G1通过将内存空间分成区域(Region)的方式避免内存碎片问题
  • Eden, Survivor, Old区不再固定、在内存使用效率上来说更灵活
  • G1可以通过设置预期停顿时间(Pause Time)来控制垃圾收集时间避免应用雪崩现象
  • G1在回收内存后会马上同时做合并空闲内存的工作、而CMS默认是在STW(Stop The World)的时候做
  • G1会在整个堆(新生代和老年代)中使用、而CMS只能在老年代中使用

就目前而言、CMS还是默认首选的GC策略、可能在以下场景下G1更适合:

  • 服务端多核CPU、JVM内存占用较大的应用(至少大于4G)
  • 应用在运行过程中会产生大量内存碎片、需要经常压缩空间
  • 想要更可控、可预期的GC停顿周期;防止高并发下应用雪崩现象

标签:线程,阐述,Java,收集器,GC,内存,JVM,原理
来源: https://blog.csdn.net/weixin_43866728/article/details/99842788