JVM中的对象及引用

Java
232
0
0
2023-09-09
标签   JVM

JVM中对象的创建流程

对象的内存分配

当虚拟机遇到一条new指令的时候,首先会检查是否被类加载过了。如果没有先必须执行相应的类加载。类加载就是把class文件加载到JVM运行时数据区的过程。

JVM中的对象及引用

  • 检查加载

首先检查这指令的参数是否能在常量池中定位到一个类的符号引用(符号引用:符号引用以一组符号来描述所引用的目标),并且检查类是否已经被加载、解析和初始化过。

  • 分配内存

接下来 虚拟机 将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

指针碰撞

如果Java堆中的内存是绝对规整的,所有用过的内存都放在一边,用过的内存都放在一边,空闲的内存都放在另一边。中间有一个指针作为分界线。如果继续分有内存分配进来,指针只需要向空闲内存移动该对象同样大小的内存区域 (该对象大小经过对齐填充,填充后的对象内存是8字节的倍数,因此指针碰撞存储的对象依然是有规律的空间) ,从而保证内存的连续性,这种分配方式叫做 “指针碰撞” ;、

JVM中的对象及引用

空闲列表

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没办法简单地进行指针碰撞了。虚拟机此时就需要维护一张表,记录哪些区域是可用的。在分配内存的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式叫做 “空闲列表” ;

JVM中的对象及引用

采用哪种方式由堆得存在形式决定。而堆得存在形式又由垃圾回收器是否带有压缩整理功能决定。

在此多提一下:垃圾回收中只有CMS垃圾回收会造成空闲列表,其余都是指针碰撞。

并发安全问题

除了如何划分对象的存储空间外,还有一个需要考虑的是虚拟机中创建对象是非常频繁的。 堆内存 是线程共享区,多个线程同时访问同一片区域的时候,两个线程同时判断一片区域是空闲区域,但当第一个对象分配完内存后,第二个对象依旧分配给了这个已分配过的内存,从而产生线程安全问题。

解决方案

解决这类问题的方案有以下两种

CAS机制

JVM中的对象及引用

CAS的实际宗旨其实不管三七二十一,先把活儿干了,再管有没有用。以消耗CPU性能为代价争取更高的效率。

TLAB机制(分配缓冲)

另一种是把内存分配的动作以线程为单位划分在堆内存中,即每一个线程在 Java 堆中预先分配一块小的私有内存,也就是本地线程分配换成(Thread Local Allocation Buffer,TLAB),JVM在线程初始化时,同时也会申请一块指定大小的内存,只给当前内存使用。这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大的提升分配效率。当Buffer容量不够的时候,再从Edsen区域申请一块继续使用。

TLAB的目的是在新对象分配内存空间时,让每个Java应用线程能在使用自己专属的内存指针来分配内存,减少同步开销。

TLAB只是让每个线程有私有的分配指针。但地下存活对象的内存空间还是给所有线程访问的,只是其他线程无法在这个区域分配而已。相当于一个线程的TLAB用满,就再申请一个该线程的TLAB。

相当于从根本上解决线程安全问题。

JVM中的对象及引用

  • 内存空间初始化

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值( 成员变量初始化。此时成员变量都在堆内存中,如int=0;boolean=false )。这一步操作证实了对象的实例字段在Java代码中可以不赋初值就可以直接使用。程序能访问到这些字段的数据类型所应的零值。

  • 设置

接下来,虚拟机要对对象进行必要的设置。例如这个对象属于哪个类的实例,如何才能找到类元数据信息(Java classes在Java hotspot VM内部表示为类元数据),对象的哈希码,对象的GC分代年龄等信息。这些信息都存放在对象的对象头中。

  • 对象初始化

在上面工作完成之后,从虚拟机的角度来看,一个新的对象已经产生了,但是从Java的角度来说,对象的创建才刚开始。所有的字段都是零值。所以说,执行new执行之后,会接着把对象按照程序员的意愿进行初始化 (构造方法) 。这样一个真正可用的对象就产生了。

对象的内存布局

JVM中的对象及引用

在HotSpot中,对象在内存中存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data) 和对齐填充(Padding)。

对象头包括两部分信息,第一部分用于存储自身对象运行时数据:如哈希码(HashCode),GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳。

对象头的另一部分是类型指针,即对象指向它的类元数据( 方法区的class数据 )的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

如果对象是一个Java数组,那么对象头中还有一块用于记录数组长度的数据。

第三部分对齐填充并不是必然存在的,也没有特别的含义,仅仅起到一个 占位符 的作用。由于HotSpot VM的自动内存管理系统要求对对象的大小必须是8字节的整数倍。当对象其他数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

建立对象是为了使用对象,我们的Java程序需要通过栈上的reference(引用)数据来操作堆上的具体对象。目前主流的访问方式由使用句柄和直接指针两种。

