1.什么是类加载?类加载的过程?

类的加载指的是将类的​​class​​文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个此类的对象,通过这个对象可以访问到方法区对应的类信息。

一文搞定 JVM 面试,教你吊打面试官~_加载

加载

  1. 通过类的全限定名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 使用到类时才会加载,例如调用类的main( )方法,new对象等等,在加载阶段会在内存中生成一个代表该类的​​Class​​对象,作为方法区类信息的访问入口

验证

  1. 校验字节码文件的正确性

准备

  1. 给类的静态变量分配内存,并赋予默认值

解析

  1. 虚拟机将常量池内的符号引用替换为直接引用。
  2. 符号引用用于描述目标,直接引用直接指向目标的地址。

注意:这里的符号引用是指那些在编译期间就能够确定下来的数据,包括静态属性、常量、私有属性等等,因为这些数据不会被继承或者被重写,所以它们适合在类加载阶段进行解析,这就是所谓的静态链接过程(类加载期间完成)。

而那些在编译期间无法确定下来的数据,就只能等到运行期间再将符号引用替换为直接引用,这也就是动态链接的过程。

初始化

  1. 对类的静态变量初始化为指定的值,执行静态代码块

2.什么是类加载器,类加载器有哪些?

实现通过类的全限定名获取该类的二进制字节流的代码块叫做类加载器。

Java中有如下四种类加载器

  1. 引导类加载器:负责加载支撑JVM运行的位于JRE的​​lib​​​目录下的核心类库,比如
    rt.jar、charsets.jar等,该加载器无法被Java程序直接引用。
  2. 扩展类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的​​ext​​​扩展目录中的JAR
    类包。
  3. 系统类加载器:负责加载ClassPath路径下的类包,主要就是加载你自己写的那
    些类。
  4. 自定义加载器:负责加载用户自定义路径下的类包。

3.什么是双亲委派机制?


一文搞定 JVM 面试,教你吊打面试官~_加载_02

  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。
  1. 系统类加载器先看看自己是否已经加载过当前类(缓存),如果有就直接返回,如果没有则委托给其父加载器—扩展类加载器,扩展类加载器先看看自己是否已经加载过当前类(缓存),如果有就直接返回,如果没有则再委托给引导类加载器。。。
  2. 注意:这里的父类加载器并不是说这两个类是子父类关系(extends),而是说系统类加载器的parent属性是扩展类加载器,然后扩展类加载器的parent属性是null,源码里面会判断扩展类加载器的parent为null然后进入到寻找引导类加载器。
  3. 一文搞定 JVM 面试,教你吊打面试官~_加载_03

  1. 如果父类加载器可以完成类加载任务,就成功返回;倘若父类加载器无法完成此加载任务,子类加载器才会尝试自己去加载,这就是双亲委派机制。
  2. 父类加载器一层一层往下分配任务,如果子类加载器能加载,则加载此类;如果将加载任务分配到系统类加载器也无法加载此类,则抛出异常。

为什么要设计双亲委派机制?

沙箱安全机制

  1. 自己写的 java.lang.String.class 类不会被加载,这样便可以防止核心API库被随意篡改
  2. 尽管我们自己可以改写成和JDK原生类路径一样的代码,但是因为双亲委派机制是往上走,到了引导类加载器,会自动把对应路径的原生API加载到虚拟机,这样子就能避免黑客随意篡改我们的核心类库了。

避免类的重复加载

  1. 当父加载器已经加载了该类,就没有必要让子ClassLoader再加载一次,保证被加载类的唯一性

全盘负责委托机制

“全盘负责”是指当一个ClassLoder装载一个类时,除非显示的使用另外一个ClassLoder,否则该类
所依赖及引用的类也由这个ClassLoder载入。

4.如何自定义类加载器?

我们来看下应用程序类加载器AppClassLoader加载类的双亲委派机制源码,AppClassLoader的loadClass方法最终会调用其父类ClassLoader的loadClass方法,该方法的大体逻辑如下:

  1. 首先,检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
  2. 如果此类没有加载过,那么再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用parent.loadClass(name, false);)或者是调用bootstrap类加载器来加载。
  3. 如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载。
//ClassLoader的loadClass方法,里面实现了双亲委派机制
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) { //如果当前类加载器的父加载器不为空则委托父加载器加载该类
c = parent.loadClass(name, false);
} else { //如果当前类加载器的父加载器为空则委托引导类加载器加载该类
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();
//都会调用URLClassLoader的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(c);
}
return c;
}
}

自定义类加载器

自定义类加载器只需要继承 java.lang.ClassLoader 类,该类有两个核心方法,一个是loadClass(String, boolean),实现了双亲委派机制,还有一个方法是findClass,默认实现是空方法。

  1. 我们一般自定义类加载器都是继承ClassLoader,重写findClass()方法,来实现类加载,这样就不会违背双亲委派机制。
  2. 也可以通过重写loadClass()方法进行类加载,但是这样会违背双亲委派机制。

5.Tomcat 打破双亲委派机制

先思考一个问题,Tomcat是个web容器, 那么它要解决什么问题:

  1. 一个web容器可能需要部署多个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器具有多份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
  2. 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机。
  3. web容器也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
  4. web容器要支持jsp的修改,我们知道,jsp文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情, web容器需要支持 jsp 修改后不用重启。

以 Tomcat 类加载为例,Tomcat 如果使用默认的双亲委派类加载机制行不行?

