JVM面试大总结

一、汇总

JVM是运行在操作系统之上的,它与硬件没有直接的交互。先说一下JVM的内存区域,当函数开始运行时,JVM拿到自己的内存将自己的内存区域进行了分割,分为五块区域:线程共享的有堆、方法区,线程私有的有java栈、本地方法栈、程序计数器。

方法区是用来加载class文件的区域,静态变量、常量、类元信息、运行时的常量池存放在在方法区中, 方法区在jdk1.7之前它又叫做永久代,但是jdk1.8之后改成元数据空间了;

new的对象都存放在堆中;

栈也叫栈内存,8种类型的基本变量、对象的引用变量、实例方法都是在函数的栈内存中分配,栈中的数据都是以栈帧的格式存在,每执行一个方法都会产生一个栈帧,保存到栈(后进先出)的顶部,顶部栈就是当前的方法,该方法执行完毕后会自动将此栈帧出栈。java栈随着线程创建而产生,随着线程的终结而销毁,每个线程在开辟、运行的过程中会单独创建这样的一份内存,有多少个线程就可能有多少个栈区;

本地方法栈是存储C++的native方法运行时候的栈区;程序计数器是指向当前程序运行的位置。

内存模型、类加载机制、GC是重点,性能调优部分更偏向应用,重点突出实践能力,编译器优化和执行模式部分偏向于理论基础,重点掌握知识点。

image-20221123104653211

1、JMM如何保证原子性、一致性、可见性

在java中提供了两个高级的字节码指令monitorenter和monitorexit,使用对应的关键字Synchronized来保证代码块内的操作是原子的。

2、环境变量理解

classpath是javac编译器的一个环境变量。它的作用与import、package关键字有关。

package的所在位置,就是设置CLASSPATH当编译器面对import packag这个语句时,它先会查找CLASSPATH所指定的目录,并检视子目录java/util是否存在,然后找出名称吻合的已编译文件(.class文件)。如果没有找到就会报错!

二、分区和内存模型

内存模型叫做内存结构。 所谓模型是行为+数据 也就是JVM的内存结构布局,加上内存的执行行为,栈中数据如何分配,堆中数据如何分配,堆栈数据运行时如何同步,加锁状态数据如何同步,也就是happen before那一套。

1、JVM的划分及作用

image-20221208093850776

Java虚拟机主要分为以下几个区:

(1)方法区

​ a. 有时候也成为永久代,在该区内很少发生垃圾回收,但是并不代表不发生GC,在这里进行的GC主要是对方法区里的常量池和对类型的卸载

​ b. 方法区主要用来存储已被虚拟机加载的类的信息、常量、静态变量和即时编译器编译后的代码等数据。

​ c. 该区域是被线程共享的。

​ d. 方法区里有一个运行时常量池,用于存放静态编译产生的字面量和符号引用。该常量池具有动态性,也就是说常量并不一定是编译时确定,运行时生成的常量也会存在这个常量池中。

(2)虚拟机栈:

​ a. 虚拟机栈也就是我们平常所称的栈内存,它为java方法服务,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接和方法出口等信息。

​ b. 虚拟机栈是线程私有的,它的生命周期与线程相同。

​ c. 局部变量表里存储的是基本数据类型、returnAddress类型(指向一条字节码指令的地址)和对象引用,这个对象引用有可能是指向对象起始地址的一个指针,也有可能是代表对象的句柄或者与对象相关联的位置。局部变量所需的内存空间在编译器间确定

​ d. 操作数栈的作用主要用来存储运算结果以及运算的操作数,它不同于局部变量表通过索引来访问,而是压栈和出栈的方式

​ e. 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接.动态链接就是将常量池中的符号引用在运行期转化为直接引用。

(3)本地方法栈
本地方法栈和虚拟机栈类似,只不过本地方法栈为Native方法服务。

(4)堆

java堆是所有线程所共享的一块内存,在虚拟机启动时创建,几乎所有的对象实例都在这里创建,因此该区域经常发生垃圾回收操作。

堆里面分为新生代和老生代(java8 取消了永久代,采用了 Metaspace),新生代包 含 Eden+Survivor 区,survivor 区里面分为 from 和 to 区,内存回收时,如果用的是复制算法,从 from 复制到 to,当经过一次或者多次 GC 之后,存活下来的对象会被移动到老年区,当 JVM 内存不够用的时候,会触发 Full GC,清理 JVM 老年区当新生区满了之后会触发 YGC,先把存活的对象放到其中一个 Survice 区,然后进行垃圾清理。

因为如果仅仅清理需要删除的对象,这样会导致内存碎片,因此一般会把 Eden 进行完全的清理,然后整理内存。那么下次 GC 的时候, 就会使用下一个 Survive,这样循环使用。如果有特别大的对象,新生代放不下,就会使用老年代的担保,直接放到老年代里面。因为 JVM 认为,一般大对象的存活时间一般比较久远。

