参考文献

img

JVM简述

  • Java Virtual Machine,Java程序的运行环境(Java二进制字节码的运行环境);

  • 好处:

    • 一次编写,到处运行
    • 自动内存管理,垃圾回收机制
    • 数组下标越界检查
  • Java 内存模型规定了 JVM 应该如何使用计算机内存(RAM).广义来讲, Java 内存模型分为两个部分:

    • JVM 内存结构

    • JMM 与线程规范

JVM内存结构

  • JVM 内部使用的Java内存模型, 在逻辑上将内存划分为线程栈(thread stacks)和堆内存 (heap)两个部分.

  • JVM 中,每个正在运行的线程,都有自己的线程栈. 线程栈包含了当前正在执行的方法链/调用链上的所有方法的状态信息.

  • 线程栈上的和堆内存中的对象的存储情况:

    • 如果是原生数据类型的局部变量,那么它的内容就全部保留在线程栈上.
    • 如果是对象引用,则栈中的局部变量槽位中保存着对象的引用地址,而实际的对象内容保存在堆中.
    • 对象的成员变量与对象本身一起存储在堆上, 不管成员变量的类型是原生数值,还是对象引用.
    • 类的静态变量则和类定义一样都保存在堆中.
  • 总结一下:原始数据类型和对象引用地址在栈上;对象、对象成员与类定义、静态变量在堆上.

  • 堆内存又称为“共享堆”,堆中的所有对象,可以被所有线程访问, 只要他们能拿到对象的引用地址.

    • 如果一个线程可以访问某个对象时,也就可以访问该对象的成员变量.
    • 如果两个线程同时调用某个对象的同一方法,则它们都可以访问到这个对象的成员变量.但每个线程的局部变量副本是独立的.
  • 总结一下:虽然各个线程自己使用的局部变量都在自己的栈上,但是大家可以共享堆上的对象,特别地各个不同线程访问同一个对象实例的基础类型的成员变量,会给每个线程一个变量的副本.

JVM整体架构图

img

img

程序计数器Program Counter Register(PC)

  • 是一块较小的内存空间,它可以看作是当前线程锁执行的字节码的行号指示器;

  • 在JVM的概念模型里,字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成.

  • 由于JVM的多线程是是通过线程轮流切换,分配处理器执行时间的方式来实现,在任何一个确定的时刻,一个处理器(对于多核处理器说是一个内核)都只会执行一条线程中的指令.因此,为了线程切换后能回复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这个类内存区域为"线程私有"的内存.

  • 若线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;若正在执行的是本地(Native)方法,这个计数器值则应该为空(undefined).

  • 此内存区域是唯一一个在<<Java虚拟机规范>>中没有规定任何OutOfMemoryError情况的区域

具体的步骤:

1、程序计数器初始化:在程序开始运行之前,程序计数器会被初始化为指向第一条要执行的指令的地址.比如现在是10000;
2、指令执行:CPU从内存中取出程序计数器中存储的指令地址所对应的指令,并执行该指令.在执行完当前指令后,程序计数器会自动加1或者跳转到其他指令的地址,以指向下一条要执行的指令的地址.比如现在是10001;
3、指令跳转:当程序需要进行分支、循环或者函数调用等操作时,程序计数器会被更新为新的指令地址,以便CPU能够跳转到新的位置继续执行程序.这种跳转可以是条件性的或者无条件的,可以通过各种方式实现,例如条件语句、循环语句、函数调用等.
4、中断处理:当计算机遇到硬件故障、系统错误或者其他中断事件时,程序计数器也可能会被更新为中断服务例程的入口地址,以便CPU能够立即跳转到中断处理程序中执行相应的操作.

2.5.1. The pc Register

The Java Virtual Machine can support many threads of execution at once (JLS §17). Each Java Virtual Machine thread has its own pc (program counter) register. At any point, each Java Virtual Machine thread is executing the code of a single method, namely the current method (§2.6) for that thread. If that method is not native, the pc register contains the address of the Java Virtual Machine instruction currently being executed. If the method currently being executed by the thread is native, the value of the Java Virtual Machine’s pc register is undefined. The Java Virtual Machine’s pc register is wide enough to hold a returnAddress or a native pointer on the specific platform.

https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html#jvms-2.5.1

