Java虚拟机

Android必备知识

Posted by wushiqian on November 18, 2019

Java虚拟机

图片来自《Android进阶解密》

概述

Java虚拟机家族

  • HotSpot VM
  • J9 VM
  • Zing VM

Java虚拟机执行流程

Java虚拟机结构

Class文件格式

类的生命周期

  • 加载:查找并加载 Class 文件。
  1. 根据特定名称查找类或接口类型的二进制字节流
  2. 将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
  • 链接: 验证:确保导入类型的正确性,准备:为累的静态字段分配字段,并用默认值初始化字段,解析:虚拟机将常量池内的符号引用替换为直接引用。
  • 初始化:将类变量初始化为正确初始值。

类加载子系统

类加载器 说明
BootstrapClassLoader Bootstrap 类加载器负责加载 rt.jar 中的 JDK 类文件,它是所有类加载器的父加载器。Bootstrap 类加载器没有任何父类加载器,如果你调用 String.class.getClassLoader(),会返回 null,任何基于此的代码会抛出 NUllPointerException 异常。Bootstrap 加载器被称为初始类加载器
ExtClassLoader 而 Extension 将加载类的请求先委托给它的父加载器,也就是Bootstrap,如果没有成功加载的话,再从 jre/lib/ext 目录下或者 java.ext.dirs 系统属性定义的目录下加载类。Extension 加载器由 sun.misc.Launcher$ExtClassLoader 实现
AppClassLoader 第三种默认的加载器就是 System 类加载器(又叫作 Application 类加载器)了。它负责从 classpath 环境变量中加载某些应用相关的类,classpath 环境变量通常由 -classpath 或 -cp 命令行选项来定义,或者是 JAR 中的 Manifest 的 classpath 属性。Application 类加载器是 Extension 类加载器的子加载器

运行时数据区域

区域 说明
程序计数器 每条线程都需要有一个程序计数器,计数器记录的是正在执行的指令地址,如果正在执行的是Natvie 方法,这个计数器值为空(Undefined)
java虚拟机栈 Java 方法执行的内存模型,每个方法执行的时候,都会创建一个栈帧用于保存局部变量表,操作数栈,动态链接,方法出口信息等。一个方法调用的过程就是一个栈帧从VM栈入栈到出栈的过程
本地方法栈 与 VM 栈发挥的作用非常相似,VM 栈执行 Java 方法(字节码)服务,Native 方法栈执行的是 Native 方法服务。
Java堆 此内存区域唯一的目的就是存放对象实例,几乎所有的对象都在这分配内存
方法区 方法区是各个内存所共享的内存空间,方法区中主要存放被 JVM 加载的类信息、常量、静态变量、即时编译后的代码等数据

程序计数器

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域。

Java虚拟机栈

内存模型

Java 虚拟机栈也是线程私有的,它描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。

局部变量表存放了编译器克制的各种基本数据类型(boolean、byte、char、shot、int、float、long、double)、对象引用(reference 类型,它不是对象本身)和 returnAddress 类型(指向一条字节码指令的地址)

在 JVM 规范中,对该区域规定了这两种异常情况:

  • StackOverflowError异常,当线程请求的栈深度大于虚拟机所允许的深度,将抛出。
  • OutOfMemoryError异常,当虚拟机栈可以动态拓展,如果拓展时无法申请到足够的内存,就会抛出。

本地方法栈

本地方法栈的作用与虚拟机栈作用是非常类似的,只不过虚拟机栈为执行 Java 方法(也就是字节码)服务,而本地方法栈则为 Native 方法服务。

Java堆

对大多数应用来说,Java 堆是 JVM 所管理的内存中最大的一块。它在虚拟机启动时创建,被所有线程所共享,它的唯一目的就是存放对象实例。

Java 堆是垃圾收集器管理的主要区域,也被称作“GC”堆。

  • 从内存回收的角度来看,现代收集器基本都采用分代收集算法,所以 Java 还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。
  • 从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区,但存储的仍然是对象实例,进一步划分的目的是为了更好地回首内存,或者更快地分配内存。

Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可拓展的。如果在堆中没有内存完成实例分配,并且堆也无法再拓展时,将会抛出 OutOfMemoryError 异常。

方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

方法区也被称作“永久代”,但两者并不等价,仅仅是因为方法区是靠使用永久代来实现。

  • 在 JDK 1.7 中,已经把原本放在永久代的字符串常量池移出;

  • 在 JDK 1.8 中,移除了整个永久代,取而代之的是一个叫元空间(Metaspace)的区域。

