(转)JVM原理浅析

摘要

根据多篇文章结合记录了JVM大概的执行过程。

Java程序的执行过程

图片来自Java虚拟机工作原理详解

1.写好Java代码,保存在硬盘中;

2.执行javac YourClassName.java,此时Java代码被编译成字节码(.class);

3.执行java YourClassName,就完成了上面红色方框中的工作。(ClassLoader从硬盘中读取class文件,载入到系统分配给JVM的内存区域–运行数据区(Runtime Data Areas),然后执行引擎解释或者编译类文件,转化成特定的CPU的机器码,CPU执行机器码,至此完成整个过程。)


图片来自一图读懂JVM架构解析

类加载器子系统(Class Loader Subsystem)

1.加载

Android中的类加载器和Java中的稍有不同,但工作机制是一样的,都使用双亲委派模式

2.链接

可以看到,在类加载完毕之后,JVM继续完成Java类的链接(Linking)的工作。

Java类的链接指的是将Java类的二进制代码合并到JVM的运行状态之中的过程。在链接之前,这个类必须被成功加载。
  1. Verify(验证):验证是用来确保Java类的二进制表示在结构上是完全正确的。如果验证过程出现错误的话,会抛出java.lang.VerifyError错误。

  2. Prepare(准备):准备过程则是创建Java类中的静态域,并将这些域的值设为默认值。准备过程并不会执行代码;对类的成员变量分配空间。虽然有初始值,但这个时候不会对他们进行初始化(因为这里不会执行任何 Java 代码);值得注意的是,JVM 可能会在这个时期给一些有助于程序运行效率提高的数据结构分配空间

  3. Resolve(解析):有符号存储器引用都将替换为来自方法区(Method Area)的原始引用。符号引用是只包含语义信息,不涉及具体实现的;而解析(resolve)过后的直接引用则是与具体实现息息相关的。解析的过程就是确保这些被引用的类能被正确的找到。解析的过程可能会导致其它的Java类被加载。

另外

第3步Resolve(解析)与之后的类初始化是不冲突的,并非一定要所有的解析结束以后才执行类的初始化。不同的JVM实现不同。

1
2
3
4
5
6
public class LinkTest {
public static void main(String[] args) {
ToBeLinked toBeLinked = null;
System.out.println("Test link.");
}
}

类LinkTest引用了类ToBeLinked,但是并没有真正使用它,只是声明了一个变量,并没有创建该类的实例或是访问其中的静态域。如果把编译好的ToBeLinked的Java字节代码删除之后,再运行LinkTest,程序不会抛出错误。这是因为ToBeLinked类没有被真正用到。链接策略使得ToBeLinked类不会被加载,因此也不会发现ToBeLinked的Java字节代码实际上是不存在的。如果把代码改成ToBeLinked toBeLinked = new ToBeLinked();之后,再按照相同的方法运行,就会抛出异常了。因为这个时候ToBeLinked这个类被真正使用到了,会需要加载这个类。

以上Test说明:Resolve(解析)这一步永远延迟,程序不会显示的在第一次判断出错误时抛出错误,而会在对应的类第一次主动使用的时候抛出错误。

3.初始化

类的初始化也是延迟的,直到类第一次被主动使用(active use),JVM 才会初始化类。



初始化过程的主要操作是执行静态代码块和初始化静态域。

在一个类被初始化之前,它的直接父类也需要被初始化。

但是,一个接口的初始化,不会引起其父接口的初始化。

在初始化的时候,会按照源代码中从上到下的顺序依次执行静态代码块和初始化静态域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class StaticTest {
public static int X = 10;
public static void main(String[] args) {
System.out.println(Y); //输出60
}
static {
X = 30;
}
public static int Y = X * 2;
}

类的初始化分两步:

  1. 如果基类没有被初始化,初始化基类。
  2. 有类构造函数,则执行类构造函数。

