您的位置 首页 java

玩转JVM中的对象及引用:从创建到引用到分配和优化策略

类加载检查

java 虚拟机遇到一条new指令的时候,它会先去运行时常量池中寻找new的类的符号引用,并且检查这个符号引用所代表的类是否已经被加载、解析、初始化过。如果没有即需要进行相应的类加载过程。

为新生对象分配 Java 堆内存

对象所需要的内存大小在Java类加载的时候已经确定下来了。为对象分配堆内存相当于把一块内存分出来放置对象。

主要分配内存的方式有两种: 指针碰撞 空闲列表

  • 指针碰撞 :如果堆内存空间是规整的,那么,只需要将指针向空闲区域移动对象大小的内存即可以实现分配内存。
  • 空闲列表 :维护一个空闲列表,记录哪些内存空间是可以使用的,在分配内存的时候,选取一块足够大的空间分配给对象实例,并更新空闲列表。

注意到对象创建在 虚拟机 执行的过程中是非常频繁的行为,仅仅修改一个指针所指向的位置,在并发情况下不是 线程 安全的。因此也有两种解决方案:

  1. 使用CAS并配上失败重试的方式保证更新操作的原子性。
  2. 给每一个线程在Java堆中预先分配线程私有分配缓冲区,哪个线程需要分配内存,只要在线程私有分配缓冲区中分配即可以。

将分配到的内存空间初始化零值

将分配到的内存空间初始化零值,这保证了实例字段不赋值可以直接使用。如果使用了TLAB,这一步可以提前到TLAB分配的时候进行。

对对象进行必要的设置

对象是哪个类的实例;

如何找到类的元数据信息;

对象的哈希码;

对象的GC分代年龄信息;

这些信息存在对象的对象头信息之中

构造函数

执行完以上四步,从虚拟机角度,一个对象已经产生了,但是对于java程序而言,构造函数还没有开始执行。接下来按照构造函数的要求,对对象进行初始化即可。

另:Java堆中对象的内存布局和访问定位

  1. 对象头主要包含两类信息。第一类是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针。
  2. 类型数据部分是对象真正存储的有效信息,即程序代码中定义的各种类型的字段内容。
  3. 对齐填充:任何对象的大小都必须是8字节的整数倍。

对象的访问定位:

  • 使用句柄访问的话,Java堆中将可能会划分出来一块内存来作为句柄池。Reference变量中存放的是句柄池的地址, 句柄 池中存放有到对象实例数据的指针以及到对象类型数据的指针。
  • 使用直接访问的话,reference变量中存放的是对象的实例数据、对象的实例数据中包含有到对象类型数据的指针。

对象的内存布局

问:在 Java 对象创建后,到底是如何被存储在Java内存里的呢?

答:在Java虚拟机(HotSpot)中,对象在 Java 内存中的 存储布局 可分为三块:

  • 对象头 存储区域
  • 实例数据 存储区域
  • 对齐填充 存储区域

①对象头 区域

此处存储的信息包括两部分:

  • 对象自身的运行时数据(Mark Word)

如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等

该部分数据被设计成1个 非固定的数据结构 以便在极小的空间存储尽量多的信息(会根据对象状态复用存储空间)

  • 对象类型指针

即对象指向它的类元数据的指针

虚拟机通过这个指针来确定这个对象是哪个类的实例

特别注意

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

②实例数据 区域

存储的信息:对象真正有效的信息

即代码中定义的字段内容

注:这部分数据的存储顺序会受到虚拟机分配参数(FieldAllocationStyle)和字段在Java源码中定义顺序的影响。

 // HotSpot虚拟机默认的分配策略如下:
longs/doubles、ints、shorts/chars、bytes/booleans、oop(Ordinary Object Pointers)
// 从分配策略中可以看出,相同宽度的字段总是被分配到一起
// 在满足这个前提的条件下,父类中定义的变量会出现在子类之前
 Compact Fields = true;
// 如果 CompactFields 参数值为true,那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。  

③对齐填充 区域

存储的信息: 占位符

占位作用

因为对象的大小必须是8字节的整数倍,而因HotSpot VM的要求对象起始地址必须是8字节的整数倍,且对象头部分正好是8字节的倍数。

