清蒸 JVM (一)

摘要

JVM(Java Virtual Machine)Java 虚拟机是整个 java 平台的基石,是 java 系统实现硬件无关与操作系统无关的关键部分,是保障用户机器免于恶意代码损害的屏障。Java开

前言

JVM(Java Virtual Machine)Java 虚拟机是整个 java 平台的基石,是 java 系统实现硬件无关与操作系统无关的关键部分,是保障用户机器免于恶意代码损害的屏障。Java开发人员不需要了解JVM是如何工作的,但是,了解 JVM 有助于我们更好的开(通)发(过) java(公司) 程(面)序(试)。

写这篇文章的目的:

  • 总结所学的 JVM 知识
  • 帮助想了解 JVM 的朋友,知无不言,言无不尽

本篇文章将会介绍一下内容:

  • 什么是 JVM
  • JVM 用来做什么事情
  • JVM 生命周期
  • JVM 的整体架构
  • JVM 内存管理
  • 总结

什么是 JVM

要想说明白什么 JVM 就不得不提另外两个概念,JRE 和 JDK,初学者总是把这几个概念搞混

java-tutorial.png

Jvm,Jre,Jdk 都是 java 语言的支柱,他们分工协作。但不同的是 Jdk 和 Jre 是真实存在的,而 Jvm 是一个抽象的概念,并不真实存在。

JDK
JDK(Java Development Kit) 是 Java 语言的软件开发工具包(SDK)。JDK 物理存在,是 programming tools、JRE 和 JVM 的一个集合

jdk.png

JRE
JRE(Java Runtime Environment)Java 运行时环境,JRE 物理存在,主要由Java API 和 JVM 组成,提供了用于执行 java 应用程序最低要求的环境。

jre.png

JVM
JVM(Java Virtual Machine) 是一种软件实现,执行像物理机程序的机器(即电脑)。
本来,Java被设计基于从物理机器分离实现WORA( 写一次,随处运行 )的虚拟机上运行,虽然这个目标已经几乎被遗忘。
JVM 并不是专为 Java 所实现的运行时,实际上只要有其他编程语言的编译器能生成正确 Java bytecode 文件,则这个语言也能实现在JVM上运行。
因此,JVM 通过执行 Java bytecode 可以使 java 代码在不改变的情况下运行在各种硬件之上。
jVM 有如下特点:

  • 基于堆栈的虚拟机 :最流行的计算机体系结构,如英特尔X86架构和ARM架构上运行基于寄存器 。 但是,JVM是基于栈的。
  • 符号引用 :除了基本类型以外的数据(类和接口)都是通过符号来引用,而不是通过显式地使用内存地址来引用。
  • 垃圾收集 :一个类的实例是由用户明确创建的代码和垃圾回收自动销毁。
    通过明确界定的基本数据类型的保证平台的独立性 :传统的语言,如C / C ++根据平台有不同的int型的大小。 JVM中明确规定了基本数据类型,以保持它的兼容性和保证平台的独立性。
  • 网络字节顺序 :Java class文件用网络字节码顺序来进行存储:为了保证和小端的Intel x86架构以及大端的RISC系列的架构保持无关性,JVM使用用于网络传输的网络字节顺序,也就是大端。

Java bytecode
为了实现WORA,JVM使用Java字节码,java(用户语言)和机器语言之间的中间语言。
该Java字节码是部署Java代码的最小单位。

JVM 用来做什么

基于安全方面考虑,JVM 要求在 class 文件中使用许多强制性的语法和机构化约束,但任意一门功能性语言都可以表示为一个能被 JVM 接受的有效的 class 文件。作为一个通用的、机器无关的执行平台,任何其他语言的实现者都可将 JVM 当作他的语言产品交付媒介。

JVM 中执行以下操作:

  • 加载代码
  • 验证代码
  • 执行代码
  • 提供运行环境

JVM 提供定义了:

  • 存储区
  • 类文件格式
  • 寄存器组
  • 垃圾回收堆
  • 致命错误报告等

