发布于 

4.Java虚拟机

这个图总结的非常好,强烈推荐配合这张图来理清关于JVM的模型1、JVM内存模型总览 流程图模板_ProcessOn思维导图、流程图

1、Java内存区域

如果没有特殊说明,都是针对的是 HotSpot 虚拟机。

1.1 运行时数据区

Java 虚拟机在执行 Java 程序时,会将内存划分为若干区域,划分的方式, 在 JDK1.8 和之前的方式略有不同。

JDK1.8 之前:(线程共享:堆、方法区、直接内存;线程私有:虚拟机栈、本地方法栈、程序计数器)

运行时数据区
运行时数据区

JDK1.8:(线程共享:堆、方法区变为了元空间(放在了本地内存中)、直接内存; 线程私有:虚拟机栈、本地方法栈、程序计数器)

1.1.1 程序计数器

程序计数器可以看作是线程所执行的字节码的行号指示器,记录线程运行到哪一行的位置。

它主要有两个作用:

(1)字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。

(2)在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候,能够知道该线程上次运行到哪儿了。

⚠️注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

1.1.2 虚拟机栈

平常说的栈内存就是 Java 虚拟机栈,它由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。

栈

局部变量表:主要存放编译期可知的各种数据类型、对象应用。

操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算产生的临时变量也会放在操作数栈中。

动态链接:主要服务一个方法需要调用其他方法的场景。其作用就是为了将符号引用转换为调用方法的直接引用,这个过程被称为动态链接。

动态链接
动态链接

Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束

会出 StackOverFlowError OOM 这两 个错误。

1.1.3 本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误。

1.1.4 堆

Java 虚拟机所管理的内存中最大的一块,堆是所有线程共享的一块内存区域,在虚拟机启动时创建。

此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。 Java堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)

Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。

JDK 1.7及之前,堆内存通常被分为三部分:

  1. 新生代
  2. 老年代
  3. 永久代

下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。

堆结构
堆结构

JDK 1.8及之后,永久代已经被元空间取代,元空间使用的是本地内存。

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

1.1.5 方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

方法区常用的参数有哪些?

1
2
3
4
5
6
//JDK 1.8之前
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
//JDK 1.8
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

1.1.6 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

1.2 方法区和永久代以及元空间是什么关系?

方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口。这里类就可以看做是永久代和元空间,接口可以看做是方法区

也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。

永久代和元空间与方法区的关系
永久代和元空间与方法区的关系

1.3 为什么要将永久代替换为元空间?

  1. 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小,而且元空间可以动态调整大小
  2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

1.4 字符串常量池

1.4.1 设计思想

JVM为了提高性能,减少内存开销,在实例化字符串常量时,进行了一些优化:

  1. 为字符串开辟了一个字符串常量池,类似于缓存区;
  2. 创建字符串常量时,首先查询字符串常量池中是否存在该字符串;
  3. 若存在该字符,返回引用实例,若不存在,实例化该字符串并放入池中;

1.4.2 字符串常量的位置

JDK 1.6及之前:有永久代,运行时常量池在永久代,运行时常量池中包含字符串常量池。

JDK 1.7:有永久代,此时字符串常量池已经被分离出来,放在堆中。

JDK 1.8:无永久代,运行时常量池在元空间中,而字符串常量池在堆中。

1.4.3 常见的三种字符串操作及原理分析

1
String s = "xxx";

在创建对象s时,JVM会先去常量池中通过equals(key)方法,判断是否有相同的对象:

  • 如果有,则直接返回该对象在常量池中的引用;

  • 如果没有,则会在常量池中创建一个新对象,再返回引用。

这种方式创建的字符串对象,只会在常量池中。s最终指向常量池中的引用


1
String s = new String("xxx");

这种方式会保证字符串常量池和堆中都有这个对象,没有就创建,**最后返回堆内存中的对象引用**。

首先会检查字符串常量池中是否存在”xxx”:

  • 不存在,先在字符串常量池中创建一个字符串对象”xxx”,再去堆内存中创建一个字符串对象”xxx”;
  • 存在,直接去堆内存中创建一个字符串对象”xxx”;

最后将堆内存中的引用返回。

注意:如果”xxx”在字符串常量池中存在,那么就只会创建一个对象(在堆中创建),如果不存在,那么会创建两个对象(在堆中和字符串常量池中都创建)。


