JVM 虚拟机与对象创建过程

问题:

Q1:什么是 JVM?

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

Q2:JVM、Dalvik 和 ART

android5.0之前使用的虚拟机是Dalvik虚拟机,这个虚拟机在JVM的基础上做了一定的优化。android5.0之后,android采用了新的虚拟机ART。

Q3:JVM 内存模型,也就是说 JVM 包含什么?

Q4:虚拟机中对象的创建过程

这些问题在下文会有叙述。

一、Java 技术体系

Java 的技术体系主要包含下列内容:

  • Java 程序设计语言:也就是 Java 语言,包括各种定义、规范等;
  • Java 虚拟机:各种硬件平台上的虚拟机;
  • Class 文件格式:简单的说就是把 Java 代码转换为二进制、格式为 Class 的文件,方便在各个平台被虚拟机读取;
  • Java API 类库:Java 提供的 API,方便开发者日常使用。比如日历 Calendar,数学计算 Math 等;
  • 来自商业机构和开源社区的第三方 Java 类库:也就是第三方库。
各部件关系

关于 Java 运行流程,先看前半部分。Java 源代码经过编译器处理过,生成 .class 文件。这是一种二进制文件,在虚拟机运行后,再通过 ClassLoader.class 文件加载到虚拟机中运行。虚拟机加载文件流程后面会记录。

二、JVM 虚拟机

Java 虚拟机是一种抽象化的计算机,在实际上的计算机模拟各种计算机功能来实现。

正是因为模拟了一台计算机,所以可以方便地在其它计算机环境下运行。
就比如市面上的 GBA、NES 游戏模拟器,模拟了游戏运行环境,可以在手机、电脑等设备上运行,实现执行游戏文件的功能。Java 也是类似的过程实现了跨平台运行。

2.1 JVM 启动流程
  1. 为 JVM 分配内存空间;
    这部分由运行平台实现,不同平台会根据环境以及 JVM 配置为其分配空间。一旦运行成功,JVM 把内存划分为若干个不同的区域。
JVM 内存模型
  1. 创建引导类加载器,加载系统类到内存空间;
    JVM 创建成功之后,会实例化一个引导类加载器(Bootstrap Classloader),它会会读取 {JRE_HOME}/lib 下的 jar 包和配置,并将一些系统类加载到方法区中。比如 java.lang.String, java.lang.Object 都是这时加载的。

一、JVM 虚拟机

JVM 虚拟机运行时包含以下五大区域:


JVM 内存模型图
  • 程序计数器
  • Java虚拟机栈
  • 本地方法栈
  • 方法区
1.1 程序计数器

Java 文件通过编译器编译成 java 字节码文件(也就是 .class 文件),而 java 虚拟机执行的就是字节码文件。如果想了解什么是字节码,可以阅读下面文章:

JVM:这次一定要搞懂字节码

那么程序计数器就是记录当前线程执行到哪个字节码指令的地址,关于程序计数器的资料记录:

程序计数器是一块较小的内存空间,它可以看作是当前线程执行的字节码的行号指示器。在虚拟机的概念模型当中,字节码解释器工作时就是通过改变这个计数器的值来选择下一条需要执行的字节码指令。

程序计数器有两个作用:
  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注:如果当前线程正在执行的是一个本地方法,那么此时程序计数器为空。 因为本地方法是操作计算机的语言,和字节码无关。

程序计数器的特点:
  1. 是一块较小的存储空间。
  2. 线程私有。每条线程都有一个程序计数器。
  3. 是唯一一个不会出现OutOfMemoryError的内存区域。
  4. 生命周期随着线程的创建而创建,随着线程的结束而死亡。

通俗的总结一下:程序计数器用一块很小的内存空间来记录某线程当前执行到的字节码行号(第xxx行)。

1.2 Java 虚拟机栈(JVM Stack)

Java虚拟机栈是描述Java方法运行过程的内存模型。