(5)程序计数器

内存空间小,字节码解释器工作时通过改变这个计数值可以选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理和线程恢复等功能都需要依赖这个计数器完成。该内存区域是唯一一个java虚拟机规范没有规定任何OOM情况的区域。

2、 heap 和stack 有什么区别

(1)申请方式

stack:由系统自动分配。例如,声明在函数中一个局部变量 int b; 系统自动在栈中为 b 开辟空间

heap:需要程序员自己申请,并指明大小,在 c 中 malloc 函数,对于Java 需要手动 new Object()的形式开辟

(2)申请后系统的响应

stack:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。

heap:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。

(3)申请大小的限制

stack:栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在 WINDOWS 下,栈的大小是 2M(也有的说是 1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示 overflow。因此,能从栈获得的空间较小。

heap:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见, 堆获得的空间比较灵活,也比较大。

(4)申请效率的比较

stack:由系统自动分配,速度较快。但程序员是无法控制的。

heap:由 new 分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。

(5)heap和stack中的存储内容

stack:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址, 然后是函数的各个参数,在大多数的 C 编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。

当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。

heap:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容有程序员安排。

3、Jvm内存模型(重排序、内存屏障)

内存屏障:可以阻挡编译器的优化,也可以阻挡处理器的优化

happens-before原则:

1:一个线程的A操作总是在B之前,那多线程的A操作肯定实在B之前。

2:monitor 再加锁的情况下,持有锁的肯定先执行。

3:volatile修饰的情况下,写先于读发生

4:线程启动在一起之前 strat

5:线程死亡在一切之后 end

6:线程操作在一切线程中断之前

7:一个对象构造函数的结束都该对象的finalizer的开始之前

8:传递性,如果A肯定在B之前,B肯定在C之前,那A肯定是在C之前。

主内存:所有线程共享的内存空间

工作内存:每个线程特有的内存空间

三、类加载过程

1、JVM的类加载过程

Java类加载需要经历一下几个过程:

(1)加载

加载时类加载的第一个过程,在这个阶段,将完成一下三件事情:

​ a. 通过一个类的全限定名获取该类的二进制流。

​ b. 将该二进制流中的静态存储结构转化为方法去运行时数据结构。

​ c. 在内存中生成该类的Class对象,作为该类的数据访问入口。

(2)验证

验证的目的是为了确保Class文件的字节流中的信息不回危害到虚拟机.在该阶段主要完成以下四种验证:

​ a. 文件格式验证:验证字节流是否符合Class文件的规范,如主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型.

​ b. 元数据验证:对字节码描述的信息进行语义分析,如这个类是否有父类,是否集成了不被继承的类等。

​ c. 字节码验证:是整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要针对方法体的验证。如:方法中的类型转换是否正确,跳转指令是否正确等。

​ d. 符号引用验证:这个动作在后面的解析过程中发生,主要是为了确保解析动作能正确执行。

​ e. 准备

准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

(3)解析

该阶段主要完成符号引用到直接引用的转换动作。解析动作并不一定在初始化动作完成之前,也有可能在初始化之后。

(4)初始化

初始化时类加载的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码。

2、类加载器

类加载器 就是根据指定全限定名称将class文件加载到JVM内存,转为Class对象。

主要有一下四种类加载器:

(1)启动类加载器(Bootstrap ClassLoader)用来加载java核心类库,无法被java程序直接引用,由C++语言实现(针对HotSpot),负责将存放在<JAVA_HOME>\lib目录或-Xbootclasspath参数指定的路径中的类库加载到内存中。。

(2)扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类,负责加载<JAVA_HOME>\lib\ext目录或java.ext.dirs系统变量指定的路径中的所有类库。。

(3)系统类加载器(system class loader)也叫应用类加载器:它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。

(4)用户自定义类加载器,通过继承 java.lang.ClassLoader类的方式实现。

3、双亲委派机制

如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。

image-20221209092113983

为什么需要双亲委派模型?

在这里,先想一下,如果没有双亲委派,那么用户是不是可以自己定义一个java.lang.Object的同名类,java.lang.String的同名类,并把它放到ClassPath中,那么类之间的比较结果及类的唯一性将无法保证,因此,为什么需要双亲委派模型?防止内存中出现多份同样的字节码。

怎么打破双亲委派模型?

打破双亲委派机制则不仅要继承ClassLoader类,还要重写loadClass和findClass方法。

四、垃圾回收算法

1、java中垃圾收集的方法有哪些?

java中有四种垃圾回收算法,分别是标记清除法、标记整理法、复制算法、分代收集算法;

①标记-清除

这是垃圾收集算法中最基础的,根据名字就可以知道,它的思想就是标记哪些要被回收的对象,然后统一回收。

第一步:利用可达性去遍历内存,把存活对象和垃圾对象进行标记;

第二步:在遍历一遍,将所有标记的对象回收掉;

这种方法很简单,但是会有两个主要问题:1.效率不高,标记和清除的效率都很低;2.会产生大量不连续的内存碎片,导致以后程序在分配较大的对象时,由于没有充足的连续内存而提前触发一次GC动作。

②复制算法:

为了解决效率问题,复制算法将可用内存按容量划分为相等的两部分,然后每次只使用其中的一块,当一块内存用完时,就将还存活的对象复制到第二块内存上,然后一次性清楚完第一块内存,再将第二块上的对象复制到第一块。

但是这种方式,内存的代价太高,每次基本上都要浪费一般的内存。

于是将该算法进行了改进,内存区域不再是按照1:1去划分,而是将内存划分为8:1:1三部分,较大那份内存交Eden区,其余是两块较小的内存区叫Survior区。每次都会优先使用Eden区,若Eden区满,就将对象复制到第二块内存区上,然后清除Eden区,如果此时存活的对象太多,以至于Survivor不够时,会将这些对象通过分配担保机制复制到老年代中。(java堆又分为新生代和老年代)

*③标记-整理:

第一步:利用可达性去遍历内存,把存活对象和垃圾对象进行标记;

第二步:将所有的存活的对象向一段移动,将端边界以外的对象都回收掉;

该算法主要是为了解决标记-清除,产生大量内存碎片的问题;当对象存活率较高时,也解决了复制算法的效率问题。它的不同之处就是在清除对象的时候现将可回收对象移动到一端,然后清除掉端边界以外的对象,这样就不会产生内存碎片了。

④分代收集

现在的虚拟机垃圾收集大多采用这种方式,它根据对象的生存周期,将堆分为新生代和老年代。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用复制算法。老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用标记-整理 或者 标记-清除。

内存利用率:标记整理算法 > 标记清除算法 > 复制算法

内存连续性: 标记整理算法 = 复制算法 > 标记清除算法

效率:

对象存活率不高:复制算法 > 标记清楚算法 > 标记整理算法对象存活率高:标记清除算法 > 复制算法 > 标记整理算法新生代对象存活率不高:选择复制算法

老年代对象存活率较高:选择标记清除算法 标记整理算法

2、JVM如何判断一个对象可以被回收

判断一个对象是否存活有两种方法:

(1)引用计数法

所谓引用计数法就是给每一个对象设置一个引用计数器,每当有一个地方引用这个对象时,就将计数器加一,引用失效时,计数器就减一。当一个对象的引用计数器为零时,说明此对象没有被引用,也就是“死对象”,将会被垃圾回收.

引用计数法有一个缺陷就是无法解决循环引用问题,也就是说当对象A引用对象B,对象B又引用者对象A,那么此时A,B对象的引用计数器都不为零,也就造成无法完成垃圾回收,所以主流的虚拟机都没有采用这种算法。

(2)可达性算法(引用链法)

该算法的基本思路就是通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象是不可用的。

GCRoot对象有四种对象:

1、jvm栈中的引用对象

2、方法区中的引用静态常量

3、方法区中的普通引用常量4、native方法中的引用对象

一个对象经过两次标记为垃圾对象,该对象才会被判定为垃圾对象。

3、JVM内存分配与回收策略

内存分配:

(1)栈区:栈分为java虚拟机栈和本地方法栈

(2)堆区:堆被所有线程共享区域,在虚拟机启动时创建,唯一目的存放对象实例。堆区是gc的主要区域,通常情况下分为两个区块年轻代和年老代。更细一点年轻代又分为Eden区,主要放新创建对象,From survivor 和 To survivor 保存gc后幸存下的对象,默认情况下各自占比 8:1:1。

(3)方法区:被所有线程共享区域,用于存放已被虚拟机加载的类信息,常量,静态变量等数据。被Java虚拟机描述为堆的一个逻辑部分。习惯是也叫它永久代(permanment generation)

(4)程序计数器:当前线程所执行的行号指示器。通过改变计数器的值来确定下一条指令,比如循环,分支,跳转,异常处理,线程恢复等都是依赖计数器来完成。线程私有的。

回收策略以及Minor GC和Major GC:

Minor GC是新生代GC,指的是发生在新生代的垃圾收集动作。由于java对象大都是朝生夕死的,所以Minor GC非常平凡,一般回收速度也比较i快。

Major GC/Full GC 是老年代GC,指的是发生在老年代的GC,出现Major GC一般经常会伴有Minor GC,Major GC的速度比Minor GC慢的多。

(1)对象优先在堆的Eden区分配。

(2)大对象直接进入老年代。

(3)长期存活的对象将直接进入老年代。

当Eden区没有足够的空间进行分配时,虚拟机会执行一次Minor GC.Minor GC通常发生在新生代的Eden区,在这个区的对象生存期短,往往发生GC的频率较高,回收速度比较快;Full Gc/Major GC 发生在老年代,一般情况下,触发老年代GC的时候不会触发Minor GC,但是通过配置,可以在Full GC之前进行一次Minor GC这样可以加快老年代的回收速度。

4、GC流程

java堆 = 新生代+老年代;

新生代 = Eden + Suivivor(S0 + S1),默认分配比例是8:1:1;

当Eden区空间满了的时候,就会触发一次Minor GC,以收集新生代的垃圾,存活下来的对象会被分配到Survivor区

大对象(需要大量连续内存空间的对象)会直接被分配到老年代

如果对象在Eden中出生,并且在经历过一次Minor GC之后仍然存活,被分配到存活区的话,年龄+1,此后每经历过一次Minor GC并且存活下来,年龄就+1,当年龄达到15的时候,会被晋升到老年代;

当老年代满了,而无法容纳更多对象的话,会触发一次full gc;full gc存储的是整个内存堆(包括年轻代和老年代);

Major GC是发生在老年代的GC,清理老年区,经常会伴随至少一次minor gc;

5、垃圾收集器及各自的特点

垃圾收集器是JVM调优中最核心的一个知识点,我们常说的JVM调优其实都是根据对应的垃圾收集器特性而去做调整和优化。

垃圾收集器虽然看起来数量比较多,但其实总体逻辑都是因为我们硬件环境的升级而演化出来的产品,不同垃圾收集器的产生总体可以划分为几个阶段。。

第一阶段:单线程收集时代(Serial和Serial Old)

第二阶段:多线程收集时代(Parallel Scanvenge 和Parallel Old)

第三阶段:并发收集时代(ParNew和CMS)

第四阶段:智能并发收集时代(G1)

下面的图一方面介绍了有哪些垃圾收集器,另外一方面也描述了每个垃圾收集器是负责哪个分代(新生代、老年的)的垃圾收集,还有一部分信息是告诉我们每个新生代的垃圾收集器可以与哪些老年代的搜集配合工作。

image-20221213090646764

常用组合:Serial+Serial Old, Parallel Scavenge+Parallel Old,ParNew+CMS,G1(不需要组合其他收集器)。

Serial 垃圾收集流程

Serial会开启一个线程进行垃圾收集,在收集的整个过程都会暂停用户线程(Stop the Word),直到垃圾收集完毕,如果把垃圾收集的过程当作打扫房间卫生,那么Serial 的收集过程就是在你收集房间的时候,你首先会让房间里的人都出去,然后你再安心打扫房间,直到你打扫完毕了才能让外面的人进来,这样就不用担心你一边打扫房间一边还有人在房间里扔垃圾了。

注意:说到“暂停用户线程”,这里也是各种垃圾收集器的一个区分指标,后面的有些垃圾收集器收集的某些阶段是不需要暂停用户线程的。

收集器特点

收集区域: Serial (新生代),Serial Old(老年代)。

使用算法: Serial (标记复制法),Serial Old(标记整理法)。

搜集方式: 单线程收集。

优势: 内存资源占用少、单核CPU环境最佳选项。

劣势: 整个搜集过程需要停顿用户线程。多核CPU、内存富足的环境,资源优势无法利用起来。

Parallel Scavenge工作流程

Parallel Scavenge 和Parallel Old的工作机制一样,这里以Parallel Scavenge为例,Parallel Old在收集过程中会开启多个线程一起收集,整个过程都会暂停用户线程,直到整个垃圾收集过程结束。和之前的Serial垃圾收集器一对比,同样进行垃圾收集前都是先叫其他人都离开房间,但是不同的是serial只有一个人打扫房间,而这里却是有多个人一起打扫房间,所以从这一点看Parallel 系列的收集器要比之前的效率高上很多。

收集器特点

收集区域: Parallel Scavenge (新生代),Parallel Old(老年代)。

使用算法: Parallel Scavenge (标记复制法),Parallel Old(标记整理法)。

搜集方式: 多线程。

优势: 多线程收集,CPU多核环境下效率要比serial高。

劣势: 整个搜集过程需要停顿用户线程。

③ParNew收集器流程

ParNew收集流程和Parallel Scavenge一样 ,同样是先停止应用程序线程,再进行多线程同时收集,整个收集过程都会暂停用户线程(Stop the Word),直到垃圾收集完毕。

ParNew的特点

收集区域: 新生代。

使用算法: 标记复制法。

搜集方式: 多线程。

搭配收集器: CMS。

优势: 多线程收集,CPU多核环境下效率要比serial高,新生代唯一一个能与CMS配合的收集器。

劣势: 整个搜集过程需要停顿用户线程。

CMS收集器

为了尽量减少用户线程的停顿时间,CMS采用了一种全新的策略使得在垃圾回收过程中的某些阶段用户线程和垃圾回收线程可以一起工作,这样就避免了因为长时间的垃圾回收而使用户线程一直处于等待之中。

整个过程就像我们打扫房间的时候可以让大家留在房间里工作,等我把房间的其他地方都打扫完,只剩大家工作的那部分区域的垃圾,这个时候再让大家到房间外面去,我再把房间里那些剩下的地方清理干净就行了,这样做的好处就是大家的工作时间变长了,在房间外等待的时间变短了。

CMS 也是按这个逻辑把整个垃圾收集的过程分成四个阶段,分别是初始标记、并发标记、重新标记、并发清理四个阶段,然后CMS会根据每个阶段不同的特性来决定是否停顿用户线程。

阶段一:初始标记

初始标记的目的是先把所有GC Root直接引用的对象进行标记,因为需要避免在标记GC Root的过程还有程序在继续产生GC Root对象,所以这个过程是需要需要停止用户线程 ,因为这个过程只会标记GC Root的直接引用,并不会对整个GC Root的引用进行遍历,所以这个过程速度也是所有阶段中最快的。

阶段二:并发标记

并发标记阶段的工作就是把阶段一标记好的GC Root对象进行深度的遍历,找到所有与GC Root关联的对象并进行标记,这个过程中是采用多线程的方式进行遍历标记,对整个JVM 的GC Root进行遍历的过程是垃圾收集过程中最耗时的一步,CMS为了考虑尽量不停顿用户线程,所以这个阶段是不停止用户线程的,也就是说这个阶段JVM会分配一些资源给用户线程执行任务,通过这样的方式减少用户线程的停顿时间。

阶段三:重新标记

因为在阶段二的时候用户线程同时也在运行,这个过程中又会产生新的垃圾,所以重新标记阶段主要任务是把上一个阶段中产生的新垃圾进行标记( 使用多线程标记),很显然这个过程是对上一个阶段用户线程运行遗留的垃圾进行标记,所以数量 是非常少执行时间也是最短的,当然为了避免这个过程再次产生新的垃圾,所以重新标记的过程是会停顿用户线程的。

阶段四:并发清理

并发清理阶段是对那些被标记为可回收的对象进行清理,在一般情况下并发清理阶段是使用的标记清除法,因为这个过程不会牵扯到对象的地址变更,所以CMS在并发清理阶段是不需要停止用户线程的。也正因为并发清理阶段用户线程也可以同时运行,所以在用户线程运行的过程中自然也会产生新的垃圾,这也就是导致CMS收集器会产生“浮动垃圾”的原因。

当然,在一种情况下并发清理阶段CMS也会停顿用户线程,这就和我们之前说过的CMS选用的垃圾回收算法有关系,因为一般情况下使用的都是标记清除法,但是标记清除法的弊端就是在于会产生空间碎片,所以当空间碎片到达了一定程度时,此时CMS会使用标记整理法解决空间碎片的问题,不过因为标记整理法会将对象的位置进行挪动并更新对象的引用的指向地址,那么这个过程中用户线程同时运行的话会产生并发问题,所以当CMS进行碎片整理的时候必须得停止用户线程。

CMS的特点

收集区域: 老年代。

使用算法: 标记清除法+标记整理法。

搜集方式: 多线程。

搭配收集器: ParNew。

优势: 多线程收集,收集过程不停止用户线程,所以用户请求停顿时间短。

CMS遗留的问题

CMS收集器开辟了一条垃圾收集的新思路,不过这么好的垃圾收集器却一直没有被Hospot虚拟机纳入到默认的垃圾收集器,到Jdk8使用的默认收集器都还是 Parallel scavenge 和 Parallel old,这其中非常重要的原因就是CMS遗留了几个比较头疼的问题。

1、浮动垃圾

在并发清理阶段因为需垃圾收集线程是和用户线程同时执行任务的,这个时候用户线程运行时产生的垃圾是无法在当前阶段进行回收的,所以这段时间用户线程产生的新垃圾只能遗留到下一次收集,这些在垃圾收集过程中新产生的垃圾我们称为浮动垃圾。

3、空间碎片整理造成卡顿

CMS在平常情况下会使用标记清除法进行回收,只有在老年代的空间碎片达到一定程度,这个时候就会使用标记整理法对内存的空间碎片进行整理,因为标记整理的过程需要移动对象的位置,所以这个过程只能Stop the word,这个时候内存越大那么这个收集时间就越长,造成这种卡顿现象。

4、可能导致系统长时间的假死。

因为在并发清除阶段会有新的对象产生,在有担保机制的情况下,当新生代垃圾清理的时候存活的对象大多,导致Survior区无法容纳全部的对象,这时就会触发担保机制,这里存活的对象里面会有一部分会直接进入老年代,所以在每次GC的时候老年代需要预留一部分内存出来,所以通常CMS 在老年代占用了大概百分之七八十的时候就进行FullGC。

不过这段时间的产生对象的总体大小是未知的,如果新生代存活的对象非常多,这些担保的对象转移到老年代的时候可能导致老年代预留的空间也不足以容纳,那么此时CMS不得不进行一次Stop the word 的Full GC ,因为此时堆空间已经完全占满,这个时候已经无法使用并发的清理方式进行收集了,所以此时只能停止用户线程来专心进行垃圾收集,而这时候老年代收集器不得不从CMS切换成Serial old垃圾收集器来进行垃圾收集 。

至于这里为什么要使用单线程的Serial old,而不选择多线程的Parallel Old,那是因为CMS的新生代收集器是ParNew,而ParNew只能与CMS和Serial Old配合),所以这也是个无奈的选择。而切换成Serial old来进行垃圾收集的时候就有问题了,Serial old收集器是单线程的,它只适用于内存大小在几十到上百M的大小,而往往我们现在的内存大小都是几G到几十G,所以这种情况下整个垃圾收集的时间可能会特别特别长,有时候可能达到几个小时甚至好几天的都有可能。