答案是不行的。为什么?

第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的类加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。

第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性

第三个问题和第一个问题一样。

我们再来看第四个问题,我们想要怎么实现jsp文件的热加载,jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。

那怎么办呢?我们可以直接卸载掉这个jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载掉这个jsp类加载器。重新创建jsp类加载器,重新加载jsp文件。

Tomcat自定义类加载器详解


一文搞定 JVM 面试,教你吊打面试官~_加载_04

tomcat的几个主要类加载器:

  • commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
  • catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
  • sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
  • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见,比如加载war包里相关的类,每个war包应用都有自己的WebappClassLoader,实现相互隔离,比如不同war包应用引入了不同的spring版本,这样实现就能加载各自的spring版本。

从图中的委派关系中可以看出:

CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离。

WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。

而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个​​.Class​​文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉当前的JasperLoader实例,并通过再创建一个新的JSP类加载器来实现JSP文件的热加载功能。

tomcat 这种类加载机制违背了Java 推荐的双亲委派模型了吗?

答案是:违背了。
很显然,tomcat 不是这样实现的,tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader 加载自己的目录下的class文件,不会传递给父加载器,打破了双亲委派机制。

Tomcat的JasperLoader热加载

后台启动线程监听jsp文件变化,如果变化了找到该jsp对应的servlet类的加载器引用(GCRoots),重新生成新的JasperLoader加载器并赋值给该引用;

然后加载新的jsp对应的servlet类,之前的那个加载器因为没有GCRoots引用了,下一次GC的时候会被销毁。

6.JVM内存模型(运行时数据区)

类加载后的大的Class对象是存放在堆中,math对象应该是指向方法区中的类元信息,而类元信息再去指向堆中的大的Class对象


一文搞定 JVM 面试,教你吊打面试官~_加载器_05

public class Math {

public static final int initData = 666;
public static User user = new User();

public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}

public static void main(String[] args) {
Math math = new Math();
math.compute();
System.out.println("test");
}

}

7.JVM内存参数设置

一文搞定 JVM 面试,教你吊打面试官~_加载_06

栈和方法区所占用的内存空间都是堆外的本地内存

Spring Boot程序的JVM参数设置格式

java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar microservice-eureka-server.jar

-Xss:每个线程的栈大小

-Xms:设置堆的初始可用大小,默认物理内存的1/64

-Xmx:设置堆的最大可用大小,默认物理内存的1/4

-Xmn:新生代大小

-XX:NewRatio:默认2表示新生代占老年代的1/2,占整个堆内存的1/3。

-XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存。

关于元空间的JVM参数有两个:-XX:MetaspaceSize=N-XX:MaxMetaspaceSize=N

-XX:MaxMetaspaceSize: 设置元空间最大值, 默认是​​-1​​, 即不受限制, 或者说只受限于本地内存大小。

-XX:MetaspaceSize: 指定元空间触发Full GC的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M左右,达到该值就会触发Full GC进行类型卸载, 同时收集器会对该值进行调整:如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过 -XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。

这个跟早期JDK版本的**-XX:PermSize**参数意思不一样,-XX:PermSize代表永久代的初始容量。

由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSizeMaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M

StackOverflowError示例:

// JVM设置  -Xss128k(默认1M)
public class StackOverflowTest {

static int count = 0;

static void redo() {
count++;
redo();
}

public static void main(String[] args) {
try {
redo();
} catch (Throwable t) {
t.printStackTrace();
System.out.println(count);
}
}
}

运行结果:
java.lang.StackOverflowError
at com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:12)
at com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:13)
at com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:13)
......

注意:

-Xss设置越小count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多。

8.对象的创建

对象创建的主要流程:

一文搞定 JVM 面试,教你吊打面试官~_加载器_07

类加载检查

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

new指令对应到语言层面上讲是,new关键词、对象克隆、反射、对象序列化等。

分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

这个步骤有两个问题:

  1. 如何划分内存。
  2. 在并发情况下, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

划分内存的方法:

  • “指针碰撞”(Bump the Pointer,默认用指针碰撞)

如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。

  • “空闲列表”(Free List,CMS收集器使用这种方式)

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录。

解决并发问题的方法:

  • CAS(compare and swap)

虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。

  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)

把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存。通过 -XX:+/-UseTLAB 参数来设定虚拟机是否使用 TLAB(JVM会默认开启 -XX:+UseTLAB),-XX:TLABSize:指定TLAB大小。

初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。

这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

设置对象头

初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。

HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

32位对象头

一文搞定 JVM 面试,教你吊打面试官~_类加载器_08

64位对象头

一文搞定 JVM 面试,教你吊打面试官~_加载_09

关于对其填充

对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式。

什么是指针压缩?

  1. jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩
  2. jvm配置参数:UseCompressedOops,compressed–压缩、oop(ordinary object pointer)–对象指针
  3. 启用指针压缩:-XX:+UseCompressedOops(默认开启),禁用指针压缩:-XX:-UseCompressedOops

为什么要进行指针压缩?

  1. 在64位平台的HotSpot中如果使用32位指针(实际存储用64位),内存使用会多出1.5倍左右;而如果使用较大指针在主内存和缓存之间移动数据,会占用较大宽带,同时GC也会承受较大压力
  2. 为了减少64位平台下内存的消耗,启用指针压缩功能
  3. 在jvm中,32位地址最大支持4G内存(2^32),可以通过对对象指针存入堆内存时进行压缩编码,取出到cpu寄存器后进行解码的方式来进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)
  4. 堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
  5. 堆内存大于32G时,指针压缩会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好

