JVM是什么?
一种软件实现,执行物理机程序
特点:
- 基于堆栈的虚拟机
- 符号引用:基本类型以外的数据,也就是类和接口,都是通过符号来引用而不是通过显式地使用内存地址来引用
- 垃圾收集: 一个类的实例是由用户明确创建的代码和垃圾回收自动销毁
- 网络字节顺序:
Java class
文件用网络字节码顺序来进行存储
作用
提供通用的机器无关的执行平台:
- 加载代码
- 验证代码
- 执行代码
- 提供运行环境
定义了:
- 存储区
- 类文件格式
- 寄存器组
- 垃圾回收堆
- 致命错误报告
生命周期
- 启动:任何一个拥有
main
函数的class
都可以作为JVM
实例运行的起点 - 运行:
main
函数为起点,程序中的其他线程均由它启动,包括daemon
守护线程和non-daemon
普通线程。daemon
是JVM
自己使用的线程比如GC
线程,main
方法的初始线程是non-daemon
- 消亡:所有线程结束时
JVM
实例结束生命
整体架构
执行过程:
Class Loader
类加载器,负责加载程序中的类型(类和接口),并赋予唯一的名字
三种 ClassLoader:
三种classloader关系
Bootstrp loader
是在Java虚拟机启动后初始化的
-
Bootstrp loader
负责加载ExtClassLoader,
并且将ExtClassLoader
的父加载器设置为Bootstrp loader
-
Bootstrp loader
加载完ExtClassLoader
后,就会加载AppClassLoader
,并且将AppClassLoader
的父加载器指定为ExtClassLoader
Class Loader | 实现 | 负责加载 |
---|---|---|
Bootstrp loader | C++ | %JAVA_HOME%/jre/lib,-Xbootclasspath参数指定的路径以及%JAVA_HOME%/jre/classes中的类 |
ExtClassLoader | Java | %JAVA_HOME%/jre/lib/ext,此路径下的所有classes目录以及java.ext.dirs系统变量指定的路径中类库 |
AppClassLoader | Java | classpath所指定的位置的类或者是jar文档,它也是Java程序默认的类加载器 |
双亲委托模型
ClassLoader
的加载采用了双亲委托机制,有以下几个步骤:
- 当前
ClassLoader
首先从自己已经加载的类中查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类 - 当前
classLoader
的缓存中没有找到被加载的类的时候,委托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到bootstrp ClassLoader
- 当所有的父类加载器都没有加载的时候,再由当前的类加载器加载,并将其放入它自己的缓存中,以便下次有加载请求的时候直接返回
步骤如图:
为什么使用双亲委托模型?
这里涉及到ClassLoader
的隔离问题
每个类装载器都有一个自己的命名空间用来保存已装载的类。当一个类装载器装载一个类时,它会通过保存在命名空间里的类全局限定名进行搜索来检测这个类是否已经被加载了
那么,一个运行程序中有没有可能同时存在两个包名和类名完全一致的类?
答案是,有可能
JVM
及Dalvik
对类唯一的识别是 ClassLoader id + PackageName + ClassName,所以一个运行程序中是有可能存在两个包名和类名完全一致的类的。并且如果这两个”类”不是由一个ClassLoader
加载,是无法将一个类的示例强转为另外一个类的,这就是ClassLoader
隔离
双亲委托是ClassLoader
问题的一种解决方案,也是 Android 差件化开发和热修复的基础
类装载器的特点
Java提供了动态加载特性:他会在运行时的第一次引用到一个class
的时候对它进行加载、链接和初始化,而不是在编译时进行
类装载器的特点:
- 层级结构:所有的类装载器是有父子关系的层级结构,
Bootstrap ClassLoader
是所有装载器的父亲 - 代理模式:基于层级结构,类的代理可以在装载器之间进行
- 可见性限制:父装载器对子装载器可见,反之不可见
- 禁止卸载:类装载器可以装载一个类但不可以卸载它,可以通过删除此类装载器重新新建类装载器装载
过程分析
上文提到的三个过程:
- 加载
- 链接
- 初始化
一一分析:
加载:找到代表这个类的class
文件或根据特定的名字找到接口类型,然后读取到一个字节数组中。这些字节会被解析检验它们是否代表一个class
对象并包含正确的major
、minor
版本信息。直接父类的类和接口也会被加载进来。这些操作一旦完成,类或者接口对象就从二进制表示中创建出来了
链接:检验类或接口并准备类型和父类接口的过程。包含三个步骤:
- 验证:最复杂的过程。任务是确保导入类型的准确性。验证阶段的检查运行时不需要再做,避免了多次检查
- 准备:分配一个结构来存储类的信息,其中包括:类的成员变量,接口,和方法信息
- 解析:可选阶段,把这个类中的常量池中的符号引用改变成直接引用,如果不执行,符号解析要等到字节码指令使用到这个引用时才会执行
初始化:把类中的变量初始化成合适的值。执行静态初始化程序,把静态变量初始化成指定的值
执行引擎
通过类装载器装载的,被分配到JVM的运行时数据区的字节码会被执行引擎执行。执行引擎以指令为单位读取 Java 字节码。它就像一个 CPU 一样,一条一条地执行机器指令。每个字节码指令都由一个1字节的操作码和附加的操作数组成。执行引擎取得一个操作码,然后根据操作数来执行任务,完成后就继续执行下一条操作码。
不过 Java 字节码是用一种人类可以读懂的语言编写的,而不是用机器可以直接执行的语言。因此,执行引擎必须把字节码转换成可以直接被 JVM
执行的语言。
字节码可以通过以下两种方式转换成合适的语言:
- 解释器:一条条读取,解释并且执行字节码指令。解释很快,执行很慢
- 即时编译器:用来弥补解释器的缺点。执行引擎首先按照解释执行的方式来执行,然后在合适的时候,即时编译器把整段字节码编译成本地代码。这样一来执行引擎就不再需要解释执行方法,而是直接执行本地的代码。本地代码是保存在缓存中,所以执行很快
补充说明:
Java字节码是解释执行的,没有在JVM
中执行原生代码快。
为了提到性能,Oracle Hotspot
虚拟机会找到执行最频繁的字节码片段并把它们编译成原生机器码,编译出的原生代码保存在非堆内存的代码缓存中。基于这种方法(JIT),Hotspot
虚拟机将权衡下面两种时间消耗:
- 将字节码编译成本地代码需要的额外时间
-
解释执行字节码消耗的更多的时间
Android 5.0 以后用的 ART 虚拟机使用的是 AOT 机制
Dalvik 是依靠一个 Just-In-Time (JIT)编译器去解释字节码。开发者编译后的应用代码需要通过一个解释器在用户的设备上运行,这一机制并不高效,但让应用能更容易在不同硬件和架构上运 行。ART 则完全改变了这套做法,在应用安装时就预编译字节码到机器语言,这一机制叫 Ahead-Of-Time (AOT)编译。在移除解释代码这一过程后,应用程序执行将更有效率,启动更快。
运行时数据区
JVM运行时结构图:
PC寄存器(PC Register)
也叫程序计数器,是一块较小的内存空间,作用可以看做是当前线程所执行的字节码的信号指示器
每一条JVM
线程都有自己的PC寄存器
任意时刻,一条 JVM
线程只会执行一个方法的代码,该方法称为该线程的当前方法(Current Method
)
- 如果该方法是Java方法,PC寄存器保存的是JVM正在执行的字节码的地址
- 如果这个方法是Native方法,PC 寄存器的值是
undefined
此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何OutOfMemoryError
情况的区域
JVM 栈
同PC寄存器一样,JVM
栈也是线程私有,每一个JVM
线程都有自己的JVM
栈,这个栈与线程同时创立,与线程生命周期相同,用来保存栈帧。JVM
只会在JVM
栈上执行push
和pop
操作
JVM 栈异常情况:
- StackOverflowError:线程请求分配的栈容量大于
JVM
允许的最大容量时抛出 - OutOfMemoryError:如果JVM栈可以动态扩展,但是在尝试扩展时无法申请到足够的内存去完成扩展,或者在创建线程时没有足够的内存去创建对应的
JVM
栈,这两种情况下会抛出
栈帧:随着方法被调用而创建,随着方法结束而销毁(方法出现异常也视为方法结束)
每一个栈帧都包含下列三样:
- 局部变量表(
Local Variables
) - 操作数栈(
Operand Stack
) - 指向 当前方法所属的类的 运行时常量池的 引用
局部变量数组(Local variable array):
每一个栈帧内部都包含一组称为局部变量表(Local Variables
)的变量列表。栈帧中局部变量表的长度由编译期决定
Java 虚拟机使用局部变量表来完成方法调用时的参数传递,当一个方法被调用的时候,它的参数将会传递至从 0 开始的连续的局部变量表位置上
当一个实例方法被调用的时候,第 0 个局部变量一定是用来存储被调用的实例方法所在的对象的引用(即 Java 语言中的“this”关键字)
操作数栈(Operand stack):
每一个栈帧内部都包含一个称为操作数栈(Operand Stack
)的后进先出(Last-In-First-Out
,LIFO
)栈
Java 虚拟机提供一些字节码指令来 从局部变量表或者对象实例的字段中 复制常量或变量值到操作数栈中,也提供了一些指令用于 从操作数栈取走数据、操作数据和把操作结果重新入栈。
作用:
在方法调用的时候,操作数栈用来准备调用方法的参数以及接收方法返回结果
动态链接(Dynamic Linking
):
每个栈帧都有一个运行时常量池的引用。这个引用指向栈帧当前运行方法所在类的常量池。通过这个引用支持动态链接(dynamic linking
)
在 Java中,链接阶段是运行时动态完成的
Java类文件编译时,所有变量和方法的引用都被当做符号引用存储在此类的常量池中
符号引用是常量引用,实际上并不指向物理内存地址
JVM
可以选择符号引用解析的时机:
- 类文件加载并校验通过后,这种解析方式被称为饥饿方式
- 符号引用在第一次使用的时候被解析,这种解析方式称为惰性方式
JVM
必须在第一次使用符号引用时完成解析,并抛出可能发生的解析错误
绑定是将对象域、方法、类的符号引用替换为直接引用的过程,绑定只会发生一次
如果一个类的符号引用还没被解析,就会载入这个类
每一个直接引用都被存储为相对于存储结构(与运行时变量或方法的位置相关联)的偏移量
方法正常调用完成
在这种场景下,当前栈帧承担着 回复调用者状态 的责任,其状态包括 调用者的局部变量表、操作数栈和被正确增加过来表示执行了该方法调用指令的程序计数器等。使得调用者的代码能在被调用的方法返回并且返回值被推入调用者栈帧的操作数栈后继续正常地执行
方法异常调用完成
方法执行的过程中,某些执行导致了Java虚拟机的异常,并且此异常在本方法中没法处理或者执行过程中遇到了 throw 字节码指令显式地抛出异常,并且在该方法内部没有把异常捕获住
此种场景下,一定不会有返回值返回给它的调用者
本地方法栈(Native method stack
)
Java虚拟机可能会使用到传统的栈来支持native
方法(使用Java语言以外的其它语言编写的方法)的执行,这个栈就是本地方法栈
方法区
被加载类的信息都保存在方法区中,包括:
- 类型信息
- 方法列表
方法区是线程共享的,所以访问方法区线程的方法必须是线程安全的
方法区是在JVM启动的时候创建的
方法区中存储了每一个类的结构信息:
- 运行时常量池
- 字段和方法数据
- 构造函数和普通方法的字节码
- 类、实例、接口初始化时用到的特殊方法
...
运行时常量池
每一个接口或者类的常量池的运行时表现形式
包括了若干种常量:编译器可知的数值字面量到必须运行期解析后才能获得的方法或字段的引用
简而言之,当一个方法或者变量被引用时,JVM
通过运行时常量池来查找方法和变量在内存中的实际地址
运行时常量池是方法区的一部分,每一个运行时常量池都分配在JVM
的方法区中,在类和接口被加载到JVM
中时,对应的运行时常量池就会被创建
堆
在JVM
中,堆是所有线程共享的运行时内存区域,也是供所有类实例和数据对象分配内存的区域
堆在虚拟机启动的时候就被创建,其中存储了各种对象,这些对象被垃圾回收器所管理,无需也无法显式的被销毁
对比栈和方法区:
堆(heap) : 最大的一块区域,用于存放对象实例和数组,是全局共享的
栈(stack) : 全称为虚拟机栈,主要存储基本数据类型,以及对象的引用,私有线程
方法区(Method Area) : 在
class
被加载后的一些信息 如常量,静态常量这些被放在这里,在Hotspot
里面我们将它称之为永生代