Note/青空笔记/JVM笔记/JVM笔记(二).md
2023-08-31 11:30:31 +08:00

58 KiB
Raw Blame History

JVM内存管理

在之前我们了解了JVM的大致运作原理以及相关特性这一章我们首先会从内存管理说起。

在传统的C/C++开发中,我们经常通过使用申请内存的方式来创建对象或是存放某些数据,但是这样也带来了一些额外的问题,我们要在何时释放这些内存,怎么才能使得内存的使用最高效,因此,内存管理是一个非常严肃的问题。

比如我们就可以通过C语言动态申请内存并用于存放数据

#include <stdlib.h>
#include <stdio.h>

int main(){
    //动态申请4个int大小的内存空间
    int* memory = malloc(sizeof(int) * 4);
    //修改第一个int空间的值
    memory[0] = 10;
    //修改第二个int空间的值
    memory[1] = 2;
    //遍历内存区域中所有的值
    for (int i = 0;i < 4;i++){
        printf("%d ", memory[i]);
    }
    //释放指针所指向的内存区域
    free(memory);
    //最后将指针赋值为NULL
    memory = NULL;
}

而在Java中这种操作实际上是不允许的Java只支持直接使用基本数据类型和对象类型至于内存到底如何分配并不是由我们来处理而是JVM帮助我们进行控制这样就帮助我们节省很多内存上的工作虽然带来了很大的便利但是一旦出现内存问题我们就无法像C/C++那样对所管理的内存进行合理地处理因为所有的内存操作都是由JVM在进行只有了解了JVM的内存管理机制我们才能够在出现内存相关问题时找到解决方案。

内存区域划分

既然要管理内存那么肯定不会是杂乱无章的JVM对内存的管理采用的是分区治理不同的内存区域有着各自的职责所在在虚拟机运行时内存区域如下划分

点击查看图片来源

我们可以看到内存区域一共分为5个区域其中方法区和堆是所有线程共享的区域随着虚拟机的创建而创建虚拟机的结束而销毁而虚拟机栈、本地方法栈、程序计数器都是线程之间相互隔离的每个线程都有一个自己的区域并且线程启动时会自动创建结束之后会自动销毁。内存划分完成之后我们的JVM执行引擎和本地库接口也就是Java程序开始运行之后就会根据分区合理地使用对应区域的内存了。

大致划分

程序计数器

首先我们来介绍一下程序计数器它和我们的传统8086 CPU中PC寄存器的工作差不多因为JVM虚拟机目的就是实现物理机那样的程序执行。在8086 CPU中PC作为程序计数器负责储存内存地址该地址指向下一条即将执行的指令每解释执行完一条指令PC寄存器的值就会自动被更新为下一条指令的地址进入下一个指令周期时就会根据当前地址所指向的指令进行执行。

而JVM中的程序计数器可以看做是当前线程所执行字节码的行号指示器而行号正好就指的是某一条指令字节码解释器在工作时也会改变这个值来指定下一条即将执行的指令。

因为Java的多线程也是依靠时间片轮转算法进行的因此一个CPU同一时间也只会处理一个线程当某个线程的时间片消耗完成后会自动切换到下一个线程继续执行而当前线程的执行位置会被保存到当前线程的程序计数器中当下次轮转到此线程时又继续根据之前的执行位置继续向下执行。

程序计数器因为只需要记录很少的信息,所以只占用很少一部分内存。

虚拟机栈

虚拟机栈就是一个非常关键的部分看名字就知道它是一个栈结构每个方法被执行的时候Java虚拟机都会同步创建一个栈帧其实就是栈里面的一个元素栈帧中包括了当前方法的一些信息比如局部变量表、操作数栈、动态链接、方法出口等。

image-20220131110349472

其中局部变量表就是我们方法中的局部变量之前我们也进行过演示实际上局部变量表在class文件中就已经定义好了操作数栈就是我们之前字节码执行时使用到的栈结构 每个栈帧还保存了一个可以指向当前方法所在类的运行时常量池,目的是:当前方法中如果需要调用其他方法的时候,能够从运行时常量池中找到对应的符号引用,然后将符号引用转换为直接引用,然后就能直接调用对应方法,这就是动态链接(我们还没讲到常量池,暂时记住即可,建议之后再回顾一下),最后是方法出口,也就是方法该如何结束,是抛出异常还是正常返回。

可能听起来有点懵逼,这里我们来模拟一下整个虚拟机栈的运作流程,我们先编写一个测试类:

public class Main {
    public static void main(String[] args) {
        int res = a();
        System.out.println(res);
    }

    public static int a(){
        return b();
    }

    public static int b(){
        return c();
    }

    public static int c(){
        int a = 10;
        int b = 20;
        return a + b;
    }
}

当我们的主方法执行后,会依次执行三个方法a() -> b() -> c() -> 返回,我们首先来观察一下反编译之后的结果:

{
  public com.test.Main();   #这个是构造方法
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/test/Main;

  public static void main(java.lang.String[]);    #主方法
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: invokestatic  #2                  // Method a:()I
         3: istore_1
         4: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: iload_1
         8: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        11: return
      LineNumberTable:
        line 5: 0
        line 6: 4
        line 7: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      12     0  args   [Ljava/lang/String;
            4       8     1   res   I

  public static int a();
    descriptor: ()I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: invokestatic  #5                  // Method b:()I
         3: ireturn
      LineNumberTable:
        line 10: 0

  public static int b();
    descriptor: ()I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: invokestatic  #6                  // Method c:()I
         3: ireturn
      LineNumberTable:
        line 14: 0

  public static int c();
    descriptor: ()I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=0
         0: bipush        10
         2: istore_0
         3: bipush        20
         5: istore_1
         6: iload_0
         7: iload_1
         8: iadd
         9: ireturn
      LineNumberTable:
        line 18: 0
        line 19: 3
        line 20: 6
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            3       7     0     a   I
            6       4     1     b   I
}

可以看到在编译之后,我们整个方法的最大操作数栈深度、局部变量表都是已经确定好的,当我们程序开始执行时,会根据这些信息封装为对应的栈帧,我们从main方法开始看起:

image-20220131142625842

接着我们继续往下,到了 0: invokestatic #2 // Method a:()I时,需要调用方法a(),这时当前方法就不会继续向下运行了,而是去执行方法a(),那么同样的,将此方法也入栈,注意是放入到栈顶位置,main方法的栈帧会被压下去:

image-20220131143641690

这时进入方法a之后又继而进入到方法b最后在进入c因此到达方法c的时候我们的虚拟机栈变成了

image-20220131144209743

现在我们依次执行方法c中的指令最后返回a+b的结果在方法c返回之后也就代表方法c已经执行结束了栈帧4会自动出栈这时栈帧3就得到了上一栈帧返回的结果并继续执行但是由于紧接着马上就返回所以继续重复栈帧4的操作此时栈帧3也出栈并继续将结果交给下一个栈帧2最后栈帧2再将结果返回给栈帧1然后栈帧1就可以继续向下运行了最后输出结果。

image-20220131144955668

本地方法栈

本地方法栈与虚拟机栈作用差不多,但是它 备的,这里不多做介绍。

堆是整个Java应用程序共享的区域也是整个虚拟机最大的一块内存空间而此区域的职责就是存放和管理对象和数组而我们马上要提到的垃圾回收机制也是主要作用于这一部分内存区域。

方法区

方法区也是整个Java应用程序共享的区域它用于存储所有的类信息、常量、静态变量、动态编译缓存等数据可以大致分为两个部分一个是类信息表一个是运行时常量池。方法区也是我们要重点介绍的部分。

image-20220201140516096

首先类信息表中存放的是当前应用程序加载的所有类信息,包括类的版本、字段、方法、接口等信息,同时会将编译时生成的常量池数据全部存放到运行时常量池中。当然,常量也并不是只能从类信息中获取,在程序运行时,也有可能会有新的常量进入到常量池。

其实我们的String类正是利用了常量池进行优化这里我们编写一个测试用例

public static void main(String[] args) {
    String str1 = new String("abc");
    String str2 = new String("abc");

    System.out.println(str1 == str2);
    System.out.println(str1.equals(str2));
}

得到的结果也是显而易见的,由于str1str2是单独创建的两个对象,那么这两个对象实际上会在堆中存放,保存在不同的地址:

image-20220201141848804

所以当我们使用==判断时,得到的结果false,而使用equals时因为比较的是值,所以得到true。现在我们来稍微修改一下:

public static void main(String[] args) {
    String str1 = "abc";
    String str2 = "abc";

    System.out.println(str1 == str2);
    System.out.println(str1.equals(str2));
}

现在我们没有使用new的形式而是直接使用双引号创建那么这时得到的结果就变成了两个true,这是为什么呢?这其实是因为我们直接使用双引号赋值,会先在常量池中查找是否存在相同的字符串,若存在,则将引用直接指向该字符串;若不存在,则在常量池中生成一个字符串,再将引用指向该字符串:

image-20220201142710405

实际上两次调用String类的intern()方法,和上面的效果差不多,也是第一次调用会将堆中字符串复制并放入常量池中,第二次通过此方法获取字符串时,会查看常量池中是否包含,如果包含那么会直接返回常量池中字符串的地址:

public static void main(String[] args) {
    //不能直接写"abc"双引号的形式写了就直接在常量池里面吧abc创好了
    String str1 = new String("ab")+new String("c");
    String str2 = new String("ab")+new String("c");

    System.out.println(str1.intern() == str2.intern());
    System.out.println(str1.equals(str2));
}

image-20220201145204505

所以上述结果中得到的依然是两个true。在JDK1.7之后,稍微有一些区别,在调用intern()方法时,当常量池中没有对应的字符串时,不会再进行复制操作,而是将其直接修改为指向当前字符串堆中的的引用:

image-20220201144747139

public static void main(String[] args) {
  	//不能直接写"abc"双引号的形式写了就直接在常量池里面吧abc创好了
    String str1 = new String("ab")+new String("c");
    System.out.println(str1.intern() == str1);
}
public static void main(String[] args) {
    String str1 = new String("ab")+new String("c");
    String str2 = new String("ab")+new String("c");

    System.out.println(str1 == str1.intern());
    System.out.println(str2.intern() == str1);
}

所以最后我们会发现,str1.intern()str1都是同一个对象,结果为true

值得注意的是在JDK7之后字符串常量池从方法区移动到了堆中。

最后我们再来进行一个总结,各个内存区域的用途:

  • (线程独有)程序计数器:保存当前程序的执行位置。
  • (线程独有)虚拟机栈:通过栈帧来维持方法调用顺序,帮助控制程序有序运行。
  • (线程独有)本地方法栈:同上,作用与本地方法。
  • 堆:所有的对象和数组都在这里保存。
  • 方法区:类信息、即时编译器的代码缓存、运行时常量池。

当然,这些内存区域划分仅仅是概念上的,具体的实现过程我们后面还会提到。

爆内存和爆栈

实际上在Java程序运行时内存容量不可能是无限制的当我们的对象创建过多或是数组容量过大时就会导致我们的堆内存不足以存放更多新的对象或是数组这时就会出现错误比如

public static void main(String[] args) {
    int[] a = new int[Integer.MAX_VALUE];
}

这里我们申请了一个容量为21亿多的int型数组显然如此之大的数组不可能放在我们的堆内存中所以程序运行时就会这样

Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
	at com.test.Main.main(Main.java:5)

这里得到了一个OutOfMemoryError错误,也就是我们常说的内存溢出错误。我们可以通过参数来控制堆内存的最大值和最小值:

-Xms最小值 -Xmx最大值

比如我们现在限制堆内存为固定值1M大小并且在抛出内存溢出异常时保存当前的内存堆转储快照

image-20220201202346882

注意堆内存不要设置太小,不然连虚拟机都不足以启动,接着我们编写一个一定会导致内存溢出的程序:

public class Main {
    public static void main(String[] args) {
        List<Test> list = new ArrayList<>();
        while (true){
            list.add(new Test());    //无限创建Test对象并丢进List中
        }
    }

    static class Test{ }
}

在程序运行之后:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid35172.hprof ...
Heap dump file created [12895344 bytes in 0.028 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
	at java.util.ArrayList.grow(ArrayList.java:267)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:241)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:233)
	at java.util.ArrayList.add(ArrayList.java:464)
	at com.test.Main.main(Main.java:10)

