JVM中的对象及引用
new的过程
基本上所有的对象都是通过new
关键字创建出来的,类似下面这样的
MyObject my = new MyObject()
当JVM遇到new
时,会按照下面的流程处理
- 类加载,先加载对象的class
- 检查加载,确认class加载
- 分配内存,为要创建的对象划分内存区域
- 内存空间初始化,主要是进行零值初始化,比如int的默认值是0 boolean的默认值是false
- 设置,设置对象的对象头,实例数据以及对齐填充
- 对象初始化,调用对象的构造器
分配内存
创建对象时,要为对象分配内存空间。分配的方式有两种:
1. 指针碰撞
指针碰撞是示意图如下:
在内存空间规整的情况下,使用JVM使用指针碰撞的方式分配内存,指针会指向已分配内存的末端,当再分配内存时,指针会向后偏移,就像这样:
这种方式就被成为指针碰撞。指针碰撞的方式分配要求内存空间必须是规则且连续的
2. 空闲列表
当内存中对象的分布不连续时,JVM就会使用空闲列表法来分配对象,示意图如下:
JVM会对内存区域编号然后维护一个类似下面这样的列表:
区域 | 是否占用 |
---|---|
区域1 | 否 |
区域2 | 是 |
区域3 | 是 |
区域4 | 是 |
区域5 | 否 |
区域6 | 否 |
区域7 | 否 |
区域8 | 否 |
区域9 | 否 |
当需要分配内存时,JVM从空闲列表中检索可用的区域来分配内存
并发安全
当程序在运行时,不是只有一个线程需要创建对象,而是几乎所有的线程都需要创建对象,这时候为对象分配内存区域就会存在并发安全问题,假如线程1的Obj1和线程2的Obj2被分配了同一个内存区域,那程序就乱套了。JVM解决对象内存分配的并发安全问题的方式也有两种:
1. CAS
图源:King老师
从上面的图中可以看出来,当JVM要为一个对象划分一块内存区域时,会使用CAS的方式,即,先读取当前内存地址A的值,经过JVM的预处理,然后将上次读取的值与现在内存地址A的值做比较,如果这两次读到的值相同,则分配成功,也就是Compare and Swap
2. TLAB
使用CAS的方式已经相对较快的(毕竟没有同步块),但是依旧需要进行一次Compare and Swap,还是有性能的损耗。所以JVM中有一个TLAB(Thread Local Allocation Buffer)机制,这是一块存在于Eden中的线程独享的内存区域,由于是线程独享的,所以并不存在并发问题,也就是快。但是TLAB的默认值是Eden的1%,如果对象比较大,还是得通过CAS的方式在堆中分配(不一定会分配在Eden中!大对象的话,会直接分配到年老代)
对象的内存布局
对象的内存布局大体如下:
在一个对象中,一定会有的是对象头和实例数据,但是不一定会有对齐填充。因为虚拟机规范中规范每个对象的大小都必须是8byte的整数倍,如果当前对象的实例数据+对象头的大小不是8字节的整数倍,那就需要对齐填充,将这个对象填充够8字节的整数倍。
整理一下对象的内存布局
- 对象头
- 对象运行时数据(mark word)
- 哈希码
- GC分代年龄
- 锁状态标识
- 线程持有的锁
- 偏向线程id
- 偏向时间戳
- 对象的类型指针
- 如果对象是数组类型,那还会存储数组的长度
- 对象运行时数据(mark word)
- 实例数据
- 对齐填充(不是100%有)
对象的访问定位
对象访问定位有两种方式:
句柄池
使用句柄池的方式访问对象的意思是,栈帧中局部变量中的引用指向的句柄池中的某个句柄,然后这个句柄再指向堆中的某个对象,大概就是这样:
优点:这样做的优点是局部变量表中的引用是指向句柄池中的句柄的,当对象发生变化(被回收或被移动到其他的内存地址)时,只需要更新句柄池对对象的引用即可,这样可以保证程序的稳定
缺点:由于引入了句柄池,导致了对象的访问多了一次处理从而降低了访问速度
引申:句柄池在堆里
直接指针
直接指针就是这样的:中间没有句柄池,局部变量表中的引用直接指向堆中的内存对象
优点:没有句柄池,也就没有中间过程,对象的访问速度提升了
缺点:实现复杂
引申:现在的Hotspot是使用直接指针的方式访问对象的
垃圾回收
众所周知,JVM的内存是自动回收的,开发人员大多数情况下不需要手动申请(malloc)、释放(free)内存。但是像C、C++这类语言是需要手动申请、释放内存的。
手动管理内存的申请释放容易产生两种问题:
- 忘记释放
会导致内存泄露 - 反复释放
会导致释放有用的内存对象,比如下面这样:
线程1 M F F
线程2 M
也就是说线程1free了内存之后,线程2malloc了相同地址的内存,然后线程1反复释放了同一个地址的内存,这就会导致线程2在相同地址上创建的对象丢失
如何判断对象的是否是垃圾
引用计数法
在引用计数法中,如果没有任何引用指向一个或多个对象,那么这些对象就是垃圾的
对象上记录了自己被引用的次数,每次引用会使引用次数+1,每次置空会使引用-1
存在的问题:引用计数法无法解决循环引用的问题
上面这种引用关系下,对象1
的引用计数为1,对象2
的引用计数也是1,虽然它们与整体已经没有任何关系,但是因为彼此之间持有引用,就导致引用计数!=0
最终无法被回收
可达性分析算法
JVM采用的是这种判断对象是否为垃圾的方式,JVM从GC roots开始查找对象的引用链,凡是在引用链上的对象都是活对象,其他对象为垃圾对象
class的回收机制
class的回收机制非常严格,需要同时满足以下条件才能回收
- 堆中已经不存在当前class的任何实例
- classLoader已经被回收
- 对Class对象没有任何引用
所以说,class几乎不可能被回收,可以使用参数-Xnoclassgc
来禁用class回收,这样可以稍微提高gc效率
注意: class回收是默认开启的,并不是class不能被回收,只是回收条件比较苛刻
对象的引用类型
- 强引用
一般Object o1 = new Object()
这样的引用就是强引用,强引用使用=
- 软引用
软引用需要使用SoftReference<T>
才可以,当系统即将发生OOM时,如果系统中存在软引用,那么GC将会回收软引用所引用的对象。利用这种特点,一般软引用会被用来构建一些不重要数据的内存缓存,比如一些图片、视频等,当系统内存不足时,可以被回收。 - 弱引用
比软引用更弱一层的引用,需要使用WeakReference<T>
,只要发生GC就一定会被回收,比如我们常用的WeakHashMap
,通常也是用来构建内存敏感的缓存的。 - 幽灵引用
对象的分配策略
图源:king老师
- 一般来说,对象会优先分配在Eden
- 但是如果满足逃逸分析(
对象逃不出去,且符合JTI的条件,对象可以拆解为基本数据类型
)的话,会在栈上分配,栈上分配的好处是不需要进行内存回收,方法运行时,栈帧在虚拟机栈中入栈,满足逃逸分析的对象会被创建在局部变量表中,方法执行完毕后,栈帧出栈,消耗的内存自然就释放了。 - 如果不满足逃逸分析,对象会优先在TLAB(默认Eden的1%),如果TLAB中放不下,判断是否是对象,大对象直接进入老年代,小对象进入Eden
- 长期存活对象进入老年代。一般是15次,在对象头的mark word上会有分代年龄,一般是是4位的2进制数值 最大值1111,也就是15,但是也可以通过参数修改
- 动态年龄判断
如果某一年龄的所有对象的大小总和已经大于S区的一半,那么这些对象会提前晋级到老年代 - 空间分配担保
起因:当Eden满了之后,如果还需要分配新的对象,这时就会触发YGC,YGC的同时可能会有新的对象晋升到年老代,这时年老代需要有足够的内存空间来容纳新晋升的对象。
实现方式:JVM计算之前每次发起YGC时晋升到年老代对象的平均大小M,如果本次晋升对象大小<=M,就可以放心发起YGC