JVM学习笔记(1)
本文是我学习JAVA虚拟机时记录的学习笔记,主要知识点来自于《深入理解Java虚拟机:JVM高级特性与最佳实践》(作者:周志华),以及互联网,图片来自互联网。
一、JVM内存模型
上图是JAVA的运行时内存区域即JVM虚拟机在解释运行JAVA程序时的动态视图。
1.1 根据是否线程私有区分
线程私有
- 虚拟机栈
- 本地方法栈
- 程序计数器
线程共用
- 堆
- 元空间
- 直接内存
1.2 各内存空间的作用
虚拟机栈
虚拟机栈平时我们简称称栈,虚拟机栈由一个个栈帧组成,可以把模型理解为JAVA容器中的栈。
每个栈帧由局部变量表、操作数栈、动态链接、方法出口信息等,每个JAVA方法开始执行就会进行一次入栈,方法返回结束就出栈。
局部变量表
主要存放:八种基础类型、引用类型和returnAddress 类型(指向了一条字节码指令的地址)。
局部变量表由一个个4字节长度(64位虚拟机就是8字节长度)的slot(槽)构成,8字节的double和long要占两个slot(64位虚拟机只占一个)。
在编译阶段就能确认局部变量表的大小(这个大小指的是需要几个slot)。
栈会出现的异常
如果线程请求的栈深度大于虚拟机所允许的深度(深度由栈大小控制,JVM参数 -Xss),将抛出StackOverflowError异常。
如果栈无法申请到足够的内存会抛出OutOfMemoryError异常。
本地方法栈
本地方法栈和虚拟机栈基本一样,区别是本地方法栈是为运行本地方法服务的,但是在一些虚拟机中会将二者合二为一(如Hotspot)。
程序计数器
- 是一块小内存,可以被视为是线程执行的当前字节码的行号指示器。
- JVM的字节码解释器通过改变这个计数器的值来选取下一条执行的指令。分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成
- 因为JVM是为多线程程序设计的,不同线程运行的指令位置不同,所以这个区域需要线程私有
- 这个区域是JVM规范中唯一没有规定任何OutOfMemoryError异常的地方
堆
堆是虚拟机所管理的最大的一块内存,是所有对象共享的,是由垃圾收集器(GC)管理的内存区域。
堆不一定是物理上连续的存储空间,但是在逻辑上是连续的。但对于大对象(数组对象等),处于简单实现和高效的考虑,虚拟机可能会要求连续的内存空间。
堆可以设置为固定大小,也可以设置为动态大小(根据-Xmx、-Xms参数)。另外进入云原生时代后,JVM也引入了一些新的参数来控制堆大小以适应容器化的部署环境。
堆的唯一作用就是存放对象实例。但值得注意,并不是所有对象都在堆上分配,由于即时编译、逃逸分析技术的发展,栈上分配和标量替换等优化手段使得 “JAVA对象都在对象分配” 这句话已成为过去式。
堆的分区和垃圾分代收集
堆怎么分区取决于采用的垃圾收集器,目前常用的几款垃圾收集器都基于经典分代设计,分区是为了方便进行分代垃圾收集。一般堆分区为
- 新生代,又具体细分为:
- Eden空间
- From Survivor(s0)
- To Survivor(s1)
- 老年代,经过了几次垃圾收集的对象如果还存活就会进入老年代。超过一定大小的大对象也会直接被分配到老年代。
但堆的分区不是《Java虚拟机规范》的划分,比如G1收集器就没有新生代、老年代的概念而是采用了将堆分割为多个大小相等的Region进行收集。
元空间(Metaspace)、方法区
方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
不要将方法区和永久代混淆。在概念上
- 方法区 —— 《Java虚拟机规范》规定的一个内存逻辑空间
- 永久代(perm space) —— Hotspot虚拟机1.7及之前版本实现方法区的方式,永久代也是堆的一部分,也就是说GC也可以管理这部分内存。但是不能说永久代就是方法区,准确的说法是方法区在永久代中,永久代还包括字符串常量池等内存空间。
Hotspot在JDK>=1.8版本已经取消了永久代这个区域,改为使用直接内存的Metaspace来实现方法区。同样的道理,因为方法区和元空间在概念意义上不同所有不能说元空间就是方法区。
个人理解可以用面向对象的思维理解,方法区可以理解是Interface,由虚拟机规范制定,元空间或永久代可以理解Hotspot设计来实现了这个Interface的Class,同时所有的企业都可以用自己的方式来设计实现方法区这个逻辑概念。我们不能等价的看待接口和接口的实现类。
运行时常量池
类加载时会将Class文件中的常量池表(Constant Pool Table)存放到运行时常量池中。
运行时常量池与类常量池表的区别
-
Class文件(包括类常量池)格式非常严格,精确到每个字节。而运行时常量池根据不同的虚拟机有不同的实现。
-
另外一个重要特征是运行时常量池具备动态性,Java并不要求常量一定只有编译期才能产生,编译时也可以产生新的常量。一个典型的例子就是String的intern()方法
直接内存
- 不属于堆,不属于垃圾收集器管理的地方,大小取决于本机总内存。
- 一般在NIO时使用,可以直接在堆外分配内存,然后通过一个堆中的DirectByteBuffer对象对这块内存进行引用操作,避免了堆内堆外的数据复制。
1.3 JAVA几种常量池
JAVA常量池之前我经常混淆,这边特别做笔记。JAVA的三个常量池分别是字符串常量池、Class文件常量池、运行时常量池。
字符串常量池
字符串常量池是在堆内分配的一块空间实现的,只用于存储String对象。在**<=1.6版本只能存储对象,>=1.7版本**存储的既可能是对象也可能是引用。字符串常量池也会被GC管理,也有垃圾回收。
>=1.7版本,判断字符串常量池中存的是对象还是引用?
当调用String.intern()方法时,如果字面量在常量池不存在,即将String对象的引用存入常量池,并且将其返回。如果存在就返回常量池中的字面量的引用。
Class文件常量池
这个常量池是静态的常量池,实际上是Class文件的一个组成部分。存储位置在磁盘上
Class文件常量池存放类编译阶段生成的一系列信息,包括:
- 类的版本
- 类的字段
- 类的方法
- 类的接口
运行时常量池
运行时常量池是方法区的组成部分,存储位置在使用直接内存的metadata中。
二、JVM对象
2.1 JVM构建对象的过程
Hotspot构建对象的过程可以分为五部,先后顺序是:
- 类加载检查
- 分配内存
- 初始化零值
- 设置对象头
- 执行init方法
类加载检查
类加载包括加载、验证、准备、解析、初始化 5个阶段。
分配内存
对象所需的内存大小在类加载后就可以确认。分配内存就是从堆中选取一块确定大小的块来存放要构建的对象。
分配内存的两种方法
内存的分配方法取决于堆的状态是否是规整的(即内存是一块一块分散的或者是空闲的在一边非空闲的在另一边),而堆是否规整又取决于JVM所使用的垃圾收集器是否带有空间压缩整理功能。
内存分配的主体思想就是指针碰撞和空闲列表两种方式。另外还有一些结合了这两种方法的方法。
指针碰撞
这种方法适用于规整的堆,方法很简单,用一个指针分隔已分配的内存和未分配的内存。每次分配时只要让指针往一个方向移动即可。使用Serial、ParNew收集器时使用这种分配方式。
空闲列表
这种方式适用于不规整的堆。需要维护一个空闲内存区域的列表。每次需要分配时从列表找一块足够大的区域分给对象。使用CMS时使用这种分配方式。
分配内存时的线程安全
对象创建是非常频繁了,因此需要保证对象内存分配时不出现并发问题。有两种方法:
同步方法
对分配内存空间的动作进行同步处理,实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。
TLAB
TLAB称为本地线程分配缓冲(Thread Local Allocation Buffer)。方法是预先给从堆中划分一定的区域每个线程分配一个缓冲区,当该线程要创建对象时首先从缓冲区分配空间,仅当缓冲区分配完后才采用第一种同步方法。是否启用TLAB,可以通过-XX: +/-UseTLAB来设定。
初始化零值
将分配给对象的内存全部初始化为零。如果使用了TLAB的话,这一项工做也可以提取到TLAB分配内存时顺便进行。
初始化零值的目的是为了实例字段可以不赋值就使用。
设置对象头
设置的对象头包括但不限于以下内容
- 对象是哪个类的实例
- 如何找到类的元数据信息
- 对象的哈希码(实际上在调用**Object::hashcode()**时才会设置)
- 对象的CG分代年龄
执行init()方法
执行定义的构造函数给对象的字段赋值。
2.2 对象的内存布局
对象内存布局主要分为3部分,对象头、实例数据、对齐部分。
对象头
包括两部分信息
Mark Word
第一部分用于存储对象自身的运行时数据**(哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等),这部分数据的总长度为32bit或64bit取决于虚拟机是32位还是64位,这部分数据被称为Mark Word**。
出于节约存储空间的目的,Mark Word 被设计成一个有着动态定义的数据结构可以根据对象的状态来复用自己的存储空间。也就是说对象状态不同的时候,Mark Word 里存储的数据不同。
以32位Hotspot为例,当对象处于未锁定状态,32bit分别用于
- 25个bit存储对象哈希码
- 4个bit存储对象分代年龄
- 2个bit存储锁标准位
- 1个bit固定为0
以下是各状态的存储内容情况
类型指针
另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。(但并非所有虚拟机实现都必须在对象数据上保留类型指针)
实例数据
实例数据就是对象中定义的各字段的数据,包括父类继承下来的和子类自己定义的。
这部分存储顺序会受虚拟机分配策略参数(-XX:FieldsAllocationStyle参数,)和字段在Java源码中定义顺序(父类定义的变量会在子类变量之前)的影响。如果没有指定策略,按照默认的分配顺序是longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)。这个分配策略的目的是要将相同宽度的字段分配到一起存放。
对齐填充
对齐部分也没什么好说的,没有什么含义,仅仅起到占位4的作用。因为hotspot虚拟机内存管理要求内存的起始地址必须是8字节的整数倍。因此分配的每个对象的内存大小也必须是8字节的倍数。
2.3 对象的访问定位
《Java虚拟机规范》只规定了reference类型是一个指向对象的引用,但并未强制定义这个引用是通过什么方式区定位、访问到堆中的对象,所以不同的虚拟机有不同的实现方式。主流的是两种方式,使用句柄访问和直接指针。
这两种访问方式的图解如下
使用句柄
使用句柄的话需要在堆中再划分一块内存来作为句柄池,reference中存储的是对象的句柄地址,句柄中存放对象实例数据和类型数据各自的地址。
句柄方式的好处是在GC进行垃圾收集移动对象的位置的时候只需要更新句柄池的值而不需要去更新reference的值。缺点是访问对象的时候要多一次访问、
直接指针
直接指针就很简单,reference中存储的就是对象的地址,优缺点就是把使用句柄的优缺点颠倒一下。Hotspot虚拟机采用的就是使用直接指针的方式(使用Shenandoah收集器是一个例外情况)。