写点什么

JVM 中的对象及引用

作者:Ayue
  • 2021 年 12 月 03 日
  • 本文字数:9338 字

    阅读完需:约 31 分钟

JVM中的对象及引用

对象的创建过程

Java 是一种面向对象的语言,所谓的对象是什么?怎么来的?但是你一定听过没对象怎么办,自己 new 一个对象,所以,当 JVM 遇到一条字节码 new 的指令,就相当于告诉它要创建对象了,所以它会执行以下的步骤。


类加载

虚拟机遇到一条 new 指令时,首先检查是否被类加载器加载,如果没有,那必须先执行相应的类加载过程,简单来说就是把 class 加载到 JVM 的运行时数据区的过程(可参考我的这篇文章:JVM加载class文件)。

检查加载

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

分配内存

JVM 为新生对象分配内存,等同于把一块确定大小的内存从 Java 堆中划分出来,而内存的划分又有两种方式:


  • 指针碰撞:如果 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞。


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


  • 选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。如果是 Serial、ParNew 等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。如果是使用 CMS 这种不带压缩(整理)的垃圾回收器的话,理论上只能采用较复杂的空闲列表(后续会细讲垃圾回收器)。

  • 并发问题

    我们知道 Java 天生就是多线程的,所以对象创建在虚拟机中是非常频繁的行为,在划分空间的时候仅仅是修改指针所指向的位置也是线程不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。怎么解决?

    CAS 机制

    CAS,Compare and Swap,即比较再交换。一个 CAS 操作过程都包含三个运算符:一个内存地址 V,一个期望的值 A 和一个新值 B,操作的时候如果这个地址上存放的值等于这个期望的值 A,则将地址上的值赋为新值 B,否则不做任何操作。CAS 的基本思路就是,如果这个地址上的值和期望的值相等,则给其赋予新值,否则不做任何事儿,但是要返回原值是多少。循环 CAS 就是在一个循环里不断的做 CAS 操作,直到成功为止(想仔细了解可以看我这篇文章:CAS详解)。



分配缓冲

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

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

    TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个 TLAB 用满(分配指针 top 撞上分配极限 end 了),就新申请一个 TLAB。

    -XX:+UseTLAB:允许在年轻代空间中使用线程本地分配块(TLAB)。默认情况下启用此选项。要禁用 TLAB,请指定-XX:-UseTLAB

内存空间初始化

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

设置

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

对象初始化

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。

所以,一般来说,执行 new 指令之后会接着把对象按照程序员的意愿进行初始化(构造方法),这样一个真正可用的对象才算完全产生出来。


对象的内存布局

根据 java 虚拟机规范里面的描述:java 对象分为三部分:对象头(Object Header), 实例数据(instance data),对齐填充(padding)。


对象头

HotSpot 虚拟机的对象头主要包括两部分(若是数组对象还包括一个数组的长度)信息,对象头在 32 位系统上占用 8bytes,64 位系统上占用 16bytes。


  • Mark Word,主要存储哈希码(HashCode)、GC 分代年龄、锁状态标识、线程持有的锁、偏向线程 ID、偏向时间戳等

  • 类型指针,即对象指向它的类元数据的指针(即存在于方法区的 Class 类信息),虚拟机通过这个指针来确定这个对象是哪个类的实例。

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

实例数据

实例数据部分是对象真正存储的有效信息(也就是被 new 出来的对象信息),也是在程序代码中所定义的各种类型的字段内容。原生类型(primitive type)的内存占用如下:



reference类型在 32 位系统上每个占用 4bytes, 在 64 位系统上每个占用 8bytes。

对齐填充

对齐填充不是必然存在的,没有特别的含义,它仅起到占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,也就是说对象的大小必须是 8 字节的整数倍(这是个规定)。对象头部分是 8 字节的倍数,所以当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。


(对象头 + 实例数据 + padding) % 8 等于 0 且 0 <= padding < 8


一个Java对象到底占用多大内存?

对象的访问定位

建立对象的目的是为了使用对象,我们的 Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有 句柄直接指针 两种。