可以看到错误出现原因正是Java heap space也就是堆内存满了并且根据我们设定的VM参数堆内存保存了快照信息。我们可以在IDEA内置的Profiler中进行查看

image-20220201203157213

可以很明显地看到在创建了360146个Test对象之后堆内存蚌埠住了于是就抛出了内存溢出错误。

我们接着来看栈溢出,我们知道,虚拟机栈会在方法调用时插入栈帧,那么,设想如果出现无限递归的情况呢?

public class Main {
    public static void main(String[] args) {
        test();
    }

    public static void test(){
        test();
    }
}

这很明显是一个永无休止的程序并且会不断继续向下调用test方法本身那么按照我们之前的逻辑推导无限地插入栈帧那么一定会将虚拟机栈塞满所以当栈的深度已经不足以继续插入栈帧时就会这样

Exception in thread "main" java.lang.StackOverflowError
	at com.test.Main.test(Main.java:12)
	at com.test.Main.test(Main.java:12)
	at com.test.Main.test(Main.java:12)
	at com.test.Main.test(Main.java:12)
	at com.test.Main.test(Main.java:12)
	at com.test.Main.test(Main.java:12)
	....以下省略很多行

这也是我们常说的栈溢出,它和堆溢出比较类似,也是由于容纳不下才导致的,我们可以使用-Xss来设定栈容量。

申请堆外内存

除了堆内存可以存放对象数据以外我们也可以申请堆外内存直接内存也就是不受JVM管控的内存区域这部分区域的内存需要我们自行去申请和释放实际上本质就是JVM通过C/C++调用malloc函数申请的内存,当然得我们自己去释放了。不过虽然是直接内存,不会受到堆内存容量限制,但是依然会受到本机最大内存的限制,所以还是有可能抛出OutOfMemoryError异常。

这里我们需要提到一个堆外内存操作类:Unsafe就像它的名字一样虽然Java提供堆外内存的操作类但是实际上它是不安全的只有你完全了解底层原理并且能够合理控制堆外内存才能安全地使用堆外内存。

注意这个类不让我们new也没有直接获取方式压根就没想让我们用

public final class Unsafe {

    private static native void registerNatives();
    static {
        registerNatives();
        sun.reflect.Reflection.registerMethodsToFilter(Unsafe.class, "getUnsafe");
    }

    private Unsafe() {}

    private static final Unsafe theUnsafe = new Unsafe();
  
    @CallerSensitive
    public static Unsafe getUnsafe() {
        Class<?> caller = Reflection.getCallerClass();
        if (!VM.isSystemDomainLoader(caller.getClassLoader()))
            throw new SecurityException("Unsafe");   //不是JDK的类不让用。
        return theUnsafe;
    }

所以我们这里就通过反射给他giao出来

public static void main(String[] args) throws IllegalAccessException {
    Field unsafeField = Unsafe.class.getDeclaredFields()[0];
    unsafeField.setAccessible(true);
    Unsafe unsafe = (Unsafe) unsafeField.get(null);
    
}

成功拿到Unsafe类之后我们就可以开始申请堆外内存了比如我们现在想要申请一个int大小的内存空间并在此空间中存放一个int类型的数据

public static void main(String[] args) throws IllegalAccessException {
    Field unsafeField = Unsafe.class.getDeclaredFields()[0];
    unsafeField.setAccessible(true);
    Unsafe unsafe = (Unsafe) unsafeField.get(null);

    //申请4字节大小的内存空间并得到对应位置的地址
    long address = unsafe.allocateMemory(4);
    //在对应的地址上设定int的值
    unsafe.putInt(address, 6666666);
    //获取对应地址上的Int型数值
    System.out.println(unsafe.getInt(address));
    //释放申请到的内容
    unsafe.freeMemory(address);

    //由于内存已经释放,这时数据就没了
    System.out.println(unsafe.getInt(address));
}

我们可以来看一下allocateMemory底层是如何调用的这是一个native方法我们来看C++源码:

UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory0(JNIEnv *env, jobject unsafe, jlong size)) {
  size_t sz = (size_t)size;

  sz = align_up(sz, HeapWordSize);
  void* x = os::malloc(sz, mtOther);   //这里调用了os::malloc方法

  return addr_to_java(x);
} UNSAFE_END

接着来看:

void* os::malloc(size_t size, MEMFLAGS flags) {
  return os::malloc(size, flags, CALLER_PC);
}

void* os::malloc(size_t size, MEMFLAGS memflags, const NativeCallStack& stack) {
	...
  u_char* ptr;
  ptr = (u_char*)::malloc(alloc_size);   //调用C++标准库函数 malloc(size)
	....
  // we do not track guard memory
  return MemTracker::record_malloc((address)ptr, size, memflags, stack, level);
}

所以我们上面的Java代码转换为C代码差不多就是这个意思

#include <stdlib.h>
#include <stdio.h>

int main(){
    int * a = malloc(sizeof(int));
    *a = 6666666;
    printf("%d\n", *a);
    free(a);
    printf("%d\n", *a);
}

所以说直接内存实际上就是JVM申请的一块额外的内存空间但是它并不在受管控的几种内存空间中当然这些内存依然属于是JVM的由于JVM提供的堆内存会进行垃圾回收等工作效率不如直接申请和操作内存来得快一些比较追求极致性能的框架会用到堆外内存来提升运行速度如nio框架。