句柄

JVM中的对象及引用

引用此时存储的是句柄池的地址。当引用访问对象时,需要先访问到句柄池。在 句柄 池中有对象实例数据的指针以及对象类型数据的指针。相当于多了一个步骤访问对象,所以使用句柄池要在堆内创建中出一个新的区域。

句柄池的好处:如果当对象的位置发生了转变 (例:在垃圾回收时,标记整理算法会使对象的地址发生改变) ,我们不需要修改栈中的引用地址,只需要改变句柄池中指针的地址。带来了稳定性,损耗了性能。

直接指针

JVM中的对象及引用

如果使用直接指针访问,reference中存储的直接就是对象地址。

这两种对象访问方式各有优势。使用直接指针访问的最大好处就是速度快,节省了一次指针定位的开销时间,由于对象的访问在Java中非常频繁,因此这类开销积少成多之后也是一项非常客观的执行成本,

HotSpot就是使用直接指针的访问方式进行对象访问的。

判断对象的存活

在堆里面,存放着几乎所有的对象实例 (部分对象被虚拟机优化技术分配到了栈内存中)。垃圾回收器在对对象进行回收前,要做的事情就是确认这些垃圾哪些是“存活的”,哪些是”死去的”,那么什么对象才算是死去的呢?

垃圾的产生过程

JVM中的对象及引用

由图可见,Study对象一开始被创建出来,但之后与引用断开。也就是说没有任何引用指向的一个或多个对象就变成了垃圾。

如何判断垃圾

引用计数法

在对象中添加一个引用 计数器 。每当有一个地方使用它,计数器就加1,当引用失效时就减step1:

JVM中的对象及引用

step2:

JVM中的对象及引用

step3:

JVM中的对象及引用

此时的对象2,对象4理论上已经是垃圾了,但是引用计数不为0,所以无法回收。

Python中依然在用此方法判断垃圾,但主流虚拟机没有使用。因为存在对象相互引用的情况。这时候需要引入额外的机制来处理这类问题,导致影响效率。

JVM中的对象及引用

JVM中的对象及引用

我们在每一个对象中创建一个10M大小的字节数组。两个对象互相引用共20M。当垃圾回收时内存空间由25M回收至0.8M,证明在HotSpot中这两个循环引用的对象已经被回收。那么HotSpot中使用的是什么算法判断垃圾的呢?

可达性分析算法 (面试重点)

来判断对象是否存活,这个算法的思路就是通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索时经过的路径被称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明对象不可用。作为GC Roots的对象包括以下几种( 重点是前4种

  1. 虚拟机栈(栈帧中的本地变量表)中的引用。各个线程虚拟机栈中的局部变量。
  2. 方法区中类静态属性的引用:Java中的引用类型静态变量。
  3. 方法区中的常量引用:字符串常量池里的字面量对应的引用。
  4. 本地方法栈中 JNI (即我们一般说的Native方法)中的变量。
  5. JVM内部引用(class对象,异常对象,类加载器等)
  6. 被同步所持有的对象。
  7. JVM内部中 JMX Bean, JVM TI中注册的 回调 ,本地代码缓存等。
  8. JVM实现中的”临时变量对象”,跨代引用的对象(在使用分代模型回收只回收部分代的对象,后续会讲,大致了解)

类的回收条件

类的回收条件比较苛刻,必须同时满足以下的条件(仅仅是可以,不代表必然,因此仍有参数可以控制)。参数可控禁止类的垃圾回收,从而提高效率。

  1. 该类的所有实例已经被回收
  2. 加载该类的ClassLoader已经被回收。
  3. 该类对应的Java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

Finallize方法

即使通过可达性分析判断的对象也不是非死不可,此时它仍处于“缓刑阶段”。真正要宣告一个对象死亡,需要经过两次标记过程。一次是没有找到与GC Roots的引用链。此时将被第一次标记。随后进行一次筛选(如果对象覆盖了 finalize ),我们可以在finallize中去拯救它。

代码演示:

JVM中的对象及引用

JVM中的对象及引用

可以看到,第一次对象被成功拯救,第二次无法被拯救。但是Finallize方法一定能拯救成功么?我们看下面的例子。

JVM中的对象及引用

JVM中的对象及引用

我们可以看到,在Finallize方法在GC执行后再执行,对象会拯救失败。因此无法保证Finallize的准确性,因此不推荐使用。

其他语言的垃圾回收

C语言申请内存 malloc free

C++: new delete

C/C++ 手动回收内存

Java:new

Java是自动内存回收的,编程简单,系统不容易出错。

手动释放内存,容易出现两种类型的问题:

1:忘记回收

C/C++手动回收 忘记回收造成内存泄漏

2:多次回收

如果两个对象

A malloc free free

B malloc

此时就有可能造成我的线程创建出的对象被其他线程回收掉的情况。