执行 init 方法

执行init方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值)和执行构造方法。

9.对象内存分配

对象内存分配流程图

一文搞定 JVM 面试,教你吊打面试官~_加载器_10

对象栈上分配

我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行内存回收,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。

为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸则可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

对象逃逸分析

就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

public User test1() {
User user = new User();
user.setId(1);
user.setName("zhuge");
//TODO 保存到数据库
return user;
}

public void test2() {
User user = new User();
user.setId(1);
user.setName("zhuge");
//TODO 保存到数据库
}

很显然test1方法中的user对象被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结束时这个对象就是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内存一起被回收掉。

JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis

标量替换

通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象的成员变量分解成若干个被这个方法使用的成员变量所代替,这些代替的成员变量就在栈帧或寄存器上分配空间。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启

标量与聚合量

标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int、long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。

栈上分配示例:

/**
* 栈上分配,标量替换
* 代码调用了1亿次alloc(),如果是分配到堆上,大概需要1GB以上的堆空间,如果堆空间小于该值,必然会触发GC。
*
* 使用如下参数不会发生GC
* -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
* 使用如下参数都会发生大量GC
* -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
* -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
* +DoEscapeAnalysis:开启逃逸分析,-XX:+EliminateAllocations:开启标量替换
*/
public class AllotOnStack {

public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}

private static void alloc() {
User user = new User();
user.setId(1);
user.setName("zhuge");
}
}

结论:栈上分配依赖于逃逸分析和标量替换

10.对象在Eden区分配

大多数情况下,对象在新生代中的 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

Minor GC 和 Full GC

  • Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
  • Major GC/Full GC:一般会回收老年代 、年轻代、方法区的垃圾,Major GC的速度一般会比Minor GC 慢10倍以上。

Eden : s0 : s1 = 8 : 1 : 1

  1. 大量的对象被分配在Eden区,Eden区满了后会触发Minor GC,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor区;
  2. 下一次eden区满了后又会触发minor gc,把eden区和survivor区的垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的 8:1:1 的比例是很合适的,让eden区尽量的大,survivor区够用即可
  3. JVM 默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个 8:1:1 的比例自动变化,如果不想让这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。

当Eden区的空间填满时,程序又需要创建新对象,JVM的垃圾收集器将对Eden区进行垃圾回收(Minor GC / Young GC),将Eden区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到Eden区。如果触发MinorGC后对象还是无法放在Eden区,说明是超大对象,则直接将对象放到老年代。

JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和 ParNew 两个收集器下有效。

比如设置JVM参数:-XX:PretenureSizeThreshold=1000000(单位是字节,只有Serial和ParNew的收集器有效)

为什么要这样设计呢?

为了避免为大对象分配内存时的复制操作而降低效率。

长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象设置一个对象年龄(Age)计数器。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。

对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。

对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

对象动态年龄判断

当前存放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了;

例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n 的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。

这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在 Minor GC 之后触发的。

老年代空间分配担保机制

年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间

如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象

就会看一个 “-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了

如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次 minor gc 后进入老年代的对象的平均大小

如果上一步结果是小于或者前面说的参数没有设置,那么就会触发一次 Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生 “OOM

当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发Full gc,Full gc 完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”。


一文搞定 JVM 面试,教你吊打面试官~_加载_11

11.对象内存回收

堆中几乎存放着所有的对象实例,对堆进行垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;

当引用失效时,计数器就会减1,任何时候计数器为0的对象就是不可能再被使用的对象。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。

所谓对象之间的相互循环引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是因为他们互相引用着对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知 GC 回收器回收他们。

public class ReferenceCountingGc {
Object instance = null;

public static void main(String[] args) {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}

可达性分析算法

“GC Roots” 对象作为起点,从这些节点开始向下搜索引用到的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象。

一文搞定 JVM 面试,教你吊打面试官~_加载器_12

**GC Roots **根节点有哪些?

  1. 局部变量
  2. 静态变量

finalize( ) 方法最终判定对象是否存活

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。

标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。

第一次标记并进行一次筛选

筛选的条件是此对象是否有必要执行finalize( )方法。当对象没有重写finalize( )方法时,对象将直接被回收。

第二次标记

如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize( )中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,

譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。

注意:一个对象的finalize( )方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会只有一次。

12.常见引用类型

Java 的引用类型一般分为四种:强引用软引用、弱引用、虚引用。

强引用

指在程序代码中普遍存在的引用赋值,类似 “Object obj = new Object( )” 这种引用关系;如果内存空间不足了,GC 宁愿抛出 OutOfMemoryError,也不会回收具有强引用的对象。

软引用

用来描述一些还有用,但非必需的对象,例如缓存数据。当内存足够时,不会回收软引用的可达对象;当内存不够时,才会回收软引用的可达对象。

SoftReference<User> user = new SoftReference<User>(new User());

软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退显示的网页内容是重新进行请求还是从缓存中取出呢?

我们就可以用到软引用,将后退显示的网页内容缓存起来,后面需要点击后退按钮时就无需重新进行请求可以快速响应。当内存空间不足时,就会回收掉这些软引用的对象。

因为这些缓存数据是可有可无的,有的话更好,没有的话也不影响。

弱引用

用来描述那些非必需的对象,如果一个对象只具有弱引用,不管内存空间是否充足,都会在下一次 GC 时被回收。

WeakReference<User> user = new WeakReference<User>(new User());

虚引用

如果一个对象只具有虚引用,那么它就和没有任何引用一样,任何时候都可能被 GC 回收。

13.如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类呢?

类需要同时满足下面3个条件才能算是 “无用的类”