Java类和接口的初始化只有在特定的时机才会发生,这些时机包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
创建一个Java类的实例。如
MyClass obj = new MyClass()
调用一个Java类中的静态方法。如
MyClass.sayHello()
给Java类或接口中声明的静态域赋值。如
MyClass.value = 10
访问Java类或接口中声明的静态域,并且该域不是常值变量。如
int value = MyClass.value
通过Java反射API
MyClass.class.newInstance()

需要注意的是,当访问一个Java类或接口中的静态域的时候,只有真正声明这个域的类或接口才会被初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class B {
static int value = 100;
static {
System.out.println("Class B is initialized."); //输出
}
}
class A extends B {
static {
System.out.println("Class A is initialized."); //不会输出
}
}
public class InitTest {
public static void main(String[] args) {
System.out.println(A.value); //输出100
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class B {
static {
System.out.println("Class B is initialized."); //会输出
}
}
class A extends B {
static int value = 100;
static {
System.out.println("Class A is initialized."); //会输出
}
}
public class InitTest {
public static void main(String[] args) {
System.out.println(A.value); //输出100
}
}

运行时数据区(Runtime Data Area)

1.方法区(Method Area)

是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

Java虚拟机规范把方法区描述为堆得一个逻辑部分。

异常:当方法区无法满足内存分配需求时,将抛出OOM。

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这个部分内容将在类加载后进入方法区的运行时常量池中存放。

Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,用的比较多的是String类的intern()方法。

2.堆区域

是线程共享的内存区域,在虚拟机启动时创建。

所有的对象实例以及数组都要在堆上分配,随着JIT编译器的发展与逃逸分析技术逐渐成熟,所有对象都分配在堆上也渐渐变得不是那么绝对。

Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。

异常:如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OOM。

3.栈区域

线程私有,它的生命周期与线程相同。为虚拟机执行Java方法服务。

虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

我们常说的栈内存(Stack)是现在虚拟机栈中局部变量表的部分。

局部变量表存放了编译器可知的各种基本数据类型、对象引用(可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置) 和returnAddress类型(指向一条字节码指令的地址)。

局部变量表的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

4.PC寄存器

线程私有。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OOM情况的区域。

Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。

5.本地方法栈

线程私有,为虚拟机使用到的Native方法服务。与虚拟机栈(3.栈区域)发挥的作用相似,抛出的异常一样。

执行引擎(Execution Engine)

类加载器将字节码载入内存之后,执行引擎以Java 字节码指令为单元,读取Java字节码。问题是,现在的java字节码机器是读不懂的,因此还必须想办法将字节码转化成平台相关的机器码。这个过程可以由解释器(Interpreter)来执行,也可以由即时编译器(JIT Compiler)来完成。

分配给运行时数据区的字节码将由执行引擎执行,执行引擎读取字节码并逐个执行。

(1) 解释器:解释器更快地解释字节码,但执行缓慢。解释器的缺点是当一个方法被调用多次时,每次都需要一个新的解释;

(2) JIT编译器:JIT编译器消除了解释器的缺点。执行引擎将在转换字节码时使用解释器的帮助,但是当它发现重复的代码时,将使用JIT编译器,它编译整个字节码并将其更改为本地代码。这个本地代码将直接用于重复的方法调用,这提高了系统的性能。JIT的构成组件为:

  • 中间代码生成器(Intermediate Code Generator):生成中间代码
  • 代码优化器(Code Optimizer):负责优化上面生成的中间代码
  • 目标代码生成器(Target Code Generator):负责生成机器代码或本地代码
  • 分析器(Profiler):一个特殊组件,负责查找热点,即该方法是否被多次调用;

(3) 垃圾收集器(Garbage Collector):收集和删除未引用的对象。可以通过调用“System.gc()”触发垃圾收集,但不能保证执行。JVM的垃圾回收对象是已创建的对象。

(4) Java本机接口(JNI):JNI将与本机方法库进行交互,并提供执行引擎所需的本机库。

(5) 本地方法库(Native Method Libraries):它是执行引擎所需的本机库的集合。

参考

  1. Java类的加载、链接和初始化
  2. 一图读懂JVM架构解析
  3. Java虚拟机工作原理详解
  4. 《深入理解JVM虚拟机》