总结
  • 作用: 存储指向下一条指令的地址.
  • 特点:
    • 内存空间较小,线程私有;
    • 不会出现OutOfMemoryError;

Java虚拟机栈Java Virtual Machine Stack

  • Java虚拟机栈也是线程私有的,它的生命周期也线程相同;

  • 栈是有一些列帧(frame)组成,每一次方法调用都会创建一个帧,并压栈,退出方法的时候,修改栈定指针就可以把栈帧中的内容销毁.

  • 栈的大小可能是动态的或固定的,对于Java虚拟机栈这个内存区域规定了两类异常状况:

    • 如果线程需要的stack大小超过限制,则会抛出 StackOverflowError.
    • 如果线程需要一个新的栈帧,但没有足够的内存可分配, 则会抛出 OutOfMemoryError.
栈帧Frame
  • 栈帧是用于支持JVM进行方法调用和方法执行的数据结构.

  • 栈帧随着方法调用而创建,随着方法结束而销毁.

  • 每个栈帧,都包含四个区域:

    • 局部变量表(Local Variable Array)

      • 局部变量的数组包含所有执行期间使用的变量的方法,包括一个参考,所有其他方法参数和局部定义的变量.类方法(即静态方法)的方法参数从0开始,然而,例如方法零槽为其预留.
      • 一个局部变量可以是下表中类型
      类型 说明
      boolean 布尔
      byte 字节
      char 字符
      long 长整型
      short 短整型
      int 整型
      float 单精度
      double 双精度
      reference 引用
      returnAddress 返回值地址
    • 动态链接(Dynamic Linking)

      • 每个栈帧持有一个指向运行是常量池中该栈帧所属方法的引用,以支持方法调用过程的动态连接.
    • 操作数栈(Operand Stack)

    • 方法返回地址(ReturnAddress)

      • 对于 JVM 来说,程序就是存储在方法区的字节码指令,而 returnAddress 类型的值就是指向特定指令内存地址的指针.
  • 栈桢大小缺省为1M,可用参数 –Xss调整大小,例如-Xss256k

线程方法栈(栈)->栈帧(元素)=>方法级别的操作.
栈帧里的操作数栈(栈)->操作数(元素)=> 字节码指令级的操作.
主管的功能不同,层次也不同.

本地方法栈Native Method Stacks

  • 本地方法栈和虚拟机栈锁发挥的作用非常相似,区别在于虚拟机栈执行Java方法(也就是字节码)服务,而本地方法栈则是虚拟机使用到的本地(Native)方法服务;
  • 和虚拟机栈一样,本地方法栈也会在栈深度溢出或者扩展失败的时候分别抛出StackOverflowErrorOutOfMemoryError异常

Java堆Heap

  • Java堆是虚拟机所管理的内存中最大的一块.

  • Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建.

  • Java堆是在运行期间动态分配内存大小,自动进行垃圾回收.

  • Java堆用来存储应用系统创建的对象和数组. Java 的对象可以分为基本数据类型和普通对象.

    • 对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用.比如,把这个引用保存在虚拟机栈的局部变量表中.

    • 对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况:

      • 每个线程拥有一个虚拟机栈.当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配.

      • 其他情况,都是在堆上分配. 注意,像 int[] 数组这样的内容,是在堆上分配的.数组并不是基本数据类型

  • Java堆是垃圾收集器管理的内存区域,因此也被称为GC堆(Garbage Collected Heap)

    • Java堆内存分为**年轻代(Young generation)老年代(Old generation, 也叫 Tenured)**两部分.
    • 年轻代还划分为 3 个内存池,新生代(Eden space)存活区(Survivor space), 在大部分 GC 算法中有 2 个存活区(S0, S1),在我们可以观察到的任何时刻,S0 和 S1 总有一个是空的, 但一般较小,也不浪费多少空间.
    • 可用以下参数调整:
      • -Xms:堆的最小值;
      • -Xmx:堆的最大值;
      • -Xmn:新生代的大小;
      • -XX:NewSize: 新生代最小值;
      • -XX:MaxNewSize:新生代最大值; 例如-Xmx256m
  • 若Java堆中没有内存完成实例分配,并且堆也无法再扩展,Java虚拟机将会抛出OutofMemoryError

    1
    2
    // 堆内存溢出
    java.lang.OutofMemoryError :java heap space.