G1收集器

CMS开创了垃圾收集器的一个新时代,它实现了垃圾收集和用户线程同时执行,达到垃圾收集的过程不停止用户线程的目标,这个思路作为后面的收集器提供了一个很好的典范。时代向前优化不止,除了需要解决了CMS遗留了的几个问题外,硬件资源的升级换代,可用的内存资源越来越多一直是促进垃圾收集器发展的一个核心驱动力,可使用的内存资源变多对于软件来说这当然是个好事,不过对于垃圾收集器来说就变得越来越麻烦了,随着发展我们发现传统垃圾收集器的收集方式已经不适用于这种大内存的垃圾收集了。

不管是Serial系列、Parallel系列、CMS系列,它们都是基于把内存进行物理分区的形式把JVM内存分成老年代、新生代、永久代或MetaSpace,这种分区模式下进行垃圾收集时必须对某个区域进行整体性的收集(比如整个新生代、整个老年代收集或者整个堆), 原来的内存空间都不是很大,一般就是几G到几十G,但现在的硬件资源发展可用的内存达到几百G甚至上T的程度,那么JVM中的某一个分代区域就可能会有几十上百G的大小,那么如果这时候采用传统模式下的物理分区的收集的话,每次垃圾扫描内存区域变大了、那么需要的清理时间自然就会变得更加长了;换做打扫卫生来说,原来你只需要打扫几个小办公室就行了,但是随着公司业务发展整栋楼是都是你公司了,这个时候你需要打扫公司卫生的时间无疑也会变得特别长。