  • 该类所有的对象实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收(自定义类加载器可以被回收,比如jsp类加载器)。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

这三个条件是非常苛刻的,所以当我们做完Full GC后,元空间是释放不出什么空间的,因为没有太多的类是能被回收的。当然也有特殊的情况,就是tomcat自定义的类加载器,jsp类加载器这些就可以被回收。

14.垃圾收集算法

分代收集理论

当前虚拟机的垃圾收集都采用分代收集算法,根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

  1. 比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
  2. 而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
  3. 注意,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上。

复制算法

为了解决效率问题,“复制算法”出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。

当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次性清理掉。这样就使得每次的内存回收都是对内存区间的一半进行回收。


一文搞定 JVM 面试,教你吊打面试官~_加载_13

在Minor GC过程中对象被挪动后,引用如何修改?

对象在堆内部挪动的过程其实是复制,原有区域对象还在,一般不直接清理,JVM内部清理过程只是将对象分配指针移动到原有区域的头位置即可。

比如扫描S0区域,扫到GC Roots引用的非垃圾对象,是将这些对象复制到S1区或老年代,最后扫描完了再将S0区域的对象分配指针移动到S0区域的起始位置即可,S0区域之前的对象并不直接清理,当有新对象分配了,原有区域里的对象也就被覆盖(清除)了。

Minor GC在根扫描过程中会记录所有被扫描到的对象引用(在年轻代这些引用很少,因为大部分都是垃圾对象不会被扫描到),如果引用的对象被复制到新地址了,最后会一并更新引用指向新地址。

标记-清除算法

算法分为“标记”和“清除”两个阶段:

标记存活的对象, 统一回收所有未被标记的对象(一般选择这种),

也可以反过来,标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象 。它是最基础的收集算法,比较简单,但是会带来两个明显的问题:

  1. 效率问题(如果需要标记的对象太多,效率不高)
  2. 空间问题(标记清除后会产生大量不连续的碎片)

一文搞定 JVM 面试,教你吊打面试官~_加载_14

标记-整理算法

根据老年代的特点推出的一种标记算法,标记过程仍然与“标记-清除”算法一样,

但后续步骤不是直接对可回收对象进行回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。


一文搞定 JVM 面试,教你吊打面试官~_类加载器_15

15.垃圾收集器

一文搞定 JVM 面试,教你吊打面试官~_加载器_16

虽然我们对各个垃圾收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体的应用场景选择适合自己的垃圾收集器

Serial收集器

-XX:+UseSerialGC -XX:+UseSerialOldGC

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。

这个收集器是一个单线程收集器。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作时必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。

新生代采用复制算法,老年代采用标记-整理算法

一文搞定 JVM 面试,教你吊打面试官~_加载器_17

Serial收集器有没有优于其他垃圾收集器的地方呢?

简单而高效(与其他收集器的单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。

Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案

Parallel Scavenge收集器

-XX:+UseParallelGC -XX:+UseParallelOldGC

Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。

默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。

Parallel Scavenge 收集器的关注点是吞吐量(高效率的利用CPU)。

CMS、G1 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。

所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。

新生代采用复制算法,老年代采用标记-整理算法

一文搞定 JVM 面试,教你吊打面试官~_加载器_18

Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。

在注重吞吐量以及CPU资源的场合,都可以优先考虑Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)。

ParNew收集器

-XX:+UseParNewGC

ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。

新生代采用复制算法,老年代采用标记-整理算法

一文搞定 JVM 面试,教你吊打面试官~_加载器_19

它是许多运行在Server模式下的虚拟机的首要选择。

CMS收集器(重点)

-XX:+UseConcMarkSweepGC(old)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

运作过程

一文搞定 JVM 面试,教你吊打面试官~_加载_20

  1. 初始标记阶段(STW):暂停所有的用户线程(STW),并记录下 GC Roots 能直接引用的对象,速度很快。
  2. 并发标记阶段: 并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要暂停用户线程(STW), 可以和用户线程一起并发运行。因为用户程序继续运行,可能会导致已经标记过的对象状态发生改变。
  3. 重新标记阶段(STW): 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,但远远比并发标记阶段的时间短。
    主要用到三色标记里的增量更新算法(见下面详解)做重新标记。
  4. 并发清除阶段: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
    这个阶段如果有新增对象会被直接标记为黑色,并且不做任何处理。
  5. 并发重置阶段:重置本次 GC 过程中的标记数据。

CMS 是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面几个明显的缺点:

  1. 会和应用线程一起争抢 CPU 资源,从而导致应用程序变慢,吞吐量降低。
  2. 无法处理浮动垃圾(在并发标记和并发清除阶段又产生垃圾,这种浮动垃圾只能等到下一次 GC 再清理了)
  3. 它使用的标记-清除算法会导致收集结束时产生大量空间碎片,当然可以通过设置参数**-XX:+UseCMSCompactAtFullCollection** 让JVM在执行完标记清除后再做整理
  4. 执行过程中的不确定性,有可能在并发标记或并发清除阶段还没完成时,老年代突然来了个大对象或者年轻代触发了动态年龄判断机制导致一大批对象挪动到老年代,而此时老年代是没有空间可以存放这些对象的,此时整个过程就会STW,并切换到用Serial Old(单线程)收集器来进行垃圾收集,直到本次垃圾收集结束。(也就是**“concurrent mode failure”**,并发失败)

CMS的相关核心参数

