浅谈Java虚拟机运行机制(一)
在进入主题内容之前,先和大家分享一首歌曲,来自张晓涵改编的《青花瓷》,小姐姐将戏剧唱腔和现代流行音乐完美结合起来,带来一种不一样的感觉。
楼主编写Java代码也有些许日子了,很多时候停留在业务的增删改查,忘却了底层原理上的东西,这里开始慢慢的拾起来一些,今天先简单了解下Java虚拟机运行Java文件的执行过程,楼主初学,如有哪里不对,请大家不吝赐教!
注意,本文章所用到的JDK环境为1.8,其JVM官方文档说明详见:
https://docs.oracle.com/javase/specs/jvms/se8/html
官方文档其他版本的介绍,详见:
https://docs.oracle.com/javase/specs/index.html
首先,我们来看一段java代码:
这段简单的代码其结果很简单:在短暂的停留200毫秒后,打印输出12
这里细心的朋友可以发现,这段代码,在调用线程休眠的时候,我没有使用它的静态方法,而是用引用在调用,我其实是想做个试验,后面会说到。
当然,我们今天所注意的不是这个结果,而是这几行代码在计算机中是怎么样执行导致最后打印输出了12的
一、Java程序执行过程
这里用图片描述本程序的大致执行过程:
简单对图片做个简述:一个Java源文件,通过一些指令被编译成.class文件,最后在各种操作系统中被对应的Java虚拟机执行。其中编译的环境是JDK,运行时环境为JRE。
这里截取Java官方的一张图例来更加明确的解释编译和运行过程:
从官方图片我们可以看出,JVM虚拟机在执行Java程序的时候,是处于一个JRE环境中,而编译Java源文件过程则是通过JDK去编译的,这里我们也可以清楚的明确JDK、JVM和JRE的区别了,即JDK是包含了JRE的,JRE只能去执行.class文件,而每一个JDK或者JRE也包含了对应的JVM。
这里引用官方的一句话来说明JDK包含了JVM:
The JDK provides one or more implementations of the Java virtual machine (VM):
On platforms typically used for client applications, the JDK comes with a VM implementation called the Java HotSpot Client VM (client VM). The client VM is tuned for reducing start-up time and memory footprint. It can be invoked by using the -client command-line option when launching an application.
On all platforms, the JDK comes with an implementation of the Java virtual machine called the Java HotSpot Server VM (server VM). The server VM is designed for maximum program execution speed. It can be invoked by using the -server command-line option when launching an application
编译Java源文件过程,在这里我们不在过多描述,需要了解的同学,可以下来相互探讨学习下,同时我会在后续发布相关的文章。接下来,我们重点分享下执行.class文件的过程。
二、JVM执行.class文件
JVM,全称是Java Virtual Machine,那么这到底是个啥呢?我的一个理解就是,它也是一个程序,一种可以执行Java二进制文件的程序。所以,我们的JVM也是可以单独下载和安装的,而不同的计算机操作系统,其安装的JVM是不一样的,但是可以执行相同的Java二进制文件,这也就解释了,Java语言的跨平台性,“一次编码,到处使用”。
基于官方对JVM的介绍(https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html,第2章,The Structure of the Java Virtual Machine),我们首先来了解下JVM体系结构:
这里有个小插曲,楼主在刚开始阅读其官方文档的时候,从其文档描述结构来看,起初还以为,运行时常量池(Run-Time Constant Pool)也是运行时数据区单独的一部分,后面仔细看到它的介绍,才发现是方法区(Method Area)的一部分。文档其实也明确说明了运行时常量池的产生(Each run-time constant pool is allocated from the Java Virtual Machine's method area (§2.5.4). The run-time constant pool for a class or interface is constructed when the class or interface is created (§5.3) by the Java Virtual Machine.),所以还是得细心看文档。
接下来,我们看看JVM怎么执行的Java二进制文件的?,文档指出,JVM通过使用类加载器装载class文件开始,创建一个初始类,然后链接初始类,并且调用公共方法类main方法开始,JVM便启动并执行程序了《The Java Virtual Machine starts up by creating an initial class, which is specified in an implementation-dependent manner, using the bootstrap class loader (§5.3.1). The Java Virtual Machine then links the initial class, initializes it, and invokes the public class method void main(String[]). The invocation of this method drives all further execution. Execution of the Java Virtual Machine instructions constituting the main method may cause linking (and consequently creation) of additional classes and interfaces, as well as invocation of additional methods.》。以文章起始的的Java文件为例:
JVM虚拟机执行程序,先通过类加载子系统(class loader)装载.class文件到JVM中,上面说到JVM的启动,这里就直接开始说明JVM执行.class文件了。
计算机在运行程序的时候,首先是分配一个进程去做这件事,一个进程包含了多个线程操作。这里先假定一个线程来执行这个程序。
在这里我们需要提一点东西,上图中黄色区域(堆/方法区)是属于线程共享数据,绿色区域(java虚拟机栈/本地方法栈/程序计数器)属于线程私有数据。即:当某个线程执行的时候,其“《java虚拟机栈》、《本地方法栈》、《程序计数器》”是不存在线程共享的,所以线程安全所出现的情况,就是“《堆》和《方法区》”的数据发生改变,影响到另外的线程执行取值,出现的问题。
在具体说明运行时数据区时,先说明一个概念---栈帧,栈帧:从创建帧的线程的Java虚拟机栈中分配的,即,上图中,在线程执行程序的时候,java虚拟机栈中会分配很多栈帧出来,所以我们姑且认为java虚拟机栈的结构如下:
下面,在线程执行这个程序的时候,会在java虚拟机栈中分配一个栈帧并且压入java虚拟机栈中,以执行main()方法,当main()方法执行过程中,会遇到doFunction()方法,于是在当前线程中会再次分配一个栈帧同样压入java虚拟机中执行doFunction()方法,
用IEDAdebug模式可以看出:
三、JVM指令解析
下面我们来说说每一行代码的执行,在说这个之前,我们先了解下操作指令,操作指令,就是JVM虚拟机能够看懂的指令,我们可以通过反编译.class文件得到,在本例中,.class文件目录下,执行javap -c -p Main.class >main.txt(具体这个命令的用法,参见javap命令,这里不再赘述),得到main.txt文件(同级目录下),截图如下:
图中带黄色底的文字描述是我加上去的,主要用于描述其下面对应指令的实际意义,具体可参考The Java® Virtual Machine Specification说明,第六章,先对此图做一些说明,方便后面的继续。图中每个指令前的数字,就是线程独有的程序计数器,记录程序的每一步执行。指令后面有的地方有个数字,或者#+数字,这个表示classFile中常量池数组的索引。
这里结合上图指令及其意义,我们来把这段代码逐步翻译出来:
前面我们说了,Java虚拟机栈会分配一个main栈帧压入Java虚拟机栈中,此时,Java虚拟机栈中,只包含一个main帧,在编译时期,会分配操作数栈和局部变量的大小和空间,此时Java虚拟机栈的结构如下:
new-->创建一个对象(这里就是创建一个Main对象),并将其引用值压入栈顶,
此时Java虚拟机栈如图:
dup-->复制栈顶数值并将复制值压入栈顶,意思就是复制操作数栈的栈顶数据同时压入栈顶,此时操作数栈中则有2个元素,如图:
invokespecial-->调用超类构造方法,实例初始化方法,私有方法。执行Main的初始化构造函数。此时会弹出栈顶的引用类型去找到初始化方法,并初始化。
iconst_4-->将int型4推送至栈顶,这就是把4压到操作数栈顶中,执行完后,如图:
istore_2-->将栈顶int型数值存入第三个局部变量,同时弹出操作数栈顶的4。这个过程实际就是执行给param赋值4,即完成int param = 4操作:
invokestatic-->调用静态方法,这里就是执行了Thread thread = Thread.currentThread()。返回了当前线程对象。
astore_3-->将栈顶引用型数值存入第四个局部变量,这里其实保存当前线程变量。同时操作数栈顶的引用型数值出栈。见图:
aload_3-->将第四个引用类型局部变量推送至栈顶,这里也就是在操作数栈顶中,压入第四个局部变量
pop-->将栈顶数值弹出(数值不能是long或double类型的)
这里弹出thread对象,因为下一个调用其sleep方法,其实为静态方法,不需要对象,这里也直接将该引用对象弹出。
回到我正文起初的时候说的,这种写法,导致JVM执行的时候,会多2次操作,一次压栈,一次弹出,我把这个写法改成正确写法之后,我们会看见这里就少执行了这2步操作,下图只截取了这里关键的差异部分:
本文章接下来还是按照这个不规范的接着讲:
ldc2_w-->将long或double型常量值从常量池中推送至栈顶(宽索引),这个操作其实就是将200这个数字推送至栈顶,因为我们在调用thread.sleep(200)时,传入了一个200毫秒,这个200,是一个long型,在编译装载的过程中就会在常量池中初始化一个long型的200:
invokestatic-->调用静态方法,这里就是执行线程休眠,thread.sleep(200),传入参数就是上一步操作中压入操作数栈的200l,无返回值入栈。
aload_1-->将第二个引用类型局部变量推送至栈顶
iload_2-->将第三个int型局部变量推送至栈顶
这时候main帧栈结构如下图:
下面我们继续分析doFunction帧中的指令
iconst_3-->将int型3推送至栈顶
istore_2-->将栈顶int型数值存入第三个局部变量
iload_2-->将第三个int型局部变量推送至栈顶
iload_1-->将第二个int型局部变量推送至栈顶
imul-->将栈顶两int型数值相乘并将结果压入栈顶,这里就是先弹出栈顶的4和3,然后相乘,把结果12重新压入栈顶
istore_3-->将栈顶int型数值存入第四个局部变量
iload_3-->将第四个int型局部变量推送至栈顶
invokestatic-->调用静态方法,即执行Method java/lang/String.valueOf:(I)Ljava/lang/String,返回一个String型的12。
areturn-->从当前方法返回对象引用,执行到这里,doFuntion栈帧运行完毕,返回一个String的引用类型值为12
此时doFunction出栈,
返回的引用类型压入到main栈帧操作数栈中
我们来看看此时的Java虚拟机栈
接着执行main帧的指令
astore-->将栈顶引用型数值存入指定局部变量
aload-->将指定的引用类型局部变量推送至栈顶,这里就是指将String s压入栈顶
invokevirtual-->调用实例方法,这里就是打印操作了,此时操作数栈内元素出栈:
return-->从当前方法返回void,main栈帧出栈。执行到这里,实例代码就执行完毕了
最后,需要补充一点,在main帧调用当前线程的时候,我们可以在jar包中可以看到"public static native Thread currentThread()",这个方法是native修饰的,而且没有实现,这个就是JVM调用了本地方法接口(Native Method)。
简单的代码,在Java虚拟机中,也就是这么执行的了,看起貌似还是挺复杂的。这里分享出来,主要还是希望对JVM虚拟机内部结构有个更加深层次的了解,方便进一步了解JVM性能调优做准备。
四、JVM垃圾回收
首先,我们来了解下堆的结构(下图来源于官网https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/G1GettingStarted/index.html#t5):
JVM堆可以分为新生代(Eden、Survivors)和老年代(old Generation),其内存结构变化:以文章开头代码而言,当new Main()时,会在Eden中分配一块空间来存储,如果不断的创建对象,会在Eden中不断的你分配空间来存储,当mian方法结束之后,这个对象会变成一个游离状态,这个时候会被垃圾回收掉。当如果这些对象没有处于游离状态,同时又新进了很多的对象,这时候,Eden内存可能会被充满,当快要充满的时候,会触发一次Minor GC,这时候会对Eden中的对象进行分析,看哪些是否可以销毁(没有用到则可以销毁),这个时候不是所有的对象都会被回收,剩下没有被回收的对象,将会进入Survivors的from区(从上图可以看出,Survivors区有2个小区,其中一个是From区,另外一个是To区),进入Survivors的From区的对象,会有一个标记;当程序继续运行,Eden中的内存又快满了,这个时候会执行第二次Minor GC,同时类似的会把没有销毁的对象放入Survivors的From区,这时候,如果From区也满了,则Survivors区的From区和To区交换位置,即From区变成To区,To区变成From区,将From中的对象拷贝到新的From区,标记+1,在程序继续运行的时候,持续进行上述过程,Survivors区中的对象可能在标记在第XX次的时候,就会被销毁,但是,总会有一些对象,在标记达到某个值(JVM默认15次)的时候,依然存在,这个时候,GC会视为这些对象是一个不老对象,会放入old Generation区。在程序持续运行的时候,old Generation区也总有一天会被充满,这时候会触发FULL GC,即程序出现假死状态。
下面图解下这个过程《From到To区对象拷贝交换,均视为该区内存已经满了,满足交换条件》:创建对象以为new Main()为例。
当创建对象快要占满Eden的时候
触发第一次Minor GC,没有销毁的进入From区:
对象快要占满Eden的时候:
触发第二次Minor GC,没有销毁的进入From区:
对象快要占满Eden的时候:
触发第三次Minor GC,没有销毁的进入From区(交换From和To区),假定new Main()+3的被销毁掉了:
对象快要占满Eden的时候:
触发第三次Minor GC,没有销毁的进入From区(交换From和To区),假定Survivors区也满了,这时候就会往old Generation存放:
。。。。。。
这里就不重复了,最后会占满老年代,最终触发FULL GC
整个过程我们可以通过Java自带程序jvisualvm进行可视化监控,这里我举个例子,来可视化描述这个过程,首先看下面这段代码:
这段代码最终运行结果是会抛出一个异常:
这个运行次数,只是在我本机的运行次数,其他是不一定的
线程休眠时间可以根据自身情况调整,主要用于观看jvisualvm可视化效果的,请自行设定。
然后我们通过jvisualvm命令打开jvisualvm工具,直接cmd命令,执行jvisualvm,打开工具:
有些朋友可能刚开始还没有安装visual GC插件,打开工具-->插件-->可用插件中可以找到
然后安装visual GC插件,当然也是可以离线安装的,请自行Google
这时候,我们运行上面那段代码,之后打开jvisualvm,打开执行监控的Java文件
打开visual GC,我们可以看到执行全过程,包括堆中内存变化
直到运行结束,old区满了,抛出异常
至此,JVM堆的结构和运行时的变化,也就大致如此了。
这里就简单和大家分享到这里,今天的这些东西,其实也是为之后的学习做个铺垫,如果内容有所侵权,请联系我修改。后续会继续分享相关内容(类加载子系统、JVM性能调优等等)