JVM虚拟机基本原理

88 阅读38分钟

JVM虚拟机基本原理

一、运行时数据区

image-20230406141744420

image-20230407145914030

说明:在JDK 1.8 中加入了元数据区的概念,将原来保存在永久代中的**运行时常量池和类常量池都包括其中,但不包含字符串常量池,字符串常量池存放到了堆中**,相当于元数据区和堆共同瓜分了原来的永久代

1、程序计数器

①作用

记住下一条JVM指令的执行地址

②特点
  • 线程私有
  • 不存在内存溢出

2、虚拟机栈

①作用
  • Java方法执行的内存模型
  • 每个栈由栈帧(Frame)组成,对应着每次方法调用时占用的内存
  • 每个线程只有一个活动栈帧,对应着当前正在执行的方法

问题辨析:

  1. 垃圾回收是否设计虚拟机栈:不涉及
  2. 栈内存分配越大越好吗:-Xss size(-Xss256k)来指定栈大小,同物理内存下,如果栈内存分配越大,可创建的线程数就会越少
  3. 方法内的局部变量是否线程安全:虚拟机栈是线程私有的
②异常场景
  • 当线程请求的栈深度大于虚拟机所允许的深度,就会抛出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

image-20230406152027453

# 第一次执行,还未创建arr对象,堆内存的使用空间在6m左右
jmap -heap 31060

image-20230406152103439

# 第二次执行,已经创建了10m的arr对象,堆内存的使用空间增加了10m
jmap -heap 31060

image-20230406152306443

# 第三次执行,arr的引用指向null,执行过了GC,16m的内存已经被垃圾回收了
jmap -heap 31060

image-20230406152404322

方式二:**jconsole**动态查看堆内存使用情况(动态查看,但没有转储(dump)功能)

image-20230406153236662

image-20230406153109765

方式三:**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

image-20230406160658442

image-20230406160949066

image-20230406160332802

将堆内存dump出来,发现其中有一个大对象arrayList,里面每个bigObj对象都有10m左右,真相大白

5、方法区

①作用

用来存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

说明:HotSpot虚拟机在**Java6之前,方法区在逻辑上作为堆的一部分,实现方式是永久代,意思是使用和堆一样的垃圾回收工具方便管理。Java8的方法区已经从堆内存中移出,使用本地内存存储类信息等数据,并改名为元空间**。

为什么废除永久代?Oracle为什么要做这样的改进呢?

  1. 容易内存溢出:在原来的永久代划分中,每当一个类初次被加载的时候,它的元数据都会放到永久代中。但是永久代的内存空间也是有大小限制的,如果加载的类太多,很有可能导致永久代内存溢出
  2. 大小无法确定永久代大小也不容易确定,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等但是PermSize指定太小又很容易造成永久代内存溢出
  3. 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);
        }
    }
}

image-20230406181802790

说明:元空间默认是操作系统内存,通常情况下不会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

image-20230406212334538

image-20230406212623783

作用:常量池就是存在于**字节码文件中的一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量**等信息

②运行时常量池

作用:常量池是.class文件中的,当该类被加载,它的常量池信息就会被放入运行时常量池,并把里面的符号地址变为真实地址。

7、字符串常量池

①作用

字符串常量池是运行时常量池中的一部分,它被用来存储字符串对象,这些对象在编译期间就被确定了。对于相同的字符串常量,只会在常量池中存储一份,也就是说,Java中的字符串常量是唯一的。

工作原理:本质上是一个**hashTable**,且不能扩容,大小可以在虚拟机参数中指定

当一个Java程序需要使用某个字符串常量时,首先会在字符串常量池中查找该常量。如果该常量已经存在于字符串常量池中,则直接返回该常量的引用;如果该常量不存在于字符串常量池中,则将该常量添加到字符串常量池中,并返回该常量的引用。

②特点

在Java中,字符串常量池的存在可以帮助**减少内存的使用提高程序的性能。当程序需要大量使用字符串时,尽量使用字符串常量,以避免重复创建**相同的字符串对象,从而减少内存的使用。

③工作原理案例解析
public class MetaspaceTest {
    public static void main(String[] args) {
        String a = "a";
        String b = "b";
        String ab = "ab";
    }
}

反编译后结果:

image-20230407104813175