JVM 生命周期

  • 启动:任何一个拥有main函数的class都可以作为JVM实例运行的起点
  • 运行:main函数为起点,程序中的其他线程均有它启动,包括daemon守护线程和non-daemon普通线程。daemon是JVM自己使用的线程比如GC线程,main方法的初始线程是non-daemon。
  • 消亡:所有线程终止时,JVM实例结束生命。

JVM 的整体架构

先看一下 java 代码执行过程

jvm.png

疑问:

  • Class Loader
  • Excution Engine
  • Runtime Data Areas

Class Loader

类加载器负责加载程序中的类型(类和接口),并赋予唯一的名字。

JDK 默认提供了三种 ClassLoader

classloader.png

关系

  1. Bootstrp loader 是在Java虚拟机启动后初始化的。
  2. Bootstrp loader 负责加载 ExtClassLoader,并且将 ExtClassLoade r的父加载器设置为 Bootstrp loader。
  3. 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程序默认的类加载器

双亲委托模型
Java中ClassLoader的加载采用了双亲委托机制,采用双亲委托机制加载类的时候采用如下的几个步骤:

  1. 当前ClassLoader首先从自己已经加载的类中查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类。
  2. 当前classLoader的缓存中没有找到被加载的类的时候,委托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到bootstrp ClassLoader.
  3. 当所有的父类加载器都没有加载的时候,再由当前的类加载器加载,并将其放入它自己的缓存中,以便下次有加载请求的时候直接返回。

为什么使用双亲委托模型——ClassLoader 隔离问题
每个类装载器都有一个自己的命名空间用来保存已装载的类。当一个类装载器装载一个类时,它会通过保存在命名空间里的类全局限定名(Fully Qualified Class Name)进行搜索来检测这个类是否已经被加载了。
大家觉得一个运行程序中有没有可能同时存在两个包名和类名完全一致的类?
JVM 及 Dalvik 对类唯一的识别是 ClassLoader id + PackageName + ClassName,所以一个运行程序中是有可能存在两个包名和类名完全一致的类的。并且如果这两个”类”不是由一个 ClassLoader 加载,是无法将一个类的示例强转为另外一个类的,这就是 ClassLoader 隔离。
双亲委托是 ClassLoader 问题的一种解决方案,也是 Android 差价化开发和热修复的基础。

Android 插件化 动态升级
Android 热补丁动态修复框架小结

类装载器特点
Java提供了动态加载特性;他会在运行时的第一次引用到一个class的时候对它进行装载(Loading)、链接(Linking)和初始化(Initialization),而不是在编译时进行。不同的JVM的实现不同,本文所描述的内容均只限于Hotspot Jvm。JVM的类装载器负责动态装载,Java的类装载器有如下几个特点:

  • 层级结构:Java里的类装载器被组织成了有父子关系的层级结构。Bootstrap类装载器是所有装载器的父亲。
  • 代理模式: 基于层级结构,类的代理可以在装载器之间进行代理。当装载器装载一个类时,首先会检查它在父装载器中是否进行了装载。如果上层装载器已经装载了这个类,这个类会被直接使用。反之,类装载器会请求装载这个类
  • 可见性限制:一个子装载器可以查找父装载器中的类,但是一个父装载器不能查找子装载器里的类。
  • 不允许卸载:类装载器可以装载一个类但是不可以卸载它,不过可以删除当前的类装载器,然后创建一个新的类装载器装载。

过程

加载(Loading)是这样一个过程,找到代表这个类的class文件或根据特定的名字找到接口类型,然后读取到一个字节数组中。接着,这些字节会被解析检验它们是否代表一个Class对象并包含正确的major、minor版本信息。直接父类的类和接口也会被加载进来。这些操作一旦完成,类或者接口对象就从二进制表示中创建出来了。

