概述
JVM 是一个规范,定义了 .class 文件的结构、加载机制、数据存储、运行时栈等诸多内容,最常用的 JVM 实现就是 Hotspot。文章中一些关键术语的描述翻译自Oracle JVM规范文档 , 文档说明非常详细, 认真阅读可以加深对JVM机制的认知和理解
- 运行过程
一个 Java 程序,首先经过 javac 编译成 .class 文件,然后 JVM 将其加载到元数据
区,执行引擎将会通过混合模式
执行这些字节码。执行时,会翻译成操作系统相关的函数。JVM 作为 .class 文件的黑盒存在,输入字节码,调用操作系统函数。
过程如下:Java 文件->编译器>字节码->JVM->机器码。
内存管理
JDK1.8内存布局-
JAVA虚拟机栈
每个Java虚拟机线程都有一个私有Java虚拟机堆栈,与该线程同时创建。Java虚拟机堆栈存储框架(第2.6节)。Java虚拟机堆栈类似于C之类的常规语言的堆栈:它保存局部变量和部分结果,并在方法调用和返回中起作用。因为除了推送和弹出帧外,从不直接操纵Java虚拟机堆栈,所以可以为堆分配帧。Java虚拟机堆栈的内存不必是连续的。
简单来说就是基于线程去调用方法, 创建栈帧进行入栈出栈操作, 当所有的栈帧都出栈后,线程也就结束了。每个栈帧,都包含四个区域:局部变量表, 操作数栈, 动态连接, 返回地址
-
本地方法栈
本地方法栈是和虚拟机栈非常相似的一个区域, 它服务的对象是 native 方法
-
程序计数器
每个Java虚拟机线程都有其自己的 pc
(程序计数器)寄存器。在任何时候,每个Java虚拟机线程都在执行单个方法的代码,即该线程的当前方法(第2.6节)。如果不是 native
,则该pc
寄存器包含当前正在执行的Java虚拟机指令的地址
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。摘自《深入理解JAVA虚拟机》
-
方法区(元空间)
Java虚拟机具有在所有Java虚拟机线程之间共享的方法区域。该方法区域类似于常规语言的编译代码的存储区域,或者类似于操作系统过程中的“文本”段。它存储每个类的结构,例如运行时常量池,字段和方法数据,以及方法和构造函数的代码,包括用于类和实例初始化以及接口初始化的特殊方法(第2.9节)。
JDK1.8之后改用元空间, 有什么优缺点呢? JDK1.8之前类信息存放在永久代,在更早之前的版本运行时常量池也存放在此,该区域有大小限制, 容易造成 JVM 内存溢出。但是无限制的使用会造成操作系统的死亡。所以,一般也会使用参数 -XX:MaxMetaspaceSize 来控制大小。
-
直接内存
不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。直接内存是在Java堆外的、直接向系统申请的内存区间; 起源于NIO,通过存在堆中的DirectByteBuffer操作Native内存通常,访问直接内存的速度会优于Java堆。即读写性能高。因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
-
堆
Java虚拟机具有一个在所有Java虚拟机线程之间共享的堆。堆是运行时数据区,从中分配了所有类实例和数组的内存, 所以该区域需要GC进行对象的垃圾回收
-
执行引擎和二进制执行引擎
- 执行引擎: 将字节码指令解释/编译为对应平台上的本地机器指令, 通过执行方式可分为: 解释执行和JIT(即时编译)
- 二进制执行引擎: 运行二进制文件
类加载机制
类的加载主要过程:加载、验证、准备、解析、初始化;
-
加载
加载的主要作用是将外部的 .class 文件,加载到 Java 的方法区内。加载阶段主要是找到并加载类的二进制数据,比如从 jar 包里或者 war 包里找到它们。
-
验证
肯定不能任何 .class 文件都能加载,那样太不安全了,容易受到恶意代码的攻击。验证阶段在虚拟机整个类加载过程中占了很大一部分,不符合规范的将抛出 java.lang.VerifyError 错误。像一些低版本的 JVM,是无法加载一些高版本的类库的,就是在这个阶段完成的。
-
准备
为一些类变量分配内存,并将其初始化为默认值。此时,实例对象还没有分配内存,所以这些动作是在方法区上进行的。类变量和局部变量有些不同, 局部变量不像类变量那样存在准备阶段, 局部变量必须赋初值才能够使用, 否则编译不通过 (如图)
编译不通过-局部变量不赋初值
-
解析
类解析在类加载中是非常非常重要的一环,是将符号引用替换为直接引用的过程。符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量, 解析过程保证了相互引用的完整性,把继承与组合推进到运行时。下面的字节码包含了类解析的相关信息, 存储在方法区(元空间)的常量池, javap -v xx.class
可查看
-
初始化
如果前面的流程一切顺利的话,接下来该初始化成员变量了,到了这一步,才真正开始执行一些字节码。初始化有一些规则, 决定代码加载运行的顺序, 如下
- static 语句块,只能访问到定义在 static 语句块之前的变量
- JVM 会保证在子类的初始化方法执行之前,父类的初始化方法已经执行完毕。所以,JVM 第一个被执行的类初始化方法一定是 java.lang.Object。另外,也意味着父类中定义的 static 语句块要优先于子类的。
-
<clinit>与<init>的区别, 前者为类加载(类的初始化阶段调用), 后者为对象加载(通过new的方式调用)
类加载器
负责类文件的加载, 使用双亲委派的机制防止核心类被用户更改, 根据类别可分别一下几种:
-
Bootstrap ClassLoader
这是加载器中的大 Boss,任何类的加载行为,都要经它过问。它的作用是加载核心类库,也就是 rt.jar、resources.jar、charsets.jar 等。当然这些 jar 包的路径是可以指定的,-Xbootclasspath 参数可以完成指定操作。
这个加载器是 C++ 编写的,随着 JVM 启动。
-
Extention ClassLoader
扩展类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。同样的,通过系统变量 java.ext.dirs 可以指定这个目录。
这个加载器是个 Java 类,继承自 URLClassLoader。
-
App ClassLoader
这是我们写的 Java 类的默认加载器,有时候也叫作 System ClassLoader。一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,我们写的代码,会首先尝试使用这个类加载器进行加载。
-
Custom ClassLoader
自定义加载器,支持一些个性化的扩展功能。
双亲委派机制
双亲委派机制的意思是除了顶层的启动类加载器以外,其余的类加载器,在加载之前,都会委派给它的父加载器进行加载。这样一层层向上传递,直到祖先们都无法胜任,它才会真正的加载。实现可翻阅 JDK 代码的 ClassLoader#loadClass 方法, 这个方法可以被覆盖, 所以双亲委派机制并不一定生效
双亲委派的模式可以确保核心类不被篡改, 那么有什么缺点呢, 打破之后有什么好处? 缺点就是父级加载器无法加载子级类加载器路径中的类, 打破双亲委派的例子很多, 如tomcat, SPI 机制(Service Provider Interface), 数据库驱动的加载, OSGi等等, 有些是通过Thread.currentThread().setContextClassLoader()然后再get出来实现破坏绕过双亲委派的的机制, 具体可参考文章末尾的博客
当 Java 的原生 API 不能满足需求时,比如我们要修改 HashMap 类,就必须要使用到 Java 的 endorsed 技术。我们需要将自己的 HashMap 类,打包成一个 jar 包,然后放到 -Djava.endorsed.dirs 指定的目录中。注意类名和包名,应该和 JDK 自带的是一样的。但是,java.lang 包下面的类除外,因为这些都是特殊保护的。
从栈帧看字节码是如何在 JVM 中进行流转的
加载机制查看字节码可用
javap
命令行工具, 常用的参数-v -c
, 或用可视化工具jclasslib
; 方法的执行是在虚拟机栈中执行的, 当前线程方法运行的单位是栈帧- aload_0
把第 1 个引用型局部变量推到操作数栈,这里的意思是把 this 装载到了操作数栈中. 对于 static 方法,aload_0 表示对方法的第一个参数的操作。 -
getfield #2
将栈顶的指定的对象的第 2 个实例域(Field)的值,压入栈顶。#2 就是指的我们的成员变量 a。#后面的为符号引用, 在字节码的类常量池中存放
- i2l
将栈顶 int 类型的数据转化为 long 类型,这里就涉及我们的隐式类型转换了。图中的信息没有变动,不再详解介绍。 - lload_1
将第一个局部变量入栈。也就是我们的参数 num。这里的 l 表示 long,同样用于局部变量装载。你会看到这个位置的局部变量,一开始就已经有值了。 - ladd
把栈顶两个 long 型数值出栈后相加,并将结果入栈。 - getstatic #3
根据偏移获取静态属性的值,并把这个值 push 到操作数栈上 - ladd
再次执行 ladd - lstore_3
把栈顶 long 型数值存入第 4 个局部变量。 - lload_3
正好与上面相反。上面是变量存入,我们现在要做的,就是把这个变量 ret,压入虚拟机栈中。 - lreturn
从当前方法返回 long
更多的指令查看, Oracle的规范第六章
参考
Java8中的JVM元空间是不是方法区?
深入理解Java虚拟机栈的栈帧
JVM执行引擎
Tomcat 类加载器为什么违背双亲委派模型
浅谈双亲委派机制的缺陷及打破双亲委派机制
网友评论