所以问题出现了,那么自然就有人会来解决的,G1就是在这种环境下诞生的,G1首先吸取了CMS优良的思路,还是使用并发收集的模式,但是更重要的是G1摒弃了原来的物理分区,而是把整个内存分成若干个大小的Region区域,然后由不同的Region在逻辑上来组合成各个分代,这样做的好处是G1进行垃圾回收的时候就可以用Region作为单位来进行更细粒度的回收了,每次回收可以只针对某一个或多个Region来进行回收。

G1最核心的分区基本单位Region ,G1没有像之前一样把堆内存划分为固定连续的几块区域,而是完全舍弃了进行内存上的物理分区,而是把堆内存拆分成了大小为1M-32M的Region块,然后以Region为单位自由的组合成新生代、老年代、Eden区、survior区、大对象区(Humonggous Region),随着垃圾回收和对象分配每个Region也不会一直固定属于哪个分代,我们可以认为Region可以随时扮演任何一个分代区域的内存。

G1的回收流程和CMS逻辑大致相同,分别进行初始标记、并发标记、重新标记、筛选清除,区别在最后一个阶段G1不会直接进行清除,而是会根据设置的停顿时间进行智能的筛选和局部的回收。

阶段一:初始标记

初始标记额目的是先把所有GC Root直接引用的对象进行标记,因为需要避免在标记GC Root的过程还有程序在继续产生GC Root对象,所以这个过程是需要停止用户线程 ,因为这个过程并不会对整个GC Root的引用进行遍历,所以这个过程速度是非常快的。