因此,当对象实例数据部分没有对齐时(即对象的大小不是8字节的整数倍),就需要通过对齐填充来补全。

总结

对象的访问定位

问:建立对象后,该如何访问对象呢?

实际上需访问的是 对象类型数据 & 对象实例数据

答:Java程序 通过 栈上的引用类型数据(reference) 来访问Java堆上的对象

由于引用类型数据(reference)在 Java虚拟机中只规定了一个指向对象的引用,但没定义该引用应该通过何种方式去定位、访问堆中的对象的具体位置

所以对象访问方式取决于虚拟机实现。目前主流的对象访问方式有两种:

  • 句柄 访问
  • 直接指针 访问

具体请看如下介绍:

对象生死判断算法

垃圾回收的第一步就是判断对象是否存活,只有“死去”的对象,才会被垃圾回收器所收回。

①引用 计数器 算法

引用计算器判断对象是否存活的算法是这样的:给每一个对象设置一个引用计数器,每当有一个地方引用这个对象的时候,计数器就加1,与之相反,每当引用失效的时候就减1。

优点 : 实现简单、性能高。

缺点 : 增减处理频繁消耗 CPU 计算、计数器占用很多位浪费空间、最重要的缺点是无法解决循环引用的问题。

因为引用计数器算法很难解决循环引用的问题,所以主流的Java虚拟机都没有使用引用计数器算法来管理内存。

来看一段循环引用的代码:

 public class ReferenceDemo {
    public Object instance = null;
    private static final int _1Mb = 1024 * 1024;
     private  byte[] bigSize = new byte[10 * _1Mb]; // 申请内存
    public static void main(String[] args) {
        System.out.println(String.format(
                "开始:%d M", Runtime .getRuntime().freeMemory() / (1024 * 1024)));
        ReferenceDemo referenceDemo = new ReferenceDemo();
        ReferenceDemo referenceDemo2 = new ReferenceDemo();
        referenceDemo.instance = referenceDemo2;
        referenceDemo2.instance = referenceDemo;
        System.out.println(String.format(
                "运行:%d M",Runtime.getRuntime().freeMemory() / (1024 * 1024)));
        referenceDemo = null;
        referenceDemo2 = null;
        System.gc(); // 手动触发垃圾回收
        System.out.println(String.format(
                "结束:%d M",Runtime.getRuntime().freeMemory() / (1024 * 1024)));
    }
}  

运行的结果:

 开始:117 M
运行中:96 M
结束:119 M  

从结果可以看出,虚拟机并没有因为相互引用就不回收它们,也侧面说明了虚拟机并不是使用引用计数器实现的。

②可达性分析算法

在主流的语言的主流实现中,比如Java、C#、甚至是古老的Lisp都是使用的可达性分析算法来判断对象是否存活的。

这个算法的核心思路就是通过一些列的“GC Roots”对象作为起始点,从这些对象开始往下搜索,搜索所经过的路径称之为“引用链”。

当一个对象到GC Roots没有任何引用链相连的时候,证明此对象是可以被回收的。如下图所示:

在Java中,可作为GC Roots对象的列表:

  • Java虚拟机栈中的引用对象。
  • 本地方法栈中 JNI (既一般说的Native方法)引用的对象。
  • 方法区中类静态常量的引用对象。
  • 方法区中常量的引用对象。

对象生死与引用的关系

从上面的两种算法来看,不管是引用计数法还是可达性分析算法都与对象的“引用”有关,这说明:对象的引用决定了对象的生死。那对象的引用都有那些呢?

在JDK1.2之前,引用的定义很传统:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一块引用。

这样的定义很纯粹,但是也很狭隘,这种情况下一个对象要么被引用,要么没引用,对于介于两者之间的对象显得无能为力。

JDK1.2之后对引用进行了扩充,将引用分为:

  • 强引用(Strong Reference)
  • 软引用(Soft Reference)
  • 弱引用(Weak Reference)
  • 虚引用(Phantom Reference)

对象不是非生即死的,当空间还足够时,还可以保留这些对象,如果空间不足时,再抛弃这些对象。很多 缓存 功能的实现也符合这样的场景。

强引用、软引用、弱引用、虚引用,这4种引用的强度是依次递减的。

