首先,虽然是超级详细且容易理解,但是jvm的工作原理绝对也不是一两句话能说明白的,为了方便理解我已经将复杂的部分用白话描述,相信大家能更容易明白

一、编译

从这张图来看我们一点一点整理

首先一个人写了一个MyClass.java,我们不管里面内容如何,他是一个java文件,里面没有什么语法错误,就是一个简单的java文件。

要想一个.java文件被计算机识别并执行,我们首先要把这个.java给翻译成.class(字节码文件),这将交给java编译器来做。

现在我们得到了.class文件,当然这还是不够的,计算机只能识别二进制的目标代码。

之后JRE(Java运行环境)做了红框里的事情,复杂的地方也就是这里,我们一点一点来看。

二、类加载

首先,我们需要一个类加载器去加载这个.class到JRE的内存区域–运行数据区,那么这个类加载器是什么?他又是怎样加载.class的呢

我们只需要记住一段话:从底层到高层检查,从高层到底层加载。什么意思?看图说话。

左图表现的是加载器的层级结构,右图则就是我所说的从底层到高层检查,从高层到底层加载

我们用简洁的语言来描述一下:

现在你有MyClass.class这个字节码文件,当JRE运行的时候,如果没有指定这个.class继承ClassLoader(这里我们这个类不指定通过Custom ClassLoader加载),则首先会从APP(System)ClassLoader命名空间中查找是否已经加载这个.class到内存区域,如果已经加载了,那就已经在内存区存在这个类了,就不用继续检查和加载了,直接使用这个类就行了,而如果没有加载,则继续向上到Extension ClassLoader检查,同样已经加载就使用,没有加载就继续检查,直到检查完BootStrap ClassLoader,发现这个类一直没有被加载,然后就从Bootstrap ClassLoader往下加载,如果命名空间中有这个类,则直接加载,否则继续向下Extension ClassLoader的命名空间看是否有这个类的定义,有则加载,无则继续。最后我们到了App ClassLoader中,发现其中classpath指定目录下有这个类(不懂得可以研究一下classpath目录是怎么生成的就懂了),然后就加载他。

废话有点多,但是把每个执行细节都描述了一下。现在我们的.class文件就已经以字节码的形势加载到了JRE内存区域。

接下来,JVM继续工作,咱们还是看图

框图中各个步骤简单介绍如下:

Loading:文章前面介绍的类加载,将文件系统中的Class文件载入到JVM内存(运行数据区域)

Verifying:检查载入的类文件是否符合Java规范和虚拟机规范。

Preparing:为这个类分配所需要的内存,确定这个类的属性、方法等所需的数据结构。

Resolving:将该类常量池中的符号引用都改变为直接引用。

Initialing:初始化类的局部变量,为静态域赋值,同时执行静态初始化块。

三、运行数据区域

那么有人就要问了,你光说加载到内存区域,怎样加载的,内存区域是个什么鬼?

别急,我们先了解一下什么叫做运行数据区域,来看这张图

解释一下:当运行一个JVM示例时,系统将分配给它一块内存区域(大小可以设置的),这一内存区域由JVM自己来管理。从这一块内存中分出一块用来存储一些运行数据,例如创建的对象,传递给方法的参数,局部变量,返回值等等。分出来的这一块就称为运行数据区域。运行数据区域可以划分为6大块:Java栈、程序计数寄存器(PC寄存器)、本地方法栈、Java堆、方法区域、运行常量池。运行常量池本应该属于方法区,但是由于其重要性,JVM规范将其独立出来说明。其中,前面3各区域(PC寄存器、Java栈、本地方法栈)是每个线程独自拥有的,后三者则是整个JVM实例中的所有线程共有的。

接下来我们介绍一下这六大块:

先看每个线程都拥有的:PC计数器、JAVA栈、本地方法栈

一、PC计数器

每一个线程都拥有一个PC计数器,当线程启动(start)时,PC计数器被创建,这个计数器存放当前正在被执行的字节码指令(JVM指令)的地址。说白了,PC计数器就是用来定位当前代码运行到哪里的,就像是调试,一步一步,PC计数器就记录了当前正在运行哪个.class的哪一句代码。

