成都近日气温已逾零下。让我想起在赤峰拍戏的日子,八月凌晨三点,茶水车结出一道冰溜子,18 个人抢 8 件军大衣,有人要去扒箱车,制片大喊:“别动!那是给领导穿的” —— 生活如此不易。如今我更加体会到“平凡的生活也要靠努力去争取”。

什么是 JVM

JVM(Java Virtual Machine Java 虚拟机)是一种用于计算设备的规范,基于这套规范,许多团队开发了多种不同的虚拟机实现,目前使用范围最广的是从 Sun 公司开始,到 Oracle 后一直在使用 的 HotSpot 以及 Open JDK,二者一个由 Oracle 公司维护,一个由开源社区维护,使用起来并没有太大差别。

$java -version
java version "1.8.0_91"
Java(TM) SE Runtime Environment (build 1.8.0_91-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.91-b14, mixed mode)

名词释义

JRE(JavaRuntimeEnvironment,Java运行环境),包含了 Java API 类库中的 Java SE API 子集和 Java 虚拟机两部分,所有的Java 程序都要在JRE下才能运行。普通用户只需要运行已开发好的java程序,安装JRE即可。

JDK(Java Development Kit) 包含了 Java 程序设计语言、 Java 虚拟机、Java API 类库三部分,是支持 Java 程序开发的最小环境,包含了开发者用来来编译、调试java程序用的开发工具包。JDK的工具也是Java程序,也需要JRE才能运行。为了保持JDK的独立性和完整性,在JDK的安装过程中,JRE也是 安装的一部分。所以,在JDK的安装目录下有一个名为jre的目录,用于存放JRE文件。

JVM 是JRE的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。

Java SE(Standard Edition)Java 标准版,提供完整的 Java 核心 API,我们通常在 Oracle 下载的 JDK 全称就是“Java SE Development Kit”。

Java EE (Enterprise Edition)Java 企业版,包含了 Java SE 并增加了附加类库,Java EE是以Java SE为基础的。所以并没有“JVM for Java EE”这么一说,只有“JVM for Java SE”,可以用于Java SE与Java EE。

Java 技术体系包含的内容 图片来自:https://docs.oracle.com/javase/8/docs/

JVM运行时数据区

根据 Java 虚拟机规范最新版)规定,Java 虚拟机所管理的内存将会包括以下几个运行时数据区域,如图所示:

程序计数器(Program Counter Register): 可以看作是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。PC Register 是线程私有的内存,每个线程的计数器是独立存储的,如果线程执行的是一个 Java 方法,这个计数器记录的就是正在执行的虚拟机字节码地址,方便线程在 CPU 中切换后能够恢复在切换之前的程序执行位置,并且不能互相被干扰。如果正在执行的是 Native 方法,则计数器为空。

由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。

Java 虚拟机栈(Java Virtual Machine Stacks )是Java方法执行的内存模型,有着和线程相同的生命周期,每一个方法从调用到完成,都对应着一个栈帧(Stack Frame)在虚拟机中入栈到出栈的过程,栈帧用于局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池的引用(Reference to runtime constant pool)、方法返回地址(ReturnAddress)和一些额外的附加信息。

  • 局部变量表是用来存储方法中的局部变量(包括在方法中声明的非静态变量以及函数形参)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。
  • 操作数栈也常被称为操作栈。和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作—压栈和出栈—来访问的。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。虚拟机在操作数栈中存储数据的方式和在局部变量区中是一样的:如int、long、float、double、reference和returnType的存储。对于byte、short以及char类型的值在压入到操作数栈之前,也会被转换为int。程序中的所有计算过程都是在借助于操作数栈来完成的。
  • 指向运行时常量池的引用,因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。
  • 方法返回地址,当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。

由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰。

当线程请求的栈深度大于虚拟机允许的深度,将抛出 StackOveflowError;
当虚拟机扩展时也无法申请到足够的内存,将抛出 OutOfMemoryError.

