全面分析 JVM 原理

 2022-09-06
原文地址:https://blog.csdn.net/cyf860201/article/details/117676669

JVM简介

我们总结出JVM内存包含两个子系统和两个组件,两个子系统是:Classloader子系统和Executionengine(执行引擎)子系统;两个组件分别是:Runtimedataarea(运行时数据区域)组件和Nativeinterface(本地库接口)组件。

运行时数据区域包含5部分:方法区,堆,虚拟机栈,本地方法栈,程序计数器

JVM的结构

202209062306445691.png

202209062306463892.png

202209062306475673.png

JVM内部执行运行流程

首先,当一个程序启动之前,它的class会被类装载器装入方法区,执行引擎读取方法区的字节码自适应解析,边解析就边运行(其中一种方式),然后pc寄存器指向了main函数所在位置,虚拟机开始为main函数在java栈中预留一个栈帧(每个方法都对应一个栈帧),然后开始跑main函数,main函数里的代码被执行引擎映射成本地操作系统里相应的实现,然后调用本地方法接口,本地方法运行的时候,操纵系统会为本地方法分配本地方法栈,用来储存一些临时变量,然后运行本地方法,调用操作系统API等等。

各模块的生命周期

启动一个jvm虚拟机程序就是启动了一个进程。启动的同时就在操作系统的堆内存中开辟一块jvm内存区,各个小模块的生命周期:

虚拟机栈、本地方法栈、程序计数器这三个模块是线程私有的,有多少线程就有多少个这三个模块,生命周期跟所属线程的生命周期一致。以程序计数器为例,因为多线程是通过线程轮流切换和分配执行时间来实现,所以当线程切回到正确执行位置,每个线程都有独立的程序计数器,各个线程之间的计数器互不影响,独立存储。

其余是跟JVM虚拟机的生命周期一致。
程序计数器模块是JVM内存区域唯一不会报outofMemoryError情况的区域。

我们总结出JVM内存包含两个子系统和两个组件,两个子系统是:Classloader子系统和Executionengine(执行引擎)子系统;两个组件分别是:Runtimedataarea(运行时数据区域)组件和Nativeinterface(本地库接口)组件。

JVM结构图各模块的内容

程序计数器(Program Counter Register)

也叫PC寄存器,是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
(1),区别于计算机硬件的pc寄存器,两者不略有不同。计算机用pc寄存器来存放“伪指令”或地址,而相对于虚拟机,pc寄存器它表现为一块内存(一个字长,虚拟机要求字长最小为32位),虚拟机的pc寄存器的功能也是存放伪指令,更确切的说存放的是将要执行指令的地址。
(2)当虚拟机正在执行的方法是一个本地(native)方法的时候,jvm的pc寄存器存储的值是undefined。
(3)程序计数器是线程私有的,它的生命周期与线程相同,每个线程都有一个。
(4)此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈(Java Virtual Machine Stack)

(1)线程私有的,它的生命周期与线程相同,每个线程都有一个。
(2)每个线程创建的同时会创建一个JVM栈,JVM栈中每个栈帧存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型:boolean、char、byte、short、int、long、float、double;和reference (32 位以内的数据类型,具体根据JVM位数(64为还是32位)有关,因为一个solt(槽)占用32位的内存空间 )、部分的返回结果,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址;
(3)每一个方法从被调用直至执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
(4)栈运行原理:栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法和运行期数据的数据集,当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压入栈,B方法又调用了C方法,于是产生栈帧F3也被压入栈…… 依次执行完毕后,先弹出后进…F3栈帧,再弹出F2栈帧,再弹出F1栈帧。
(5)JAVA虚拟机栈的最小单位可以理解为一个个栈帧,一个方法对应一个栈帧,一个栈帧可以执行很多指令,如下图:

202209062306487684.png
(6)对上图中的动态链接解释下,比如当出现main方法需要调用method1()方法的时候,操作指令就会触动这个动态链接就会找打方法区中对于的method1(),然后把method1()方法压入虚拟机栈中,执行method1栈帧的指令;此外如果指令表示的代码是个常量,这也是个动态链接,也会到方法区中的运行时常量池找到类加载时就专门存放变量的运行时常量池的数据。

本地方法栈(Native Method Stack)

(1)先解释什么是本地方法:jvm中的本地方法是指方法的修饰符是带有native的但是方法体不是用java代码写的一类方法,这类方法存在的意义当然是填补java代码不方便实现的缺陷而提出的。案例介绍将在 下面22知识点仔细介绍。
(2)作用同java虚拟机栈类似,区别是:虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
(3)是线程私有的,它的生命周期与线程相同,每个线程都有一个。

Java 堆(Java Heap)