方法区Method Area/Non-Heap

  • 方法区和Java堆一样是各个线程共享的内存区域

  • 方法区是Java虚拟机规范中定义的一个区域.注意方法区只是规范中的一个定义,抽象地规定了这是一块存储类元数据的空间.

    • 对于HotSpot虚拟机,在JDK1.8之前,方法区的实现是“永久代”(Permanent Generation)
    • 到了JDK1.8,HotSpot虚拟机完全移除了永久代,Metaspace取而代之,即Metaspace是方法区新的实现.
  • 可用以下参数调整:

    • JDK1.7及以前:-XX:PermSize;-XX:MaxPermSize
    • JDK1.8以后:
      • -XX:MetaspaceSize: 指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:
        • 如果释放了大量的空间,就适当降低该值;
        • 如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了话)的情况下,适当提高该值.
      • -XX:MaxMetaspaceSize: 设置元空间的最大值,默认为-1,即不限制或者说只受限于本地内存大小.
      • -XX:MinMetaspaceFreeRatio: 作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集频率.类似的还有-XX:MaxMetaspaceFreeRatio用于控制最大的元空间剩余容量的百分比.
      • -XX:CompressedClassSpaceSize:Metaspace 中的 Compressed Class Space 的最大允许内存,默认值是 1G,这部分会在 JVM 启动的时候向操作系统申请 1G 的虚拟地址映射,但不是真的就用了操作系统的 1G 内存.
  • 若方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常

    • 1.8以前会导致永久代内存溢出
    • 1.8以后会导致元空间内存溢出
方法区
  • 方法区会进行垃圾回收,类会回收,分以下几种情况:
    • 首先该类的所有实例对象都已经从Java堆内存里被回收
    • 其次加载这个类的ClassLoader已经被回收
    • 最后该类的Class对象没有任何引用.
  • 方法区包括以下内容:
    1. 类信息:类的完整信息,包括类的名称、修饰符、父类、接口等都被存储在方法区中,可以理解为一个Java 类在虚拟机内部的表示.
    2. 字段信息:字段信息包括字段名称、字段类型、访问修饰符等.
    3. 方法信息:方法信息包括方法名称、方法返回类型、方法参数类型、方法访问修饰符等.
    4. 构造函数信息:构造函数信息包括构造函数名称、构造函数的参数类型、访问修饰符等.
    5. 运行时常量池:运行时常量池是方法区的一部分,用于存储编译期生成的各种字面量和符号引用.
    6. 静态变量:静态变量是指用static关键字定义的变量,它们的值被存储在方法区中.
    7. 即时编译器编译后的代码:在运行时,JVM会将字节码转换为机器码,以便更快地执行程序.这些机器码被保存在方法区中,以便下次执行时直接使用.

常量池

  • 类文件常量池(Class File Constant Pool):类文件常量池是编译器在编译Java源代码时生成的数据结构,它位于class文件中.

    • 类文件常量池包含了所有的常量,例如字符串、数字、类名、方法名、字段名等信息.在类文件中,每个常量都包含一个标记(tag),用于标识该常量的类型,并且包含了该常量的值.当JVM加载类时,它会将类文件中的常量池加载到运行时常量池中,以供程序运行时使用.
  • 运行时常量池(Runtime Constant Pool):运行时常量池是一个存储在方法区中的数据结构,它是JVM在加载类文件时所创建的.

    • 在类的定义中,每个常量池(constant_pool)的项都包含一个索引,该索引指向运行时常量池中的一个值.运行时常量池中包含了在类中定义的常量、方法和字段的引用等信息,它还包含了在类文件中出现的所有符号引用,例如常量、方法和字段的名称、类型和描述符等.
    • 字符串常量池(String Pool):字符串常量池是一个存储字符串常量的地方,它位于堆内存中.
      • 在Java中,字符串常量池是一种特殊的存储区域,用于存储所有字符串常量.当程序创建一个字符串常量时,JVM首先检查该字符串是否在常量池中已经存在,如果存在,则直接返回该字符串的引用;否则,JVM会将该字符串添加到字符串常量池中,并返回该字符串的引用.
  • 类文件常量池是编译时期生成的,存储在类文件中;

  • 运行时常量池是每个类或接口在加载后生成的,包含类文件常量池中的内容以及其他类型的常量;

  • 字符串常量池是运行时常量池的一部分,用于存储所有字符串字面量