方法区除了和 Java 堆一样不需要连续的内存空间和可以选择固定大小或者可拓展外,还可以选择不实现垃圾收集,但不实现垃圾回收可能会引发内存泄漏。

当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

运行时常量池

它是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得较多的便是 String 类得 intern() 方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时便会抛出 OutOfMemoryError 异常。

对象的创建

  1. 判断对象对应的类是否加载、链接和初始化
  2. 为对象分配内存
  3. 处理并发安全问题
  4. 初始化分配到的内存空间
  5. 设置对象的对象头

对象的堆内存布局

对象创建完毕,并且已经在Java堆中分配了内存,那么对象在堆内存是如何进行布局 的呢?以 Hotspot虚拟机为例,对象在堆内存的布局分为 三个区域,分别是对象头( Header)、实例数据( Instance Data)、对齐填充( Padding)。

  • 对象头 对象头包括两部分信息,分别是 Mark World 和元数据指针, Mark World用于存储对象运行时的数据,比如 HashCode、锁状态标志、GC 分代年龄、线程持有的锁等。 而元数据指针用于指向方居区中的目标类的元数据,通过元数据可以确定对象的具体类型。
  • 实例数据:用于存储对象中的各种类型的字段信息(包括从父类继承来的)。
  • 对齐填充:对齐填充不一定存在,起到了占位符的作用,没有特别的含义。Mark Word 在 HotSpot 中的实

oop-klass模型

垃圾标志算法

Java中的引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。

Java 提供了四种强度不同的引用类型。

    1. 强引用

被强引用关联的对象不会被回收。

使用 new 一个新对象的方式来创建强引用。

Object obj = new Object();
    1. 软引用

被软引用关联的对象只有在内存不够的情况下才会被回收,常用于缓存。

使用 SoftReference 类来创建软引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联
    1. 弱引用

被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。

使用 WeakReference 类来创建弱引用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
    1. 虚引用

又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。

为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。

使用 PhantomReference 来创建虚引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;

引用计数算法

每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。

根搜索算法

从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。不可达对象。

在Java语言中,GC Roots包括:

  • 虚拟机栈中引用的对象。
  • 方法区中类静态属性实体引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中 JNI 引用的对象。
  • 处于激活状态的线程
  • JNI栈中的对象
  • JNI中的全局对象
  • 正在被用于同步的各种锁对象
  • JVM自身持有的对象,比如系统类加载器等。

Java对象在虚拟机中的生命周期

在Java对象被类加载器加载到虚拟机中后,Java对象在Java虚拟机中有7个阶段。

  1. 创建阶段( Created) 创建阶段的具体步骤为: (1)为对象分配存储空间。 (2)构造对象。 (3)从超类到子类对 static成员进行初始化。 (4)递归调用超类的构造方怯。 (5)调用子类的构造方法。

  2. 应用阶段( In Use)

    当对象被创建,并分配给变量赋值时,状态就切换到了应用阶段。这一阶段的对象至少要具有一个强引用,或者显式地使用软引用、弱引用或者虚引用。

  3. 不可见阶段( Invisible) 在程序中找不到对象的任何强引用,比如程序的执行已经超出了该对象的作用域。在 不可见阶段,对象仍可能被特殊的强引用 GC Roots 持有着,比如对象被本地方陆战中 JNI 引用或运行中的线程引用等。

  4. 不可达阶段( Unreachable) 在程序中找不到对象的任何强引用,并且垃圾收集器发现对象不可达。

  5. 收集阶段( Collected) 垃圾收集器已经发现对象不可达,并且垃圾收集器已经准备好要对该对象的内存空间重新进行分配,如果重写了finalize方法,则会调用。

  6. 终结阶段(Finalized) 在对象执行完finalize方法后仍然处于不可达状态时,或者对象没有重写finalize方法,则该对象进入终结阶段,并等待垃圾收集器回收该对象空间。

  7. 对象空间重新分配阶段( Deallocated) 当垃圾收集器对对象的内存空间进行回收或者再分配时,这个对象就会彻底消失。

被标记为不可达的对象会立即被垃圾收集器回收吗?很显然是不会的,被标记为不可达的对 象会进入收集阶段,这时会执行该对象重写的 finalize方法,如果没有重写 finalize方法或者没有重新与一个可达的对象进行关联则进入终结阶段,并进行回收。

垃圾收集算法

标志-清除算法

“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。

标志-压缩算法

复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

GC 分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。

“分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

小结