java虚拟机学习笔记(一)——虚拟机发展和内存分布

525 阅读12分钟

  本文为java虚拟机学习笔记,作为学习安卓虚拟机的知识铺垫,仅为学习补充,强烈建议各位阅读《深入理解java虚拟机》。

一、java和java虚拟机简述

1.1 JDK的发展历史

  耳熟能详的JDK包括三部分:java语言,java类库和java虚拟机。三部分长足发展,展现出了巨大的活力。1996年java1.0发布,2006年,sun公司发布JDK 6,此后sun公司陷入泥潭,Oracle收购sun公司,并在2011年发布JDK 7,JDK8的第一个正式版本原定于2013年9月发布,最终还是跳票到了2014年3月18日。鉴于JDK开发中的频繁跳票延期,JDK 9发布后,Oracle随即宣布Java将会以持续交付的形式和更加敏捷的研发节奏向前推进,以后JDK将会在每年的3月和9月各发布一个大版本,目的就是为避免众多功能特性被集中捆绑到一个JDK版本上而引发交付风险。

  从此以后,每六个JDK大版本中才会被划出一个长期支持(Long Term Support,LTS)版,只有LTS版的JDK能够获得为期三年的支持和更新,普通版的JDK就只有短短六个月的生命周期。JDK8和JDK11会是LTS版,再下一个就到2021年发布的JDK17。而国内互联网技术发展时,正是JDK 8大放异彩的时间,这也就为什么开发中尽量推荐使用JDK 8,而不是别的版本的原因。

1.2 JAVA虚拟机的发展

  JAVA虚拟机的始祖,是1996年初Sun发布的JDK1.0中包含的Sun Classic虚拟机。这款虚拟机只能使用纯解释器方式来执行Java代码,如果要使用即时编译器那就必须进行外挂,但是假如外挂了即时编译器的话,即时编译器就会完全接管虚拟机的执行系统,解释器便不能再工作了。

  Sun的虚拟机团队努力去解决Classic虚拟机所面临的各种问题,提升运行效率,在JDK1.2时,曾在Solaris平台上发布过一款名为Exact VM的虚拟机,它的编译执行系统已经具备现代高性能虚拟机雏形,如热点探测、两级即时编译器、编译器与解释器混合工作模式等。

  最广为人知的HotSpot虚拟机,它是Sun OracleJDK和OpenJDK中的默认Java虚拟机,也是目前使用范围最广的Java虚拟机。HotSpot既继承了Sun之前两款商用虚拟机的优点(如前面提到的准确式内存管理),也有许多自己新的技术优势,如它名称中的HotSpot指的就是它的热点代码探测技术。

  得益于Sun/OracleJDK在Java应用中的统治地位,HotSpot理所当然地成为全世界使用最广泛的Java虚拟机,是虚拟机家族中毫无争议的“武林盟主”。

  java虚拟机远不止上文的几种,而且在嵌入式等小内催领域,也发展除了独特的虚拟机,不过远不如上述虚拟机出名罢了。

  安卓中的Dalvik虚拟机并不是一个Java虚拟机,它没有遵循《Java虚拟机规范》,不能直接执行Java的Class文件,使用寄存器架构而不是Java虚拟机中常见的栈架构。但是它执行的DEX(Dalvik Executable)文件可以通过Class文件转化过来,使用java语言编写的程序,可以直接使用绝大部分的Java API等。在Androld发展早期,Dalvik虚拟机随着Android的成功迅速流行,在Android 2.2中开始提供即时编译器实现,执行性能又有了进一步提高。不过到了Android4.4时代,支持提前编译(Ahead of Time Compilation,AOT)的ART虚拟机迅速崛起,在当时性能还不算特别强大的移动设备上,提前编译要比即时编译更容易获得高性能,所以在Android 5.0里ART就全面替代了Dalvik虚拟机。

二、Java内存区域分配与溢出

  Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。根据《Java虚拟机规范》,java虚拟机管理的内存包括以下几个运行时数据区域: java内存模型

  其中线程私有的区域为程序计数器、虚拟机栈、本地方法栈。而方法区、堆、执行引擎、本地库接口都是所有线程共享的。

  大致关系可以梳理如图: java运行时内存

  而内存溢出异常则为两种情况:

  • OutOfMemoryError
  • StackOverflowError

2.1 程序计数器

  可以看作是当前线程执行的字节码的行号指示器。可以类比计算机组成原理中的PC,不过此处是java虚拟机管理的一块内存区域。

  如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefimed)

  这块内存是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

2.2 虚拟机栈

  虚拟机栈用于存储局部变量表、操作数栈、动态链接、方法出口等信息。栈是方法执行的线程内存模型。每个方法执行的时候,jvm都会同步创建一个栈帧用于存储局部变量表、操作数栈等到,方法被调用直到执行完毕,就是对应一个栈帧在虚拟机栈里从入栈到出栈的过程

  局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddr类型(指向了一条字节码指令的地址)。这部分区域在编译时确定大小完成分配。

  在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

2.3 本地方法栈

  本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

  《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

2.4 java堆

  对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎"所有的对象实例都在这里分配内存。

2.5 方法区

  方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储己被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

三、java对象的创建和内存分配

3.1 对象的创建

  在java虚拟机中,当jvm遇到一条字节码new指令时,大致会按照如下步骤进行对象的创建:

  1. 检查指令参数能否在常量池定位到一个类的符号引用。

  2. 检查这个符号引用代表的类,是否被加载、解析、初始化过

    2.1 如果没有,那么进行类加载过程,类加载后可知道对象占用的内存大小

  3. 类加载通过后,为新生对象分配内存。

  4. 将分配到的内存空间初始化为0(不包括对象头)。

  5. 对对象进行必要的设置,如哈希码,GC分代信息等。这些存放到对象头。

  6. 执行构造方法,按程序员的意愿进行初始化。

这之中会有各种各样的问题,我们在此处探讨几个比较重要的问题。

3.2 内存的分配方法

  为对象分配内存的工作并不是只有java才有,在各种语言中都是很基础的任务,大致的实现方法有以下两种:

  1. 指针碰撞:   假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。

  2. 空闲链表:   如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

  选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理的能力决定。因此可以得出结论,如果垃圾收集器带有压缩功能,则使用指针碰撞,如Serial、ParNew。当垃圾收集器没有压缩功能,则使用空闲链表,如CMS这种基于清除算法的收集器。

3.3 对象的内存组织

  在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。如下图:

java对象内存组织

3.3.1 对象头

  HotSpot虚拟机对象的对象头部分包括两类信息。

  1. 存储对象自身的运行时数据   如哈希码、GC分代年龄、锁状态标志、线程只有的锁、偏向线程ID、偏向时间锁等。这部分在不同的虚拟机中占32位或64位,被官方称为Mark Word。这部分被设计成动态结构,在不同的状态,相同的位存储不同的信息。

  2. 类型指针   对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

3.3.2 实例数据

  存储对象真正的有效信息,即各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

3.3.3 对齐填充

  起到占位符的作用,并不是必须存在的。HotSpot的对象必须时8字节的整数倍。

四、对象的访问定位

  创建对象自然是为了后续使用该对象,Java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:

  1. 句柄:   如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图: 句柄访问

  2. 直接指针:   如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据旳相天信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图: 直接指针访问

  使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就虚拟机HotSpot而言,它主要使用第二种方式进行对象访问。