栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 系统运维 > 运维 > Linux

JVM 运行时数据区(二)

Linux 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

JVM 运行时数据区(二)

一、堆

一个进程对应 JVM 的一个实例,一个 JVM 实例只有一个运行时数据区。

1.1.堆的核心概述

Java 堆区在 JVM 启动时被立即创建,其空间大小也就确定了,是 JVM 管理的最大一块内存空间。所有线程共享堆,在这里还可以划分线程私有的缓冲区(Thread Loacl Allocation Buffer,TLAB)。

所以线程都是共享堆空间所有的区域吗?不是,因为存在线程私有缓冲区。

《Java虚拟机规范》中对 Java 堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。

堆空间内存细分:

Java 7 及以前堆内存逻辑上分为三部分:新生代 + 老年代 + 永久代(逻辑上分为这三部分,实际上不包括永久代)

Young Generation Space 新生代Tenure Generation Space 老年代Permanent Space 永久代

Java 8 及以后堆内存逻辑上分为三部分:新生代 + 老年代 + 元空间

Young Generation Space 新生代Tenure Generation Space 老年代meta Space 元空间 1.2 新生代与老年代

为什么需要把 Java 堆分代? 不分代就不能正常工作了吗?

其实不分代完全可以,分代的唯一理由就是优化 GC 性能。经研究,70% - 99% 的对象是临时对象。如果不分代,GC 的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是临时对象。如果分代的话,把新创建的对象放到某一地方,当 GC 的时候先把这块存储临时对象的区域进行回收,这样就会腾出很大的空间出来。

新生代占 1/3 的堆空间,老年代占 2/3 的堆空间(新生代:老年代 = 1:2)

配置新生代与老年代在堆的结构占比:

默认 -XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的 1/3

修改 -XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的 1/5

命令行查看堆空间内存占比:

jps :查看当前系统有哪些 Java 程序在运行。

jinfo -flag SurvivorRatio 端口号 : 查看某个 java 进程的 幸存空间占比

jinfo -flag NewRatio 端口号 : 查看某个 java 进程的 新生代空间占比

在 HotSpot 中,Eden 空间和另外两个 Survivor 空间默认所占的比例是 8:1:1,可以通过选项 -XX:SurvivorRatio 调整这个空间比例。比如 -XX:SurvivorRatio=8。表示 Eden : Survivor0 : Survivor1 = 8:1:1

但是我们在 Visual GC 中,看到的默认比例不是 8:1:1,而是 6:1:1,这是因为存在自适应机制,默认情况下有个自适应比例。如果想看到 8:1:1,需要显示指定 SurvivorRatio 大小为 8 。 -XX:SurvivorRatio=8

-Xmn : 设置新生代内存空间大小。(了解。这个是设置值大小,比如设置成 200,而 -XX:NewRatio=2 是设置新生代与老年代的比例,如果都设置了以 -Xmn 为准。一般不使用这个命令进行设置,使用默认值就可以)。

1.3 内存分配策略

YGC / MinorGC :当 Eden 区满了的时候会触发 YGC 。此时触发 STW ,用户线程停止,GC 线程开始工作。没有被回收的对象被放到 Survivor0 区,并分配一个年龄计数器。当 Eden 又满了的时候,没有被回收的新的对象和 Survivor0 中的对象,一起被复制到 Survivor1 区。当对象的的年龄达到 15 时,这些对象会晋升(Promotion)到老年代。15 称为阈值,这个阈值可以通过参数 -XX:MaxTunuringThreshold= 进行修改。

内存分配策略:

优先分配到 Eden 区

大对象直接分配到老年代(尽量避免程序中出现过多的大对象)

长期存活的对象分配到老年代

动态对象年龄判断。 如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄(15)。

空间分配担保。在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

如果大于,则此次 Minor GC 是安全的;如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败。JDK7 以后默认是是允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。(如果 HandlePromotionFailure=false,则进行一次 Full GC)