本地方法栈(Native Method Stack)用于执行 Java 方法以外的本地方法,没有过多限制,由虚拟机自由实现。Sun HotSpot VM 就把 Native Method Stack 和 VM Stack 合二为一。
和 JVM Stack 一样,Native Method Stack 也有 OutOfMemoryError 和 StackOverflowError 两个 Error。

Java 堆(Java Heap)存放几乎所有的对象实例,也是 GC(Garbage Collection)发生的主要区域,被所有线程共享。因为 GC 的需求,Heap 可以再被细分为新生代和老年代(这一部分在Java系列(二)——JVM内存回收中有涉及),但是与其储存的内容无关。Java Heap 应该可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。Heap 既可以固定大小也可以动态扩展。在 JVM 中只有一个堆。
如果 Heap 中没有内存完成实例分配,且堆无法再扩展,则会抛出 OutOfMemoryError。

方法区(Method Area)用于存放类信息、常量、静态变量等数据,结构上和 Heap 一致,功能上却要加以区分。在 Java8 以前,这块区域对应的是 GC 的“永久代”(Permanent Generation),java8 后被更名为“元空间”(MetaSpace)。

在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。当方法区无法满足内存需求时会抛出 OutOfMemoryError 异常。

运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期生成的各种字面量和符号。避免了频繁的创建和销毁对象而影响系统性能,实现了对象的共享,节省了内存空间和运行时间。值得说明的是,运行时常量池具备动态特性,常量不止在编译期可以产生,在运行期间也可以产生,比如String的intern方法。

对象的创建

  1. 在常量池中检查是否已有该类的符号引用,并检查该类是否已初始化,如果没有则需要加载该类。
  2. 在 Heap 中为对象分配内存:指针碰撞(Bump the Pointer)、空闲列表(Free List),取决于 Java 堆是否规整;除此之外出于虑线程安全的考虑,使用 CAS(Compare and Swap)和失败重试的方式保证操作的原子性。也可以用 TLAB(Thread Local Allocation Buffer),即预先给线程匹配缓冲内存的方式实现。
  3. 内存分配完成后,虚拟机会将该部分内存除了头以外的空间初始化为零,这就是为什么 Java 中的对象可以不赋初值就使用。
  4. 对对象头进行设置,如这个对象是属于哪个类的实例、如何才能找到类的元数组信息、对象哈希码、 GC 分代年龄、锁状态标志等信息。
  5. 执行完 new 之后会接着执行 init 方法,把对象按照程序员的意愿初始化,一个真正可用的对象才算完全被初始化出来。

对象的内存布局

  • 对象头(Header)
    第一部分:用于存储对象自身的运行时数据,成为“Mark Word”,被设计为一个非固定结构的数据结构,它会根据对象的状态复用自己的存储空间。
    第二部分:类型指针,用于指向它的类元数据③,虚拟机能通过这个指针确定这个对象是哪个类的实例。(非必须)
  • 实例数据(Instance Data)
    程序代码中定义的各种类型字段内容。HotSpot 虚拟默认分配策略是等长字段分配到一起,从父类继承来的变量在子类之前,子类变量较窄的值也可能插入到父变量的空隙中。
  • 对齐填充(Padding)
    占位符(非必须)。填满8字节的整数倍。

关于锁

独占锁是一种悲观锁,synchronized就是一种独占锁,它假设最坏的情况,并且只有在确保其它线程不会造成干扰的情况下执行,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

对象定位

Java 对象的使用是通过 Stack 的 reference 数据来操作的,通常的实现方式有两种:使用句柄 和 直接指针。

  • 句柄访问:Java 堆中将分配一块句柄池,用于 Map 句柄地址和对象实例以及类型数据的指针,这种方式 Reference 里保存的信息就是句柄地址。
  • 指针访问:Reference 中储存的是 Java 堆中对象的地址,这个对象中就要考虑如何放置类型数据相关的信息,这部分信息在方法区里。

句柄访问的好处在于可以灵活应对对象被移动的情况而不需要改变 Stack 里的 Reference,缺点是维护多一个表增大了时间上的开销,所以 Sun HotSpot 使用直接指针的方式进行对象访问。