在 JDK 8 之前,JVM 中的运行时常量池是独立于方法区的,而在 JDK 8 之后,JVM 将运行时常量池移动到了方法区中.这意味着运行时常量池现在是方法区的一部分,而不是一个独立的区域.

另外,在 JDK 7 及之前的版本中,字符串常量池是方法区的一部分,而在 JDK 8 及之后的版本中,字符串常量池被移动到了堆内存中.但是需要注意的是,字符串常量池仍然是运行时常量池的一部分,只不过位置发生了变化.

运行时常量池Runtime Constant Pool
  • 运行时常量池是方法区的一部分.Class文件中出了有类的版本,字段,方法,接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行是常量池中.

  • 除了保存Class文件中描述的符号引用外,还会把有符号引用翻译出来的直接引用也存储在运行时常量池中.

    • 运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要要求常量一定只有编译期才能产生,就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行是期间也可以将新的常量放入池中,这种特性被开发人员利用比较多的便是String类的intern()方法;
  • 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常

  • 通过反编译来查看类的信息

    • 编译.java文件生成.class: javac Test.java
    • 反编译.class文件,javap -v Test.class
  • 常量池与串池的关系

    串池(StringTable)

    特征

    • 常量池中的字符串仅是符号,只有在被用到时才会转化为对象
    • 利用串池的机制,来避免重复创建字符串对象
    • 字符串变量拼接的原理是StringBuilder
    • 字符串常量拼接的原理是编译器优化
    • 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中
    • 注意:无论是串池还是堆里面的字符串,都是对象
    • 用来放字符串对象且里面的元素不重复
    • 常量池中的信息,都会被加载到运行时常量池中,但这是a b ab 仅是常量池中的符号,还没有成为Java字符串
    • 注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候时,该字符串才会被创建并放入串池中.
    • 使用拼接字符串常量的方法来创建新的字符串时,因为内容是常量,javac在编译期会进行优化,结果已在编译期确定
    • 使用拼接字符串变量的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值,所以需要使用StringBuilder来创建
    intern()方法 1.8

    调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

    • 如果串池中没有该字符串对象,则放入成功
    • 如果有该字符串对象,则放入失败

    无论放入是否成功,都会返回串池中的字符串对象

    注意:此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象

    intern()方法 1.6

    调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

    • 如果串池中没有该字符串对象,会将该字符串对象复制一份,再放入到串池中
    • 如果有该字符串对象,则放入失败

    无论放入是否成功,都会返回串池中的字符串对象

    注意:此时无论调用intern方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象

    StringTable 垃圾回收

    StringTable在内存紧张时,会发生垃圾回收

    StringTable调优
    • 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间

      1
      -XX:StringTableSize=xxxx
    • 考虑是否需要将字符串对象入池

      可以通过**intern方法减少重复入池**

直接内存

Direct Memory

不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域;如果使用了NIO,这块区域会被频繁使用,在Java堆内可以用DirectByteBuffer对象直接引用并操作;

这块内存不受Java堆大小限制,但受本机总内存的限制,可以通过-XX:MaxDirectMemorySize来设置(默认与堆内存最大值(由-Xmx指定)一样),所以也会出现OOM异常.

  • 属于操作系统,常见于NIO操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受JVM内存回收管理
不使用直接内存 使用直接内存
  • 直接内存的使用

    1
    2
    //通过ByteBuffer申请1M的直接内存
    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1M);
  • 直接内存的释放

    1
    直接内存的回收不是通过JVM的垃圾回收来释放的,而是通过unsafe.freeMemory来手动释放
  • 直接内存的回收机制总结
    • 使用了Unsafe类来完成直接内存的分配回收,回收需要主动调用freeMemory方法
    • ByteBuffer的实现内部使用了Cleaner(虚引用)来检测ByteBuffer.一旦ByteBuffer被垃圾回收,那么会由ReferenceHandler来调用Cleaner的clean方法调用freeMemory来释放内存

内存结构与OOM

