JVM | 2 对象及引用

297 阅读9分钟

JVM 是Java平台的基石,JVM是一个抽象的计算机,符合计算机的体系模型。

JVM中对象的创建过程

本章节主要是学习JVM是如何创建对象?
如下代码:

public class ObjectCreate {
    private int age;
    private boolean isKing;

    public static void main(String[] args) {
        //JVM 遇到 new 指令
        // 检查加载
        // 分配内存
        //1. 内存空间连续 指针碰撞
        //2. 内存空间不连续 jvm维护空闲列表 根据空闲列表分配
        //3. 并发安全问题:CAS加失败重试,本地线程分配缓冲(TLAB Thread Local Allocation Buffer) 每个线程在Eden区
        //单独的把某一块内存区域划分给该线程,所以效率会更高一些
       
        ObjectCreate objectCreate = new ObjectCreate();
        System.out.println(objectCreate.age);
        System.out.println(objectCreate.isKing);
    }
}

一个对象的创建过程如下:
image.png

  • 检查加载:首先要检查ObjectCreate 类对应的符号引用,检查这个类是否被加载过。
  • 分配内存:在堆空间划分内存,解决并发安全问题。
    • 划分内存的方式
      • 指针碰撞:堆内存是连续规整的,直接按照顺序分配内存即可
        • image.png
      • 空闲列表:堆内存是不连续的不规整的,这时候JVM会有一个空闲列表,进行分配空间,例如下面打叉的是空闲的,打对勾的是占用的,
        • image.png
    • 并发安全问题:多个线程在分配内存安全性问题的解决
      • CAS 加失败重试:当线程1在分配内存时,会先读取当前值的old,然后经过预处理,CSA会通过实时值与old进行比较,如果相等则分配内存,如果不相等,则说明已经被其他线程访问过,那么线程1就会再来一次。
        • image.png
      • 分配缓冲:本地线程分配缓冲(TLAB Thread Local Allocation Buffer) 每个线程在Eden区,单独的把某一块内存区域划分给该线程,所以效率会更高一些,但是会受制于Eden区的大小。 XX:+UseTLAB 默认是开启的
        • image.png
  • 内存空间初始化:(注意:不是对象的构造方法) “零”值 如:int值为0 Boolean为false age=0 isKing=false
  • 设置:设置对象头
    • image.png
  • 对象初始化:构造方法

上述,就是创建一个对象的过程,当然这个过程还是比较复杂的,需要一些时间去了解和掌握

对象的内存布局及访问

对象占据的内存空间应该是8字节的整数,如果不是8字节的整数,则需要有对齐填充,添加剩余的字节,保证对象的空间是8字节的整数。这样有利于分配内存和垃圾回收。 ### 对象是如何访问的呢? 在Java中我们通常是在方法中访问的,而方法中的调用对象都是使用对象的引用进行调用,对象的调用有两种方式。

句柄方式访问对象

句柄的方式,就是在Java堆中多了一个中间句柄池,通过操作句柄池来指向对象的实例
句柄方式的好处:稳定性好,为什么呢?例如实例对象销毁了没有了,那么直接修改句柄池,到对象实例数据的指针为null即可,而不用再去修改当前线程正在执行的引用重置reference
而句柄方式的坏处:损耗的性能,由于中间句柄池,而多了一层访问的性能开销。
image.png

直接指针访问对象

Hotspot的采用的就是直接指针。也是我们常用的一种方式,节省了中间句柄池的开销,减少了性能的损耗。
image.png
直接指针和句柄的区别:

  • 句柄:例如实例对象销毁了没有了,那么直接修改句柄池,到对象实例数据的指针为null即可,而不用再去修改当前线程正在执行的引用重置reference
  • 直接引用:如果对象实例销毁了,那么就需要修改当前线程正在执行的引用reference 去进行重置。
  • 句柄的稳定性好,直接指针性能高

判断对象的存活?

在JVM中是如何判断对象是活的还是死的呢?

C、C++是手动回收内存,Java是自动回收内存
手动释放内存容易出现的问题:

  • 忘记回收-内存泄漏
  • 多次回收 - malloc free free