阶段二:并发标记

并发标记阶段的工作就是把阶第一段标记好的GC Root对象进行深度的遍历,找到所有与GC Root关联的对象并进行标记,这个过程中是采用多线程的方式进行遍历标记,对整个JVM 的GC Root进行遍历的过程是垃圾收集过程中最耗时的一步,为了尽量不停顿用户线程,所以这个阶段GC线程会和用户线程同时运行,通过这样的方式减少用户线程的停顿时间。

阶段三:最终标记

因为在上个阶段用户线程同时也在运行,用户线程运行的过程中又会产生新的垃圾,所以重新标记阶段主要任务是把上一个阶段中产生的新垃圾进行标记( 使用多线程标记),很显然这个过程是对上一个阶段用户线程运行遗留的垃圾进行标记,所以数量是非常少执行时间也是非常短的,当然为了避免这个过程再次产生新的垃圾,所以重新标记的过程是会停顿用户线程的。

阶段四:筛选回收

把存活的对象复制到空闲Region区域,再根据Collect Set记录的可回收Region信息进行筛选,计算Region回收成本,根据用户设定的停顿时间值制定回收计划,根据回收计划筛选合适的Region区域进行回收。

回收算法:从局部来说G1是使用的标记复制法,把存活对象从一个Region复制到另外的Region,但从整个堆来说G1的逻辑又相当于是标记整理法,每次垃圾收集时会把存活的对象整理到其他对应区域的Region里,再把原来的Region标记为可回收区域记录到CSet里,所以G1的每一次回收都是一次整理过程,所以也就不会产生空间碎片问题。