区域 是否线程私有 是否发生OOM
程序计数器(PC) ✔️
虚拟机栈(Java Virtual Machine Stacks) ✔️ ✔️
本地方法栈(Native Method Stacks) ✔️ ✔️
方法区(Method Area) ✔️
直接内存 ✔️
堆(Heap) ✔️
  • OOM有几个原因:
    • 内存的容量太小了,需要扩容,或者需要调整堆的空间.
    • 错误的引用方式,发生了内存泄漏.没有及时的切断与 GC Roots 的关系.比如线程池里的线程,在复用的情况下忘记清理 ThreadLocal 的内容.
    • 接口没有进行范围校验,外部传参超出范围.比如数据库查询时的每页条数等.
    • 对堆外内存无限制的使用.这种情况一旦发生更加严重,会造成操作系统内存耗尽.

对象的创建

基础数据类型是在栈上分配还是在堆上分配

  • 首先要看这个数据类型在哪里定义的,有以下三种情况:
    • 如果在方法体内定义的,这时候就是在栈上分配的,直接存储在栈内存的栈帧中的
    • 如果是类的成员变量,这时候就是在堆上分配的,存储在堆内存中的对象中的
    • 如果是类的静态成员变量,在方法区上分配的,存储在方法区的常量池中的

对象的大小

  • 基于 64 位虚拟机, 首先记住公式,对象由 对象头 + 实例数据 + padding 填充字节组成,虚拟机规范要求对象所占内存必须是 8 的倍数,padding 就是干这个的
img
Java Object Size
type size in bytes
boolean 1
byte 1
char 2
short 2
int 4
float 4
long 8
double 8
reference 4(开启UseCompressedOops)/8(关闭UseCompressedOops)
String length * 2 + 4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// -XX:+UseCompressedOops(默认开启)
# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

// -XX:-UseCompressedOops
System.out.println(VM.current().details());
# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.
# Field sizes by type: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
使用JOL查看对象大小
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class JolTest {

public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new A()).toPrintable());
System.out.println(ClassLayout.parseInstance(new B()).toPrintable());
}

}

class A { // 12 字节 mark + class
int a; // 4 字节
byte b; // 1 字节
Integer c = Integer.valueOf(0); // 4 字节的引用
} // 12+4+1+4+3(对齐填充)=24

class B { }//12 字节 mark + class
// 12+4=16
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
com.holelin.sundry.test.jvm.A object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
8 4 (object header: class) 0x00066a48
12 4 int A.a 0
16 1 byte A.b 0
17 3 (alignment/padding gap)
20 4 java.lang.Integer A.c 0
Instance size: 24 bytes
Space losses: 3 bytes internal + 0 bytes external = 3 bytes total

com.holelin.sundry.test.jvm.B object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
8 4 (object header: class) 0x000e73b8
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

对象的内存布局

HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:

  • 对象头(Header)

  • 实例数据(Instance Data)

  • 对齐填充(Padding)

对象头
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|-------------------------------------------------------|--------------------|

|------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | Normal |
|------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | Lightweight Locked |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | Heavyweight Locked |
|------------------------------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|------------------------------------------------------------------------------|--------------------|
  • HotSpot 虚拟机对象的对象头部分包括两类信息.

    • 第一类是用于存储对象自身的运行时数据.如哈希码(HashCode)、 GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 个比特和 64 个比特,官方称它为“Mark Word”.

      存储类型 标志位 状态
      对象哈希码,对象分代年龄 01 未锁定
      指向锁记录的指针 00 轻量级锁定
      指向重量级锁的指针 10 膨胀(重量级锁定)
      空,不需要记录信息 11 GC标记
      偏向线程ID,偏向时间戳,对象分代年龄 01 可偏向
    • 对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针, Java 虚拟机通过这个指针来确定该对象是哪个类的实例.并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身.此外如果对象是一个Java数据组,那么对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数据的长度是不确定的,将无法通过元数据中的信息推断出数组的大小.

实例数据
  • 实例数据部分是对象真正存储的有效信息,即在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是子类中定义的字段都必须记录起来.
    • 这部分的存储顺序会受到虚拟机分片策略参数-XX:FieldAllocationStyle参数和字段在Java源码中定义顺序影响
  • HotSpot虚拟机默认的分配顺序为longs/doubles,ints,shorts/chars,bytes/booleans,oops(Ordinary Object Pointers,OOPs).相同宽度的字段总是被分配到一起存放,在满足这个前提条件下,在父类中定义的变量会出现在子类的之前.
    • 如果HotSpot虚拟机的-XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间.