  1. -XX:+UseConcMarkSweepGC:启用CMS
  2. -XX:ConcGCThreads:并发的GC线程数
  3. -XX:+UseCMSCompactAtFullCollection:Full GC之后做压缩整理(减少碎片)
  4. -XX:CMSFullGCsBeforeCompaction:多少次Full GC之后压缩一次,默认是0,代表每次Full GC后都会压缩一次
  5. -XX:CMSInitiatingOccupancyFraction:当老年代使用达到该比例时会触发Full GC(默认是92,这是百分比,如果系统中大对象比较多,可能需要把该参数调小一点)
  6. -XX:+CMSScavengeBeforeRemark:在CMS(Full GC)前启动一次minor gc,目的在于减少老年代对年轻代的引用,降低CMS标记阶段时的开销,一般CMS的GC耗时 80%都在标记阶段
  7. -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
  8. -XX:+CMSParallelRemarkEnabled:表示在重新标记的时候多线程执行,缩短STW

G1收集器(重点)


一文搞定 JVM 面试,教你吊打面试官~_加载器_21

一文搞定 JVM 面试,教你吊打面试官~_加载_22

G1将Java堆划分为多个大小相等的独立区域(Region),JVM目标是不超过2048个Region(JVM源码里 TARGET_REGION_NUMBER 定义),实际可以超过该值,但是不推荐。

一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数"-XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。

G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是可以不连续的Region集合。

默认年轻代对堆内存的初始占比是「5%」,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比。

在系统运行过程中,JVM会不停地给新生代增加更多的Region,但是新生代最多占比不会超过「60%」,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的Region也跟之前一样,默认「8:1:1」,假设年轻代现在有1000个Region,Eden区对应800个,S0对应100个,S1对应100个。

一个Region可能之前是年轻代,之后这个Region进行了垃圾回收,变成空的Region,那么后续这个Region就有可能会变成老年代,也就是说Region的区域功能可能会动态变化。

G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理

G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。

在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的「50%」,比如每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous区中,而且一个大对象如果太大,可能会横跨多个Region来存放。

Humongous区专门存放短期巨型对象,不用直接进入老年代,可以节约老年代的空间,避免因为老年代空间不足而导致的GC开销。

Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。

G1收集器进行一次GC(主要是Mixed GC)的运作过程

一文搞定 JVM 面试,教你吊打面试官~_类加载器_23

  1. 初始标记阶段(STW):暂停所有的用户线程(STW),并记录下 GC Roots 能直接引用的对象,速度很快。
  2. 并发标记阶段:并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要暂停用户线程(STW), 可以和用户线程一起并发运行。因为用户程序继续运行,可能会导致已经标记过的对象状态发生改变。
  3. 最终标记阶段(STW): 最终标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,但远远比并发标记阶段的时间短。
  4. 筛选回收阶段(STW):筛选回收阶段首先对各个Region的回收价值和回收成本进行排序根据用户所期望的GC停顿STW时间(可以用JVM参数 -XX:MaxGCPauseMillis 指定)来制定回收计划

比如说老年代此时有1000个Region都满了,但是根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。

这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,所以停顿用户线程将大幅提高收集效率。

不管是年轻代还是老年代,回收算法主要用的是「复制算法」,将一个Region中的存活对象复制到另一个Region中,这种不会像CMS收集器那样回收完因为有很多内存碎片还需要再整理一次,G1采用复制算法几乎不会有太多内存碎片。

注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没有实现并发回收,不过到了ZGC,Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本。

优先列表

G1收集器在后台维护了一个「优先列表」,每次根据允许的收集时间,优先选择回收价值最大的Region,

比如一个Region花200ms只能回收10M垃圾,另外一个Region花50ms就能回收20M垃圾,在回收时间有限的情况下,G1当然会优先选择后面这个Region来进行回收。

这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能的提高收集效率。

G1收集器的优点:

  • 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU核心来缩短 STW 停顿时间。部分其他收集器原本需要停顿用户线程来执行GC操作,而G1收集器仍然可以通过并发的方式让用户程序继续运行。
  • 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但还是保留了分代的概念。
  • 空间整合:与CMS的「标记-清除」算法不同,G1从整体上来看是基于「标记-整理」算法实现的收集器,而从局部上来看是基于「复制」算法实现的。
  • 可预测的停顿时间:这是G1相对于CMS的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"-XX:MaxGCPauseMillis"指定)内完成垃圾收集。

G1垃圾收集分类

Young GC

Young GC并不是说现有的Eden区放满了就会马上触发,G1会计算现在Eden区回收大概要多长时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的Region,继续给新对象存放,不会马上做Young GC,直到下次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发 Young GC。

Mixed GC

Mixed GC不是Full GC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值时触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定Old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做Mixed GC,主要使用「复制」算法,需要把各个Region中存活的对象拷贝到别的Region中去,拷贝过程中如果发现没有足够的空Region能够承载拷贝对象就会触发一次Full GC。

Full GC