G1的特点

收集区域: 整个堆内存。

使用算法: 标记复制法

搜集方式: 多线程。

搭配收集器: 无需其他收集器搭配。

优势: 停顿时间可控,吞吐量高,可根据具体场景选择吞吐量有限还是停顿时间有限,不需要额外的收集器搭配。

劣势: 因为需要维护的额外信息比较多,所以需要的内存空间也要大,6G以上的内存才能考虑使用G1收集器。

⑥总结

从现在往回看,我们会发现每个垃圾收集器都是一个时代的产物。

第一阶段:在单核CPU,内存资源稀缺的时代使用的是Serial和Serial Old收集器,对于单核CPU,内存只有几十M的场景Serial的效率是非常高的。

第二阶段:进入多核CPU时代后出现了Parallel Scavenge和Parallel Old收集器,利用多线程并行收集极大的提高了垃圾收集的效率,所以在多核CPU场景,内存在几百M到几G的场景Parallel Scavenge和Parallel Old是适用的。

第三阶段:随着内存的变大,垃圾收集的过程时间变得越来越长了,BS系统的发展也逐渐开始重视用户体验了,所以就出现了CMS以减少用户线程停顿时间为目的的收集器,CMS通过并发收集减少了用户线程的停顿时间,在多核CPU,并且内存空间几G到几十G的空间、并且注重用户体验的CMS垃圾收集器是适用的。