当一个方法即将运行时,Java 虚拟机栈首先会在该区域为该方法创建一个“栈帧”。

栈帧中包括 局部变量表(用于储存要创建的局部变量)、操作数栈(虚拟机的工作区--弹出数据,执行运算,再把结果压回操作数栈)、动态链接(需要时执向所需要的资源地址)、方法出口信息(方法要结束时的信息)、等。

当这个方法执行完毕以后,该方法所对应的栈帧将会出栈,并释放内存空间。而Java虚拟机栈管理着这些栈帧。

Java 虚拟机栈的特点:
  1. 局部变量表的创建是随着栈帧的创建而创建,而且局部变量表的大小是在编译时期就确定了,在方法运行过程中该表大小不会改变。
  2. Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
    2.1 StackOverFlowError:Java 虚拟机栈不允许动态扩展内存的情况下线程请求栈的深度超过当前 Java 虚拟机栈的最大深度。Java 虚拟机栈创建时有一定的深度,当栈帧越大、数量越多的时候栈深度就越小,但内存并不一定就用完了。
    2.2 OutOfMemoryError:允许动态扩展 Java 虚拟机栈内存时,且当线程请求栈时内存用完了无法再进行扩展,会抛出该异常。
  3. Java 虚拟机栈也是线程私有,每个线程持有各自的虚拟机栈,跟随线程的生命周期。
1.3 本地方法栈

本地方法栈和 Java 虚拟机栈功能类似,它是本地方法运行的内存模型,执行的是 Native 方法。

本地方法执行的时候也会在本地方法栈创建栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、接口信息等。

方法执行完毕也会释放内存空间,出现异常也会抛出 Stack Overflow和 OutOfMemoryError 异常。

1.4 堆(heap)

几乎所有对象的实例都在堆中分配内存。也就是说堆中保存了大多数对象的实例。

堆的特点:
  1. 线程共享,上图也有注明。
  2. JVM 启动时创建。
  3. 垃圾回收的主要场所。
  4. 可以进一步细分为:新生代、老年代。
    新生代又可以细分为:Eden、From Survior、To Survior。
    这样划分的目的是为了使 Jvm 更好地管理堆内存中的对象,包括内存的分配和回收。
  5. 堆的大小既可以固定也可以动态扩展。在 Android 运行环境下,一般虚拟机堆的大小是可以扩展的,不同的设备为每个 App 分配的内存是不确定的但有一个最大值。当某应用可用内存被使用完,又去请求内存分配时就会抛出 OutOfMemoryError。
1.5 方法区

Jvm 栈规范定义方法区是堆的一个逻辑部分。

方法区中存放的是已经被 JVM 加载的类信息(class)、常量(final static,enum等)、静态变量(static),即经过编译器编译后的代码(static{})等。

方法区的特点:
  1. 线程共享:方法区是堆的一个逻辑部分,都是线程共享的。整个虚拟机只有一个方法区。
  2. 持久代:方法区中的信息一般需要长期存在,因此根据堆的逻辑来划分,把方法区称为持久代。
  3. 内存回收效率低:因为方法区的信息需要长期存在,回收可能会丢失数据。
    方法区内存回收的主要目标是:对常量池的回收和对类型的卸载。
  4. JVM 对方法区要求宽松:你回不回收内存都可以,想要大内存可以申请。
运行时常量池:

方法区中存放三种数据:类信息、常量、静态变量。其中常量储存在运行时常量池中。

当某个类被 JVM 加载后,class 文件中的常量就存放在方法区中的运行时常量池中。并且在运行期间可以向常量池中添加新的常量。比如:String.intern() 方法可以向运行时常量池中添加字符串常量。

二、HotSpot 虚拟机

HotSpot 虚拟机是虚拟机的一个实现,上面所说虚拟机更像是概念上的,而 HotSpot 虚拟机是依据理论创造出来的虚拟机。