如果大于,则尝试进行一次 Minor GC,但这次 Minor GC 依然是有风险的;如果小于,则改为进行一次 Full GC。

在 JDK7 以后,只要老年代的连续空间大于新生代对象总大小 或者 大于历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC。

问题:

什么时候触发 YGC?Eden 区满了的时候触发 YGC

Survivor 区满了的时候会触发 YGC 吗?不会

那当 Survivor 满了会怎样?无法容纳的对象直接放入老年代(空间分配担保)

总结:

针对 Survivor0,Survivor1 区的总结:复制之后有交换,谁空谁是to。

关于垃圾回收:频繁在新生代收集(YGC/MinorGC),很少在养老带收集(OGC/MajorGC),几乎不在永久区/元空间收集(FullGC)。

1.4 Minor GC、Major GC、Full GC

Minor GC / Young GC:新生代收集,只是新生代的垃圾收集。主要是 Eden 区满了会进行 YGC,Survivor 区满了不会触发 YGC。Minor GC 会触发 STW,但是新生代的空间相对老年代的空间较小,所以 STW 的时间也会比较短。

Major GC / Old GC:老年代收集,只是老年代的垃圾收集。目前只有 CMS 会有单独收集老年代的行为。

Full GC:整堆收集,收集整个 Java 堆和方法区的垃圾收集。

出现了Major GC, 经常会伴随至少一次的 Minor GC。 (但非绝对的,在 ParallelScavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)Major GC 的速度一般会比 Minor GC 慢10倍以上,STW 的时间更长。如果 Major GC 后,内存还不足,就报OOM了。

触发 Full GC 执行的情况有如下五种:

(1) 调用 System. gc() 时,系统建议执行 Full GC,但是不必然执行

(2) 老年代空间不足

(3) 方法区空间不足

(4) 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存

(5) 由 Eden区、Survivor0 区向 Survivor1 区复制时,对象大小大于 Survivor1 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

说明: Full GC 是开发或调优中尽量要避免的,这样暂停时间会短一些。

1.5 线程私有缓冲区:TLAB

为什么有 TLAB ( Thread Local Allocation Buffer) ?

堆区是线程共享区域,多个线程操作同一地址的时候,会引发线程安全问题,所以需要使用加锁等机制,但是会影响分配速度。所以 JVM 为每个线程分配了一个私有缓存区,它包含在 Eden 区中。多线程同时分配内存时,使用 TLAB 可以避免一系列的线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

所有 openJDK 衍生出来的 JVM 都提供了 TLAB 的设计:

在程序中,开发人员可以通过选项 -XX:UseTLAB 设置是否开启 TLAB 空间。(默认是开启的)默认情况下, TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,当然我们可以通过选项 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。一旦对象在 TLAB 空间分配失败时, JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。 1.6 堆是分配对象的唯一选择吗?

在 HotSpot 虚拟机中,是的。

随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。在 Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了,以此达到降低GC的回收频率和提升GC的回收效率的目的。这也是最常见的堆外存储技术。

逃逸分析的基本行为就是分析对象动态作用域:

➢当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。

➢当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

在 JDK 6 版本之后,HotSpot中默认就已经开启了逃逸分析。如果使用的是较早的版本,开发人员则可以通过:

➢选项 “-XX:+DoEscapeAnalysis” 显式开启逃逸分析。

➢通过选项 “-XX:+PrintEscapeAnalysis” 查看逃逸分析的筛选结果。

使用逃逸分析,编译器可以对代码做如下优化:

一、栈上分配

虚拟机的垃圾回收子系统会回收堆中不再使用的对象,但回收动作无论是标记出筛选可回收的对象,还是回收和整理内存,都需要耗费大量的资源。如果确定一个对象不会逃逸出线程之外,那可以让这个对象在栈上分配内存。栈上分配可以支持方法逃逸,但不能支持线程逃逸。

二、标量替换。

有的对象可能不需要作为以个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

