前言
讲一下大概的内容(类加载-JVM内存模型-对象的创建-垃圾回收-JVM调优(入门))
最近抽了时间学了一直都很想学的 JVM,之前也学过一点,也发布过一些零散的文章,但这篇文章会更加全面,学完这篇文章就足以应对有关 JVM 的面试(如果遇到没有的题,请写在评论区)。
本文主要从以下六个方面来讲述:
- 类加载之前
- 类加载。
- JVM 内存模型。
- 对象的创建。
- 垃圾回收。
- JVM 调优(入门)。
本文主要按照对象的生命周期:字节码文件的编译到被加载进 JVM,再到对象的创建,最后到各种垃圾收集器对对象的回收流程,最后的最后再补上 JVM 调优。
类加载之前
在类加载之前,我们必然需要写好一段代码,并将这段代码编译好,得到一个 class 文件,这个文件就是我们所说的字节码文件,一说到字节码就会提到静态常量池,还有运行时常量池是什么?
这一部分我们就来普及这些概念:
- 字节码
- 常量池( 静态常量池 & 运行时常量池、字面量 & 符号引用)
字节码
首先,我们需要一个 java 文件,像这样。
然后,编译它,使用javac User.java
或者 IDEA 编辑器编译都可以。
然后得到一个 User.class,这就是字节码文件。
字节码的含义
当我们使用文本编辑工具打开字节码文件,会看到很多很多根本看不懂的十六进制数(除了最前面的cafe babe
,这个数被称为魔数,用来标识这个文件是 java 的字节码文件)。
如果想要一个字一个字地去解读这份字节码文件,可以翻看我之前的文章,但这个不是特别重要,毕竟我们不需要敲字节码来开发,这些都是给虚拟机看的。
我们可以使用javap -v User.class
命令来把字节码编译成更加可读的指令码。
javap -v User.class > User.txt
这个命令可以把指令码文件输出到 User.txt 中,于是我们可以得到这样一个文件:
好像变得更容易读懂了,又好像没有那么容易读懂。
像这里的 Constant pool 就是我们常说的静态常量池,后续会进行解读,而 Code 里面的就是指令码,想要读懂指令码就需要参照 Java指令码表。
常量池
我们可以在字节码中看到静态常量池,它和运行时常量池有什么关系?还有字符串常量池、基础类型常量池又是什么?
静态常量池
静态常量池说的就是字节码文件中的 Constant pool,这里存放的都是字面量和符号引用。
字面量
字面量就是那种我们写在代码中的字符串或者基本数据类型的值,比如下图的"11",1
。
但如果把这个代码编译成字节码,我们只能看到字符串的字面量出现在 Constant pool 中,而不会出现在 Code 中。
符号引用
符号引用的主要表现形式是字符串,主要包括以下三种类型:
- 类和接口的全限定名(比如上图的#4、#5)
- 字段的名称和描述符(比如#2、#3)
- 方法的名称和描述符(比如#1)
因为字节码文件是静态的,还没有被加载进内存,所以无法使用准确的地址来表示要引用那个类/接口、字段、方法,于是就使用了一段(能够准确地表达想要引用什么的)符号来表示要引用那个类、接口、字段、方法。
在经过类加载器加载进入 JVM 时,类加载器就会把这个符号引用转换为一个可以被引用的地址,这个过程就是把符号引用转换成直接引用。
当然,直接引用也有三种分类:
- 目标的地址
- 相对的偏移量
- 一个可以间接定位的句柄
按我的理解:
- 类的静态成员属性或者静态方法的引用转换成目标的地址的符号引用
- 对象的成员属性的引用转换成相对的偏移量的符号引用
- 对象或者接口(不能明确调用那个类的对象)的方法的引用转换成句柄的符号引用
总之,直接引用就是内存中实打实的可以被调用的地址。
运行时常量池
当静态常量池被加载进 JVM 后,就变成了运行时常量池,而运行时常量池存放着类信息,属性信息,方法信息以及字符串常量池,当然还有根据不同的对象类型被分为 Integer 常量池、Long 常量池等基础数据类型的包装类常量池。
这一小节,统统给大家讲明白。
字符串常量池
字符串常量池是一个比较复杂的常量池。
为什么会有字符串常量池?
字符串不是基础数据类型,它是一个对象,但它的使用频率却和基础数据类型差不多,高频率地创建对象会极大地影响程序的性能。
JVM 为了提高程序的性能和减少内存的开销,于是给字符串的创建增加了一层缓存,以此来减少相同字符串的创建。
字符串常量池的位置
首先,我们要了解一下字符串常量池的存放位置。
先放一张 JVM 内存模型的图片,看不懂没关系,可以看到后面的JVM内存模型再回来看。
字符串主要涉及的内存区域是堆和方法区。
在 JDK1.6 时,字符串常量池是在运行时常量池里的,而运行时常量池是放在方法区里的永久代中,除非手动把字符串加入到方法区(这个操作后面会说),否则只有在静态常量池中的字符串才会被加载到字符串常量池。
所以 JDK1.6 的字符串分布大概是这样的。
从 JDK1.7 开始,JVM 就把字符串常量池从永久代中转移到了堆,运行时常量池还在永久代中,永久代还在方法区中。
所以 JDK1.7 之后的字符串分布大概是这样的。
到了 JDK1.8,就没有永久代了,字符串常量池在堆中,运行时常量池就是方法区内了。
所以我们要探讨字符串的设计原理需要分为 JDK1.7 之前和之后两种情况来讨论。
三种字符串操作
然后,讲一下字符串的一些操作,这样我们才能知道什么时候字符串会在堆里创建,什么时候在字符串常量池中创建。
- 直接赋值一个字符串
String s="abc";
通过这种方式创建的字符串都是在字符串常量池里面的,因为 JVM 在加载这个字面量“abc”的时候,会先去字符串常量池中通过 equals(key) 的方式找一下有没有相同的字符串,如果有就返回,没有就在字符串常量池中创建一个。
- new String()
String s=new String("aaa");
通过这种方式创建的字符串都是在堆里的,这也很好记,通过 new 创建的对象都是在堆里的,不会再去字符串常量池中判断。
但是,这个操作会同时创建两个字符串,一个在字符串常量池中,是在加载字节码时加载字面量"aaa"
创建的,另一个是直接 new String() 操作时创建在堆里的。
- String.intern()方法
String s1 = new String("bbb");
String s2 = s1.intern();
System.out.println(s1==s2); //false
String.intern()方法是一个 native 方法,想看它的代码只能去下载 JVM 的源码了。
(这个方法逻辑不同版本不一样,详情往下看)
看起来这个逻辑和直接赋值一个字符串差不多,但这个方法的调用对象在或者不在字符串常量池里,就会有不同的情况。
回到上面的代码String s2 = s1.intern();
,这里的 s1 是创建在堆上的,不在字符串常量池里的,在字符串常量池里的字符串是在字节码加载到 JVM 时,JVM 根据字面量"bbb"
创建的。
这里以 JDK1.8 为标准画了一个图, s2 指向的是字符串常量池内的字符串"bbb"。
所以 s1==s2 是 false。
那不同版本的 intern 方法差别有多大?
JDK1.7 之前,在调用 intern 方法时,JVM 会通过 equals(key) 的方式在字符串常量池中去找一下这个字符串,如果有相同的就返回,没有就新建一个。
JDK1.7 之后,在调用 intern 方法时,JVM 会通过 equals(key) 的方式在字符串常量池中去找一下这个字符串,如果有相同的就返回,没有就把调用这个方法的对象加入字符串常量池。
字符串常量池从方法区转移到了堆和 intern 方法的调整会带来多大的影响呢?让我们看一下下面这段代码。
String s1=new String("aa")+new String("bb");
String s2=s1.intern();
System.out.println(s1==s2);
/*
在 JDK1.7 之前,输出的结果是 false,总共创建了 6 个字符串。
在 JDK1.7 之后,输出的结果是 true,总共创建了 5 个字符串。
*/
你知道为什么会这样吗?
说不出来也没关系,我来给你详细地讲述一下(说出来这一段就不用看了)。
字符串常量池实际上是一个类似 Map 的数据结构,想要从字符串常量池中查找字符串是需要通过 equals(key) 的方式去查找的,所以实际上的字符串常量池不是包含着字符串,而是把字符串的引用存储在一个 map 里,如下图。
接下来,我就按照 JDK1.7 之前和之后两种情况分别画图演示一下上面三行代码的运行过程。
JDK1.7 之前
- 第一行代码会在加载字节码的时候根据字面量在字符串常量池(在方法区里)创建两个字符串
"aa","bb"
,紧接着有因为 new String() 在堆里创建了两个字符串"aa","bb"
,后来因为两个字符串相加又在堆里创建了一个字符串"aabb"
。 "aabb"
调用 intern 方法,因为这个字符串不在堆里,字符串常量池里是找不到这个字符串的,所以又会在字符串常量池中再创建一个字符串"aabb"
。
所以,s1 指向的是堆里的"aabb"
,而 s2 指向的是方法区里被字符串常量池引用的"aabb"
,这不是同一个字符串,s1==s2 是 false。
JDK1.7 之后
- 和 JDK1.7 之前一样,只不过字符串都是在堆里的。
"aabb"
调用 intern 方法,但在字符串常量池中找不到,于是就把这个字符串加入到字符串常量池中了,再把这个字符串赋值给 s2。
所以,s1 和 s2 指向的都是同一个字符串,s1==s2 是 true。
关于字符串的练习题
最后用几个笔试可能会经常遇到的练习题来帮助大家更了解字符串的三种操作。
习题1:
String s3 = "xiaobai";
String s4 = "xiaobai";
String s5 = "xiao"+"bai";
System.out.println(s3 == s4); // true
System.out.println(s4 == s5); // true
这道题很简单,s3 = s4 是因为他们的字面量是一样的,所以必然是同一个字符串,而 s5 则是在编译成字节码的时候会做出优化,s5 的字面量也是"xiaobai"
。
习题2:
String s6 = "xiaobai";
String s7 = new String("xiaobai");
String s8 = "xiao"+new String("bai");
System.out.println(s6 == s7); // false
System.out.println(s6 == s8); // false
System.out.println(s7 == s8); // false
String s12 = "xiaobai";
String s13 = "bai";
String s14 = "xiao"+s13;
System.out.println(s12 == s14); //false
s6 是通过字面量生成的,所以位于字符串常量池的;s7 是通过 new String 生成的,所以是一个新的字符串;而 s8 实际上也是 new String 生成的,因为 Java 的编译器是没办法优化"xiao"+new String("bai")
(字面量+对象/对象引用)的情况,只能优化"xiao"+"bai"
(字面量+字面量)这种情况。
可以看到字面量+对象/对象引用的情况,是会调用 StringBuilder 来拼接字符串的,最后调用 StringBuilder.toString() 返回一个新的字符串。
而字面量+字面量的情况是包含字符串字面量+基础类型字面的情况的,什么意思呢?看一下下面的代码就懂了。
String s9 = "xiaobai1";
String s10 = "xiaobai"+1;
String s11 = "xiaobai"+'1';
System.out.println(s9 == s10); // true
System.out.println(s9 == s11); // true
在编译之后,s9、s10、s11 的字面量都会变成"xiaobai1"
。
总结
最后放一个字符串常量池在 JDK1.7前后 的区别的表格。
JDK1.7之前 | JDK1.7之后 | |
---|---|---|
字符串常量池的位置 | 方法区 | 堆 |
直接赋值 | 在字符串常量池中创建一个字符串 | 在字符串常量池中创建一个字符串 |
new String() | 在堆上创建一个字符串 | 在堆上创建一个字符串 |
intern() | 先在字符串常量池中找有没有相同的字符串,有就返回,没有就重新创建一个 | 先在字符串常量池找有没有相同的字符串,找到就返回,找不到就把调用该方法的字符串加入字符串常量池并返回 |
基础数据类型常量池
基础数据类型常量池主要是针对八种基础数据类型的包装类做的缓存,而且这些缓存都是直接使用 Java 代码实现的,和字符串常量池的实现是不一样的。
基础数据类型 | 包装类 |
---|---|
byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
char | Char |
boolean | Boolean |
(我除了 int 的包装类,其他就把第一个字母大写了,如果有写错了,请以官方的为标准。😘)
这里以 Integer 为例子,去看一下 Integer 是如何做缓存的,其他包装类也是同理哈。
先来敲一段关于 Integer 的代码,你可以先看看,并且把你认为的打印结果写下来。
public static void main(String[] args) {
Integer i1 = 1;
Integer i2 = 1;
Integer i3 = 127;
Integer i4 = 127;
Integer i5 = 128;
Integer i6 = 128;
System.out.println("i1 = i2 :"+ (i1 == i2));
System.out.println("i3 = i4 :"+ (i3 == i4));
System.out.println("i5 = i6 :"+ (i5 == i6));
}
写下来了,我们就开始看看 Integer 是何如去做缓存的。
我们先来看看指令码是怎么样的。
可以看到,基本上都是通过调用java/lang/Integer.valueOf (I)
方法把 int 类型的数据转换成 Integer 对象,那么我们可能会这个方法中找到答案。
Integer.valueOf(int)方法:
可以看到这里是否处于最高值或者最低值之间,如果是就是直接返回 cache 中的对象,这就是缓存,否则直接 new Integer。
Integer 的缓存范围是 [-128,127],最低值是不能改变的了,最高值可以通过设置 JVM 的启动参数改变。
所以打印的结果是:
i1 = i2 :true
i3 = i4 :true
i5 = i6 :false
例如我们想把 Integer 的缓存范围修改为[-128,1000],就可以使用这行命令: -Djava.lang.Integer.IntegerCache.high=1000
如果加上了这行命令-Djava.lang.Integer.IntegerCache.high=1000
,打印的结果是:
i1 = i2 :true
i3 = i4 :true
i5 = i6 :true
到这里,我们就知道了基础类型常量池就是在包装类中维护了一个数组,这里数组里面就存放着某个范围内的包装类,如果有相同的就拿出来用,没有就重新 new 一个。
类加载
前面带大家从字节码开始一步步地深入到字符串常量池的原理,希望一下子钻入到一个细节里不会把各位劝退了,接下来我们就正式地开始讲类加载相关的内容了。
类加载做的事情就是把一个编译好的字节码文件加载到 JVM 内存中,最后转变为运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应 class 实例的引用等信息。
类加载的全过程
类加载的过程主要包括:
- 判断是否要加载一个类。
- 前戏:判断该由谁来加载类。
- 调用 loadclass 方法来加载类。
判断是否需要加载一个类
加载类的情况参考后面的类初始化的时机。
类加载的前戏
当需要加载一个类时,JVM 会调用 sun.misc.Launcher.getLauncher() 方法,获取一个 Launcher 对象。
接着再调用 launcher.getClassLoader() 方法,获取到一个类加载器(这个类加载器其实就是 AppClassLoade,各种类加载器后面会讲)。
最后,调用 classLoader.loadClass(类的全限定名) 来把一个 class 文件加载进 JVM。
而 loadClass 方法会根据双亲委派机制,把加载类的任务委派给一个合适的类加载器。
loadclass 方法的几个过程
类的生命周期主要分为以下的七个阶段。
这七个阶段的顺序基本上是固定的,在《深入理解java虚拟机》第三版中,笔者强调了这些阶段可能会出现相互交叉地混合进行,这七个阶段的顺序也指的是开始的顺序。
使用阶段和卸载阶段不用多说了,前者是这个类被使用,后者是这个类被清除。
而在 loadclass 方法中就会包含加载、验证、准备、解析和初始化这五个阶段
加载
一开始,我以为加载阶段是会被某些特定的条件(比如new、调用类的静态方法等)触发的,但这个阶段是由 JVM 自由控制的,有触发条件的是初始化阶段(这个后面再讲),也就是说我们并不知道一个类什么时候会被加载进 JVM,可能会有一些判断机制。
但我没看过 JVM 的底层源码嘛,只能通过一些测试来验证,经过测试,我发现这个阶段的触发条件和初始化阶段的一模一样。
在加载阶段,JVM 会做三件事情:
-
JVM 会通过类的全限定名来获取到这个类的二进制字节流,没有限制获取字节流的方式,可以通过压缩包、网络传输和动态生成获取类的二进制字节流。
-
把二进制字节流所包含的静态数据结构都转换为运行时数据结构(运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应class实例的引用等)。
-
在堆中生成一个 java.lang.Class 对象,作为这个类在方法区的各种数据的访问入口。
验证
验证阶段的目的是保证我们加载的类的二进制字节流所包含的信息符合《Java虚拟机规范》,保证加载的这个类不会危害 JVM 的安全。
主要做的验证如下:
- 文件格式验证。验证字节流是否符合 class 文件的规范(魔数是否以
cafe babe
开头,版本号是否正确等等)。 - 元数据验证。验证这个类是否有父类(除了 java.lang.Object )、验证父类是否不可以被继承、验证父类是不是抽象类等。
- 字节码验证。验证由 java 代码中的方法编译而来的 class 文件中的 code 属性中的指令码是否正确。(在 JDK1.6 之后,Java 的设计团队就尽可能地把这部分验证优化到了编译器中。)
- 符号引用验证。这个验证会在解析阶段中发生,主要做的事情就是验证能否根据符号引用描述的全限定名找到对应的类,符号引用描述的类、方法、字段能否被当前的类访问。
准备
在准备阶段,会给类的静态成员变量分配内存并赋予一个初始值。
比方说,有一个类定义了一个静态成员变量。
public static int i = 11;
那这个静态成员变量i
在准备阶段不会被直接赋值为 11,而是被赋值为初始值 0。
我把八大基础数据类型和对象的初始值都绘成一个表格放在下面:
类型 | 初始值 |
---|---|
byte | (byte)0 |
short | (short)0 |
int | 0 |
long | 0L |
float | 0.0F |
double | 0.0D |
char | ‘\u0000’ |
boolean | false |
对象 | null |
这个有什么用呢?
当我们定义了静态成员变量,没有赋值,就是这个初始值啦。
解析
解析阶段就是把符号引用解析为直接引用,前面有比较详细的定义了,这里不赘诉了。
JVM 会在解析阶段把能够确定的类、方法、成员属性等(大多数情况是调用类的静态方法、类的静态成员变量)都解析成固定的一个指针,这被叫做静态链接。
而只有在运行时才能确定的类、方法、成员属性等(调用抽象类、接口和类引用的对象的非静态方法和非静态成员变量)都会在运行到时才会被解析成一个指针,这被叫做动态链接。
若是静态链接,就只会有一次解析阶段,解析完成后会使用一个标识符来说明这个方法不需要再被解析了;若是动态链接,则会在每次被调用时就会触发一次解析阶段。所以解析阶段是有可能在初始化阶段后出现的。
初始化
初始化阶段是类加载的最后一个阶段,在这个阶段中,JVM 才会把主导权交给应用程序(我们编写的代码程序),让应用程序去执行<cinit>()
方法。
在编译器编译时,编译器会把静态成员变量的赋值操作和静态代码块都融合到<cinit>()
方法中,融合的顺序是按照静态成员变量和静态代码块的先后顺序来融合的。
可以看到在<cinit>()
方法中,先打印 11,再生成 “s”,再打印 22。
不过静态代码块可以在静态变量之前给静态变量赋值,但是不能访问。
类初始化的时机
终于到了类的初始化时机,在《深入理解Java虚拟机》中说的是类的加载时机是不确定的,能确定的只有类的初始化时机,但我觉得类初始化的时机可以等同于类的加载时机了。
这里举例一下常见的类初始化时机:
-
遇到 new、getstatic、putstatic 或者 invokestatic 这四条指令码时。具体来说就是使用关键字 new 创建对象时、读取或者设置一个类的静态成员变量时、调用类的静态方法时。
-
使用反射访问类时。
-
当初始化某个类,发现这个类的父类没有被初始化时。
-
运行 main 方法时,会加载 main 方法所在的类。
End,整一套的类加载过程都讲完了,讲的比较简单,更加详细的内容大家可以去看看《深入理解Java虚拟机》哈哈。
各种类加载器
类加载器主要有四种:
-
引导类加载器。
由 C++ 实现,负责加载支撑 JVM 运行的核心类库,这个类库位于 jre/lib。
下面都是引导类加载器加载的类的 jar。
URL[] urls = Launcher.getBootstrapClassPath().getURLs();
for (int i = 0; i < urls.length; i++) {
System.out.println(urls[i]);
}
/**
* file:/C:/Program%20Files/Java/jdk1.8.0_271/jre/lib/resources.jar
* file:/C:/Program%20Files/Java/jdk1.8.0_271/jre/lib/rt.jar
* file:/C:/Program%20Files/Java/jdk1.8.0_271/jre/lib/sunrsasign.jar
* file:/C:/Program%20Files/Java/jdk1.8.0_271/jre/lib/jsse.jar
* file:/C:/Program%20Files/Java/jdk1.8.0_271/jre/lib/jce.jar
* file:/C:/Program%20Files/Java/jdk1.8.0_271/jre/lib/charsets.jar
* file:/C:/Program%20Files/Java/jdk1.8.0_271/jre/lib/jfr.jar
* file:/C:/Program%20Files/Java/jdk1.8.0_271/jre/classes
*/
-
扩展类加载器。
由 Java 实现,也是负责加载支撑 JVM 运行的扩展类库,这个类库位于 jre/lib/ext。下面都是扩展类加载器加载的类的路径。
System.out.println(System.getProperty("java.ext.dirs")); //C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext;C:\WINDOWS\Sun\Java\lib\ext
至于为啥同样是加载重要类还用两种类加载器,我就不清楚了。
-
应用程序类加载器。
由 Java 实现,负责加载 ClassPath 路径下的类,也就是我们写的程序的类。
System.out.println(System.getProperty("java.class.path")); /** * C:\Program Files\Java\jdk1.8.0_271\jre\lib\charsets.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\deploy.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\access-bridge-64.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\cldrdata.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\dnsns.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\jaccess.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\jfxrt.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\localedata.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\nashorn.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\sunec.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\sunjce_provider.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\sunmscapi.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\sunpkcs11.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\ext\zipfs.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\javaws.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\jce.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\jfr.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\jfxswt.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\jsse.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\management-agent.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\plugin.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\resources.jar; * C:\Program Files\Java\jdk1.8.0_271\jre\lib\rt.jar; * D:\IdeaProjects\achieve\target\classes; * D:\Program Files\apache-maven\repository-3.3.3\cglib\cglib\3.3.0\cglib-3.3.0.jar; * D:\Program Files\apache-maven\repository-3.3.3\org\ow2\asm\asm\7.1\asm-7.1.jar; * D:\Program Files\apache-maven\repository-3.3.3\com\baomidou\mybatis-plus\3.1.0\mybatis-plus-3.1.0.jar; * D:\Program Files\apache-maven\repository-3.3.3\com\baomidou\mybatis-plus-extension\3.1.0\mybatis-plus-extension-3.1.0.jar; * D:\Program Files\apache-maven\repository-3.3.3\com\baomidou\mybatis-plus-core\3.1.0\mybatis-plus-core-3.1.0.jar; * D:\Program Files\apache-maven\repository-3.3.3\com\baomidou\mybatis-plus-annotation\3.1.0\mybatis-plus-annotation-3.1.0.jar; * D:\Program Files\apache-maven\repository-3.3.3\org\mybatis\mybatis\3.5.0\mybatis-3.5.0.jar; * D:\Program Files\apache-maven\repository-3.3.3\com\github\jsqlparser\jsqlparser\1.4\jsqlparser-1.4.jar; * D:\Program Files\apache-maven\repository-3.3.3\org\mybatis\mybatis-spring\2.0.0\mybatis-spring-2.0.0.jar; * D:\Program Files\apache-maven\repository-3.3.3\mysql\mysql-connector-java\5.1.25\mysql-connector-java-5.1.25.jar; * D:\Program Files\apache-maven\repository-3.3.3\com\zaxxer\HikariCP\2.5.1\HikariCP-2.5.1.jar; * D:\Program Files\apache-maven\repository-3.3.3\org\slf4j\slf4j-api\1.7.21\slf4j-api-1.7.21.jar; * D:\Program Files\JetBrains\IntelliJ IDEA 2019.3.5\lib\idea_rt.jar */
可以看到一堆 D 盘上的文件,要么是我写的代码,要么是第三方依赖包,而
D:\IdeaProjects\achieve\target\classes;
前面的包因为有双亲委派机制的存在,都是不会被加载的。 -
自定义类加载器。
自定义加载器就是需要我们手动去实现的了,可以加载我们指定的任意路径的类。
我们可以通过 getClassLoader() 获取到这个类是由那个类加载器加载的,甚至还能知道三种类加载器的父子关系,ExtClassLoader.parent 为什么为空?接着往下看就明白了。
System.out.println(Test01.class.getClassLoader());
//sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(Test01.class.getClassLoader()); //sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(Test01.class.getClassLoader().getParent()); //sun.misc.Launcher$ExtClassLoader@266474c2
System.out.println(Test01.class.getClassLoader().getParent().getParent()); //null
那我们想知道一个类的类加载器是谁,就要明白这个类是在那个路径下的。
至于为什么要分成这么多个类加载器呢?
我想这大概是为了实现双亲委派机制,
双亲委派
通过 launcher.getClassLoader() 获得的类加载器是应用程序类加载器,所以 JVM 会使用应用程序类加载器去加载类,但由于双亲委派机制的存在,应用程序类加载器不能越级加载该由引导类加载器和扩展类加载器加载的类,扩展类加载器不能越级加载该由引导类加载器加载的类。
什么是双亲委派机制?
这三种类加载器都会有父子级别的关系。
应用程序类加载器的 parent 是扩展类加载器,扩展类加载器的 parent 是引导类加载器。
双亲委派机制就是,子类加载器在加载类时,如果自己没有加载过这个类,那自己就不会先加载,而是会先让父类加载器(parent)去加载,只有父类加载器加载不了这个类时,子类加载器才会去尝试加载类。
所以就算系统参数java.class.path
打印出来了 JDK 的相关路径,应用程序类加载器也是加载不了 JDK 的类的,JDK 的类都是会被扩展类加载器或者引导类加载器先一步加载。
为什么要设计双亲委派机制?
设计双亲委派机制主要有两点:
- 保证核心类库不被破坏。引导类加载器已经加载过了 String 类,那么应用程序类加载器就不能再加载 String 类了,当然啦除此之外 JVM 还会做其他的保护措施。
- 避免重复加载相同的类。大家用的都是同一个类,父类加载器加载了,子类加载器就没必要加载了,直接用就好了。
虽然每次在第一次加载类的时候,都可能要从应用程序类加载器走到扩展类加载器以及引导类加载器,但是 90% 以上的类都是我们写的应用程序的类,只要都被加载一遍,以后就不需要再去走父类加载器了。
双亲委派是怎么实现的?
JVM 直接调用的类加载器是应用程序类加载器AppClassLoader
,这个类是 Launcher 的一个静态内部类。
这里也可以看到扩展类加载器ExtClassLoader
,所以这程序类加载器和扩展类加载器都是 Java 实现的。
另外,ExtClassLoader
和AppClassLoader
的父子关系是可以在 Launcher 的初始化方法中看到的。
Launcher.AppClassLoader.getAppClassLoader(var1)
传入的这个 var1 是 ExtCLasLoader 的实例,而在 Launcher.AppClassLoader.getAppClassLoader 方法中,最后是把 ExtCLasLoader 的实例传给了 ClassLoader 的初始化方法。
在这里,就把 ExtCLasLoader 的实例赋值给了 AppClassLoader.parent,所以 AppclassLoader 的 paren 是 ExtCLasLoader,这里的父子关系指的不是继承关系,至于为啥 ExtCLasLoader 的父类加载器是 引导类加载器BootstapClassLoader
,需要我们看一下 ClassLoader.loadClass 方法才能知道。
JVM 在获取到 AppClassLoader 之后,就会去调用 AppClassLoader.loadClass 方法。
在 AppClassLoader.loadClass 方法中会做一些校验(我也不想管这些检验是做了啥),最后会调用 super.loadClass,这个方法就是 ClassLoader.loadClass。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 1. 判断是否加载过这个类
Class<?> c = findLoadedClass(name);
if (c == null) {
// 没有加载过这个类
long t0 = System.nanoTime();
try {
// 2. 判断是否有父亲
if (parent != null) {
// 2.1 让 parent 去加载类
c = parent.loadClass(name, false);
} else {
// 2.2 让 BootstapClassLoader 去加载器类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// 3. 自己去加载类
// 如果 parent 或者 BootstapClassLoader 都没加载到这个类,就自己尝试加载这个类
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
// 根据名字去加载类
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;
}
}
下面解释一下上面的代码:
-
判断是否加载过这个类。
调用 findLoadedClass 方法在当前类加载器中查找是否已经加载过这个类了。
如果已经加载过了,那这个方法就会直接返回一个 Class 对象,就不需要再去走后面的双亲委派机制了,直接返回当前这个 Class 对象。
如果没有加载过,就走到 2。
-
判断是否有父亲
2.1 如果 parent 不为 null,让 parent 去加载类。
这里当然只有 AppClassLoader.parent 不等于 null,所以 AppClassLoader 就会委派给 ExtClassLoader 去加载类。
2.2 如果 parent 为 null,让 BootstapClassLoader 去加载器类。
因为 ExtClassLoader.parent 是 null,所以在 ExtClassLoader 中不会再去调用 parent.loadClass,而是会调用 findBootstrapClassOrNull 方法。
findBootstrapClassOrNull 方法是一个本地方法,就是这个方法会去调用引导类加载器
BootstrapClassLoader
加载类。也就是这段代码写明了 ExtClassLoader 和 BootstrapClassLoader 的父子关系。
-
如果经历了上面的步骤都没有加载到类,就自己去加载类。
ExtClassLoader 是在调用 findBootstrapClassOrNull 方法后,依然没有加载到类就会自己去加载。
而 AppClassLoader 是在 ExtClassLoader 调用 findBootstrapClassOrNull 方法没有加载到类和 ExtClassLoader 自己去加载类也没有加载到的情况下,才会自己去加载类。
可以说看懂了 ClassLoader.loadClass 这个方法,就知道双亲委派的基本原理了。
为啥只看这个方法就可以了?
因为 AppClassLoader 和 ExtClassLoader 都继承了 URLClassLoader,而 URLClassLoader 继承了 ClassLoader,所以在 AppClassLoader 中调用 parent.loadClass 方法,最后也是会回到 ClassLoader.loadClass 方法中的。
打破双亲委派机制
什么情况下会打破双亲委派机制呢?
最常见的例子就是 Tomcat,在 Tomcat 中会加载很多应用,这么多应用就有可能会有相同全限定名的类,所以为了能够加载相同全限定名的类,Tomcat 自定义了类加载器,给每个应用一个独立的类加载器,从而打破了双亲委派机制。
想破环 Java 的核心类几乎是不可能的,除非整个类加载的过程都手动写一遍,不然逃不过 Java 的各种验证。
怎么打破双亲委派机制?
上面我们也讲了,只要看懂 ClassLoader.loadClass 方法就能明白双亲委派机制了,那我们只要能跳过这个方法,就能打破双亲委派机制了。
要自定义类加载器,我们就得继承 ClassLoader,接着实现 findClass 和 loadClass 方法,前者在 CalssLoader 中是一个空的方法,必须得实现,后者是实现我们绕过双亲委派的逻辑。
还有就是要实现一个从磁盘加载文件成字节流的方法loadByte
。
public class MyClassLoader extends ClassLoader {
// 自定义的路径
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
/**
*去 classPath 中加载类
*
*@param name
*@return
*@throws ClassNotFoundException
*
*/
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] data = loadByte(name);
//上面的只是类文件的数据,需要调用下面这个方法,才能把类文件数据转成一个类对象
return defineClass(name, data, 0, data.length);
} catch (IOException e) {
e.printStackTrace();
}
return super.findClass(name);
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
if (name.startsWith("test.")) {
//只有当以 test. 开头的类才会打破双亲委派机制
c = findClass(name);
} else {
//其他类都走父类的加载方法
c = this.getParent().loadClass(name);
}
long t1 = System.nanoTime();
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
if (resolve) {
resolveClass(c);
}
}
return c;
}
/**
* 从磁盘导入文件数据
*
* @param name 类名
* @return 类文件数据
* @throws IOException
*/
private byte[] loadByte(String name) throws IOException {
name = name.replaceAll("\.", "/");
//别忘记加.class
File classFile = new File(this.classPath + "/" + name + ".class");
FileInputStream fileInputStream = new FileInputStream(classFile);
byte[] data = new byte[fileInputStream.available()];
fileInputStream.read(data);
fileInputStream.close();
return data;
}
}
双亲委派并不能完全打破,Java 的核心类还是得由 ExtClassLoader 和 BootstrapClassLoader 来加载,所以这里只有包名以 test. 开头的才会打破双亲委派机制,优先被加载。
接着就是实验环节:
我们先写一个 Test 类。
编译过后把 class 文件放到 D:\project\src\main\java\test 下。
接着再修改一下 IDEA 中的 Test 类:
再运行下面的代码:
public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException {
MyClassLoader myClassLoader = new MyClassLoader("D:\project\src\main\java");
Class<?> clazz = myClassLoader.findClass("test.Test");
System.out.println(clazz.getName());
Method print = clazz.getMethod("print");
print.invoke(clazz.newInstance());
}
打印结果为:
test.Test
aaa
因为这里打印出来的是aaa
,所以加载的肯定是 D:\project\src\main\java\test 下的类,双亲委派已经被打破了。
JVM 内存模型
C/C++ 的开发者在管理内存这个领域上,他们虽然拥有管理对象生死的权力,但也有事事都要亲力亲为的义务,对象的创建和死亡都需要开发者手动处理。
而在 Java 中就没有这样的烦恼,对象的生死都交给了 JVM 来处理,我们在开发过程中对于对象的生死基本上都是没有感觉的,这减少了我们的学习成本,但如果出现了内存溢出或者泄露的问题,我们不懂 JVM 的内存模型,那我们想要解决问题就是难上加难。
JVM 的整体结构
我在网上看到很多都是把 JVM 内存模型等同于运行时数据区的,但我感觉这两者还是会有点区别,我是这样理解的:运行时数据区是存在于 JVM 中,而 JVM 内存模型是对运行时数据区的内存做的一个分析,所以运行时数据区是一个宏观的称呼,在我们讨论和 JVM 中其他系统的关系时应该说这个,而 JVM 内存模型是一个微观的称呼,在分析内存分布时说这个。
先来讲一下 JVM 的整体结构,也就是运行时数据区、类加载系统、字节码执行引擎之间的关系。
当运行java Math.class
命令时,JVM 就会调用类加载系统把这个字节码文件加载进运行时数据区,在执行指令码的时候就会调用字节码执行引擎去解析和执行指令码。
类加载系统整体和字节码执行引擎基本上都是由 C/C++ 实现的,所以说 Java 的尽头是 C/C++(开玩笑的,现在的 Java 也很🐂的)。
而运行时数据区是我们学习 JVM 调优必须学习的部分,想要让 JVM 不内存溢出、不频繁 GC,就必须得懂得 JVM 内存模型。
先来讲线程私有的:程序计数器、方法栈、本地方法栈。
程序计数器
程序计数器存放的是字节码执行引擎需要执行的字节码指令的行号,在切换线程和线程挂起时就不怕忘记下一行要执行的字节码指令了。
字节码执行引擎的主要工作就是获取程序计数器中的地址然后去执行字节码指令和执行完后修改程序计数器的地址,在字节码执行引擎执行字节码指令后程序计数器中存放的地址就是下一行要执行的字节码地址。
若当前执行的是 Java 方法,程序计数器中存放的就是字节码指令的地址;若当前执行的是本地方法,程序计数器中存放的就是空。
程序计数器也是唯一一个不会报内存溢出错误的内存区域。
方法栈
方法栈又可以被叫做虚拟机栈或者线程栈,是线程私有的,一个线程会对应一个方法栈。
而在线程运行的过程中,方法栈又会给运行的方法分配一块空间————栈帧,一个方法会对应一个栈帧。
public static void main(String[] args) {
Test test = new Test();
test.print();
}
例如上面这段代码,在运行 main 方法前,就会给当前线程分配一个方法栈,接着给 main 方法分配一个栈帧,当运行到test.print();
这行代码时,就会再给 print 方法分配一个栈帧,当执行完了print 方法,就会把分配给 print 方法的栈帧出栈,接着运行完了 main 方法也是同样。
这个线程的栈帧变化图如下:
栈帧
在执行一个方法时,线程会在方法栈内分配一个战帧,用于存储执行方法需要用到的内存空间:局部变量表、操作数栈、动态链接、方法出口等。
局部变量表存放的是编译器就可以知道的数据类型或者引用:
- 八种基本数据类型(boolean,byte,char,short,int,float,long,double)。
- 对象引用(reference 类型,不等同于一个对象,这里存放的是一个指向对象的地址,而对象则是存放在堆里的)。
- returnAddress 类型(指向一行字节码的地址,我从书本抄来的,目前不太理解)。
操作数栈则是在执行方法的字节码指令时需要用到的内存空间,它是一种栈结构的内存,符合数据先进后出的原则。
动态链接存放的是调用抽象类、接口和类引用的对象的非静态方法和非静态成员变量的具体地址。
还记得之前说的静态链接和动态链接吗?
静态链接是在加载类的时候就解析了的,而动态链接就是在具体执行到某个方法(确定了当前引用的对象具体是什么类型)时,才会去解析的,而解析的地址就是存放在栈帧的种名为“动态链接”的内存空间。
方法出口存放的是,当方法执行完后,要回到调用这个方法的下一行代码。
继续举例这段代码。
1 public static void main(String[] args) {
2 Test test = new Test();
3 test.print();
4 System.out.print();
5 }
在 test.print 方法的方法出口存放的就是指向第 4 行代码的字节码指令,当 print 方法执行完后,print 方法的方法出口的地址就是写入程序计数器中。
字节码指令的运行逻辑
想要知道部变量表、操作数栈、动态链接、方法出口这四块内存区域是怎么协作完成字节码指令的,就需要看一下 JVM 是怎么运行字节码指令的。
准备
首先,我们先写一段代码:
public class Test03 {
public int add(int a,int b){
return a+b;
}
public static void main(String[] args) {
Test03 test03 = new Test03();
int add = test03.add(2, 3);
System.out.println(add);
}
}
然后获取到这个类的 class 文件的反编译后的信息(主要看 add 和 main 方法):
javap -v Test03.class > Test03.txt
public int add(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: iload_1
1: iload_2
2: iadd
3: ireturn
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this Lorg/example/simulate/jvm/Test03;
0 4 1 a I
0 4 2 b I
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: new #2 // class org/example/simulate/jvm/Test03
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: iconst_2
10: iconst_3
11: invokevirtual #4 // Method add:(II)I
14: istore_2
15: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
18: iload_2
19: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
22: return
LineNumberTable:
line 13: 0
line 14: 8
line 15: 15
line 17: 22
LocalVariableTable:
Start Length Slot Name Signature
0 23 0 args [Ljava/lang/String;
8 15 1 test03 Lorg/example/simulate/jvm/Test03;
15 8 2 add I
}
还有从网上下载一份 JVM 指令手册:
我们想知道指令是干嘛的就查这个手册。
开始看指令
我们启动 main 方法:
我们这时就有了这样一个画面,JVM 给主线程分配了一个方法栈,又在方法栈内给 main 方法分配了一个栈帧。
(因为这里是会涉及堆的,所以就把堆也画出来了。)
看代码:
-
0: new #2 // class org/example/simulate/jvm/Test03
这个不用看指令了,这是创建一个 Test03 对象。
-
3: dup
这里可能是复制刚刚创建的对象的地址。
-
4: invokespecial #3 // Method "<init>":()V
调用 init 方法的静态链接,初始化 Test03 对象。
-
7: astore_1
把复制的 Test03 对象的引用类型存储到局部变量表中,这位引用对应的索引是 1。
-
8: aload_1
把局部变量表中的索引 1 引用的地址加载到操作数栈中。前面的指令码解释也没有说什么时候会出栈操作数栈中的数据,所以我猜测在 new 时会把对象的地址入栈,调用 init 方法后会出栈,dup 是不会出栈的,后面就不太仔细地关注这个了,只要它把对象或者数据压入栈,那栈就是空的。
-
9: iconst_2
&10: iconst_3
把两个 int 类型的常量压入操作数栈。
我们在代码中确实没有定义这两个 int 类型的引用,所以这里就直接压入操作数栈了,没有先放入局部变量表。
-
11: invokevirtual #4 // Method add:(II)I
这里写的应该是调用对象的实例方法吧。
出栈操作数栈中全部数据,调用 Test03 对象的 add 方法。
下面是 add 方法:
-
0: iload_1
&1: iload_2
看来入参都是被直接存入局部变量表的。
把这两个 int 数据出栈到操作数栈中。
-
2: iadd
执行加法:2 + 3 = 5。
-
3: ireturn
方法结束,通过方法出口返回到 main 方法中的第 14 行代码,并且返回操作数栈中的数据。
下面又回到 main 方法:
-
14: istore_2
-
15: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
获取 System 的 静态对象 out(PrintStream),入栈。
-
18: iload_2
14.19: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
调用 PrintStream.println 方法,打印输入。
-
22: return
main 方法结束。
end
从上面的图中我们可以看出方法栈和堆的关系,在方法栈中不会存储对象,只会存储堆中对象的引用。
本地方法栈
本地方法栈就是提供给带有native
关键字的方法使用的。
比如说,Object.getClass 方法就带了native
,这个方法是用 C/C++ 实现的,在这里看不到源码。
而前面所提到的方法栈就是提供给 Java 代码写的方法使用的。
为什么会有本地方法栈呢?
我们要知道在 Java 出现之前,就有了 C/C++,但是 C/C++ 有个比较麻烦的点就是需要手动管理内存,这时必然会出现一些工具来解决这个痛点。
Java语言的出现时间是 1995 年,那么在这之前可能就有一个类似 Java 的内存自动管理框架来提供给 C/C++ 的开发人员使用。
所以 Java 的前身很大几率是这些 C/C++ 内存自动管理框架,有些 C/C++ 的方法也不奇怪啦,但是这些方法要在哪里运行呢?于是就有了本地方法栈。
而具体调用本地方法的细节就是,当我们在 Java 代码中调用 Object.getClass 这个方法时,JVM 就会找到这个方法对应 C/C++ 方法,然后在本地方法栈上分配空间去执行方法。
接下来讲的就是所有线程公用的内存区域(堆和方法区)了。
方法区
先讲内容比较少的方法区。
在方法区中存放的是常量、静态变量和类元信息。
再来看看 Test03.class 文件的反编译:
Constant pool 就是我们常说的常量池,这里的常量除了字符串常量都会被加载方法区,作为一个常量存储在方法区中。
而类元信息就是在上图中所看到的所有方法的信息和字节码指令。
最后,类静态变量就是我们写在类中的静态成员变量,如下:
public static String name="xiaobai";
但是呢,这里的对象是会创建在堆里的,方法区只会存储一个指向堆中对象的指针。
呈现为这样一种关系。
那么通过方法区与堆的关系+程序计数器与字节码执行引擎的关系+方法栈与堆的关系,我们就能得到如下这张内存模型关系图。
堆
终于到了最重要,也是内容最多的堆。
众所周知,在 JVM 中创建的对象都是存放在堆里的,而堆又被分为两块区域:年轻代和老年代,下面细讲一下堆的内存划分。
堆的内存划分
年轻代和老年代有个默认比例,前者占堆内存的 1/3,后者占堆内存的 2/3。
年轻代主要存储的是朝生夕死的对象(也可以说是年轻的对象),老年代存放的就是经过 n 轮 GC 后仍然存在的对象(也可以理解为存放常量、静态变量等等)。
在年轻代就会在细分为 Eden,server0,server1 三块区域,它们的比例是 8:1:1。
以上讲的年轻代与老年代的比例和年轻代中三块区域的比例都是可以调整的,后面会讲。
对象在堆中的流转
当我们刚启动 JVM 时,创建一个 User 对象,这个对象是会被放在年轻代的 Eden 区域的(大多数情况下)。
接着我们一直创建 User 对象,直到放满了 Eden 区域后。
这时,就会触发 Minor_GC(Young_GC),把 Eden 中的垃圾对象清除掉,剩余存活的对象放入 Survivor0 区域。
(Minor_GC 是只在年轻代进行的 GC)
怎么判定一个对象是垃圾对象后面再讲。
如果我们再一直都创建 User 对象,直到放满了 Eden 区域后。
这时,就再次会触发 Minor_GC(Young_GC),把 Eden 和 Survivor0 中的对象一起进行一次 GC,存活的对象就会被放到 Survivor1 区域中。
再接着创建 User 对象,就会把 Eden 和 Survivor1 中的对象一起进行一次 GC,然后把存活的对象放到 Survivor0 区域中。
在年轻代的对象流转就是一直地挪来挪去。
但如果在某次挪动的过程中,Survivor 的其中一个区域放不下存活的对象时,就会把一部分对象放入老年代。
还有,每个对象每被挪动一次,就会‘岁数’+1,当一个对象的‘岁数’达到了 15 时,这个对象就会被放入老年代。
一旦,老年代被放满了,就会触发 Full_GC,这个 GC 的作用范围是整个堆,它会找出整个堆中的垃圾对象并清除,当无法清除时,就会抛出 OOM。
jvisualvm
Java 有一个命令工具可以帮助我们更加清晰地看到对象在各个区域的流转——jvisualvm。
随便写一个一直往 list 中加对象的代码:
public class Test04 {
private int[] array = new int[1024*100];
public static void main(String[] args) throws InterruptedException {
List<Object> list = new ArrayList<>();
while (true){
list.add(new Test04());
Thread.sleep(1);
}
}
}
执行!
再执行jvisualvm
命令。
这个就是我们启动的线程。
像我这样没有 Visual GC 的就需要自己在“工具-插件”上找到并安装。
这里的年轻代是直接分成三块区域来显示的,老年代对应的是 old 区域。
这就是 Eden,Survivor0,Survivor1 三个区域之间相互挪来挪去的情况。
当 Eden + Survivor 中的对象都放不进 Old 中时,就会触发 OOM。
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at org.example.simulate.jvm.Test04.<init>(Test04.java:8)
at org.example.simulate.jvm.Test04.main(Test04.java:13)
STW
STW 是 stop the world 的简称,停止整个世界。
在 STW 阶段,JVM 中的所有用户线程都会被暂停,只有 JVM 的后台线程才会运行。
比如说,一个用户在访问我们的网站,当他点进一个链接进入一个详情页面时,如果触发了 STW,那么这个详情页面的数据就会等待 STW 结束后才会去查询并返回给用户,用户就会觉得很卡。
所以 JVM 的优化主要就是减少 STW。
Minor_GC 和 Full_GC 都是会触发 STW,只是 Minor_GC 的 STW 几乎可以忽略,而 Full_GC 的 STW 就比较长,但我们没法减少 STW 的时间,所以我们只能尽量地减少 Full_GC。
居然 STW 会影响用户体验,那能不能不 STW?
至少目前不行。
因为没有 STW,几乎没有办法去做 GC。
在 Serial 和 Parallel 垃圾收集器中,就通过 STW,不让用户线程生成对象,而是把垃圾对象清除掉先。
在 CMS 和它的衍生垃圾收集器中,就通过 STW,标记好了要非垃圾对象的 root,确定了一个要清除垃圾对象的范围。
调节这些内存的一些参数
能设置参数的区域有方法区、方法栈、堆。
方法区
先来讲一下方法区(元空间)和永久代:
在 JDK1.7 之后永久代就变成了方法区
-XX:MetaspaceSize = 21M
——设置方法区的初始大小,默认值是 21M。
-XX:MaxMetaspaceSize = 256M
——设置方法区的最大的大小,默认值是 -1,即不作限制。
在方法区的内存占用达到了一开始设置的初始大小(比如 21M)时,就会进行一次 Full_GC,如果释放的内存空间比较多,就会下调初始大小的值;如果释放的内存比较少且没有超过 MaxMetaspaceSize,就会上调初始大小的值。
因此建议把这两个参数设置的一样,比如-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
,降低因为加载类所导致的频繁 Full_GC。
-XX:PermSize = 20.75M
——设置永久代的初始大小。
-XX:MaxPermsize = 82M
——设置永久代最大的大小。
方法栈
-Xss128K
——设置每个线程对应方法栈占用的空间大小。
如果我们设置了方法栈的空间越小,那就能创建更多的方法栈、开启更多的线程,但在这个方法栈中分配的栈帧就越少、能调用的方法就越少。
一般情况下,128K 就够用了。
public class Test05 {
/**
* -Xss = 128K
*/
public int count = 0;
public static void main(String[] args) {
Test05 test05 = new Test05();
test05.recurse();
}
private void recurse(){
count++;
System.out.println("count:"+count);
recurse();
}
}
-Xss128K
这个设置都可以调用一千个方法了,这都不够用,就考虑一下是不是哪里卡 bug 了。
堆
堆就比较复杂了。
-Xms1024M
——设置堆内存最小值,默认值为物理内存的 1/64。
-Xmx2048M
——设置堆内存最大值,默认值为物理内存的 1/4。
-XX:NewSize=64M
——设置年轻代内存的最小值。
-XX:MaxNewSize=64M
——设置年轻代内存的最大值。
-XX:NewRatio=2
——设置老年代内存和年轻代内存的比例,这里是 old:new = 2:1。
-Xmn64M
——设置年轻代的初始大小和最大大小。
如果设置了 -XX:NewSize、-XX:MaxNewSize 或者 -Xmn,-XX:NewRatio 参数就会失效。
-XX:SurvivorRatio=8
——设置 Eden 和 Survivor0 或 Survivor1 的比例,这里是 Eden:Survivor0:Survivor1 = 8:1:1。
-XX:MaxTenuringThreshold=15
——设置在年轻代中对象最大的年龄,超过这个年龄(每经历一次 GC 年龄+1)就会被放入老年代,这里是是超过 15 岁就会放入老年代。
对象的创建过程
当我们写下一行new Object();
时,就会触发new
字节码指令,而new
字节码指令就会触发对象的创建过程。
(当然啦,对象的克隆和序列化也会触发对象的创建过程,只是这两个动作的底层不知道调用的是否是new
字节码指令,不能一句话完整地一起说出来。)
对象创建的一个基本的流程大体上分为五个步骤:检查类加载、分配内存、初始化成员变量、设置对象头、执行<init>
方法。
-
检查类加载。
这个就是结合我们前面说的类加载系统,在每次创建对象前都会去检查一下它的类有没有加载,没有就去加载。
-
分配内存。
想要创建一个对象,总得给它分配一块内存吧,于是到了这一步就会先决定对象的内存该分配在哪,以及如何分配,这个过程比较复杂。
-
初始化成员变量。
很多人都知道,我们给对象的成员变量赋的初始值都不是一步到位的,而是会先赋予一个默认值,前面也有讲(类加载-类加载的全过程-loadclass 方法的几个过程-准备),但前面讲的是静态变量,这里是成员变量。
-
设置对象头。
一个对象的组成部分是包括对象头、实例数据和对齐填充,对象头主要包含的信息是:对象的 hashcode、分代年龄、锁标志位等等,这一部分概念比较多,不过不会很复杂。
-
执行
<init>
方法这里的
<init>
方法可以等价于对象的构造方法,和前面讲的<cinit>
方法类似(类加载-类加载的全过程-loadclass 方法的几个过程-初始化)。
检查类加载
每当 JVM 想要执行 new 指令时,就要检查要创建的对象的类信息是否被加载进了内存。
如果已经加载了,就走到下一步分配内存。
如果没有加载,需要去执行类加载操作。
所以这个节点的流程如上图。
分配内存
分配内存这里就比较复杂了,主要涉及到以下三个问题:
- 对象该分配到哪里?
- 内存的分配方式?
- 分配内存的并发问题?
对象该分配到哪里?
这里主要讨论的是对象该分配到栈上还是分配到堆上,以及在堆上是怎么分配的?没错,对象也是会分配到栈上的。
不过对象在堆上,除了直接分配之外,还会涉及到 GC 之后对象会被移动到哪里?
这里需要讲述 Minor_GC(长期存活对象进入老年代、对象动态年龄判断机制、老年代空间担保机制) 和 Full_GC,这一部分在讲完对象的创建过程后,会在下一章节垃圾回收再讲。
分配到栈上
这里主要是会对要创建的对象做一个逃逸分析,如果判断当前对象不会逃逸,则直接分配到栈上,如果逃逸就继续走放在堆上的逻辑。
我们可以通过-XX:+DoEscapeAnalysis
参数来开启逃逸分析,-XX:-DoEscapeAnalysis
为关闭。
逃逸分析:分析一个对象的动态作用范围,如果这个对象可能会出现在其他方法的作用范围内,这个对象就被分析为是会逃逸的,反之就是不会逃逸的。
是否逃逸,通俗点说就是判断这个对象的作用域是否只在当前的方法内,如果在的话,那么当这个方法执行完之后,这个对象就随这栈帧的出栈一同回收,这样比把对象创建到堆上然后再回收更加节省性能。
那么,具体一点讲,什么样的代码会造成逃逸和避免逃逸呢?
public class Test06 {
private Test06 a = null;
// 1. 不会逃逸
private void t1(){
Test06 test06 = new Test06();
System.out.println(test06.toString());
}
// 2. 逃逸
private void t2(){
Test06 test06 = new Test06();
this.a = test06;
}
//3. 逃逸
private Test06 t3(){
Test06 test06 = new Test06();
return test06;
}
//4. 逃逸
private void t4(){
Test06 test06 = new Test06();
t5(test06);
}
private void t5(Test06 t){
t.toString();
}
}
第一种情况,test06 对象只在方法体内使用,就会被判定为非逃逸的对象。
第二种情况,把对象赋值给了对象的成员变量(或者全局引用),判定为逃逸对象。
第三种情况,return 了对象,判定为逃逸对象。
第四种对象,把对象作为其他方法的入参,判定为逃逸对象。
后三种情况都是把对象的地址提供给其他方法或者线程调用,一但不能确定只有当前方法使用这个对象,就会被判定为逃逸。
最后,逃逸分析虽然 1999 年就提出了理论,但是现在 JDK 中实现的还不够成熟,逃逸分析会在编译 class 文件时浪费较多的性能,但会提升代码的运行效率,总得来说还是聊胜于无。
标量替换
除了逃逸分析,JVM 还会对对象做标量替换,以此更加地节省内存空间。
如果已经确定了对象不会逃逸,且对象可以分解为标量(标量:基础数据类型以及引用类型),JVM 就不会用一整块内存去创建这个对象,而是把这个对象拆分为多个标量,分散地分配在栈帧中。
我们可以通过-XX:+EliminateAllocations
开启标量替换,JDK1.7 后默认开启。
比方说,下图是一块栈帧的内存空间,橙色的是分配在栈帧中的非逃逸对象,绿色的是栈帧的其他空间区域占用的内存,如果不标量替换,那可能就会分配成如下情况,会有一块空白区域无法被使用。
但如果使用了标量替换,就可能变成如下情况:
空间的利用率就会变得更高。
感觉我举的例子不能直观地看出标量替换的作用,不过能理解是什么意思就好了。
分配到堆上
如果要创建的对象是逃逸对象,就要创建到堆上了,而堆又被分为年轻代和老年代,初次分配到年轻代的话就会被分配到 Eden,经过 Minor_GC 就会被分配到 Survivor,分配到老年代就是分配到 Old 了。
是否是大对象?
在 JVM 的堆中创建一个对象是需要一块连续的内存空间的,如果一个大对象被放入年轻代,那么年轻代的空间就很快被放满,不仅会频繁地触发 Minor_GC,而且这些大对象也会被移到老年代,既然这样,不如直接让大对象直接进入老年代。
于是,当对象被判定为大对象,就会被直接放入 Old;当对象被判定为非大对象,就会被放入年轻代的 Eden。
我们可以通过-XX:PretenureSizeThreshold=1024
(单位字节)参数设置判定大对象的大小,超过的对象就被直接放入老年代,但这个参数只有在 Serial 和 ParNew 这两个收集器才会生效,并且默认值为 0,如果不设置对象都会被放入年轻代。
是否开启TLAB?
Thread Local Allocation Buffer,线程本地分配缓冲区。
我们可以通过XX:+/-UseTLAB
开启或者关闭 TALB。
如果开启 TLAB,JVM 就会在堆中给每个线程预先分配一小块内存空间,当线程需要创建对象时,优先在这块线程专用的内存中创建对象,线程专用内存被用完了,再往堆中的非线程专用的内存中创建对象。
内存的分配方式?
前面基本上讲述了对象该被分配到那块区域,接下来就该给对象分配内存空间了。
我们都知道创建一个对象是需要一块连续的空间(标量替换除外),并且我们可以确定创建一个对象具体需要多大的内存。
现在主要有两种分配方式:指针碰撞和空闲列表。
-
指针碰撞。
这种方式非常规整地分配内存,用过的内存放在一边,空闲的内存放在一边,中间用一个指针作为分界点的指示器。
当需要分配内存时,JVM 只需要把指针往后挪动新生对象所需的内存大小的距离,新生对象的空间就分配出来了。
-
空间列表。
(假设图中右边的数字就是 JVM 维护的空间列表,1 代表空间已使用,0 代表空闲。实际上 JVM 内部的维护方式不一定是这样)
这种方式就比较混乱了,已使用的和空闲的内存交织在一起,JVM 要维护一个空间列表来标记那些空间是空闲的,其他空间就是已使用的。
当需要分配内存时,JVM 就需要在空间列表中找到一块足够大的内存空间,把空间列表上标记为已使用。
分配内存的并发问题?
在 JVM 里肯定会存在很多个线程,必然会出现同一时刻多个线程同时创建对象的情况。解决分配内存的并发问题主要有两种方式:CAS 和 TLAB。
-
CAS(compare and swap)。
CAS 就是通过比较和替换的方式来保证当前的资源只有一个线程能够使用。
具体的解释就是,每次 CAS 操作都是会有三个元素:数据的内存地址、预期值、新值,只有当内存地址上的数据和预期值相等时,才会把新值赋值给内存地址上的数据,如果不相等,就会重新尝试操作,重新获取预期值和新值或者数据的内存地址。
在分配内存中的 CAS 操作中,我猜想 JVM 应该会判断当前的内存是否被使用,如果没有被使用就移动分界点的指针(这个应该会上锁);如果当前内存被使用了,就更新内存地址再次尝试,直到成功。
-
TLAB(Thread Local Allocation Buffer)。
这个前面也有讲到,这个策略就是给每个线程先分配一块专属的内存空间,每个线程都优先往这块专属内存空间分配对象,实在放不下再放到堆中的其他地方。
初始化成员变量
对象所需的内存空间分配完后,就会给对象的成员变量赋初值,和静态成员变量的赋初值一样,都是为了保证对象的成员属性在没有赋值的情况下也能使用,没有赋初值就访问一下的初始值。
八大基础对象以及对象的初始值:
类型 | 初始值 |
---|---|
byte | (byte)0 |
short | (short)0 |
int | 0 |
long | 0L |
float | 0.0F |
double | 0.0D |
char | ‘\u0000’ |
boolean | false |
对象 | null |
设置对象头
一个对象的组成部分有:对象头、实例数据、对齐填充。
对象头包括:标记字段(Mark World)、类型指针(Klass Pointer)、数组长度(Array length)。
- 标记字段:内存占用在 32 位的操作系统占 4 字节, 64 位占 8 字节,存储的内容有哈希值、GC 分代年龄、锁标志状态、线程持有锁等等数据。
- 类型指针:内存占用开启指针压缩 4 字节,不开启 8 字节。指向方法区中的类元数据地址。
- 数组长度:内存占用 4 字节,只有当前对象是数组时才会有这一部分。
标记字段的情况会比较多,有无锁的、是否要 GC 的。
32 位的标记字段:
64 位的标记字段会多更多的无用字段:
C++ 中的注释:
// 32 bits:
// ‐‐‐‐‐‐‐‐
// hash:25 ‐‐‐‐‐‐‐‐‐‐‐‐>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐>| (CMS free block)
// PromotedObject*:29 ‐‐‐‐‐‐‐‐‐‐>| promo_bits:3 ‐‐‐‐‐>| (CMS promoted object)
//
// 64 bits:
// ‐‐‐‐‐‐‐‐
// unused:25 hash:31 ‐‐>| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 ‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐>| promo_bits:3 ‐‐‐‐‐>| (CMS promoted object)
// size:64 ‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐>| (CMS free block)
//
// unused:25 hash:31 ‐‐>| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
// JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
// narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ‐‐‐‐‐>| (COOPs && CMS promoted object)
// unused:21 size:35 ‐‐>| cms_free:1 unused:7 ‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐>| (COOPs && CMS free block)
实例数据:写在类中的成员变量,如果是基础数据类型则直接存放数据,如果是对象则存放对象的地址。
对齐填充:JVM 中规定对象的大小必须是 8 字节的整数倍,如果不是的话就会把对象填充到 8 的整数倍字节,据说这样的查找效率比较高。
查看对象头
-
导入 maven 依赖
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.8</version> </dependency>
-
创建一个对象
public class User { private String name = "aa"; private int age = 18; }
-
写一段调用代码
public static void main(String[] args) { System.out.println("User:"); User user = new User(); System.out.println(ClassLayout.parseInstance(user).toPrintable()); System.out.println("Object:"); Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); System.out.println("Array:"); int[] array = new int[16]; System.out.println(ClassLayout.parseInstance(array).toPrintable()); }
最后查看对象头:
User:
org.example.simulate.jvm.User object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 int User.age 18
16 4 java.lang.String User.name (object)
20 4 (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
Object:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
Array:lei
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
12 4 (object header) 10 00 00 00 (00010000 00000000 00000000 00000000) (16)
16 64 int [I.<elements> N/A
Instance size: 80 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
下图解读:
指针压缩
从 JDK1.6 开始,JVM 在 64 位的操作系统中就支持指针压缩,使用-XX:+UseCompressedOops
开启指针压缩,-XX:-UseCompressedOops
关闭指针压缩。
开启指针压缩后,JVM 就可以使用 32 位的地址来表示 34 位的地址,也就是本来只能支持 4G 内存,开启了指针压缩后就能支持 32G 内存。
2^32 bit = 4 G
2^35 bit = 32 G
但如果堆内存小于 4G,JVM 则会直接采用 32 位的地址寻址,不会开启指针压缩。
如果堆内存大于 32G,指针压缩则会失效,JVM 会强制采用 64 位的地址寻址。
执行<init>
方法
我们所写的构造方法都会被编译器转换为<init>
方法,而在 JVM 加载好对象的类、为对象分配好内存空间、设置完初始值和对象头后,就会去调用<init>
方法,运行完成后就会把运行的权力交回给我们编写的 Java 代码。
比如这两个构造方法:
public class User {
private String name = "aa";
private int age = 18;
public static void main(String[] args) {
}
public User(){
}
public User(String a){
}
}
就会被转换成这两个<init>
方法
public <init>()V
L0
LINENUMBER 11 L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
L1
LINENUMBER 4 L1
ALOAD 0
LDC "aa"
PUTFIELD org/example/simulate/jvm/User.name : Ljava/lang/String;
L2
LINENUMBER 5 L2
ALOAD 0
BIPUSH 18
PUTFIELD org/example/simulate/jvm/User.age : I
L3
LINENUMBER 13 L3
RETURN
L4
LOCALVARIABLE this Lorg/example/simulate/jvm/User; L0 L4 0
MAXSTACK = 2
MAXLOCALS = 1
// access flags 0x1
public <init>(Ljava/lang/String;)V
L0
LINENUMBER 16 L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
L1
LINENUMBER 4 L1
ALOAD 0
LDC "aa"
PUTFIELD org/example/simulate/jvm/User.name : Ljava/lang/String;
L2
LINENUMBER 5 L2
ALOAD 0
BIPUSH 18
PUTFIELD org/example/simulate/jvm/User.age : I
L3
LINENUMBER 18 L3
RETURN
L4
LOCALVARIABLE this Lorg/example/simulate/jvm/User; L0 L4 0
LOCALVARIABLE a Ljava/lang/String; L0 L4 1
MAXSTACK = 2
MAXLOCALS = 2
我们也可以看到,name = "aa"
的赋值操作也被放到了<init>
方法。
这里内容太长了,垃圾回收和 JVM 调优都放到下一篇。