句柄

句柄(Handle)是什么?


举个栗子,比如我们开门(Door)的时候是通过扭动门把手(Door Handle)来控制的,但是Door Handle 又不是Door本身,但是确实需要这个 Handle 去操作,所以可以把句柄理解为一个中间媒介。


所以,如果使用句柄访问对象 的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。


使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。


直接指针

如果使用直接指针访问, reference 中存储的直接就是对象地址。 就相当于我开门的时候不需要门把手去控制,直接用手一推就开了。



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


对 Sun HotSpot 而言,它是使用直接指针访问方式进行对象访问的。

判断对象的存活

在堆里面存放着几乎所有的对象实例(为什么是几乎,因为栈也可能存在对象,后续会讲到),垃圾回收器在对对进行回收前,要做的事情就是确定这些对象中哪些还是存活着,哪些已经死去(死去代表着不可能再被任何途径使用得对象了) 。


引用计数法

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

主流 Java 虚拟机并未采用该算法,很难解决对象之间相互循环引用的问题,如:对象 A 指向 B,对象 B 反过来指向 A,此时它们的引用计数器都不为 0,但它们俩实际上已经没有意义因为没有任何地方指向它们。

简单来说就是假如公司要搜集员工意见,员工 A 和员工 B 相互讨论了很多意见,但是最后公司上层压根就没有采纳 A 和 B 的意见,等于 A 和 B 相互在玩。

我们可以验证一下 HotSpot VM 是否采用了该算法,如下:

public class ReferenceCount {    static class A {        private B b;        //get/set    }    static class B {        private A a;        //get/set    }    public static void main(String[] args) {        A a = new A();        B b = new B();        a.setB(b);        b.setA(a);        a = null;        b = null;        System.gc();//虽然相互引用,但在GC之后还是被回收了    }}
复制代码

可达性分析

Java 是通过可达性分析来判定对象是否存活的(可以叫做根可达)。这个算法的基本思路就是通过一系列的称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可达的。

怎么理解?我们从 3 个方面去解释


什么是 GC Roots

垃圾回收时,JVM 首先要找到所有的GC Roots,这个过程称作 「枚举根节点」 ,这个过程是需要暂停用户线程的,即触发 STW。然后再从 GC Roots 这些根节点向下搜寻,可达的对象就保留,不可达的对象就回收。

GC Roots其实就是对象,而且是 JVM 确定当前绝对不能被回收的对象(如方法区中类静态属性引用的对象 ),只有找到这种对象,后面的搜寻过程才有意义,不能被回收的对象所依赖的其他对象肯定也不能回收。

当 JVM 触发 GC 时,首先会让所有的用户线程到达安全点SafePoint时阻塞,也就是STW(垃圾回收器再去细讲),然后枚举根节点,即找到所有的GC Roots,然后就可以从这些GC Roots向下搜寻,可达的对象就保留,不可达的对象就回收。

即使是号称几乎不停顿的CMS、G1等收集器,在枚举根节点时,也是要暂停用户线程的。

GC Roots是一种特殊的对象,是 Java 程序在运行过程中所必须的对象,而且是根对象。

什么是对象可达

对象可达:对象双方存在直接或间接的引用关系。

根可达(GC Roots 可达):对象到 GC Roots 存在直接或间接的引用关系。


那些对象可作为 GC Roots

作为 GC Roots 的起始点对象,这个对象主要是下面前四种:

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象,各个线程在执行方法时会打包为一个栈帧,堆栈中使用到的参数、局部变量、临时变量会存放到栈帧的局部变量表中。只要方法还在运行,还没出栈,就意味这本地变量表的对象还会被访问,GC 就不应该回收,所以这一类对象也可作为GC Roots

  • 方法区中类静态属性引用的对象,java 类的引用类型静态变量属于 Class 对象,Class 对象本身很难被回收,回收的条件非常苛刻,只要 Class 对象不被回收,静态成员就不能被回收。

  • 方法区中常量引用的对象,比如:字符串常量池里的引用,常量本身初始化后不会再改变。

  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

