Java 虚拟机技术是每个 Java 开发工程师都应该深入掌握的。本系列文章将深入介绍 JVM 相关技术,主要包括内存划分、对象创建回收与分配以及垃圾收集三大部分。本系列文章将力求全面概要地汇总核心知识点,并使知识点串联成面,以方便学习、工作以及备忘复习。本文将介绍第二部分——对象创建与回收。
对象创建
对象在 JVM 的创建过程可见下图:
类加载检查
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个
符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
类加载
这部分之前已经探讨过,参见《一文彻底掌握 Java 类加载机制》。
简单来说,就是类对象的加载、连接(验证->准备->解析)和初始化(<clinit>方法)等过程。
分配内存
分配方法
类加载完毕后,类的新对象所需要的内存大小已经确定。这时候可以为新对象在堆中分配空间。分配空间的算法在不同的垃圾收集器中实现不一样。常见有以下两种解决方案:
- 指针碰撞:内存空间是规整,使用和未使用的空间由指针相隔,则从指针位置开始尝试申请一块空闲空间。Serial、ParNew 采用此法;
- 空闲列表:内存空间是零碎的,JVM 需要维护一个空闲内存列表,分配时从列表里选取一块内存用于分配对象,并更新空闲列表。CMS 采用此法;
并发方法
并发情况下分配内存,存在线程安全问题,常见解决方案:
- CAS:JVM 使用 CAS 和失败重试来保证操作原子性,从而对分配内存的过程进行同步处理,以实现并发安全;
- TLAB(Thread Local Allocation Buffer):即线程本地分配缓存区。每个线程预先分配一小块内存空间,然后每个线程的对象分配在各自的 TLAB 空间进行,互不干扰。通过虚拟机参数 -XX:UseTLAB 可以开启该功能。
其他技术要点
对象栈上分配
逃逸分析 (-XX:+DoEscapeAnalysis,JDK7后默认开启)
JVM 通过对象逃逸分析确定对象是否会被外部访问,如果不会逃逸则该对象将在栈上分配。栈上分配的内存空间会随着出栈销毁,避免对象分配在堆中,从而减轻回收压力。
注意默认情况下,数组对象长度超过64时不会通过逃逸分析优化,会自动在堆上分配。这个大小可以通过启动参数-XX:EliminateAllocationArraySizeLimit=n来进行控制,n是数组的大小。
标量分析 (-XX:+EliminateAllocations, JDK7后默认开启)
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该 对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就 不会因为没有一大块连续空间导致对象内存不够分配。
对象在新生代分配
大多数情况下,对象都在新生代中分配。当新生代中的 Eden 区以及其中一个 Survivor 区没有足够空间的时候,会触发一次 Minor GC,并将剩余存活对象移动到另一个空的 Survivor 区。
Eden与Survivor区默认8:1:1。可以通过参数 -XX:SurvivorRatio=n 改变这个比例,该参数设置的是Eden区与每一个Survivor区的比值,例如当n=8可以反推出占新生代的比值,Eden为8, 两个Survivor为1, Eden占新生代的4/5, 每个Survivor占1/10,两个占1/5。
JVM还有个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变 化可以设置参数-XX:-UseAdaptiveSizePolicy。
大对象直接进入老年代
大对象需要大量连续内存空间(比如:字符串、数组等)。一般情况下大对象会在新生代分配。另外存在两种情况会直接分配到老年代:
- 在 Serial 和 ParNew 这两个收集器下,可以通过参数 -XX:PretenureSizeThreshold=n 设置大对象的大小(n 是字节数),此时大对象会直接分配到老年代;
- 在 G1 收集器下,超过 Region 大小的一半的对象,会直接分配到老年代。
因为大对象占用较大空间,在新生代里复制十几次才被晋升的话,效率太低。
老对象进入老年代
如果一个对象在新生代多次回收依然存活,则会被晋升到老年代。通过参数 -XX:MaxTenuringThreshold 设置年龄阈值。(一般默认值是15,CMS是6)。
通过 -XX:+PrintFlagsFinal 可以打印启动后参数值
对象动态年龄判断
在一次 minor GC之后 JVM 将当前保存对象的 Survivor 区对象从年龄小到大排序,并累加,如果当前对象占用内存总和超过了 Survivor 区的50%,则剩下的较老的对象会直接晋升老年代。通过参数 -XX:TargetSurvivorRatio 可以改变该比例,默认值是50%。
老年代空间担保
简单来说,就是 Minor GC 前先看看老年代是否有足够剩余空间,如果没有则先触发 Full GC。具体流程看图:
初始化
分配内存结束后,对象会被初始化为零值;如果使用 TLAB,则提前至 TLAB 分配时进行。这个阶段确保了对象新建后不用对其字段赋值,便可以使用其字段默认零值的原因,如下:
public class App {
private int value;
private boolean flag;
private App app;
public App() {
System.out.println("value=" + value);
System.out.println("flag=" + flag);
System.out.println("app=" + app);
}
public static void main(String[] args) {
new App();
}
}
输出结果:
value=0
flag=false
app=null
设置对象头
对象空间划分为对象头和实例数据两部分,其中对象头又包含以下几个部分:
Mark Word 标记字段
Mark Word 在32位 JVM 占32bit,64位系统占64bit。Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:
Kclass 类型指针
类的元数据指针,而元数据是保存在方法区(或元空间)。在64位 JVM 中,开启指针压缩占用32bit,不开启的话占用64bit;在32位 JVM 中,占用32bit。
数组长度(只有数组对象才有)
只有数组才会有该字段,占用32bit。
填充字节
JVM 要求对象空间长度是 8 字节的倍数。如果不满足倍数关系,需要填充
指针压缩
从 JDK1.6 update 14开始,JVM 在64位系统开始支持指针压缩,主要包含两个参数:
- -XX:+UseCompressedOops 开启压缩所有指针(默认开启,禁用可用-XX:-UseCompressedOops);
- -XX:+UseCompressedClassPointers 开启压缩对象头里的类型指针Klass Pointer(默认开启,禁用可用-XX:-UseCompressedClassPointers)。
使用 MarkWordTest 可以验证压缩效果,仓库可能还没公开,先贴下代码:
public class MarkWordTest {
public static void main(String[] args) {
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());
System.out.println();
ClassLayout layout2 = ClassLayout.parseInstance(new int[10]);
System.out.println(layout2.toPrintable());
System.out.println();
ClassLayout layout3 = ClassLayout.parseInstance(new Person());
System.out.println(layout3.toPrintable());
}
static class Person {
private byte enable;
private int age;
String name;
}
}
禁用压缩指针效果:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 00 dc 46 16 (00000000 11011100 01000110 00010110) (373742592)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 68 cb 46 16 (01101000 11001011 01000110 00010110) (373738344)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
16 4 (object header) 0a 00 00 00 (00001010 00000000 00000000 00000000) (10)
20 4 (alignment/padding gap)
24 40 int [I.<elements> N/A
Instance size: 64 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
org.demo.jvm.MarkWordTest$Person object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) c8 a4 22 18 (11001000 10100100 00100010 00011000) (404923592)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
16 4 int Person.age 0
20 1 byte Person.enable 0
21 3 (alignment/padding gap)
24 8 java.lang.String Person.name null
Instance size: 32 bytes
Space losses: 3 bytes internal + 0 bytes external = 3 bytes total
启动指针压缩效果:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
12 4 (object header) 0a 00 00 00 (00001010 00000000 00000000 00000000) (10)
16 40 int [I.<elements> N/A
Instance size: 56 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
org.demo.jvm.MarkWordTest$Person object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 4e f2 00 f8 (01001110 11110010 00000000 11111000) (-134155698)
12 4 int Person.age 0
16 1 byte Person.enable 0
17 3 (alignment/padding gap)
20 4 java.lang.String Person.name null
Instance size: 24 bytes
Space losses: 3 bytes internal + 0 bytes external = 3 bytes total
为何需要指针压缩?
- 64位 JVM 中使用指针压缩,可以大大减少内存占用,降低内存压力;
- 32位 JVM 支持最大的内存是4G(2^32);
- 64位 JVM 中,如果堆内存小于4G,不需要开启指针压缩,JVM 会直接去掉高32位地址;而当堆空间超过32G的时候,指针压缩会失效,强制改为使用64位地址空间。这和指针压缩的实现原理有关。简单来说,指针压缩是从byte的角度寻址,而不是从bit的角度,因为堆里的对象都是8字节对齐的,堆内使用字节角度来寻址更快更优,当然在寄存器层面依然是按位寻址。
两种寻址方式的图解如下:
执行<init>方法
<init>方法是由 JVM 生成的方法,会执行一系列的初始化,按顺序包括:
- 父类变量初始化
- 父类语句块
- 父类构造函数
- 子类变量初始化
- 子类语句块
- 子类构造函数
这里提一下类加载过程中的<clinit>方法,其内部的初始化步骤按顺序包括:
- 父类静态变量初始化
- 父类静态语句块
- 子类静态变量初始化
- 子类静态语句块
读者需要注意区分 <clinit> 和 <init>,前者是在类加载阶段执行,而后者是在对象初始化之后执行。也就是说 <clinit> 一定先于 <init> 执行。
对象回收
对象存活判断算法
常见的垃圾回收器都是通过标记那些存活对象,而没有得到标记的对象将成为垃圾对象被回收。常见的判断对象存活算法有二:
- 引用计数法:简单高效,但是很难解决循环依赖问题。
- 可达性分析算法:大多数垃圾收集器都采用此法。可达性算法的GC Roots根节点一般是线程栈本地变量、静态变量、本地方法栈变量等等。
常见引用类型
- 强引用:最常见的引用方式
Person person = new Person();
- 软引用:使用 SoftReference 包裹的对象,正常情况下不会回收,但如果 GC 后依然无法释放空间存放新对象的时候,会把软引用对象回收掉。
SoftReference<Person> persion = new SoftReference<>(new Person());
- 弱引用:使用 WeakReference 包裹的对象,只要发生 GC 会直接被回收掉。
WeakReference<Person> persion = new WeakReference<>(new Person());
- 虚引用:最弱的一种引用关系。
Finalize 方法
每个对象再回收前都会执行且仅执行一次其 Finalize 方法。一般情况下,不要尝试重载该方法。
回收方法区
无用类判断条件:
- 该类的所有实例都已经被回收;
- 加载该类的 ClassLoader 已经被回收;
- 该类的 Class 对象已经没有任何引用。
参考资料
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 duval1024@gmail.com