1
2
String s1 = new String("xxx");
String s2 = s1.intern();

String中的intern()方法是一个native方法,当调用这个方法时会进行判断:

  • 如果常量池中已经包含一个等于此String对象的字符串(用equals()方法确定),则返回常量池中的字符串,此时指向常量池中的引用;
  • 否则,将intern返回的引用指向当前字符串s1(JDK 1.6版本需要将s1复制到字符串常量池中)。此时指向堆中的引用。

1.4.4 经典面试题

(1)下面代码创建了几个对象,最终的结果是什么?

1
2
3
String s1 = new String("he") + new String("llo");
String s2 = s1.intern();
System.out.println(s1 == s2);

在JDK 1.6环境下输出的是false,一共创建了6个对象,因为s1.intern()会将字符串复制到字符串常量池中,并创建新的对象,返回新对象的引用地址,所以结果为false。

在JDK 1.7及以上版本输出的是true,一共创建了5个对象。因为用了”+”,所以创建了new SrtingBuilder()对象,然后字符串常量池和堆中各创建了2个,所以一共是5个对象。之所以结果是true,是因为s1.intern()返回的是s1对象的引用地址,并没有创建新的对象,所以是true。


(2) 下面程序执行的结果是什么?

1
2
3
4
5
String s0 = "ab";
String s1 = "ab";
String s2 = "a" + "b";
System.out.println(s0 == s1);//true
System.out.println(s0 == s2);//true

因为s0和s1中”ab”都是字符串常量,它们在编译期就已经被确定了,所以返回true;

而”a”和”b”也都是字符串常量,当一个字符串由多个字符串常量拼接而成时,那么它自己肯定也是字符串常量,所以s2在编译期也被优化为”ab”。

所以s0 == s1 == s2.


(3)字符串常量和字符串对象的比较

1
2
3
4
5
6
String s0 = "ab";
String s1 = new String("ab");
String s2 = "a" + new String("b");
System.out.println(s0 == s1);//false
System.out.println(s0 == s2);//false
System.out.println(s1 == s2);//false

因为new String()创建的字符串并不是常量,不能再编译期就确定,所以所创建的字符串返回的是堆中的引用。

s0是字符串常量池中的引用,s1是堆中的引用,所以s0 == s1输出的当然是false;

同理s2的后半部分是新创建对象在堆中的引用,所以s0 == s2输出的当然是false;

s1 会在堆上创建一个新的 String 对象,而 s2 则会在常量池中创建一个新的 String 对象。虽然它们的值都是 "ab",但是它们的引用不同,因此 s1 == s2 的比较结果是 false


(4)String字符串不可变

1
2
3
4
5
6
String s1 = "a" + "b" + "c";	//看jdk版本,5个或者1个
String a = "a";
String b = "b";
String c = "c";
String s2 = a + b + c; //会创建一个StringBuilder对象、一个String对象和一个"abc"对象,共三个对象
System.out.println(s1 == s2);//false

s1等价于String s1 = "abc",而s2在JVM底层其实是通过StringBuilderappend()方法实现的。

所以在用”+”来拼接引用类型时,会产生新的String对象。


(5)下面的代码创建了几个对象?

1
String s1 = "a" + "b" + "c";

在JDK 1.6及之前创建了五个对象,分别是 “a”,“b”,“c”,“ab”,”abc”。因为该版本中字符串常量池在永久代中,永久代的垃圾回收机制是很特殊的,在此区域内的数据是不会被回收的,只有到达临界值会发生Full GC,关闭JVM才会释放内存。

在JDK 1.7及之后创建了一个对象,也即JVM会在编译期进行优化。

1.4.5 总结

当字符串用”+”来拼接时,拼接后的字符串是否会生成新的对象,要根据”+”两侧的字符串判断。

  • 如果两侧字符串均为字符串常量,即有确切的常量值,则JVM会在编译期对其进行优化,会将两个字符串常量拼接为一个字符串常量,如果拼接后的字符串常量在字符串常量池中存在的,则不创建对象,如果不存在,则会创建对象。
  • 如果两侧字符串有字符串引用存在,因为引用的值在JVM的编译期是无法确定的,所以”+”无法被JVM编译器进行优化,只有在程序运行期来动态分配,并为凭借后的字符串创建新的对象(分配新的内存地址)。

1.5 说一说Java对象创建的过程?

