目录
- 一. JVM对象创建过程详解
- 1. 类加载检查
- 2. 分配内存
- 2.1 如何划分内存?
- 2.2 并发问题
- 3. 初始化
- 4. 设置对象头
- 5. 执行<init>方法
- 二. 对象头和指针压缩详解
- 三. JVM对象内存分配详解
- 四.逃逸分析 & 栈上分配 & 标量替换详解
- 1. 逃逸分析 & 栈上分配
- 2. 标量替换
- 3. 标量与聚合量
- 4. 对象在堆内存中的流转与分配
- 五.对象内存回收机制详解
- ★ 1. 如何判断对象是可回收的?
- ★ 2. 常见的引用类型
- ★ 3. 对象真正被GC回收的两次标记过程详解
- ★ 4. 如何判断一个类是无用的类?
一. JVM对象创建过程详解
1. 类加载检查
当虚拟机遇到一条new指令时,首先会先检查这个指令的参数是否能在常量池
中定位到一个类的符号引用
,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,必须先执行相应的类加载过程
。
2. 分配内存
在类检查通过之后,虚拟机会给新生对象分配内存
。对象所需要的内存大小在类加载完成之后就可以确定,为对象分配空间的任务等同于把一块确定的内存从JVM堆
中划分出来。
2.1 如何划分内存?
指针碰撞
(Bump the Pointer): 默认使用; 如果JVM堆中的内存是绝对规整的,所有用过的内存在一边,空闲的内存放在另外一边,中间放一个指针作为分界点的指示器,那所分配内存就是将指针向空闲空间那边挪动一段与对象大小相等的距离
空闲列表
(Free List):JVM堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,此时就无法使用指针碰撞了,JVM就必须维护一个空闲内存的列表,记录堆中哪些位置是可用的,在分配内存时,在列表中分配一块足够大的空间给对象实例,并且更新列表上的记录。
★ 为啥默认使用指针碰撞方式?- 空闲列表中,已使用的空间是
无规则排列
的,并且未使用的内存空间的大小是不一致的,当一个对象实例需要存储的时候,就需要先去空闲列表中找一个和实例大小相匹配的内存空间,并且还需要更新空闲列表。 - 空闲列表,无法将内存空间使用率最大化。
- 空闲列表中,已使用的空间是
2.2 并发问题
- 如何产生?
指针碰撞
:当给对象A分配内存时,指针位置还未及时修改,此时对象B也使用原来的指针来分配内存空间,俗称抢内存。空闲列表
:当给对象A分配内存时,在空闲列表寻找合适对象A的内存空间,如果此时找到一块内存位置,还未及时存入对象A,空闲列表也未做更新,对象B也通过空闲列表找到同一块内存位置,此时就会出现两个对象争抢同一块内存空间的现象。
- 解决办法?
CAS
(Compare And Swap):比较与交换
,是实现多线程同步的原子指令,将内存位置的内容与给定值进行比较,只有在相同的情况下,将该内存位置的内容更新为给定值。JVM虚拟机采用CAS并且配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。TLAB
(Thread Local Allocation Buffer):线程本地分配缓存区
,把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存 。
3. 初始化
内存分配完成后,JVM虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)(如果使用TLAB,此步骤也可以提前至TLAB分配时进行),保证了对象的实例字段在Java代码中可以不赋初始值就可以直接使用,程序能访问到这些字段类型所对应的零值。
4. 设置对象头
★ 初始化零值之后,虚拟机需要对对象进行的必要的设置,例如:这个对象是哪个类的实例,如何才能找到类的元数据信息
,对象的哈希码值
,对象的GC分代年龄
等信息,这些信息存放在对象的对象头Object Heade
r中。
★ 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头
(Header)、实例数据
(Instance Data)和对齐填充
(Padding)。
★ HotSpot虚拟机的对象头包括 两部分信息
第一部分用于存储对象自身的运行时数据,如哈希码
(HashCode)、GC分代年龄
、锁状态标志
、线程持有的锁
、偏向线程ID
、偏向时间戳
等。
另外一部分是类型指针
,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
5. 执行方法
即对象按照程序的编码进行初始化,也就是属性赋值
(此处赋值,并非赋零值,而是真实的程序编码赋予的值)和执行构造方法
。
二. 对象头和指针压缩详解
Mark word
是一种用于对象头部的标记
,它记录了对象的元数据信息和运行时状态。
在JVM中,每个对象都有一个对象头部,用于描述对象的元数据信息和运行时状态。其中,mark word记录了对象的锁状态、GC状态以及其他一些标志位信息。它可以被用于多种用途,如实现线程安全、对象的同步和对象的标记-清除等垃圾回收算法。在64位JVM中,mark word占据了8字节的空间,可以存储更多的信息,因此可以提高JVM的性能。Klass pointe
: 是指向对象类元数据的指针
,在64位JVM中,klass pointer占据了4字节的空间。
每个Java对象都有一个klass pointer,它指向该对象所属的类的元数据。元数据描述了该类的所有属性,方法和其他信息。klass pointer也被用于确定对象的大小和布局,以便在内存中分配对象时可以正确地分配空间。
模拟: 对象大小和指针压缩(代码如下)
- 首先需要在项目的pom文件中依赖jol-core包
<!-- 可以明细jvm中的对象大小 -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
- 可配置JVM参数
UseCompressedOops,compressed压缩、oop(ordinary object pointer)对象指针
启用指针压缩 -XX:+UseCompressedOops(默认开启)
禁止指针压缩 -XX:-UseCompressedOops
禁止类型指针压缩 -XX:-UseCompressedClassPointers
- 示例代码: 模拟对象头信息
public class JolSample {
public static class Model{
//8B mark word
//4B klass pointer 如果关闭指针压缩,则占用8B
int id; //4B
String name; //4B 如果关闭指针压缩,则占用8B
byte b; //1B
Object o; //4B 如果关闭指针压缩,则占用8B
}
public static void main(String[] args) {
ClassLayout classLayout = ClassLayout.parseInstance(new Object());
System.out.println("Object对象" + classLayout.toPrintable());
ClassLayout classLayout1 = ClassLayout.parseInstance(new int[]{});
System.out.println("数组对象" + classLayout1.toPrintable());
ClassLayout classLayout2 = ClassLayout.parseInstance(new Model());
System.out.println("自定义对象" + classLayout2.toPrintable());
}
}
两种模式运行结果:
- VM options JVM参数不做任何设置(64位,默认指针压缩开启)
从下图运行结果可知,打开了指针压缩,Klass Pointer类型指针占了4个字节
- VM options 参数设置
‐XX:‐UseCompressedOops
关闭指针压缩
从下图运行结果可知,关闭了指针压缩,Klass Pointer类型指针占了8个字节
★ 什么是指针压缩?
指针压缩是一种内存优化技术
,可以减少程序中指针类型变量
所占用的内存空间。在32位系统中,每个指针类型变量占用4个字节的内存空间,而在64位系统中,每个指针类型变量占用8个字节的内存空间。指针压缩通过将指针地址转换为相对于某个基地址的偏移量来实现,从而可以将指针类型变量占用的内存空间减少至4个字节,而不会引起错误。指针压缩通常应用于内存占用较大的程序中,例如Java虚拟机。
★ 为什么使用指针压缩?
主要原因是为了减少内存占用
,提高性能
。
在32位的计算机系统中,一个指针占用4个字节,而在64位的计算机系统中,一个指针占用8个字节,而且64位指针存储的范围更大,会导致在某些情况下内存浪费。
因此,为了更好地使用内存,JVM引入了指针压缩技术。当堆内存小于32GB时,JVM会启用指针压缩,将对象引用从原来的64位压缩为32位。
三. JVM对象内存分配详解
内存概念: 内存是计算机中存放程序和数据的地方,它可以被电脑随时读取和写入。内存通常指的是随机存储器
(RAM),因为它可以随机访问,也就是在任何时间和任何位置都可以读取和写入。内存是临时的存储器
,当计算机关闭时,内存中的所有数据都会被清除。
计算机内存的大小通常以GB(千兆字节)
来计量,因为随着电脑处理速度的提高,程序和数据的大小也越来越大。在计算机运行多个程序时,内存的大小将会决定电脑的性能和响应速度。
JVM内存布局规定了Java在运行过程中的内存申请
、分配
、管理的策略
,保证了JVM高效的运行。
步骤解析: new一个对象,对象在JVM内存中是如何流转的?
- JVM会先通过
逃逸分析
判断,如果对象不会逃逸,则在栈中
开辟一个临时空间(很小)存储,随着栈帧
的空间的回收而销毁。如果会逃逸,则会在堆中
开辟内存空间存储。 - 确定对象会在堆中存储,先判断对象是否为大对象(JVM参数可配置),如果对象的大小达到大对象阀值,那么此对象会直接存储到堆中
老年代
中。 - JVM判断是否开启
TLAB
,如果开启,会在堆中的Eden
给当前线程开辟一块本地分配缓存区
用于存放对象,不管是否开启TLAB,对象都会存放在堆中的Eden中,只是存储在Eden中的位置不一样。 - JVM判断Eden区中是否可以放的下当前对象,放得下就放,放不下触发Minor GC,如果GC过后还是放不下,系统会直接报
OOM
随着后续Minor GC
次数增多,未销毁的对象会在S0
和S1
区域来回转移,当分代年龄达到设定阀值时,对象就会被移到老年代中。 - 老年代内存放满了,会触发
Full GC
,如果GC后对象还是在老年代中放不下,也会报OOM。
四.逃逸分析 & 栈上分配 & 标量替换详解
技术诞生背景:当new一个对象时,对象是存在JVM的堆内存中,当对象没有被引用时,需要依靠GC来回收对象释放内存,如果对象的数量较多,会给GC带来压力,也间接的影响应用的性能。
1. 逃逸分析 & 栈上分配
为了减少临时对象在堆内存中分配的数量,JVM通过逃逸分析确定该对象会不会被外部引用,如果对象不会逃逸,则将对象在栈上分配一块临时内存存储,这样该对象所占用的内存空间就会随着栈帧出栈而销毁,减轻GC的压力。
- 可以通过以下代码演示JVM的逃逸分析:
public class EscapeAnalysisDemo {
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
Person person = new Person(i, "Name " + i);
person.displayInfo();
}
}
static class Person {
private int id;
private String name;
public Person(int id, String name) {
this.id = id;
this.name = name;
}
public void displayInfo() {
System.out.println("Person [id=" + id + ", name=" + name + "]");
}
}
}
在上面的示例中,创建了一个Person对象,并在main方法中执行一个循环,每次迭代都创建一个新的Person对象并调用其displayInfo()方法。如果JVM可以通过逃逸分析确定Person对象的作用域仅限于main方法中,则可以将其在栈上分配。
- 要启用JVM的逃逸分析,我们可以使用以下JVM选项:
-XX:+DoEscapeAnalysis
- 在执行程序时,我们可以通过以下命令启用JVM的逃逸分析
java -XX:+DoEscapeAnalysis EscapeAnalysisDemo
- 如果JVM成功地进行了逃逸分析,则可以在运行时观察到堆分配和栈分配的数量。
可以使用以下JVM选项来获取这些信息:
-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime
-XX:+PrintCompilation
如果JVM成功地进行了逃逸分析,则可以看到较少的堆分配和更多的栈分配,并且程序执行速度更快。
2. 标量替换
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。
3. 标量与聚合量
标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量
(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。
总结:JVM在JDK7之后默认开启逃逸分析和标量替换,两者有任何一个被关闭,都会产生大量的GC,所以栈上分配依赖于逃逸分析和标量替换。
4. 对象在堆内存中的流转与分配
-
对象在Eden上的分配
当对象进入新生代中Eden区分配内存时,当Eden没有足够的内存空间进行分配时,JVM虚拟机就会触发一次Minor GC -
大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)
★ 为什么设置大对象直接进入老年代?
为了避免为大对象分配内存时的复制操作而降低效率。 -
长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
如果对象在Eden出生并经过第一次Minor GC后仍然能够存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。
对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。 -
对象动态年龄判断
当前放对象的Survivor区域里(其中一块区域,放对象的那块S区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。
对象动态年龄判断机制一般是在Minor GC之后触发的。 -
老年代空间分配担保机制
★问: Minor GC与Full GC的区别?
Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也 比较快
Major GC/Full GC:一般会回收老年代,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上
★问: 为什么Eden与Survivor区(S0和S1区域)默认内存空间大小比例是8:1:1?
大量的对象被分配在Eden区,Eden区满了后会触发Minor GC,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到S0区,下一次Eden区满了后又会触发Minor GC,把Eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到S1区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的,让Eden区尽量的大,Survivor区够用即可。
JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy。
五.对象内存回收机制详解
堆中存放着几乎所有的对象实例,在对堆中对象进行垃圾回收前第一步就需要判断堆中哪些对象是可回收对象(即不能被再被任何途径使用的对象)。
★ 1. 如何判断对象是可回收的?
- 引用计数算法 (未选择)
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。- 优点:实现简单,效率高
- 缺点:很难解决对象之间相互循环引用的问题
public class ReferenceCountingGC {
Object instance = null;
public static void main(String[] args) {
ReferenceCountingGC a = new ReferenceCountingGC();
ReferenceCountingGC b = new ReferenceCountingGC();
a.instance = b;
b.instance = a;
a = null;
b = null;
}
}
虽然a与b对象最后都赋值为null,但是两者之间相互引用,导致两个对象的计数器不为0,GC不会将这两个对象当作垃圾回收的。
- 可达性分析算法(选择)
将“GC Roots”对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象。
绿色标记的对象是非垃圾对象,因为有GC Root引用,是可达的; Object6,Object7和8之间虽然有引用,但是此3个对象到GC Roots是不可达的,会被判定为可回收对象,即第一次标记。
★ 2. 常见的引用类型
引用类型 | 说明 |
---|---|
强引用 | 普通的变量引用 public static User user = new User(); |
软引用 | 将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。 软引用可用来实现内存敏感的高速缓存 public static SoftReference user = new SoftReference(new User()); |
弱引用 | 将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用 public static WeakReference user = new WeakReference(new User()); |
虚引用 | 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用 |
★ 3. 对象真正被GC回收的两次标记过程详解
- 第一次标记: 经历可达性分析算法之后的不可达的对象被打上标记,然后不可达的对象还需要进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,然后直接回收该对象。
- 第二次标记:通过finalize()方法最终判定对象是否存活
经历了第一次标记和筛选后剩余的不可达对象,此时是被虚拟机视为“有必要执行”finalize()方法的,这些对象会被放在F-Queue队列中,然后由虚拟机自己建立一个低优先级的Finalizer线程去执行它,此时对象是覆盖了finalize()方法并且方法中没有与引用链上任何一个对象建立关联,即视为自救失败,会被GC回收掉,否则视为自救成功,从F-Queue队列中移除,GC不会回收。
备注:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次。
代码演示对象的自救和回收:
public class OOMTest {
@Data
@AllArgsConstructor
static class Model {
private Integer id;
private String serialId;
@Override
protected void finalize() throws Throwable {
System.out.println("关闭资源,id=" + id + ",即将被回收");
}
}
public static void main(String[] args) {
ArrayList<Object> list = new ArrayList<>();
int i = 0, j = 0;
while (true) {
list.add(new Model(i++, UUID.randomUUID().toString()));
new Model(j--, UUID.randomUUID().toString());
}
}
}
运行结果: 会被GC回收的对象都是id小于0的
★ 4. 如何判断一个类是无用的类?
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
- 加载该类的
ClassLoader
已经被回收 - 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法