(1)是Java虚拟机所管理的内存中最大的一块。
(2)不同于上面3个,堆是jvm所有线程共享的。
(3)在虚拟机启动的时候创建。
(4)唯一目的就是存放对象实例,几乎所有的对象实例以及数组都要在这里分配内存。
(5)Java堆是垃圾收集器管理的主要区域。
(6)因此很多时候java堆也被称为“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代;新生代又可以分为:Eden 空间、From Survivor空间、To Survivor空间。
(7)java堆是计算机物理存储上不连续的、逻辑上是连续的,也是大小可调节的(通过-Xms和-Xmx控制)。
(8)如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区(Method Area)

(1)在虚拟机启动的时候创建。
(2)所有jvm线程共享。
(3)除了和堆一样不需要不连续的内存空间和可以固定大小或者可扩展外,还可以选择不实现垃圾收集。
(4)用于存放已被虚拟机加载的类信息、常量、静态变量、以及编译后的方法实现的二进制形式的机器指令集等数据。
(5)被装载的class的信息存储在Methodarea的内存中。当虚拟机装载某个类型时,它使用类装载器定位相应的class文件,然后读入这个class文件内容并把它传输到虚拟机中。
(6)运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

方法区补充:指令集是个非常重要概念,因为程序员写的代码其实在jvm虚拟机中是被转成了一条条指令集执行的,看下图

202209062306496055.png
首先看看上面各部位位于13图中的那些位置:左侧的foo代码是指令集,可见就是在方法区,程序计数器就不用说了,局部变量区位于虚拟机栈中,右侧最下方的求值栈(也就是操作数栈)我们从动图中明显可以看出存在栈顶这个关键词因此也是位于java虚拟机栈的。

另外,图中,指令是Java代码经过javac编译后得到的JVM指令,PC寄存器指向下一条该执行的指令地址,局部变量区存储函数运行中产生的局部变量,栈存储计算的中间结果和最后结果。

上图的执行的源代码是:

    public class Demo {
        public static void foo() {
           int a = 1;
           int b = 2;
           int c = (a + b) * 5;
        }
    }

下面简单解释下执行过程,注意:偏移量的数字只是简单代表第几个指令哦,首先常数1入栈,栈顶元素就是1,然后栈顶元素移入局部变量区存储,常数2入栈,栈顶元素变为2,然后栈顶元素移入局部变量区存储;接着1,2依次再次入栈,弹出栈顶两个元素相加后结果入栈,将5入栈,栈顶两个元素弹出并相乘后结果入栈,然后栈顶变为15,最后移入局部变量。执行return命令如果当前线程对应的栈中没有了栈帧,这个Java栈也将会被JVM撤销。

本地(native)方法

(1)本地方法就是带有native标识符修饰的方法;
(2)native修饰符修饰的方法并不提供方法体,但因为其实现体是由非java代码在在外部实现的,因此不能与abstract连用;
(3)存在的意义:不方便用java语言写的代码,使用更为专业的语言写更合适;甚至有些JVM的实现就是用c编写的,所以只能使用c来写,
(4)更多的本地方法最好是与jdk的执行引擎的解释器语言一致(执行引擎、解释器:参考21的执行引擎);
(5)Windows、Linux、UNIX、Dos操作系统的核心代码大部分是使用C和C++编写,底层接口用汇编编写.
(6)为什么native方法修饰的修饰的方法PC程序计数器为undefined。读懂上面的所有知识点可以就很容易自己理解了。在一开始类加载时,native修饰的方法就被保存在了本地方法栈中,当需要调用native方法时,调用的是一个指向本地方法栈中某方法的地址,然后执行方法直接与操作系统交互,返回运行结果。整个过程并没有经过执行引擎的解释器把字节码解释成操作系统语言,PC计数器也就没有起作用。

双亲委派机制

JVM在加载类时默认采用的是双亲委派机制。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

202209062306506806.png

202209062306516637.png

例如:当jvm要加载Test.class的时候,

(1)首先会到自定义加载器中查找(其实是看运行时数据区的方法区有没有加载),看是否已经加载过,如果已经加载过,则返回字节码。

(2)如果自定义加载器没有加载过,则询问上一层加载器(即AppClassLoader)是否已经加载过Test.class。

(3)如果没有加载过,则询问上一层加载器(ExtClassLoader)是否已经加载过。

(4)如果没有加载过,则继续询问上一层加载(BoopStrap ClassLoader)是否已经加载过。

(5)如果BoopStrap ClassLoader依然没有加载过,则到自己指定类加载路径下(“sun.boot.class.path”)查看是否有Test.class字节码,有则返回,没有通

知下一层加载器ExtClassLoader到自己指定的类加载路径下(java.ext.dirs)查看。

(6)依次类推,最后到自定义类加载器指定的路径还没有找到Test.class字节码,则抛出异常ClassNotFoundException。