链接(Linking)是检验类或接口并准备类型和父类接口的过程。链接过程包含三步:校验(Verifying)、准备(Preparing)、部分解析(Optionally resolving)。

loadclass.png
  • 验证:这是类装载中最复杂的过程,并且花费的时间也是最长的。任务是确保导入类型的准确性,验证阶段做的检查,运行时不需要再做,虽然减慢加了载速度,但是避免了多次检查。
  • 准备:分配一个结构用来存储类信息,这个结构中包含了类中定义的成员变量,方法和接口的信息。
  • 解析:可选阶段,把这个类的常量池中的所有的符号引用改变成直接引用。如果不执行,符号解析要等到字节码指令使用这个引用时才会进行

初始化(Initialization)把类中的变量初始化成合适的值。执行静态初始化程序,把静态变量初始化成指定的值。

JVM规范定义了上面的几个任务,不过它允许具体执行的时候能够有些灵活的变动。

执行引擎(Execution Engine)

通过类装载器装载的,被分配到JVM的运行时数据区的字节码会被执行引擎执行。执行引擎以指令为单位读取 Java 字节码。它就像一个 CPU 一样,一条一条地执行机器指令。每个字节码指令都由一个1字节的操作码和附加的操作数组成。执行引擎取得一个操作码,然后根据操作数来执行任务,完成后就继续执行下一条操作码。
不过 Java 字节码是用一种人类可以读懂的语言编写的,而不是用机器可以直接执行的语言。因此,执行引擎必须把字节码转换成可以直接被 JVM 执行的语言。字节码可以通过以下两种方式转换成合适的语言。

  • 解释器:一条一条地读取,解释并且执行字节码指令。因为它一条一条地解释和执行指令,所以它可以很快地解释字节码,但是执行起来会比较慢。这是解释执行的语言的一个缺点。字节码这种“语言”基本来说是解释执行的。
  • 即时(Just-In-Time)编译器:即时编译器被引入用来弥补解释器的缺点。执行引擎首先按照解释执行的方式来执行,然后在合适的时候,即时编译器把整段字节码编译成本地代码。然后,执行引擎就没有必要再去解释执行方法了,它可以直接通过本地代码去执行它。执行本地代码比一条一条进行解释执行的速度快很多。编译后的代码可以执行的很快,因为本地代码是保存在缓存里的。

Java 字节码是解释执行的,但是没有直接在 JVM 宿主执行原生代码快。为了提高性能,Oracle Hotspot 虚拟机会找到执行最频繁的字节码片段并把它们编译成原生机器码。编译出的原生机器码被存储在非堆内存的代码缓存中。通过这种方法(JIT),Hotspot 虚拟机将权衡下面两种时间消耗:将字节码编译成本地代码需要的额外时间和解释执行字节码消耗更多的时间。

java_compiler_and_jit_compiler.png

这里插入一下 Android 5.0 以后用的 ART 虚拟机使用的是 AOT 机制。

Dalvik 是依靠一个 Just-In-Time (JIT)编译器去解释字节码。开发者编译后的应用代码需要通过一个解释器在用户的设备上运行,这一机制并不高效,但让应用能更容易在不同硬件和架构上运 行。ART 则完全改变了这套做法,在应用安装时就预编译字节码到机器语言,这一机制叫 Ahead-Of-Time (AOT)编译。在移除解释代码这一过程后,应用程序执行将更有效率,启动更快。

运行时数据区

JVM 运行时数据结构图:

runtime-data-access-configuration.png

PC寄存器(PC Register)
也叫程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的信号指示器。
每一条JVM线程都有自己的PC寄存器
在任意时刻,一条 JVM 线程只会执行一个方法的代码。该方法称为该线程的当前方法(Current Method)
如果该方法是 java 方法,那PC寄存器保存 JVM 正在执行的字节码指令的地址
如果该方法是 native,那 PC 寄存器的值是 undefined。
此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

