JVM

类加载器

  • 与普通程序不同的是,Java 程序(class 文件)并不是本地的可执行程序。当运行 Java 程序时,首先运行 JVM(Java 虚拟机),然后再把 Java class 文件加载到 JVM 里头运行,负责加载 Java class 的这部分叫做 Class Loader。也就是类加载器。
  • 某个特定的 类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。
    委托机制的意义 — 防止内存中出现多份同样的字节码
    如果不用委托而是自己加载自己的,那么类 A 就会加载一份 System 字节码,然后类 B 又会加载一份 System 字节码,这样内存中就出现了两份 System 字节码。

java 虚拟机的特性

一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入 Java 语言虚拟机后,Java 语言在不同平台上运行时不需要重新编译。Java 语言使用模式 Java 虚拟机屏蔽了与具体平台相关的信息,使得 Java 语言编译程序只需生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java 虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。

虚拟机里面

在虚拟机有栈、堆和方法区。

unknown_filename

  • 线程共享的:堆、方法区

  • 不共享的:栈、程序计数器(代码执行的行号)

  • 方法区:类信息、常量池、静态变量,即 类被编译后的数据。系统自动分配和回收。

  • 栈区:存储运行方法的形参、局部变量、返回值、对象的引用。由系统自动分配和回收。

  • 堆区:new 一个对象的引用或地址存储在栈区,指向该对象存储在堆区中的真实数据。在 Java 虚拟机(JVM)中,成员变量存储在对象的堆内存中。堆内存是用于存储对象实例的区域,而成员变量就是对象实例的一部分。

成员变量(静态的也一样)共享引起的安全问题,局部变量的话就不会

如何理解成员变量在堆内,局部变量在栈内?_变量定义时在栈中还是堆中-CSDN博客