第四阶段:CMS遗留了一些比较致命的问题,所以就有了G1,G1不再对内存进行物理上的分代,而只是进行逻辑上的分区,通过各种机制让垃圾收集变得更智能和可控了,多核CPU,并且内存在10G到上百G的场景G1比较适合。

6、强引用、软应用、弱引用、虚引用的区别?

①强引用:
强引用是我们使用最广泛的引用,如果一个对象具有强引用,那么垃圾回收期绝对不会回收它,当内存空间不足时,垃圾回收器宁愿抛出OutOfMemoryError,也不会回收具有强引用的对象;我们可以通过显示的将强引用对象置为null,让gc认为该对象不存在引用,从而来回收它;

②软引用:
软应用是用来描述一些有用但不是必须的对象,在java中用SoftReference来表示,当一个对象只有软应用时,只有当内存不足时,才会回收它;
软引用可以和引用队列联合使用,如果软引用所引用的对象被垃圾回收器所回收了,虚拟机会把这个软引用加入到与之对应的引用队列中;

③弱引用:
弱引用是用来描述一些可有可无的对象,在java中用WeakReference来表示,在垃圾回收时,一旦发现一个对象只具有软引用的时候,无论当前内存空间是否充足,都会回收掉该对象;
弱引用可以和引用队列联合使用,如果弱引用所引用的对象被垃圾回收了,虚拟机会将该对象的引用加入到与之关联的引用队列中;

④虚引用:
虚引用就是一种可有可无的引用,无法用来表示对象的生命周期,任何时候都可能被回收,虚引用主要使用来跟踪对象被垃圾回收的活动,虚引用和软引用与弱引用的区别在于:虚引用必须和引用队列联合使用;在进行垃圾回收的时候,如果发现一个对象只有虚引用,那么就会将这个对象的引用加入到与之关联的引用队列中,程序可以通过发现一个引用队列中是否已经加入了虚引用,来了解被引用的对象是否需要被进行垃圾回收。

五、性能调优

常用JVM参数

-Xmn:调整新生代大小

-Xms:调整堆初始大小,默认内存的1/64

-Xmx:调整堆的最大可扩展大小,默认是1/4

-XX:+PrintGCDetails 输出详细的GC处理日志,查看堆的详细信息。

设置JVM参数

命令:java -Xms20m -Xmx50m xx.class

image-20221208091214424

1、用过哪些调优的参数?用过jmap等条用工具么?

1)堆栈配置相关

-Xms 设置初始堆的大小

-Xmx 设置最大堆的大小

-Xmn 设置年轻代大小,相当于同时配置-XX:NewSize和-XX:MaxNewSize为一样的值

