【读书笔记】深入理解Java虚拟机(周志明)(3)第三部分 虚拟机执行子系统(4)第四部分 程序编译与代码优化

文章说明

本文是《深入理解Java虚拟机(周志明)》这本书的重点摘要。

本笔记仅作为复习,不过多的对内容进行讲解。

本笔记按照书的目录进行,如遇到需要细看的,可以到书中找对应内容。

本笔记并不是按照书中原话进行摘要,而是根据自己的理解使用大白话进行记录,同时进行了少部分扩展。如有错误欢迎指出。

由于内容较多,一共分为三篇:

篇幅链接
深入理解Java虚拟机(周志明)(1)第一部分 走进Java(2)第二部分 自动内存管理机制https://blog.csdn.net/zhaohongfei_358/article/details/134927759
深入理解Java虚拟机(周志明)(3)第三部分 虚拟机执行子系统(4)第四部分 程序编译与代码优化https://blog.csdn.net/zhaohongfei_358/article/details/135067398
深入理解Java虚拟机(周志明)(5)第五部分 高效并发https://blog.csdn.net/zhaohongfei_358/article/details/135111650

第三部分 虚拟机执行子系统

第6章 类文件结构

6.1 概述

无重点

6.2 无关性的基石

Java虚拟机只和字节码(也就是class文件)打交道。因此,JVM可以运行其他语言,例如:Groovy、Jython、Scala、Kotlin等。

它们都是先将各自的代码编译成class文件,然后交给JVM运行即可。

在这里插入图片描述

这也是JVM的语言无关性

而字节码可以拿到任何平台运行,例如Windows、Mac、Linux等,只要该平台有JVM即可。这是Java语言的平台无关性,即“一次编译,处处运行”。

6.3 Class类文件的结构

无重点

第6章后续讲解了如何看懂字节码,这部分比较高级,且大多数人用不到,感兴趣可以自行阅读。

第7章 虚拟机类加载机制

7.1 概述

虚拟机把class文件记载到内存,并进行校验、解析、初始化等,最终形成可以被虚拟机直接使用的java类,这就是虚拟机的类加载机制。

class文件不一定非要是文件,从网络或其他地方加载的二进制流也可以。

7.2 类加载的时机

类的生命周期:

在这里插入图片描述

加载、连接的发生时机:各JVM实现自行决定

初始化的发生时机:当需要用到该类时(包括访问其属性和方法),主要场景:

  1. 首次new对象时
  2. 调用静态属性或方法时
  3. 对类进行反射时
  4. 该类作为main入口

7.3 类加载过程

类加载包括如下动作:

  • 加载:执行过程:① 读取类的二进制字节流;② 将静态存储结构(类的方法等)存到方法区;③ 生成该类对应的java.lang.Class对象。(注意:加载过程和后面的验证、准备等动作是交叉进行的,并不是整个做完加载才进行后续验证)
  • 验证:验证字节码是否符合JVM规范。包括:
    • ① 文件格式验证;
    • ② 元数据验证:是否符合Java规范,例如:是否继承了final类,继承接口的类是否还有未实现的方法等);
    • ③ 字节码验证:验证语义的是否合法、是否符合逻辑。例如:类型转换是否有效、是否有危害虚拟机安全的事件等。
    • ④ 符合引用验证:验证符号引用的正确性。例如:是否访问了一个其他类的private方法。
  • 准备:为类的静态变量分配内存(存在方法区),并附上“0”的初始值。(真正的默认值赋值是在初始化阶段做的)。
  • 解析:将符号引用转为直接引用
  • 初始化:从上到下执行给static字段赋值和执行static静态代码块。

7.4 类加载器

类加载可以允许用户在运行时加载类。用户可以实现自己的类加载器。

7.4.1 类与类加载器

一个类可以被多个类加载器加载,每个类加载器有自己的独立的类名称空间。这也意味着两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。

7.4.2 双亲委派模型