当然Unsafe类不仅仅只是这些功能在其他系列课程中我们还会讲到它。


垃圾回收机制

**注意:**此部分为重点内容。

我们前面提到Java会自动管理和释放内存它不像C/C++那样要求我们手动管理内存JVM提供了一套全自动的内存管理机制当一个Java对象不再用到时JVM会自动将其进行回收并释放内存那么对象所占内存在什么时候被回收如何判定对象可以被回收以及如何去进行回收工作也是JVM需要关注的问题。

对象存活判定算法

首先我们来套讨论第一个问题,也就是:对象在什么情况下可以被判定为不再使用已经可以回收了?这里就需要提到以下几种垃圾回收算法了。

image-20220222084649786

引用计数法

我们知道,如果我们要经常操作一个对象,那么首先一定会创建一个引用变量:

//str就是一个引用类型的变量它持有对后面字符串对象的引用可以代表后面这个字符串对象本身
String str = "lbwnb";

//str.xxxxx...

实际上,我们会发现,只要一个对象还有使用价值,我们就会通过它的引用变量来进行操作,那么可否这样判断一个对象是否还需要被使用:

  • 每个对象都包含一个 引用计数器,用于存放引用计数(其实就是存放被引用的次数)
  • 每当有一个地方引用此对象时,引用计数+1
  • 当引用失效( 比如离开了局部变量的作用域或是引用被设定为null)时,引用计数-1
  • 当引用计数为0时,表示此对象不可能再被使用,因为这时我们已经没有任何方法可以得到此对象的引用了

但是这样存在一个问题,如果两个对象相互引用呢?

public class Main {
    public static void main(String[] args) {
        Test a = new Test();
        Test b = new Test();

        a.another = b;
        b.another = a;

        //这里直接把a和b赋值为null这样前面的两个对象我们不可能再得到了
        a = b = null;
    }

    private static class Test{
        Test another;
    }
}

按照引用计数算法,那么当出现以上情况时,虽然我们无法在得到此对象的引用了,并且此对象我们也无需再使用,但是由于这两个对象直接存在相互引用的情况,那么引用计数器的值将会永远是1,但是实际上此对象已经没有任何用途了。所以引用计数法并不是最好的解决方案。

可达性分析算法

目前比较主流的编程语言包括Java一般都会使用可达性分析算法来判断对象是否存活它采用了类似于树结构的搜索机制。

首先每个对象的引用都有机会成为树的根节点GC Roots可以被选定作为根节点条件如下

  • 位于虚拟机栈的栈帧中的本地变量表中所引用到的对象其实就是我们方法中的局部变量同样也包括本地方法栈中JNI引用的对象。
  • 类的静态成员变量引用的对象。
  • 方法区中,常量池里面引用的对象,比如我们之前提到的String类型对象。
  • 被添加了锁的对象比如synchronized关键字
  • 虚拟机内部需要用到的对象。

image-20220222125507229

一旦已经存在的根节点不满足存在的条件时那么根节点与对象之间的连接将被断开。此时虽然对象1仍存在对其他对象的引用但是由于其没有任何根节点引用所以此对象即可被判定为不再使用。比如某个方法中的局部变量引用在方法执行完成返回之后

image-20220222130350950

这样就能很好地解决我们刚刚提到的循环引用问题,我们再来重现一下出现循环引用的情况:

image-20220222130903349

可以看到对象1和对象2依然是存在循环引用的但是只有他们各自的GC Roots断开那么就会变成下面这样

image-20220222131219350

所以我们最后进行一下总结如果某个对象无法到达任何GC Roots则证明此对象是不可能再被使用的。

最终判定

虽然在经历了可达性分析算法之后基本可能判定哪些对象能够被回收,但是并不代表此对象一定会被回收,我们依然可以在最终判定阶段对其进行挽留。

还记得我们之前在讲解Object类时提到的finalize()方法吗?

/**
 * Called by the garbage collector on an object when garbage collection
 * determines that there are no more references to the object.
 * A subclass overrides the {@code finalize} method to dispose of
 * system resources or to perform other cleanup.
 * ...
 */
protected void finalize() throws Throwable { }

此方法正是最终判定方法,如果子类重写了此方法,那么子类对象在被判定为可回收时,会进行二次确认,也就是执行finalize()方法而在此方法中当前对象是完全有可能重新建立GC Roots的所以如果在二次确认后对象不满足可回收的条件那么此对象不会被回收巧妙地逃过了垃圾回收的命运。比如下面这个例子

public class Main {
    private static Test a;
    public static void main(String[] args) throws InterruptedException {
        a = new Test();

        //这里直接把a赋值为null这样前面的对象我们不可能再得到了
        a  = null;

        //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行)
        System.gc();

        //等垃圾回收一下()
        Thread.sleep(1000);

        //我们来看看a有没有被回收
        System.out.println(a);
    }

    private static class Test{
        @Override
        protected void finalize() throws Throwable {
            System.out.println(this+" 开始了它的救赎之路!");
            a = this;
        }
    }
}

注意finalize()方法并不是在主线程调用的,而是虚拟机自动建立的一个低优先级的Finalizer线程正是因为优先级比较低所以前面才需要等待1秒钟进行处理我们可以稍微修改一下看看

private static class Test{
    @Override
    protected void finalize() throws Throwable {
        System.out.println(Thread.currentThread());
        a = this;
    }
}
Thread[Finalizer,8,system]
com.test.Main$Test@232204a1

同时,同一个对象的finalize()方法只会有一次调用机会,也就是说,如果我们连续两次这样操作,那么第二次,对象必定被回收:

public static void main(String[] args) throws InterruptedException {
    a = new Test();
    //这里直接把a赋值为null这样前面的对象我们不可能再得到了
    a  = null;
    //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行)
    System.gc();
    //等垃圾回收一下
    Thread.sleep(1000);
    
    System.out.println(a);
    //这里直接把a赋值为null这样前面的对象我们不可能再得到了
    a  = null;
    //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行)
    System.gc();
    //等垃圾回收一下
    Thread.sleep(1000);
    
    System.out.println(a);
}