HotSpot的正式发布名称为"Java HotSpot Performance Engine",是Java虚拟机的一个实现,包含了服务器版和桌面应用程序版,现时由Oracle维护并发布。

接下来记录 HotSpot 虚拟机对于对象的创建、内存分配等过程。

2.1 对象的创建

Object object = new Object();
  1. 当虚拟机遇到一条 new 指令时,首先检查常量池中是否有该对象所属类的符号引用,并且检查该类是否被加载、解析和初始化:
  • 如果常量池没有该类的符号引用,抛出 ClassNotFoundException;
  • 如果存在并经过 JVM 的执行、解析和初始化等一系列工作,执行下一步工作;
  1. 类加载完成后,虚拟机将为新生对象分配内存。
    一个对象所需内存,在 JVM 把该类加载进入方法区的时候就已经确定了,且一个类所产生的对象所占内存大小是一样的。
  2. 从堆中划分一块相应大小的内存给新的对象:
    给对象分配内存有两种方式:
  • 指针碰撞(Bump the Pointer)
    如果堆中的内存是规整的,也就是说使用中的内存在一边,空闲内存在另一边,中间有一个指针作为分界点指示器。那么只需要把指针向空闲区域挪动一段与新对象大小相等的距离。
    什么样的情况下堆内存是规整的呢?当然是经过整理的,比如 JVM 的垃圾收集器采用复制算法或标记-整理算法,那么堆内存是相对规整的。
  • 空闲列表(Free List)
    如果堆中的内存不是规整的,而是已使用内存和未使用内存交错的,那么就需要虚拟机维护一个列表并记录哪些内存是可用的。在可用内存中找到一块足够大的空间划分给新对象。
    JVM 的垃圾收集器采用标记-清除算法,就会使用这种方式分配内存。

选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

  1. 为新对象中的成员变量附上初始值;
  2. 设置并保存对象头信息(Object Header),对象头信息包括该对象是哪个类实例、对象的哈希码、对象的 GC 分代年龄等信息。
  3. 一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员规定的构造函数进行初始化。

经过以上步骤,对象的创建过程就完成了。

2.2 对象的内存模型

一般情况下,一个对象包括成员变量,构造函数,成员方法。那么在内存中分为三个部分:对象头、实例数据、对齐填充。

  • 对象头(Header),HotSpot 虚拟机的对象包括两部分信息:
    (1) 第一部分用于存储对象自身运行时数据,包括哈希码、GC分代年龄、锁状态标志、线程持有的锁。
    (2) 第二部分是类型指针,虚拟器通过这个指针来确定该对象是哪个类的实例。
  • 实例数据(Instance Data):
    对象存储的真正有效信息,也就是成员变量的值,包括本类和父类的成员变量的值。
  • 对齐填充(Padding):
    不是必然存在,仅仅起着占位符的作用。HotSpot 要求对象的总长度必须是 8 字节的整数倍,当对象实例数据部分没有对齐时,就需要通过对齐补充来补全。

2.3 对象的访问

我们知道引用类型的变量存储为一个地址,对象的访问方式取决于虚拟机的实现。主流的访问方式有两种,句柄式访问和直接指针访问:

  1. 句柄式访问
    堆中有一块内存空间叫 "句柄池",用于存放所有对象的地址和对象所属类信息。
    引用类型变量存放的地址是该对象在句柄池中的地址,访问对象时首先通过该对象的句柄,然后根据句柄再访问该对象。
  2. 直接指针访问
    通过引用直接访问该对象的地址,但对象所在内存空间需要额外的内存策略来记录该对象在方法区中的类信息的地址。

这两种访问方式各有利弊,句柄式访问的好处是实际引用对象改变时只需更改该句柄的指针;而使用直接指针访问速度较快,但需要额外的策略储存被引用对象的类信息。
对于 HotSpot,使用的是直接指针访问的方式。

参考资料:

深入理解JVM(一)——JVM内存模型