案例中,字符串a、b、c是如何被加载进入字符串常量池中的?

  1. 字符串a、b、c是在编译期就确定下来的字符串,通过反编译字节码文件,可以看到,首先存在于常量池中
  2. 当字节码文件被运行时,这些字符串被加载到运行时常量池中,这时这三个字符串只是常量池中的符号,还没有变成Java的字符串对象
  3. 当运行到这行代码时(懒惰机制):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

image-20230407113313895

通过查看反编译字节码文件可以得出:

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:

image-20230407114424780

断点2:

image-20230407114515313

断点3:

image-20230407114540210

说明:

  1. 延迟加载:只有执行到这一行时,才会被定义为Java对象,并且放入StringTable中
  2. 当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");
    }
}

image-20230407142323046

需要注意的是: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的位置

image-20230407150308942

Java6及之前,StringTable是放在永久代的运行时常量池中的,Java8的元数据区和堆代替了原有的永久代,StringTable被放在了堆中。这样优化的好处是什么呢

原来的劣势:

  1. 旧的JVM实现中,在进行**fullGC**时,才会对永久代进行StringTable的垃圾回收,使得StringTable的垃圾回收时间较晚,且效率低下。
  2. 在应用程序中会存在大量字符串常量,如果垃圾回收不及时,则容易产生永久代的内存溢出。

优化后的好处:

而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);
        }
    }
}

image-20230407160318823

image-20230407155935037

-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

image-20230407161113316

image-20230407161308335

image-20230407161433732

该程序是循环10次读取48万的字符串,并将其添加到list中,如果没有进行入池操作,如图二所示,String对象占34%的内存,实例数达到480万左右,进行入池操作后,如图3所示,内存占用只有21%,实例数只有64万左右。

入池操作减少了重复的字符串对象,节约了内存空间,提高程序性能

8、直接内存
工作原理

image-20230407162838005

普通IO操作读取磁盘文件的流程:

  • 将磁盘文件读取至系统缓存区
  • 再由系统缓存区读取至Java堆内存,Java程序从堆内存中读取并处理后续操作

弊端:

  • 系统内存和Java堆内存都有一个缓存区,数据需要复制两份,效率低

image-20230407162817425

NIO操作读取磁盘文件流程:

  • 在操作系统划出一块缓冲区,该缓冲区叫做直接内存
  • 直接内存区域系统和JVM都可以直接操作
  • 将磁盘文件读取至直接内存中,Java程序可以直接访问并处理后续操作

好处:

  • 比普通IO操作减少了一次缓冲区间的复制操作
特点
  1. 常见于NIO操作,用于数据缓冲区
  2. 分配回收成本高,但读写性能高
  3. 不受JVM垃圾回收管理
  4. 会出现OOM
内存划分释放机制
  • 使用Unsafe对象完成直接内存的分配回收,并且回收需要主动调用freeMemory方法
  • ByteBuffer对象被垃圾回收时,JVM的守护线程ReferenceHandler检测到后就会执行freeMemory方法来释放直接内存

通常情况下,JVM调优时会增加参数**-XX:+DisableExplicitGC**来禁用程序中使用代码显式地进行GC(使得System.gc()无效),这种情况下,直接内存只能等到JVM自动进行垃圾回收时才会被释放,可能会导致直接内存长期占用大量内存而出现OOM的风险。

正确姿势:

手动调用freeMemory方法进行释放

二、垃圾回收机制

1、如何判断对象可以回收

引用计数法

原理:对象增加一个引用,引用数+1,减少一个引用,引用数-1,当引用数为0时,则判定可以被回收

弊端:循环引用

image-20230407171333869

可达性分析算法

原理:扫描堆中对象,是否以一系列GC Root对象为起点的引用链找到该对象,如果不能,则可以被回收

GC Roots对象:

image-20230407174639582

  • 系统类加载器中的核心对象
  • 本地方法栈中引用的对象
  • 活动线程中使用的对象
  • 同步锁持有的对象