Java提供了三种类加载器:

  • 启动类加载器(Bootstrap ClassLoader):虚拟机用的,用于加载<JAVA_HOME>/lib下的类,用户无法直接使用。
  • 扩展类加载器(Extension ClassLoader):用户可以使用,用来加载<JAVA_HOME>/lib/ext中的类。
  • 应用程序类加载器(Application ClassLoader):用户可以使用,用来加载用户自己编写的类。

类加载器的双亲委派模型:下游的类加载应该将类加载动作尽可能的委派给父类,如果父类不能加载(搜索范围内找不到该类),那么子类再做加载工作。(这里上下游并不是继承关系,而是组合关系)。这么做的目的是:一个类尽可能被一个类加载器加载,否则就会出现上面提到的“同一个class文件存在两个不相等的类”的问题。

在这里插入图片描述

7.4.3 破坏双亲委派模型

双亲委派模型只是一个建议,并不强制。

有些情况确实无法遵守双亲委派模型。例如:

  • 基础类需要调用外部类:例如JDBC等。通常基础类(jre中的类)是由BootstrapClassLoader加载的,然而,对于JDBC这个基础类,它需要调用JDBC实现类,而实现类又是由ApplicationClassLoader加载的。因此为了让JDBC基础类可以调用实现类,就不得不让JDBC基础类使用ApplicationClassLoader来加载。这就违反了上面说的双亲委派模型。
  • 热部署:例如OSGi实现的热部署中,就没有按照双亲委派模型做。(具体细节请参考原文)

第8章 虚拟机字节码执行引擎

8.1 概述

无重点

8.2 运行时栈帧结构

程序运行时,每当进入一个新方法,就会在“栈”中创建一个“栈帧(Stack Frame)”,并放置在栈的最顶层。也就说,栈中最顶层的栈帧存储就是当前运行的方法的数据。

在这里插入图片描述

一个栈帧存储了如下数据:

  • 局部变量表(Local Variable Table):用于存放方法参数和方法内部定义的局部变量。注意:每个方法需要使用多大的局部变量表在编译时就会确定下来。
  • 操作数栈(Operand Stack):当方法内部进行+-x÷时,用于存放操作数的。例如:执行1+2的步骤就是,先1,2,3分别入栈,执行+操作,从栈顶取出1,2进行相加操作。(感兴趣可以百度“计算器栈实现”)
  • 动态连接(Dynamic Linking):将方法中的符号引用转为直接引用。即记录了该方法中调用的其他方法的实际地址。例如:当前的A方法中调用了B方法和C方法,那么A方法的动态链接就会存储B方法和C方法所在的实际地址是多少。
  • 返回地址(Return Address):存放了“当方法退出后,下一条指令的执行地址”。① 若方法正常return的退出,则执行下一条指令。② 若方法抛出异常的退出,则由异常表决定下一条指令执行什么。

8.3 方法调用

在Java中,由于部分方法(重写或重载的方法)具体调用哪个方法是在运行时决定的。因此,方法调用是指:确定应该调用哪个方法。

8.3.1 解析

部分方法调用在编译期就可以唯一确定,这类方法的调用称为解析(Resolution)。

符合“编译期可知,运行期不变”要求的方法,主要包括静态方法私有方法两大类。

8.3.2 分派

对于多态方法(重载和重写),虚拟机确定其调用有两种方式:静态分派动态分派


静态分派:对于静态方法的重载多态,会在编译期给调用的参数确定一个静态类型,以确定一个确定的方法。

样例:

public class Test {

    static abstract class Human {}

    static class Man extends Human {}

    static class Woman extends Human {}

    public static void sayHello(Human human) {
        System.out.println("Hello, human");
    }

    public static void sayHello(Man man) {
        System.out.println("Hello, man");
    }

    public static void sayHello(Woman woman) {
        System.out.println("Hello, woman");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        sayHello(man);
        sayHello(woman);
    }
}

输出:

Hello, human
Hello, human

上述代码中的Human称为静态类型(Static Type)(也称为外观类型(Apparent Type)),Man则称为实际类型(Actual Type)

