JVM对象创建与内存分配机制
# JVM对象创建与内存分配机制
# 一 、对象的创建

# 1、加载检查
- 虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池(Class常量池)中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。 new指令对应到语言层面上讲是,new关键词、对象克隆、对象序列化等。
# 2、分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
如何划分内存
指针碰撞(Bump the Pointer)默认
- 如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存存放在另一边(按内存分配顺序规整分配),中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离
空闲列表(Free List)
- 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行 ’指针碰撞‘ 了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
并发情况
[可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况]
CAS失败重试(compare and swap)
虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间动作进行同步处理
本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
把内存分配的动作按照线程划分不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过-XX:+/-UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启-XX:+UseTLAB),-XX:TLABSize指定TLAB大小。
提示
1)TLAB 只用于新生代(Eden)给应用线程分配。
- 快路径:线程先在自己的 TLAB 里“指针往前一推”完成分配(不需要 CAS)。
- TLAB 用尽 → 走共享 Eden 的“全局指针碰撞”,这一步为了并发安全会用 CAS 抢
eden_top
(失败再重试/走慢路径或触发 Minor GC)。 - 所以“TLAB + CAS”指的是:先 TLAB;不够时到共享 Eden 用 CAS。这是新生代场景。
2)遇到老年代要看回收器是否把空间整理为规整:
- Parallel Old/Serial Old(标记-整理/压缩):空间规整,老年代分配也可走“指针碰撞”,并发下同样会对共享
top
用 CAS。 - CMS(标记-清除,不整理):空间不规整,走空闲列表(Free List) 找块儿——这里没有 TLAB。并发安全主要靠 锁/分级空闲链表 的同步(不是抢一个线性
top
,因此不谈 TLAB,也不一定是 CAS)。
一句话: “能碰撞就碰撞(TLAB→共享区 CAS),不能碰撞(碎片化,如 CMS 老年代)就走空闲列表(无 TLAB,用锁/链表同步)”。
3)容易混淆的点:PLAB ≠ TLAB GC 线程在复制/提升对象时会用 PLAB(Promotion Local Allocation Buffer) 来减小互抢,但这是 GC 线程 在回收期用的缓冲,不是应用线程分配对象时的 TLAB。
4)小表速记
场景 空间是否规整 分配方式 并发手段 新生代(Eden)常规分配 规整 线程私有 TLAB 指针碰撞 无需 CAS 新生代(TLAB 不够,去共享 Eden) 规整 共享区指针碰撞 CAS 争抢 top
老年代(Parallel Old/Serial Old) 规整 指针碰撞 CAS 老年代(CMS) 不规整 空闲列表 锁/分级链表,同步管理(无 TLAB)
# 3、零值填充/默认初始化
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一不操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值
在堆上完成对象内存分配后,JVM 将这块内存(不含对象头)统一置为类型零值:
int=0
、long=0L
、double=0.0d
、boolean=false
、char='\u0000'
、引用=null 等。如果启用了 TLAB,这个清零可能在 TLAB 申请时提前完成。
目的:保证即使程序员没有显式赋值,实例字段也有可用的默认值。
提示
注意:此阶段不会执行构造器、实例字段的显式初始值、实例初始化块,也不会出现任何你在 Java 里能写出来的日志输出。
# 4、设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。
在hotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)(不足8个字节填充到8个字节或者8字节的倍数,存储效率最高,计算机寻址最优大小)。
hotSpot虚拟机的对象头包括两部分信息
- 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、偏向线程ID、偏向时间戳。(Mark Word)
- 另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。(Klass pointer)
Mark Word(状态条):一块会“变脸”的状态区
- 没加锁时:放 identity hashCode、GC年龄(age)、偏向锁位 等
- 偏向锁:写入持有线程ID、时间戳(epoch)、age
- 轻量级锁:变成指向栈上锁记录的指针
- 重量级锁:变成指向监视器(Monitor)对象的指针
- 做 GC 时会用到其中的 年龄位(对象在年轻代“熬过”几次 YGC)
Klass Pointer(类型指针):指向“这是什么类”的元数据(Klass),JVM据此知道字段布局、方法等
Array Length(仅数组有):数组长度
# 你需要记住的 5 句话
- 对象头 = Mark Word + Klass 指针(数组再加长度)
- Mark Word 是动态的**:没锁放 hash/age;加锁就放“锁的元数据”**
- Klass 指针告诉 JVM 这个对象“属于哪个类”
- 数组必带长度
- 按 8 字节对齐**:64 位下常见“普通对象最小 16B”
提示
注意:对象头就像“运单+条码”:Klass 指针标明“我是谁”,Mark Word 记录“我现在啥状态(锁/哈希/年龄)”,数组再写“我有多长”,最终按 8 字节对齐。
# 5、执行<init>方法(构造器链 + 每层类的实例显式初始化)
执行<init>方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法
执行顺序(JDK8/HotSpot 语义):
- 选择“最终构造器”:从当前构造器出发,若第一行是
this(...)
,继续跳到被调用的那个构造器;直到命中第一行不是this(...)
的构造器(即最终构造器)。 - 进入父类:在最终构造器第一行执行
super(...)
,逐级向上到Object()
。 - 逐层返回并初始化:每一层类在从
super(...)
返回后、该层构造器主体执行前,按源码顺序执行:- a) 实例字段显式初始值(例如
int x = 1;
的=1
) - b) 实例初始化块(
{ ... }
非 static) - c) 构造器主体(
ctor-body
)
- a) 实例字段显式初始值(例如
- 所有父类都处理完后,回到最初的子类构造器主体,完成收尾。
- 约束:必须存在且仅有一个最终构造器以
super(...)
开头;没写会隐式插入super()
。
class Parent { int p = log("P.field-init"); // 实例字段显式初始值 { log("P.init-block"); } // 实例初始化块 Parent(int id) { log("P.ctor-body"); } static int log(String s) { System.out.println(s); return 1; } } class Child extends Parent { int c = log("C.field-init"); // 实例字段显式初始值 { log("C.init-block"); } // 实例初始化块 Child() { this(42); } // ① 本类“换乘”:先走到最终构造器 Child(int id) { // ② 最终构造器(第一行不是 this(...)) super(id); // 先上到父类 log("C.ctor-body"); // 再执行本类构造器主体 } static int log(String s) { System.out.println(s); return 2; } } public class Demo { public static void main(String[] args) { new Child(); } } //输出结果 P.field-init P.init-block P.ctor-body C.field-init C.init-block C.ctor-body
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32读法:
Child()
先this(42)
→ 进入Child(int)
(最终构造器)super(id)
先处理 Parent:字段/块 →Parent
构造器主体- 回到 Child:字段/块 →
Child
构造器主体 - “字段/块”在每层类里只执行一次,且总在该层构造器主体之前。
- 选择“最终构造器”:从当前构造器出发,若第一行是
# 小结
new
对象时:若类未初始化,先跑一次类加载过程 <clinit>
(静态);随后分配内存 → 零值填充 → 设置对象头 → 执行 <init>
(构造方法)。其中“零值填充”只做清零;实例字段显式初值、实例初始化块与构造器主体都在 <init>
阶段完成。
# 二、对象大小与指针压缩
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol‐core</artifactId>
<version>0.9</version>
</dependency>
2
3
4
5
/**
* 计算对象大小
*/
publicclassJOLSample{
public static void main(String[] args) {
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());
System.out.println();
ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
System.out.println(layout1.toPrintable());
System.out.println();
ClassLayout layout2 = ClassLayout.parseInstance(new A());
System.out.println(layout2.toPrintable());
}
// ‐XX:+UseCompressedOops 默认开启的压缩所有指针
// ‐XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer
// Oops : Ordinary Object Pointers
public static class A {
//8B mark word
//4B Klass Pointer 如果关闭压缩‐XX:‐UseCompressedClassPointers或‐XX:‐UseCompressedOops,则占用8B
int id; //4B
String name; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
byte b; //1B
Object o; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
}
}
运行结果:
java.lang.Objectobjectinternals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) //mark word
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) //mark word
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (‐134217243) //Klass Pointer
12 4 (loss due to the next object alignment)
Instancesize:16bytes
Spacelosses:0bytesinternal+4bytesexternal=4bytestotal
[Iobjectinternals:
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) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
16 0 int [I.<elements> N/A
Instancesize:16bytes
Spacelosses:0bytesinternal+0bytesexternal=0bytestotal
56
com.tuling.jvm.JOLSample$Aobjectinternals:
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) 61 cc 00 f8 (01100001 11001100 00000000 11111000) (‐134165407)
124intA.id0
161byteA.b0
17 3 (alignment/padding gap)
20 4 java.lang.String A.name null
24 4 java.lang.Object A.o null
28 4 (loss due to the next object alignment)
Instancesize:32bytes
Spacelosses:3bytesinternal+4bytesexternal=7bytestotal
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# 什么是java对象的指针压缩?
jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩
jvm配置参数:UseCompressedOops,compressed--压缩、oop(ordinary object pointer)--对象指针
启用指针压缩:-xx:+UseCompressedOops(默认开启),禁止指针压缩:-xx:-UseCompressedOops
# 为什么要进行指针压缩?(节约内存空间)
- 在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同事GC也会承受较大压力
- 为了减少64位平台下内存的消耗,启用指针压缩功能
- 在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)
- 堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
- 堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好
- UseCompressedOops:普通对象指针压缩
- UseCompressedClassPointers:类指针压缩
# 这两者有什么作用呢?
拿新建一个对象来说:
Object o = new Object() 如果不开启普通对象指针压缩,-UseCompressedOops,会在内存中消耗24个字节,o 指针占8个字节,Object对象占16个字节。
如果开启普通对象指针压缩,+UseCompressedOops,会在内存中消耗20个字节,o指针占4个字节,Object对象占16个字节。
这样一看,好像UseCompressedOops 对Object的内存并没有影响,其实不然,Object对象在内存中的布局,包括markword 、 klass pointer、实例数据和填充对其,开启UseCompressedOops,默认会开启UseCompressedClassPointers,会压缩klass pointer 这部分的大小,由8字节压缩至4字节,间接的提高内存的利用率。
由于UseCompressedClassPointers的开启是依赖于UseCompressedOops的开启,因此,要使UseCompressedClassPointers起作用,得先开启UseCompressedOops,并且开启UseCompressedOops 也默认强制开启UseCompressedClassPointers,关闭UseCompressedOops 默认关闭UseCompressedClassPointers。
如果开启类指针压缩,+UseCompressedClassPointers,根据上面的条件,结果跟只开启UseCompressedOops一样,会在内存中消耗20个字节,o指针占4个字节,Object对象占16个字节。
如果关闭类指针压缩,-UseCompressedClassPointers,根据上面的条件,UseCompressedOops还是会开启,会在内存中消耗20个字节,o指针占4个字节,Object对象占16个字节。
如果开启类指针压缩,+UseCompressedClassPointers,并关闭普通对象指针压缩,-UseCompressedOops,此时会报错, UseCompressedClassPointers requires UseCompressedOops
// UseCompressedOops must be on for UseCompressedClassPointers to be on.
if (!UseCompressedOops) {
if (UseCompressedClassPointers) {
warning("UseCompressedClassPointers requires UseCompressedOops");
}
FLAG_SET_DEFAULT(UseCompressedClassPointers, false);
}
2
3
4
5
6
7
# 三、对象内存分配

