从零开始JVM实战高手,建议收藏,加源妹儿微信 “ ymsdsss”领取整套JVM实战资料和精品视频,关注微信公众号 “疯狂Java程序猿” ,后续会推出JVM,Mybatis,SpringBoot,Redis等等一些列从入门到源码剖析的精品视频和文章。你的鼓励是我最大的动力。
作者:源码时代-Raymon老师
目前市面上已有太多的JVM相关教程和书籍,但是大部分偏理论,比较枯燥难懂,少有结合实际业务开发,站在项目开发的视角下去分析和讲解相关经验的教程;而本套教程会从零开始带着大家一步一步深入了解JVM底层原理,以及结合一些开发中的典型生产环境问题来进行实战剖析,并且几乎采用一步一图的方式进行讲解。
通过核心理论和实战案例的结合,希望能对大家对JVM的理解和应用更上一层楼。
面试需要(中大厂必考核的一项技能),说说你的项目是如何处理JVM GC、OOM等问题…
深入的理解Java这门语言。(万丈高楼地基最重要),Java的 boolean类型在JVM中是如何表现?类路径和类名是否唯一确定一个类?
更好的解决线上排查问题(更好的解决生产线问题), 线上系统跑着跑着突然卡死,FullGC非常频繁,各种GC日志一大堆,无从下手分析,何谈优化,遇到OOM异常直接躺平,求救或跑路
走向高级程序员和架构师的必经之路(底层逻辑),知其然更要知其所以然,透过现象看本质。只有掌握了底层逻辑,只有探寻到万变中的不变,才能动态地、持续地看清事物的本质;可以通过不变的底层逻辑,推演出顺应时势的方法论。
我们的学习路线图就通过从一个类的加载开始,来学习Java是如何将代码运行起来的,由点到面的方式,一步一步深入理解JVM的整体运行机制。
整体内容划分:
我们平时写的Java代码,到底是如何运行起来的?
我们都知道,我们平时创建的一个一个类,在本地磁盘中的文件名后缀就是 .java,比如User.java 、Product.java ,这也叫做源代码文件。这些源代码文件必须经历我们的javac工具进行编译后生成 .class 的字节码文件才能被运行。
那接着我们就要继续思考了:那这些 .class 字节码文件又是如何运行起来的?(这里我们可以借助于DOS窗口执行 java 命令进行启动)
> javac User.java
> java User
输出: hello World…
此时一旦采用 java 命令,实际上就是启动了一个JVM进程,由JVM来负责加载这些字节码文件到内存进行执行。
而将class字节码文件加载到虚拟机的内存,这个过程称为类加载,其中涉及到 【类加载机制】和【类加载器】的概念。
当字节码文件被类加载器加载进入到JVM内存中后,会通过JVM的执行引擎来执行我们内存中对应的类,比如类中的main方法,就会先被执行,而main方法中如果还涉及到其他的对象引用,类加载又会开始加载对应的字节码文件到内存,再由JVM进行调用执行。(如下图)
当我们通过Eclipse或IDEA工具开发完一个完整的项目后,一般都会将项目整体打成一个jar
包或者war
包,然后部署到对应线上服务器进行运行;其实就是将我们写的所有java代码编译成对应的字节码文件后,加上一些项目的资源文件一起打包,部署进服务器比如Tomcat,当我们通过 java -jar
之类的命令就可以运行和执行我们写好的代码。
ok,通过以上的分析,我们先整体对java代码的运行流程做了一个初步的介绍,接下来再深入分析类加载过程又是如何执行的,一步一步深入学习。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为链接(Linking),这7个阶段的发生顺序如图:
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始, 这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。
加载Loading阶段就是JVM第一次去加载和读取对应位置上的文件,上述的整个类加载过程其实都是类加载器(后续讲)在完成。
相当于正式建立了IO通道,在这条通道上,我们需要做上述的一系列事情,比如验证、准备、解析等。
思考:JVM在什么情况下会加载一个类呢?
通过以上的类加载流程,我们可以得知第一个环节就是加载一个类,因此当我们在IDEA中或直接运行某一个类的时候(比如First.java),其实是启动了JVM进程,然后JVM会通过类加载器将这个类的字节码(First.class)加载到内存,然后调用main方法开始执行。如果main方法中的代码是:
public class First {
public static void main(String[] args) {
//创建Second这个类的实例
Second second = new Second();
}
}
JVM这个时候会先检查内存中是否有该类的对象,如果没有会触发类加载器加载磁盘中的Second.class字节码到内存中,如下图:
如何验证类是否已经加载?可以通过如下方式演示:
验证是链接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
主要包括四种验证,文件格式验证(魔数CAFEBABE),元数据验证,字节码验证,符号引用验证。
简单说就是我们的【.class】文件是否符合JVM规范,是否有被篡改,否则JVM是没法执行该字节码文件的。
每个符合规范的Java文件二进制开头应该是对应的魔数CAFEBABE
每个类在被加载到JVM内存前都会还行验证这个过程:
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段
注意事项:static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾,(换句话理解就是:1.7之前存储于方法区,1.7之后存储于堆内存中)
static 变量分配空间和赋值是两个步骤,分配空间(内存分配)在准备阶段完成,赋值在初始化阶段完成
关于准备阶段,还有两个容易产生混淆的概念需要着重强调,首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次是这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value = 123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把 value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。
如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
上面提到在“通常情况”下初始值是零值,那言外之意是相对的会有某些“特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定 的初始值,假设上面类变量value的定义修改为:
public static final int value = 123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置 将value赋值为123。
如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
Java代码在进行Javac编译的时候,在虚拟机加载Class 文件的时候进行动态连接。在Class文件中不会保存各个方法、字段最终 在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号 引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引 用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,
但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚 拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
这三个阶段中,大家最应该关心的核心是:准备阶段,这个阶段是给加载进来的类进行空间的分配,以及static静态变量的空间分配,并且给与初始化值。
这里安利一款查看字节码的IDEA插件:IDEA中安装插件:<查看对应字节码文件二进制>
通过准备阶段类变量已经赋过一次系统要求的初始零值,而初始化阶段就是在给类变量进行赋值操作。
初始化阶段会执行类构造器方法
,该方法不同于类的构造器(是虚拟机视角下的
);该方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
通过代码观察:我们second类中的静态变量b是在
方法中才被真正初始化的–>对应了静态变量的赋值操作
如果我们在second类中再添加一个静态代码块,去修改b的值为11,可以验证静态代码块的操作也是在
中执行的:
注意1: 编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
public class InitDemo {
static {
i = 20;// 给变量赋值可以正常编译通过
//System.out.println(i);// 这句编译器会提示“非法向前引用”
}
public static int i = 10;
}
注意2:父类初始化先执行
方法与类的构造函数(即在虚拟机视角中的实例构造器
方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的 clinit方法执行前,父类的clinit方法已经执行完毕。因此在Java虚拟机中第一个被执行的 clinit 方法的类型肯定是java.lang.Object。
注意3:线程同步 : Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行完毕()方法。如果在一个类的()方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
public class ThreadInitTest {
public static void main(String[] args) {
Runnable script = new Runnable() {
public void run() {
System.out.println(Thread.currentThread() + "start");
DeadLoopClass dlc = new DeadLoopClass();
System.out.println(Thread.currentThread() + " run over");
}
};
Thread thread1 = new Thread(script);
Thread thread2 = new Thread(script);
thread1.start();
thread2.start();
}
}
class DeadLoopClass {
static {
if (true) {
System.out.println(Thread.currentThread() + "init DeadLoopClass");
while (true) {
}
}
}
}
其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程唤醒后则不会再次进入<clinit>()方法。同一个类加载器下,一个类型只会被初始化一次。
总结一句话:一个类如果已经被一个线程加载到内存中,也就代表加载过程中的这些阶段(加载-链接)都已经执行完毕了,那么后续如果有其他线程想要访问对应的字节码对象直接访问即可,不需要再次去加载这个类的字节码文件了。
对应了我们JavaSE中的语法问题:一个类中的静态修饰的内容会随着类的加载而加载,只会被加载一次《这个说话仅仅限于同一个类加载器》
文章对应的配套视频请关注微信公众号:疯狂Java程序猿,