标量(Scalar) 是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量聚合量(Aggregate) 是指可以分解的数据。Java 中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被方法外部访问的话,那么经过 JIT 优化,程序真正执行的时候将可能不去创建这个对象,而是将这个对象拆解成若干个其中包含的成员变量来代替,这个过程就是标量替换。标量替换不允许对象逃逸出方法范围内。

JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。

// 完全未优化的代码
public int test(int x) {
    int xx = x + 2;
    Point p = new Point(xx, 42); // Point 就是包含 x 和 y 的 POJO 类
    return p.getX();
}

经过逃逸分析,发现在整个 test() 方法的范围内 Point 对象实例不会发生任何程度的逃逸,这样可以对它进行标量替换,把其内部的 x 和 y 直接置换出来,分析为 test() 方法内的局部变量,从而避免 Point 对象实例被实际创建,优化后的结果如下所示:

// 标量替换后的样子
public int test(int x) {
	int xx = x + 2;
    int px = xx;
    int py = 42;
    return px;
}

通过数据流分析,发现 py 的值不会对方法造成任何影响,可以做无效代码消除优化:

// 做无效代码消除后的样子
public int test(int x) {
    return x + 2;
}

三、同步省略。

线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问到,那么这个变量的读写肯定不会出现竞争,对这个变量实施的同步措施也就可以完全的被消除掉。

在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象(同步监视器)是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略, 也叫锁消除。

public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}

代码中对 hollis 这个对象进行加锁,但是 hollis 对象的生命周期只在 f() 方法中,并不会被其他线程所访问到,所以在 JIT 编译阶段就会被优化掉。优化成:

public void f() {
    Object hollis = new Object();
    System.out.println(hollis);
}

小结:

逃逸分析也需要消耗一定的性能,无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。

Oracle Hotspot 虚拟机暂时还没有做这项优化,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是 intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。

二、方法区

方法区是线程共享的区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、运行时常量池和即时编译器编译后的代码缓存等数据。

2.1 栈、堆、方法区的交互关系

比如我们创建了一个对象 User user = new User(),第一个 User 代表的是这个类的本身 User.class 文件,这个 User.class 文件会被存放在方法区中;第二个 user 是变量(对象的引用),存放在栈中的局部变量表中(double 和 long 类型占两个 slot,其他基本数据类型和对象的引用占一个 slot);而 new User() 是对象,存放在堆中。

栈中的 reference 指向堆中的实例对象,堆中的实例对象的 类型数据指针 指向 方法区中的 对象类型数据(.class)。

2.2 方法区的理解

方法区在 JVM 启动的时候被创建,并且它的实际的物理内存空间中和 Java 堆区一样都可以是不连续的。

方法区的大小跟堆空间一样,可固定大小也可扩展。

方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类, 导致方法区溢出,虚拟机同样会抛出内存溢出错误:

JDK7及以前:java.lang.OutOfMemoryError:PermGen space

JDK8及以后:java.lang.OutOfMemoryError:metaspace

关闭 JVM 就会释放这个区域的内存。

在 jdk7 及以前,习惯上把方法区称为永久代。jdk8 开始,使用元空间取代了永久代。本质上,对 HotSpot 虚拟机而言,方法区和永久代并不等价。BEA JRockit / IBM J9 中不存在永久代的概念。

元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。 减少了 Java 程序出现 OOM。

2.3 方法区的内部结构

不同版本方法区存放的结构式不一样的。

1.7以前,方法区是用于存储已被虚拟机加载的类型信息、静态变量、常量、运行时常量池、即时编译器编译后的代码缓存等。

1.7以后,运行时常量池、静态变量被移到堆中。

2.3.1 类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM 必须在方法区中存储以下类型信息:

① 这个类的全类名(包名+类名)

② 这个类的修饰符(public, abstract, final 的某个子集)

③ 这个类继承的直接父类的全类名(对于 interface 或是 java.lang.object,都没有父类)

④ 这个类实现的直接接口的一个有序列表(实现的接口可以有多个,所以是一个有序列表,包括实现接口的泛型的全限定类名)