JVM 栈(Java Virtual Machine Stack)
与 PC 寄存器一样,java 虚拟机栈(Java Virtual Machine Stack)也是线程私有的。每一个JVM线程都有自己的java虚拟机栈,这个栈与线程同时创建,它的生命周期与线程相同,用来保存栈帧。JVM 只会在 JVM 栈上进行 push 和 pop 的操作。
JVM stack 可以被实现成固定大小,也可以根据计算动态扩展。
如果采用固定大小的JVM stack设计,那么每一条线程的JVM Stack容量应该在线程创建时独立地选定。JVM实现应该提供调节JVM Stack初始容量的手段。
如果采用动态扩展和收缩的JVM Stack方式,应该提供调节最大、最小容量的手段。

  • JVM 栈异常情况
    • StackOverflowError:当线程请求分配的栈容量超过JVM允许的最大容量时抛出
    • OutOfMemoryError:如果JVM Stack可以动态扩展,但是在尝试扩展时无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的虚拟机栈时抛出。
  • 栈帧(stack frame)
    栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间分配在 Java 虚拟机栈之中,每一个栈帧都有自己的局部变量表(Local Variables)、操作数栈(Operand Stack)和指向当前方法所属的类的运行时常量池的引用。
    • 局部变量数组(Local variable array)
      每个栈帧内部都包含一组称为局部变量表(Local Variables)的变量列表。栈帧中局部变量表的长度由编译期决定。
      局部变量使用索引来进行定位访问,第一个局部变量的索引值为零,局部变量的索引值是从零至小于局部变量表最大容量的所有整数。
      Java 虚拟机使用局部变量表来完成方法调用时的参数传递,当一个方法被调用的时候,它的参数将会传递至从 0 开始的连续的局部变量表位置上。特别地,当一个实例方法被调用的时候,第 0 个局部变量一定是用来存储被调用的实例方法所在的对象的引用(即 Java 语言中的“this”关键字)。后续的其他参数将会传递至从 1 开始的连续的局部变量表位置上。
    • 操作数栈(Operand stack)
      每一个栈帧内部都包含一个称为操作数栈(Operand Stack)的后进先出(Last-In-First-Out,LIFO)栈。栈帧中操作数栈的长度由编译期决定。
      操作数栈所属的栈帧在刚刚被创建的时候,操作数栈是空的。Java 虚拟机提供一些字节码指令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据和把操作结果重新入栈。在方法调用的时候,操作数栈也用来准备调用方法的参数以及接收方法返回结果。
    • 动态链接(Dynamic Linking)
      每个栈帧都有一个运行时常量池的引用。这个引用指向栈帧当前运行方法所在类的常量池。通过这个引用支持动态链接(dynamic linking)。
      C/C++ 代码一般被编译成对象文件,然后多个对象文件被链接到一起产生可执行文件或者 dll。在链接阶段,每个对象文件的符号引用被替换成了最终执行文件的相对偏移内存地址。在 Java中,链接阶段是运行时动态完成的。
      当 Java 类文件编译时,所有变量和方法的引用都被当做符号引用存储在这个类的常量池中。符号引用是一个逻辑引用,实际上并不指向物理内存地址。JVM 可以选择符号引用解析的时机,一种是当类文件加载并校验通过后,这种解析方式被称为饥饿方式。另外一种是符号引用在第一次使用的时候被解析,这种解析方式称为惰性方式。无论如何 ,JVM 必须要在第一次使用符号引用时完成解析并抛出可能发生的解析错误。绑定是将对象域、方法、类的符号引用替换为直接引用的过程。绑定只会发生一次。一旦绑定,符号引用会被完全替换。如果一个类的符号引用还没有被解析,那么就会载入这个类。每个直接引用都被存储为相对于存储结构(与运行时变量或方法的位置相关联的)偏移量。
    • 方法正常调用完成
      在这种场景下,当前栈帧承担着回复调用者状态的责任,其状态包括调用者的局部变量表、操作数栈和被正确增加过来表示执行了该方法调用指令的程序计数器等。使得调用者的代码能在被调用的方法返回并且返回值被推入调用者栈帧的操作数栈后继续正常地执行。
    • 方法异常调用完成
      方法异常调用完成是指在方法的执行过程中,某些指令导致了 Java 虚拟机抛出异常,并且虚拟机抛出的异常在该方法中没有办法处理,或者在执行过程中遇到了 athrow 字节码指令显式地抛出异常,并且在该方法内部没有把异常捕获住。如果方法异常调用完成,那一定不会有方法返回值返回给它的调用者。