对象创建过程
对象创建过程
  1. 虚拟机遇到一条new指令时,首先会进行类加载检查:
    1. 检查这个指令的参数是否能在常量池中定位到一个类的符号引用;
    2. 检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那么需要先执行相应的类加载过程。
  2. 对象所需要的内存大小在类加载之后就可以完全确定,分配内存就相当于把一块确定大小的内存从堆中划出来,具体的分配方式取决于堆内存是否规整,是否规整又取决于所采用的GC收集器是否带有压缩整理功能。
    1. 内存分配的两种方式:
      • 指针碰撞
      • 空闲列表
        内存分配方式
        内存分配方式
    2. 内存分配并发问题(保证线程安全
      • CAS+失败重试:虚拟机采用 CAS+失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
      • TLAB:为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
  3. 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一步工作过程也可以提前至TLAB分配时进行。初始化的操作保证了对象实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 初始化零值之后,虚拟机要对对象进行必要的设置,这些设置信息放在对象头中,对象头中包括:MarkWord、数组长度、类型指针。
  5. 为对象的属性赋值,执行对象的构造方法。

1.6 说一说对象的内存布局?

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

对象的内存布局
对象的内存布局

Hotspot 虚拟机的对象头包括两部分信息第一部分MarkWord,用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。(只有数组对象才有第三部分,即数组长度部分

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

1.7 说一说对象的访问定位的方式?

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄直接指针

使用句柄:如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。

使用句柄
使用句柄

直接指针:如果使用直接指针访问,reference 中存储的直接就是对象的地址。

直接指针
直接指针

使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。

使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

HotSpot 虚拟机主要使用的就是直接指针的方式来进行对象访问。

1.8 说一说JVM是由哪些部分组成的,他们的运行流程是什么?

JVM有四大组成部分,分别是:

  • 类加载器
  • 运行时数据区
  • 执行引擎
  • 本地库接口

执行流程是:

  1. 首先利用IDE,将java源代码.java,编译成.class文件;
  2. 类加载器将.class文件加载到jvm中;
  3. 再通过执行引擎将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口来实现整个程序的功能。

1.9 说一说堆和栈的区别?

  1. 栈内存中一般存储的是局部变量和方法调用,堆内存中存储的是Java对象和数组
  2. 栈不会垃圾回收,堆会进行垃圾回收;
  3. 栈是线程私有的,堆是线程共有的。

1.9.1 数据结构中的堆栈和JVM中的堆栈区别?

在数据结构中,堆和栈是数据结构。

  1. 堆是完全二叉树,堆中个元素是有序的。在这个二叉树中所有的节点和左右孩子节点存在着大小关系,如果所有的节点都大于左右孩子节点则为大根堆,如果所有的节点都小于其左右孩子节点,那说明这是一个小根堆,建堆的过程其实就是一个排序的过程,而且堆的查询效率也很高。
  2. 栈其实是一种特殊的线性表,最主要的特性就是先进后出,只允许在一端(栈顶)插入、删除。

在JVM虚拟机中得堆栈对应内存的不同区域,和数据结构中所说的堆栈是两码事。

1.10 哪些区域会造成OOM异常?

除了程序计数器,其他五个区域都会造成OOM异常。

1.10.1 栈溢出的原因可能是什么?

  1. 递归调用过深。在递归函数中如果没有正确的终止递归,每一层递归都会向栈中压入一个新的栈帧,当递归层数太深时,栈空间就会被耗尽,导致栈溢出异常。
  2. 局部变量过多。在方法中定义大量的局部变量,每个局部变量都需要在栈帧中分配内存空间,当局部变量太多时,会占用过多的栈空间,从而导致栈溢出异常。

1.10.2 有什么解决方法?

  1. 增加栈大小。通过调整JVM启动参数,可以增加JVM所分配的栈大小。例如,在使用Java命令行运行程序时,可以使用”-Xss”选项指定栈大小。但是,增加栈大小可能会导致内存的消耗过多,应该慎重考虑;
  2. 优化递归调用。对于递归函数,应该正确地设计终止递归的条件,从而避免无限递归。此外,也可以尝试改为非递归实现;
  3. 优化代码。对于方法中定义的局部变量,可以尝试将其转换为成员变量,从而减少栈空间的占用。

2、类加载过程和类加载器

2.1 类的生命周期

类的生命周期
类的生命周期

2.2 类加载过程

系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析

加载的过程主要完成三件事:

  1. 通过全类名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口

2.3 类加载器

  • 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载步骤;
  • 每个Java类都有个引用指向加载它的ClassLoader
  • 数组类不是通过ClassLoader创建的,是由JVM直接生成的。

简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。

2.3.1 类加载器加载规则

JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。

对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次

2.3.2 类加载器总结

JVM中设置了三个重要的ClassLoader

  1. BootstrapClassLoader(启动类加载器) :最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jarresources.jarcharsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。

  2. ExtensionClassLoader(扩展类加载器) :主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。

  3. AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。

类加载过程
类加载过程

除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。

每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoadernull的话,那么该类是通过 BootstrapClassLoader 加载的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Main{
public static void main(String[] args) {
ClassLoader classLoader = Main.class.getClassLoader();
StringBuilder split = new StringBuilder("|--");
boolean needContinue = true;
while(needContinue){
System.out.println(split.toString() + classLoader);
if (classLoader == null){
needContinue = false;
}else{
classLoader = classLoader.getParent();
split.insert(0, "\t");
}
}
}
}

结果:

1
2
3
|--sun.misc.Launcher$AppClassLoader@18b4aac2
|--sun.misc.Launcher$ExtClassLoader@1b6d3586
|--null

从输出结果可以看出:

  • 我们编写的 Java 类 MainClassLoaderAppClassLoader
  • AppClassLoader的父 ClassLoaderExtClassLoader
  • ExtClassLoader的父ClassLoaderBootstrap ClassLoader,因此输出结果为 null

2.3.3 自定义类加载器

如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader抽象类。

ClassLoader 类有两个关键的方法:

  • protected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resove 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。
  • protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。

如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

2.4 双亲委派模型

2.4.1 概念

当加载一个类时,先委托其父类加载器(扩展类加载器)进行查找,如果找不到,再委托上层父类加载器(启动类加载器)进行查找,如果找到了,就加载该目标类。

如果所有父类加载器在各自的加载路径下均找不到目标类,则在自己的类加载器(应用程序加载器)路径中查找,并加载该目标类。

总结:向上委派,向下加载

双亲委派模型
双亲委派模型

注意⚠️:双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。

类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。

2.4.2 底层源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
//首先,检查该类是否已经加载过
Class c = findLoadedClass(name);
if (c == null) {
//如果 c 为 null,则说明该类没有被加载过
long t0 = System.nanoTime();
try {
if (parent != null) {
//当父类的加载器不为空,则通过父类的loadClass来加载该类
c = parent.loadClass(name, false);
} else {
//当父类的加载器为空,则调用启动类加载器来加载该类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//非空父类的类加载器无法找到相应的类,则抛出异常
}

if (c == null) {
//当父类加载器无法加载时,则调用findClass方法来加载该类
//用户可通过覆写该方法,来自定义类加载器
long t1 = System.nanoTime();
c = findClass(name);

//用于统计类加载器相关的信息
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
//对类进行link操作
resolveClass(c);
}
return c;
}
}

从源码中可以看出整个双亲委派机制的流程:

  1. 检查指定名称的类是否已经加载过,如果加载过,就不需要再加载,直接返回。
  2. 如果此类没有加载过,那么再判断是否有父加载器,有的话则由父加载器加载,即调用parent.loadClass(name, false);,或者调用启动类加载器来加载。
  3. 如果父类加载器和启动类加载器都没有找到指定的类,那么子加载器才会尝试自己加载,即调用当前类的findClass(name)方法来完成类加载。

2.4.3 Java中如何判定两个类是否相同?

JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样

只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。

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

  1. 避免类的重复加载。每个类加载器都有自己的命名空间,类加载器之间互相隔离,这样就可以避免在同一个虚拟机中出现两个完全相同的类。如果没有使用双亲委派机制,那么同一个类可能会被不同的类加载器加载多次,这会浪费内存空间,并且也容易引起类之间的兼容性问题。
  2. 提高安全性。由于父类加载器可以保证自己加载的类不被子类加载器所替代,这样就可以防止恶意的代码替换掉核心 API 中的类,从而保证了程序的安全性。
  3. 简化类加载器的实现。如果每个类都要自己实现类加载的过程,那么会十分繁琐,使用双亲委派机制可以让类加载器只关注自己的加载任务,把加载过程交给父类加载器实现。

2.4.5 如何打破双亲委派机制?

自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

3、JVM垃圾回收

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)

3.1 对象堆内分配

3.1.1 对象优先在Eden区分配

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

堆内存中,Eden与Survivor区内存默认比例为8:1:1(堆内存分配原则:让Eden区尽量大,servivor区够用即可

3.1.2 大对象直接进入老年代

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

大对象直接进入老年代主要是为了避免为大对象分配内存时复制操作(因为新生代中垃圾回收采用的是复制算法)而降低效率。

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

虚拟机给每个对象一个对象年龄(Age)计数器。

大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。

对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁,不同垃圾收集器不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

3.1.4 对象动态年龄判断

对象动态年龄判断机制一般是Minor GC之后触发

当一批对象的总大小 ≥ Survivor区域内存大小的50%(这个值可以通过-XX:TargetSurvivorRatio指定),那么此时大于等于这批对象中年龄最大的其他对象,就可以直接进入老年代了。目的是让那些可能是长期存活的对象,尽早进入老年代

3.1.5 空间分配担保

空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间

3.2 对象死亡的判断方法(垃圾判断算法)

其实就是对象的回收,释放掉在内存中已经没用的对象。

有两种方法:引用计数法根可达性分析算法

3.2.1 引用计数法

给对象中添加一个引用计数器:

  • 每当有一个地方引用它,计数器就加 1;
  • 当引用失效,计数器就减 1;
  • 任何时候计数器为 0 的对象就是不可能再被使用的。

计数器值为0的对象就是要被回收的对象。

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


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


3.2.2 根可达性分析算法

通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。

根可达性分析法
根可达性分析法

哪些对象可以作为GC Roots?

  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态变量
  • 方法区中常量
  • 持有同步锁的对象
  • 常驻异常对象

引用类型

JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)

  • 强引用:普通引用变量,比如new出来的对象。无论内存是否足够,都不会回收
  • 软引用:当GC内存不足时,会回收该对象
  • 弱引用:GC会直接回收
  • 虚引用:GC会直接回收,主要用来跟踪对象被垃圾回收的活动。

特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

3.2.4 如何判断一个类是无用的类?

同时满足以下三个条件,就是无用的类:

  • 该类的所有实例都已经被回收,也即Java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3.2.5 如何判断一个常量是废弃常量?

  • 常量没有被任何对象引用。
  • 常量所在的类已经被加载,而且加载的Class对象已经被引用。

3.3 垃圾收集算法

3.3.1 标记清除算法

首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。

  • 优点:删除较快,简单高效。

  • 缺点:产生内存碎片(内存不连续);当老年代对象过多时,效率较慢。

标记清除算法
标记清除算法

3.3.2 标记复制算法

它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

  • 优点:不会出现内存碎片问题,简单高效。

  • 缺点:浪费空间,移动对象的开销大。

标记复制算法
标记复制算法

3.3.3 标记整理算法

根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

  • 优点:消除了清除算法中内存不连续的问题,消除了复制算法中内存减半的高额代价。
  • 缺点:移动对象的开销较大。
标记整理算法
标记整理算法

3.3.4 分代收集算法

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

新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。

老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。


为什么虚拟机要分新生代和老年代?

当前虚拟机的垃圾收集都采用分代收集算法,这种算法需要根据对象存活周期的不同将内存分为几块。所以JVM将堆分为新生代和老年代,这样就可以根据各个年代的特点选择合适的垃圾收集算法,更好地管理内存、提高垃圾回收效率。


3.4 垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

3.4.1 Serial收集器

这个收集器是一个单线程收集器,它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。

简单而高效(与其他收集器的单线程相比)

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

Serial 收集器
Serial 收集器

3.4.2 ParNew收集器

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

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

老年代GC采用的是串行方式收集。

ParNew 收集器
ParNew 收集器

3.4.3 Parallel Scavenge收集器

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。

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

可以设置老年代的收集方式:

1
2
3
4
-XX:+UseParallelGC
使用 Parallel 收集器+ 老年代串行
-XX:+UseParallelOldGC
使用 Parallel 收集器+ 老年代并行

这是 JDK1.8 默认收集器

Parallel Old收集器运行示意图
Parallel Old收集器运行示意图

3.4.4 Serial Old收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。

Serial 收集器
Serial 收集器

它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

3.4.5 Parallel Old收集器

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

在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

Parallel Old收集器运行示意图
Parallel Old收集器运行示意图

3.4.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
CMS 收集器
CMS 收集器

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;
  • 无法处理浮动垃圾;
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

3.4.7 G1收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

它具备以下特点:

并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。

分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。

空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。

可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

G1 收集器
G1 收集器

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

3.4.8 ZGC收集器

与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。

在 ZGC 中出现 Stop The World 的情况会更少!

3.5 Minor Gc 和 Full GC 有什么不同呢?

Minor GC 也称为新生代垃圾回收,指的是清理新生代内存区域中不再被引用的对象。一般来说,新生代中存活时间较短的对象被回收的概率较高。Minor GC 通常比较快速,并且不会对整个堆空间产生影响

Full GC 则是对整个 Java 堆空间进行垃圾回收,包括新生代老年代。Full GC 一般比较耗时,会暂停整个应用程序的运行,对性能有较大的影响。

需要注意的是,Full GC 的触发条件通常比较严格,一般是当新生代没有足够空间存放新对象,或老年代空间不足,或永久代空间不足等情况下才会触发。而 Minor GC 触发的条件相对较为宽松,一般是当新生代中的对象数量超过了新生代的容量限制时就会触发

因此,Minor GC 和 Full GC 的主要区别在于执行的范围和影响程度。Minor GC 通常只涉及新生代空间,对整个应用程序的影响较小;而 Full GC 涉及整个堆空间,会导致整个应用程序暂停,对性能有较大的影响。

3.6 三色标记算法

三色标记算法是一种JVM垃圾标记的算法,它可以减少JVM在GC过程中的STW时长,是CMS、G1等垃圾收集器中主要使用的标记算法。

3.6.1 为什么需要三色标记算法?

三色标记算法之前JVM主要使用的是根可达性算法,但是根可达性算法存在一些问题:

  • 误标记的问题。在多线程环境下,如果一个线程正在遍历对象图,而此时另一个线程正在修改图,就会出现遍历结果不可靠的问题。
  • STW时间长。根可达性算法的整个过程都需要STW,但是这会导致GC过程中应用程序的卡顿时间也很长,从而影响系统的整体性能。

3.6.2 三色标记法中三色对应的状态

三色标记法将对象的状态通过:白色、灰色、黑色三种颜色表示。

  • 白色:表示对象尚未被GC访问过。如果全部标记完成后,对象仍为白色,那表示这个对象就是需要回收的对象;
  • 灰色:表示对象已经被GC访问过,但至少存在一个引用没有被扫描过。属于中间状态;
  • 黑色:表示对象已经被GC访问过,且对象的所有引用都被扫描过

3.6.3 三色标记法标记的过程

三色标记法
三色标记法
  1. 初始时,所有对象都是白色,都放到白色集合中;
  2. 将GC Roots直接引用的对象都设置为灰色,并且放到灰色集合中;
  3. 遍历所有灰色对象:
    • 如果灰色的对象没有其他引用,就将该对象标记为黑色,并将该对象从灰色集合中移到黑色集合中;
    • 如果灰色对象有其他引用的对象,就将引用的对象标记为灰色,并将引用的对象移到灰色集合中,然后将该对象标记为黑色,并移动到黑色集合中。
  4. 然后继续重复步骤3,直到所有标记结束,在白色集合中的对象就是需要回收的对象。

3.6.4 三色标记法的缺陷

  1. 多标,多标会产生浮动垃圾。在并发标记的过程中,如果由于方法运行结束导致部分局部变量(GC Roots)被销毁,这个GC Roots引用的对象之前又被标记为非垃圾对象,那么本轮GC就不会回收这个对象。而这些本应该被回收,但是没有回收的内存,就被称为浮动垃圾。

    解决方案:浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮GC才能被清除。

  2. 漏标,漏标会导致被引用的对象被当成垃圾误删除。

    解决方案

    1. 增量更新:当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束后,再以这些记录过的引用关系中的黑色对象为Root,重新扫描一次。可以简单理解为:黑色对象一旦新插入指向白色对象的引用后,就变回灰色对象了,然后又继续遍历扫描
    2. 原始快照:当灰色对象要删除指向白色对象的引用关系时,就将要删除的引用记录下来,在并发扫描结束后,再以这些记录过的灰色对象为Root,重新扫描一次,这样就能扫描到白色对象,将白色对象直接标记为黑色。

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

原始快照比增量更新效率会更高,但是缺点是会造成更多的浮动垃圾。

因为原始快照不需要在重新标记阶段再次深度扫描被删除的引用对象,而CMS对增量引用的跟对象会做深度扫描,重新深度扫描对象的话G1的代价会比CMS代价更高,所以G1选择原始快照,不深度扫描对象,只是简单标记,等下一轮GC再深度扫描。

4、JVM调优

4.1 JVM调优的参数可以在哪里设置?

  1. war包部署在Tomact中设置,可以修改TOMCAT_HOME/bin/catalina.sh文件
  2. jar包部署在启动参数设置,通过命令java -Xms512m -Xmx1024m -jar xxxx.jar

4.2 常用的调优参数有哪些?

4.2.1 调整最大堆内存和最小堆内存

-Xmx:指定 java 堆最大值(默认值是物理内存的 1/4(<1GB))

-Xms:初始 java 堆最小值(默认值是物理内存的 1/64(<1GB))

开发过程中,通常会将 -Xms 与 -Xmx 两个参数配置成相同的值,其目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源

4.2.2 调整虚拟机栈的设置

每个线程默认会开启1M的内存,用于存放栈帧、参数、局部变量等,一般256k就够用了。

-Xss:对每个线程stack大小的调整,比如-Xss128k

4.2.3 调整新生代和老年代的比值

-XX:NewRatio — 新生代(eden+2*Survivor)和老年代(不包含永久区)的比值

例如:-XX:NewRatio=4,表示新生代:老年代=1:4,即新生代占整个堆的 1/5。在 Xms=Xmx 并且设置了 Xmn 的情况下,该参数不需要进行设置。

4.2.4 调整Survivor区和Eden区的比值

-XX:SurvivorRatio(幸存代)— 设置两个 Survivor 区和 Eden 的比值

例如:8,表示两个 Survivor:Eden=2:8,即一个 Survivor 占年轻代的 1/10

4.3.5 设置年轻代晋升老年代的阈值

-XX:MaxTenuringThreshold=thrshold 默认值是15,取值范围为[0, 15]。

4.3.6 设置垃圾回收器

-XX:+UseParallelGC

-XX:+UseParallelOldGC

4.3.6 设置年轻代和老年代的大小

-XX:NewSize — 设置年轻代大小

-XX:MaxNewSize — 设置年轻代最大值

-XX:OldSize — 设置老年代大小

4.3.7 设置元空间大小

-XX:MetaspaceSize — 设置元空间初始化大小,64位JVM默认为20.75M

-XX:MaxMetaspaceSize — 设置元空间最大大小,逻辑限制为内存上限

一般建议在JVM中将这两个设置为一样的值,并且设置的要比默认初始值更大,对于8G内存的机器来说,一般会设置为256M。

4.3.8 一台8G内存的机器JVM的参数怎么设置?

java -Xmx3550m -Xms3550m -Xss128k -XX:NewRatio=4 -XX:SurvivorR atio=4 -XX:MaxPermSize=16m -XX:MaxTenuringThreshold=0

4.3 JVM常用调优工具?

  • 命令工具
    • jps:进程状态信息
    • jstack:查看java进程内线程的堆栈信息
    • jmap:查看堆转内存快照、内存使用情况
    • jhat:堆转储快照分析工具
    • jstat:JVM统计监测工具
  • 可视化工具:
    • jconsole:用于对jvm的内存、线程、类的监控
    • VisualVM:能够监控线程、内存情况

4.4 Java内存泄漏的排查思路?

  1. 获取堆内存的dump文件;
    • 使用jmap命令获取;
    • 使用vm参数获取;
  2. 通过工具(比如VisualVM)分析dump文件;
  3. 通过查看堆信息,可以大概定位具体的代码行;
  4. 找到对应的代码行进行修复。

4.5 CPU飙高排查思路?

  1. 使用top命令查看cpu的占用情况;
  2. 找到哪个进程占用的cpu较高;
  3. 使用jps命令查看进程中的线程信息;
  4. 使用jstack命令查看具体的进程中哪些线程出现了问题,然后定位问题。

本站由 Cccccpg 使用 Stellar 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。