⑤ 加载当前类的加载器也保存在类信息里面(类信息会记录被哪个 ClassLoader 加载进来的,ClassLoader 也会记录加载过哪些类)

⑥ 域信息(Field,成员变量)

域的相关信息包括:域名称、域类型、域修饰符(public, private, protected, static, final, volatile, transient的某个子集)以及域的声明顺序

⑦ 方法信息(Method)

方法的相关信息包括:方法名称、返回类型(或void)、参数的数量和类型(按顺序)、方法的修饰符(public, private, protected, static, final, synchronized, native, abstract 的一个子集)、方法的字节码(bytecodes)、 操作数栈的深度、局部变量表及大小 (abstract和native方法除外)、异常表(abstract 和native方法除外)

2.3.2 静态变量

静态变量和类关联在一起,随着类的加载而加载,他们成为数据在逻辑上的一部分静态变量被类的所有实例共享,即使没有类实例时也可以访问它 2.3.3 常量

全局常量(static final):被声明为 final 的类变量的处理方法不同,每个全局常量在编译的时候就会被分配。

字节码文件:

{
    public static int count;   // 没有 final 修饰的 count
    	descriptor: I
        flags: ACC_PUBLIC, ACC_STATIC  // 修饰符
            
    public static final int number;  // 有 final 修饰的 number
        descriptor: I
        falgs: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
        ConstantValue: int 2    // 编译期就会被分配
}
2.3.4 运行时常量池

字节码文件中包含了常量池,方法区中包含了运行时常量池

.class 文件被类加载器加载到方法区中,字节码文件中的常量池也会随之被加载到方法区。在方法区当中的常量池叫运行时常量池。

为什么要常量池?

一个 java 源文件中的类。接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态连接的时候会用到运行时常量池。

常量池可以看做是一张表,虚拟机指令根据这张表找到要执行的类名、方法名、参数类型、字面量等类型。

常量池中存储的数据类型:数量值、字符串值、类引用、字段引用、方法引用。

常量池是 .class 文件中的一部分,运行时常量池是方法区的一部分。字节码文件中的常量池用于存放编译期生成的各种字面量与符号引用,这些内容在类加载后存放到方法区的运行时常量池中。运行时常量池除了保存 Class 文件中描述的符号引用外,还把由符号引用翻译出来的直接引用也存储在运行时常量池中。

运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。运行时常量池相对于 .class 文件中的常量池的另一个重要特性是:具备动态性。例如 String 类的 intern() 方法。

运行时常量池的两个特性:存储在方法区中,具备动态性。

代码:

public class MethodAreaDemo {
    public static void main(String[] args) {
        int x = 500;
        int y = 100;
        int a = x / y;
        int b = 50;
        System.out.pringln(a + b);
    }
}

方法区中对应字节码指令对应操作:

序号字节码指令代表含义
0sipush 500将 500 这个数压入操作数栈(程序计数器记录 0)
3istore_1弹出操作数栈栈顶 500,保存到本地变量表1的位置(程序计数器记录 3)
4bipush 100将 100 这个数压入操作数栈(程序计数器记录 4)
6istore_2弹出操作数栈栈顶 100,保存到本地变量表2的位置(程序计数器记录 6)
7iload_1读取本地变量表1的位置(500),压入操作数栈(程序计数器记录 7)
8iload_2读取本地变量表2的位置(100),压入操作数栈栈顶(程序计数器记录 8)
9idiv将栈顶两个 int 类型数(500 和 100)相除,结果入栈 500/100 = 5(程序计数器记录 9)
10istore_3弹出操作数栈栈顶 5,保存到本地变量表3的位置上(程序计数器记录 10)
11bipush 50将 50 这个数压入操作数栈(程序计数器记录 11)
13istore_4弹出操作数栈栈顶 50,保存到本地变量表4的位置(程序计数器记录 13)
15getstatic #2获取类或接口字段的值,并将其推入操作数栈。#2 对应常量池中的 Fieldref #15.#16
18iload_3读取本地变量表3的位置(5),压入操作数栈(程序计数器记录 18)
19iload_4读取本地变量表4的位置(50),压入操作数栈栈顶(程序计数器记录 19)
21iadd将栈顶两个 int 类型数(50和 5)相加,结果入栈 50 + 5 = 55(程序计数器记录 21)
22invokevirtual #3虚方法调用(这些方法可能会被重写,所以叫虚方法),调用这个方法,把 55 输出
25returnvoid 函数返回,main 方法结束
2.4 方法区的演进细节