![[Android内存优化#静态方法]]

unknown_filename.10

程序计数器 (Program Counter Register):

一小块内存空间,当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧 (Stack Frame[1])用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、 double)、对象引用(方法区是编译后)

  • 栈区:存储运行方法的形参、局部变量、返回值、对象的引用。由系统自动分配和回收。
  • 栈是一种线形集合,其添加和删除元素的操作应在同一段完成。栈按照后进先出的方式进行处理。
  • 存在栈中的数据可以共享。假设我们同时定义:  int a = 3;  int b = 3;  编译器先处理 int a = 3;首先它会在栈中创建一个变量为 a 的引用,然后查找有没有字面值为3的地址,如果没找到,就开辟一个存放3这个字面值的地址,然后将 a 指向3的地址。接着处理 int b = 3;在创建完 b 的引用变量后,由于在栈中已经有3这个字面值,便将 b 直接指向3的地址。这样,就出现了 a 与 b 同时均指向3的情况。
  • StackOverflowError
  • 方法压入和弹出
  • 连续
  1. 引用类型变量总是被分配到“堆”上。不论是成员变量还是局部
  2. 基础类型总是分配到它声明的地方:成员变量在堆内存里,局部变量在栈内存里。

比如
void func (){
int a = 3;
}
这自然是存在栈里的。局部方法。

class Test{
int a = 3;
}
这就肯定是随对象放到堆里的。

  • 用来存放对象的,几乎所有对象都放在这里,被线程共享的,或者说是被栈共享的
  • 栈空间操作起来最快但是栈很小,通常大量的对象都是放在堆空间,理论上整个内存没有被其他进程使用的空间甚至硬盘上的虚拟内存都可以被当成堆空间来使用(Android 中不适用)。
  • 堆又可以分为新生代和老年代,实际还有一个区域叫永久代,但是 jdk1.7已经去永久代了,所以可以当作没有,永久代是当 jvm 启动时就存放的 JDK 自身的类和接口数据,关闭则释放。
  • oom
  • 新生代:主要是用来存放新生的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。

方法区

类信息、常量池、静态变量,即 类被编译后的数据
程序中的字面量(literal)如直接书写的100、hello 和常量都是放在常量池中

类信息
类型全限定名
类型是类类型还是接口类型。
类型的访问修饰符(public、abstract 或 final 的某个子集)。
类型的常量池。
字段名和属性
方法名和属性
除了常量以外的所有类(静态)变量。

字面值
值型的字面值是最常见的,字符串字面值可以算是一种,当然也可以把特殊的 null 当做字面值。字面值大体上可以分为整型字面值、浮点字面值、字符和字符串字面值、特殊字面值。

常量池的好处
常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。
(1)节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间。
(2)节省运行时间:比较字符串时,==比 equals () 快。对于两个引用变量,只用==判断引用是否相等,也就可以判断实际值是否相等。

1
2
3
4
5
6
7
String s0=”kvill”; 
String s1=”kvill”;
String s2=”kv” + “ill”;
System.out.println( s0==s1 );
System.out.println( s0==s2 );
结果为:
true true

s0和 s1中的”kvill”都是字符串常量,它们在编译期就被确定了,存放在常量池

用 new String () 创建的字符串不是常量,不能在编译期就确定,所以 new String () 创建的字符串不放入常量池中,它们有自己的地址空间。存放在堆

1
2
3
4
5
6
7
8
9
10
11
String s0=”kvill”; 
String s1=new String(”kvill”);
String s2=”kv” + new String(“ill”);
System.out.println( s0==s1 );
System.out.println( s0==s2 );
System.out.println( s1==s2 );
结果为: false false false


String str = new String("hello");

上面的语句中变量 str 放在栈上,用 new 创建出来的字符串对象放在堆上,而“hello”这个字面量放在静态区。

GC Roots 算法判定一个对象需要被回收
GC Root 的分类中:

  1. 运行中的线程,thread 就是一种 gcroot, gcroot->asyntask->activity (泄露)
  2. 静态变量、静态对象也是一种 gcroot, 例如单例 (长生命周期)
1
2
3
4
5
Utils.leakviews.add(view);
public class Utils {
public static LinkedList<View> leakviews =new LinkedList<>( );
}

  1. 来自本地代码的引用。JNI 的引用是指那些本地代码执行 JNI 调用过程中所创建出来的 Java 对象
  2. 方法区中常量引用的对象。

深入理解 jvm

垃圾收集器与内存分配策略

哪些内存需要回收?
什么时候回收?
如何回收?

  • Java 内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由 JIT 编译器进行一些优化,但在本章基于概念模型的讨论中,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区
    域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
  • 而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回
    收都是动态的,垃圾收集器所关注的是这部分内存。

对象存活判定算法

引用计数算法
  • 给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
  • 但是,至少主流的 Java 虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。
  • 举个简单的例子,请看代码清单3-1中的 testGC()方法:对象 objA 和 objB 都有字段 instance,赋值令 objA. instance=objB 及 objB. instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数算法无法通知 GC 收集器回收它们。
    虚拟机并不是通过引用计数算法来判断对象是否存活的。
可达性分析算法

基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(ReferenceChain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的

unknown_filename.3
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程
如果对象在进行可达性分析后发现没有与 GCRoots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是对象是否有必要执行 finalize()方法。
当对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的 finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。

finalize()能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时,所以笔者建议大家完全可以忘掉 Java 语言中有这个方法的存在。

在 Java 语言中,可作为 GC Roots 的对象包括下面几种

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。?

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,
也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。

  1. 方法区中类静态属性引用的对象。
  2. 方法区中常量引用的对象。
  3. 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

再谈引用
在 JDK 1.2以前,Java 中的引用的定义很传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。
在 JDK 1.2之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次
逐渐减弱。

  • 强引用就是指在程序代码之中普遍存在的,类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

垃圾收集算法

标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象 (GC Roots 不可达的对象),在标记完成后统一回收所有被标记的对象。

这样做的好处:
数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。
为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。

它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

unknown_filename.4

复制算法

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。
unknown_filename.5

  • 新生代中的对象98%是“朝生夕死”的,所以并不需要按照 1:1的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor [1] 。
  • 当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。
  • HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。
  • 当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

标记-整理算法

  • 复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
  • 标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

unknown_filename.6

分代收集算法

  • 一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

标记-清除算法 (会产生空闲内存碎片)
标记-整理算法(防止产生内存碎片)
复制算法(效率最高,但是内存利用率低)
JVM 中新生代使用复制算法,老年代使用标记整理算法

我的理解

  • 新生代和老年代分为1:2,新生代分为 Eden和俩个Survivor(
    /sərˈvaɪvər/ ) 区。from 区和 to 区,8:1:1
  • 新创建出来的对象放在 Eden 区,Eden 满了以后,会使用一次小的 gc(minor GC ),使用复制算法,把存活的对象放在一个 Survivor,然后把 Eden 区清空。
  • 如果俩个都满了,会把存活的对象当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。会把存活的对象年龄+1
    • 在 age 达到一定值时会移动到老年代。
    • 在 minor GC 时,存活对象大于 to 区域,那么会直接进入老年代
  • 在新生代中,每次垃圾收集时都发现有大批对象销毁,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
  • 而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
    unknown_filename.7
    大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的 byte[]数组就是典型的大对象)
长期存活的对象将进入老年代

关于跨代引用
为了防止不能确定新生代的对象是否被老年代的对象引用而需要进行 full GC 。
通过 card table 将老年代分成若干个区域,所以在 minor GC 时只需要对表中记录的老年代区域进行扫描就可以了。
unknown_filename.8

Minor GC 和 Full GC 有什么不一样吗?
新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快
老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过
程)。Major GC 的速度一般会比 Minor GC 慢10倍以上。