停止用户程序,然后采用单线程进行标记清理和压缩整理,使得空闲出来一批Region来供下一次Mixed GC使用,这个过程是非常耗时的。

G1收集器参数设置

-XX:+UseG1GC:使用G1收集器

-XX:ParallelGCThreads:指定GC工作的线程数量

-XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区

-XX:MaxGCPauseMillis:目标暂停时间(默认200ms)

-XX:G1NewSizePercent:新生代内存初始空间(默认整堆的5%,值配置整数,默认就是百分比)

-XX:G1MaxNewSizePercent:新生代最大内存空间,默认是50%

-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个Region,如果有接近1000个Region都是老年代的Region,则可能就要触发「Mixed GC」了

-XX:G1MixedGCLiveThresholdPercent:默认85%,Region中的存活对象低于这个值时才会回收该Region,如果超过这个值,存活对象过多,回收的的意义不大

-XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复用户程序运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。

-XX:G1HeapWastePercent:默认5%,GC过程中空出来的Region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程中就会不断空出来新的Region,一旦空闲出来的Region数量达到了整堆内存的5%,此时就会立即停止「混合回收」,意味着本次混合回收结束了。

垃圾收集器优化建议

假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代GC。

那么存活下来的对象可能就会很多,此时就会导致Survivor区放不下那么多的对象,就会进入老年代中。

或者是你年轻代GC过后,存活下来的对象过多,导致进入Survivor区后触发了动态年龄判断机制,达到了Survivor区的50%,也会导致一些对象进入老年代中。

所以这里的核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代GC别太频繁的同时,还得考虑每次GC过后的存活对象有多少,避免存活对象太多快速进入老年代,导致频繁触发「Mixed GC」。

什么场景适合使用 G1

  1. 50%以上的堆被存活对象占用
  2. 对象分配和晋升的速度变化非常大
  3. 垃圾回收时间特别长,超过1秒
  4. 8GB以上的堆内存(建议值)
  5. 停顿时间是500ms以内

每秒几十万并发的系统如何优化JVM

Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息是很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配三四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于Eden区的 young GC是很快的,这种大内存情况下它的执行速度还会很快吗?

很显然不可能,因为内存太大,处理起来还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的Eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young GC卡顿几秒钟没法处理新消息,显然是不行的。

那么对于这种情况下如何优化呢,我们可以使用G1收集器,设置 -XX:MaxGCPauseMills 为50ms,假设 50ms 能够回收三到四个G的内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。

G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。

如何选择垃圾收集器

  1. 优先调整堆的大小让服务器自己来选择
  2. 如果内存小于100M,使用串行收集器(Serial + Serial Old)
  3. 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
  4. 如果允许停顿时间超过1秒,选择并行或JVM自己选
  5. 如果响应时间最重要,并且不能超过1秒,使用并发收集器(CMS、G1)
  6. 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC

以下有连线的可以搭配使用

一文搞定 JVM 面试,教你吊打面试官~_加载器_24

JDK1.8 默认使用 Parallel(年轻代和老年代都是)

JDK1.9 默认使用 G1

16.三色标记算法(加分)


一文搞定 JVM 面试,教你吊打面试官~_加载器_25

为了解决「标记-清除」算法的问题,于是就出现了『三色标记算法』!

三色标记算法指的是将所有对象分为白色、黑色和灰色三种类型。

  1. 黑色表示从 GCRoots 开始,已经扫描过它全部引用的对象,
  2. 灰色指的是扫描过对象本身,还没完全扫描过它全部引用的对象,
  3. 白色指的是还没扫描过的对象。

一文搞定 JVM 面试,教你吊打面试官~_加载_26

但仅仅将对象划分成三个颜色还不够,真正关键的是:实现可达性分析算法的时候,将整个过程拆分成了初始标记、并发标记、重新标记、并发清除四个阶段。

  • 初始标记阶段,指的是标记 GC Roots 直接引用的节点,将它们标记为灰色,这个阶段需要 「Stop the World」。
  • 并发标记阶段,指的是从灰色节点开始,去扫描整个引用链,然后将它们标记为黑色,这个阶段不需要「Stop the World」。
  • 重新标记阶段,指的是去校正并发标记阶段的错误,这个阶段需要「Stop the World」。
  • 并发清除,指的是将已经确定为垃圾的对象清除掉,这个阶段不需要「Stop the World」。

通过将最耗时的引用链扫描剥离出来作为「并发标记阶段」,将其与用户线程并发执行,从而极大地降低了 GC 停顿时间。 但 GC 线程与用户线程并发执行,会带来新的问题:对象引用关系可能会发生变化,有可能发生多标漏标问题。

多标问题

在并发标记过程中,如果由于方法运行结束导致部分局部变量(GC Roots)被销毁,这个GC Roots引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存。

这部分本应该回收但没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收才被清除。

另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮回收不会进行清除。这部分对象在运行期间可能也会变成垃圾,这也算是浮动垃圾的一部分。

漏标问题

漏标问题指的是原本应该被标记为存活的对象,被遗漏标记为白色,从而导致该对象被错误地回收。

例如下图中,假设我们现在遍历到了节点 E,此时应用执行如下代码。

这时候因为 E 对象已经没有引用 G 对象了,因此扫描 E 对象的时候并不会将 G 对象标记为黑色存活状态。但由于用户线程的 D 对象引用了 G 对象,这时候 G 对象应该是存活的,应该标记为黑色。但由于 D 对象已经被扫描过了,不会再次扫描,因此 G 对象就被漏标了。