本地方法栈(Native method stack)
Java虚拟机可能会使用到传统的栈来支持native方法(使用Java语言以外的其它语言编写的方法)的执行,这个栈就是本地方法栈(Native Method Stack)
如果JVM不支持native方法,也不依赖与传统方法栈的话,可以无需支持本地方法栈。
如果支持本地方法栈,则这个栈一般会在线程创建的时候按线程分配。
异常情况:

  • StackOverflowError:如果线程请求分配的栈容量超过本地方法栈允许的最大容量时抛出
  • OutOfMemoryError:如果本地方法栈可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的本地方法栈,那Java虚拟机将会抛出一个OutOfMemoryError异常。

方法区(Method area)
在Java虚拟机中,被加载类型的信息都保存在方法区中。包括类型信息(Type Information)和方法列表(Method Tables)。方法区是所有线程共享的,所以访问方法区信息的方法必须是线程安全的。如果你有两个线程都去加载一个叫Lava的类,那只能由一个线程被容许去加载这个类,另一个必须等待。
它是在JVM启动的时候创建的。
存储了每一个类的结构信息,例如运行时常量池(Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法。
方法区的容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。
方法区在实际内存空间中可以是不连续的。
Java虚拟机实现应当提供给程序员或者最终用户调节方法区初始容量的手段,对于可以动态扩展和收缩方法区来说,则应当提供调节其最大、最小容量的手段。
是否对方法区进行垃圾回收对JVM的实现是可选的。
Java 方法区异常:

  • OutOfMemoryError: 如果方法区的内存空间不能满足内存分配请求,那Java虚拟机将抛出一个OutOfMemoryError异常。

运行时常量池(Runtime constant pool)
运行时常量池是每一个类或接口的常量池(Constant_Pool)的运行时表现形式,它包括了若干种常量:编译器可知的数值字面量到必须运行期解析后才能获得的方法或字段的引用。简而言之,当一个方法或者变量被引用时,JVM通过运行时常量区来查找方法或者变量在内存里的实际地址。
运行时常量池是方法区的一部分。每一个运行时常量池都分配在JVM的方法区中,在类和接口被加载到JVM后,对应的运行时常量池就被创建。
在创建类和接口的运行时常量池时,可能会遇到的异常:

  • OutOfMemoryError:当创建类和接口时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大内存空间后就会抛出OutOfMemoryError

堆(Heap)
在 JVM 中,堆(heap)是可供各条线程共享的运行时内存区域,也是供所有类实例和数据对象分配内存的区域。
Java堆载虚拟机启动的时候就被创建,堆中储存了各种对象,这些对象被自动管理内存系统(Automatic Storage Management System,也即是常说的“Garbage Collector(垃圾回收器)”)所管理。这些对象无需、也无法显示地被销毁。
Java堆的容量可以是固定大小,也可以随着需求动态扩展,并在不需要过多空间时自动收缩。
Java堆所使用的内存不需要保证是物理连续的,只要逻辑上是连续的即可。
JVM实现应当提供给程序员调节Java 堆初始容量的手段,对于可动态扩展和收缩的堆来说,则应当提供调节其最大和最小容量的手段。
Java 堆异常:

  • OutOfMemoryError:如果实际所需的堆超过了自动内存管理系统能提供的最大容量时抛出。

堆内的内存回收—— JVM GC

待续。。

IT家园
IT家园

网友最新评论 (0)