从零开始理解JVM:对象的生命周期之对象创建

发布于:2024-11-28 ⋅ 阅读:(35) ⋅ 点赞:(0)

一、对象创建

从你new一个对象开始,发生了什么?

遇到new指令,jvm首先要做的事是检查有没有这个类,没有的话,加载它!

接下来,就要进行实例的内存分配,通过什么样的方式进行内存分配呢?

1、内存分配方式

指针碰撞

这种分配前提是内存中有整片连续的空间,用的在一边,空闲的在另一边,用一个指针指向当前已经被分配的内存边界。

需要多少指针往空闲那边移动多少,直接划分出来一段,给当前对象,完工。

分配内存就由指针往后挪就行了

但是这种方式,思考一个问题,中间的一些对象被回收之后,为了确保内存的连续性,是不是该把后面的对象占用的内存往前移,这称为内存整理,显然,是有很大开销的,如果在一个区域发生对象回收的频率较高,用指针碰撞的方式是不适合的。

空闲列表

那如果jvm堆不那么规整呢?用的和没用的交叉在一起,也就是我们所说的内存碎片。

这种情况就需要我们单独有一张表来记录,哪些内存块是空的。

分配的时候查表,找到大小够用的一块,分配给对象,同时更新列表。

2、并发性问题

无论指针移动还是空闲列表的同一个指针空间,在并发分配的情况下会不会有问题?

很聪明!确实有并发问题。那jvm是如何解决的呢?(当然传统的粗暴的加锁和同步机制肯定能解决,我们暂不讨论这个)

方式一:cas原子操作 + 失败重试

在做内存指针更新的时候,将指针的获取和更新操作变为一气呵成的原子操作

CAS 操作通常包括三个参数:内存位置(V)、预期值(A)和新值(B)。CAS 操作会检查内存位置 V 的值是否等于预期值 A,如果是,则将 V 的值更新为 B;否则,操作失败。

操作失败就进行重试就行了

方式二:本地线程分配缓冲(TLAB)

TLAB 是 JVM 为每个线程分配的一个本地缓冲区(其实就是从堆上分配一小块空间给每个线程),用于对象的快速分配。每个线程在其 TLAB 中分配对象,这样可以减少线程之间的竞争,提高内存分配的效率。

那么线程创建对象需要内存时,可以在自己划走的堆上先操作。相当于每个线程批发了一批内存先用着。

当前线程空间不够时,再去公共堆上申请,这样就减少了并发冲突的机会。当然也多少有点浪费

3、对象分配内存的完整过程总结

我们要先讲两个概念,逃逸分析和标量替换

逃逸分析:

逃逸分析是一种编译器优化技术,用于确定对象的作用域。如果一个对象只在一个方法内部使用,且不会被其他线程访问,那么这个对象就被称为“未逃逸”。

对于未逃逸的对象,可以进行“标量替换”。

标量替换:

标量替换是一种优化技术,它将对象拆解成多个标量值(如基本类型或引用),并将这些标量值直接存储在栈上,而不是创建一个完整的对象。避免了对象的创建和垃圾回收。

接下来我们再来看完整的对象分配内存过程:

①,若开启逃逸分析,那么对于未逃逸的对象,我们将直接在本地的栈帧中分配内存(当然是内存空间够的情况下),这个过程也利用标量替换来优化

②,如果本地栈空间不够,若采用TLAB,我们会优先在TLAB中分配

③,若TLAB空间也不够,我们才会在堆区进行分配内存,大概率是进入Eden区

二、内存布局

上面我们给这个对象分配好了内存空间,那么问题来了。对象拿走的这块内存,它都写了些啥进去呢?

对象在堆上的布局,可以分为三个部分:对象头、实例数据、对齐填充。

1、对象头

对象头一般分为两部分,Mark Word 和 类型指针(Hotspot)

1)Mark Word,官方叫法,其实就是存储对象自己运行时的数据

如哈希码、GC分代年龄、锁状态标记、线程持有的锁、偏向的线程id……(不用记)

2)类型指针(Klass)

指向当前对象的类型。也就是方法区里,类信息的地址。

当然这里不是绝对的,hotspot这么设计。

2、实例数据

对象里各个字段的值。这个好理解。

long,double,int等长度都是固定的

string、对象类型等是个地址,指向其他外部堆空间

3、对齐填充 

不是必须的。就是个占位符而已。

Hotspot规定的,内存管理系统要求对象的大小必须是8字节的整数倍。

三、对象的访问

句柄访问

句柄方式:

栈指针指向堆里的一个句柄的地址,这个句柄再定义俩指针分别指向类型和实例

很显然,垃圾回收移动对象的话只需要改句柄即可,不会波及到栈,但是多了一次寻址操作

直接地址

直接地址:

栈指针指向的就是实例本身的地址,在实例里封装一个指针指向它自己的类型

很显然,垃圾回收移动对象要改栈里的地址值,但是它减少了一次寻址操作。

备注:hostspot使用的是直接地址方式