导航:
【黑马Java笔记+踩坑汇总】JavaSE+JavaWeb+SSM+SpringBoot+瑞吉外卖+SpringCloud+黑马旅游+谷粒商城+学成在线+牛客面试题
目录
1、说说你了解的JVM内存模型
2、简单说下你对JVM的了解
3、说说类加载机制
4、说说对象的实例化过程
5、说说JVM的双亲委派模型
6、说说JVM调优思路
7、项目中有没有实际的JVM调优经验?
7.1 CPU飙升
7.2 GC调优
8、请你说说内存溢出
9、请你说说内存泄漏
10、JVM中一次完整的GC流程是怎样的
11、说说JVM的垃圾回收机制
12、说说GC的可达性分析算法
13、说说JVM的垃圾回收算法
14、说说七个垃圾回收器
15、请你讲下CMS(并发标记清除)回收器
16、请你讲下G1垃圾优先回收器
得分点
类加载子系统、运行时数据区、执行引擎
JVM由三部分组成:类加载子系统、运行时数据区、执行引擎
类加载子系统:通过类加载机制加载类的class文件,如果该类是第一次加载,会执行加载、验证、解析。只负责class文件的加载,至于是否可运行,则由执行引擎决定。
类加载过程是在类加载子系统完成的:加载 --> 链接(验证 --> 准备 --> 解析) --> 初始化
运行时数据区:
在程序运行时,存储程序的内容(例如字节码、对象、参数、返回值等)。运行时数据区包括本地方法栈、虚拟机栈、方法区、堆、程序计数器。
只有方法区和堆是各线程共享的进程内存区域,其他运行区都是每个线程可以独立拥有的。
执行引擎:将字节码指令解释/编译为对应平台上的本地机器指令。充当了将高级语言翻译为机器语言的译者。执行引擎在执行过程中需要执行什么样的字节码指令依赖于PC寄存器。每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
详细参考:
什么是JVM的内存模型?详细阐述Java中局部变量、常量、类名等信息
内存模型:
内存模型里的运行时数据区:
JVM由三部分组成:类加载子系统、执行引擎、运行时数据区。
1. 类加载子系统,可以根据指定的全限定名来载入类或接口。
2. 运行时数据区。当程序运行时,JVM需要内存来存储许多内容,例如:字节码、对象、参数、返回值、局部变量、运算的中间结果,等等,JVM会把这些东西都存储到运行时数据区中,以便于管理。而运行时数据区又可以分为方法区、堆、虚拟机栈、本地方法栈、程序计数器。
3. 执行引擎,负责执行那些包含在被载入类的方法中的指令。
加分回答-运行时数据区
运行时数据区是开发者重点要关注的部分,因为程序的运行与它密不可分,很多错误的排查也需要基于对运行时数据区的理解。在运行时数据区所包含的几块内存空间中,方法区和堆是线程之间共享的内存区域,而虚拟机栈、本地方法栈、程序计数器则是线程私有的区域,就是说每个线程都有自己的这个区域。
得分点
Java跨平台、HotSpot、热点代码探测技术、内存模型、垃圾回收算法、垃圾回收器
跨平台
Java跨平台,JVM不跨平台。
JVM是Java语言跨平台的关键,Java在虚拟机层面隐藏了底层技术的复杂性以及机器与操作系统的差异性。运行程序的物理机千差万别,而JVM则在千差万别的物理机上面建立了统一的运行平台,实现了在任意一台JVM上编译的程序,都能在任何其他JVM上正常运行。这一极大的优势使得Java应用的开发比传统C/C++应用的开发更高效快捷,程序员可以把主要精力放在具体业务逻辑,而不是放在保障物理硬件的兼容性上。通常情况下,一个程序员只要了解了必要的Java类库、Java语法,学习适当的第三方开发框架,就已经基本满足日常开发的需要了,JVM会在用户不知不觉中完成对硬件平台的兼容及对内存等资源的管理工作。
默认Java虚拟机HotSpot
HotSpot是Sun/OracleJDK和OpenJDK中的默认Java虚拟机,也是目前使用范围最广的Java虚拟机。HotSpot既继承了Sun之前两款商用虚拟机的优点,也有许多自己新的技术优势,如它名称中的HotSpot指的就是它的热点代码探测技术。HotSpot的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知即时编译器以方法为单位进行编译。如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准即时编译和栈上替换编译行为。通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更复杂的代码优化技术,输出质量更高的本地代码。本地方法栈和Java方法栈是合并的。
内存模型
JVM由三部分组成:类加载子系统、运行时数据区、执行引擎。
类加载子系统:根据指定的全限定名来载入类或接口。
运行时数据区:在程序运行时,存储程序的内容,例如:字节码、对象、参数、返回值等。而运行时数据区又可以分为方法区、堆、虚拟机栈、本地方法栈、程序计数器。
执行引擎:负责执行那些包含在被载入类的方法中的指令。
得分点
加载、验证、准备、解析、初始化
标准回答
类加载过程:加载、链接(验证、准备、解析)、初始化。这个过程是在类加载子系统完成的。
加载:生成类的Class对象。
链接:将类的二进制数据合并到JRE中。该过程分为以下3个阶段:
初始化:类变量赋初值、执行静态语句块。
详细参考:
JDK编译生成的.class字节码文件是什么?从底层结构到代码验证,深度解析Java字节码文件-CSDN博客
Java的类是怎样在虚拟机中加载的?详细阐述JVM的加载、验证和解析过程
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析三个部分统称为连接,而前五个阶段则是类加载的完整过程。
1. 在加载阶段JVM需要在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据的访问入口。
2. 验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
3. 准备阶段是正式为类中定义变量(静态变量)分配到内存并设置类变量初始值的阶段,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域。
4. 解析阶段是Java虚拟机将常量池内的符号替换为直接引用的过程,符号引用以一组符号来描述所引用的目标,直接引用是可以直接指向目标的指针、相对偏移量或者一个能间接定位到目标的句柄。
5. 类的初始化阶段是类加载过程的最后一个步骤,直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。本质上,初始化阶段就是执行类构造器的过程。并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物。
加分回答
关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”:
1. 使用new实例化对象、读写类的静态字段、调用类的静态方法时。
2. 使用java.lang.reflect包的方法对类型进行反射调用时。
3. 当初始化类时,若发现其父类还没有进行过初始化,则先初始化这个父类。
4. 虚拟机启动时,需要指定一个要执行的主类,虚拟机会先初始化这个主类。
5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
得分点
类加载、分配内存(内存规整和不规整)、处理并发安全问题、设置对象头、成员变量赋初值、执行构造方法
对象的实例化过程:
指针碰撞法: 指针一直在空闲和已用内存中间,分配空间时,指针往空闲内存方向移动一段距离,使这段距离刚好满足新对象内存大小。
元空间:是Hotspot在JDK8引入的,用于取代永久代。元空间属于本地内存,由操作系统直接管理,不再受JVM管理。同时也可以自动扩容内存空间,避免内存溢出。默认情况下元空间可以无限使用本地内存,也可以通过-XX:MetaspaceSize限制内存大小。
指针碰撞法: 所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。
如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。 一般使用带有compact( 整理)过程的收集器时,使用指针碰撞。
回顾synchronized用到的对象头:
前面多线程篇有提到,synchronized锁基于对象头的Mark Word,锁升级四个状态里,偏向锁和轻量级锁基于CAS原子替换,重量级锁基于Monitor对象。对象头里Mark Word存哈希码、GC标记、锁信息。对象头里类型指针指向当前对象所在的类。
锁信息:
锁标志位:01未锁定、01可偏向、00轻量级锁、10重量级锁、11垃圾回收标记
偏向锁线程ID、时间戳等
轻量级锁的指针:指向锁记录的指针
重量级锁的指针:指向Monitor锁的指针
【Java面试八股文】Java多线程篇_java多线程八股文_vincewm的博客-CSDN博客
在JVM中,对象的创建遵循如下过程:
当JVM遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。
内存分配完成之后,虚拟机必须将分配到的内存空间都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
接下来,虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的`
()`方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。 一般来说,new指令之后会接着执行`
()`方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
得分点
三个默认类加载器、工作过程、作用
双亲委派模型:当一个类加载器接收到加载类的请求时,它首先会将这个请求委派给其父类加载器处理,只有在父类加载器无法完成加载任务时,才会由该类加载器自己去加载类。
JVM三个默认类加载器:
双亲委派模型的工作过程:
工作过程:
检查父类加载器是否已经加载过这个类:JVM 会首先询问父类加载器是否已经加载了该类。如果已经加载过了,直接返回该类的 Class 对象。如果没加载过,则:
委派给父类加载器加载:如果父类加载器没有加载过该类,那么 JVM 将委托给父类加载器进行加载。每一层都是这样继续委派,直到达到最顶层的启动类加载器。
尝试加载类:如果父类加载器无法加载该类(即所有的父类加载器都无法加载),那么 JVM 将尝试使用自己的类加载器来加载类。
实际流程:
JVM在加载一个类时,会调用应用程序类加载器的loadClass()方法来加载这个类,不过在这方法中,会先使用扩展类加载器的loadClass()方法来加载类,同样扩展类加载器的loadClass()方法中会先使用启动类加载器来加载类;
如果启动类加载器加载到了就直接成功,如果启动类加载器没有加载到,那扩展类加载器就会自己尝试加载该类,如果没有加载到,那么则会由应用程序类加载器来加载这个类。
双亲委派模型的作用:
JAVA_HOME\lib:
是 JDK(Java Development Kit)安装目录下的一个子目录,其中包含了 Java 核心类库,包括一些 Java 的基础类和工具类等。这些类库是 Java 编程语言的基础,为 Java 程序的运行提供了必要的支持。
一些常见的在 JAVA_HOME\lib 目录下的重要文件包括:
- rt.jar:Java 运行时的核心库,包含了 Java 核心类库的大部分内容,如 java.lang、java.util 等。
- charsets.jar:包含了字符集支持的类库。
- jfxrt.jar:JavaFX 运行时的核心库。
- tools.jar:包含了一些 Java 开发工具的类库,如编译器、调试器等。
- dt.jar:包含了 Java 开发工具包的类库,如图形界面工具等。
在编译和运行 Java 程序时,这些类库会被 JVM 的 Bootstrap ClassLoader 加载,以便程序能够使用 Java 核心类库提供的功能。
JAVA_HOME\lib\ext:
是 JDK(Java Development Kit)安装目录下的一个子目录,用于存放 Java 的扩展类库。这些类库提供了一些 Java 平台的扩展功能,如 XML 解析、网络协议、加密解密等。
在 JAVA_HOME\lib\ext 目录下,通常会包含一些 JAR 文件,这些文件是扩展类库的实现。一些常见的扩展类库包括:
- dnsns.jar:DNS 名称服务提供者实现。
- jaccess.jar:Java 访问桥实现。
- ldapsec.jar:LDAP 安全实现。
- sunjce_provider.jar:Sun 的 JCE(Java Cryptography Extension)提供者实现。
- sunpkcs11.jar:Sun 的 PKCS#11 提供者实现。
这些扩展类库提供了一些 Java 平台的高级功能,但并不是所有的 Java 运行时环境都会使用到。通常情况下,如果你需要使用这些扩展功能,你可以将相应的 JAR 文件添加到类路径中,以便 Java 程序能够访问到这些功能。
类路径:
classpath:类路径classpath是编译之后的target文件夹下的WEB-INF/class文件夹。内容等同于打包前的src.main.java和src.main.resource下的目录和文件
classpath* :不仅包含class路径,还包括jar文件中(class路径)进行查找.
对于JDK8及其之前版本的Java应用,都会使用到以下3个系统提供的类加载器来进行加载:
1.启动类加载器
这个类加载器负责加载存放在`
\lib`目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的类库加载到虚拟机的内存中。注意,Java虚拟机会按照文件名识别类库,例如rt.jar、tools.jar,对于名字不符合的类库即使放在lib目录中也不会被加载。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可,即让java.lang.ClassLoader.getClassLoader()返回null。 2.扩展类加载器
这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载`
\lib\ext`目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。 3.应用程序类加载器
这个类加载器由sun.misc.Launcher$AppClassLoader来实现。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。
用户还可以加入自定义的类加载器来进行拓展,这些类加载器之间的协作关系“通常”如下图所示。图中展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。
工作过程
双亲委派模型的工作过程是,如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。
作用:避免类的重复加载、防止核心API被篡改
使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。
反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。
加分回答-双亲委派模型的3次被破坏
双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。在Java的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,双亲委派模型主要出现过3次较大规模的“被破坏”的情况。
1.双亲委派模型的第一次“被破坏”发生在双亲委派模型出现之前
双亲委派模型在JDK1.2之后才被引入,但是类加载器的概念和抽象类ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协。为了兼容这些已有代码,只能在之后的ClassLoader中添加一个protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码。双亲委派的具体逻辑就实现在这里面,按照loadClass()的逻辑,如果父类加载失败,会自动调用自己的findClass()来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器符合双亲委派规则。
2.双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的
双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题,基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?
一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器来完成加载,肯定属于Java中很基础的类型了。但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?
为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。
3.双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的
这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换、模块热部署等。说白了就是希望Java应用程序能像我们的电脑外设那样,接上鼠标、U盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用关机也不用重启。
早在2008年,在Java社区关于模块化规范的第一场战役里,由Sun/Oracle公司所提出的JSR-294、JSR-277规范提案就曾败给以IBM公司主导的JSR-291(即OSGi R4.2)提案。尽管Sun/Oracle并不甘心就此失去Java模块化的主导权,随即又再拿出Jigsaw项目迎战,但此时OSGi已经站稳脚跟,成为业界“事实上”的Java模块化标准。
OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。
JVM调优三步骤、性能监控、性能分析、性能调优
JVM调优三步骤:
监控发现问题:看服务器有没有以下情况,有的话需要调优:
工具分析问题:使用分析工具定位oom、内存泄漏等问题
// 开启在出现 OOM 错误时生成堆转储文件 -Xmx1024m -XX:+HeapDumpOnOutOfMemoryError // 将生成的堆转储文件保存到 /tmp 目录下,并以进程 ID 和时间戳作为文件名 -XX:HeapDumpPath=/tmp/java_%p_%t.hprof // 在进行 Full GC 前生成堆转储文件 // 注:如果没有开启自动 GC,则此参数无效。JDK 9 之后该参数已被删除。 -XX:+HeapDumpBeforeFullGC
性能调优:
JVM参数:
//调整内存大小 -XX:MetaspaceSize=128m(元空间默认大小) -XX:MaxMetaspaceSize=128m(元空间最大大小) -Xms1024m(堆最大大小) -Xmx1024m(堆默认大小) -Xmn256m(新生代大小) -Xss256k(栈最大深度大小) //调整内存比例 //伊甸园:幸存区 -XX:SurvivorRatio=8(伊甸园:幸存区=8:2) //新生代和老年代的占比 -XX:NewRatio=4 //表示新生代:老年代 = 1:4 即老年代占整个堆的4/5;默认值=2 //修改垃圾回收器 //设置Serial垃圾收集器(新生代) //-XX:+UseSerialGC //设置PS+PO,新生代使用功能Parallel Scavenge 老年代将会使用Parallel Old收集器 //-XX:+UseParallelOldGC //CMS垃圾收集器(老年代) //-XX:+UseConcMarkSweepGC //设置G1垃圾收集器 -XX:+UseG1GC //GC停顿时间,垃圾收集器会尝试用各种手段达到这个时间 -XX:MaxGCPauseMillis //进入老年代最小的GC年龄,年轻代对象转换为老年代对象最小年龄值,JDK8默认值15,JDK9默认值7 -XX:InitialTenuringThreshold=7 //新生代可容纳的最大对象,大于则直接会分配到老年代,0代表没有限制。 -XX:PretenureSizeThreshold=1000000 //使用多少比例的老年代后开始CMS收集,默认是68%,如果频繁发生SerialOld卡顿,应该调小 -XX:CMSInitiatingOccupancyFraction //G1混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65% -XX:G1MixedGCLiveThresholdPercent=65 //Heap Dump(堆转储)文件 //当发生OutOfMemoryError错误时,自动生成堆转储文件。 -XX:+HeapDumpOnOutOfMemoryError //错误输出地址 -XX:HeapDumpPath=/Users/a123/IdeaProjects/java-test/logs/dump.hprof //GC日志 -XX:+PrintGCDetails(打印详细GC日志) -XX:+PrintGCTimeStamps:打印GC时间戳(以基准时间的形式) -XX:+PrintGCDateStamps:打印GC时间戳(以日期格式) -Xlog:gc:(打印gc日志地址)
原因:CPU利用率过高,大量线程并发执行任务导致CPU飙升。例如锁等待(例如CAS不断自旋)、多线程都陷入死循环、Redis被攻击、网站被攻击、文件IO、网络IO。
定位步骤:
最差情况下能接受的GC频率:Young GC频率10s一次,每次500ms以内。Full GC频率10min一次,每次1s以内。
其实一小时一次Full GC已经算频繁了,一个不错的应用起码得控制一天一次Full GC。
监控发现问题:上午8点是我们的业务高峰,一到高峰的时候,用户感觉到明显卡顿,监控工具(例如Prometheus和Grafana)发现TP99(99%请求在多少ms内完成)时长明显变高,有明显的的毛刺;内存使用率也不稳定,会周期性增大再降低,于是怀疑是GC导致。
命令行分析问题:通过jstat -gc观察服务器的GC情况,发现Young GC频率提高成原来的10倍,Full GC频率提高成原来的四倍。正常YGC 10min一次,FGC 10h一次。异常YGC 1min一次,FGC 3h一次;
所以主要问题是Young GC频繁,进而导致Full GC频繁。Full GC频繁会触发STW,导致TP99耗时上升。
解决方案:
调优效果:调优后我们重新进行了一次压测,发现TP99耗时较之前降低60%。FullGC耗时降低80%,YoungGC次数减少30%。TP99耗时基本持平,完全符台预期。
得分点
内存溢出、溢出原因、解决方案、mat定位
内存溢出: 申请的内存大于系统能提供的内存。
溢出原因:
使用JDK自带的命令行调优工具 ,判断是否有OOM:
MAT定位导致OOM:示例代码:写死循环创建对象,不断添加到list里,导致堆内存溢出;
# 1.查看进程号 jps # 2.根据进程号导出 jmap -dump:format=b,file=D:\heapdump.hprof
解决方案:
标准回答
内存溢出,简单地说内存溢出就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。
引起内存溢出的原因有很多种,常见的有以下几种:
1. 内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
2. 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
3. 代码中存在死循环或循环产生过多重复的对象实体;
4. 使用的第三方软件中的BUG;
5. 启动参数内存值设定的过小。
加分回答
除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OOM异常的可能。
1. Java堆溢出
Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。
2. 虚拟机栈和本地方法栈溢出
HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。
3. 方法区和运行时常量池溢出
方法区溢出也是一种常见的内存溢出异常,在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场景常见的包括:程序使用了CGLib字节码增强和动态语言、大量JSP或动态产生JSP文件的应用、基于OSGi的应用等。 在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,即常量池是方法区的一部分,所以上述问题在常量池中也同样会出现。而HotSpot从JDK 7开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代,所以上述问题在JDK 8中会得到避免。
4. 本地直接内存溢出
直接内存的容量大小可通过`-XX:MaxDirectMemorySize`参数来指定,如果不去指定,则默认与Java堆最大值一致。如果直接通过反射获取Unsafe实例进行内存分配,并超出了上述的限制时,将会引发OOM异常。
得分点
内存泄漏、内存泄露的9种情况、性能分析工具判断是否有内存泄漏、解决办法
内存泄漏: 不再使用的对象仍然被引用,导致GC无法回收;
内存泄露的9种情况:
性能分析工具判断是否有内存泄漏:
解决办法:
内存泄漏,是指不再使用的对象仍然被引用,导致垃圾收集器无法回收它们的内存。由于不再使用的对象仍然无法清理,甚至这种情况可能会越积越多,最终导致致命的OutOfMemoryError。
可以按照如下的思路来分析和解决内存泄漏问题:
1. 启用分析器
Java分析器是通过应用程序监视和诊断内存泄漏的工具,它可以分析我们的应用程序内部发生的事情,例如如何分配内存。使用分析器,我们可以比较不同的方法并找到可以最佳利用资源的方式。
2. 启用详细垃圾收集日志
通过启用详细垃圾收集日志,我们可以跟踪GC的详细进度。要启用该功能,我们需要将以下内容添加到JVM的配置当中:`-verbose:gc`。通过这个参数,我们可以看到GC内部发生的细节。
3. 使用引用对象
我们还可以借助java.lang.ref包内置的Java引用对象来规避问题,使用java.lang.ref包,而不是直接引用对象,即使用对象的特殊引用,使得它们可以轻松地被垃圾收集。
4. Eclipse内存泄漏警告
对于JDK1.5以及更高的版本中,Eclipse会在遇到明显的内存泄漏情况时显示警告和错误。因此,在Eclipse中开发时,我们可以定期地访问“问题”选项卡,并更加警惕内存泄漏警告。
5. 基准测试
我们可以通过执行基准测试来衡量和分析Java代码的性能。通过这种方式,我们可以比较执行相同任务的替代方法的性能。这可以帮助我们选择更好的方法,并可以帮助我们节约内存。
6. 代码审查
最后,我们总是采用经典的老方式来进行简单的代码演练。在某些情况下,即使这种看似微不足道的方法也有助于消除一些常见的内存泄漏问题。
加分回答-没有一刀切的解决方案,具体问题具体分析
通俗地说,我们可以将内存泄漏视为一种疾病,它通过阻塞重要的内存资源来降低应用程序的性能。和所有其他疾病一样,如果不治愈,随着时间的推移,它可能导致致命的应用程序崩溃。
内存泄漏很难解决,找到它们需要对Java语言有很深的理解并掌握复杂的命令。在处理内存泄漏时,没有一刀切的解决方案,因为泄漏可能通过各种不同的事件发生。 但是,如果我们采用最佳实践并定期执行严格的代码演练和分析,那么我们就可以将应用程序中内存泄漏的风险降到最低。
堆分为哪几个区、GC流程、注意大对象和年龄15(JDK8)
得分点
新生代收集、老年代收集、混合收集、整堆收集
依据分代假说理论,垃圾回收可以分为: 新生代收集、老年代收集、混合收集、整堆收集
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则。而分代收集理论,建立在如下三个分代假说之上,即弱分代假说、强分代假说、跨代引用假说。依据分代假说理论,垃圾回收可以分为如下几类:
1. 新生代收集:目标为新生代的垃圾收集。
2. 老年代收集:目标为老年代的垃圾收集,目前只有CMS收集器会有这种行为。
3. 混合收集:目标为整个新生代及部分老年代的垃圾收集,目前只有G1收集器会有这种行为。
4. 整堆收集:目标为整个堆和方法区的垃圾收集。
加分回答-垃圾收集器
HotSpot虚拟机内置了很多垃圾收集器,其中针对新生代的垃圾收集器有Serial、ParNew、Parallel Scavenge,针对老年代的垃圾收集器有CMS、Serial Old、Parallel Old。此外,HotSpot还内置了面向整堆的G1收集器。
在上述收集器中,常见的组合方式有:
1. Serial + Serial Old,是客户端模式下常用的收集器。
2. ParNew + CMS,是服务端模式下常用的收集器。
3. Parallel Scavenge + Parallel Old,适用于后台运算而不需要太多交互的分析任务。
得分点
概念、GC Roots、引用链、非可达对象两次标记
可达性分析算法:
以根对象集合(GC Roots)的每个跟对象为起始点,根据引用关系向下搜索,将所有与GC Roots直接或间接有引用关系的对象在对象头的Mark Word里标记为可达对象,即不需要回收的有引用关系对象。搜索过程所走过的路径称为“引用链” 。
GC Roots:即GC根节点集合,是一组必须活跃的引用。可作为GC Roots的对象:
非可达对象被回收需要两次标记:
finalize()方法:
finalize()方法是对象逃脱死亡命运的最后一次机会,需要注意的是,任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行。
另外,finalize()方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。
当前主流的商用程序语言的内存管理子系统,都是通过可达性分析算法来判定对象是否存活的。
这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
GC Roots 到底是什么东西呢,哪些对象可以作为 GC Root 呢?
是一组必须活跃的引用。在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
- 在虚拟机栈中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量;
- 在方法区中常量引用的对象,譬如字符串常量池里的引用;
- 在本地方法栈中引用的对象;
- JVM内部的引用,如基本数据类型对应的Class对象,常驻的异常对象,以及系统类加载器;
- 所有被同步锁持有的对象;
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
加分回答-宣告对象死亡要经历两次标记
真正宣告一个对象死亡,至少要经历两次标记过程:
1. 第一次标记
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。反之,该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。
2. 第二次标记
稍后,收集器将对F-Queue中的对象进行第二次小规模的标记。如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合。如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
finalize()方法是对象逃脱死亡命运的最后一次机会,需要注意的是,任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行。另外,finalize()方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。
得分点
标记清除、标记复制、标记整理,比较优缺点(效率、空间浪费、调整引用、stw)、使用场景
标记清除算法、标记复制算法、标记整理算法。
标记清除算法(Mark-Sweep):
标记复制算法(Copying) :
标记整理算法(Mark-Compact) :
分代收集算法:将堆分为新生代、老年代不同生命周期的对象放在不同的代,采用不同的收集算法,以提高回收效率。
引用计数法
每个对象都保存一个引用计数器属性,用户记录对象被引用的次数。
可达性分析法
可达性分析法会以GC Roots作为起始点,然后一层一层找到所引用的对象,被找到的对象就是存活对象,那么其他不可达的对象就是垃圾对象。
1. 标记清除算法
算法分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。它主要有如下两个缺点: 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。 第二个是内存空间碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当程序在运行过程中需要分配较大对象时无法找到足够的连续的内存而不得不提前触发另一次垃圾收集。
2. 标记复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。另外,如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。所以,现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代。
3. 标记整理算法
针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。
加分回答
目前,新生代的垃圾回收采用标记复制算法比较多,老年代的垃圾回收采用标记整理算法比较多。而标记复制算法浪费一半内存的缺点长期以来被人诟病,所以业界也有人针对该算法给出了改进的方案。
IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。在1989年,Andrew
Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。
Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1:1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。
98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor
GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。
对比三种垃圾回收算法:
得分点
Serial、Serial Old、PawNew、CMS、Parallel Scavenge、Parallel Old、G1
各版本默认回收器:JDK8默认回收器是Parallel+Parallel Old。
各区域对应算法:
Serial(串行收集器):
Serial Old(老年代串行收集器):
ParNew(并行新生代收集器):Par是Parallel(并行,平行)的缩写,New:只能处理的是新生代
Parallel Scavenge(并行收集器):
Parallel Old(老年代并行收集器):
CMS(并发标记清除收集器):
G1(Garbage-First,垃圾优先收集器):
//G1混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65% -XX:G1MixedGCLiveThresholdPercent=65
标准回答
《Java虚拟机规范》中对垃圾收集器应该如何实现并没有做出任何规定,因此不同的厂商、不同版本的虚拟机所包含的垃圾收集器都可能会有很大差别,不同的虚拟机一般也都会提供各种参数供用户根据自己的应用特点和要求组合出各个内存分代所使用的收集器。下图是HotSpot虚拟机中包含的垃圾收集器,图中展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。
串行收集器Serial
Serial收集器是最基础、历史最悠久的收集器,使用复制算法,曾经是HotSpot虚拟机新生代收集器的唯一选择。这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。也就是说它在进行垃圾收集时,会发生“Stop The World”
老年代串行收集器Serial Old
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法,这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。ParNew收集器
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
Parallel Scavenge收集器
Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。Parallel Old
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
CMS
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,从名字上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:初始标记、并发标记、重新标记、并发清除。G1
Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。到了JDK 8 Update 40的时候,G1提供并发的类卸载的支持,补全了其计划功能的最后一块拼图。这个版本以后的G1收集器才被Oracle官方称为“全功能的垃圾收集器”。
加分回答
通常,Serial收集器搭配Serial Old使用,ParNew收集器搭配CMS使用,Parallel Scavenge收集器搭配Parallel Old使用。此外,G1是整堆收集器,它无需搭配其他的垃圾收集器。
得分点
介绍、算法、回收区域、四个步骤、优缺点(并发、停顿、内存碎片、浮动垃圾、回收条件)、应用场景
CMS(并发标记清除收集器):
CMS收集器是一种以获取最短回收停顿时间为目标的收集器,从名字上就可以看出CMS收集器是基于标记清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:初始标记、并发标记、重新标记、并发清除。其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。
STW:Stop-The-World是在垃圾回收算法执行过程中,将jvm内存冻结,停顿的一种状态。即暂停用户线程。
1. 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
2. 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
3. 重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
4. 并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
指定老年代使用CMS GC:
-XX:+UseConcMarkSweepGC
加分回答-优缺点
CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿(单位时间内占用用户线程的时间更少了),一些官方公开文档里面也称之为“并发低停顿收集器”。
CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点:
1. 并发阶段,它虽然不会导致用户线程停顿,却因为占用一部分线程而导致应用程序变慢,降低总吞吐量。
2. 它无法处理“浮动垃圾”,有可能会出现“并发失败”进而导致另一次Full GC的发生。
3. 它是一款基于标记清除算法实现的收集器,这意味着收集结束时会有大量内存碎片产生。
得分点
整堆、Region、标记整理、四个步骤
G1(Garbage-First,垃圾优先收集器):
//G1混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65% -XX:G1MixedGCLiveThresholdPercent=65
Garbage First(G1)垃圾优先收集器开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。在G1收集器出现之前的所有其他收集器,垃圾收集的目标范围要么是整个新生代,要么就是整个老年代,再要么就是整个Java堆。而G1跳出了这个限制,它可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。
巨大区:此外,还有一类专门用来存储大对象的特殊区域(Humongous Region)。G1认为只要超过了Region一半的对象即可判定为大对象。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。
更具体的处理思路是,让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小(垃圾数量),价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。
G1收集器的运作过程大致可划分为以下四个步骤:初始标记、并发标记、最终标记、筛选回收。其中,初始标记和最终标记阶段仍然需要停顿所有的线程,但是耗时很短。
加分回答-G1与CMS的对比:
G1从整体来看是基于标记整理算法实现的收集器,但从局部上看又是基于标记复制算法实现。无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内比起CM存。S,G1的弱项也可以列举出不少。例如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比CMS要高。
G1与CMS的选择:
目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间。以上这些也仅是经验之谈,随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜。
G1比CMS更耗费内存和负载:因为使用写前屏障和写后屏障维护记忆集,而cms只用写后屏障。