我们为什么要学习JVM呢?我们来看一下官方解释:
Java官网 :https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-1.html#jvms-1.2
Java虚拟机是Java平台的基石,其负责其硬件和操作系统的独立性,其编译的代码很小以及保护用户免受恶意程序攻击的能力。
Java虚拟机是一种抽象计算机,像真正的计算机一样,它有一个指令集并在运行时操作各种内存区域。
Java虚拟机不承担任何特定的实现技术、主机硬件或主机操作系统,它本身并没有被解释。
Java虚拟机不知道Java编程语言,只知道特定的二进制格式,即 class 文件格式, class 文件包含Java虚拟机指令(或字节码)和符号表,以及其他辅助信息。
出于安全考虑,Java虚拟机对 class 文件中的代码施加了强大的语法和结构约束,但是,任何具有可以用有效 class 文件表示的功能的语言都可以由Java虚拟机托管,由通用的、与机器无关的平台吸引,其他语言的实现者可以将Java虚拟机作为其语言的交付工具。
我相信上面这些理由就足以让一部分Java开发者深入的学习JVM了。
但是上述这些理由可能都不足以让我们所有人信服,因为现在大部分的攻城狮以及程序猿可能更关注的是跟我们相关的一些问题,那么在这里我就换一种方式让你接受JVM。
-
如果你在线上遇到了OOM,你是否会束手无策。
-
线上卡顿是否可能是因为频繁 Full GC 造成的。
-
面试经常会问到JVM的一些问题,但是当面试官问到你实际的落地点时,你就会茫然不知所措,没有条理性,或者答非所问。
那么现在你觉得JVM是否应该深入学习呢?
1. JVM是什么?
Java Virtual Machine(Java虚拟机)
Write Once Run Anywhere
2. JDK 、JRE 、JVM
Java官网 :https://docs.oracle.com/javase/8/
Reference -> Developer Guides -> 定位到:https://docs.oracle.com/javase/8/docs/index.html
3. JVM到底该学习什么
或者换句话说,JVM到底从哪边开始学习起?
3.1 源码到类文件
3.1.1 源码demo
public class Person {
private String name = "Jack";
private int age;
private final double salary = 100;
private static String address;
private final static String hobby = "Programming";
private static Object obj = new Object();
public void say() {
System.out.println("person say...");
}
public static int calc(int op1, int op2) {
op1 = 3;
int result = op1 + op2;
Object obj = new Object();
return result;
}
public static void main(String[] args) {
calc(1, 2);
}
}
编译: javac -g:vars Person.java —> Person.class
3.1.2 编译器干了什么呢?
Person.java -> 词法分析器 -> tokens流 -> 语法分析器 -> 语法树/抽象语法树 -> 语义分析器 -> 注解抽象语法树 -> 字节码生成器 -> Person.class文件
由上可知,其实我们的编译器其实做的事情其实就是“对等信息转换”。JAVA文件中的信息其实跟我们Class文件中的信息,其实是一样的。
3.1.3 类文件 (Class文件)
打开我们刚刚编译的class文件,这个我们是完全看不懂的,我们需要将其转换为16进制文件。
3.1.3.1 16进制
将上文中的乱码转换为16进制即可。
3.1.3.2 The ClassFile Structure
官网 : https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html
官网对以下各参数做出了具体的解释,可以参考一下。
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
3.1.4 反编译验证
用javap指令反编译class文件,查看其结构。
编译指令:javap -v -p Person.class
进行反编译之后,查看字节码信息和指令等信息是否有一种感觉?
JVM相对class文件来说可以理解为是操作系统;class文件相对JVM来说可以理解为是汇编语言或者机器语言。
由于内容太多,就不一一截图了,可以自行反编译,然后查看其中的内容。
经过这样一番操作,相信大家就能知道JAVA文件中的信息其实跟我们Class文件中的信息是一样的了。
3.2 类加载机制
类加载机制是指我们将类的字节码文件所包含的数据读入内存,同时我们会生成数据的访问入口的一种特殊机制。那么我们可以得知,类加载的最终产品是数据访问入口。
这个时候,看到这张图,我们应该有一个问题,那就是我们的字节码加载的方式,也就是我们的字节码文件可以用什么方式进行加载呢?
加载.class文件的方式
-
从本地系统中直接加载
-
通过网络下载.class文件
- 典型场景:Web Applet,也就是我们的小程序应用
-
将Java源文件动态编译为.class文件,也就是运行时计算而成
- 典型场景:动态代理技术
-
- 典型场景:典型的防Class文件被反编译的保护措施
好,聊完了这个问题之后,问题接踵而至,我们的类加载的方式已经了解了,那么加载的流程到底是怎样的呢?
3.2.1 装载 (Load)
查找和导入class文件
-
通过一个类的全限定名获取定义此类的二进制字节流(由上可知,我们不一定从字节码文件中获得,还有上述很多种方式)。
那么这个时候我们是不是需要一个工具,来寻找获取我们的二进制字节流?
正好我们的java中恰好有这么一段代码模块。可以实现通过类全名来获取此类的二进制字节流这个动作,并且将这个动作放到java虚拟机外部去实现,以便让应用程序决定如何获取所需要的类,实现这个动作的代码模块成为“类加载器”。
-
在Java堆中生成一个代表这个类的java.lang.class对象,作为对方法区中这些数据的访问入口
获取类的二进制字节流的阶段是我们JAVA程序员最关注的阶段,也是操控性最强的一个阶段。因为这个阶段我们可以对于我们的类加载器进行操作,比如我们想自定义类加载器进行操作用以完成加载,又或者我们想通过 JAVA Agent 来完成我们的字节码增强操作。
在我们的装载阶段完成之后,这个时候在我们的内存当中,我们的运行时数据区的方法区以及堆就已经有数据了。
-
**方法区:**类信息,静态变量,常量
-
**堆:**代表被加载类的java.lang.class对象
需要注意的是即时编译之后的热点代码并不在这个阶段进入方法区。
3.2.2 链接(Link)
3.2.2.1 验证(Verify)
验证只要是为了确保Class文件中的字节流包含的信息完全符合当前虚拟机的要求,并且还要求我们的信息不会危害虚拟机自身的安全,导致虚拟机的崩溃。
-
文件格式验证
验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。这阶段的验证是基于二进制字节流进行的,只有经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面验证都是基于方法区的存储结构进行的。
举例:
-
是否以16进制cafebaby开头
-
版本号是否正确
-
-
元数据验证
对类的元数据信息进行语义校验(其实就是对Java语法校验),保证不存在不符合Java语法规范的元数据信息。
举例:
-
字节码验证
字节码的验证会相对来说较为复杂 。
进行数据流和控制流分析,确定程序语义是合法的、符合逻辑的。对类的方法体进行校验分析,保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
举例:
-
运行检查
-
栈数据类型和操作码操作参数吻合(比如栈空间只有4个字节,但是我们实际需要的远远大于4个字节,那么这个时候这个字节码就是有问题的)
-
跳转指令指向合理的位置
-
-
符号引用验证
这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段),可以看作是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。符号引用验证的目的是确保解析动作能正常执行。
举例:
-
常量池中描述类是否存在
-
访问的方法或者字段是否存在且具有足够的权限
-
但是,我们很多情况下可能认为我们的代码肯定是没问题的,验证的过程完全没必要,那么其实我们可以添加参数
-Xverify:none
取消验证。
3.2.2.2 准备(Prepare)
-
数据类型 零值 int 0 long 0L short (short)0 char \u0000
byte (byte)0 boolean false float 0.0f double 0.0d reference null -
这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;
-
这里不会为实例变量(也就是没加static)分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
public class Demo1 {
private static int i;
public static void main(String[] args) {
// 正常打印出0,因为静态变量i在准备阶段会有默认值0
System.out.println(i);
}
}
public class Demo2 {
public static void main(String[] args) {
// 编译通不过,因为局部变量没有赋值不能被使用
int i;
System.out.println(i);
}
}
进行分配内存的只是包括类变量(静态变量),而不包括实例变量,实例变量是在对象实例化时随着对象一起分配在java堆中的。通常情况下,初始值为零值,假设public static int a=1;那么a在准备阶段过后的初始值为0,不为1,这时候只是开辟了内存空间,并没有运行java代码,a赋值为1的指令是程序被编译后,存放于类构造器()方法之中,所以a被赋值为1是在初始化阶段才会执行。
对于一些特殊情况,如果类字段属性表中存在ConstantValue属性,那在准备阶段变量a就会被初始化为ContstantValue属性所指的值。对于这句话,我们又怎么理解呢?
那么ConstantValue属性到底是干什么的呢?
实际上ConstantValue属性的作用是通知虚拟机自动为静态变量赋值,只有被static修饰的变量才可以使用这项属性。非static类型的变量的赋值是在实例构造器方法中进行的;static类型变量赋值分两种,在类构造其中赋值,或使用ConstantValue属性赋值。
在实际的程序中,我们什么时候才会用到ContstantValue属性呢?
在实际的程序中,只有同时被final和static修饰的字段才有ConstantValue属性,且限于基本类型和String。编译时Javac将会为该常量生成ConstantValue属性,在类加载的准备阶段虚拟机便会根据ConstantValue为常量设置相应的值,如果该变量没有被final修饰,或者并非基本类型及字符串,则选择在类构造器中进行初始化。
那么为什么ConstantValue的属性值只限于基本类型和string?
主要是因为从常量池中只能引用到基本类型和String类型的字面量。
那么这个时候,我们来举个例子:
假设上面的类变量a被定义为: private static final int a = 1;
编译时Javac将会为a生成ConstantValue属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将value赋值为1。我们可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中
3.2.2.3 **解析 **(Resolve)
把类中的符号引用转换为直接引用
符号引用就是一组符号来描述目标,可以是任何字面量。引用的目标并不一定已经加载到了内存中。 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。
直接引用是与虚拟机内存布局实现相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定存在内存中。
对解析结果进行缓存
同一符号引用进行多次解析请求是很常见的,除invokedynamic指令以外,虚拟机实现可以对第一次解析结果进行缓存,来避免解析动作重复进行。无论是否真正执行了多次解析动作,虚拟机需要保证的是在同一个实体中,如果一个引用符号之前已经被成功解析过,那么后续的引用解析请求就应当一直成功;同样的,如果第一次解析失败,那么其他指令对这个符号的解析请求也应该收到相同的异常。
inDy(invokedynamic)是 java 7 引入的一条新的虚拟机指令,这是自 1.0 以来第一次引入新的虚拟机指令。到了 java 8 这条指令才第一次在 java 应用,用在 lambda 表达式中。 indy 与其他 invoke 指令不同的是它允许由应用级的代码来决定方法解析。
3.2.3 初始化 (Initialize)
初始化阶段是执行类构造器()方法的过程。
或者讲得通俗易懂些:
在准备阶段,类变量已赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源,比如赋值。
在Java中对类变量进行初始值设定有两种方式:
按照程序员的逻辑,你必须把静态变量定义在静态代码块的前面。因为两个的执行是会根据代码编写的顺序来决定的,顺序搞错了可能会影响你的业务代码。
JVM初始化步骤:
3.2.4 使用
那么这个时候我们去思考一个问题,我们的初始化过程什么时候会被触发执行呢?或者换句话说类初始化时机是什么呢?
3.2.4.1 主动引用
只有当对类的主动使用的时候才会导致类的初始化,类的主动使用有六种:
-
创建类的实例,也就是new的方式
-
访问某个类或接口的静态变量,或者对该静态变量赋值
-
反射(如 Class.forName(“com.demo.Test”) )
-
初始化某个类的子类,则其父类也会被初始化
-
Java虚拟机启动时被标明为启动类的类,直接使用 java.exe 命令来运行某个主类
3.2.4.2 被动引用
-
定义类数组,不会引起类的初始化。
-
引用类的static final常量,不会引起类的初始化(如果只有static修饰,还是会引起该类初始化的)。
3.2.5 卸载
在类使用完之后,如果满足下面的情况,类就会被卸载:
-
该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。
-
加载该类的ClassLoader已经被回收。
-
该类对应的java.lang.class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机本身会始终引用这些类加载器,而这些类加载器则会始终引用它们所加载的类的Class对象,因此这些Class对象始终是可触及的。
如果以上三个条件全部满足,jvm就会在方法区垃圾回收的时候对类进行卸载,类的卸载过程其实就是在方法区中清空类信息,java类的整个生命周期就结束了。但是一般情况下启动类加载器加载的类不会被卸载,而我们的其他两种基础类型的类加载器只有在极少数情况下才会被卸载。
3.3 **类加载器(**ClassLoader)
3.3.1 什么是类加载器?
-
负责读取 Java 字节代码,并转换成 java.lang.class 类的一个实例的代码模块。
-
类加载器除了用于加载类外,还可用于确定类在Java虚拟机中的唯一性。
一个类在同一个类加载器中具有唯一性(Uniqueness),而不同类加载器中是允许同名类存在的,这里的同名是指全限定名相同。但是在整个JVM里,纵然全限定名相同,若类加载器不同,则仍然不算作是同一个类,无法通过 instanceOf 、equals 等方式的校验。
-
Bootstrap ClassLoader
负责加载$JAVA_HOME中 jre/lib/rt.jar 里所有的class或Xbootclassoath选项指定的jar包。由 C++实现,不是ClassLoader子类。
-
Extension ClassLoader
负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar 或 -Djava.ext.dirs 指定目录下的jar包。
-
App ClassLoader
-
Custom ClassLoader
通过java.lang.classLoader的子类自定义加载class,属于应用程序根据自身需要自定义的 ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader。
3.3.2 为什么我们的类加载器要分层?
在老版本的JVM中,只有一个类加载器,就是现在的“Bootstrap”类加载器。也就是根类加载器。但是这样会出现一个问题:
假如用户调用他编写的java.lang.String类。理论上该类可以访问和改变java.lang包下其他类的默认访问修饰符的属性和方法的能力。也就是说,我们其他的类使用String时也会调用这个类,因为只有一个类加载器,我无法判定到底加载哪个。因为Java语言本身并没有阻止这种行为,所以会出现问题。
这个时候,我们就想到,可不可以使用不同级别的类加载器来对我们的信任级别做一个区分呢?
比如用三种基础的类加载器做为我们的三种不同的信任级别。最可信的级别是java核心API类。然后是安装的拓展类,最后才是在类路径中的类(属于你本机的类)。
所以,我们三种基础的类加载器由此而生。但是这是我们开发人员的视角。
3.3.3 JVM类加载机制的三种方式
-
全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
例如,系统类加载器AppClassLoader加载入口类(含有main方法的类)时,会把main方法所依赖的类及引用的类也载入,依此类推。“全盘负责”机制也可称为当前类加载器负责机制。显然,入口类所依赖的类及引用的类的当前类加载器就是入口类的类加载器。以上步骤只是调用了ClassLoader.loadClass(name)方法,并没有真正定义类。真正加载class字节码文件生成Class对象由“双亲委派”机制完成。
-
父类委托,“双亲委派”是指子类加载器如果没有加载过该目标类,就先委托父类加载器加载该目标类,只有在父类加载器找不到字节码文件的情况下才从自己的类路径中查找并装载目标类。
父类委托别名就叫双亲委派机制。
“双亲委派”机制加载Class的具体过程是:
-
ClassLoader先判断该Class是否已加载,如果已加载,则返回Class对象;如果没有则委托给父类加载器。
-
依此类推,直到始祖类加载器(引用类加载器)。
-
始祖类加载器判断是否加载过该Class,如果已加载,则返回Class对象;如果没有则尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,则委托给始祖类加载器的子类加载器。
-
始祖类加载器的子类加载器尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,则委托给始祖类加载器的孙类加载器。
-
依此类推,直到源ClassLoader。
-
源ClassLoader尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,源ClassLoader不会再委托其子类加载器,而是抛出异常。
“双亲委派”机制只是Java推荐的机制,并不是强制的机制。
我们可以继承java.lang.classLoader类,实现自己的类加载器。如果想保持双亲委派模型,就应该重写findClass(name)方法;如果想破坏双亲委派模型,可以重写loadClass(name)方法。
-
-
缓存机制,缓存机制将会保证所有加载过的Class都将在内存中缓存,当程序中需要使用某个Class时,类加载器先从内存的缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效。对于一个类加载器实例来说,相同全名的类只加载一次,即loadClass方法不会被重复调用。
而这里我们JDK8使用的是直接内存,所以我们会用到直接内存进行缓存。这也就是我们的类变量为什么只会被初始化一次的由来。
3.3.4 如何打破双亲委派
双亲委派这个模型并不是强制模型,而且会带来一些些的问题。就比如java.sql.Driver这个东西。JDK只能提供一个规范接口,而不能提供实现。提供实现的是实际的数据库提供商。提供商的库总不能放JDK目录里吧。
所以java想到了几种办法可以用来打破我们的双亲委派。
**SPI :**比如Java从1.6搞出了SPI就是为了优雅的解决这类问题——JDK提供接口,供应商提供服务。编程人员编码时面向接口编程,然后JDK能够自动找到合适的实现,岂不是很爽?
Java 在核心类库中定义了许多接口,并且还给出了针对这些接口的调用逻辑,然而并未给出实现。开发者要做的就是定制一个实现类,在 meta-inf/services 中注册实现类信息,以供核心类库使用。比如JDBC中的DriverManager。
**Osgi:**比如我们的JAVA程序员更加追求程序的动态性,比如代码热部署,代码热替换。也就是就是机器不用重启,只要部署上就能用。Osgi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块都有一个自己的类加载器,当需要更换一个程序模块时,就把程序模块连同类加载器一起换掉以实现代码的热替换。
3.3.5 自定义类加载器
public class MyClassLoader extends ClassLoader {
private String root;
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] loadClassData(String className) {
String fileName = root + File.separatorChar + className.replace('.', File.separatorChar) + ".class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printstacktrace();
}
return null;
}
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public static void main(String[] args) {
MyClassLoader classLoader = new MyClassLoader();
//classLoader.setRoot("D:\\cc\\code\\java-jvm\\src\\main\\java");
classLoader.setRoot("D:\\");
Class<?> testClass = null;
try {
//testClass = classLoader.loadClass("com.demo.Demo");
testClass = classLoader.loadClass("Demo");
System.out.println(testClass);
Object object = testClass.newInstance();
System.out.println(object.getClass().getClassLoader());
} catch (ClassNotFoundException e) {
e.printstacktrace();
} catch (InstantiationException e) {
e.printstacktrace();
} catch (illegalaccessexception e) {
e.printstacktrace();
}
}
}
自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。由于这里只是演示,我并未对class文件进行加密,因此没有解密的过程。这里有几点需要注意:
-
这里传递的文件名需要是类的全限定性名称,即 Test 格式的,因为 defineClass 方法是按这种格式进行处理的。
如果没有全限定名,那么我们需要做的事情就是将类的全路径加载进去,而我们的setRoot就是前缀地址 setRoot + loadClass的路径就是文件的绝对路径
-
最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。
-
这类Test 类本身可以被 AppClassLoader 类加载,因此我们不能把 Test.class 放在类路径下。否则,由于双亲委托机制的存在,会直接导致该类由 AppClassLoader 加载,而不会通过我们自定义类加载器来加载。
如果我们将测试类放在类路径之下,那么我们将会通过AppClassLoader加载 :
所以这个时候我们需要将测试类移到其他目录中,同时注意的是需要注意这个类,如果上面有package引入的话回演示失败。
然后对该类进行编译。
这个时候我们修改一下路径测试一下即可发现这次使用的是我们的自定义加载器。
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。