首先明确:只有 HotSpot 才有永久代。BEA JRockit、IBM J9 不存在永久代的概念。永久代是针对 HotSpot 虚拟机的垃圾回收器而言的。当时使用永久代来实现方法区,可以使得 HotSpot 的垃圾收集器能够像管理 Java 堆一样管理方法区内存,省去专门为方法区编写管理内存代码的工作。但是这样设计导致了 Java 应用更容易遇到内存溢出问题。

HotSpot 中方法区的变化:

jdk1.6 以前, 有永久代(Permanent Generation),静态变量存放在永久代上。jdk1.7,有永久代,但已经逐步 “去永久代”,字符串常量池、静态变量移除,保存在堆中。jdk1.8 以后,无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量(引用)仍在堆中。

问题1:为什么要是用元空间代替永久代?

永久代属于 JVM 虚拟机的一部分,而元空间落地到了本地内存,不再受 JVM 控制。元空间存储的大多是类信息,类信息的生命周期都比较长。

1)为永久代设置空间大小是很难确定的

因为在实际的项目中,功能点比较多,在运行过程中要不断动态加载很多类,加载的类越多永久代的内存越不可控,如果设置小了,容易出现 OOM,如果设置大了,又浪费空间。 而元空间和永久代最大的区别在于:元空间并不在虚拟机中,而使用了本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

2)对永久代进行调优是很困难的(Full GC 很花费时间)

方法区中的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。

问题2:为什么要将字符串常量池移到堆中?

因为永久代的回收效率很低,在 Full GC 的时候才会触发。而 Full GC 是老年代、永久代空间不足才会触发。这就导致字符串常量池回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低就会导致永久代内存不足,放到堆中能及时回收。

代码:

public class Test {
    static ObjectHolder staticObj = new ObjectHolder();  // 1.7之前 staticObj 存放到方法区中,1.7以后存放在堆中
    ObjectHolder instanceObj = new ObjectHolder();   // instanceObj 存放到堆中
    
    void foo() {
        ObjectHolder localObj = new ObjectHolder();  // localObject 存放到 foo() 方法栈帧的局部变量表中
        System.out.println("done");
    }
}

staticObj 随着 Test 的类型信息存放到方法区,instanceObj 随着 Test 的对象实例存放在 Java 堆,localObj 则是存放在 foo() 方法栈帧的局部变量表中。三个对象的数据在内存中的地址都落在 Eden 区范围内,所以结论:只要是对象实例必然会在 Java 堆中分配。

2.5 方法区的垃圾回收

方法区中可以实现垃圾回收,只不过 《Java虚拟机规范》对方法区的约束非常宽松。提到过可以不要求虚拟机在方法区中实现垃圾收集。

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型(.class)。(对类型的卸载条件非常苛刻)

方法区内常量池之中主要存放的两大类常量:字面量和符号引用。

字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。

符号引用则属于编译原理方面的概念,包括下面三类常量:

➢1、类和接口的全限定名
➢2、字段的名称和描述符
➢3、方法的名称和描述符

HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。回收废弃常量与回收Java堆中的对象非常类似。对常量池的回收比较简单,只要没有引用了就回收。

判断一个类型是否属于“不再使用的类”的条件比较苛刻。需要同时满足三个条件:

1)该类的所有实例都已经被回收
2)加载该类的加载器已经被回收
3)该类对应的 java.lang.Class 对象没有在任何人地方被引用。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/746884.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号