0. 前言
JVM笔记系列,以JDK1.7为基准,主要以《深入理解Java虚拟机》(第二版)和《Java虚拟机规范(Java SE 7版)》 为参考,主要包括下图所示的五部分内容:1.类加载,2.内存区域,3.垃圾回收,4.JVM参数,5.JVM监控工具。
本人是Java程序员,重点关注这些有助于优化开发、性能调优、问题解决等这些和具体生产密切相关的部分;关于Class的文件结构、编译、指令等部分,可以阅读上述书籍或其它材料。
本文主要记录JVM内存区域结构的相关知识,本文的主要知识点如下:
1. JVM内存区域结构
JVM定义了若干程序运行期使用到的数据区,其中一些随着JVM进程启动而创建,随着JVM退出而销毁;另一些则是与线程一一对应,随着线程的启动和结束而建立和销毁。JVM的运行时数据区分为5个部分,如下图所示,分别是程序计数器、Java栈、Native方法栈、堆、方法区。
1.1 程序计数器(Program Counter)
- 程序计数器占用非常小的内存,指向下一条指令的地址。
- 每个线程拥有一个程序计数器。
- 程序计数器在线程创建时创建。
- 如果是Java方法,程序计数器指向字节码指令的地址。
- 如果是Native方法,程序计数器值则为空(Undefined)。
- 程序计数器不会出现OutOfMemoryError。
1.2 Java栈
- Java栈是线程私有的,生命周期和线程相同。
- 栈是由一系列栈帧组成的。
- 每个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 每一个方法被调用到执行完成的过程,就是一个栈帧在JVM从入栈到出栈的过程。
JVM规范中描述,Java栈可能会出现两种异常。
- StackOverflowError:线程请求的栈深大于虚拟机所允许的深度(例如无限递归)。
- OutOfMemoryError:虚拟机栈可以动态扩展,如果扩展无法申请足够的内存时,就会报出。
1.3 Native方法栈
本地方法栈和Java栈是非常相似的,Java栈是为了执行Java方法服务,本地方法栈是为了执行Native方法使用。在HotSpot虚拟机中,Java栈和本地方法栈合二为一。
1.4 堆(Heap)
- Java堆是JVM所管理的内存中最大的一块,生命周期和JVM进程相同。
- 用于存放对象实例,几乎所有的对象都在Heap上。
- Java堆是所有线程共享的空间。
从垃圾回收的角度来说,Java堆分为新生代和老生代,其中新生代还分为Eden、From Survivor(S0)、To Survivor(S1)三部分,如下图所示。
默认参数下,新生代:老生代 = 1:2,Eden:Survivor = 8:1。Java堆中最大可用内存 = 老生代+ Eden + Survivor*1,即S0和S1永远有一个处于闲置的状态,GC的时JVM候会把其中一个Survivor中存活的对象复制到另一个Survivor中。
- Eden区是Java实例对象优先分配的区域,如果Eden没有足够的空间,将会执行一次Minor GC。
- 经过Minor GC后,Eden+S0(或者S1)中还存活的对象将会转移到S1中,然后S0会被清空。
- Survivor中放不下的、存活次数超过一定数目的对象,会被转移到老年代(Old)空间,大对象也可能会直接分配到老年代(Old)空间。
- 当老年代(Old)空间不够时,将会发生Major GC。
- 如果垃圾回收后,仍然没有足够的空间,那么将会抛出OutOfMemoryError。
1.5 方法区
- 方法区是线程共享的空间,生命周期和JVM进程相同。
- 方法区用于存储类的信息、常量池、字段和方法数据、字节码内容等。
在我们常用的HotSpot虚拟机中,JDK1.7之前,使用PermGen(永久代)来实现方法区;在JDK1.8中完全移除了PermGen,改用Metaspace(元空间)来实现方法区。
其实,移除PermGen的工作从JDK1.7就开始了,符号引用(Symbols)、字面量(interned strings)、类的静态变量(class statics)在1.7中都转移到了Heap中,这大大减少了PermGen抛出OutOfMemoryError的机会。
Metaspace使用的是本地内存,而非JVM内存;因此Metaspace的大小限制,受限于物理内存的的限制;当然它是可以通过参数-XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 来指定的。
方法区的空间不够用了,将会抛出OutOfMemoryError。
关于方法区,运行时常量池特别值得一提,运行时常量池中的常量,基本来源于各个class文件中的常量池;程序运行时,除非手动向常量池中添加常量(比如调用String.intern方法),否则jvm不会自动添加常量到常量池。
1.6 直接内存(Direct Memory)
直接内存并不是JVM运行时数据区的一部分,属于堆外(off-heap)内存,也不是JVM规范中定义的内存区域。JDK1.4新增了NIO包,引入了一种基于Channel和Buffer的IO方式,可以使用Native方法直接分配堆外内存,然后通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。
//见 java.nio.ByteBuffer
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
// native方法,见sun.misc.Unsafe类
public native long allocateMemory(long var1);
使用堆外内存,可以扩展使用更大的内存空间,理论上能减少GC的暂停时间,还可以在进程间共享(MappedByteBuffer和FileChannel)。
Direct Memory默认的大小是等同于JVM最大堆,我们可以通过-XX:MaxDirectMemorySize参数来控制其大小。
如果直接内存空间不够用了,将会抛出OutOfMemoryError。
2. 对象的创建和访问过程
2.1 对象的创建过程
类加载检测。当new对象的时候,将会检查能否在常量池中定位到一个类的符号引用,并检查这个类是否被加载、解析和初始化,如果没有,则执行相应的类加载过程。
类加载检查通过后,JVM将会为新生的对象分配内存。如果Java堆内存是规整的,内存分配采用“指针碰撞”方式;如果内存不是规整的,则采用“空闲列表”的方式。Java堆内存是否规整,取决于使用的垃圾回收器是否带有压缩整理的功能。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。给对象分配内线的过程,是指针移动的过程,它不是线程安全的,需要同步;为了解决这个问题,JVM给每个线程在Java堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),这样以来,只有缓冲区用完了,重新分配时才需要同步操作。
对象内存分配完毕之后,JVM把分配的内存空间都初始化为零值。
JVM对对象做必要的设置。例如对象是哪个类的实例、如何找到类的元数据、对象的哈希码、对象的GC分代年龄等,这些信息存放在对象头(Object Header)中。
至此,在JVM看来对象创建完成;接下来执行<init>方法,把对象按照程序员的意愿初始化,形成一个真正可用的对象。
2.2 对象的内存布局
对象在堆中的布局分为三个区域:对象头,实例数据,对齐填充。
对象头 包括两个部分,第一部分是“Mark Word”,用于存储对象自身的运行时数据,包括HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向ID、偏向时间戳等;第二部分是类型指针,指向存放指向方法区的类数据,即JVM通过这个指针来确定对象是哪个类的实例。
实例数据 存放类的属性,包括父类的属性信息。相同宽度的字段(例如long和double都是8字节)分配在一起。
对齐填充 这是虚拟机要求对象起始地址必须是8字节的整数倍,如果实例数据部分不是8字节的整数倍,那么就需要对齐填充来补齐,除此之外,并无它意。
2.3 对象的访问定位
引用存放在Java栈上,数据类型为reference;对象存放在Java堆中,引用是如何指向对象实例呢?
目前主流的访问方式有两种,1.使用句柄;2.使用直接指针。
如果使用句柄访问,那么Java堆中将会分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据和类型数据的具体地址。句柄的好处在于,当对象被移动时(垃圾回收时发生),只会改变句柄中的实例数据指针,reference本身不需要修改。
如果使用直接指针访问,reference引用直接指向堆中的对象实例,对象实例的对象头存放对象类型指针,这种方式的好处在于,减少了一次指针定位的开销,访问速度更快。
HotSpot虚拟机中使用的是直接指针访问的方式。
(完)