四种引用
引用概念引用的对象何时被垃圾回收
强引用new关键字赋值操作的引用GC Roots不再关联
软引用有用,但非必须的对象(缓存等)垃圾回收完还没有足够的内存时,会被垃圾回收掉
弱引用非必须对象,比软引用更弱垃圾回收完无论有无足够内存,都会被垃圾回收掉
虚引用最弱的引用,不会对其引用的对象的生命周期产生影响对象被收集器回收时收到一个通知
终结器引用当对象没有被强引用关联时,虚拟机会把该对象用终结器引用关联GC时,终结器引用入队,由Finalizer线程通过找到被引用的对象并调用它的finalize()方法,下一次GC时才能回收被引用对象
引用使用场景
软引用用于实现缓存功能,在内存不足的时候,JVM可以回收软引用对象,从而释放一些内存,避免OutOfMemoryError的出现。
弱引用适用于一些不必要的缓存对象,当JVM发现存在弱引用对象时,会立即回收这些对象,释放内存。
虚引用虚引用一般用于作为一个对象销毁的监控机制,可以在一个对象销毁后,做一些对象销毁后的后续处理工作。一个典型案例是直接内存的使用:创建ByteBuffer对象时,会创建一个虚引用cleaner指向创建的直接内存块,当ByteBuffer没有强引用要被垃圾回收时,虚引用cleaner会被加入到引用队列中,当守护线程检测到队列中有cleaner虚引用时,就会调用clean方法,释放掉申请的直接内存块

2、垃圾回收算法

标记清除

image-20230408213936304

将没有GC引用的对象进行标记,然后进行清除。

优势:效率高

劣势:产生内存碎片

标记整理

image-20230408213836718

将没有GC Root引用的对象标记,将可用的对象整理到一起,剩余的空间进行清除

优势:没有内存碎片

劣势:降低了效率

复制

image-20230408215017056

image-20230408215056104

image-20230408215116653

两块内存,只使用一块,三个步骤:

  1. 将可用的对象转移到另一块并整理
  2. 将原来那块内存清理
  3. 将两块内存交换一下位置

优势:没有内存碎片

劣势:占用双倍内存空间

3、分代垃圾回收

image-20230410134013748

  • 对象首先分配在伊甸园(Eden区)
  • 新生代内存不足时,发生minor GC,Eden和from区存活的对象复制到to区,对象的年龄+1,并且交换空间
  • minor GC会触发stop the world,暂停其他用户线程,等GC结束后,才恢复其他线程
  • 当对象年龄超过阈值,会晋升老年代,最大寿命是15
  • 当老年代内存不足时,会先尝试触发minor GC,如果内存还是不足,则会触发full GC,stop the world的时间更长

疑问:

  1. 对象在Eden区分配,是如何到from区的?
  2. 新生代内存不足时,Eden区和from区幸存的对象能够放到to区吗,to的空间够吗?
  3. 如果一个对象很大,新生代放不下的情况下,会怎么样?

解释:

  1. Eden区内存不足时,发生minor GC,幸存的对象被复制到了to区,然后from和to区交换空间,所以幸存的对象到了from区,并且年龄+1
  2. 新生代的对象朝生夕死,绝大多数对象都是要被垃圾回收的,其实能够幸存的对象是非常少量的,所以to区是肯定够用的。Eden和from、to分别占用内存的比例为8:1:1
  3. 如果大对象新生代放不下的话,就会直接进入老年代,而不用关注15年龄

image-20230410152035105

4、垃圾回收器

串行
特点
  • 单线程
  • 堆内存较小,适合个人电脑
开启参数

-XX:+UseSerialGC=Serial + SerialOld

Serial负责新生代的垃圾回收,采用复制算法

Serial Old负责老年代的垃圾回收,采用标记整理算法

image-20230410153740597

吞吐量优先
特点
  • 多线程
  • 堆内存较大、多核CPU
  • 让单位时间内,STW的时间最短
  • 多个垃圾回收器并行执行,但是垃圾回收期间不允许用户线程执行
开启参数

-XX:+UseParallelGC

-XX:+UseParallelOldGC

Java8默认开启,如果设置其中一个参数,另一个会自动开启

image-20230410160615607

多线程,线程数和CPU核数相关,可以通过-XX:ParallelGCThreads=n指定。

响应优先
特点
  • 多线程
  • 堆内存较大、多核CPU
  • 尽可能让单词STW时间最短
  • 多个垃圾回收线程和用户线程并发执行
开启参数

-XX:+UseConcMarkSweepGC:简称CMS,工作在老年代,并发的基于标记清除算法的垃圾回收器

-XX:+UseParNewGC:新生代垃圾回收器,和CMS配合使用