代码如下:

    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException
        {
            synchronized (getClassLoadingLock(name)) {
                // 首先,检查是否已经加载过
                Class<?> c = findLoadedClass(name);
                if (c == null) {
                    long t0 = System.nanoTime();
                    try {
                        if (parent != null) {
                            //父加载器不为空,调用父加载器的loadClass
                            c = parent.loadClass(name, false);
                        } else {
                            //父加载器为空则,调用Bootstrap Classloader
                            c = findBootstrapClassOrNull(name);
                        }
                    } catch (ClassNotFoundException e) {
                        // ClassNotFoundException thrown if class not found
                        // from the non-null parent class loader
                    }
     
                    if (c == null) {
                        // If still not found, then invoke findClass in order
                        // to find the class.
                        long t1 = System.nanoTime();
                        //父加载器没有找到,则调用findclass
                        c = findClass(name);
     
                        // this is the defining class loader; record the stats
                        sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                        sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                        sun.misc.PerfCounter.getFindClasses().increment();
                    }
                }
                if (resolve) {
                    //调用resolveClass()
                    resolveClass(c);
                }
                return c;
            }
        }

为什么要使用这种加载方式呢?这里要注意几点,1,类加载器代码本身也是java类,因此类加载器本身也是要被加载的,因此显然必须有第一个类加载器不是Java类,这就是bootStrap,是使用c++写的其他这是java了。2,虽说bootStrap、extclassLoader、appclassloader三个是父子类加载器关系,但是并没有使用继承,而是使用了组合关系。3,优点,具备了一种带优先级的层次关系,越是基础的类,越是被上层的类加载器进行加载,可以比较笼统的说像jdk自带的几个jar包肯定是位于最顶级的,再就是我们引用的包,最后是我们自己写的,保证了java程序的稳定性。

类加载器子系统(class loader subsystem)

(1)根据给定的全限定名类名(如java.lang.Object)来装载class文件的内容到Runtimedataarea中的methodarea(方法区域)。Java程序员可以extends java.lang.ClassLoader类来写自己的Classloader。
(2) 对(1)中的加载过程是:当一个classloader启动时,classloader的生存地点在jvm中的堆,然后它去主机硬盘上去装载A.class到jvm的methodarea(方法区),方法区中的这个字节文件会被虚拟机拿来new A字节码,然后在堆内存生成了一个A字节码的对象,然后A自己码这个内存文件有两个引用,一个指向A的class对象,一个指向加载自己的classloader。
见下图:

202209062306530718.png

执行引擎(Executionengine子系统)

1)负责执行来自类加载器子系统(class loader subsystem)中被加载类中在方法区包含的指令集,通俗讲就是类加载器子系统把代码逻辑(什么时候该if,什么时候该相加,相减)都以指令的形式加载到了方法区,执行引擎就负责执行这些指令就行了。

用网上最流行的一张图表示就是:

202209062306544389.png
(1)程序在JVM主要执行的过程是执行引擎与运行时数据区不断交互的过程,可理解为上面“方法区中的动图”
(2)但是执行引擎拿到的方法区中的指令还是人能够看懂的,这里执行引擎的工作就是要把指令转成JVM执行的语言(也可以理解成操作系统的语言),最后操作系统语言再转成计算机机器码。
(3)解释器与JIT

解释器

一条一条地读取,解释并且执行字节码指令。因为它一条一条地解释和执行指令,所以它可以很快地解释字节码,但是执行起来会比较慢。这是解释执行的语言的一个缺点。字节码这种“语言”基本来说是解释执行的。

即时(Just-In-Time)编译器

即时编译器被引入用来弥补解释器的缺点。执行引擎首先按照解释执行的方式来执行,然后在合适的时候,即时编译器把整段字节码编译成本地代码。然后,执行引擎就没有必要再去解释执行方法了,它可以直接通过本地代码去执行它。执行本地代码比一条一条进行解释执行的速度快很多。编译后的代码可以执行的很快,因为本地代码是保存在缓存里的。
上面也是才能够别处拷来的,是对上图的解释,这里的字节码解释器也就对应20中的解释器。简单理解jit就是当代码中某些方法复用次数比较高的,并超过一个特定的值就成为了“热点代码”。那么这个这些热点代码就会被编译成本地代码(其实可以理解成缓存)加快访问速度。

GC垃圾回收机制

类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,以方便执行器执行,堆内存分为三部分:

2022090623065600310.png

新生区

新生区是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。新生区又分为两部分:伊甸区(Eden space)和幸存者区(Survivor pace),所有的类都是在伊甸区被new出来的。幸存区有两个:0区(Survivor 0 space)和1区(Survivor 1 space)。当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园进行垃圾回收(Minor GC),将伊甸园中的剩余对象移动到幸存0区。若幸存0区也满了,再对该区进行垃圾回收,然后移动到1区。那如果1去也满了呢?再移动到养老区。若养老区也满了,那么这个时候将产生Major GC(FullGCC),进行养老区的内存清理。若养老区执行Full GC 之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”。

如果出现java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够。原因有二:
a.Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。
b.代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

老年区