当然,finalize()方法也并不是专门防止对象被回收的,我们可以使用它来释放一些程序使用中的资源等。

最后,总结成一张图:

image-20220222141854678

当然,除了堆中的对象以外,方法区中的数据也是可以被垃圾回收的,但是回收条件比较严格,这里就暂时不谈了。


垃圾回收算法

前面我们介绍了对象存活判定算法,现在我们已经可以准确地知道堆中的哪些对象可以被回收了,那么,接下来就该考虑如何对对象进行回收了,垃圾收集器会不定期地检查堆中的对象,查看它们是否满足被回收的条件。我们该如何对这些对象进行回收,是一个一个判断是否需要回收吗?

分代收集机制

实际上,如果我们对堆中的每一个对象都依次判断是否需要回收,这样的效率其实是很低的,那么有没有更好地回收机制呢?第一步,我们可以对堆中的对象进行分代管理。

比如某些对象,在多次垃圾回收时,都未被判定为可回收对象,我们完全可以将这一部分对象放在一起,并让垃圾收集器减少回收此区域对象的频率,这样就能很好地提高垃圾回收的效率了。

因此Java虚拟机将堆内存划分为新生代老年代永久代其中永久代是HotSpot虚拟机特有的概念在JDK8之前方法区实际上就是采用的永久代作为实现而在JDK8之后方法区由元空间实现并且使用的是本地内存容量大小取决于物理机实际大小之后会详细介绍这里我们主要讨论的是新生代老年代

不同的分代内存回收机制也存在一些不同之处在HotSpot虚拟机中新生代被划分为三块一块较大的Eden空间和两块较小的Survivor空间默认比例为811老年代的GC评率相对较低永久代一般存放类信息等其实就是方法区的实现如图所示

image-20220222151708141

那么它是如何运作的呢?

首先所有新创建的对象在一开始都会进入到新生代的Eden区如果是大对象会被直接丢进老年代在进行新生代区域的垃圾回收时首先会对所有新生代区域的对象进行扫描并回收那些不再使用对象

image-20220222153104582

接着在一次垃圾回收之后Eden区域没有被回收的对象会进入到Survivor区。在一开始From和To都是空的而GC之后所有Eden区域存活的对象都会直接被放入到From区最后From和To会发生一次交换也就是说目前存放我们对象的From区变为To区而To区变为From区

image-20220222154032674

接着就是下一次垃圾回收了操作与上面是一样的不过这时由于我们From区域中已经存在对象了所以在Eden区的存活对象复制到From区之后所有To区域中的对象会进行年龄判定每经历一轮GC年龄+1,如果对象的年龄大于默认值为15那么会直接进入到老年代否则移动到From区

image-20220222154828416

最后像上面一样交换To区和From区之后不断重复以上步骤。

而垃圾收集也分为:

  • Minor GC - 次要垃圾回收,主要进行新生代区域的垃圾收集。
    • 触发条件新生代的Eden区容量已满时。
  • Major GC - 主要垃圾回收,主要进行老年代的垃圾收集。
  • Full GC - 完全垃圾回收对整个Java堆内存和方法区进行垃圾回收。
    • 触发条件1每次晋升到老年代的对象平均大小大于老年代剩余空间
    • 触发条件2Minor GC后存活的对象超过了老年代剩余空间
    • 触发条件3永久代内存不足JDK8之前
    • 触发条件4手动调用System.gc()方法

我们可以添加启动参数来查看JVM的GC日志

image-20220222162536616

public class Main {
    public static void main(String[] args) {
        Object o = new Object();
        o = null;
        System.gc();
    }
}
[GC (System.gc()) [PSYoungGen: 2621K->528K(76288K)] 2621K->528K(251392K), 0.0006874 secs] [Times: user=0.01 sys=0.01, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 528K->0K(76288K)] [ParOldGen: 0K->332K(175104K)] 528K->332K(251392K), [Metaspace: 3073K->3073K(1056768K)], 0.0022693 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 76288K, used 3277K [0x000000076ab00000, 0x0000000770000000, 0x00000007c0000000)
  eden space 65536K, 5% used [0x000000076ab00000,0x000000076ae334d8,0x000000076eb00000)
  from space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f580000)
  to   space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000)
 ParOldGen       total 175104K, used 332K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
  object space 175104K, 0% used [0x00000006c0000000,0x00000006c00532d8,0x00000006cab00000)
 Metaspace       used 3096K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 333K, capacity 388K, committed 512K, reserved 1048576K

现在我们还只能大致看懂GC日志不过在学习完成本章全部内容后我们就可以轻松阅读了。

空间分配担保

我们可以思考一下有没有这样一种极端情况正常情况下新生代的回收率是很高的所以说不用太担心会经常出现这种问题在一次GC后新生代Eden区仍然存在大量的对象因为GC之后存活对象会进入到一个Survivor区但是很明显这时已经超出Survivor区的容量了肯定是装不下的那么现在该怎么办

这时就需要用到空间分配担保机制了可以把Survivor区无法容纳的对象直接送到老年代让老年代进行分配担保当然老年代也得装得下才行在现实生活中贷款会指定担保人就是当借款人还不起钱的时候由担保人来还钱。

当新生代无法容纳更多的的对象时,可以把新生代中的对象移动到老年代中,这样新生代就腾出了空间来容纳更多的对象。

那既然新生代装不下就丢给老年代那么要是老年代也装不下新生代的数据呢这时老年代肯定担保人是当不成了那么这样的话首先会判断一下之前的每次垃圾回收进入老年代的平均大小是否小于当前老年代的剩余空间如果小于那么说明也许可以放得下不过也仅仅是也许依然有可能放不下因为判断的实际上只是平均值万一这一次突然非常大呢否则会先来一次Full GC进行一次大规模垃圾回收来尝试腾出空间再次判断老年代是否有空间存放要是还是装不下直接抛出OOM错误摆烂。