强引用 : 在代码中普遍存在的,类似“Object obj = new Object()”这类引用,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象。

软引用 : 是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当jvm认为内存不足时,才会去试图回收软引用指向的对象。 jvm 会确保在抛出OutOfMemoryError之前,清理软引用指向的对象。

弱引用 : 非必需对象,但它的强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。

虚引用 : 也称为幽灵引用或幻影引用,是最弱的一种引用关系,无法通过虚引用来获取一个对象实例,为对象设置虚引用的目的只有一个,就是当着个对象被收集器回收时收到一条系统通知。

死亡标记与拯救

在可达性算法中不可达的对象,并不是“非死不可”的,要真正宣告一个对象死亡,至少要经历两次标记的过程。

如果对象在进行可达性分析之后,没有与GC Roots相连接的引用链,它会被第一次标记,并进行筛选,筛选的条件是此对象是否有必要执行 finalize ()方法。

执行 Finalize ()方法的两个条件:

  1. 重写了finalize()方法。
  2. finalize()方法之前没被调用过,因为对象的finalize()方法只能被执行一次。

如果满足以上两个条件,这个对象将会放置在F-Queue的队列之中,并在稍后由一个虚拟机自建的、低优先级Finalizer线程来执行它。

①对象的“自我拯救”

finalize()方法是对象脱离死亡命运最后的机会,如果对象在finalize()方法中重新与引用链上的任何一个对象建立关联即可,比如把自己(this关键字)赋值给某个类变量或对象的成员变量。

来看具体的实现代码:

 public class FinalizeDemo {
    public static FinalizeDemo Hook = null;
    @ Override 
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("执行finalize方法");
        FinalizeDemo.Hook = this;
    }
    public static void main(String[] args) throws InterruptedException {
        Hook = new FinalizeDemo();
        // 第一次拯救
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待finalize执行
        if (Hook != null) {
            System.out.println("我还活着");
        } else {
            System.out.println("我已经死了");
        }
        // 第二次,代码完全一样
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待finalize执行
        if (Hook != null) {
            System.out.println("我还活着");
        } else {
            System.out.println("我已经死了");
        }
    }
}  

执行的结果:

 执行finalize方法
我还活着
我已经死了
  

从结果可以看出,任何对象的finalize()方法都只会被系统调用一次。

不建议使用finalize()方法来拯救对象 ,原因如下:

  1. 对象的finalize()只能执行一次。
  2. 它的运行代价高昂。
  3. 不确定性大。
  4. 无法保证各个对象的调用顺序。

基本垃圾回收算法

①按照基本回收策略分

(1)引用计数(Reference Counting)

比较古老的回收算法。原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一 个计数。垃圾回收时,只用收集计数为 0 的对象。此算法最致命的是无法处理循环引用的 问题。

(2)可达性分析清理

标记-清除(Mark-Sweep) :此算法执行分两阶段。第一阶段从 引用根节点开始标记 所有被引用的对象,第二阶段 遍历整个堆 ,把未标记的 对象清除 。此算法需要 暂停 整个 应用 ,同时, 会 产生内存碎片

复制(Copying) :此算法把内存空间 划为两个相等 的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域, 把正在使用中 的对象 复制 到另外 一个区域 中。次算法每次 只处理正在使用 中的 对象 ,因此复制成本比较小,同时复制过去以后还能进行相应的 内存整理 , 不会 出现“碎片”问题 。当然,此算法的缺点也是很明显的,就是 需要两倍内存空间

标记-整理(Mark-Compact) :此算法结合了 “标记-清除” “复制” 两个算法的优点。也是分两阶段,第一阶段从根节点开始 标记所有被引用对象 ,第二阶段 遍历整个堆 清除标记对象 ,并未标记对象并且把 存活对象“压缩”到堆的其中一块 ,按 顺序排放 。此算法避免了“标 记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。

②按分区对待的方式分

(1) 增量收集(Incremental Collecting) :实时垃圾回收算法,即:在应用进行的同时进 行垃圾回收。不知道什么原因 JDK5.0 中的收集器没有使用这种算法的。

(2) 分代收集(Generational Collecting) :基于对对象生命周期分析后得出的垃圾回收算法。把对象分为年青代、年老代、持久代,对不同生命周期的对象使用不同的算法(上述方 式中的一个)进行回收。现在的垃圾回收器(从 J2SE1.2 开始)都是使用此算法的。