在编译期,静态类型已经可以确定,因此代码编译时该方法的调用也选择sayHello(Human human)进行调用,而不理会它的实际类型。

也就是说,对于静态分派,要调用哪个方法在编译期就已经确定下来了。


动态分派:如果是非静态方法的重写,那就需要在运行期动态确定应该调用哪个方法。

样例:

public class Test2 {

    static abstract class Human {
        void sayHello() {
            System.out.println("Hello, human");
        }
    }

    static class Man extends Human {
        @Override
        void sayHello() {
            System.out.println("Hello, man");
        }
    }

    static class Woman extends Human {
        @Override
        void sayHello() {
            System.out.println("Hello, woman");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
    }
}

输出:

Hello, man
Hello, woman

由于调用的是非静态方法,因此会在运行期来确定这两个对象实际是什么类型,然后调用其对应的方法。

8.3.3 动态类型语言支持

动态语言:在运行期对类型进行检查,例如:Javascript,python等。例如,js一个变量可以是任何类型 var a = new Object(); a=3;,但Java就不行。优点:动态语言灵活性高,开发效率高;缺点:错误不容易在编译期发现,导致代码稳定性差,扩展性也差。

静态语言:在编译期对类型进行检查,例如:Java,C++等。例如:java中Object a = new Object(); a=3 就是非法的。优点:代码稳定性好,很多错误在编译期就可以被发现。缺点:代码不够灵活和简洁。

Java1.7之后,在虚拟机层面对动态语言类型进行了支持,可以使用java.lang.invoke包来实现。

8.4 基于栈的字节码解释执行引擎

无重点(较高级)。

第9章 类加载及执行子系统的案例与实战

9.1 概述

虽然类加载大部分过程用户无法通过Java控制(都是由JVM自行完成的),但字节码生成类加载器用户可以操作,利用这两点就可以玩出很多花样。

9.2 案例分析

9.2.1 Tomcat:正统的类加载器结构

多个应用程序会部署在一个Tomcat的服务器上(不过现在都用Springboot了,都是一个程序一个tomcat)。

如果Tomcat只使用一个类加载器可能会引发:两个程序相同路径且相同名称类只加载了一份,但两个类代码又不一样出现问题。

如果Tomcat为每一个应用程序使用完全独立的类加载又会引发:两个程序都使用了Spring3.0,如果将Spring的类加载两份,又太浪费内存资源。

因此,Tomcat采用的方案如下(非常符合上面提到的“双亲委派模型”):

在这里插入图片描述

灰色部分为Java提供的类加载器,白色部分为Tomcat自己实现的类加载器。

即:对于公共类(例如:Spring等框架的类),都使用公共的CommonClassLoader。而对应用程序自己的类,都使用各自独立的WebAppClassLoader,这样就完美解决了上面两个问题。

9.2.2 OSGi:灵活的类加载器结构

OSGi中,没有使用双亲委派模型的“树状结构”,而是采用了更复杂的“网状结构”。

其将代码分成了各个模块(Bundle),每个Bundle有自己的类加载器,Bundle之间会互相依赖。

例如:

在这里插入图片描述

例如,该例子中,BundleB模块就依赖BundleA和BundleC中的代码。

9.2.3 字节码生成技术与动态代理的实现

在Spring中有大量的代理类,其可以在原类发放的执行前后增加一些逻辑。

使用Java生成代理类的简单样例:

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class DynamicProxyTest {

    interface IHello {
        void sayHello();
    }

    static class Hello implements IHello {

        @Override
        public void sayHello() {
            System.out.println("hello world");
        }
    }

    // 定义动态代理类
    static class DynamicProxy implements InvocationHandler {

        Object originalObj; // 原对象

        Object bind(Object originalObj) {
            this.originalObj = originalObj;
            // 返回代理类
            return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(),
                    originalObj.getClass().getInterfaces(),
                    this); 
        }

        // 使用代理类时,执行的其实是这个方法
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            System.out.print("welcome! "); // 在原来的方法前面增加逻辑
            return method.invoke(originalObj, args);  // 执行原始方法
        }
    }

    public static void main(String[] args) {
        IHello hello = (IHello) new DynamicProxy().bind(new Hello());  // 给Hello绑定代理类
        hello.sayHello();
    }

}