二、Java栈:

这里比较重要,咱们还是看图

这个栈中存放着一系列的栈帧(Stack Frame),VM只能进行压入(Push)和弹出(Pop)栈帧这两种操作。每当调用一个方法时,JVM就往栈里压入一个栈帧,方法结束返回时弹出栈帧。如果方法执行时出现异常,可以调用printStackTrace来查看当前栈帧的情况。

下面我们来看看栈帧中放着的三个东西:

1.本地变量数组(Local Variable Array),故名思议,以数组的方式按顺序存放一个方法中的变量,举个例子:

运行到了一个方法public void method(int a, double b, Object c) {  … }

则本地变量数组中存放的值为0:this    1,2:b    3:c

其中double类型的b需要两个连续的索引。取值的时候,取出的是2这个索引中的值。如果是静态方法,则数组第0个不存放this引用,而是直接存储传递的参数。

2.操作数栈(Operand Stack),操作数栈其实是一个数据临时存储区,存放一些中间变量,举个例子,当执行int a = 90int b = 10int c = a + b;  时,先将90压入操作数栈,再将10压入操作数栈,然后90、10出栈做加法后压入操作数栈100,最后100出栈。说白了就是一个过渡区。

3.方法所属类的常量池引用(Reference to Constant Pool),当JVM执行到需要常量池的数据时,就是通过这个引用来访问常量池的。栈帧中的数据还要负责处理方法的返回和异常。如果通过return返回,则将该方法的栈帧从Java栈中弹出。如果方法有返回值,则将返回值压入到调用该方法的方法的操作数栈中。

三、本地方法栈

这个栈比较难以理解, 简单地讲,一个本地方法是这样一个方法:该方法的实现由非Java语言实现,比如C语言实现。很多其它的编程语言都有这一机制,比如在C++中,你可以告知C++编译器去调用一个C语言编写的方法。而本地方法栈就是针对这些对应方法的栈。

好了,看完了每个线程都有的部分,咱们最后再来看一下线程共有的内存区域

四、方法区域:

官方资料:方法区域是一个JVM实例中的所有线程共享的,当启动一个JVM实例时,方法区域被创建。它用于存运行放常量池、有关域和方法的信息、静态变量、类和方法的字节码。不同的JVM实现方式在实现方法区域的时候会有所区别。Oracle的HotSpot称之为永久区域(Permanent Area)或者永久代(Permanent Generation)。

五、运行常量池:

这个区域存放类和接口的常量,除此之外,它还存放方法和域的所有引用。当一个方法或者域被引用的时候,JVM就通过运行常量池中的这些引用来查找方法和域在内存中的的实际地址。

六、堆:

这是一个非常重要的区域,堆中存放的是程序创建的对象或者实例。这个区域对JVM的性能影响很大。垃圾回收机制处理的也正是这一块内存区域。

所以,类加载器加载其实就是根据编译后的Class文件,将java字节码载入JVM内存,并完成对运行数据处于的初始化工作,供执行引擎执行。

下面介绍引擎执行的过程

四、执行引擎过程

java字节码机器是读不懂的,必须将字节码转化成平台相关的机器码。这个过程可以由解释器来执行,也可以有即时编译器(JIT Compiler)来完成。

执行过程如下图:

解释器或者编译器将字节码文件进行解析,将其中的代码进行各种处理,最后将得到计算机可以解析的二进制执行代码,现在我们有了计算机可以解析的代码,同时需要操作的变量方法等也已经加载到了内存中,这样我们就可以对这些变量和方法进行操作了。这就是JVM主要的工作原理。

当然,如果研究到底层,包括解释器是怎样解释字节码文件等等,那内容就多了,要学习一下编译原理相关的知识。总之JVM看起来复杂,实际做起来的事情并不复杂,但是深入到底层去研究又变得复杂,大家可以根据需要去学习。

了解JVM的运行机制,对代码流程的书写和内存的优化有很大的帮助,是一个中高级JAVA工程师必备的技能。