# 对象栈上分配
/**
* 栈上分配,标量替换 代码调用了1亿次alloc(),如果是分配到堆上,大概需要1GB以上堆空间,如果堆空间小于该 值,必然会触发GC。
* 使用如下参数不会发生GC
* ‐Xmx15m ‐Xms15m ‐XX:+DoEscapeAnalysis ‐XX:+PrintGC ‐XX:+EliminateAllocations
* 使用如下参数都会发生大量GC
* ‐Xmx15m ‐Xms15m ‐XX:‐DoEscapeAnalysis ‐XX:+PrintGC ‐XX:+EliminateAllocations
* ‐Xmx15m ‐Xms15m ‐XX:+DoEscapeAnalysis ‐XX:+PrintGC ‐XX:‐EliminateAllocations
*/
publicclassAllotOnStack{
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println(end ‐ start);
}
private static void alloc() {
User user = new User();
user.setId(1);
user.setName("bajie");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
提示
结论:栈上分配依赖于逃逸分析和标量替换 对象在Eden区分配
# 对象栈上分配
通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
对象逃逸分析
就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。
public User test1(){ User user = new User(); user.setId(1); user.setName("bajie"); //TODO 保存到数据库 return user; } public void test2(){ User user = new User(); user.setId(1); user.setName("bajie"); //TODO 保存到数据库 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14很显然test1方法中的user对象被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结 束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内 存一起被回收掉。 JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优 先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)
开启逃逸分析
开启:-XX:+DoEscapeAnalysis;关闭:-XX:-DoEscapeAnalysis;开启后通过标量替换:-XX:+EliminateAllocations;优先分配在栈上(栈上分配);JDK7之后默认开启逃逸分析
标量替换
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations)
标量与聚合量
标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量成为聚合量。而在 JAVA对象 就是可以被进一步分解的聚合量(通过多个标量集合在一起)
# 对象在Eden区(年轻代)分配
大多情况下,对象在新生代中Eden区分配,Eden区满了后会触发minor gc ,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,把eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的 ,让eden区尽量的大,survivor区够用即可,JVM默认参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致8:1:1比例自动变化,如果不想比例发生变化可以关闭:-XX:-UseAdaptiveSizePolicy
- Minor GC和Full GC有什么不同?
Minor GC / Young GC:指发生新生代的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快
Major GC / Full GC:一般会回收老年代,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上
提示
当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,GC期间新产生的对象无法存入Survivor空间,那么新生代的对象会提前转移到老年代中
# 大对象直接进入老年代
大对象指的是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数-XX:PretenureSizeThreshold可以设置大对象大小,如果对象超过设置大小会直接进入老年代,不会分配到年轻代,这个参数只在Serial和ParNew两个收集器下有效
为什么要这样做?
为了避免为大对象分配内存时的复制操作而降低效率
# 长期存活的对象将进入老年代
如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设置为1。对象在Survivor中每熬过一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度(默认为15岁,CMS搜集器默认6岁,不通垃圾收集器略微不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold来设置
# 对象动态年龄判断
在 Minor GC 时,JVM 会把存活对象按“年龄(age)”分桶,并统计每个年龄桶占用的字节数。
随后按年龄从小到大(age=1 → 2 → …k)依次累加这些字节数,直到累计值达到:TargetSurvivorRatio × Survivor 容量
(通常取 Survivor 的 50%)
将此时的年龄记为阈值 k。本次 GC 中,年龄 ≥ k 的对象会被直接提升(promotion)到老年代,即使它们的年龄尚未达到 MaxTenuringThreshold
(晋升年龄次数)。
这一判断发生在 Minor GC 的搬迁/计算阶段,用来尽量提前把“可能长期存活”的对象移出 Survivor,降低后续复制与溢出风险。
- 极端情况:如果从 age=1 开始累计就已达到目标,会得到 k=1,等价于“只要经历过一次 Young GC 的对象都将被提升”。
- 为何会这样:当 Survivor 空间偏小或短期内有大量存活对象时,尽早提升能避免 Survivor 后续频繁复制与溢出(promotion failure)。
如何观察
- JDK 8:
-XX:+PrintTenuringDistribution -XX:+PrintGCDetails
- JDK 9+:
-Xlog:gc+age=trace
日志里可见每次 GC 的各年龄分布与本次计算出的 k;若经常出现 k=1 或大规模 promotion,可作为告警信号。
常见调优思路
- 适度扩大 Survivor(
-XX:SurvivorRatio
/ 新生代比例),降低过早提升。 - 结合业务对象生命周期,评估并调整
-XX:MaxTenuringThreshold
(谨慎),避免短命对象被提前晋升。 - 必要时评估整体堆大小,避免频繁 promotion-failure。
小例子(Survivor=512MB,TargetSurvivorRatio=50%
⇒ 目标=256MB)
年龄分布:age1=120MB,age2=80MB,age3=100MB…
累加:120 → 200 → 300≥256 ⇒ k=3
⇒ age≥3 的对象本次直接晋升。
# 老年代空间分配担保机制
年轻代每次Minor GC之前JVM都会计算下老年代剩余可用空间
如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)
就会看一个 ”-XX:-HandlePromotionFailure“ (jdk1.8默认设置)参数是否设置
如果设置,就会判断老年代的可用内存大小是否大于之前每一次Minor GC后进入老年代的对象的平均大小
如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full GC,对老年代和年轻代一起回收一次垃圾
# 四、对象内存回收
堆中几乎放着所有的对象实例,对垃圾回收前的第一步就是判断哪些对象已经死亡(即不能再被任何途径使用的对象)
# 引用计数器法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器加1,当引用失效,计数器减1;任何时候计数器为0的对象就是不可能再被使用的
优点:实现简单、效率高
缺点:它很难解决对象之间相互循环引用的问题
所谓对象之间的相互引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对 方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算 法无法通知 GC 回收器回收他们。
publicclassReferenceCountingGc{
Object instance = null;
public static void main(String[] args) {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}
2
3
4
5
6
7
8
9
10
11
12
# 可达性分析算法
将 "GC Root" 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未找到的对象都是垃圾对象
# GC Root 根节点
- 线程栈本地变量、静态变量、本地方法栈变量等等
# 常见引用类型
强引用:普通变量引用的
public static User user = new User();
软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放 新的对象,则会把这些软引用的对象回收掉。
应用场景:软引用可用来实现内存敏感的高速缓存,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从 缓存中取出就要看具体的实现策略了
public static SoftReference<User> user = new SoftReference<User>(newUser());
弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用
public static WeakReference<User> user = new WeakReference<User>(newUser());
虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用
# finalize()方法最终判定对象是否存活
即使在可达性分析算法中不可达的对象也并非是 “非死不可” 的,这个时候它们处于缓刑阶段,要真正宣告一个对象的死亡,至少要经历再次标记过程
public class OOMTest{
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
inti=0;
intj=0;
while (true) {
list.add(new User(i++, UUID.randomUUID().toString()));
new User(j‐‐, UUID.randomUUID().toString());
}
}
}
2
3
4
5
6
7
8
9
10
11
标记的前提是对象在进行可达性分析后发现没有与GC Root相连接的引用链
第一次标记
- 筛选条件是此对象是否有必要执行finalize()方法,当对象么有覆盖finalize()方法,对象将被回收
第二次标记
- 如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救 自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第 二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。
# 如何判断一个类是无用的类?
- 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法