养老区用于保存从新生区筛选出来的 JAVA 对象,一般池对象都在这个区域活跃。

永久区

永久存储区是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。

如果出现java.lang.OutOfMemoryError: PermGen space,说明是Java虚拟机对永久代Perm内存设置不够。原因有二:
a. 程序启动需要加载大量的第三方jar包。例如:在一个Tomcat下部署了太多的应用。
b. 大量动态反射生成的类不断被加载,最终导致Perm区被占满。
说明:
Jdk1.6及之前:常量池分配在永久代 。
Jdk1.7:有,但已经逐步“去永久代” 。
Jdk1.8及之后:无(java.lang.OutOfMemoryError: PermGen space,这种错误将不会出现在JDK1.8中)。

垃圾回收策略

JVM的垃圾回收主要是针对java堆这块内存空间,因为java的虚拟栈是随着线程的销毁而自动回收的,而方法区的垃圾回收条件极其苛刻。

java堆中存放着运行期间的对象实例,随着程序的运行实例越来越多,不回收垃圾就会产生OOM异常,而怎么判断一个对象是否是垃圾呢,下面是几种常用的垃圾判别法。

引用计数法

基本想法就是 一个对象,如果有地方在引用它,那么它就不是垃圾,因此呢,我给这个对象创建一个引用计数器,对象 被引用一次,计数器就+1,引用失效,计数器-1,垃圾回收的时候一看这个对象计数器值为0,说明这个对象没被引用了,就被当作垃圾回收了。这个算法非常高效,但是存在的问题就是两个对象互相引用,这样这两个对象就永远无法被回收。

根搜索法

这个方法的基本思路是,从一些根节点,称为“GC Roots”,为起始点往下搜索,形成一条引用链,在引用链之外的对象被视作垃圾。java语言中的“GC Roots”有如下几种:帧栈中本地变量表中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象。

** 在jvm的垃圾回收实现中经常会使用如下的算法 **

标记清除算法

在这种回收机制中,分为两个过程,一是标记,再就是清除,标记阶段就是用根搜索法来判断对象是否需要回收,清除就是回收内存空间。这个算法是垃圾回收中最基本的算法,许多高级的垃圾回收算法都是根据它为基础。这个算法的缺点是效率低,回收后的内存空间过于碎片化。

复制算法

针对标记清除算法的效率问题,复制算法给出的解决方法是,将内存空间一分为二,每次只用其中一块空间,当进行垃圾回收时,把活着的对象复制到另一块空间中,清除时将原空间全部清除,这样效率既高也解决了内存碎片,缺点就是太废内存了。

标记整理算法

过程和标记清除算法类似,区别就是它不直接把垃圾空间清除掉,而是将活着的对象都移动到一起,剩下的空间就是垃圾空间了,这时候直接清除掉就行。

分代回收算法

分代回收法就是根据对象的生存周期,分为老年代和新生代两个内存空间,对于新生代这种对象生成和死亡都比较快的区域使用复制算法进行回收,对于老年代则使用标记清除或标记整理算法。

几种垃圾回收器

下面先来了解Java虚拟机垃圾回收的几种常见算法:标记-清除算法、复制算法、标记-整理算法、分代收集算法、火车算法,介绍它们的算法思路,有什么优点和缺点,以及主要应用场景。

1、垃圾收集器概述

    垃圾收集器是垃圾回收算法(标记-清除算法、复制算法、标记-整理算法、火车算法)的具体实现,不同商家、不同版本的JVM所提供的垃圾收集器可能会有很在差别,本文主要介绍HotSpot虚拟机中的垃圾收集器。

1-1、垃圾收集器组合
JDK7/8后,HotSpot虚拟机所有收集器及组合(连线),如下图:

2022090623065723511.png
(A)、图中展示了7种不同分代的收集器:
Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1;
(B)、而它们所处区域,则表明其是属于新生代收集器还是老年代收集器:
新生代收集器:Serial、ParNew、Parallel Scavenge;
老年代收集器:Serial Old、Parallel Old、CMS;
整堆收集器:G1;
(C)、两个收集器间有连线,表明它们可以搭配使用:
Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
(D)、其中Serial Old作为CMS出现"Concurrent Mode Failure"失败的后备预案(后面介绍);

1-2、并发垃圾收集和并行垃圾收集的区别
(A)、并行(Parallel)
指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态;
如ParNew、Parallel Scavenge、Parallel Old;
(B)、并发(Concurrent)
指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行);
用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;
如CMS、G1(也有并行);

1-3、Minor GC和Full GC的区别
(A)、Minor GC
又称新生代GC,指发生在新生代的垃圾收集动作;
因为Java对象大多是朝生夕灭,所以Minor GC非常频繁,一般回收速度也比较快;
(B)、Full GC
又称Major GC或老年代GC,指发生在老年代的GC;
出现Full GC经常会伴随至少一次的Minor GC(不是绝对,Parallel Sacvenge收集器就可以选择设置Major GC策略);
Major GC速度一般比Minor GC慢10倍以上;