image.png
没有任何引用指向的一个对象或者多个对象(循环引用)就是垃圾
JVM虚拟机的中的判断有两种方式:

  • 引用计数法

A对象引用B对象,那么B对象的计数+1. 但是引用计数法无法解决循环引用的问题。
image.png

  • 可达性分析(根可达)

可达性分析又叫根可达,只要对象的引用有根,也就是根可达那么就不能回收,如果对象没有根就可以进行回收。这里可能比较难以理解,看下图所示:
image.png

如上图所示:可达性分析,很简单的解决了循环引用的问题。循环引用没有根,那么就是垃圾回收的对象。
那么GC roots哪些可以作为根呢?
作为GC roots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(Native方法)引用的对象
  • JVM的内部引用(class对象、异常对象NullPointException、OutofMemoryError,系统类加载器)
  • 所有被同步锁(synchronized 关键字)持有的对象
  • JVM内部的JMXBean、JVMTI中注册的回调、本地代码缓存等
  • JVM实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收只回收部分代的对象)
public class Isalive {
    public Object instance = null;
    //占据内存,便于判断分析GC
    private byte[] bigSize = new byte[10 * 1024 * 1024];

    public static void main(String[] args) {
        Isalive objectA=new Isalive();//objectA 局部变量表 GCRoots
        Isalive objectB = new Isalive();
        //互相引用
        objectA.instance = objectB;
        objectB.instance = objectA;
        //切断可达
        objectA = null;
        objectB = null;
        //强制垃圾回收
        System.gc();
    }
}


如下: 两个对象大约是20多M,回收之后就是536K,也就是说两个对象都被回收掉了

[GC (System.gc())  24412K->536K(251392K), 0.0008534 secs]
[Full GC (System.gc())  536K->416K(251392K), 0.0129759 secs]

Class回收条件(方法区)


Class的回收条件比较苛刻,必须同时满足以下的条件,这也是为什么JVM要把方法区单独出来。是和堆区的对象的回收是有很大的区别的。

  1. 该类所有的实例都已经 被回收,也就是堆中不存在该类的任何实例
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
  4. 参数控制:
      • Xnoclassgc : 禁用类的垃圾收集(GC),从而缩短了应用程序运行期间的中断时间。

Finalize (不推荐使用,忘掉)

如果对象覆盖了,finalize方法,那么在垃圾收集的时候对象会根据finalize方法进行自我拯救。

/**
 * 对象的自我拯救
 */
public class FinalizeGC {
    public static FinalizeGC instance = null;
    public void isAlive(){
        System.out.println("I am still alive!");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed");
        FinalizeGC.instance = this;
    }

    public static void main(String[] args) throws InterruptedException {
        instance = new FinalizeGC();
        //对象进行第一次GC
        instance = null;
        System.gc();
        Thread.sleep(1000); //Finalize 方法优先级很低,需要等待
        if (instance!=null){
            instance.isAlive();
        }else {
            System.out.println("I am dead");
        }
        //对象进行第二次GC
        instance = null;
        System.gc();
        Thread.sleep(1000);
        if (instance != null){
            instance.isAlive();
        }else {
            System.out.println("I am dead");
        }
    }
}

运行结果如下:

finalize method executed
I am still alive!
I am dead  -- 第二次GC不能在自我拯救了 finalize 不能执行第二次


Java中比如try-finally里可以更好的代替finalize

各种引用

  • 强引用 强引用就是=的关系
  • 软引用 SoftReference :

当系统即将发生OOM之前会对软引用进行回收,如果空间足够了就不会抛出OOM异常,如果空间还是不够则还会抛出OOM异常。

如下代码:

/**
 * -Xms20m -Xmx20m
 */
public class TestSoftRef {
    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,"jake");//new 是强引用
        SoftReference<User> userSoft = new SoftReference<>(u);//软引用
        u = null;//干掉强引用,确保这个实例只有userSoft软引用
        System.gc();//进行一次GC垃圾回收,
        System.out.println("After GC");
        System.out.println(userSoft.get());//对象还存活 User{id=1, name='jake'}
        //往堆中填充数据,导致OOM
        List<byte[]> list = new LinkedList<>();
        try {
            for (int i = 0; i < 100; i++) {
                list.add(new byte[1024*1024*1]);
            }
        } catch (Throwable e) {
            System.out.println("抛出异常时,打印软引用对象:"+userSoft.get());//抛出异常时,打印软引用对象:null
        }
    }
}