最后我们来总结一下一次Minor GC的整个过程

image-20220222205605690


标记-清除算法

前面我们已经了解了整个堆内存实际上是以分代收集机制为主,但是依然没有讲到具体的收集过程,那么,具体的回收过程又是什么样的呢?首先我们来了解一下最古老的标记-清除算法。

首先标记出所有需要回收的对象然后再依次回收掉被标记的对象或是标记出所有不需要回收的对象只回收未标记的对象。实际上这种算法是非常基础的并且最易于理解的这里对象我就以一个方框代替了当然实际上存放是我们前说到的GC Roots形式

image-20220222165709034

虽然此方法非常简单,但是缺点也是非常明显的 ,首先如果内存中存在大量的对象,那么可能就会存在大量的标记,并且大规模进行清除。并且一次标记清除之后,连续的内存空间可能会出现许许多多的空隙,碎片化会导致连续内存空间利用率降低。

标记-复制算法

既然标记清除算法在面对大量对象时效率低,那么我们可以采用标记-复制算法。它将容量分为同样大小的两块区域,

标记复制算法,实际上就是将内存区域划分为大小相同的两块区域,每次只使用其中的一块区域,每次垃圾回收结束后,将所有存活的对象全部复制到另一块区域中,并一次性清空当前区域。虽然浪费了一些时间进行复制操作,但是这样能够很好地解决对象大面积回收后空间碎片化严重的问题。

image-20220222210942507

这种算法就非常适用于新生代因为新生代的回收效率极高一般不会留下太多的对象的垃圾回收而我们之前所说的新生代Survivor区其实就是这个思路包括8:1:1的比例也正是为了对标记复制算法进行优化而采取的。

标记-整理算法

虽然标记-复制算法能够很好地应对新生代高回收率的场景但是放到老年代它就显得很鸡肋了。我们知道一般长期都回收不到的对象才有机会进入到老年代所以老年代一般都是些钉子户可能一次GC后仍然存留很多对象。而标记复制算法会在GC后完整复制整个区域内容并且会折损50%的区域,显然这并不适用于老年代。

那么我们能否这样,在标记所有待回收对象之后,不急着去进行回收操作,而是将所有待回收的对象整齐排列在一段内存空间中,而需要回收的对象全部往后丢,这样,前半部分的所有对象都是无需进行回收的,而后半部分直接一次性清除即可。

image-20220222213208681

虽然这样能保证内存空间充分使用并且也没有标记复制算法那么繁杂但是缺点也是显而易见的它的效率比前两者都低。甚至由于需要修改对象在内存中的位置此时程序必须要暂停才可以在极端情况下可能会导致整个程序发生停顿被称为“Stop The World”

所以,我们可以将标记清除算法和标记整理算法混合使用,在内存空间还不是很凌乱的时候,采用标记清除算法其实是没有多大问题的,当内存空间凌乱到一定程度后,我们可以进行一次标记整理算法。


垃圾收集器实现

聊完了对象存活判定和垃圾回收算法,接着我们就要看看具体有哪些垃圾回收器的实现了。我们可以自由地为新生代和老年代选择更适合它们的收集器。

Serial收集器

这款垃圾收集器也是元老级别的收集器了在JDK1.3.1之前,是虚拟机新生代区域收集器的唯一选择。这是一款单线程的垃圾收集器,也就是说,当开始进行垃圾回收时,需要暂停所有的线程,直到垃圾收集工作结束。它的新生代收集算法采用的是标记复制算法,老年代采用的是标记整理算法。

image-20220223104605648

可以看到当进入到垃圾回收阶段时所有的用户线程必须等待GC线程完成工作就相当于你打一把LOL 40分钟中途每隔1分钟网络就卡5秒钟可能这时你正在打团结果你被物理控制直接在那里站了5秒钟这确实让人难以接受。

虽然缺点很明显,但是优势也是显而易见的:

  1. 设计简单而高效。
  2. 在用户的桌面应用场景中,内存一般不大,可以在较短时间内完成垃圾收集,只要不频繁发生,使用串行回收器是可以接受的。

所以在客户端模式一般用于一些桌面级图形化界面应用程序下的新生代中默认垃圾收集器至今依然是Serial收集器。我们可以在java -version中查看默认的客户端模式:

openjdk version "1.8.0_322"
OpenJDK Runtime Environment (Zulu 8.60.0.21-CA-macos-aarch64) (build 1.8.0_322-b06)
OpenJDK 64-Bit Server VM (Zulu 8.60.0.21-CA-macos-aarch64) (build 25.322-b06, mixed mode)

我们可以在jvm.cfg文件中切换JRE为Server VM或是Client VM默认路径为

JDK安装目录/jre/lib/jvm.cfg

比如我们需要将当前模式切换为客户端模式,那么我们可以这样编辑:

-client KNOWN
-server IGNORE

ParNew收集器

这款垃圾收集器相当于是Serial收集器的多线程版本它能够支持多线程垃圾收集

image-20220223111344962

除了多线程支持以外其他内容基本与Serial收集器一致并且目前某些JVM默认的服务端模式新生代收集器就是使用的ParNew收集器。

Parallel Scavenge/Parallel Old收集器

Parallel Scavenge同样是一款面向新生代的垃圾收集器同样采用标记复制算法实现在JDK6时也推出了其老年代收集器Parallel Old采用标记整理算法实现

image-20220223112108949

与ParNew收集器不同的是它会自动衡量一个吞吐量并根据吞吐量来决定每次垃圾回收的时间这种自适应机制能够很好地权衡当前机器的性能根据性能选择最优方案。

目前JDK8采用的就是这种 Parallel Scavenge + Parallel Old 的垃圾回收方案。

CMS收集器

在JDK1.5HotSpot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器CMSConcurrent-Mark-Sweep收集器这款收集器是HotSpot虚拟机中第一款真正意义上的并发注意这里的并发和之前的并行是有区别的并发可以理解为同时运行用户线程和GC线程而并行可以理解为多条GC线程同时工作收集器它第一次实现了让垃圾收集线程与用户线程同时工作。