  • JVM 的内部引用(class 对象、异常对象 NullPointException、OutofMemoryError,系统类加载器)。

  • 所有被同步锁(synchronized 关键)持有的对象。

  • JVM 内部的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

  • JVM 实现中的临时性对象,跨代引用的对象(在使用分代模型回收只回收部分代的对象)。

上面都是对象的回收,对于类 Class 的回收,条件比较苛刻,必须同时满足以下的条件(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):

  1. 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。

  2. 加载该类的 ClassLoader 已经被回收。

  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

  4. 参数控制-Xnoclassgc:关闭虚拟机对 class 的垃圾回收功能。


部分概念来自:jvm之可达性分析算法

Finalize 方法

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

public class FinalizeGC {    public static FinalizeGC instance;    @Override    protected void finalize() throws Throwable {        super.finalize();        FinalizeGC.instance = this;    }    public static void main(String[] args) throws Exception {        //创建对象        instance = new FinalizeGC();        System.out.print("第一次gc:");        instance =null;//help gc        System.gc();        //为什么休眠,因为finalize的优先级很低,需要等待        Thread.sleep(1000);        if(instance==null){            System.out.println("you have been dead");        } else {            System.out.println("I am still alive");        }        //进行第二次gc        System.out.print("第二次gc:");        instance =null;//help gc        System.gc();        Thread.sleep(1000);        if(instance==null){            System.out.println("you have been dead");        } else {            System.out.println("I am still alive");        }    }}
复制代码

输出:

第一次gc:I am still alive第二次gc:you have been dead
复制代码

可以看到,对象可以被拯救一次(finalize 执行第一次,但是不会执行第二次)。

如果把代码中的休眠去掉Thread.sleep(1000),则输出:

第一次gc:you have been dead第二次gc:you have been dead
复制代码

对象没有被拯救,这个就是 finalize 方法执行缓慢,还没有完成拯救,垃圾回收器就已经回收掉了。

因此,finalize尽量不要使用 ,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序。

finalize 方法能做的工作,java 中有更好的,比如 try-finally 将要执行的后续操作放入到finally块中。


对象的四大引用

Object o=new Object(),这个 o,我们可以称之为对象引用,而 new Object()我们可以称之为在内存中产生了一个对象实例。



当写下 o = null 时,只是表示 o 不再指向堆中 object 的对象实例,不代表这个对象实例不存在了。

强引用

就是指在程序代码之中普遍存在的,类似Object obj=new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象实例。

软应用

是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象实例列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 之后,提供了 SoftReference 类来实现软引用。


/** * 软引用 * VM:-Xms20m -Xmx20m */public class TestSoftRef {    //对象    public static class User {        public int id = 0;        public String name = "";
public User(int id, String name) { this.id = id; this.name = name; }
@Override public String toString() { return "User [id=" + id + ", name=" + name + "]"; } }
public static void main(String[] args) { User u = new User(1, "Ayue"); //new是强引用 SoftReference<User> userSoft = new SoftReference<User>(u);//软引用 u = null;//干掉强引用,确保这个实例只有userSoft的软引用 System.out.println(userSoft.get()); //看一下这个对象是否还在 System.gc();//进行一次GC垃圾回收 System.out.println("After gc"); System.out.println(userSoft.get()); //往堆中填充数据,导致OOM List<byte[]> list = new LinkedList<>(); try { for (int i = 0; i < 100; i++) { //System.out.println("第" + (i+1) + "次" + userSoft.get()); list.add(new byte[1024 * 1024 * 1]); //1M的对象 100m } } catch (Throwable e) { //抛出了OOM异常时打印软引用对象 System.out.println("Exception:" + userSoft.get()); } }}
复制代码


输出:


User [id=1, name=Ayue]After gcUser [id=1, name=Ayue]第1次User [id=1, name=Ayue]第2次User [id=1, name=Ayue]......第18次User [id=1, name=Ayue]Exception:null
复制代码


可以看到,尽管调用 GC 也没有立即回收掉,大概在第 18 次之后就溢出了,此时会把软引用对象给回收掉。

弱引用

也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时, 无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在 JDK 1.2 之后,提供了 WeakReference 类来实现弱引用。


public class TestWeakRef {  public static class User{    public int id = 0;    public String name = "";    public User(int id, String name) {      super();      this.id = id;      this.name = name;    }    @Override    public String toString() {      return "User [id=" + id + ", name=" + name + "]";    }  }
public static void main(String[] args) { User u = new User(1,"Ayue"); WeakReference<User> userWeak = new WeakReference<User>(u); u = null;//干掉强引用,确保这个实例只有userWeak的弱引用 System.out.println(userWeak.get()); System.gc();//进行一次GC垃圾回收 System.out.println("After gc"); System.out.println("发生GC后:"+userWeak.get()); }}
复制代码


输出:


User [id=1, name=Ayue]After gc发生GC后:null
复制代码


弱引用的引用比较常见的就是ThreadLocal,感兴趣的可以去看我的这篇文章:ThreadLocal详解

虚引用

也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象实例是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象实例被收集器回收时收到一个系统通知。在 JDK 1.2 之后,提供了PhantomReference 类来实现虚引用。

对象的分配策略

之前将了 JVM 的内存布局,以及对象在 JVM 中的分布,但是对象在 JVM 中的分配是通过什么决定的呢?比如常说的一句话就是:几乎所有的对象都分配在堆中,那为什么不能说是一定呢?难道出了堆,其他地方也能分配对象吗?栈能不能分配对象?而我们知道就算是堆又细分为新生代(Eden,From,To)和老年代,那么每个区域都会存在对象吗?其实这取决于我们 JVM 的对象分配策略。


栈上分配

几乎所有的对象都分配在堆中,都说了是几乎,那几乎堆之外的栈是不是也能分配对象?答案是可以的,但是肯定是需要条件的,满足什么条件可以使得对象在栈上分配呢?


如果方法中的对象没有发生逃逸,对象可以在栈上分配。


**逃逸分析的原理:**分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用。


方法逃逸:调用参数传递到其他方法中,这种称之为方法逃逸,甚至还有可能被外部线程访问到,如:赋值给其他线程中访问的变量,这个称之为线程逃逸。


从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。


如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高 JVM 的效率,因为不需要进行垃圾回收了。

堆上分配

一般来说,对象都分配在堆中,我们知道堆被划分为新生代和老年代(Tenured),新生代又被进一步划分为 EdenSurvivor 区,最后 SurvivorFrom SurvivorTo Survivor 组成。


对象优先分配在 Eden 区

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间分配时,虚拟机将发起一次 Minor GC。

大对象直接进入老年代

大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。


大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群朝生夕死的短命大对象,我们写程序的时候应注意避免。


在 Java 虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们。而当复制对象时,大对象就意味着高额的内存复制开销。


HotSpot 虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在 Eden 区及两个 Survivor区之间来回复制,产生大量的内存复制操作。


这样做的目的是什么?


1.避免大量内存复制


2.避免提前进行垃圾回收,明明内存有空间进行分配。注意:PretenureSizeThreshold 参数只对 SerialParNew 两款收集器有效。

长期存活对象进入老年区

HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中,也就是对象的内存布局中的 GC 分代年龄


如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 区容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1,对象在 Survivor区中每熬过一次 Minor GC,年龄就增加 1,当它的年龄增加到一定程度(并发的垃圾回收器默认为 15,CMS 是 6 ,可通过-XX:MaxTenuringThreshold调整)时,就会被晋升到老年代中。

对象年龄动态判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的,如果担保失败则会进行一次 Full GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC


总结

这部分主要讲的是对象在 JVM 中是一种什么样的状态,其中涉及到 GC 部分会在下篇文章中细讲。


发布于: 47 分钟前阅读数: 6
用户头像

Ayue

关注

还未添加个人签名 2019.10.16 加入

学习知识,目光坚毅

评论

发布
暂无评论
JVM中的对象及引用