JVM复习宝典,详细的知识点大总结
目录虚拟机模拟某种计算机体系结构,执行特定指令集的软件Java语言虚拟机分为两大类系统虚拟机(Virtual Box、VMware)程序虚拟机(JVM、.NET CLR、P-Code)Java语言虚拟机可以执行Java语言的高级虚拟机,Java虚拟机并不一定就可以称为JVM,例如:Apache HarmonyJava虚拟机必须通过Java TCK的兼容性测试的Java语言虚拟机才能称为Java虚拟
目录
虚拟机
模拟某种计算机体系结构,执行特定指令集的软件
Java语言虚拟机分为两大类
- 系统虚拟机(Virtual Box、VMware)
- 程序虚拟机(JVM、.NET CLR、P-Code)
Java语言虚拟机
- 可以执行Java语言的高级虚拟机,Java虚拟机并不一定就可以称为JVM,例如:Apache Harmony
Java虚拟机
- 必须通过Java TCK的兼容性测试的Java语言虚拟机才能称为Java虚拟机
- Java虚拟机并非一定要执行Java程序
- 业界三大商用JVM:Oracle HotSpot、Oracle JRockit VM、IBM J9 VM
- 其它虚拟机:Google Dalvik VM、Microsoft JVM、TaoBao JVM等
Java虚拟机架构
- Class Loader:类加载器
- Runtime Data Area:运行时数据区
- Execution Engine:执行引擎
- Native Interface:本地接口
类加载器
Java类加载器是Java运行时环境的一部分,负责动态加载Java类到Java虚拟机的内存空间中。
显示类加载的过程
在配置中添加 -verbose:class 来显示类加载过程
然后运行程序
public class Demo01 {
public static void main(String[] args) {
// 在配置中添加 -verbose:class 来显示类加载过程
System.out.println("加载...");
}
}
结果
显示出了加载的所有的类
[Opened C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.Object from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.io.Serializable from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.Comparable from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.CharSequence from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.String from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.reflect.AnnotatedElement from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.reflect.GenericDeclaration from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.reflect.Type from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.Class from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.Cloneable from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.ClassLoader from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.System from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.Throwable from C:\Java\JDK\jre\lib\rt.jar]
[Loaded java.lang.Error from C:\Java\JDK\jre\lib\rt.jar]
......
三个默认的类加载器
JVM中有三个默认的类加载器
1、引导(Bootstrap)类加载器
- 由原生代码(如c语言)编写,负责加载核心Java库,存储在< JAVA_HOME>/jre/lib目录中
2、扩展(Extensions)类加载器
- 用来在< JAVA_HOME>/jre/lib/ext或java.ext.dirs指明的目录中加载Java的扩展库,Java虚拟机的实现提供一个扩展库目录,该类加载器在此目录中查找并加载Java类,该类由sun.misc.Launcher$ExtClassLoader实现
3、Apps类加载器(也叫系统类加载器)
- 根据Java应用程序的类路径来加载Java类,一般来说,Java应用的类都是由它来完成加载
这三个类加载器存在父子关系
- 引导类加载器是扩展类加载器的父类
- 扩展类加载器是系统类加载器的父类
public class ClassLoaderTest {
public static void main(String[] args) {
// 获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);
// 获取扩展类加载器
ClassLoader extLoader1 = systemClassLoader.getParent();
System.out.println(extLoader1);
ClassLoader extLoader2 = SunEC.class.getClassLoader();
System.out.println(extLoader2);
// 引导类加载器
ClassLoader loader = extLoader1.getParent();
System.out.println(loader);
}
}
结果
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@60e53b93
sun.misc.Launcher$ExtClassLoader@60e53b93
null
最后一个是引导类加载器,是本地方法,由c/c++写的,所以获取不到,为null
类加载过程
class文件加载到虚拟机的内存
加载
- 类加载过程的一个阶段:通过一个类的完全限定查找此类字节码文件,并利用字节码文件创建—个Class对象
验证
- 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。
- 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
准备
- 为类变量(即static修饰的字段变量)分配内存并且设置该类变量的初始值即0(如static int i=5;这里只将i初始化为0,至于5的值将在初始化时赋值),这里不包含用final修饰的static,因为final在编译的时候就会分配了,注意这里不会为实例变量分配初始化,类变量会分配在方法区中(JDK8放在堆中),而实例变量是会随着对象—起分配到Java堆中。
解析
- 主要将常量池中的符号引用替换为直接引用的过程。符号引用就是一组符号来描述目标,可以是任何字面量,而直接引用就是直接指向目标的指针、相对偏移量或一个间
接定位到目标的句柄。 - 常量池中的符号
初始化
- 类加载最后阶段,若该类具有超类,则对其进行初始化,执行静态初始化器和静态初始化成员变量(如前面只初始化了默认值的static变量将会在这个阶段赋值,成员变
量也将被初始)
双亲委托机制
-
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
-
如果父类加载器还存在其父类加载器,则进一步向上委托
依次递归 -
请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回
-
倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载
为什么要使用双亲委托机制?
比如自己创建一个 java.lang包,然后创建一个String类,则此类永远不被执行到
因为在加载此类的时候,会向上委托顶层的加载器去加载,而这个引导类加载器发现是java.lang包下的String,而JDK中正好有此类,所以就加载了那个,自己写的String类就不会被加载
使用类加载器加载属性文件
先创建一个属性文件 user.properties
username=zbx
age=21
gender=GG
测试
public class Demo02 {
public static void main(String[] args) throws IOException {
Properties properties = new Properties();
InputStream inputStream = Demo02.class.getClassLoader().getResourceAsStream("com/robot/jvm/user.properties");
properties.load(inputStream);
inputStream.close();
properties.list(System.out);
}
}
结果
-- listing properties --
age=21
gender=GG
username=zbx
假如不使用类加载器类加载,直接加载“src\user.properties”路径行不行?
答案是可以运行成功,但是导出jar包后就不可以了
因为导出jar包后,只导出了.class文件,而src目录存放的是源码.java文件,所以src目录不会被导出,而直接用src目录则会找不到路径报错
执行jar包:java -jar 名字.jar
运行时数据区
运行时数据区的划分
程序私有
- 程序计数器
- Java虚拟机栈
- 本地方法栈
线程共享
- Java堆
- 方法区
程序计数器
程序计数器也称PC寄存器
- 一块较小的内存空间,是当前线程执行字节码的行号指示器
- 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址
- 此内存区域是唯一一个在Jav虚拟机规范中没有规定任何OutOfMemoryError情况的区域
虚拟机栈和本地方法栈
虚拟机栈
- 线程私有
- 先进后出栈
- 存储栈帧,支持Java方法的调用、执行和退出
- 可能出现OutOfMemoryError异常和StackOverflowError异常
本地方法栈
- 线程私有
- 先进后出栈
- 作用是支撑Native方法的调用、执行和退出
- 可能出现OutOfMemoryError异常和StackOverflowError异常
- HotSpot虚拟机将Java虚拟机栈和本地方法栈合并实现
栈帧
栈帧的概念和特征
- Java虚拟机栈中存储的内容,它被用于存储数据和执行过程结果的数据结构,同时也被用来处理动态连接、方法返回地址和异常完成信息
- 一个完整的栈帧包含:局部变量表、操作数栈、动态连接、方法返回地址等附加信息
每一个线程的启动,在内存中创建一个对于的JVM栈,用于存储栈帧
- 一个方法对应一个栈帧结果
- 一个方法从调用起到执行完毕的过程,就对应一个栈帧在JVM从入栈到出栈的过程
局部变量表
- 由若干个 Slot(槽)组成,长度由编译期决定,Code属性的locals指定。
- 单个Slot可以存储一个类型为boolean、byte、char、short、int、float
- reference和returnAddress (已过时)的数据,两个Slot可以存储一个类型为long或double的数据。
- 局部变量表用于方法间参数传递,以及方法执行过程中存储基本数据类型的值和对象的引用
操作数栈
- 是一个后进先出栈,由若个 Entry组成,长度由编译期决定、由Code属性的stacks指定
- 单个Entry 即可以存储一个Java虚拟机中定义的任意数据类型的值,包括long和double类型,但是存储long和double类型的Entry深度为2,其他类型的深度为1
- 在方法执行过程中,操作数栈用于存储计算参数和计算结果,在方法调用时,操作数栈也用来准备调用方法的参数以及接收方法返回结果
Java虚拟机找和本地方法栈可能发生如下异常情况
- 如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量时,Java虚拟机将会抛出一个StackOverflowError异常
- 如果Java虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的虚拟机栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常
- 调整栈空间大小:-Xss1m
- 在JDK1.5之前栈默认大小为256k,JDK1.5之后为1m
动态链接
-
每一个栈帧内部都包含一个执行运行时常量池中该栈帧所属方法的引用
-
每一次方法调用时,动态的将符号引用转成直接引用(入口地址),支持多态
方法返回地址
方法结束有正常结束和异常结束
正常结束
- 当前栈帧承担着恢复调用者状态的责任,其中包括恢复调用者的局部变量表和操作数栈,正确递增程序计数器、将返回值压入调用者的操作数栈。
异常结束
- 如果当前方法中没有处理此种异常、当前栈帧恢复调用者状态的同时,不会返回任何值,而是通过athrow指令将当前异常抛给调用者
堆
Java堆的特征
- 全局共享
- 通常是Java虚拟机最大的一块内存区域
- 作为Java对象或数组的主要存储区域
- 由JVM自动管理的线程共享区域,在JVM启动时创建
- 堆可以处于逻辑上连续、物理上不连续的空间当中,既可以实现固定大小的,也可以实现可扩展的,当前主流虚拟机实现都是可扩展的,可通过相关参数进行配置
- 如果实际所需的堆超过了自动内存管理系统能提供的最大容量,那Java虚拟机将会抛出一个OutOfMemoryError 异常
Java堆相关参数
- -Xms: 初始堆大小
- -Xmx: 最大堆大小
- -XX:+PrintGC 打印查看GC日志
- -XX:+PrintGCDetails 打印查看GC日志详情
- -XX:+PrintCommandLineFlagss 打印虚拟机参数
分代存储
新创建的对象存入Eden区,在第一次垃圾回收后,幸存下来的对象就会进入Survivor中的From区中,第二次垃圾回收后,Eden区幸存的对象就会放入To区,From中幸存的对象也放入To中,然后From和To区互换职责,From变为To,To变为From
当年轻代中的对象年龄大于等于15岁时,就会将其放入老年代中,当Eden区放不下的时候,也会放入老年代。
相关参数
- -Xmn: 新生代大小
- -XX:NewRatio=?: 表示年轻代和老年代的比例,默认1:2
- -XX:SurvivorRatio=?: Eden和Survivor的,默认是8:1:,但实际调整6:1:1
对象分配策略
- 优先分配Eden区:绝大多数对象都是“朝生夕死”的对象,Eden区的回收时间短、效率高,适用于频繁回收。
- 大对象直接进入老年代:Enden和Survivor的空间不足时,大对象直接进入老年代。-XX:PretenureSizeThreshold=KB,对象大小超过此值,直接进入老年代。
- 长期存活对象进入老年代:在Survivor区存活N岁(来回复制N次)的对象,将直接进入老年代。-XX:MaxTenuringThreshold=15(只有单线程收集器
可用)
对象的内容
对象:对象头、实例数据、对齐补白
-
对象头:Mark Word、Klass Pointer、[数组长度](数组对象有)
- MarkWord:存储HashCode、分代年龄、线程锁的标志位、偏向线程ID,偏向时间戳等
-
实例数据:对象属性信息
-
对齐补白:必须是8的倍数,如果不够,就自动补齐
方法区和运行时常量池
方法区特征
- 全局共享
- 作用是存储Java类的结构信息、常量、静态变量、即时编译器编译后的代码
- 可能出现OutOfMemoryError异常
运行时常量池特征
- 全局共享
- 是方法区的一部分
- 作用是存储Java类文件常量池中的符号信息
- 可能出现OutOfMemoryError异常
永久代与方法区
-
JDK1.2—JDK6,HotSpot使用永久代实现方法区
-
JDK7,符号表被移到Native Heap中,字符串常量和类的静态引用移到堆中
-
JDK8开始,永久代被元空间代替
-
字符串常量和类的静态引用仍在堆中
使用虚拟机参数 -XX:+PrintGCDetails,查看详细信息
然后执行程序
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。应用在某些场景中能显著提高性能,因为其避免了在Java堆和Native堆中来回复制数据。
在JDK1.4的NIO中已经出现直接内存的使用。
可能出现OutOfMemoryError异常。
其它空间
在HotSpot虚拟机中还有其他一些空间
-
TLAB:TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区这是一个线程专用的内存分配区域。因为堆空间是线程共享的,很多线程都去访问其中的资源,容易发生冲突碰撞,会降低效率,所以每个线程都开辟了一小块线程私有的空间,直接操作自己私有的区域效率比较高(所以,堆中的空间并不全是共享的)
-
CodeCache:Code Cache用于存储JVM JIT产生的编译代码。
-
Compressed Class Space (JDK1.8以后)∶类指针压缩空间,目的为了省内存。
逃逸分析
概念
Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在Java堆上分配内存的一项技术
逃逸分析的JVM参数
- 开启逃逸分析:-XX:+DoEscapeAnalysis(默认开启)
- 关闭逃逸分析:-XX:-DoEscapeAnalysis
- 显示分析结果:-XX:+PrintEscapeAnalysis
对象逃逸
- 当一个对象在方法中被定义后,它可能被外部方法引用,例如作为参数或返回值传递到其它地方中,称为对象逃逸
使用逃逸分析,编译器可以对代码做如下优化
1、锁消除
-
线程同步锁是非常牺牲性能的,当编译器确定当前对象只有当前线程使用,那么就会移除该对象的同步锁。
-
锁消除的JVM 参数如下
- 开启锁消除:-XX:+EliminateLocks
- 关闭锁消除:-XX:-EliminateLocks
- 锁消除在JDK8中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上。
测试锁消除
锁消除默认是开启的
public class LockEliminate {
public static void main(String[] args) {
StringBuffer stringBuffer = new StringBuffer();
String string = "love";
long startTime = System.currentTimeMillis();
for(int i = 0; i < 10000000; i++) {
stringBuffer.append(string);
}
long endTime = System.currentTimeMillis();
System.out.println("耗时: " + (endTime - startTime));
}
}
内部执行的是StringBuffer的append方法,此方法属于同步方法,有锁
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
执行时,这个功能只在main方法内部实现,不会发生逃逸,所以默认是会进行锁消除,结果为
耗时: 671 耗时: 672 耗时: 657
当关闭锁消除后
耗时: 713 耗时: 705 耗时: 765
2、标量替换
- 标量:不能被进一步分解的数据,基本类型数据和对象的引用可以理解为标量
- 聚合量:可以被进一步分解成标量,比如对象
- 标量替换:将对象成员变量分解为分散的标量,这就叫做标量替换。
如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。
标量替换的JVM参数如下
- 开启标量替换:-XX:+EliminateAllocations
- 关闭标量替换:-XX:-EliminateAllocations
- 显示标量替换详情:-XX:+PrintEliminateAllocations
- 标量替换同样在JDK8中都是默认开启的,并且都要建立在逃逸分析的基础上。
3、栈上分配
当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了GC压力,提高了应用程序性能(所以,对象不一定都是在堆中创建)
垃圾回收
- 释放垃圾占用的空间,防止内存溢出或内存泄漏,为了有效的使用内存,对内存堆中已经死亡的或长时间没有使用的对象进行清除和回收
什么是垃圾?
没有任何引用指向的对象,称为垃圾
垃圾判定算法
- 引用计数算法
- 可达性分析算法
引用计数算法
是通过在对象头中分配一个空间来保存核对象被引用的次数(Reference Count)。如果该对象被其它对象引用,则它的引用计数加1,如果删除对该对象的引用,那么它的引用计数就减1,当该对象的引用计数为0时,那么该对象就会被回收。
优点
- 引用计数收集器可以很快的执行,交织在程序运行中。
- 对程序需要不被长时间打断的实时环境比较有利。
缺点
- 无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。
可达性分析算法
通过一系列的称为"GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。此算法解决了循环引用的问题。
Root对象
- 虚拟机栈中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象:字符串常量池的引用
- 本地方法栈中Native引用的对象
- synchronize锁对象
- Class对象
finalize
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。当垃圾回收器发现没有引用指向一个对象,即垃圾回收此对象之前,总会先调用这个对象的finalize()方法。finalize()方法运行在子类中被重写,用于对象被回收时进行资源释放。通常在这个方法进行一些资源释放和清理的工作,比如关闭文件,套接字和数据库连接等。也可以在里面让对象重新建立引用(复活此对象)
Java虚拟机中的对象可能的三种状态、
- 可触及的:从根节点开始,可以达到这个对象。
- 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活(在finalize()中让此对象与引用链上的对象关联就复活了)
- 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活。因为finalize()只会被调用一次。
垃圾回收算法
- 标记-清除算法(Mark-Sweep)
- 标记-整理算法(Mark-Compact)
- 复制算法
- 分代收集算法
标记-清除算法
第一步:标记,利用可达性遍历内存,把存活的对象和垃圾对象进行标记
第二部:清除,再遍历一遍,把所有垃圾对象所占用的空间直接清空
特点:实现简单、但是容易产生碎片
标记-整理算法
第一步:标记,利用可达性遍历内存,把存活对象和垃圾对象进行标记
第二部:整理,把所有存活的对象堆到同一个地方,这样就没有内存碎片了
特点:适合存活对象多、垃圾少的情况
复制算法
将内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完了,就将还活着的对象复制到另一块上,然后再把使用过的内存空间一次性清理掉。(比如年轻代中的From区和To区)
特点:简单、不会产生碎片、内存利用率低
分代收集算法
(不是具体的算法,属于一种机制)
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,根据对象存活周期的不同将内存划分为几块并采用不同的垃圾收集算法。
Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
- 新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
- 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清除”或者“标记-整理”算法来进行回收。
在新生代中的GC称为Minor GC,发生频率非常高
在老年代中的GC称为Major GC,尽量降低回收次数
(方法区中也会发生GC,但要求很严格,基本不动)
垃圾收集器
Serial收集器:串行收集器
ParNew收集器:其实就是Serial收集器的多线程版本
Serial Old收集器:是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。
Parallel Scavenge 收集器:是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器
Parallel Old收集器:是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法
CMS收集器:是一种以获取最短回收停顿时间为标的收集器。尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种集器来说更复杂一些,整个过程分为4个步骤,包括:初始标记、并发标记、重新标记、并发清除
G1(Garbage-First)收集器:是当今收集器技术发展的最前沿成果,它将整个Java堆划分为多个大小相等的独立区域,虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分独立区域(不需要连续)的集合。
ZGC:宣称暂停时间不超过10ms,支持4TB,JDK13到了16TB!和内存无关,TB级也只停顿1-10ms
Epsilon收集器:开发一个处理内存分配但不实现任何实际内存回收机制的GC,—旦可用堆内存用完,JVM就会退出。主要用途性能测试,内存压力测试。
Shenandoah:它的evacuation阶段工作能通过与正在运行中Java工作线程同时进行(即并发),从而减少GC的顿时间。
JDK8使用Parallel Scavenge和Parallel Old
JDK14中使用G1和ZGC
Java对象引用
强引用
- 垃圾回收器永远不会回收存活的强引用对象,比如:Object obj = new Object()
软引用
- 还有用,但并非必要的对象,在系统将要发生内存溢出的时候,将会把这些对象列进回收范围内
弱引用
- 当垃圾回收器工作时,无论内存是否足够,都会回收掉被弱引用关联的对象
虚引用
- 无法通过虚引用来获取一个实例,为一个对象设置虚引用关联的唯一目的就是在这个对象被收集器回收时收到一个系统通知
测试
创建一个实体类Student,并重写finalize()方法,输出一句话
@Override
protected void finalize() throws Throwable {
System.out.println("回收...");
}
测试4种引用
public class GCTest {
public static void main(String[] args) {
Student student = new Student("bao", 21);
// 强引用:不会被回收
// Student robot = student;
// 软引用:不会被回收
// SoftReference<Student> softReference = new SoftReference<>(student);
// 弱引用:会被回收
// WeakReference<Student> weakReference = new WeakReference<>(student);
// 虚引用:会被回收
PhantomReference<Student> phantomReference = new PhantomReference<>(student, null);
student = null;
System.gc();
}
}
更多推荐
所有评论(0)