它主要采用标记清除算法:

image-20220223114019381

它的垃圾回收分为4个阶段

  • 初始标记需要暂停用户线程这个阶段的主要任务仅仅只是标记出GC Roots能直接关联到的对象速度比较快不用担心会停顿太长时间。
  • 并发标记从GC Roots的直接关联对象开始遍历整个对象图的过程这个过程耗时较长但是不需要停顿用户线程可以与垃圾收集线程一起并发运行。
  • 重新标记(需要暂停用户线程):由于并发标记阶段可能某些用户线程会导致标记产生变得,因此这里需要再次暂停所有线程进行并行标记,这个时间会比初始标记时间长一丢丢。
  • 并发清除:最后就可以直接将所有标记好的无用对象进行删除,因为这些对象程序中也用不到了,所以可以与用户线程并发运行。

虽然它的优点非常之大但是缺点也是显而易见的我们之前说过标记清除算法会产生大量的内存碎片导致可用连续空间逐渐变少长期这样下来会有更高的概率触发Full GC并且在与用户线程并发执行的情况下也会占用一部分的系统资源导致用户线程的运行速度一定程度上减慢。

不过如果你希望的是最低的GC停顿时间这款垃圾收集器无疑是最佳选择不过自从G1收集器问世之后CMS收集器不再推荐使用了。

Garbage First (G1) 收集器

此垃圾收集器也是一款划时代的垃圾收集器在JDK7的时候正式走上历史舞台它是一款主要面向于服务端的垃圾收集器并且在JDK9时取代了JDK8默认的 Parallel Scavenge + Parallel Old 的回收方案。

我们知道,我们的垃圾回收分为Minor GCMajor GC Full GC它们分别对应的是新生代老年代和整个堆内存的垃圾回收而G1收集器巧妙地绕过了这些约定它将整个Java堆划分成2048个大小相同的独立Region块,每个Region块的大小根据堆空间的实际大小而定整体被控制在1MB到32MB之间且都为2的N次幂。所有的Region大小相同且在JVM的整个生命周期内不会发生改变。

那么分出这些Region有什么意义呢?每一个Region都可以根据需要自由决定扮演哪个角色Eden、Survivor和老年代收集器会根据对应的角色采用不同的回收策略。此外G1收集器还存在一个Humongous区域它专门用于存放大对象一般认为大小超过了Region容量一半的对象为大对象这样新生代、老年代在物理上不再是一个连续的内存区域而是到处分布的。

image-20220223123636582

它的回收过程与CMS大体类似

image-20220223123557871

分为以下四个步骤:

  • 初始标记暂停用户线程仅仅只是标记一下GC Roots能直接关联到的对象并且修改TAMS指针的值让下一阶段用户线程并发运行时能正确地在可用的Region中分配新对象。这个阶段需要停顿线程但耗时很短而且是借用进行Minor GC的时候同步完成的所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记从GC Root开始对堆中对象进行可达性分析递归扫描整个堆里的对象图找出要回收的对象这阶段耗时较长但可与用户程序并发执行。
  • 最终标记(暂停用户线程):对用户线程做一个短暂的暂停,用于处理并发标记阶段漏标的那部分对象。
  • 筛选回收负责更新Region的统计数据对各个Region的回收价值和成本进行排序根据用户所期望的停顿时间来制定回收计划可以自由选择任意多个Region构成回收集然后把决定回收的那一部分Region的存活对象复制到空的Region中再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动是必须暂停用户线程由多个收集器线程并行完成的。

元空间

JDK8之前Hotspot虚拟机的方法区实际上是永久代实现的。在JDK8之后Hotspot虚拟机不再使用永久代而是采用了全新的元空间。类的元信息被存储在元空间中。元空间没有使用堆内存而是与堆不相连的本地内存区域。所以理论上系统可以使用的内存有多大元空间就有多大所以不会出现永久代存在时的内存溢出问题。这项改造也是有必要的永久代的调优是很困难的虽然可以设置永久代的大小但是很难确定一个合适的大小因为其中的影响因素很多比如类数量的多少、常量数量的多少等。

image-20220223130536357

因此在JDK8时直接将本地内存作为元空间Metaspace)的区域,物理内存有多大,元空间内存就可以有多大,这样永久代的空间分配问题就讲解了,所以最终它变成了这样:

image-20220223125137512

到此我们对于JVM内存区域的讲解就基本完成了。


其他引用类型

最后,我们来介绍一下其他引用类型。

我们知道在Java中如果变量是一个对象类型的那么它实际上存放的是对象的引用但是如果是一个基本类型那么存放的就是基本类型的值。实际上我们平时代码中类似于Object o = new Object()这样的的引用类型,细分之后可以称为强引用

我们通过前面的学习可以明确,如果方法中存在这样的强引用类型,现在需要回收强引用所指向的对象,那么要么此方法运行结束,要么引用连接断开,否则被引用的对象是无法被判定为可回收的,因为我们说不定后面还要使用它。

所以当JVM内存空间不足时JVM宁愿抛出OutOfMemoryError使程序异常终止也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题。

除了强引用之外Java也为我们提供了三种额外的引用类型。

软引用

软引用不像强引用那样不可回收,当 JVM 认为内存不足时会去试图回收软引用指向的对象即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。当然,如果内存充足,那么是不会轻易被回收的。

我们可以通过以下方式来创建一个软引用:

public class Main {
    public static void main(String[] args) {
        //强引用写法Object obj = new Object();
        //软引用写法:
        SoftReference<Object> reference = new SoftReference<>(new Object());
        //使用get方法就可以获取到软引用所指向的对象了
        System.out.println(reference.get());
    }
}

可以看到软引用还存在一个带队列的构造方法软引用可以和一个引用队列ReferenceQueue联合使用如果软引用所引用的对象被垃圾回收器回收Java虚拟机就会把这个软引用加入到与之关联的引用队列中。