当CMS出现并发失败时,会退化为一个单线程的Serial0ld

image-20230410203328799

-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:常量池载入运行时常量池

image-20230416135351783

step2:方法字节码载入方法区

image-20230416135530480

step3:main线程开始执行,分配栈帧内存

(stack=2,locals=4)

image-20230416135719464

step4:执行引擎开始执行字节码

bipush 10

  • 将一个 byte 压入操作数栈(其长度会补齐 4 个字节),类似的指令还有
  • sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
  • ldc 将一个 int 压入操作数栈
  • ldc2_w 将一个 long 压入操作数栈(分两次压入,因为 long 是 8 个字节)
  • 这里小的数字都是和字节码指令存在一起,超过 short 范围的数字存入了常量池

image-20230416140035358

istore_1

  • 将操作数栈顶数据弹出,存入局部变量表的 slot 1

image-20230416140052419

ldc #3

  • 从常量池加载 #3 数据到操作数栈

注意 Short.MAX_VALUE 是 32767,所以 32768 = Short.MAX_VALUE + 1 实际是在编译期间计算好的

image-20230416140200705

istore_2

  • 将操作数栈顶数据弹出,放入局部表量表slot2

image-20230416140230928

iload_1、iload_2:将局部变量表的两个数据压入操作数栈

image-20230416140251106

image-20230416140315925

iadd:将操作数栈的两个数据执行add操作

image-20230416140336144

istore_3:将结果从操作数栈中弹出,放入局部表量表slot3

image-20230416140358846

getstatic #4:从堆中找到System.out对象的引用压入操作数栈

image-20230416140421532

image-20230416140432870

iload_3:将局部变量表的slot3的数据压入操作数栈

image-20230416140449509

invokevirtual #5

  • 找到常量池 #5 项
  • 定位到方法区 java/io/PrintStream.println:(I)V 方法
  • 生成新的栈帧(分配 locals、stack等)
  • 传递参数,执行新栈帧中的字节码

image-20230416140527606

  • 执行完毕,弹出栈帧
  • 清除 main 操作数栈内容

image-20230416140548766

return

  • 完成 main 方法调用,弹出 main 栈帧
  • 程序结束

总结

整个代码运行流程中,运行时常量池负责加载编译期间已经确定的一些常量,方法区存放类的成员变量以及编译后的代码指令,堆负责存放执行期间产生的对象,虚拟机栈分配栈帧,用来执行方法区的代码指令,包括分配一些操作数栈、局部表量表等,真正负责执行的是执行引擎。

类加载阶段

加载

将类的字节码载入方法区中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:

  • _java_mirror 即 java 的类镜像,例如对 String 来说,就是 String.class,作用是把 klass 暴露给 java 使用
  • _super 即父类
  • _fields 即成员变量
  • _methods 即方法
  • _constants 即常量池
  • _class_loader 即类加载器
  • _vtable 虚方法表
  • _itable 接口方法表

image-20230415130617376

说明:存在堆中的对象,对象头存有类地址,这里的类地址指的是堆中存的**_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 ClassLoaderJAVA_HOME/jre/libC++编写的,无法直接访问,显示为null
Extension ClassLoaderJAVA_HOME/jre/lib/ext上级为 Bootstrap
Application ClassLoaderclasspath上级为 Extension
自定义类加载器自定义上级为 Application
双亲委派

image-20230416181322241

如何理解双亲委派?

双亲委派模型指的是**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);
    }
}

image-20230416220621723

说明:

  • 构造器传入一个父级类加载器,传入当前类的类加载器,默认是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);
    }

image-20230416221703430

说明:系统报错: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()));
    }

image-20230416222500564

说明:这样一来,由父级类加载器null(BootstrapClassLoader)来加载Object,且父级无法加载Demo,交由下级类加载器(TestClassLoader)加载,所以达到了目的

某种程度上讲,这样也算破坏了双亲委派模型,破坏的是AppClassLoaderExtClassLoader的双亲委派模型,但是由于没有覆写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做了哪些事情?

  1. 根据泛型<Driver>获取到全路径类名java.sql.Driver
  2. 读取META-INF/services下的java.sql.Driver文件中的用户自定义全路径类名
  3. 使用线程上下文类加载器加载com.mysql.jdbc.Driver
  4. 加载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;
}