下面将介绍这些收集器的特性、基本原理和使用场景,并重点分析CMS和G1这两款相对复杂的收集器;但需要明确一个观点:
没有最好的收集器,更没有万能的收集;
选择的只能是适合具体应用场景的收集器。

2、Serial收集器

    Serial(串行)垃圾收集器是最基本、发展历史最悠久的收集器;
    JDK1.3.1前是HotSpot新生代收集的唯一选择;

1、特点
针对新生代;
采用复制算法;
单线程收集;
进行垃圾收集时,必须暂停所有工作线程,直到完成;
即会"Stop The World";
Serial/Serial Old组合收集器运行示意图如下:

2022090623065846812.png
2、应用场景
依然是HotSpot在Client模式下默认的新生代收集器;
也有优于其他收集器的地方:
简单高效(与其他收集器的单线程相比);
对于限定单个CPU的环境来说,Serial收集器没有线程交互(切换)开销,可以获得最高的单线程收集效率;
在用户的桌面应用场景中,可用内存一般不大(几十M至一两百M),可以在较短时间内完成垃圾收集(几十MS至一百多MS),只要不频繁发生,这是可以接受的
3、设置参数
“-XX:+UseSerialGC”:添加该参数来显式的使用串行垃圾收集器;
4、Stop TheWorld说明
JVM在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉,即GC停顿;
会带给用户不良的体验;
从JDK1.3到现在,从Serial收集器-》Parallel收集器-》CMS-》G1,用户线程停顿时间不断缩短,但仍然无法完全消除;
更多"Stop The World"信息请参考:《Java虚拟机垃圾回收(一) 基础》“2-2、可达性分析算法”
更多Serial收集器请参考:

3、ParNew收集器

      ParNew垃圾收集器是Serial收集器的多线程版本。

1、特点
除了多线程外,其余的行为、特点和Serial收集器一样;
如Serial收集器可用控制参数、收集算法、Stop The World、内存分配规则、回收策略等;
两个收集器共用了不少代码;
ParNew/Serial Old组合收集器运行示意图如下:

2022090623065946213.png
2、应用场景
在Server模式下,ParNew收集器是一个非常重要的收集器,因为除Serial外,目前只有它能与CMS收集器配合工作;
但在单个CPU环境中,不会比Serail收集器有更好的效果,因为存在线程交互开销。
3、设置参数
“-XX:+UseConcMarkSweepGC”:指定使用CMS后,会默认使用ParNew作为新生代收集器;
“-XX:+UseParNewGC”:强制指定使用ParNew;
“-XX:ParallelGCThreads”:指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同;

4、为什么只有ParNew能与CMS收集器配合
CMS是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;
CMS作为老年代收集器,但却无法与JDK1.4已经存在的新生代收集器Parallel Scavenge配合工作;
因为Parallel Scavenge(以及G1)都没有使用传统的GC收集器代码框架,而另外独立实现;而其余几种收集器则共用了部分的框架代码;
关于CMS收集器后面会详细介绍。

4、Parallel Scavenge收集器

    Parallel Scavenge垃圾收集器因为与吞吐量关系密切,也称为吞吐量收集器(Throughput Collector)。

1、特点
(A)、有一些特点与ParNew收集器相似
新生代收集器;
采用复制算法;
多线程收集;
(B)、主要特点是:它的关注点与其他收集器不同
CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间;
而Parallel Scavenge收集器的目标则是达一个可控制的吞吐量(Throughput);
关于吞吐量与收集器关注点说明详见本节后面;
2、应用场景
高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间;
当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交互;
例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序;
3、设置参数
Parallel Scavenge收集器提供两个参数用于精确控制吞吐量:
(A)、"-XX:MaxGCPauseMillis"
控制最大垃圾收集停顿时间,大于0的毫秒数;
MaxGCPauseMillis设置得稍小,停顿时间可能会缩短,但也可能会使得吞吐量下降;
因为可能导致垃圾收集发生得更频繁;
(B)、"-XX:GCTimeRatio"
设置垃圾收集时间占总时间的比率,0<n<100的整数;
GCTimeRatio相当于设置吞吐量大小;
垃圾收集执行时间占应用程序执行时间的比例的计算方法是:
1 / (1 + n)
例如,选项-XX:GCTimeRatio=19,设置了垃圾收集时间占总时间的5%–1/(1+19);
默认值是1%–1/(1+99),即n=99;
垃圾收集所花费的时间是年轻一代和老年代收集的总时间;
如果没有满足吞吐量目标,则增加代的内存大小以尽量增加用户程序运行的时间;
此外,还有一个值得关注的参数:
(C)、"-XX:+UseAdptiveSizePolicy"
开启这个参数后,就不用手工指定一些细节参数,如:
新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等;
JVM会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomiscs);
这是一种值得推荐的方式:
(1)、只需设置好内存数据大小(如"-Xmx"设置最大堆);
(2)、然后使用"-XX:MaxGCPauseMillis"或"-XX:GCTimeRatio"给JVM设置一个优化目标;
(3)、那些具体细节参数的调节就由JVM自适应完成;
这也是Parallel Scavenge收集器与ParNew收集器一个重要区别;
4、吞吐量与收集器关注点说明
(A)、吞吐量(Throughput)
CPU用于运行用户代码的时间与CPU总消耗时间的比值;
即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间);
高吞吐量即减少垃圾收集时间,让用户代码获得更长的运行时间;
(B)、垃圾收集器期望的目标(关注点)
(1)、停顿时间
停顿时间越短就适合需要与用户交互的程序;
良好的响应速度能提升用户体验;
(2)、吞吐量
高吞吐量则可以高效率地利用CPU时间,尽快完成运算的任务;
主要适合在后台计算而不需要太多交互的任务;
(3)、覆盖区(Footprint)
在达到前面两个目标的情况下,尽量减少堆的内存空间;
可以获得更好的空间局部性;