var G = objE.fieldG; 
objE.fieldG = null; // 灰色E 断开引用 白色G
objD.fieldG = G; // 黑色D 引用 白色G


一文搞定 JVM 面试,教你吊打面试官~_加载器_27

漏标问题就非常严重了,其会导致存活对象被回收,会严重影响程序功能。

那么我们的垃圾回收器是怎么解决这个问题呢?

增加一个「重新标记」阶段。无论是在 CMS 回收器还是 G1 回收器,它们都在「并发标记」阶段之后,新增了一个「重新标记」阶段来校正「并发标记」阶段出现的问题。

只是对于 CMS 回收器和 G1 回收器来说,它们解决的原理不同罢了。

漏标解决方案

漏标问题要发生需要满足如下两个充要条件:

  1. 有至少一个黑色对象在自己被标记之后指向了这个白色对象
  2. 某个灰色对象在自己引用扫描完成之前删除了对白色对象的引用

CMS 解决方案

CMS 回收器采用的是增量更新方案,即破坏第一个条件:「有至少一个黑色对象在自己被标记之后指向了这个白色对象」。

既然有黑色对象在自己标记后,又重新指向了白色对象。那么我们就把这个新插入的「引用」记录下来,在后续「重新标记」阶段再以这些记录过的引用关系中的黑色对象为根,对其引用进行重新扫描。通过这种方式,被黑色对象引用的白色对象就会变成灰色,从而变为存活状态。

G1 解决方案

G1 回收器采用的是原始快照的方案,即破坏第二个条件:「某个灰色对象在自己引用扫描完成之前删除了对白色对象的引用」。

既然灰色对象在扫描完成之前删除了对白色对象的引用,那么我们是否能在灰色对象取消引用之前,先将这个要删除的「引用」记录下来。随后在「重新标记」阶段再以这些记录过的引用关系中的灰色对象为根,对它的引用进行重新扫描,这样就能扫描到白色对象,将白色对象直接标记为黑色。

这种方式有个缺点,就是会产生浮动垃圾。 因为当用户线程取消引用的时候,有可能是真的取消引用,对应的对象是真的要回收掉的。这时候我们通过这种方式,就会把本该回收的对象又复活了,从而导致出现浮动垃圾。但相对于本该存活的对象被回收,这个代价还是可以接受的,毕竟在下次 GC 的时候就可以回收了。

为什么G1用原始快照,CMS用增量更新?

原始快照相对于增量更新效率会更高(当然原始快照可能会造成更多的浮动垃圾),因为不需要在「重新标记」阶段再次深度扫描被删除引用的对象,而CMS对增量引用的根对象会做深度扫描;

G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描根对象的话G1的代价会比CMS高,所以G1选择原始快照而不深度扫描根对象,只是简单标记,等到下一轮GC再深度扫描。

写屏障

以上无论是对引用关系的插入还是删除,虚拟机的记录操作都是通过「写屏障」实现的

写屏障

给某个对象的成员变量赋值时,其底层代码大概长这样:

/**
* @param field 某对象的成员变量,如 a.b.d
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) {
*field = new_value; // 赋值操作
}

所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):

void oop_field_store(oop* field, oop new_value) {  
pre_write_barrier(field); // 写屏障-写前操作
*field = new_value;
post_write_barrier(field, value); // 写屏障-写后操作
}


一文搞定 JVM 面试,教你吊打面试官~_加载器_28

写屏障实现原始快照

当对象E的成员变量的引用发生变化时,比如引用消失(e.g = null),我们可以利用写屏障,将E原来成员变量的引用对象G记录下来:

void pre_write_barrier(oop* field) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录原来的引用对象
}

写屏障实现增量更新

当对象D的成员变量的引用发生变化时,比如新增引用(d.g = g),我们可以利用写屏障,将D新的成员变量的引用对象D记录下来:

void post_write_barrier(oop* field, oop new_value) {  
remark_set.add(new_value); // 记录新引用的对象
}

17.卡表(Card Table)

在新生代做GC Roots可达性扫描过程中可能会碰到跨代引用的对象,这种情况如果又去对老年代做扫描那效率就太低了。

为此,在新生代可以引入**记忆集(Remember Set)**的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GC Roots扫描范围内。

在垃圾收集场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。

hotspot使用一种叫做**卡表(Card Table)**的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系, 可以类比Java语言中HashMap与Map的关系。

卡表使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。

hotSpot使用的卡页是2的9次方大小,即512字节。


一文搞定 JVM 面试,教你吊打面试官~_类加载器_29

将老年代划分成一块块特定大小的区域(512byte),每一块称为一个card,即卡页。

一个卡页中可包含多个对象,只要其中有一个对象存在「跨代引用」,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0。

卡表除了包含对应卡页的元素标识,还存储了每个卡页对应的内存地址。

在扫描GC Roots时,除了扫描年轻代的存活对象之外,还需要扫描卡表中为1的对应卡页中的对象。

亿级流量电商系统JVM参数设置优化

大型电商系统后端现在一般都是拆分成多个子系统部署的,比如,商品系统,库存系统,订单系统,促销系统,会员系统等等。

这里以比较核心的订单系统为例:

一文搞定 JVM 面试,教你吊打面试官~_加载_30

分析

假设单台机器每秒产生60M对象,运行14次后占满Eden区。

这里需要注意下,前面13秒的所有对象都可以回收掉,因为这些订单对象在产生1秒后都变为垃圾对象,而最后一秒产生的对象(300个请求),执行到一半的时候Eden区放满了,这时候就会触发Minor GC(STW),意味着最后一秒产生的那60M对象会被挪动到S区,而前面13秒产生的700多M对象会被销毁。

然后这最后的60M对象并不会挪动到S区,而是挪动到老年代里面去了,这是什么呢?

根据动态年龄判断机制,假设这60M对象的年龄都是1,而且光这60M对象就已经超过了S区内存大小的50%,所以会把这60M对象已经年龄大于等于1的所有对象都挪动到老年代中去。

也就意味着每过14秒就有60M对象挪动到老年代,假设老年代的空间大小为2G,那么几分钟后老年代就会被放满,老年代放满就会触发Full GC,这样子就会导致我们的系统几分钟就会执行一次Full GC,正常情况下Full GC应该是几个小时,几天甚至几周做一次才正常。

这种情况是可以做优化的,只需修改下JVM的参数,就可以让我们的JVM几乎不发生Full GC

解决方案:

一文搞定 JVM 面试,教你吊打面试官~_加载_31

-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M 
-XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8

将整个年轻代调大一些,比如设置整个年轻代为2G,那么Eden区就是1.6G,S0和S1各为200M。

假设经过25秒后Eden区才会放满,此时会触发Minor GC,Eden区会被清空,而最后的那60M对象会被放到S区中,而根据动态年龄判断机制发现是没问题的,因此这60M是可以放进S区的。

再经过25秒Eden区满了又会触发Minor GC,而此时会将S区的那60M对象和Eden区清空,然后再把新的60M对象放到S区,这样子循环反复,我们可以发现系统不会触发Full GC了。

这样就降低了因为对象动态年龄判断机制导致的对象频繁进入老年代的问题,其实很多优化无非就是让短期存活的对象尽量都留在survivor区里,不要进入老年代,这样在Minor GC的时候这些对象都会被回收,不会进入到老年代从而导致Full GC。

对象年龄阈值应该设置为多少才移动到老年代比较合适

本例中一次Minor GC要间隔二三十秒,大多数对象一般在几秒内就会变成垃圾,完全可以将默认的15岁改小一点,比如改为5岁,那么意味着对象要经过5次Minor GC才会进入老年代,整个时间也有一两分钟了,如果对象这么长时间都没被回收,完全可以认为这些对象是会存活的比较长的对象(例如缓存),可以移动到老年代,而不是继续一直占用survivor区空间,让更多对象可以存放在年轻代。

对于多大的对象直接进入老年代(参数 -xx:PretenureSizeThreshold),这个一般可以结合自己的系统看下有没有什么大对象生成,预估下大对象的大小,一般来说设置为1M就差不多了,很少有超过1M的大对象,这些对象一般就是你系统初始化分配的缓存对象,比如大的缓存List,Map之类的对象。

可以适当调整JVM参数如下:

-Xms3072M -Xmx3072M -Xmn2048M -Xss1M  -XX:MetaspaceSize=256M 
-XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M

通过ParNew+CMS优化(JDK8主流互联网公司用到的垃圾收集器组合)

JDK8默认的垃圾回收器是 -XX:+UseParallelGC(年轻代)和 -XX:+UseParallelOldGC(老年代),如果内存较大(超过4个G,只是经验值),系统对停顿时间比较敏感,我们可以使用 ParNew+CMS(-XX:+UseParNewGC -XX:+UseConcMarkSweepGC)

之前做的参数调整之后,几乎不会再发生Full GC,但这里有个前提就是当系统压力维持在每秒1000多单的情况下,才不会发生Full GC。

但如果系统在抢购的30分钟内,每过几分钟就会有一个峰值的访问。峰值的访问假设单台机器要承受500单,甚至七八百单、上千单的访问,在这种情况下就有可能会出现问题。

在峰值情况下,比如之前是25秒的最后一秒的60M对象要挪动到S区,而在峰值情况下,订单处理速度是比较慢的(一般单台机器每秒能抗住300单,而如果一下子要抗住七八百单,性能和内存会非常吃紧,会导致每个订单的处理周期变长,一个订单的执行时间可能就要跨几秒钟),所以有可能在每25秒的最后几秒内,这几秒的对象都要挪动到S区,这些对象的大小就远远不止60M了,可能有一两百M甚至两三百M,而我们的S区配置是200M,这样就放不下这些对象,所以要将这两三百M对象直接挪动到老年代,这样有可能在二三十分钟老年代就满了,进而触发Full GC。

然后其实在半小时后发生Full GC,这时候已经过了抢购的最高峰期,后续可能几小时才做一次Full GC。对于碎片整理,因为都是1小时或几个小时才做一次FullGC,是可以每做完一次就开始碎片整理,或者两到三次之后再做一次也行。

综上,只要年轻代参数设置合理,老年代CMS的参数设置基本都可以用默认值,如下所示:

-Xms3072M -Xmx3072M -Xmn2048M -Xss1M  -XX:MetaspaceSize=256M 
-XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=92 -XX:+UseCMSCompactAtFullCollection // 开启Full GC后进行压缩整理
-XX:CMSFullGCsBeforeCompaction=3 // 每做完3次Full GC后进行一次碎片压缩整理