[读书笔记] 有关Java对象的一切
一、对象的创建过程
(此处讨论的对象限于普通Java对象,不包括数组和Class对象等)
当JVM遇到一条new指令时,JVM会根据以下流程创建一个新的对象:
-
类加载检查:检查这个指令的参数是否能在常量池中定位到类的符号引用,检查符号引用代表的类是否被加载、解析和初始化过。如果没有,执行类加载。
-
为新对象分配内存:类加载完成后,对象所需内存大小便已确定。为对象分配内存等同于把一块确定大小的内存从Heap中划分出来。(随着JIT编译器的发展和逃逸分析技术逐渐成熟,产生了栈上分配、标量替换等优化技术,因而并不是所有对象都在Heap上分配)
-
内存初始化:将分配到的内存空间都初始化为零值(不包括对象头)。
-
设置对象头信息:例如该对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码、对象的GC分代年龄等。
-
执行<init>方法: At the level of the Java Virtual Machine, every constructor written in the Java programming language (JLS §8.8) appears as an instance initialization method that has the special name
<init>
.
为新对象分配内存的方式由Heap是否规整决定:
-
假设Heap中的内存是绝对规整的,用过的内存在一边,空闲的内存在另一边,它们之间有一个指针作为分界点指示器,只需将指针向空闲一边移动与对象大小相同的长度,即可完成分配。这种分配方式被称为“指针碰撞”。
-
假设Heap中内存不是规整的,而是已用内存和空闲内存在物理上交错分布,就无法使用“指针碰撞”的方法。而需要JVM维护一个逻辑上连续的可用内存列表,在列表中找到一块足够大的内存空间分配给对象,并更新列表记录。这种方式被称为“空闲列表”。
而Heap是否规整又由所采用的垃圾收集器(Garbage Collector)决定。下面是几种常用GC的内存分配方式:
-
指针碰撞:Serial、ParNew等带Compact过程的GC
-
空闲列表:CMS这种基于Mark-Sweep算法的GC
另外,JVM为对象分配内存时,在并发情况下要考虑线程安全。这个问题有两种解决方案:
-
对内存分配的动作做同步处理:CAS + 失败重试,以确保更新操作的原子性
-
把内存分配的动作按线程划分在不同的空间中执行:每个线程在Heap中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer)。每个线程在各自的TLAB上分配内存,只有TLAB用完并分配新TLAB时,才需要同步锁定。
二、对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可分为三块区域:对象头、实例数据和对齐填充。
对象头(Header)
对象头包括两部分信息:一部分用于存储对象自身的运行时数据,例如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针确定对象是哪个类的实例。
实例数据(Instance Data)
实例数据部分是对象真正存储的有效信息,也是程序代码中所定义的各种字段内容,其中包括从父类继承的字段和子类中自行定义的字段。
对齐填充(Padding)
由于HotSpot VM的自动内存管理系统要求对象的起始地址必须是8字节的整数倍(对象大小必须是8字节的整数倍),而且Header部分正好是8字节的整数倍。所以当Instance Data长度没有对齐时,需要做对齐填充。
三、对象的访问定位
建立对象是为了使用对象,Java程序通过Stack上的reference数据来操作Heap上的具体对象。对象访问的方式取决于虚拟机实现,主流的访问方式有句柄和直接指针。
如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。使用句柄的好处是:reference中存储的稳定的句柄地址,对象被移动时只会改变对象实例指针,reference本身不需要修改。
如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何防止访问类型数据的相关信息,reference 中存储的直接就是对象的地址。使用直接指针的好处是:速度更快,节省了一次指针定位的时间开销。
四、对象一生所在的内存区域
- 多数对象分配在新生代Eden:当Eden没有足够空间分配时,发起一次Minor GC。
- 大对象直接进入老年代:虚拟机提供了-XX:PretenureSizeThreshold参数,大于这个值的对象直接进入老年代(典型的大对象是很长的字符串和数组)。这样的目的是为了避免在Eden区和两个Survivor区之间发生大量内存复制。
- 长期存活的对象进入老年代:如果对象在Eden出生、经过第一次Minor存活、能被Survivor容纳,就移动到Survivor空间且对象年龄设为1。在Survior中每熬过一次Minor GC,年龄就+1,当年龄到达-XX:MaxTenuringThreshold(默认15),对象就移动到老年代。
- 动态对象年龄判定:如果在Survivor中 相同年龄的所有对象的内存之和 > Survivor空间的一半,则对象年龄 ≥ 该年龄 的对象直接进入老年代,无需等到MaxTenuringThreshold。
五、参考书目
周志明《深入理解Java虚拟机(第2版)》