输出:

welcome! hello world
9.2.4 Retrotranslator:跨越JDK版本

Java逆向移植(Java Backporting Tools):将高版本JDK编写的代码放到低版本的JDK环境下部署。

Retrotranslator技术就是Java逆向移植工具中较为出色的一个。

9.3 实战:自己动手实现远程执行功能

无重点,感兴趣可以自己看原文。

第四部分 程序编译与代码优化

第10章 早期(编译期)优化

10.1 概述

编译期优化就是在编译期做的优化操作,例如很多Java新特性都是靠编译期优化实现的,其底层的JVM并没有做任何改变。

10.2 Javac编译器

Javac编译器是用Java实现的。

原文讲解了调试方法和编译过程。

10.3 Java语法糖的味道

10.3.1 泛型与类型擦除

Java中的泛型只存在编译前,用于对类型进行约束(同时也方便了程序员,不用老是强制类型转换了),使代码更加安全,避免运行时产生类型转换错误

但是,在编译后泛型会被擦除

例如:

编译前代码:

List<String> list = new ArrayList<>();
list.add("hello");
String str = list.get(0);

编译后代码:

List list = new ArrayList();
list.add("hello");
String str = (String)list.get(0);

编译后泛型被擦除了。

因此,下面这段代码是无法编译:

public class GenericTypesTest {

    public static void method(List<String> list) {}  // 编译报错,报存在相同的方法

    public static void method(List<Integer> list) {}
}
10.3.2 自动装箱、拆箱与遍历循环

Java的每一个基本数据类型对应了一个包装类,例如:int对应Integer

当给包装类进行赋值时可以使用基本类型,即编译器会自动将基本数据类型转为包装类,称为“自动装箱”。例如:Integer a = 1;

包装类对象可以像基本数据类型那样使用,例如四则运算。此时会自动将包装类型转为基本数据类型,称为“自动拆箱”。

包装类比较时,遵循如下规则:① 包装类==包装类,引用比较。② 包装类==基本数据类型,值比较。

例如:

Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == 3); // true 值比较
System.out.println(c == d); // true 引用比较(但由于小于255,因此都是一个引用)
System.out.println(e == f); // false 引用比较。不是同一对象
System.out.println(c == (a + b)); // true 值比价,因为a+b后被自动拆箱成了int
System.out.println(c.equals(a + b)); // true 值比较。
System.out.println(g == (a + b));  // true  值比较。(a+b)隐含个一个自动转long
System.out.println(g.equals(a + b)); // Long不能和int比较,因此为false
10.3.3 条件编译

编译器对if和循环做了优化。

例如:

int a = 0;
if (true) { a = 1; } 
else { a = 2; }

编译后:

int a = 1;

在例如:

while (false) {}  // 编译报错,报“语句不可达”。

10.4 实战:插入式注解处理器

无重点

该节实战内容:对Javac进行改造,增加了一个对驼峰式命名的校验,若不符合,则编译失败。

第11章 晚期(运行期)优化

11.1 概述

JVM运行代码通常都是依赖解释器。

但,JVM中还有一个即时编译器(Just In Time Compiler,JIT),简称为JIT编译器。

它的作用是:将“热点代码”(也就是运行频繁的代码)编译成本地机器码,并进行进一步优化。

本章就是讲这个的。

11.2 HotSpot虚拟机内的即时编译器

无重点

11.3 编译优化技术

11.3.1 优化技术概览

优化可能会对你的并发结果产生影响,因此了解怎么优化的还是有必要的。

对于下面这段代码的最后四行,会进行四步优化

static class B {
    int value;

    final int get() {
        return value;
    }
}