上面介绍的都是新生代收集器,接下来开始介绍老年代收集器;

5、Serial Old收集器

      Serial Old是 Serial收集器的老年代版本;

1、特点
针对老年代;
采用"标记-整理"算法(还有压缩,Mark-Sweep-Compact);
单线程收集;
Serial/Serial Old收集器运行示意图如下:

2022090623070078714.png

2、应用场景
主要用于Client模式;
而在Server模式有两大用途:
(A)、在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
(B)、作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用(后面详解);

6、Parallel Old收集器

      Parallel Old垃圾收集器是Parallel Scavenge收集器的老年代版本;
      JDK1.6中才开始提供;

1、特点
针对老年代;
采用"标记-整理"算法;
多线程收集;
Parallel Scavenge/Parallel Old收集器运行示意图如下:

2022090623070194315.png

2、应用场景
JDK1.6及之后用来代替老年代的Serial Old收集器;
特别是在Server模式,多CPU的情况下;
这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的"给力"应用组合;
3、设置参数
“-XX:+UseParallelOldGC”:指定使用Parallel Old收集器;

7、CMS收集器

      并发标记清理(Concurrent Mark Sweep,CMS)收集器也称为并发低停顿收集器(Concurrent Low Pause Collector)或低延迟(low-latency)垃圾收集器;
      在前面ParNew收集器曾简单介绍过其特点;

1、特点
针对老年代;
基于"标记-清除"算法(不进行压缩操作,产生内存碎片);
以获取最短回收停顿时间为目标;
并发收集、低停顿;
需要更多的内存(看后面的缺点);
是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器;
第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;
2、应用场景
与用户交互较多的场景;
希望系统停顿时间最短,注重服务的响应速度;
以给用户带来较好的体验;
如常见WEB、B/S系统的服务器上的应用;
3、设置参数
“-XX:+UseConcMarkSweepGC”:指定使用CMS收集器;
4、CMS收集器运作过程
比前面几种收集器更复杂,可以分为4个步骤:
(A)、初始标记(CMS initial mark)
仅标记一下GC Roots能直接关联到的对象;
速度很快;
但需要"Stop The World";
(B)、并发标记(CMS concurrent mark)
进行GC Roots Tracing的过程;
刚才产生的集合中标记出存活对象;
应用程序也在运行;
并不能保证可以标记出所有的存活对象;
(C)、重新标记(CMS remark)
为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;
需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;
采用多线程并行执行来提升效率;
(D)、并发清除(CMS concurrent sweep)
回收所有的垃圾对象;
整个过程中耗时最长的并发标记和并发清除都可以与用户线程一起工作;
所以总体上说,CMS收集器的内存回收过程与用户线程一起并发执行;
CMS收集器运行示意图如下:

2022090623070345716.png

