声明:本文集中的文章都是看了《深入理解Java
虚拟机》所做的笔记,很多内容是从书中摘抄,特此声明。这里主要学习一些基本的概念,真正掌握可能还需要多加实践。
一、运行时数据区域
注:图片来自//www.greatytc.com/p/6173a467165e
Java
虚拟机在执行Java
程序的过程中会把它管理的内存划分为若干个不同的数据区域,根据《Java
虚拟机规范(Java SE 7
版)》的规定,Java
虚拟机所管理的内存将会包含如图所示的几个运行时区域。
1.1 程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。如果线程正在执行的是一个Java
方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native
方法,这个计数器值则为空(Undefined
)。此内存区域是惟一一个在Java
虚拟机规范中没有规定任何OutOfMemoryError
情况的区域。
1.2 Java虚拟机栈
- 与程序计数器一样,
Java
虚拟机栈(Java Virtual Machine Stacks
)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java
方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame
)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。 -
局部变量表存放了编译期可知的各种基本类型(
boolean、byte、char、short、int、float、long、double
)、对象引用(reference
类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress
类型(指向了一条字节码指令的地址) - 其中
64
位长度的long
和double
类型的数据会占用两个局部变量空间(Slot
),其余的类型只占一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。 - 在
Java
虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowEorror
异常;如果虚拟机可以动态扩展(虚拟机栈大小也可以是固定的),如果扩展时无法申请到足够的内存,将抛出OutOfMemoryError
异常。
1.3 本地方法栈
本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java
方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native
方法服务。虚拟机规范中对本地方法中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(如Sun HotSpot
)直接就把本地方法栈和虚拟机栈合二为一。
1.4 Java堆
对于大多数应用来说,
Java
堆(Java Heap
)是Java
虚拟机所管理的内存中最大的一块。Java
堆是被所有线程共享的一块内存区域。在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。在Java
规范中的描述是:所有对象实例以及数组都要在堆上分配,但是随着技术的发展,也不是那么绝对了。Java
堆是垃圾收集器的主要区域,因此很多时候也被称为“GC
堆”。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java
堆中还可以细分为:新生代和老年代;再细致一点有Eden
空间、From Survivor
空间、To Survivor
空间等。从内存分配角度看,线程共享的
Java
堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB
)。根据Java
虚拟机规范的规定,Java
堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可。可以通过-Xmx
指定可用最小内存,通过-Xms
指定可用最大内存。
1.5 方法区
方法区与
Java
堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java
虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap
(非堆),目的是和堆区分。Java
虚拟机规范对方法区的限制非常宽松,除了和堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不是先垃圾收集。垃圾收集在这个区域比较少见,但是并非进入此区域的数据就永久存在了,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,但是回收效果不是太好。
1.6 运行时常量池
运行时常量池(Runtime Constant Pool
)是方法区的一部分。Class
文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant poo Table
),用于存放编译器期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
1.7 直接内存
直接内存(
Direct Memory
)并不是虚拟机运行数据区的一部分,也不是Java
虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError
异常出现。在
JDK1.4
中新加入了NIO
类,引入了一种基于通道与缓冲区的I/O
方式,它可以使用Native
函数库直接分配堆外内存,然后通过一个存储在Java
堆中的DirectByteBuffer
对象作为这块内存的引用进行操作。这样能在一些场景显著提高性能,因为避免了在Java
堆和Native
堆中来回复制数据。
二、HotSpot虚拟机对象探秘
2.1 对象的创建
在语言层面上,创建对象通常仅仅是一个new
关键字而已,现在看在虚拟机中是怎样的过程。
虚拟机遇到一条
new
指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程,这在后面详细讨论。-
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全去确定。为对象分配空间的任务等同于把一块确定大小的内存从
Java
堆中划分出来,一般有两种分配方式:-
指针碰撞(
Bump the Point
):如果堆是绝对规整的,用过的内存和没用过的内存分开放置,中间使用一个指针作为分界点的指示器,那分配的时候只需要将指针移动所需大小的内存即可。 -
空闲列表(
Free List
):如果堆内存不是规整的,则需要维护一个列表来记录哪些内存是可用的,哪些是不可用的,在分配的时候需要根据这个表来进行分配。
选择哪种分配方式有Java
堆是否规整决定,而Java
堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,使用Serial、ParNew
等带Compact
过程的收集器时,采用前一种;而使用CMS
这种基于Mark-Sweep
算法的收集器时,采用后一种。
-
指针碰撞(
-
在分配内存还需要考虑一个问题,就是创建对象在虚拟机中是很频繁的,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能同一块内存会同时分配给多个线程。解决这个问题有两种方案:
- 一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用
CAS
配上失败重试的方式保证更新操作的原子性。 - 另一种是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在
Java
堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB
)。哪个线程要分配内存就在哪个线程的TLAB
上分配,只有TLAB
用完并分配新的TLAB
时,才需要同步锁定。虚拟机是否使用TLAB
,可以通过-XX:+/-UserTLAB
参数来设定。
- 一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零(不包括对象头),如果使用
TLAB
,这一工作过程也可以提前至TLAB
分配时进行,这一步操作保证了对象的实例字段在Java
代码中可以不赋初值就可以直接使用。之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的
GC
分代年龄等信息,这些信息都存放在对象头(Object Header
)中。在上面工作完成后,从虚拟机角度看,一个新对象已经完成,但从
Java
程序的角度看,对象创建才刚刚开始——<init>
方法还没执行,所有字段都还为零。一般来说,执行new
指令后会接着执行<init>
方法,把对象按照程序员的意思进行初始化,这样一个对象才算真正产生出来。
2.2 对象的内存布局
在HotSpot
虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header
)、实例数据(Instance Data
)和对齐填充(Padding
)。
对象头包括两部分信息:
- 第一部分用于存储对象自身的运行时数据,如哈希码、
GC
分代年龄、锁状态标志、线程持有的锁、偏向线程ID
、偏向时间戳等,这部分数据的长度在32
位和64
位的虚拟机(未开启压缩指针)中分别为32bit
和64bit
,官方称为"Mark Word"
。其实对象要存储的运行时数据很多,其实已经超出32
位、64
位Bitmap
能记录的限度,但是Mark Word
被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的装填复用存储空间。如32
位HotSpot
虚拟机中,如果对象处于未被锁定的状态下,那么25bit
存储对象哈希码,4bit
存储对象分代年龄,2bit
存储锁标志位,1bit
固定为0
,而在其他状态如下对象的存储内容如下:
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、对象的分代年龄 | 01 |
未锁定 |
指向锁记录的指针 | 00 |
轻量级锁定 |
指向重量级锁的指针 | 10 |
膨胀(重量级锁定) |
空,不需要记录信息 | 11 |
GC 标记 |
偏向线程ID 、偏向时间戳、对象分代年龄 |
01 |
可偏向 |
对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,也就是说查找对象的元数据信息并不一定要经过对象本身,这在后面讨论。如果对象是一个
Java
数组,那么对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java
对象的元数据确定Java
对象的大小,但是从数组的元数据中却不能确定数组的大小。对象头之后便是实例数据部分。即对象真正存储的有效信息,也是在程序代码中定义的各种类型的字段内容(父类继承的、子类中定义的都要记录)。存储的策略受到虚拟机分配策略参数(
FieldsAllocationStyle
)和字段在Java
源码中定义顺序的影响。默认分配策略为long/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)
,相同宽度的字段总是被分配在一起。在满足这个条件下,在父类中定义的变量会在子类之前。如果CompactFields
参数值为true
(默认),那么子类中较窄的变量可能会插入到父类变量的空隙中。第三部分对齐填充不是必然存在的,也没有特别含义,仅仅起着占位符的作用。由于虚拟机的自动内存管理系统要求对象起始地址必须是把八字节的整数倍,即对象的大小必须是八字节的整数倍,如果对象不够八字节的整数倍则需要填充对齐。
2.3 对象的访问定位
建立对象是为了使用对象,我们的Java程序需要通过栈上的reference
数据来操作堆上的具体对象。由于reference
类型在Java
虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。
-
如果使用句柄访问的话,那么
Java
堆中将会划分出一块内存来作为句柄池,reference
中存储的就是对象的句柄地址,而句柄中包含了对象实例类型与类型数据各自的具体地址信息。如图所示。
-
如果使用直接指针访问,那么
Java
堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference
中存储的直接就是对象地址。如图所示。
这两种对象访问方式各有优势,使用句柄来访问最大的好处就是reference
中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference
本身不需要修改。使用直接指针访问方式最大的好处就是速度更快。对于Sun HotSpot
而言,使用的是第二种方式进行对象访问的。