public void foo() {
    int y, z, sum;
    B b = new B();

    y = b.get();
    // ... do something...
    z = b.get();
    sum = y + z;
}

方法内联优化,优化后:

...
y = b.value;  // 这里被优化了
// ... do something...
z = b.value;  // 这里被优化了
sum = y + z;
...

公共子表达式消除(Common Subexpression Elimination) 优化:

y = b.value;
// ... do something...  // 假设这里没有对b.value进行修改
z = y;  // 这里被优化了
sum = y + z;

复写传播(Copy Propagation) 优化:

y = b.value;
// ... do something... 
y = y;  // 这里被优化了,消除了z
sum = y + y; // 这里被优化了

无用代码消除(Dead Code Elimination) 优化:

y = b.value;
// ... do something... 
// y = y;  // 这行被删除了
sum = y + y;
11.3.2 公共子表达式消除

公共子表达式消除:若一个计算表达式中出现了公共的表达式,则不会重复计算。

例如:

int d = (c*b) * 12 + a + (a + b*c)

会被优化成:

int E = b*c ; // 这里只是这样写便于理解,但实际并不会真的声明E
int d = E * 12 + a + (a + E)// 即 对于公共表达式“b*c”不会被重复计算两次

// 甚至部分JVM还会进一步优化成:
int d = E * 13 + a * 2;
11.3.3 数组边界检查消除

Java在使用数据取值时,JVM会判断数组是否越界,若越界则报错。但如果每次都检查,那么性能就难免受影响。因此,如果编译时能够确定下来这段不会越界,那么就会不进行越界检查。

11.3.4 方法内联

方法内联:如果编译期可以根据上下文确定调用的方法,JVM有可能会直接把该方法的内容内联到调用的地方,即不真正的调用方法。

例如:

优化前:

public static void sayHello(String name) {
	System.out.println("Hello, " + name);
}

public static void test() {
	String name = "张三";
	sayHello(name);
	// ...
}

优化后:

```java
public static void sayHello(String name) {
	System.out.println("Hello, " + name);
}

public static void test() {
	String name = "张三";
	System.out.println("Hello, " + name); // 这里被优化了
	// ...
}
11.3.5 逃逸分析

逃逸分析:一种分析手段,为其他优化提供依据。主要就是用来分析一个变量或对象是不是只在某个作用域下有效,这样就可以逃脱掉一些处理,进而优化速度或内存。

主要场景:

  • 栈上分配(Stack Allocation):正常来说,对象是在堆上分配的。但如果逃逸分析发现某个对象一定只是在该方法内使用,不会被其他方法访问,那么这个对象就可以在栈上分配,减缓GC压力。
  • 同步消除(Synchronization Elimination):如果逃逸分析发现某个变量一定是在线程内使用,即不会带来并发问题,那么对该变量的同步锁等措施就可以消除掉。
  • 标量替换(Scalar Replacement):如果逃逸分析发现一个对象不会外部访问,并且对象可以拆分成若干个变量,那么可能就不会创建该对象,而是直接创建成员变量。标量替换的主要用途就是上面的“栈上分配”。例如:
    public void method() {
    	Person p = new Person();
    	p.name = "Amy";
    	System.out.println(p.name);
    }
    
    此时,根据标量替换,就会被优化成:
    public void method() {
    	String name = "Amy"; // 并没有创建Person对象
    	System.out.println(name);
    }
    

11.4 Java与C/C++的编译器对比

JVM的即时编译器和C/C++的静态编译器相比,有如下劣势:

  • 即时编译器由于是在运行时编译,因此会占用用户程序的运行时间。
  • Java是动态的类型安全语言。jvm需要不断的进行空指针、数组是否越界、类型转换是否安全等的检查。
  • 由于多态特性,具体调用哪个方法很多都是运行时决定的。这加大了编译器优化时结合上下文的难度。
  • Java中只有局部变量才是在栈上分,而对象都是在堆上分。而C++则可以自由选择,因此在垃圾收集效率上,C++有明显优势。