③按系统线程分

(1) 串行收集 :串行收集使用单线程处理所有垃圾回收工作,因为无需多线程交互,实现容 易,而且效率比较高。但是,其局限性也比较明显,即无法使用多处理器的优势,所以此收 集适合单处理器机器。当然,此收集器也可以用在小数据量(100M 左右)情况下的多处理 器机器上。

(2) 并行收集 :并行收集使用多线程处理垃圾回收工作,因而速度快,效率高。而且理论上 CPU 数目越多,越能体现出并行收集器的优势。

(3) 并发收集 :相对于串行收集和并行收集而言,前面两个在进行垃圾回收工作时,需要暂 停整个运行环境,而只有垃圾回收程序在运行,因此,系统在垃圾回收时会有明显的暂停, 而且暂停时间会因为堆越大而越长。

优化技术:分代处理垃圾

试想,在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象,但实际上,对于 生命周期 长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是他们依旧存在。 因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上, 不同代上采用最适合它的垃圾回收方式进行回收。

虚拟机中的共划分为三个代: 年轻代(Young Generation) 年老点(Old Generation) 持久代(Permanent Generation) 。其中持久代主要存放的是 Java 类的类信息,与垃圾收集要收集的 Java 对象关系不大。年轻代和年老代的划分是对垃圾收集影响比较大的。

年轻代 :所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代分三个区。一个 Eden 区,两个 Survivor 区(一般而言)。 大部分对象在 Eden 区中生成。当 Eden 区满时,还存活的对象将被复制到 Survivor 区(两个中的一个),当这个 Survivor 区满时,此区的存活对象将被复制到另外一个 Survivor 区,当这个 Survivor 去也满了的时候,从第一个 Survivor 区复制过来的并且此时还存活的对象, 将被复制“年老区(Tenured)”。需要注意,Survivor 的两个区是对称的,没先后关系,所以同一个区中可能同时存在从 Eden 复制过来 对象,和从前一个 Survivor 复制过来的对象,而复制到年老区的只有从第一个 Survivor 去过来的对象。而且,Survivor 区总有一个是空的。 同时,根据程序需要,Survivor 区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。

年老代 :在年轻代中经历了 N 次垃圾回收后 仍然 存活的 对象,就会被放到年老代中。因此, 可以认为年老代中存放的都是一些生命周期较长的对象。

持久代 :用于存放静态文件,如今 Java 类、方法等。持久代对垃圾回收没有显著影响,但是 有些应用可能动态生成或者调用一些 class,例如 Hibernate 等,在这种时候需要设置一个 比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过 -XX:MaxPermSize=<N> 进行设置。

JAVA 中垃圾回收 GC 的类型

由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC 有两种类型: Scavenge GC Full GC

Scavenge GC : 一般情况下,当新对象生成,并且在 Eden 申请空间失败时,就会触发 Scavenge GC ,对 Eden 区域进行 GC ,清除非存活对象,并且把尚且存活的对象移动到 Survivor 区。然后整理 Survivor 的两个区。这种方式的 GC 是对年轻代的 Eden 区进行,不 会影响到年老代。因为大部分对象都是从 Eden 区开始的,同时 Eden 区不会分配的很大, 所以 Eden 区的 GC 会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使 Eden 去能尽快空闲出来。

Full GC 对整个堆进行整理 ,包括 Young Tenured Perm 。Full GC 因为需要对整个 对进行回收,所以比 Scavenge GC 要慢,因此应该尽可能减少 Full GC 的次数。在对 JVM 调优的过程中,很大一部分工作就是对于 FullGC 的调节。

有如下原因可能导致 Full GC :· 年老代(Tenured)被写满、持久代(Perm)被写满、System.gc()被显示调用 、上一次 GC 之后 Heap 的各域分配策略动态变化。

文章来源:智云一二三科技

文章标题:玩转JVM中的对象及引用:从创建到引用到分配和优化策略

文章地址:https://www.zhihuclub.com/190004.shtml

关于作者: 智云科技

热门文章

发表回复

您的电子邮箱地址不会被公开。

网站地图