深入理解JVM虚拟机 - JVM的初步了解
概述:
- JVM的基础了解:了解什么是JVM,JVM到底是什么
- JVM的大致分区:侧重了解内存分区在类进行工作时候充当的角色。
- 类加载的大致流程
- 串联整个JVM,JAVA加载到JVM内部的工作流程【重点】
前言:
这是一篇JVM的基础篇章,大致内容为讲解JVM的入门以及初级知识,重点在于关注JVM在日常运行中充当的角色以及如何加载一个Java程序直到程序结束的整个流程梳理。
下面直接进入主题
JVM是什么
首先来看下JVM的定义是什么:用于执行编译后的JAVA程序的虚拟容器。具备独立的执行引擎。为了不受到操作系统的影响,JVM支持跨平台使用,JVM是JRE的一部分,从整体上来看,JVM的内部体系结构分为三部分,分别是:类装载器(ClassLoader)子系统,运行时数据区,和执行引擎。
这里要注意我们安装的JDK是自带了JRE的,而JVM又存在于JRE当中,所以我们安装JDK的同时也安装了JVM。
一个JAVA程序是如何运行的?
在了解JVM之前,我们需要知道,一个JAVA程序是如何运行的,在JAVA SE的基础上,我们都知道一个JAVA文件是不能直接运行在JVM上的。他需要我们编译为以.class为后缀的结尾字节码文件才能运行。所以一个JAVA程序的运行流程大致如下:
-
需要一份写好的JAVA代码,存在主类以及对应入口的main()方法
-
将程序进行打包或者通过javac
命令将文件编译成为.class字节码文件。
-
通过 java -jar
命令或者容器(比如Tomcat),启动文件。将程序载入到JVM
-
JVM执行步骤
- 类加载器负责加载写好的.class文件到JVM当中
- 基于JVM的字节码执行引擎,执行加载到内存里写好的类。
- 翻译.class文件内容为字节码指令执行。
-
程序结束,JVM进程停止。
注意:加载的细节在文章的后续章节进行解释。
下面为画图理解一下这个过程:
上面就是一个类加载到JVM的整体流程,看起来似乎很简单,我们只需要写好程序,编译然后直接调用命令就可以让程序跑起来。然而实际上内部的细节非常的复杂,下面我们先从和日常工作较为密切的类加载器开始介绍,看看类加载器到底是个什么玩意儿。
类加载器的基础概念
定义:在JVM基础上用于将CLASS文件加载到虚拟机内存的一个组件,这个组件负责加载程序中的类型(类和接口)并赋予唯一的名字。每一个Java虚拟机都有一个执行引擎(execution engine)负责执行被加载类中包含的指令。
可以简单将类加载器理解为一个黑盒,当编译好的.class文件经过这个黑盒之后,被翻译为一条条的字节码指令(对应机器指令)。
类加载器在设计上使用了双亲委派机制,分为:启动类加载器,扩展类加载器(JDK9被替换为平台加载器)应用程序加载器。注意只有启动类加载器由C++
实现,而其他所有类加载器,统一由JAVA
实现,下面就来看看类加载器的细节。
类加载的细节
我们先看看类加载器的大致工作流程:
双亲委派机制的工作模式:优先向上寻找上层类加载器,如果上层加载器直到顶层都无法找到对应的.class字节码文件,则从顶层向下寻找。
简单理解:先委托给父类加载器加载,然后向下找到子加载器直到自定义加载器。
这里可能会问,为什么不从顶层往下找呢?
首先,如果从顶层往下找,可能会出现以下的问题:由于Object总是在启动类加载器被找到,而如果此时底层定义了Object,在自上而下的加载机制中,当我们加载自己的java.lang.Object
的时候,整个JVM系统就会因为不知道加载哪一个Object陷入混乱,同时也会干扰到子类的继承操作,所以从下至上可以保证类永远只会被加载一次并且可以保证程序的稳定运行。
其次,类加载引擎的设计本身就是子父类的设计,这在底层结构上就已经注定了他只能是这种行为模式。
预定义类加载器
我们再扩展一下上面提到的双亲委派机制,他的三个核心加载器加载的具体扫描内容如下:
-
启动类加载器(Bootstrap ClassLoader):主要为加载jdk目录当中的Lib目录的所有内容。
-
扩展类加载器(Extendtion ClassLoader):加载/ext/lib当中的所有内容
-
应用程序加载器(Application ClassLoader):加载classpath所指定的类。(其实就是个人写好的类)
类加载器的过程
接下来介绍类加载的关键步骤,他的整体过程如下整体过程:加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载。
下面来看下这些步骤都干了啥:
-
加载:当我们想要使用某一个对象的时候,就需要通过classpath找到对应的class文件,这时候会用到前面说的双亲委派机制进行查找,保证每一个类只会加载一次。
加载意味着从.class字节码文件翻译到jvm虚拟机这一个过程,但是此时还不能直接使用此对象
-
验证、准备、初始化(连接步骤)
- 初始化:(重点)注意准备阶段的默认值和内存空间只是给实例变量开辟了内存空间和默认值赋值,此时对象并没有真正拥有这一块内存。比如
static
阶段会把静态的对象赋值到成员对象
- 使用
- 卸载:卸载并且销毁.class类,类生命结束。
类初始化的规则
初始化的规则也比较复杂, 作为简单理解,这里列出了几种最为简单的情况:
- 当进行实例化对象的时候,会立即执行类加载的初始化过程。
- 包含main方法的主类
- 当父类未进行初始化的时候,会优先进行父类的初始化步骤
类加载工作流程图
至此,一个类加载的大致步骤就已经了解了,我们通过画图来整理一下整个过程:
Tomcat也是通过类加载器的形式将java web 程序的war包加载到jvm当中,那么tomcat是如何实现类加载机制的?
结论:Tomcat是破坏了双亲委派机制,他为每一个组件对应设置自己的类加载器,比如jsp有JSP的类加载器,webapp对应有自己的类加载器。同时tomcat是依照Common作为类加载器的主类。
- 每一个应用程序有一个自己的webapp加载器
- 统一依照Common作为公共资源的类类加载器
-
这里可以明显看到jsp的加载依靠Jsp加载器,并且向上委托给webapp加载
关于tomcat的部分,可以看下面的链接,个人了解不够深入,所以找了两篇资料:
Tomcat源码分析 -- Tomcat类加载器: https://www.jianshu.com/p/69c4526b843d
死磕Tomcat系列(4)——Tomcat中的类加载器:http://modouxiansheng.top/2019/07/05/%E4%B8%8D%E5%AD%A6%E6%97%A0%E6%95%B0-%E6%AD%BB%E7%A3%95Tomcat%E7%B3%BB%E5%88%97(4)-Tomcat%E4%B8%AD%E7%9A%84%E7%B1%BB%E5%8A%A0%E8%BD%BD%E5%99%A8-2019/
下面是一些简单的思考:
思考如何防止用户获取.class 反编译获得字节码文件的内容?
- 编写自定义的类加载器,对于.class文件字节码进行加密,
- 通过自定义的类加载器进行解码的动作。但是自定义类加载的源码也许要保密
- 核心思想是 自定义类加载器。重点了解即可
内存分区以及执行引擎
在了解内存的分区之前,我们先来回顾一下类加载的过程:将.java
文件编译为.class
文件之后,通过执行引擎将类加载到JVM系统当中完成类的加载以及初始化操作。.class类被加载进内存之后翻译为一条条的字节码指令进行运行,那么执行引擎又是如何为我们工作的呢?Jvm又要如何存放运行时产生的对象以及局部变量,JVM又是如何识别并且运行方法的?类字节码指令执行的过程中是如何运作的......这是除开类加载之外的另外一个重点JVM分区,
我们先重新解释一下什么是字节码指令,什么是字节码执行引擎:
什么是字节码指令?
当java文件编译生成.class文件之后,.class文件的内部存储的就是字节码指令。对应了一条条的机器指令。这个字节码指令将会被jvm加载之后进行翻译变为机器代码让计算机识别并且运行。
什么是字节码执行引擎:
.class文件里面会存在对应的字节码,而负责把字节码翻译为机器代码执行的装置就叫做字节码执行引擎。会逐条执行翻译出来的字节码指令。
清楚上面的类加载机制之后,我们接下来将要讲解内存分区的事情:
内存分区入门
Jvm为了更好的管理内存,会在运行时候的内存分区划分不同的区域。比如对象的方法和类加载信息放到方法区,对象实例放到堆,对象的引用到栈等。jvm将内存进行分区管理以及维护。
那么初级阶段需要了解什么呢?
-
程序计数器:
程序计数器主要保存的下一条字节码指令的地址。因为每一个线程都是独立的,所以每一个线程都有一个单独的程序计数器。
程序计数器本质为一个指针,在32位系统占4个字节,在64位系统占8个字节。
-
方法区:
jdk1.8之前代表jvm当中的一块区域,主要是存放.class
对象加载过来的类以及一些常量池的内容。
Jdk1.8之后被改为Metaspace区域,除了常量池被移动到堆之外,存储的内容还是各自的.class类的相关信息,和之前区别不是十分大。
-
虚拟机栈:
当程序运行的时候,程序计数器保存为某一条指令的地址。每一条指令执行过程中方法存在局部变量,局部变量就存储在虚拟机栈内部。
每个线程都有自己的虚拟机栈。每一个方法对应一个栈帧。
当线程执行到一个方法的时候,会加载虚拟机栈对应的栈帧
栈帧存储了局部变量表,操作数栈,动态链接和方法出口等内容。
所以方法的执行就是栈帧不断进栈和出栈(虚拟机栈)的过程。
-
堆:
堆存储的是对象的实例对象,拥有jvm最大的一块内存分区,也就是我们new对象存储的地点。
所以当栈中的对象实例创建到堆时候,虚拟机栈中的局部变量表变量指向了堆内存的对象
内存分区讲述初始化的过程
依照一个main方法的执行过程解释:程序运行的时候首先会加载并且初始化当前的主类并且将当前main()
方法的栈帧压入当前的线程的虚拟机栈,此时栈帧会存储方法的局部变量等信息,当加载到一个new 对象方法等时候,会先判断当前的被加载的.class是否初始化,如果没有初始化则进行初始化,初始化完成之后,会在对应的堆内存空先开辟一块内存空间,并且在main方法栈帧的的局部变量表内创建一个对象的引用,而对象的引用指向刚刚分配的堆内存空间,。
当执行新对象的方法时候,同样会进行加载和初始化等工作,创建方法对应的栈桢,栈桢内部存储着局部变量表,如果此时方法还存在变量,则按照同样的方式把变量存储当当前栈桢对应的局部变量表。
为了更方便理解,我们再画一个图来理解一下:
程序运行的整体工作流程(重点)
下面我们将所有的内容串联起来,根据一段程序来讲述一个程序运行的大致流程,注意这里依然是大致流程,深究到细节还需要后续的补充:
public class OneWeek {
public static void main(String[] args) throws IOException {
Properties properties = new Properties();
InputStream resourceAsStream = OneWeek.class.getClassLoader().getResourceAsStream("app.properties");
properties.load(resourceAsStream);
System.out.println("load properties user.name = " + properties.getProperty("user.name"));
}/*运行结果:
load properties user.name = 123
#app.properties:
user.name=123
*/
}
上面的代码是简单的读取项目根路径的一个配置文件,并且读取指定的内容进行展示,非常简单的一个程序,下面就来看看他做了什么,为了减少文字说明,这里的所有运行流程会根据一个流程图来进行展示,下面请根据以下问题带入到流程图进行梳理和理解:
- 执行多个方法的调用时,如何把方法的栈帧压入线程的Java虚拟机栈?
- 栈帧里如何放局部变量?
- 如何在Java堆里创建实例对象?
- 如何让局部变量引用那个实例对象?
- 方法运行完之后如何出栈?
- 垃圾回收是如何运行的?
根据上面的图标,回答如下的问题:
执行多个方法的调用时,如何把方法的栈帧压入线程的Java虚拟机栈?
回答:从图中可以看到,最终通过程序计数器以及执行引擎的配合,通过字节码指令找到的对应的.CLASS对象以及对象的方法出入口,之后压如到虚拟机栈并且创建对象以及局部变量表。
栈帧里如何放局部变量?
回答:通过局部变量表以及操作数栈的配合进处理,操作数栈进行变量的运算操作(此篇未涉及),局部变量表存储当前栈帧方法的对象引用。
注意局部变量表即使没有任何对象引用也是1,具体原因可以自行查找资料
如何在Java堆里创建实例对象?
回答:首先,当局部变量表碰到类似new操作的时候,会在堆内存开辟一块内存空间存放实例对象,并且在当前局部变量表创建一个引用指向堆内存的地址(此处不关注访问方式)。
方法运行完之后如何出栈?
回答:方法执行完成之后,会将当前栈帧弹出当前虚拟机栈,并且如果存在对象的引用,会将指向堆内存的变量引用进行销毁
垃圾回收是如何运行的?
当方法出栈的时候,垃圾回收线程会时刻监控堆内存的变化,如果发现没有任何引用执行实例对象,则根据根对象枚举判定此对象真的“毫无卵用”,则在满足条件之后开启垃圾回收进行清理操作,整个过程是客户端几乎无法感知的。(现代垃圾回收器基本实现和用户线程并行)
总结:
以一个程序最简单的运行流程为开始,我们介绍了什么是类加载器,并且了解了JVM预定义的类加载器机制:双亲委派机制,依据双亲委派机制,我们了类加载器的大致步骤,其中准备和初始化是最为关键和核心的部分,也是类加载器的过程。
介绍完类加载器之后,我们了解了JVM的大致内存分区,介绍了几个重要的分区:程序计数器,方法区,虚拟机栈和堆,在初步了解此阶段即可。
以上便是这篇文章的全部内容,类加载器当中有一个比较重要的点:Tomcat是如何打破双亲委派机制的,以及Tomcat的基本工作和加载原理。
写在最后:
这是深入理解虚拟机的开篇,希望这个系列能完结吧。。。。。