对齐填充
  • 这并不是必然存在,也没有特别含义,它仅起到占位符的作用.由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即任何对象的大小都必须是8字节的整数倍.

Java中创建对象的方式

  • 通过new: 通过调用构造器来初始化实例字段

  • 通过反射机制: 通过调用构造器来初始化实例字段

    • 使用ClassnewInstance方法
    • 使用Constructor类的newInstance方法
  • Object.clone方法: 通过直接复制已有的数据,来初始化新建对象的实例字段

  • 反序列化: 通过直接复制已有的数据,来初始化新建对象的实例字段

  • Unsafe.allocateInstance方法: 没有初始化对象的实例字段

构造器

  • 如果一个类没有定义任何构造器的话, Java 编译器会自动添加一个无参数的构造器.
  • 然后,子类的构造器需要调用父类的构造器.如果父类存在无参数构造器的话,该调用可以是隐式的,也就是说 Java 编译器会自动添加对父类构造器的调用.但是,如果父类没有无参数构造器,那么子类的构造器则需要显式地调用父类带参数的构造器.
  • 显式调用又可分为两种,一是直接使用“super”关键字调用父类构造器,二是使用“this”关键字调用同一个类中的其他构造器.无论是直接的显式调用,还是间接的显式调用,都需要作为构造器的第一条语句,以便优先初始化继承而来的父类字段.
  • 总而言之,当我们调用一个构造器时,它将优先调用父类的构造器,直至 Object 类.这些构造器的调用者皆为同一对象,也就是通过 new 指令新建而来的对象.
  • 通过 new 指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段.也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的.

对象的访问定位

  • Java程序会通过栈上的reference数据来操作堆上的具体对象.由于reference类型在Java虚拟机规范里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位,访问到堆中对象的具体位置,所以对象访问也是由虚拟机实现而定的.

  • 主流的访问方式有使用句柄直接指针两种.

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

    • 使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集是移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改.
  • 使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销.

    • 使用直接指针来访问的最大好处就是速度更快,它节省了一次指针定位的时间开销.HotSpot主要使用直接指针的方式进行对象访问.

Java字节码

  • Java 中的字节码,英文名为 bytecode, 是 Java 代码编译后的中间代码格式.JVM 需要读取并解析字节码才能执行相应的任务.

  • Java 字节码是 JVM 的指令集.JVM 加载字节码格式的 class 文件,校验之后通过 JIT 编译器转换为本地机器代码执行.

  • Java bytecode 由单字节(byte)的指令组成,理论上最多支持 256 个操作码(opcode).实际上 Java 只使用了 200 左右的操作码, 还有一些操作码则保留给调试操作.

  • 操作码, 下面称为 指令, 主要由类型前缀操作名称两部分组成.

    1
    例如,'i' 前缀代表 ‘integer’,所以,'iadd' 很容易理解, 表示对整数执行加法运算.
  • 根据指令的性质,主要分为四个大类:

    1. 栈操作指令,包括与局部变量交互的指令
    2. 程序流程控制指令
    3. 对象操作指令,包括方法调用指令
    4. 算术运算以及类型转换指令

获取字节码清单

  • 可以用javap工具拉力获取class文件中的清单.

    1
    2
    javac XXX.java
    javap -c XXX

查看 class 文件中的常量池信息

1
javap -c -verbose XXX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Classfile /Users/holelin/Projects/MySelf/Java-Notes/sundry/src/main/java/com/holelin/sundry/test/jvm/HelloByteCode.class
Last modified 2022年9月10日; size 316 bytes
MD5 checksum bf061f9200f9b71239e0436bef467b1c
Compiled from "HelloByteCode.java"
public class com.holelin.sundry.test.jvm.HelloByteCode
minor version: 0
major version: 55
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #2 // com/holelin/sundry/test/jvm/HelloByteCode
super_class: #4 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // com/holelin/sundry/test/jvm/HelloByteCode
#3 = Methodref #2.#13 // com/holelin/sundry/test/jvm/HelloByteCode."<init>":()V
#4 = Class #15 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 HelloByteCode.java
#13 = NameAndType #5:#6 // "<init>":()V
#14 = Utf8 com/holelin/sundry/test/jvm/HelloByteCode
#15 = Utf8 java/lang/Object
{
public com.holelin.sundry.test.jvm.HelloByteCode();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class com/holelin/sundry/test/jvm/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
LineNumberTable:
line 5: 0
line 6: 8
}

