JVM虚拟机基本原理
一、运行时数据区
说明:在JDK 1.8 中加入了元数据区的概念,将原来保存在永久代中的**运行时常量池和类常量池都包括其中,但不包含字符串常量池,字符串常量池存放到了堆中**,相当于元数据区和堆共同瓜分了原来的永久代
1、程序计数器
①作用
记住下一条JVM指令的执行地址
②特点
- 线程私有
- 不存在内存溢出
2、虚拟机栈
①作用
- Java方法执行的内存模型
- 每个栈由栈帧(Frame)组成,对应着每次方法调用时占用的内存
- 每个线程只有一个活动栈帧,对应着当前正在执行的方法
问题辨析:
- 垃圾回收是否设计虚拟机栈:不涉及
- 栈内存分配越大越好吗:-Xss size(-Xss256k)来指定栈大小,同物理内存下,如果栈内存分配越大,可创建的线程数就会越少
- 方法内的局部变量是否线程安全:虚拟机栈是线程私有的
②异常场景
- 当线程请求的栈深度大于虚拟机所允许的深度,就会抛出StackOverflowError
- 线程申请栈空间失败,会抛出OutOfMemoryError
3、本地方法栈
①作用
与虚拟机栈类似,虚拟机栈执行Java方法,本地方法栈执行native方法
4、堆
①作用
对象、数组都在堆中存储,jvm参数:-Xms设置初始大小,-Xmx设置最大大小
②特点
- 线程共享
- 有垃圾回收机制
③异常场景
如果堆中没有内存再去分配对象时,就会抛出OutOfMemoryError异常
④堆内存诊断
public class HeapTest {
public static void main(String[] args) throws InterruptedException {
System.out.println("使用jps查看java进程ID");
Thread.sleep(10000);
// 10m
byte[] arr = new byte[1024 * 1024 * 10];
System.out.println("新建一个10m的对象,使用jmap查看堆内存使用情况");
Thread.sleep(20000);
arr = null;
System.gc();
System.out.println("执行GC后,查看堆内存使用情况");
}
}
方式一:jps、jmap查看堆内存使用情况(只是快照,没有动态数据)
# jps查看Java进程ID
jps
# 第一次执行,还未创建arr对象,堆内存的使用空间在6m左右
jmap -heap 31060
# 第二次执行,已经创建了10m的arr对象,堆内存的使用空间增加了10m
jmap -heap 31060
# 第三次执行,arr的引用指向null,执行过了GC,16m的内存已经被垃圾回收了
jmap -heap 31060
方式二:**jconsole**动态查看堆内存使用情况(动态查看,但没有转储(dump)功能)
方式三:**jvisualvm**动态查看堆内存情况,同时可用dump功能查看某一时刻的堆内存的具体信息
import java.util.ArrayList;
import java.util.List;
public class DumpTest {
public static void main(String[] args) throws InterruptedException {
List<Car> arrList = new ArrayList<>();
for (int i = 0; i < 200; i++) {
Car car = new Car();
arrList.add(car);
}
Thread.sleep(200000);
}
}
class Car{
private byte[] bigObj = new byte[1024 * 1024 * 10];
}
模拟一个Java程序,创建了很多大对象,线上发现执行GC后,堆内存占用仍然居高不下,使用jvisualvm分析
jvisualvm
将堆内存dump出来,发现其中有一个大对象arrayList,里面每个bigObj对象都有10m左右,真相大白
5、方法区
①作用
用来存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
说明:HotSpot虚拟机在**Java6之前,方法区在逻辑上作为堆的一部分,实现方式是永久代,意思是使用和堆一样的垃圾回收工具方便管理。Java8的方法区已经从堆内存中移出,使用本地内存存储类信息等数据,并改名为元空间**。
为什么废除永久代?Oracle为什么要做这样的改进呢?
- 容易内存溢出:在原来的永久代划分中,每当一个类初次被加载的时候,它的元数据都会放到永久代中。但是永久代的内存空间也是有大小限制的,如果加载的类太多,很有可能导致永久代内存溢出;
- 大小无法确定:永久代大小也不容易确定,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等,但是PermSize指定太小又很容易造成永久代内存溢出;
- GC回收效率低:HotSpot虚拟机的每种类型的垃圾回收器都需要特殊处理永久代中的元数据。永久代会为GC带来不必要的复杂度,并且回收效率偏低。将元数据从永久代剥离出来,不仅实现了对元空间的无缝管理,还可以简化Full GC以及对以后的并发隔离类元数据等方面进行优化。
②异常场景
当需要加载的类信息超出元空间内存时,就会抛出OutOfMemoryError异常
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;
public class MetaspaceTest extends ClassLoader { // 可以用来加载类的二进制字节码
public static void main(String[] args) {
int j = 0;
try {
MetaspaceTest test = new MetaspaceTest();
for (int i = 0; i < 10000; i++,j++) {
// 作用是生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
// 版本号、public、类名、包名、父类、接口
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
// 返回了字节码的byte[]
byte[] code = cw.toByteArray();
// 执行了类的加载
test.defineClass("Class" + i, code, 0, code.length);
}
} finally {
System.out.println(j);
}
}
}
说明:元空间默认是操作系统内存,通常情况下不会OOM,所以启动虚拟机时设置一个比较小的值,可以测出OOM的场景,-XX:MaxMetaspaceSize=8m
6、运行时常量池
①常量池
通过反编译字节码文件,得到该类的详细信息
public class MetaspaceTest extends ClassLoader { // 可以用来加载类的二进制字节码
public static void main(String[] args) {
System.out.println("hello world");
}
}
# 在target目录下执行得到反编译信息
javap -v MetaspaceTest.class
作用:常量池就是存在于**字节码文件中的一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量**等信息
②运行时常量池
作用:常量池是.class文件中的,当该类被加载,它的常量池信息就会被放入运行时常量池,并把里面的符号地址变为真实地址。
7、字符串常量池
①作用
字符串常量池是运行时常量池中的一部分,它被用来存储字符串对象,这些对象在编译期间就被确定了。对于相同的字符串常量,只会在常量池中存储一份,也就是说,Java中的字符串常量是唯一的。
工作原理:本质上是一个**hashTable**,且不能扩容,大小可以在虚拟机参数中指定
当一个Java程序需要使用某个字符串常量时,首先会在字符串常量池中查找该常量。如果该常量已经存在于字符串常量池中,则直接返回该常量的引用;如果该常量不存在于字符串常量池中,则将该常量添加到字符串常量池中,并返回该常量的引用。
②特点
在Java中,字符串常量池的存在可以帮助**减少内存的使用,提高程序的性能。当程序需要大量使用字符串时,尽量使用字符串常量,以避免重复创建**相同的字符串对象,从而减少内存的使用。
③工作原理案例解析
public class MetaspaceTest {
public static void main(String[] args) {
String a = "a";
String b = "b";
String ab = "ab";
}
}
反编译后结果:
案例中,字符串a、b、c是如何被加载进入字符串常量池中的?
- 字符串a、b、c是在编译期就确定下来的字符串,通过反编译字节码文件,可以看到,首先存在于常量池中
- 当字节码文件被运行时,这些字符串被加载到运行时常量池中,这时这三个字符串只是常量池中的符号,还没有变成Java的字符串对象
- 当运行到这行代码时(懒惰机制):ldc #2 会把a符号**变为"a"字符串对象,并将其放入字符串常量池StringTable中,由于StringTable本质是HashTable,所以放入的规则是“无则添加,有则不处理**”
④字符串变量拼接、编译期优化
public class MetaspaceTest {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
// 字符串拼接
String s4 = s1 + s2;
// 编译期优化
String s5 = "a" + "b";
}
}
javap -v .\MetaspaceTest.class
通过查看反编译字节码文件可以得出:
String s4 = s1 + s2 :本质上是新建了一个StringBuilder对象,通过append方法进行拼接
String s5 = "a" + "b":本质上直接编译为"ab",其实是编译器在编译时进行了优化
⑤字符串延迟加载(懒惰机制)
public class MetaspaceTest {
public static void main(String[] args) {
int x = args.length;
System.out.println();
System.out.print("1");//断点1
System.out.print("2");
System.out.print("3");
System.out.print("4");
System.out.print("5");
System.out.print("1");// 断点2
System.out.print("2");
System.out.print("3");
System.out.print("4");
System.out.print("5");
System.out.println();//断点3
}
}
断点1:
断点2:
断点3:
说明:
- 延迟加载:只有执行到这一行时,才会被定义为Java对象,并且放入StringTable中
- 当StringTable中已经存在该字符串时,不会再新建Java对象了,节省了堆空间
⑥intern()方法
public class MetaspaceTest {
public static void main(String[] args) {
// new String("a") : 创建堆中对象,同时加入StringTable中
// new String("a") + new String("b") : 创建堆中对象s
String s = new String("a") + new String("b");
// 尝试将s放入StringTable中,无则添加,有则不处理
String intern = s.intern();
System.out.println(intern == "ab");
System.out.println(s == "ab");
}
}
需要注意的是:s.intern()方法执行后,返回的引用intern和原来的引用s,都**直接指向了StringTable中的常量**了,所以通常不需要额外使用intern()方法返回的引用
Java6和Java8在intern方法的实现策略上有所不同:
- 1.8将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,会把串池中的对象返回
- 1.6将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池,会把串池中的对象返回
总结:Java8中,intern方法将Java字符串对象放入池中后,返回的引用和之前的对象引用是同一个;
Java6中,intern方法将Java字符串对象复制了一份放入池中,所以**指向对象的引用和intern返回的引用是不同的**。
⑦问题总结
public class MetaspaceTest {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
// 问题1:
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);
System.out.println(s3 == s4);
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
// 问题2:
System.out.println(x1 == x2);
// 如果调换了最后两行代码的位置呢?
// x2.intern();
// String x1 = "cd";
// System.out.println(x1 == x2);
}
}
问题1解读:
- s1、s2是常量池引用
- s3在编译期间被确定,并优化,直接放入常量池
- s4本质使用StringBuilder对象拼接,是Java对象引用
- s5是常量池引用、
- s6是s4尝试放入常量池时,发现常量池中已经有了该字符串常量,所以返回给s6的是常量池中的引用
答案:
false
true
true
false
注意:
由于常量池已经有了该字符串常量,所以执行s4.intern()并不会把s4的引用指向常量池,所以第二次执行s3 == s4还是false
问题2解读:
- x2是Java堆中对象
- x1是常量池引用,所以是false
如果调换了最后两行代码,先执行x2.intern(),此时常量池中还没有"cd",所以x2也会指向常量池中的对象,那么x1 == x2就会变为true
⑧StringTable的位置
Java6及之前,StringTable是放在永久代的运行时常量池中的,Java8的元数据区和堆代替了原有的永久代,StringTable被放在了堆中。这样优化的好处是什么呢?
原来的劣势:
- 旧的JVM实现中,在进行**fullGC**时,才会对永久代进行StringTable的垃圾回收,使得StringTable的垃圾回收时间较晚,且效率低下。
- 在应用程序中会存在大量字符串常量,如果垃圾回收不及时,则容易产生永久代的内存溢出。
优化后的好处:
而Java8中,把StringTable放到了堆中,在**minorGC时就会对其进行垃圾回收,提高了效率,降低了内存溢出的风险**
⑨StringTable的性能调优
串池大小对性能的影响
/**
* 演示串池大小对性能的影响
* -Xms500m -Xmx500m -XX:+PrintStringTableStatistics -XX:StringTableSize=1009
*/
public class Demo1_24 {
public static void main(String[] args) throws IOException {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
String line = null;
long start = System.nanoTime();
while (true) {
line = reader.readLine();
if (line == null) {
break;
}
line.intern();
}
System.out.println("cost:" + (System.nanoTime() - start) / 1000000);
}
}
}
-XX:+PrintStringTableStatistics:打印字符串常量池的统计数据
-XX:StringTableSize=1009:设置字符串常量池的大小(1009是最小值)
当常量池大小为200000时,读取480000的字符串耗时146ms,常量池大小设置为1009时,耗时1969ms
字符串是否入池对性能的影响
/**
* 演示 intern 减少内存占用
* -XX:StringTableSize=200000 -XX:+PrintStringTableStatistics
* -Xms500m -Xmx500m -XX:+PrintStringTableStatistics -XX:StringTableSize=200000
*/
public class Demo1_25 {
public static void main(String[] args) throws IOException {
List<String> address = new ArrayList<>();
System.in.read();
for (int i = 0; i < 10; i++) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
String line = null;
long start = System.nanoTime();
while (true) {
line = reader.readLine();
if(line == null) {
break;
}
address.add(line);
// intern入池
// address.add(line.intern());
}
System.out.println("cost:" +(System.nanoTime()-start)/1000000);
}
}
System.in.read();
}
}
jvisualvm
该程序是循环10次读取48万的字符串,并将其添加到list中,如果没有进行入池操作,如图二所示,String对象占34%的内存,实例数达到480万左右,进行入池操作后,如图3所示,内存占用只有21%,实例数只有64万左右。
入池操作减少了重复的字符串对象,节约了内存空间,提高程序性能
8、直接内存
工作原理
普通IO操作读取磁盘文件的流程:
- 将磁盘文件读取至系统缓存区
- 再由系统缓存区读取至Java堆内存,Java程序从堆内存中读取并处理后续操作
弊端:
- 系统内存和Java堆内存都有一个缓存区,数据需要复制两份,效率低
NIO操作读取磁盘文件流程:
- 在操作系统划出一块缓冲区,该缓冲区叫做直接内存
- 直接内存区域系统和JVM都可以直接操作
- 将磁盘文件读取至直接内存中,Java程序可以直接访问并处理后续操作
好处:
- 比普通IO操作减少了一次缓冲区间的复制操作
特点
- 常见于NIO操作,用于数据缓冲区
- 分配回收成本高,但读写性能高
- 不受JVM垃圾回收管理
- 会出现OOM
内存划分释放机制
- 使用Unsafe对象完成直接内存的分配回收,并且回收需要主动调用freeMemory方法
- ByteBuffer对象被垃圾回收时,JVM的守护线程ReferenceHandler检测到后就会执行freeMemory方法来释放直接内存
通常情况下,JVM调优时会增加参数**-XX:+DisableExplicitGC**来禁用程序中使用代码显式地进行GC(使得System.gc()无效),这种情况下,直接内存只能等到JVM自动进行垃圾回收时才会被释放,可能会导致直接内存长期占用大量内存而出现OOM的风险。
正确姿势:
手动调用freeMemory方法进行释放
二、垃圾回收机制
1、如何判断对象可以回收
引用计数法
原理:对象增加一个引用,引用数+1,减少一个引用,引用数-1,当引用数为0时,则判定可以被回收
弊端:循环引用
可达性分析算法
原理:扫描堆中对象,是否以一系列GC Root对象为起点的引用链找到该对象,如果不能,则可以被回收
GC Roots对象:
- 系统类加载器中的核心对象
- 本地方法栈中引用的对象
- 活动线程中使用的对象
- 同步锁持有的对象
四种引用
| 引用 | 概念 | 引用的对象何时被垃圾回收 |
|---|---|---|
| 强引用 | new关键字赋值操作的引用 | GC Roots不再关联 |
| 软引用 | 有用,但非必须的对象(缓存等) | 垃圾回收完还没有足够的内存时,会被垃圾回收掉 |
| 弱引用 | 非必须对象,比软引用更弱 | 垃圾回收完无论有无足够内存,都会被垃圾回收掉 |
| 虚引用 | 最弱的引用,不会对其引用的对象的生命周期产生影响 | 对象被收集器回收时收到一个通知 |
| 终结器引用 | 当对象没有被强引用关联时,虚拟机会把该对象用终结器引用关联 | GC时,终结器引用入队,由Finalizer线程通过找到被引用的对象并调用它的finalize()方法,下一次GC时才能回收被引用对象 |
| 引用 | 使用场景 |
|---|---|
| 软引用 | 用于实现缓存功能,在内存不足的时候,JVM可以回收软引用对象,从而释放一些内存,避免OutOfMemoryError的出现。 |
| 弱引用 | 适用于一些不必要的缓存对象,当JVM发现存在弱引用对象时,会立即回收这些对象,释放内存。 |
| 虚引用 | 虚引用一般用于作为一个对象销毁的监控机制,可以在一个对象销毁后,做一些对象销毁后的后续处理工作。一个典型案例是直接内存的使用:创建ByteBuffer对象时,会创建一个虚引用cleaner指向创建的直接内存块,当ByteBuffer没有强引用要被垃圾回收时,虚引用cleaner会被加入到引用队列中,当守护线程检测到队列中有cleaner虚引用时,就会调用clean方法,释放掉申请的直接内存块 |
2、垃圾回收算法
标记清除
将没有GC引用的对象进行标记,然后进行清除。
优势:效率高
劣势:产生内存碎片
标记整理
将没有GC Root引用的对象标记,将可用的对象整理到一起,剩余的空间进行清除
优势:没有内存碎片
劣势:降低了效率
复制
两块内存,只使用一块,三个步骤:
- 将可用的对象转移到另一块并整理
- 将原来那块内存清理
- 将两块内存交换一下位置
优势:没有内存碎片
劣势:占用双倍内存空间
3、分代垃圾回收
- 对象首先分配在伊甸园(Eden区)
- 新生代内存不足时,发生minor GC,Eden和from区存活的对象复制到to区,对象的年龄+1,并且交换空间
- minor GC会触发stop the world,暂停其他用户线程,等GC结束后,才恢复其他线程
- 当对象年龄超过阈值,会晋升老年代,最大寿命是15
- 当老年代内存不足时,会先尝试触发minor GC,如果内存还是不足,则会触发full GC,stop the world的时间更长
疑问:
- 对象在Eden区分配,是如何到from区的?
- 新生代内存不足时,Eden区和from区幸存的对象能够放到to区吗,to的空间够吗?
- 如果一个对象很大,新生代放不下的情况下,会怎么样?
解释:
- Eden区内存不足时,发生minor GC,幸存的对象被复制到了to区,然后from和to区交换空间,所以幸存的对象到了from区,并且年龄+1
- 新生代的对象朝生夕死,绝大多数对象都是要被垃圾回收的,其实能够幸存的对象是非常少量的,所以to区是肯定够用的。Eden和from、to分别占用内存的比例为8:1:1
- 如果大对象新生代放不下的话,就会直接进入老年代,而不用关注15年龄
4、垃圾回收器
串行
特点
- 单线程
- 堆内存较小,适合个人电脑
开启参数
-XX:+UseSerialGC=Serial + SerialOld
Serial负责新生代的垃圾回收,采用复制算法
Serial Old负责老年代的垃圾回收,采用标记整理算法
吞吐量优先
特点
- 多线程
- 堆内存较大、多核CPU
- 让单位时间内,STW的时间最短
- 多个垃圾回收器并行执行,但是垃圾回收期间不允许用户线程执行
开启参数
-XX:+UseParallelGC
-XX:+UseParallelOldGC
Java8默认开启,如果设置其中一个参数,另一个会自动开启
多线程,线程数和CPU核数相关,可以通过-XX:ParallelGCThreads=n指定。
响应优先
特点
- 多线程
- 堆内存较大、多核CPU
- 尽可能让单词STW时间最短
- 多个垃圾回收线程和用户线程并发执行
开启参数
-XX:+UseConcMarkSweepGC:简称CMS,工作在老年代,并发的基于标记清除算法的垃圾回收器
-XX:+UseParNewGC:新生代垃圾回收器,和CMS配合使用
当CMS出现并发失败时,会退化为一个单线程的Serial0ld
-XX:ParallelGCThreads=n:并行线程数,通常和CPU核数一致
-XX:ConcGCThreads=n/4:并发线程数,通常是并行线程数的1/4
缺点:
- 如果CPU是4核心,并行线程数是4,其中一个线程用来垃圾回收,那么用户线程就只剩三个了,所以用户线程的计算能力受到一定影响,对系统的吞吐量有一定影响
- CMS是标记清除回收算法,会造成内存碎片,当内存碎片不足以容纳新对象时,CMS垃圾回收就会并发失败,退化为一个串行的单线程serialOld,此时垃圾回收时间会陡增,效率降低
注意:这里的线程和操作系统中的线程不是一个概念,这里的线程指的是**CPU的线程,和CPU的核心数一致,操作系统的线程**是进程中的更小的执行单元
G1
定义
Garbage First
- 2004 论文发布
- 2009 JDK 6u14 体验
- 2012 JDK 7u4 官方支持
- 2017 JDK 9 默认
适用场景
- 同时注重吞吐量和低延迟,默认的暂停目标是200ms
- 超大堆内存,会将堆划分为多个大小相等的Region
- 整体上是标记+整理算法,两个区域之间是复制算法
相关JVM参数
-XX:+UseG1GC:开启
-XX:G1HeapRegionSize=size:设置region大小
-XX:MaxGCPauseMillis=time :设置最大暂停时间,默认是200ms
5、垃圾回收调优
选择合适的垃圾回收器
追求吞吐量:parallelGC
追求低延迟:CMS、G1、ZGC
新生代的调优
特点
- 所有的new操作的内存分配非常廉价
- 死亡对象的回收代价是零
- 大部分对象用过即死
- Minor GC的时间远远低于Full GC
新生代内存越大越好吗?
新生代内存过大,意味着老年代内存变小,会导致老年代过早GC,而且老年代触发的是full GC,代价更大。
oracle建议新生代内存在堆的**1/4~1/2**比较合理
老年代调优
以CMS为例
-
CMS的老年代越大越好:当垃圾回收线程与用户线程并发,垃圾回收的过程中用户线程产生新的垃圾(浮动垃圾)导致老年代内存不足,CMS就会并发失败,退化为一个单线程的serialOld,垃圾回收时间就会增加
-
先尝试不调优,如果没有full GC,那么已经划分比较合理了,否则先尝试调优新生代
-
观察发生full GC时老年代的内存占用,将老年代内存预设调大1/4~1/3
-XX:CMSInitiatingOccupancyFraction=percent
这个参数是说当垃圾占用老年代多大比例时,触发垃圾回收
案例一:FullGC和minorGC频繁
频繁minorGC说明新生代内存不够,并且会降低晋升老年代的阈值,导致老年代内存紧张,并且老年代也频繁GC,所以首先考虑增大新生代内存。
案例二:请求高峰期发生full GC,单次暂停时间特别长(CMS)
CMS单次暂停时间长,通常是发生在重新标记阶段,加上参数-XX:+CMSScavengeBeforeRemark,表示在重新标记前对新生代进行minorGC,减少重新标记的耗时。
案例三:老年代内存充裕的情况下,发生full GC(CMS jdk1.7)
jdk1.7的方法区的实现方式是永久代,当永久代内存不足时会发生full GC,调整方法区的初始值和最大值
类加载
图解代码运行流程
/**
* 演示 字节码指令 和 操作数栈、常量池的关系
*/
public class Demo3_1 {
public static void main(String[] args) {
int a = 10;
int b = Short.MAX_VALUE + 1;
int c = a + b;
System.out.println(c);
}
}
# 字节码反编译
javap -v Demo3_1.class
Classfile /root/Demo3_1.class
Last modified Jul 7, 2019; size 665 bytes
MD5 checksum a2c29a22421e218d4924d31e6990cfc5
Compiled from "Demo3_1.java"
public class cn.itcast.jvm.t3.bytecode.Demo3_1
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#26 // java/lang/Object."<init>":()V
#2 = Class #27 // java/lang/Short
#3 = Integer 32768
#4 = Fieldref #28.#29 //java/lang/System.out:Ljava/io/PrintStream;
#5 = Methodref #30.#31 // java/io/PrintStream.println:(I)V
#6 = Class #32 // cn/itcast/jvm/t3/bytecode/Demo3_1
#7 = Class #33 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 Lcn/itcast/jvm/t3/bytecode/Demo3_1;
#15 = Utf8 main
#16 = Utf8 ([Ljava/lang/String;)V
#17 = Utf8 args
#18 = Utf8 [Ljava/lang/String;
#19 = Utf8 a
#22 = Utf8 c
#23 = Utf8 MethodParameters
#24 = Utf8 SourceFile
#25 = Utf8 Demo3_1.java
#26 = NameAndType #8:#9 // "<init>":()V
#27 = Utf8 java/lang/Short
#28 = Class #34 // java/lang/System
#29 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#30 = Class #37 // java/io/PrintStream
#31 = NameAndType #38:#39 // println:(I)V
#32 = Utf8 cn/itcast/jvm/t3/bytecode/Demo3_1
#33 = Utf8 java/lang/Object
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 java/io/PrintStream
#38 = Utf8 println
#39 = Utf8 (I)V
{
public cn.itcast.jvm.t3.bytecode.Demo3_1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."
<init>":()V
4: return
LineNumberTable:
line 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcn/itcast/jvm/t3/bytecode/Demo3_1;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: ldc #3 // int 32768
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: getstatic #4 // Field
java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokevirtual #5 // Method
java/io/PrintStream.println:(I)V
17: return
LineNumberTable:
line 8: 0
line 9: 3
line 12: 17
LocalVariableTable:
Start Length Slot Name Signature
0 18 0 args [Ljava/lang/String;
3 15 1 a I
6 12 2 b I
10 8 3 c I
MethodParameters:
Name Flags
args
}
step1:常量池载入运行时常量池
step2:方法字节码载入方法区
step3:main线程开始执行,分配栈帧内存
(stack=2,locals=4)
step4:执行引擎开始执行字节码
bipush 10
- 将一个 byte 压入操作数栈(其长度会补齐 4 个字节),类似的指令还有
- sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
- ldc 将一个 int 压入操作数栈
- ldc2_w 将一个 long 压入操作数栈(分两次压入,因为 long 是 8 个字节)
- 这里小的数字都是和字节码指令存在一起,超过 short 范围的数字存入了常量池
istore_1
- 将操作数栈顶数据弹出,存入局部变量表的 slot 1
ldc #3
- 从常量池加载 #3 数据到操作数栈
注意 Short.MAX_VALUE 是 32767,所以 32768 = Short.MAX_VALUE + 1 实际是在编译期间计算好的
istore_2
- 将操作数栈顶数据弹出,放入局部表量表slot2
iload_1、iload_2:将局部变量表的两个数据压入操作数栈
iadd:将操作数栈的两个数据执行add操作
istore_3:将结果从操作数栈中弹出,放入局部表量表slot3
getstatic #4:从堆中找到System.out对象的引用压入操作数栈
iload_3:将局部变量表的slot3的数据压入操作数栈
invokevirtual #5
- 找到常量池 #5 项
- 定位到方法区 java/io/PrintStream.println:(I)V 方法
- 生成新的栈帧(分配 locals、stack等)
- 传递参数,执行新栈帧中的字节码
- 执行完毕,弹出栈帧
- 清除 main 操作数栈内容
return
- 完成 main 方法调用,弹出 main 栈帧
- 程序结束
总结:
整个代码运行流程中,运行时常量池负责加载编译期间已经确定的一些常量,方法区存放类的成员变量以及编译后的代码指令,堆负责存放执行期间产生的对象,虚拟机栈分配栈帧,用来执行方法区的代码指令,包括分配一些操作数栈、局部表量表等,真正负责执行的是执行引擎。
类加载阶段
加载
将类的字节码载入方法区中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
- _java_mirror 即 java 的类镜像,例如对 String 来说,就是 String.class,作用是把 klass 暴露给 java 使用
- _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法表
说明:存在堆中的对象,对象头存有类地址,这里的类地址指的是堆中存的**_java_mirror的地址,所以Person.class作为_java_mirror是存在堆中的**,_java_mirror中存有instanceKlass的地址,这个地址才是方法区中的地址。
链接
验证:检查编译后的字节码是否符合JVM规范,安全性检查
准备:为 static 变量分配空间,设置默认值
- static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
- static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
- 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
public class ClassLoadTest {
// 准备阶段分配空间,初始化阶段赋值
static int a;
// 准备阶段分配空间,初始化阶段赋值
static int b = 1;
// 准备阶段分配空间并赋值
static final int c = 10;
// 准备阶段分配空间并赋值
static final String e = "hello";
// 准备阶段分配空间,初始化阶段赋值
static final Object d = new Object();
}
怎么理解呢?
意思就是说将上面这个类的字节码文件反编译后,就会看到,a变量只有声明的代码,没有赋值动作;b变量除了声明代码,会在构造器代码中增加该变量的赋值动作,而构造器代码要到初始化时才会被执行,所以赋值操作要到初始化阶段。final修饰的基本类型和String都是在编译后就已经赋值了,而final修饰的对象类型,也是在构造器代码中增加赋值动作,要到初始化阶段才执行。
解析:将常量池中的符号引用解析为直接引用
说明:在加载阶段,常量池中的引用只是一个符号引用,并不知道具体该引用指向的内存的地址,而解析之后,符号引用就会变为直接引用,直接引用就会明确指向内存的具体地址。
初始化
就是调用类构造器方法:<cinit>(),虚拟机会保证该构造方法线程安全
正确理解类的初始化:
<cinit>()方法不是程序员在代码中编写的方法,而是Javac编译器自动生成物,它收集了所有static变量的赋值操作和static{}代码块,并且顺序和代码出现的顺序一致。
<cinit>()方法和生成类对象的构造方法<init>()不是一个东西,<init>()方法是程序员编写或者默认的无参的构造方法,用于类中非static的成员变量的赋值或者自定义的一些操作。
<cinit>()方法一定保证了先执行父类的,然后在执行子类的,无需显示调用。
发生的时机
概括得说,类初始化是【懒惰的】
- main 方法所在的类,总会被首先初始化
main方法是程序的入口,一定会被首先初始化
- 首次访问这个类的静态变量或静态方法时
涉及到静态变量的赋值操作,所以一定会先初始化
- 子类初始化,如果父类还没初始化,会引发父类的初始化
<cinit>()方法一定保证了先执行父类的,然后在执行子类的
- 子类访问父类的静态变量,只会触发父类的初始化
相当于首次访问父类的静态变量,所以会触发父类的初始化,和子类没关系
- Class.forName
默认是会进行初始化的,如果将第二个参数设为false,则不会初始化
- new 会导致初始化
new会实例化一个对象,实例化对象前一定会先初始化该类
不会导致类初始化的情况
- 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
编译期就已经确定并放在了常量池中,不需要再初始化时赋值了
- 类对象.class 不会触发初始化
_java_mirror在类加载时期就会在堆中生成了,作为访问方法区的唯一入口,所以不需要等到初始化阶段
- 创建该类的数组不会触发初始化
- 类加载器的loadClass方法
class A {
static int a = 0;
static {
System.out.println("a init");
}
}
class B extends A {
final static double b = 5.0;
static boolean c = false;
static {
System.out.println("b init");
}
}
public class Load3 {
static {
System.out.println("main init");
}
public static void main(String[] args) throws ClassNotFoundException {
// 1. 静态常量(基本类型和字符串)不会触发初始化
System.out.println(B.b);
// 2. 类对象.class 不会触发初始化
System.out.println(B.class);
// 3. 创建该类的数组不会触发初始化
System.out.println(new B[0]);
// 4. 不会初始化类 B,但会加载 B、A
ClassLoader cl = Thread.currentThread().getContextClassLoader();
cl.loadClass("cn.itcast.jvm.t3.B");
// 5. 不会初始化类 B,但会加载 B、A
ClassLoader c2 = Thread.currentThread().getContextClassLoader();
Class.forName("cn.itcast.jvm.t3.B", false, c2);
// 1. 首次访问这个类的静态变量或静态方法时
System.out.println(A.a);
// 2. 子类初始化,如果父类还没初始化,会引发
System.out.println(B.c);
// 3. 子类访问父类静态变量,只触发父类初始化
System.out.println(B.a);
// 4. 会初始化类 B,并先初始化类 A
Class.forName("cn.itcast.jvm.t3.B");
}
}
类加载器
| 名称 | 加载哪的类 | 说明 |
|---|---|---|
Bootstrap ClassLoader | JAVA_HOME/jre/lib | C++编写的,无法直接访问,显示为null |
Extension ClassLoader | JAVA_HOME/jre/lib/ext | 上级为 Bootstrap |
Application ClassLoader | classpath | 上级为 Extension |
| 自定义类加载器 | 自定义 | 上级为 Application |
双亲委派
如何理解双亲委派?
双亲委派模型指的是**Java给出的默认加载类的规则**,当前类加载器不会首先尝试加载,总是交由父级加载器加载,如果父级类加载器加载不了,在由下级类加载器加载,即一层一层向上委派,最顶层的类加载器(启动类加载器)无法加载该类时,再一层一层向下委派给子类加载器加载。
好处:
双亲委派保证类加载器,自下而上的委派,又自上而下的加载,保证每一个类在各个类加载器中都是同一个类。
一个非常明显的目的就是保证java官方的类库<JAVA_HOME>\lib和扩展类库<JAVA_HOME>\lib\ext的加载安全性,不会被开发者覆盖。
例如类
java.lang.Object,它存放在rt.jar之中,无论哪个类加载器要加载这个类,最终都是委派给启动类加载器加载,因此Object类在程序的各种类加载器环境中都是同一个类。如果开发者自己开发开源框架,也可以自定义类加载器,利用双亲委派模型,保护自己框架需要加载的类不被应用程序覆盖。
// 抽象的类加载器给出的默认的loadClass方法实现了双亲委派模型
public abstract class ClassLoader {
//每个类加载器都有个父加载器
private final ClassLoader parent;
public Class<?> loadClass(String name) {
//查找一下这个类是不是已经加载过了
Class<?> c = findLoadedClass(name);
//如果没有加载过
if( c == null ){
//先委派给父加载器去加载,注意这是个递归调用
if (parent != null) {
c = parent.loadClass(name);
}else {
// 如果父加载器为空,查找Bootstrap加载器是不是加载过了
c = findBootstrapClassOrNull(name);
}
}
// 如果父加载器没加载成功,调用自己的findClass去加载
if (c == null) {
c = findClass(name);
}
return c;
}
protected Class<?> findClass(String name){
//1. 根据传入的类名name,到在特定目录下去寻找类文件,把.class文件读入内存
...
//2. 调用defineClass将字节数组转成Class对象
return defineClass(buf, off, len);
}
// 将字节码数组解析成一个Class对象,用native方法实现
protected final Class<?> defineClass(byte[] b, int off, int len){
...
}
}
具体方法说明:
loadClass:主要就是实现双亲委派逻辑
findClass:主要实现当前加载器的加载类逻辑(读取.class文件到内存中),loadClass最终会调用findClass
defineClass:主要将得到的字节码数据转换为一个Class对象,findClass会调用defineClass
自定义类加载器
场景一:使用默认的双亲委派原则加载类
import java.io.*;
public class TestClassloader extends ClassLoader{
protected TestClassloader(ClassLoader parent) {
super(parent);
}
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
TestClassloader classloader = new TestClassloader(Demo.class.getClassLoader());
// 使用自定义的classLoader来加载该类(加载、链接、初始化)
Class<?> clazz = Class.forName("org.slj.jvm.Demo", true, classloader);
Object obj = clazz;
System.out.println(String.format("MetaspaceTest的父类object的classLoader是:%s", obj.getClass().getClassLoader()));
System.out.println(String.format("MetaspaceTest的classLoader是:%s", clazz.getClassLoader()));
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 读取.class文件
byte[] data = null;
try {
System.out.println(name);
String namePath = name.replaceAll("\\.", "\\\\");
String classFile = "D:\\IntelliJ IDEA 2022.3.2\\IntellijIdeaProjects\\Zone\\go-offer\\target\\classes\\" + namePath + ".class";
ByteArrayOutputStream baos = new ByteArrayOutputStream();
FileInputStream fis = new FileInputStream(classFile);
byte[] bytes = new byte[1024];
int len = 0;
while ((len = fis.read(bytes)) != -1) {
baos.write(bytes, 0, len);
}
data = baos.toByteArray();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
// 字节码加载到 JVM 的方法区,
// 并在 JVM 的堆区建立一个java.lang.Class对象的实例
// 用来封装 Java 类相关的数据和方法
return super.defineClass(name, data, 0, data.length);
}
}
说明:
- 构造器传入一个父级类加载器,传入当前类的类加载器,默认是
AppClassLoader- 没有复写
loadClass方法,默认使用双亲委派模式加载类findClass方法是读取target文件中的.class文件,然后调用defineClass方法将字节码加载到方法区,并提供_java_mirror作为访问方法区的入口- 所以根据双亲委派原则,Demo类的父类Object的加载器是null(
BootstrapClassLoader),而Demo类本身的加载器是自定义的TestClassLoader
场景二:覆写loadClass方法,优先使用当前类加载器加载,试图破坏双亲委派
// 其他代码一致,不再重复
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
TestClassloader classloader = new TestClassloader(Demo.class.getClassLoader());
// 使用自定义的classLoader来加载该类(加载、链接、初始化)
Class<?> clazz = Class.forName("org.slj.jvm.Demo", true, classloader);
Object obj = clazz;
System.out.println(String.format("Demo的父类object的classLoader是:%s", obj.getClass().getClassLoader()));
System.out.println(String.format("Demo的classLoader是:%s", clazz.getClassLoader()));
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 尝试直接加载
Class<?> clazz = findClass(name);
if (clazz != null) {
return clazz;
}
// 直接加载失败,交由父级执行双亲委派
return super.loadClass(name);
}
说明:系统报错:java\lang\Object.class (系统找不到指定的路径。)
- 重写了loadClass方法,首先使用当前类加载器加载该类,如果加载失败,再交由父级执行双亲委派
- 父级类加载器传入的是
AppClassLoader- 由于所有类默认都继承自Object,所以初始化Demo类之前会先初始化Object,而**Java禁止用户用自定义的类加载器加载
java.开头的官方类,也就是说只有启动类加载器BootstrapClassLoader才能加载java.开头的官方类,**所以执行失败
场景三:指定parent类加载器为null(即BootstrapClassLoader),且不覆写loadClass方法
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
TestClassloader classloader = new TestClassloader(null);
// 使用自定义的classLoader来加载该类(加载、链接、初始化)
Class<?> clazz = Class.forName("org.slj.jvm.Demo", true, classloader);
Object obj = clazz;
System.out.println(String.format("Demo的父类object的classLoader是:%s", obj.getClass().getClassLoader()));
System.out.println(String.format("Demo的classLoader是:%s", clazz.getClassLoader()));
}
说明:这样一来,由父级类加载器null(
BootstrapClassLoader)来加载Object,且父级无法加载Demo,交由下级类加载器(TestClassLoader)加载,所以达到了目的某种程度上讲,这样也算破坏了双亲委派模型,破坏的是
AppClassLoader和ExtClassLoader的双亲委派模型,但是由于没有覆写loadClass方法,所以本质逻辑上还是双亲委派,只不过父级直接变为了BootstrapClassLoader。
Class.forName和ClassLoader.loadClass区别
-
java.lang.Class.forName会调用到forName0方法,第二个参数initialize = true,意为会进行类初始化(<cinit>())操作。 -
java.lang.ClassLoader.loadClass会调用到 protected 修饰的loadClass(String name, boolean resolve),第2个参数resolve=false,意为不进行类的解析操作,也就不会进行类初始化,包括静态变量的初始化、静态代码块的运行,都不会进行。
线程上下文类加载器
概念:Java提供了一种机制,可以在线程中设置一个类加载器,默认是Application ClassLoader,用来加载一些用户自定义的类
SPI机制:当服务的提供者提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。jdk提供服务实现查找的一个工具类:java.util.ServiceLoader。例如JDBC
案例:
// 加载Class到AppClassLoader(系统类加载器),然后注册驱动类
// Class.forName("com.mysql.jdbc.Driver").newInstance();
String url = "jdbc:mysql://localhost:3306/testdb";
// 通过java库获取数据库连接
Connection conn = java.sql.DriverManager.getConnection(url, "name", "password");
无需手动
Class.forName就可以实例化MySQL驱动,是如何做到的?这里利用了SPI机制,调用
java.sql.DriverManager的静态方法会初始化该类,该类的静态代码块中初始化了数据库驱动Driver
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
private static void loadInitialDrivers() {
String drivers;
try {
// 先读取系统属性(暂时不管)
drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
------------------------------------------------------------------------------------
// 通过SPI加载驱动类(只关心这部分)
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try{
while(driversIterator.hasNext()) {
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
});
------------------------------------------------------------------------------------
// 继续加载系统属性中的驱动类(暂时不管)
if (drivers == null || drivers.equals("")) {
return;
}
String[] driversList = drivers.split(":");
println("number of Drivers:" + driversList.length);
for (String aDriver : driversList) {
try {
println("DriverManager.Initialize: loading " + aDriver);
// 使用AppClassloader加载
Class.forName(aDriver, true,
ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
}
ServiceLoader做了哪些事情?
- 根据泛型
<Driver>获取到全路径类名java.sql.Driver- 读取META-INF/services下的
java.sql.Driver文件中的用户自定义全路径类名- 使用线程上下文类加载器加载
com.mysql.jdbc.Driver- 加载
com.mysql.jdbc.Driver会触发类的初始化,执行com.mysql.jdbc.Driver的静态代码块,静态代码块中将实例对象放入java.sql.DriverManager中
破坏双亲委派模型
java.sql.DriverManager是由启动类加载器加载的,ServiceLoader通过线程上下文类加载器加载了第三方的类库(mysql的驱动),这样一来,是**由高层的类加载器加载不了的类,调用低层的类加载器来加载**,与双亲委派模式不符。
在SPI中,为什么使用线程上下文类加载器而不是系统类加载器
Application ClassLoader?个人理解:上下文类加载器是java提供的一个功能,可以在线程中设置类加载器,不同的第三方类库,用到的类加载器也不同,所以到底这个类库的类由什么加载器加载,完全由类库自己决定,把什么类加载器放到线程中,
serviceloader只负责拿出来直接用,这个决定权在于第三方本身,这样才是符合SPI理念的,而不是统一用系统类加载器统一加载
运行期优化
方法内联
将方法代码原封不动复制到调用处
private static int square(final int i) {
return i * i;
}
System.out.println(square(9));
优化后:
System.out.println(9 * 9);
逃逸分析
简单理解为,如果该对象在方法中被定义,没有被方法外的代码引用,那么该对象就成为没有逃逸,没有逃逸的对象会进行优化处理
// Point类是一个只有x,y两个变量的普通bean
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
第一步,将**构造方法内联**
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc();// 堆中分配p对象的伪代码,知道意思就行
p.x = xx;
p.y = 42;
return p.x;
}
第二步,经过逃逸分析发现,该对象不会逃逸出方法,所以进行**标量替换**
public int test(int x) {
int xx = x + 2;
int px = xx;
int py = 42;
return px;
}
第三步,通过数据流分析,发现py值对方法没有任何影响,所以进行**无效代码消除**
public int test(int x) {
return x + 2;
}