那么什么情况下会用到软引用呢?一般是用到缓存中,但是这部分的内存会占用很大的空间,一旦系统即将OOM则回收软引用,可以预防内存溢出。

  • 弱引用 WeakReference

弱引用只要发生垃圾回收,那么弱引用就会干掉。弱引用也是用在数据缓存

        User u = new User(1,"jake");//new 是强引用
        WeakReference<User> userSoft = new WeakReference<>(u);//弱引用
        u = null;//干掉强引用,确保这个实例只有userSoft弱引用
        System.out.println(userSoft.get());//User{id=1, name='jake'}
        System.gc();//进行一次GC垃圾回收,
        System.out.println("After GC");
        System.out.println(userSoft.get());//null
  • 虚引用 PhantomReference

虚引用了解即可,在真实项目中几乎不会用到。虚引用会随时被垃圾回收器回收,主要就是监听垃圾回收器的工作是否正常。

对象的分配策略

  • 对象的分配原则
    • 对象优先在Eden分配(几乎所有的对象都在堆空间分配)
    • 空间分配担保:先回收新生代MinorGC, 判断是不是老年代能够放下,如果不能放下就触发MajorGC/FullGC->MinorGC
    • 大对象直接进入老年代
    • 长期存活的对象进入老年代
    • 动态对象年龄判定 : 相同年龄所有对象的大小,大于from区/to区的一半,则全部进入老年代

新生代GC:MinorGC
老年代GC:MajorGC
全部GC:FullGC
注意:永久代是方法区。垃圾回收是在哪一个区域满了就自动进行垃圾回收
如下图所示,是对象的一个完整的分配策略
image.png

虚拟机的优化技术:

  • 逃逸分析:如果存在逃逸则将对象分配在栈中。

逃逸分析 (不会逃逸出方法) 分配在栈上面 以避免没有必要的垃圾回收

运行如下代码:添加VM options -XX:-DoEscapeAnalysis -XX:+PrintGC 

/**
 * 逃逸分析
 * -XX:-DoEscapeAnalysis : 关闭逃逸分析
 * -XX:-DoEscapeAnalysis -XX:+PrintGC  - 执行速度:374 ms
 * 没有关闭逃逸分析:执行速度:5ms
 */
public class EscapeAnalysisTest {

    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 50000000; i++) {//5000万次  5000万个对象
            allocate();
        }
        System.out.println((System.currentTimeMillis()-start)+" ms");
        Thread.sleep(600000);
    }

    static void allocate(){//逃逸分析 (不会逃逸出方法) 分配在栈上面 以避免没有必要的垃圾回收
        //这个myObject引用没有出去,也没有其他方法使用
        MyObject myObject = new MyObject(2020,2020.6);
    }

    static class MyObject{
        int a;
        double b;

        public MyObject(int a, double b) {
            this.a = a;
            this.b = b;
        }
    }
}

我们关闭逃逸分析,运行结果:

[GC (Allocation Failure)  65536K->520K(251392K), 0.0015100 secs]
[GC (Allocation Failure)  66056K->456K(251392K), 0.0020627 secs]
[GC (Allocation Failure)  65992K->440K(251392K), 0.0034059 secs]
[GC (Allocation Failure)  65976K->472K(316928K), 0.0008799 secs]
[GC (Allocation Failure)  131544K->456K(316928K), 0.0016654 secs]
[GC (Allocation Failure)  131528K->456K(437760K), 0.0092627 secs]
[GC (Allocation Failure)  262600K->425K(437760K), 0.0017700 secs]
[GC (Allocation Failure)  262569K->425K(700416K), 0.0004432 secs]
374 ms

下面我们再来看打开逃逸分析执行速度:

6 ms

从上面看两种结果,在逃逸分析开启的时候,运行速度是6ms 可以推测出逃逸分析,将没有引用出去的对象分配在栈中。

  • 本地线程分配缓冲