前言
数据区域
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域:

程序计数器【线程私有】:这是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,比如说跳转,分支,循环等。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈【线程私有】:描述Java方法执行的内存模型。其用于存储局部变量表(存放①各种基本数据类型,比如boolean、byte、char、int、long等。②对象引用。③returnAddress类型)、操作数栈、动态链接、方法出口信息等。
本地方法栈【线程私有】:与虚拟机栈类似,区别是:虚拟机栈为虚拟机执行Java方法服务,本地栈为为虚拟机使用到的Native方法服务。HotSpot将其两者合并了。
Java堆【共享区】:是JVM管理的内存中最大的一块,所有的对象实例在这里分配。也是垃圾收集器管理的主要区域,所以也叫“GC堆”
方法区(其中包括了运行时常量池部分)【共享区】:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。很多人也称其为“永久代”。
添加几个之前忽略的问题
程序计数器为什么是私有的?
程序计数器主要有两个功能,分别是
- 字节码解释器通过改变程序计数器来依次读取当前指令,从而实现代码流程的控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当前线程被切换回来的时候能够知道该线程上次运行到哪里了。
需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
对象的内存布局
HotSpot虚拟机中,对象在内存的布局
对象头(Header)
第一部分信息用于存储对象自身的运行时数据。如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分官方称其为“Mark Word”。
第二部分是类型指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
PS:若对象是一个数组,那么在对象头中还必须有一块用于记录数组长度的数据。
实例数据(Instance Data)
对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。
对齐填充(Padding)
并不是必然存在的,仅仅起着占位符的作用。HotSpot要求对象起始地址必须是8字节的整数倍,也就是说对象大小必须是8字节整数倍,对象头部分是符合的,所以如果实例数据部分没有对齐,就需要这部分来填充对齐。
对象的创建
在语言层面的对象创建无非是一个new的关键字,而在JVM中对象的创建过程却显得复杂一些
虚拟机遇到一条new指令时,首先将去检查这个指令参数能否在常量池中定位到一个类的符号引用(reference),若已经在之前还未被加载、解析和初始化过的话就执行类的加载过程。
为这个新的对象分配内存。其实也就是为它在Java堆中分配一块空间。其中根据内存空间是否规整分为两种分配方式。
指针碰撞(Bump the Pointer)。如果Java堆中的内存是规整的,则使用指针碰撞的分配方式。我们规定所有堆中用过的内存放在一边,还未使用的内存放在另外一边,中间放一个指针作为分界点的指示器,那么分配内存就是把指针往空闲区域移动一段和分配对象大小相等的距离。
空闲列表(Free list)。如果Java堆中的内存是不规整的,那么就不能用指针碰撞的方式,而是使用空闲列表的方式。虚拟机需要维护一个表,表用来记录当前还有哪些内存块是可以用的,当需要分配对象的时候,从表中找出一块足够大的分配给对象,并更新表。
PS:Java堆的内存空间是否规整还是需要根据JVM所选择的垃圾收集器来决定,决定于选择的垃圾收集器是否带有压缩整理的功能。Serial和ParNew采用的是指针碰撞,CMS采用的是空闲列表。这个在整理到后面的笔记后提及。
在内存分配的过程中,还存在有一个问题。因为对象的创建在虚拟机中是一个十分频繁的问题,即使是修改一个指针指向的位置,在并发的情况下也不是线程安全的,这时候就有两种解决方案。
1.对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方法保证更新操作的原子性。(关于什么是CAS可以参考
[“CAS原理分析”]: https://blog.csdn.net/qiuchaoxi/article/details/79808759
简单的说,就是保证线程原子性的一种方式。)
2.另一种方式是把内存分配的动作按照线程划分在不同的空间中进行。也就是每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存就在哪个线程的TLAB上分配,只有在TLAB用完并分配新的TLAB时才需要同步锁定。虚拟机是否使用TLAB,可以通过 -XX:+/-UseTLAB参数来设定。
内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作也可以提前至TLAB分配时进行。这一步操作保证对象的实例字段在Java代码中可以不赋初始值就直接使用。
对对象进行设置。也就是对象头的设置。
从虚拟机的角度来看,一个对象已经创建好了,但是对于Java程序的角度来说,对象的创建才刚刚开始,还需要执行init方法,按照程序员的意愿对对象进行初始化。
对象的访问定位
在使用对象的时候,需要对对象进行访问。需要通过栈上的reference数据来操作堆上的具体对象。
reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置。目前主流的访问方式有使用句柄和直接指针两种。
1.使用句柄:使用句柄的话,Java堆中会单独划分出一块空间作为句柄池,reference中存储的对象就是句柄地址,而句柄中包含了对象实例数据与类型数据各自的地址信息。
2.直接指针访问:Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。(这个也是Hotspot对象访问的方式)
两种方式的比较:使用句柄来访问最大的好处是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集的时候常见)只会改变句柄中实例数据指针,而reference本身不需要修改。使用直接指针访问方式的最大好处是速度更快,它节省了一次指针定位的时间开销。