5、CMS收集器3个明显的缺点
(A)、对CPU资源非常敏感
并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。
CMS的默认收集线程数量是=(CPU数量+3)/4;
当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。
增量式并发收集器:
针对这种情况,曾出现了"增量式并发收集器"(Incremental Concurrent Mark Sweep/i-CMS);
类似使用抢占式来模拟多任务机制的思想,让收集线程和用户线程交替运行,减少收集线程运行时间;
但效果并不理想,JDK1.6后就官方不再提倡用户使用。
(B)、无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败
(1)、浮动垃圾(Floating Garbage)
在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;
这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;
也要可以认为CMS所需要的空间比其他垃圾收集器大;
“-XX:CMSInitiatingOccupancyFraction”:设置CMS预留内存空间;
JDK1.5默认值为68%;
JDK1.6变为大约92%;
(2)、“Concurrent Mode Failure"失败
如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败;
这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生;
这样的代价是很大的,所以CMSInitiatingOccupancyFraction不能设置得太大。
(C)、产生大量内存碎片
由于CMS基于"标记-清除"算法,清除后不进行压缩操作;
前面《Java虚拟机垃圾回收(二) 垃圾回收算法》“标记-清除"算法介绍时曾说过:
产生大量不连续的内存碎片会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。
解决方法:
(1)、”-XX:+UseCMSCompactAtFullCollection”
使得CMS出现上面这种情况时不进行Full GC,而开启内存碎片的合并整理过程;
但合并整理过程无法并发,停顿时间会变长;
默认开启(但不会进行,结合下面的CMSFullGCsBeforeCompaction);
(2)、"-XX:+CMSFullGCsBeforeCompaction"
设置执行多少次不压缩的Full GC后,来一次压缩整理;
为减少合并整理过程的停顿时间;
默认为0,也就是说每次都执行Full GC,不会进行压缩整理;
由于空间不再连续,CMS需要使用可用"空闲列表"内存分配方式,这比简单实用"碰撞指针"分配内存消耗大;
更多关于内存分配方式请参考:《Java对象在Java虚拟机中的创建过程》
总体来看,与Parallel Old垃圾收集器相比,CMS减少了执行老年代垃圾收集时应用暂停的时间;
但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间;

8、G1收集器

      G1(Garbage-First)是JDK7-u4才推出商用的收集器;

1、特点
(A)、并行与并发
能充分利用多CPU、多核环境下的硬件优势;
可以并行来缩短"Stop The World"停顿时间;
也可以并发让垃圾收集与用户程序同时进行;
(B)、分代收集,收集范围包括新生代和老年代
能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
能够采用不同方式处理不同时期的对象;
虽然保留分代概念,但Java堆的内存布局有很大差别;
将整个堆划分为多个大小相等的独立区域(Region);
新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合;
(C)、结合多种垃圾收集算法,空间整合,不产生碎片
从整体看,是基于标记-整理算法;
从局部(两个Region间)看,是基于复制算法;
这是一种类似火车算法的实现;
都不会产生内存碎片,有利于长时间运行;
(D)、可预测的停顿:低停顿的同时实现高吞吐量
G1除了追求低停顿处,还能建立可预测的停顿时间模型;
可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒;
2、应用场景
面向服务端应用,针对具有大内存、多处理器的机器;
最主要的应用是为需要低GC延迟,并具有大堆的应用程序提供解决方案;
如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;
用来替换掉JDK1.5中的CMS收集器;
在下面的情况时,使用G1可能比CMS好:
(1)、超过50%的Java堆被活动数据占用;
(2)、对象分配频率或年代提升频率变化很大;
(3)、GC停顿时间过长(长于0.5至1秒)。
是否一定采用G1呢?也未必:
如果现在采用的收集器没有出现问题,不用急着去选择G1;
如果应用程序追求低停顿,可以尝试选择G1;
是否代替CMS需要实际场景测试才知道。
3、设置参数
“-XX:+UseG1GC”:指定使用G1收集器;
“-XX:InitiatingHeapOccupancyPercent”:当整个Java堆的占用率达到参数值时,开始并发标记阶段;默认为45;
“-XX:MaxGCPauseMillis”:为G1设置暂停时间目标,默认值为200毫秒;
“-XX:G1HeapRegionSize”:设置每个Region大小,范围1MB到32MB;目标是在最小Java堆时可以拥有约2048个Region;
4、为什么G1收集器可以实现可预测的停顿
G1可以建立可预测的停顿时间模型,是因为:
可以有计划地避免在Java堆的进行全区域的垃圾收集;
G1跟踪各个Region获得其收集价值大小,在后台维护一个优先列表;
每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来);
这就保证了在有限的时间内可以获取尽可能高的收集效率;
5、一个对象被不同区域引用的问题
一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
在其他的分代收集器,也存在这样的问题(而G1更突出):
回收新生代也不得不同时扫描老年代?
这样的话会降低Minor GC的效率;
解决方法:
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:
每个Region都有一个对应的Remembered Set;
每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;
然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);
如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;
当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;
就可以保证不进行全局扫描,也不会有遗漏。
6、G1收集器运作过程
不计算维护Remembered Set的操作,可以分为4个步骤(与CMS较为相似)。
(A)、初始标记(Initial Marking)
仅标记一下GC Roots能直接关联到的对象;
且修改TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的Region中创建新对象;
需要"Stop The World",但速度很快;
(B)、并发标记(Concurrent Marking)
进行GC Roots Tracing的过程;
刚才产生的集合中标记出存活对象;
耗时较长,但应用程序也在运行;
并不能保证可以标记出所有的存活对象;
(C)、最终标记(Final Marking)
为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;
上一阶段对象的变化记录在线程的Remembered Set Log;
这里把Remembered Set Log合并到Remembered Set中;
需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;
采用多线程并行执行来提升效率;
(D)、筛选回收(Live Data Counting and Evacuation)
首先排序各个Region的回收价值和成本;
然后根据用户期望的GC停顿时间来制定回收计划;
最后按计划回收一些价值高的Region中垃圾对象;
回收时采用"复制"算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;
可以并发进行,降低停顿时间,并增加吞吐量;
G1收集器运行示意图如下:

2022090623070469717.png

JVM调优

堆设置
-Xms:初始堆大小
-Xmx:最大堆大小
-XX:NewSize=n:设置年轻代大小
-XX:NewRatio=n:设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4
-XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5
-XX:MaxPermSize=n:设置持久代大小

收集器设置
-XX:+UseSerialGC:设置串行收集器
-XX:+UseParallelGC:设置并行收集器
-XX:+UseParalledlOldGC:设置并行年老代收集器
-XX:+UseConcMarkSweepGC:设置并发收集器

垃圾回收统计信息
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:filename

并行收集器设置
-XX:ParallelGCThreads=n:设置并行收集器收集时使用的CPU数。并行收集线程数。
-XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间
-XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)

并发收集器设置
-XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。
-XX:ParallelGCThreads=n:设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数。

相关知识

操作系统的堆和栈:

堆(操作系统):一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收,分配方式类似于链表。

栈(操作系统):由操作系统自动分配释放,存放函数的参数值,局部变量值等。操作方式与数据结构中的栈相类似。

为什么jvm的内存是分布在操作系统的堆中呢??因为操作系统的栈是操作系统管理的,它随时会被回收,所以如果jvm放在栈中,那java的一个null对象就很难确定会被谁回收了,那gc的存在就一点意义都莫有了,而要对栈做到自动释放也是jvm需要考虑的,所以放在堆中就最合适不过了。

2022090623070606318.png
上图表明:jvm虚拟机位于操作系统的堆中,并且,程序员写好的类加载到虚拟机执行的过程是:当一个classLoder启动的时候,classLoader的生存地点在jvm中的堆,然后它会去主机硬盘上将A.class装载到jvm的方法区,方法区中的这个字节文件会被虚拟机拿来new A字节码(),然后在堆内存生成了一个A字节码的对象,然后A字节码这个内存文件有两个引用一个指向A的class对象,一个指向加载自己的classLoader

2022090623070755319.png在CPU内部有一组CPU寄存器,也就是CPU的储存器。CPU操作寄存器的速度要比操作计算机主存快的多。在主存和CPU寄存器之间还存在一个CPU缓存,CPU操作CPU缓存的速度快于主存但慢于CPU寄存器。某些CPU可能有多个缓存层(一级缓存和二级缓存)。计算机的主存也称作RAM,所有的CPU都能够访问主存,而且主存比上面提到的缓存和寄存器大很多。
当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存,进而在读取CPU缓存到寄存器。当CPU需要写数据到主存时,同样会先flush寄存器到CPU缓存,然后再在某些节点把缓存数据flush到主存。

Java内存模型和硬件架构之间的桥接
正如上面讲到的,Java内存模型和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆,从硬件上看,不管是栈还是堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中,如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:

2022090623070870320.png当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要的两个问题是:
1.共享对象对各个线程的可见性
2.共享对象的竞争现象

问题1:共享对象的可见性

当多个线程同时操作同一个共享对象时,如果没有合理的使用volatile和synchronization关键字,一个线程对共享对象的更新有可能导致其它线程不可见。

想象一下我们的共享对象存储在主存,一个CPU中的线程读取主存数据到CPU缓存,然后对共享对象做了更改,但CPU缓存中的更改后的对象还没有flush到主存,此时线程对共享对象的更改对其它CPU中的线程是不可见的。最终就是每个线程最终都会拷贝共享对象,而且拷贝的对象位于不同的CPU缓存中。

下图展示了上面描述的过程。左边CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中:

2022090623071015021.png要解决共享对象可见性这个问题,我们可以使用java volatile关键字。 Java’s volatile keyword. volatile 关键字可以保证变量会直接从主存读取,而对变量的更新也会直接写到主存。volatile原理是基于CPU内存屏障指令实现的。

问题2:竞争现象

如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。
如下图所示,线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。

如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而下图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。

2022090623071142822.png要解决上面的问题我们可以使用java synchronized代码块。synchronized代码块可以保证同一个时刻只能有一个线程进入代码竞争区,synchronized代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会flush到主存,不管这些变量是不是volatile类型的。

volatile和 synchronized区别
volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。

volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性

volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
支撑Java内存模型的基础原理

本地库接口和本地方法库:
(1)本地方法库接口:即操作系统所使用的编程语言的方法集,是归属于操作系统的。
(2)本地方法库保存在动态链接库中,即.dll(windows系统)文件中,格式是各个平台专有的。

运行时数据区各个模块协作工作的总结较好的图

JVM垃圾收集策略(包含G1垃圾回收器和传统GC)