第一章

  • 如果线程请求的栈深度大于虚拟机所允许的深度,就会抛出 stackoverflow 异常。
  • 堆划分的目的是为了更好的回收内存,或者更快的分配内存
  • string. intern,运行时也可以将新的常量放在池里

String. intern()是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。

  • 直接内存:nio,基于通道和缓冲区,它可以使用 native 函数库直接分配堆外的数据。

对象

  • 虚拟机遇到一条 new 指令时,首先会检查这个指令的参数是否能在常量池里定位到这个类符号的引用,并且检查这个符号引用的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

对象的内存布局

  • 对象在内存中存储的布局可以分为3个部分:对象头、实例数据、对齐填充。
  • 对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码 (HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,Mark Word
  • 第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位
建立对象是为了使用对象,我们的 Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在 Java 虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。

如果使用句柄访问的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
unknown_filename.1
如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址
unknown_filename.2
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。就本书讨论的主要虚拟机 Sun HotSpot 而言,它是使用第二种方式进行对象访问的

模拟溢出

堆溢出
当出现 Java 堆内存溢出时,异常堆
栈信息“java. lang. OutOfMemoryError”会跟着进一步提示“Java heap space”

1
2
3
4
java. lang. OutOfMemoryError:Java heap space
Dumping heap to java_pid3404. hprof……
Heap dump file created[22045981 bytes in 0.663 secs

Eclipse Memory Analyzer
如果是内存泄露,可进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到泄露对象是
通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收它们的。掌握了泄露对象的类型信息及 GC Roots 引用链的信息,就可以比较准确地定位出泄露代码的位置。

StackOverflowError
在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是 StackOverflowError 异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class JavaVMStackSOF{
private int stackLength=1
public void stackLeak(){
stackLength++;
stackLeak();
}
public static void main(String[]args)throws Throwable{
JavaVMStackSOF oom=new JavaVMStackSOF();
try{
oom. stackLeak();
}catch(Throwable e){
System. out. println"stack length:"+oom. stackLength);
throw e;
}
}
}

方法区和运行时常量池溢出
运行时常量池溢出,在 OutOfMemoryError 后面跟随的提示信息是“PermGen space”,说明运行时常量池属于方法区(HotSpot 虚拟机中的永久代)的一部分。

1
2
3
4
5
6
7
8
9
10
public class RuntimeConstantPoolOOM{
public static void main(String[]args){
public static void main(String[]args){
String str1=new StringBuilder"计算机"). append"软件"). toString();
System. out. println(str1. intern()==str1);
String str2=new StringBuilder"ja"). append"va"). toString();
System. out. println(str2. intern()==str2);
}
}
}

这段代码在 JDK 1.6中运行,会得到两个 false,而在 JDK 1.7中运行,会得到一个 true 和一个 false
JDK 1.7(以及部分其他虚拟机,例如 JRockit)的 intern()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此 intern()返回的引用和由 StringBuilder 创建的那个字符串实例是同一个
对 str2比较返回 false 是因为“java”这个字符串在执行 StringBuilder. toString()之前已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回 true。

理解 GC 日志

1
2
3
4
5
6

33.125:[GC[DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs]
1 0 0.6 6 7:[F u l l G C[T e n u r e d:0 K->2 1 0 K(1 0 2 4 0 K),0.0 1 4 9 1 4 2 s e c s]4603K->
210K19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]


最前面的数字“33.125:”和“100.667:”代表了 GC 发生的时间,这个数字的含义是从 Java 虚拟机启动以来经过的秒数。

接下来的“[DefNew”、“[Tenured”、“[Perm”表示 GC 发生的区域,这里显示的区域名称与使用的 GC 收集器是密切相关的,例如上面样例所使用的 Serial 收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。如果是 ParNew 收集器,新生代名称就会变为“[ParNew”,意为“Parallel NewGeneration”。如果采用 Parallel Scavenge 收集器,那它配套的新生代称为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定的。

后面方括号内部的“3324K->152K(3712K)”含义是“GC 前该内存区域已使用容量->GC 后该内存区域已使用容量(该内存区域总容量)”。而在方括号之外的“3324K->152K(11904K)”表示“GC 前
Java 堆已使用容量->GC 后 Java 堆已使用容量(Java 堆总容量)”。
再往后,“0.0025925 secs”表示该内存区域 GC 所占用的时间,单位是秒。


JVM
http://peiniwan.github.io/2024/04/e9909417ec3a.html
作者
六月的雨
发布于
2024年4月6日
许可协议