字节码文件信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Class文件当前所在位置
Classfile /sundry/src/main/java/com/holelin/sundry/test/jvm/HelloByteCode.class
// 最后修改时间 ; 文件大小
Last modified 2022年9月10日; size 316 bytes
// MD5值
MD5 checksum bf061f9200f9b71239e0436bef467b1c
// 编译自哪个文件
Compiled from "HelloByteCode.java"
// 类的全限定名
public class com.holelin.sundry.test.jvm.HelloByteCode
// JDK次版本号
minor version: 0
// 主版本号
major version: 55
// 该类的访问标志
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  • 访问标志的含义如下:

    标志名称 标志值 含义
    ACC_PUBLIC 0x0001 是否为public类型
    ACC_FINAL 0x0010 是否被声明为final,只有类可以设置
    ACC_SUPER 0x0020 是否允许使用invokespecial字节码指令的新语义
    ACC_INTERFACE 0x0200 标志这是一个接口
    ACC_ABSTRACT 0x0400 是否为abstract类型,对于接口或者抽象类来说, 次标志值为真,其他类型为假
    ACC_SYNTHETIC 0x1000 标志这个类并非由用户代码产生
    ACC_ANNOTATION 0x2000 标志这是一个注解
    ACC_ENUM 0x4000 标志这是一个枚举

常量池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // com/holelin/sundry/test/jvm/HelloByteCode
#3 = Methodref #2.#13 // com/holelin/sundry/test/jvm/HelloByteCode."<init>":()V
#4 = Class #15 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 HelloByteCode.java
#13 = NameAndType #5:#6 // "<init>":()V
#14 = Utf8 com/holelin/sundry/test/jvm/HelloByteCode
#15 = Utf8 java/lang/Object
  • Constant pool意为常量池. 常量池可以理解成Class文件中的资源仓库.主要存放的是两大类常量:字面量(Literal)和符号引用(Symbolic References).字面量类似于java中的常量概念,如文本字符串,final常量等,而符号引用则属于编译原理方面的概念,包括以下三种:

    • 类和接口的全限定名(Fully Qualified Name)
    • 字段的名称和描述符号(Descriptor)
    • 方法的名称和描述符
  • 字节码的类型对应如下:

    标识字符 含义
    B 基本类型byte
    C 基本类型char
    D 基本类型double
    F 基本类型float
    I 基本类型int
    J 基本类型long
    S 基本类型short
    Z 基本类型boolean
    V 特殊类型void
    L 对象类型,以分号结尾,如Ljava/lang/Object;
    [ 对于数组类型,每一位使用一个前置的"[“字符来描述,如定义一个java.lang.String[][]类型的维数组,将被记录为”[[Ljava/lang/String;"

方法表集合

1
2
3
4
5
6
7
8
9
10
public com.holelin.sundry.test.jvm.HelloByteCode();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
  • Code内的主要属性为:

    属性 说明
    stack 最大操作数栈,JVM运行时会根据这个值来分配栈帧(Frame)中的操作栈深度
    locals 局部变量所需的存储空间,单位为Slot, Slot是虚拟机为局部变量分配内存时所使用的最小单位,为4个字节大小.方法参数(包括实例方法中的隐藏参数this),显示异常处理器的参数(try catch中的catch块所定义的异常),方法体中定义的局部变量都需要使用局部变量表来存放.值得一提的是,locals的大小并不一定等于所有局部变量所占的Slot之和,因为局部变量中的Slot是可以重用的
    args_size 方法参数的个数,每个实例方法都会有一个隐藏参数this
    attribute_info 方法体内容,0,1,4为字节码"行号",该段代码的意思是将第一个引用类型本地变量推送至栈顶,然后执行该类型的实例方法,也就是常量池存放的第一个变量,也就是注释里的"java/lang/Object.“”:()V", 然后执行返回语句,结束方法
    LineNumberTable 该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系.可以使用-g:none-g:lines选项来取消或要求生成这项信息,如果选择不生成LineNumberTable,当程序运行异常时将无法获取到发生异常的源码行号,也无法按照源码的行数来调试程序
    LocalVariableTable 该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系.可以使用-g:none -g:vars来取消或生成这项信息,如果没有生成这项信息,那么当别人引用这个方法时,将无法获取到参数名称,取而代之的是arg0, arg1这样的占位符.
    Start 表示该局部变量在哪一行开始可见,
    length表示可见行数,
    Slot代表所在帧栈位置,
    Name是变量名称,然后是类型签名

线程栈与字节码执行模型

  • JVM是一台基于栈的计算机器.每个线程都有一个独属于自己的线程栈(JVM stack),用于存储栈帧(Frame).每一次方法调用,JVM都会自动创建一个栈帧.
  • 栈帧操作数栈,局部变量数组以及一个class 引用组成.class 引用指向当前方法在运行时常量池中对应的 class).
  • 局部变量数组也称为局部变量表(LocalVariableTable), 其中包含了方法的参数,以及局部变量. 局部变量数组的大小在编译时就已经确定: 和局部变量+形参的个数有关,还要看每个变量/参数占用多少个字节.操作数栈是一个 LIFO 结构的栈, 用于压入和弹出值. 它的大小也在编译时确定.