这里我们来进行一个测试首先我们需要设定一下参数来限制最大堆内存为10M并且打印GC日志

-XX:+PrintGCDetails -Xms10M -Xmx10M

接着运行以下代码:

public class Main {
    public static void main(String[] args) {
        ReferenceQueue<Object> queue = new ReferenceQueue<>();
        SoftReference<Object> reference = new SoftReference<>(new Object(), queue);
        System.out.println(reference);

        try{
            List<String> list = new ArrayList<>();
            while (true) list.add(new String("lbwnb"));
        }catch (Throwable t){
            System.out.println("发生了内存溢出!"+t.getMessage());
            System.out.println("软引用对象:"+reference.get());
            System.out.println(queue.poll());
        }
    }
}

运行结果如下:

java.lang.ref.SoftReference@232204a1
[GC (Allocation Failure) [PSYoungGen: 3943K->501K(4608K)] 3943K->2362K(15872K), 0.0050615 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 3714K->496K(4608K)] 5574K->4829K(15872K), 0.0049642 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 3318K->512K(4608K)] 7652K->7711K(15872K), 0.0059440 secs] [Times: user=0.03 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) --[PSYoungGen: 4608K->4608K(4608K)] 11807K->15870K(15872K), 0.0078912 secs] [Times: user=0.05 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 4608K->0K(4608K)] [ParOldGen: 11262K->10104K(11264K)] 15870K->10104K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.0587856 secs] [Times: user=0.24 sys=0.00, real=0.06 secs] 
[Full GC (Ergonomics) [PSYoungGen: 4096K->1535K(4608K)] [ParOldGen: 10104K->11242K(11264K)] 14200K->12777K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.0608198 secs] [Times: user=0.25 sys=0.01, real=0.06 secs] 
[Full GC (Ergonomics) [PSYoungGen: 3965K->3896K(4608K)] [ParOldGen: 11242K->11242K(11264K)] 15207K->15138K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.0972088 secs] [Times: user=0.58 sys=0.00, real=0.10 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 3896K->3896K(4608K)] [ParOldGen: 11242K->11225K(11264K)] 15138K->15121K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.1028222 secs] [Times: user=0.63 sys=0.01, real=0.10 secs] 
发生了内存溢出Java heap space
软引用对象null
java.lang.ref.SoftReference@232204a1
Heap
 PSYoungGen      total 4608K, used 4048K [0x00000007bfb00000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 4096K, 98% used [0x00000007bfb00000,0x00000007bfef40a8,0x00000007bff00000)
  from space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
  to   space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
 ParOldGen       total 11264K, used 11225K [0x00000007bf000000, 0x00000007bfb00000, 0x00000007bfb00000)
  object space 11264K, 99% used [0x00000007bf000000,0x00000007bfaf64a8,0x00000007bfb00000)
 Metaspace       used 3216K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 352K, capacity 388K, committed 512K, reserved 1048576K

可以看到,当内存不足时,软引用所指向的对象被回收了,所以get()方法得到的结果为null并且软引用对象本身被丢进了队列中。

弱引用

弱引用比软引用的生命周期还要短,在进行垃圾回收时,不管当前内存空间是否充足,都会回收它的内存。

我们可以像这样创建一个弱引用:

public class Main {
    public static void main(String[] args) {
        WeakReference<Object> reference = new WeakReference<>(new Object());
        System.out.println(reference.get());
    }
}

使用方法和软引用是差不多的但是如果我们在这之前手动进行一次GC

public class Main {
    public static void main(String[] args) {
        SoftReference<Object> softReference = new SoftReference<>(new Object());
        WeakReference<Object> weakReference = new WeakReference<>(new Object());

        //手动GC
        System.gc();

        System.out.println("软引用对象:"+softReference.get());
        System.out.println("弱引用对象:"+weakReference.get());
    }
}

可以看到弱引用对象直接就被回收了而软引用对象没有被回收。同样的它也支持ReferenceQueue和软引用用法一致这里就不多做介绍了。

WeakHashMap正是一种类似于弱引用的HashMap类如果Map中的Key没有其他引用那么此Map会自动丢弃此键值对。

public class Main {
    public static void main(String[] args) {
        Integer a = new Integer(1);

        WeakHashMap<Integer, String> weakHashMap = new WeakHashMap<>();
        weakHashMap.put(a, "yyds");
        System.out.println(weakHashMap);

        a = null;
        System.gc();
        
        System.out.println(weakHashMap);
    }
}

可以看到当变量a的引用断开后这时只有WeakHashMap本身对此对象存在引用所以在GC之后这个键值对就自动被舍弃了。所以说这玩意就挺适合拿去做缓存的。

虚引用(鬼引用)

虚引用相当于没有引用,随时都有可能会被回收。

看看它的源码,非常简单:

public class PhantomReference<T> extends Reference<T> {

    /**
     * Returns this reference object's referent.  Because the referent of a
     * phantom reference is always inaccessible, this method always returns
     * <code>null</code>.
     *
     * @return  <code>null</code>
     */
    public T get() {
        return null;
    }

    /**
     * Creates a new phantom reference that refers to the given object and
     * is registered with the given queue.
     *
     * <p> It is possible to create a phantom reference with a <tt>null</tt>
     * queue, but such a reference is completely useless: Its <tt>get</tt>
     * method will always return null and, since it does not have a queue, it
     * will never be enqueued.
     *
     * @param referent the object the new phantom reference will refer to
     * @param q the queue with which the reference is to be registered,
     *          or <tt>null</tt> if registration is not required
     */
    public PhantomReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }

}

也就是说我们无论调用多少次get()方法得到的永远都是null,因为虚引用本身就不算是个引用,相当于这个对象不存在任何引用,并且只能使用带队列的构造方法,以便对象被回收时接到通知。

最后Java中4种引用的级别由高到低依次为 强引用 > 软引用 > 弱引用 > 虚引用