一个 java 文件的执行过程
参考地址: https://mp.weixin.qq.com/s/_hyzqUFx1Nu8kQlk7bFyUg
平时我们都使用 idea、eclipse 等软件来编写代码,在编写完之后直接点击运行就可以启动程序了,那么这个过程是怎么样的?
我们编写的 java 文件在由编译器编译后会生成对应的 class 字节码文件, 然后再将 class 字节码文件转给 JVM 。
JVM 会处理解析 class 文件,将其内部设置的类、方法、常量等信息全部提取出来,然后找到 main 方法开始一步一步编译成机器码并执行,中间会根据需要调用前面提取的数据。
那为什么不让 JVM 直接编译 java 文件呢?这样效率不是更高么? 首先要知道 java 之所以强大,原因之一就是 JVM 的强大。
强大之一是 JVM 是 " 跨平台 " 的。无论在哪种操作系统上执行,都可以转成对应的机器语言,不需要担心适配问题。
第二点就是 JVM 是 " 跨语言 " 的,因为 JVM 只认 class 文件,所以其他语言只需要一个编译器编译成 class 文件就可以使用 JVM 来编译执行了。
组件分析
根据上面的说明可以知道 java 程序执行的核心是通过 JVM 来实现的,那么就需要知道 JVM 内部是如何执行的。
JVM 内部可以分为四大部分,运行时数据区域、类加载系统、执行引擎、本地接口和本地方法库。
类加载系统:主要就是指类加载器,用于把 class 数据文件加载到运行时数据区域,然后由数据区域来编译执行。
运行数据区域:搭配执行引擎来编译传来的文件中的代码,然后执行,并且根据需要通过本地方法接口调用本地方法。
执行引擎:主要用于代码的编译和 运行时对象的回收。
本地库接口和本地方法库:提供一些 java 无法实现,需要底层执行调用的方法,是 jvm 访问底层的重要途径。
类加载器
用于进行类的加载。
一般分为启动类加载器、扩展类加载器、应用程序类加载器、自定义类加载器。
图中的从自定义类加载器到启动类加载器一层一层使用箭头连接,这种箭头并不是继承关系,而是上下级关系。
上下级的联系是通过 ClassLoader 抽象类继承过来的 parent 属性设置的。
1、启动类加载器(Bootstrap ClassLoader)(引导类加载器),加载java核心类库(<JAVA_HOME>/jre/lib/rt.jar),
无法被java程序直接引用,是用C++编写的,用来加载其他的类加载器(类加载器本质就是类),是所有加载器的父类。
2、拓展类加载器(Extension ClassLoader),用来加载java的拓展库(<JAVA_HOME>/jre/lib/ext)。
3、系统类加载器(System ClassLoader)(应用程序类加载器),用来加载类路径下的Java类
4、用户自定义类加载器,继承java.lang.ClassLoader类的方式实现。 官方文档中将类加载器分为引导类加载器和自定义类加载器,
这是因为引导类加载器是使用其他语言实现的,而拓展类、系统类、自定义类加载器全部都是通过继承 ClassLoader 抽象类实现的,所以都统一被划分为自定义类加载器。
装载方式
1、隐式装载:由加载器加载。
2、显式装载:自定义加载,比如使用反射Class.forName(类路径),类加载器ClassLoader.getSystemClassLoader().loadClass("test.A");
使用当前进程上下文的使用的类装载Thread.currentThread().getContextClassLoader().loadClass("test.A")。
类加载是动态的,它不会一次性加载所有类然后运行,而是保证程序运行的基础类(核心类库一部分的类)完全加载到JVM中就运行,这是为了节省内存开销。
类加载器的特性
主要包括全盘负责、双亲委托机制、缓存机制、可见性。
1、全盘负责:当一个 Class 类被某个类加载器所加载时,该 Class 所依赖引用的所有 Class 都会由这个加载器负责载入,除非显式的使用另一个 ClassLoader。
(当然只是这个加载器负责,并不一定就是由这个加载器加载,这是由于双亲委托机制的作用)
2、缓存机制:当一个 Class 类加载完毕后,会放入缓存,在其他类需要引用这个类时就会从缓存中直接使用,这也是为什么我们在修改了文件后需要重启服务器才能使修改生效。
3、双亲委托机制:当一个类加载器收到了类加载的请求时,它首先会将这个请求委派给父类,父类不能执行再自己尝试执行,父类如果存在父类,也会委派给父类,
这样传到了启动类加载器加载,当启动类加载器不能读取到类时才会传给子类加载器,然后子类加载器再尝试加载。
好处:1、防止自定义的类篡改核心类库中的代码。自定义的和类路径.类名与核心类库一样的类会委托给启动类加载,启动类加载器会根据包名.类名在内存查看是否已经加载,
那么面对自定义的类启动类加载器会认为已经加载过了。如果是给系统类加载器或者自定义类加载器加载的话可能就会产生多个类名相同的类,那么其他类在调用对应基类的话就会报错。
2、防止同一个类被重复加载。
3、可见性:子类加载器可以访问父类加载器加载的类型,但是反过来是不允许的。不然,因为缺少必要的隔离,我们就没办法利用类加载器去实现容器的逻辑。
解释器和即时编译器(JIT)
主要用于将 Class 数据文件编译成对应的本地机器码执行。
解释器
传统的编译工具,主要分为 字节码解释器 和 模版解释器。字节码解释器 是在执行时通过纯软件代码模拟字节码的执行,效率低下;
模版解释器则是主流使用的解释器,原理是将每一条字节码 和一个模版函数关联,在 Class 字节码转成机器码的过程中会通过对应的模版函数生成对应的机器码,
这样短期来看效率还不错,但是一旦 同一个的字节码被多次执行,那么每次都需要通过模版函数生成机器码,效率十分低下。
即时编译器(JIT)
JIT 的原理是将字节码关联的 模版数据直接转成机器码,然后将机器码缓存起来,后面如果再次执行这个字节码时就直接返回缓存中的机器码,省去了二次执行的时间,
缺点是第一次的转换消耗比较长,所以以单次执行来看,JIT 的效率是不如 解释器的,但是一旦执行的字节码重复数多,JIT 的作用就体现出来了。
HotSpot 中有两个 JIT 编译器,分别是Client Compiler和Server Compiler,但大多数情况下我们简称为C1编译器和C2编译器。C1进行简单的优化,耗时短。
C2进行耗时长的优化,代码执行效率更高。实际中C1和C2共同协作执行的。
实际过程
当虚拟机启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。
并且伴随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为有价值的本地机器指令,以换取更高的程序执行效率。
热点代码探测的方式
1、规定热点阀值。每次方法调用时该方法的调用次数都会+1,当调用次数达到阀值,就会触发JIT编译。热点阀值可通过 -XX:CompileThreshold= 来设定。
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。
当超过一定的时间限度,如果方法的调用次数仍不足以让它提交给JIT编译,那这个方法的调用计数器就会减少一半,这个过程称为方法调用计数器热度的衰减,
而这段时间就称为此方法统计的半衰周期。可以使用-XX:-UseCounterDecay 来关闭热度衰减,也可以使用-XX:CounterHalfLifeTime设置半衰周期的时间。
2、回边计数器。
统计一个方法中循环体代码执行的次数。在字节码中遇到控制流向后跳转的指令称为 “回边”。显然,建立回边计数器统计的目的是为了触发OSR编译(JIT编译)。
运行时数据区域
程序计数器
线程私有,是当前线程执行的字节码指示器,指示字节码的执行顺序。程序计数器的内存是单独的,不会受到其他变量、对象的影响。
所以它不会发生内存溢出。也是JVM唯一 一个没有规定任何 OOM 的区域。也不存在GC。
Java虚拟机栈
线程私有。先进后出,是代码执行的核心位置,一个方法在执行前会生成这个方法对应的栈帧,
栈帧包括局部变量表(保存局部变量)、操作数栈(进行局部变量的操作)、动态链接(其他对象、方法的引用)、方法返回值以及一些附加信息。
然后进行压栈操作,开始方法的执行,如果此方法中调用了其他方法,那么会将调用的这个方法对应的栈帧压入栈,等到这个方法执行完之后,
如果方法包含返回值,将这个返回值返回给上一个方法,然后这个被调用的栈帧出栈,随后继续执行上一个栈帧。
局部变量表
基本存储单元是 slot(变量槽),用于存储各种类型的数据,其中 long 和 double 会占用两个 slot,其他基本数据类型以及对象引用变量占用一个 slot。
这也说明了为什么类方法不能使用 this 而实例方法可以(实例方法会直接在索引为0的位置创建一个 this 参数保存,
所以在实例方法中使用 this 就是直接使用这个参数的)同时局部变量表的槽位是可以重用的,当前一个局部变量失效后,下一个变量使用空出来的位置。
上面这个方法是实例方法,包含this,应该有四个index 槽位,但是因为b是在括号里作用的,出了括号就失效了,所以它的位置(index=3的位置)被新设置的c所占用。
操作数栈
先进后出结构,是当前方法执行的位置,在方法执行时,会根据编译生成的字节码按顺序将要操作的数据从局部变量表中进入入栈,栈中的数据只能从栈顶向下操作,不能跨数据。
比如代码 x=x+1,在执行时会将 x 先压入栈,然后将 1 压入栈,然后读取到 + 的指令,将栈顶的两个数相加,再将加的结果存入局部变量表 x 的位置。
如果调用了其他方法并获取了返回值,那么在调用方法执行完毕后,该方法的返回值会被压入栈顶,然后再进行后续的操作。
栈顶缓存技术
目前只是一种想法,还未实现。因为使用的是栈式架构,所以指令多,又由于操作数是存储在内存中的,所以频繁地读写必然会影响执行速度,
所以提出将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。
虚方法表
因为重写方法都是虚方法,这些方法在编译时期都需要往上寻找直到找到所执行对象的实际类型,然后进行权限验证。
这个寻找的过程是比较耗时的,所以每个类会在方法区创建一个虚方法表来保存这些虚方法的实际入口。
方法返回值
1、正常返回:(boolean、byte、char、short、int)return ; lreturn、freturn、dreturn、areturn(String);return(无返回值)。
2、异常返回:如果发生异常的方法没有捕获异常而是抛给上一级,那么该异常就会被返回给调用该方法的方法去处理。
Java 堆
线程共享。是 Java 虚拟机内存最大的一块,主要用于存储创建的对象。根据对象的寿命、大小等因素将对象存储区域划分分为新生代、老年代。
在 1.7 开始引入了字符串常量池。因为对象的创建销毁是非常频繁的,所以堆是 JVM 中的核心位置之一,也是 OOM 发生的主要位置之一。
本地方法栈与native(本地)方法
本地方法栈(也就是最上面图中的本地接口)是 JVM 与底层交互的接口,用于调用 native 方法。
作用与 Java 虚拟栈差不多,只不过是为 native 方法服务的,是由非 Java 语言编写的。
方法区
和堆一样是线程共享,用于存储已被虚拟机加载的类型信息、常量、静态变量、即使编译器编译后的代码缓存等。
方法区的实现在 1.8 之前是永久代,使用的是 JVM 的内存,在1.8开始实现变成元空间,使用的是本地内存。之所以这样改变,
是因为原来的方法区很容易发生 OOM,因为方法区的类信息被回收的条件非常苛刻,必须满足以下三点:
1、该类的所有对象都被回收;
2、加载该类的类加载器被回收;
3、该类对应的 Class 对象没有在任何地方被引用(无法在任何地方通过反射访问该类的方法)。
关于第三点的 Class 对象,在一个类被加载时,会在堆中创建一个用于用于访问这个类的类信息 Class 对象。
而在成为元空间后,使用的是本地内存,所以方法区发生 OOM 的情况会极大改善。
运行时常量池
当 Class 文件被类加载器加载到 JVM 中时,存储的位置就是在方法区,而在 Class 文件信息中包括着 class 文件的常量池,
当 JVM 开始执行时,就会将文件常量池中的数据加载到 方法区内部的运行时常量池,变成运行时状态,并将符号引用转成直接引用。
符号引用和直接引用:当在调用中调用某个类的类方法、类属性、接口方法、接口属性时,因为在执行前,对应的类、接口都还在 Class 文件常量池中,
没有加载到内存中,所以不能确定这些类、接口加载后的具体位置,这时就需要一种方式来确认位置,通常使用类的全名+属性名/方法名 来唯一标识要调用的方法/属性,
这种标识就是符号引用,等到对应的类加载到内存后,再将这些唯一标识改成在内存中的位置,这种就是直接引用。
字符串常量池
在 JDK 1.7 开始,字符串常量池就由方法区移入了堆中,字符串常量池是专门存放字符串常量的,至于为什么移入堆中,这是因为字符串的创建和对象一样频繁,销毁也就变得尤其频繁,
而方法区的 GC 是伴随着 full gc 的, 因为 full gc 会造成 STW,在 full gc 期间其他程序都会停止,所以都会避免 full gc,而字符串常量池放在方法区中就减少了 字符串被回收的频率,
提高了 OOM 的概率。
类加载
过程
在 Class 数据文件被类加载器加载到 JVM 中到编译执行,中间经历 加载、链接、初始化、使用、卸载,其中链接又分为 验证、准备、解析。
需要注意的是:这些操作阶段不一定要等上个阶段完成后才能进行下一个阶段,解析操作往往在初始化之后再执行。一部分验证和加载同时执行,一部分验证等到解析才会执行。
下面就一个个来说明每一步的操作。
加载
通过类加载器将 Class 数据文件加载到方法区,并且在堆中创建一个 Class 对象用于访问方法区的类数据。
验证:
验证主要用于检验传来的二进制数据格式是否满足加载要求。虽然在 java 文件的编译阶段编译器已经进行了一次检查,但是 JVM 是与前面编译器编译的过程隔开的。
验证主要包括格式验证、语义验证、字节码验证、符号引用验证。
1、格式验证:与加载过程同时进行的。用于检验字节码魔数是否正确、主版本和副版本是否在支持范围内、数据每一项是否有正确的长度等。
2、语义验证:校验不同类之间的关系是否正确,例如是否继承了抽象类但没有实现方法,是否继承了 final 类。
3、字节码验证:最复杂的一个验证。从方法层面验证各个操作是否正确,比如是否会跳转到不存在的指令,函数调用是否传递正确类型的值,变量赋值是否给了正确的类型
4、符号引用验证:发生在解析操作。将符号验证转化为直接引用时,验证符号引用是否能正确使用。
准备
为类属性分配内存并设置零值(这里不包括使用 static final 修饰的属性且赋值的值是一个字符串常量或一个基本数据类型常量或其他不触发方法的情况(也就是过程不会涉及构造器或者其他方法),
因为字符串或者基本数据是常量,在编译时期就会分配地址,准备阶段直接就会显式初始化,而如果赋的值包括方法调用就需要在 <client> 方法里执行)。
如果属性值是常量,那么常量值就会在方法区中分配内存,而如果是对象,那么对象则会在堆中创建;并且实例属性参数也会跟随对象的创建在堆中,
只有静态属性和对应的常量值在方法区中分配内存。而设置的零值是当前类型的默认值,比如 private int a = 2;那么设的零值就是 0, a = 2 是在后面的<client>方法中执行的。
解析
将符号引用转成直接直接引用。符号引用主要包括类或接口、静态属性、类方法和接口方法这四种(都是类数据,在类加载后就能获取的)。初始化
执行静态代码块方法以及静态属性的赋值。会将类中所有的关于类属性的赋值语句以及静态代码块中的语句收集起来集中进 <clinet> 方法中,然后执行。
执行的顺序就是按赋值以及静态代码块的排列顺序执行。虚拟机在在执行 <client>方法时会加锁,使得此方法只会被一个线程加载,
所以我们需要考虑类在加载时会不会发生异常死循环导致此类无法被加载
使用
使用不必多说,就是调用类属性、方法。卸载
上面说过一个类卸载所需要的条件:
1、该类的所有对象都被回收;
2、加载该类的类加载器被回收;
3、该类对应的 Class 对象没有在任何地方被引用(无法在任何地方通过反射访问该类的方法)。那么具体原因是什么?
我们知道,对象被回收的条件是这个对象没有被引用,类也是如此,在类被加载到内存后,它会在堆中创建一个 Class 对象,
并且和加载它的加载器互相关联,也就是图中的 MyClassLoader,而这个对象也和类对应的实例对象所关联,这种关联是无法切断的,
而如果对应的三种变量都没有再引用,那么就相当于这个类信息没有被引用,那么也就可以被回收了。
类被加载的场景
Java 对类的使用方式分为主动使用和被动使用。主动使用会触发类的初始化,被动使用不会(但是还是会触发初始化之前的操作)。
主动使用的场景
1、创建某个类的对象
2、调用某个类的类属性、类方法
3、获取某个类的反射对象
4、初始化子类,如果父类没有初始化,会先触发父类的初始化(不适用接口)
5、如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化。
6、虚拟机启动,调用主方法的类会被初始化
7、初次调用 MethodHanlder 实例时,初始化该 MethodHanlder 指向的方法所在的类。(涉及解析REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄所在的类)
被动使用的场景
1、访问的类属性不是当前类的属性,比如从父类继承而来的或者实现接口得到的,比如
这里只会触发 parent 的初始化,而不会触发 son 类的初始化,而如果 son 重写了属性 a 或者调用的是 son 的另一个属性 b ,那么就会触发 son 类的初始化,
并且因为 son 继承了 parent 类,所以在 son 初始化前还会先初始化 parent。
2、通过数组定义类引用,不会触发此类的初始化(如果数组类型是基本数据类型,那么不需要加载;如果是引用数据类型,那么就进行类的加载,但不会进行初始化操作)
3、调用 static final 修饰的且是常量或者是字符串或是其他没有方法触发的情况,也不会触发初始化操作。
4、调用 ClassLoader 的 loadClass() 方法加载一个类,只会触发加载操作,而不会触发初始化操作
类加载器的拓展
类的唯一性
每个类加载器都有其自己的命名空间,命名空间由该加载器及其所有的父加载器所加载的类组成。在同一个命名空间中,不会出现类的完整名字(包名+类名)相同的两个类。
但在不同的命名空间中,有可能出现完整名字相同的两个类。所以,在比较两个类是否是同一个类的前提是这两个类由同一个类加载器加载,
如果这两个类是由两个类加载器加载的,那么这两个类必然不是同一个类。一个类只能被一个类加载器加载一次,但是可以被多个类加载器加载。
类加载器的主要方法
1、getParent()
返回该类加载器的父类加载器。2、loadClass(name)
加载 name 类,如果找不到该类,就抛出异常。内部的实现是父类委托机制。3、findClass(name)
查找二进制的 name 类,返回该类的实例,这个类是 loadClass 内部调用的一个方法,JDK 维护了一个推荐的重写方法,鼓励我们去重写这个方法来实现对功能的拓展。
JDK 1.2 之前还未引入父类委托机制,所以要拓展就需要去重写 loadClass 方法,1.2 引入父类委托机制后通过重写 findClass 方法来拓展,并且也没有破坏父类委托机制。
4、defineClass(String name, byte[] b,int off, int len)
将字节数组 b 转换为 Class 的实例,off 和 len 参数表示实际 Class 信息在 byte 数组中的位置和长度。其中 b 是ClassLoader 从外部获取的。
这是受保护的方法,只有在自定义的 ClassLoader 子类中使用。一般在 findClass 方法中被调用,在 findClass 方法中先类的字节码数组,然后调用 defineClass 获取类实例返回。
ClassLoader 一些实现类的继承关系
SecureClassLoader 扩展了 ClassLoader,增加一些方法,但是一般我们使用的是其子类 URLClassLoader,URLClassLoader 实现了 ClassLoader 很多抽象方法,如 findClass()、findResource() 。
我们在编写自定义类加载器时,如果没有特别复杂的实现,可以直接继承 URLClassLoader ,这样可以避免自己编写 findClass 以及获取字节流的方式,使自定义类加载更加简洁。
而拓展类加载器与系统类加载器也是继承 URLClassLoader 。
Class.forName 与 ClassLoader.loadClass 的区别
ClassLoader.loadClass 是一个实例方法,该方法将 Class 文件加载到内存中后,只会执行类加载过程的加载、验证、准备、 解析。
初始化等到类的第一次使用时才会执行。Class.forName 是静态方法,该方法在将 Class 文件加载到内存的同时,还会执行类的初始化。
破坏双亲委派机制的三次场景
1、由于双亲委派机制是在 JDK1.2 之后才引入的,而在 Java 的第一个版本就有类加载器的概念以及抽象类 ClassLoader ,所以此时是没有双亲委派机制的,
用户自定义类加载器就是直接重写 loadClass 方法,这也就是破坏了双亲委托机制。
2、第二次是为了弥补双亲委托机制的缺陷,因为双亲委托机制使得父类加载器无法使用子类加载器的类资源,这样对于父类需要调用子类加载器加载的类资源时就无法实现。
为了解决这个问题,引入了线程上下文类加载器(默认为系统类加载器),当需要调用系统类加载器就可以使用这个属性进行加载。
3、IBM 公司设计的代码热部署,使得传统简单的树状继承关系,改成了更为复杂的网状结构,让每个模块都有自己自定义的类加载器
自定义类加载器
好处
1、隔离加载类,创建多个模块空间,确保相互间加载的类不会冲突。
2、修改类加载的方式。某些非必要导入的类可以自定义类加载器在某个事件按需导入。
3、扩展加载器,加载不同位置位置的资源。4、防止源码外泄。在编译时加密。
注意
1、因为同一个类被两个类加载器加载会生成不同的类对象,所以如果两个继承关系的类被两个类加载器加载,那么强制转换类型会报错。所以使用自定义类加载器需要结合场景,不能一味使用。
2、实现时推荐重写 findClass 方法,不破坏双亲委托机制。
沙箱安全机制
Java 沙箱是将 Java 代码限定在 JVM 特定的运行范围中,并且严格限制代码对本地系统资源的访问。防止对本地系统造成破坏。
演变
1、JDK 1.0 时期将执行的 Java 代码分为本地和远程两种,本地代码默认视为可信赖的,而远程代码则看作不受信赖的。
对于信赖的代码,可以访问一切本地资源。而不受信赖的代码,则会受到沙箱的限制,不能访问本地资源。
2、JDK 1.1 时期由于1.0 中对远程代码限制太过激进,导致一些需要访问本地资源的远程代码无法访问,极大影响了程序的可用性,
所以在 1.1 中进行了优化,在前者基础上,增加了 安全策略。允许用户指定代码对本地资源的访问权限。
3、JDK 1.2 时期1.1 中无法解决的是本地代码权限问题,因为本地都是可以访问本地资源的,所以在 1.2 中又引入了 代码签名。
无论是本地代码还是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。
4、JDK 1.6时期也是当前最新的安全策略,相比于前代引入了域的概念。主要升级是将资源的访问进一步划分。
虚拟机会把所有代码加载到系统域或应用域中。系统域是与关键资源交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。
JDK9 的新特性
1、扩展类加载器改名为平台类加载器(platform classloader)。可以通过 ClassLoader 的新方法 getPlatformClassLoader() 来获取。
2、原来的 rt.jar(启动类加载器加载的核心源码)和 tool.jar(Java程序启动所需的 class 目录下的类) 被拆分成数十个 JMOD 文件,Java 类库也被改成可扩展的模式,所以拓展目录也就无需存在了。
3、平台类加载器和应用程序类加载器不再继承 URLClassLoader。现在 三大加载器全部继承于 jdk.internal.loader.BuiltinClassLoader
4、类加载器拥有了 name 属性,可以通过 getName() 获取,平台类加载器的 name 是 platform。应用类加载器的名称是 app。类加载器的名称在调试与类加载器相关的问题时会非常有用。
5、启动类加载器现在是 jvm 内部和 java 类库共同协作的实现的(c++和java,过去只是c++),但是为了与之前的代码兼容,在获取启动类加载器的场景中仍然为 null。
6、委派机制变化。在加载器受到加载请求后,会先判断该类是否属于某个系统模块,如果属于直接将这个请求发给这个模块的类加载器。