【读书笔记】深入理解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实现自行决定
初始化的发生时机:当需要用到该类时(包括访问其属性和方法),主要场景:
- 首次
new
对象时 - 调用静态属性或方法时
- 对类进行反射时
- 该类作为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++有明显优势。