附录

JVM内存模型中 5个区域是如何串联起来的

JVM内存模型一共分为5个区域,分别是程序计数器、虚拟机栈、堆、方法区和运行时常量池.这些区域之间是如何串联起来的,可以通过一个具体的示例来说明.

假设我们有一个Java程序,其中包含一个类Test,该类定义了一个静态方法main,代码如下:

1
2
3
4
5
6
7
8
public class Test {
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
System.out.println(c);
}
}

当我们执行该程序时,JVM会首先创建一个程序计数器,用来记录当前线程执行的位置.在该示例中,程序计数器一开始会记录main方法的第一条指令的位置.

接下来,JVM会为该线程创建一个JVM虚拟机栈,用来存储方法调用时的相关信息.在该示例中,JVM会为main方法创建一个栈帧,并将其压入JVM虚拟机栈中.栈帧中包含了main方法的局部变量表、操作数栈、方法出口等信息.

在main方法的第一条指令中,JVM会为a变量分配内存空间,并将其初始化为1.此时,变量a会被存储在JVM虚拟机栈的局部变量表中.

在main方法的第二条指令中,JVM会为b变量分配内存空间,并将其初始化为2.此时,变量b会被存储在局部变量表中.

在main方法的第三条指令中,JVM会从局部变量表中获取a和b变量的值,并将它们相加.此时,操作数栈中会存储a和b的值,并执行加法操作,将结果存储在操作数栈的顶部.

在main方法的第四条指令中,JVM会将操作数栈顶部的值(即变量c的值)存储在堆中,并在运行时常量池中存储一份常量池中的字符串"2".

在main方法的最后一条指令中,JVM会调用System.out.println方法,将变量c的值输出到控制台.在这个过程中,JVM会在运行时常量池中查找System.out和println方法,并在堆中创建一个PrintStream对象.然后,JVM会将PrintStream对象和常量池中的字符串"3"传递给println方法,最终在控制台上输出结果3.

在上述示例中,JVM内存模型中的5个区域是如何串联起来的呢?程序计数器用来记录当前线程执行的位置,JVM虚拟机栈用来存储方法调用时的相关信息,包括局部变量表、操作数栈、方法出口等.局部变量表和操作数栈用来存储方法中的局部变量和操作数.堆用来存储Java对象和数组实例,其中包括了变量c所引用的Integer对象和PrintStream对象.方法区用来存储已加载的类信息、常量、静态变量、编译后的代码等,其中包括了Test类的定义和System类的定义.

总的来说,JVM内存模型中的5个区域是相互关联、相互作用的,它们共同构成了Java程序的运行环境.程序计数器、虚拟机栈、堆、方法区和运行时常量池是按照一定的逻辑顺序排列的,它们通过各自存储的信息和引用,相互联系、相互作用,共同实现了Java程序的运行.