-Xss 每个线程的堆栈大小

-XX:NewSize 设置年轻代大小(for 1.3/1.4)

-XX:MaxNewSize 年轻代最大值(for 1.3/1.4)

-XX:NewRatio 年轻代与年老代的比值(除去持久代)

-XX:SurvivorRatio Eden区与Survivor区的的比值

-XX:PretenureSizeThreshold 当创建的对象超过指定大小时,直接把对象分配在老年代。

-XX:MaxTenuringThreshold设定对象在Survivor复制的最大年龄阈值,超过阈值转移到老年代

2)垃圾收集器相关

-XX:+UseParallelGC:选择垃圾收集器为并行收集器。

-XX:ParallelGCThreads=20: 配置并行收集器的线程数

-XX:+UseConcMarkSweepGC:设置年老代为并发收集。

-XX:CMSFullGCsBeforeCompaction=5 由于并发收集器不对内存空间进行压缩、整理,所以运行一段时间以后会产生“碎片”,使得运行效率降低。此值设置运行5次GC以后对内存空间进行压缩、 整理。

-XX:+UseCMSCompactAtFullCollection:打开对年老代的压缩。可能会影响性能,但是可以消除 碎片

3)辅助信息相关

-XX:+PrintGCDetails 打印GC详细信息

-XX:+HeapDumpOnOutOfMemoryError让JVM在发生内存溢出的时候自动生成内存快照,排查问题用

-XX:+DisableExplicitGC禁止系统System.gc(),防止手动误触发FGC造成问题.

-XX:+PrintTLAB 查看TLAB空间的使用情况

2、调优工具

常用调优工具分为两类,jdk自带监控工具:jps、jstat、jmap、jconsole和jvisualvm,第三方有:MAT(Memory Analyzer Tool)、GChisto。

jconsole,Java Monitoring and Management Console是从java5开始,在JDK中自带的java监控和管理控制台,用于对JVM中内存,线程和类等的监控jvisualvm,jdk自带全能工具,可以分析内存快照、线程快照;监控内存变化、GC变化等。MAT,Memory Analyzer Tool,一个基于Eclipse的内存分析工具,是一个快速、功能丰富的Java heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗

GChisto,一款专业分析gc日志的工具

3、那些JVM性能调优

首先需要注意的是在对JVM内存调优的时候不能只看操作系统级别Java进程所占用的内存,这个数值不能准确的反应堆内存的真实占用情况,因为GC过后这个值是不会变化的,因此内存调优的时候要更多地使用JDK提供的内存查看工具,比如JConsole和Java VisualVM。

对JVM内存的系统级的调优主要的目的是减少GC的频率和Full GC的次数,过多的GC和Full GC是会占用很多的系统资源(主要是CPU),影响系统的吞吐量。特别要关注Full GC,因为它会对整个堆进行整理,导致Full GC一般由于以下几种情况:

旧生代空间不足

调优时尽量让对象在新生代GC时被回收、让对象在新生代多存活一段时间和不要创建过大的对象及数组避免直接在旧生代创建对象

Pemanet Generation空间不足

增大Perm Gen空间,避免太多静态对象

统计得到的GC后晋升到旧生代的平均大小大于旧生代剩余空间

控制好新生代和旧生代的比例

System.gc()被显示调用

垃圾回收不要手动触发,尽量依靠JVM自身的机制

调优手段主要是通过控制堆内存的各个部分的比例和GC策略来实现,下面来看看各部分比例不良设置会导致什么后果

1). 新生代设置过小

一是新生代GC次数非常频繁,增大系统消耗;二是导致大对象直接进入旧生代,占据了旧生代剩余空间,诱发Full GC

2). 新生代设置过大

一是新生代设置过大会导致旧生代过小(堆总量一定),从而诱发Full GC;二是新生代GC耗时大幅度增加

一般说来新生代占整个堆1/3比较合适

3). Survivor设置过小

导致对象从eden直接到达旧生代,降低了在新生代的存活时间

4). Survivor设置过大

导致eden过小,增加了GC频率

另外,通过-XX:MaxTenuringThreshold=n来控制新生代存活时间,尽量让对象在新生代被回收

由内存管理和垃圾回收可知新生代和旧生代都有多种GC策略和组合搭配,选择这些策略对于我们这些开发人员是个难题,JVM提供两种较为简单的GC策略的设置方式

1). 吞吐量优先

JVM以吞吐量为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,来达到吞吐量指标。这个值可由-XX:GCTimeRatio=n来设置

2). 暂停时间优先

JVM以暂停时间为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,尽量保证每次GC造成的应用停止时间都在指定的数值范围内完成。这个值可由-XX:MaxGCPauseRatio=n来设置

posted @ 2023-01-03 14:40  程序员田同学  阅读(167)  评论(0编辑  收藏  举报
博客主的头像