第三章 垃圾收集器与内存分配策略
3.1 意义
- Java堆和方法区具有不确定性:一个接口的多个实现类、一个方法的不同条件分支需要的内存可能不一样。
- 程序运行起来才知道到底会创建什么对象,创建多少个对象。
- 动态分配内存和垃圾回收
- 排查内存泄漏和内存溢出时
- 当垃圾收集影响系统的并发量,需要手动调节
3.2 对象已死?
进行垃圾回收前,需要判断对象是否存活
3.2.1 引用计数算法
- 在对象中设置一个引用计数器,被一个地方引用就加一,引用失效减一
- 无法解决循环引用的问题
3.2.2 可达性分析算法
-
创建GC Roots作为起始节点集,每个正常存活的对象根据引用关系一定能到达根对象(引用链),不可达则被回收。
-
固定可做为GC Roots的对象包括:
- 虚拟机栈(局部变量表)中引用的对象,比如方法参数、局部变量、临时变量等
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象,比如字符串常量池里的引用
- 本地方法栈中JNI(Java Native Interface)引用的对象
- Java虚拟机内部的引用,比如基本数据类型对应的class对象,经常出现的异常对象(OOM),系统类加载器
- 被同步锁(synchronized关键字)持有的对象
- 反映Java虚拟机内部情况的JMX(一组规范)中MBean、JVMTI(一个构建java调试和分析工具的接口)中注册的回调、本地代码缓存等
- 根据垃圾收集器和回收内存区域的不同,会有其他对象加入(比如只针对堆中某一块内存垃圾回收,这块内存里的对象可能被其他地方的对象引用,也需要把其他地方的对象加入RCRoots)
3.2.3 再谈引用
为了凸显一部分可以根据内存紧缺程度选择性回收的对象,对引用的概念进行扩充,分为强引用、软引用、弱引用和虚引用,引用强度依次减弱。
- 强引用是最传统的“引用”,类似“Object obj = new Object()”。只要强引用关系存在,就不会被回收
- 软引用关联的对象,会存活到抛出内存溢出异常前的一次垃圾回收。
- 弱引用对象在下一次垃圾回收就会被回收
- 虚引用用于跟踪对象被垃圾回收的状态,虚引用本身不决定对象的生命周期。
3.2.4 生存还是死亡?
(finalize()方法,不推荐使用)
一个对象的真正死亡,会经历两次标记过程:第一次标记在可达性分析算法判定不可达时,第二次标记为没有必要执行finalize()方法。finalize方法是Java中对象进行清理操作的最后一次机会。它在对象即将被垃圾收集器回收时调用,允许对象释放资源,例如关闭文件或网络连接。
但是运行代价高昂,不确定性大,无法保证各个对象的调用顺序,不推荐使用。使用try-finally或者其他方式都可以做得更好。
3.2.5 回收方法区
主要回收两部分:废弃常量和不使用的类
- 废弃常量:没有任何对象引用该常量,且垃圾收集器判断有必要,就会回收
- 不使用的类,符合以下三个条件将被允许进行回收。:
- 该类所有的实例都被回收,即堆中不存在该类及其子类的实例
- 加载该类的类加载器已被回收
- 该类对应的
java.lang.Class
对象没有被引用,无法在任何地方通过反射访问该类的方法
3.3 垃圾收集算法
从如何判定对象消亡的角度,垃圾收集算法可以划分为“引用计数式垃圾收集”和“追踪式垃圾收集”两类。本节讲的算法都是追踪式垃圾收集,即追踪程序中的对象引用关系来确定可达性,回收不可达对象的内存。
3.3.1 分代收集理论
分代假说:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾收集的对象就越是难以消亡
- 跨代引用假说:跨代引用相对于同代引用只占少数
所以可以将Java堆根据对象的存活时间分区,如果一个区域大部分对象都是朝生夕灭的,回收时只需要关注其中少量存活的对象,然后把这些剩下存活的对象集中管理就可以减少时间开销并有效利用内存空间。所以一般会把堆分成两个部分,“新生代(Young/Nursery)”“老年代(Old/Tenured)”,新生代中垃圾回收后依旧存活的对象将会逐步晋升为老年代。
但是会存在跨代引用的情况,即新生代中对象有可能被老年代所引用,实际情况中,存在互相引用的对象是偏向于同时存活或同时消亡的(被老年代引用的对象在收集时会存活下来然后晋升到老年代),根据第三个假说,为了避免检查跨代引用时扫描整个老年代,可以建立一个记忆集,把老年代划分成若干小块并标识出哪块存在跨代引用,在发生新生代收集时,只有包含跨代引用的块会被加入到GC Roots扫描。
一些名词:
- 部分收集(Partial GC):
- 新生代收集(Minor GC/Young GC)
- 老年代收集(Major GC/Old GC):目前只有CMS收集器会单独收集老年代,会产生和整堆收集的混淆。
- 混合收集(Mixed GC):目前只有G1会有这种行为
- 整堆收集(Full GC):收集整个Java堆和方法区
3.3.2 标记-清除算法
-
分为标记和清除两个阶段,可以标记需要回收的对象,也可以标记不需要回收的对象。
-
最基础的收集算法
-
两个缺点:
-
执行效率不稳定,随对象数量增长而降低
-
清除后会产生大量不连续的内存碎片,会影响之后给大对象分配内存,导致提前触发另一次垃圾收集。
-
3.3.3 标记-复制算法
-
将内存按容量分为大小相等的两块,每次只使用一块,其中一块内存用完就把存活的对象复制到另一块,并清理这一块的所有内存。
-
好处是不需要考虑内存碎片,只需要在另一块内存中移动指针按顺序分配。
-
明显的缺点就是只能使用一半空间
-
这种收集算法优先考虑回收新生代,但是新生代绝大部分对象活不过第一轮收集,所以可以优化分区策略,“Appel式回收”,分为一块较大的Eden空间和两块较小的Survivor空间,每次只使用Eden和一块Survivor空间,垃圾回收时将存活的对象放到另一块Survivor空间中。一般Eden和一块Survivor的比例是8:1
-
分配担保:另一块Survivor不足以放入存活的对象时,将会直接放入老年代。
3.3.4 标记-整理算法
-
标记过程与标记-清除算法一样,只不过让存活的对象都向一端移动,然后清理边界以外的内存。移动过程会出现“Stop The World”
-
在关注吞吐量时,可以基于标记整理算法,在关注延迟时,可以基于标记清楚算法。因为移动对象会在内存回收时变复杂,不移动对象会在内存分配时变复杂。
-
CMS收集器在平时会使用标记清除算法,但是当空间碎片影响到大对象的分配时,就会采用一次标记整理算法。
3.4 HotSpot算法细节实现
3.4.1 根节点枚举
- 从GC Roots找引用链
- Stop The World,因为如果对象引用关系还在不停变化,会影响收集的准确性
- 虚拟机应当可以直接得到哪些地方记录了对象引用,比如OopMap,会记录栈和寄存器里引用的位置。
3.4.2 安全点
- 安全点(Safepoint)是一个特定的位置或时间点,在这个点上,所有的线程都可以安全地暂停,以便 JVM 可以执行某些全局操作,例如垃圾收集
- 设定安全点,要避免程序长时间执行而不进入安全点,可以设定在循环的开始或结束,方法调用入口或返回处,异常处理时
- 两种方法到达安全点:
- 抢先式中断:垃圾收集时,中断所有线程,如果有线程不在安全点就让他继续执行直到安全点再中断。几乎没有虚拟机用这种方法来相应GC
- 主动式中断:不直接对线程操作,而是设定一个标志位,线程自己不断检查这个标志,标志为真时,主动暂停线程等待垃圾收集。标志位包括安全点,也包括需要分配内存时。
3.4.3 安全区域
- 程序不执行时,是不会主动进入安全点的,所以引入安全区域(在这个区域内引用关系不会发生变化),在这个区域内任意地方都可以开始垃圾收集。
- 可以在线程阻塞时使用
- 实现原理
- 线程执行安全区域里的代码时,会标记自己进入了安全区域,表示不会影响要被GC的对象
- 离开时检查虚拟机是否完成了需要STW的操作,完成的话线程就继续执行否则一直等待
3.4.4 记忆集与卡表
- 记忆集记录了从非收集区域指向收集区域指针的集合
- 三种精度:
- 字长精度:记录精确到机器字长(决定指针长度),该字包含跨代指针
- 对象精度:精确到对象,该对象有字段包含跨代指针
- 卡精度(卡表,也是最常用的精度):精确到一块内存区域,该区域有对象含有跨代指针
- 卡表的形式可以是一个字节数组,数组中每一个元素对应一块内存(卡页),然后当这块内存存在跨代指针就把对应的元素值标为1,称为变脏。筛选变脏元素即可得到有跨代指针的内存块。
3.4.5 写屏障
- 写屏障可以看做对引用字段赋值(有其他分代区域对象引用当前区域对象)时的AOP,这个AOP实现了把卡表数组元素变脏。
- 产生Around通知,分为写前屏障和写后屏障,基本只用写后屏障来维护卡表状态。
- 虽然只要对引用更新就会调用写屏障,但这种开销比Minor GC时扫描整个老年代小得多
- “伪共享”问题:缓存系统以缓存行为单位存储,在多线程时,如果需要修改的卡表元素在同一缓存行,就会彼此影响。一个简单的方法就是不采用无条件的写屏障,增加一个判断语句。
3.4.6 并发的可达性分析
垃圾收集算法中都需要”标记“这一过程,在根节点枚举中已经找到了RC Roots,但是还需要根据RC Roots寻找链上的对象。但是寻找过程会根据堆的的增大而增加停顿时间,所以要尽量削减这部分时间。
-
引入三色标记
-
白色:表示对象尚未被垃圾收集器访问过。若分析结束后仍然是白色的对象,即代表不可达。
-
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过
-
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。表示安全存活。
-
同时满足以下两个条件就会把应该保留的对象清除,也就是误把黑色标位白色
- 赋值器插入了一条或多条由黑到白的新引用
- 赋值器删除了所有由灰色到该白色对象的直接或间接引用
-
-
两种解决方案(只需要破坏一个条件,且都通过写屏障实现):
- 增量更新:记录新插入的黑到白的引用,在并发扫描结束后以新插入引用的黑对象为根再扫描一遍
- 原始快照:当灰色要删除到白色的引用时,将这个引用关系记录下来,并发扫描结束之后再将记录下来的引用关系的灰色为根再扫描一次。
★3.5 经典垃圾收集器
包含了从JDK7到JDK11之前所包含的全部可用的垃圾收集器,注意所处的区域和彼此的搭配。
3.5.1 Serial收集器(新生代)
-
单线程,垃圾收集时,会Stop The World
-
简单高效,内存受限的环境里额外内存消耗最小
-
没有线程交互开销,单线程收集效率最高
3.5.2 ParNew收集器(新生代)
-
Serial收集器的多线程版本,使用多条线程并行进行垃圾收集,其余行为与Serial一致
-
除了Serial,只有他能与CMS配合工作
-
多核时垃圾收集效果比较好
3.5.3 Parallel Scavenge收集器(新生代)
- 基于标记复制算法,也并行收集
- 关注吞吐量的收集器,适合在后台运算不需要太多交互的任务
- 两个参数用于精确控制吞吐量:
-XX:MaxGCPauseMillis
:设置一个大于零的毫秒数,系统尽力使回收内存的时间不超过这个设置的时间-XX:GCTimeRatio
:参数的值是一个大于0小于100的整数,垃圾收集时间占总时间的比率。比如参数设置为19,允许的最大垃圾收集时间占总时间的1/(1+19)=5%。默认为99,即1%。
- 还有一个参数:
-XX:+UseAdaptiveSizePolicy
,开关参数,开启后系统自适应调节新生代大小,Eden与Survivor区的比例,晋升老年代大小等细节参数。
3.5.4 Serial Old收集器(老年代)
- 单线程收集器,使用标记整理算法。
- 主要供客户端使用
3.5.5 Parallel Old收集器(老年代)
- 多线程并发收集,基于标记整理算法
- 与Parallel Scanvenge收集器配合使用,注重吞吐量
3.5.6 CMS收集器(老年代)
- 以获取最短回收停顿时间为目标,提高用户交互体验
- 基于标记清除算法
- 四个步骤
- 初始标记(需要STW):标记与GC Roots直接关联的对象
- 并发标记(不需要STW):从与GC Roots直接关联的对象开始遍历整个对线图,与垃圾收集并发执行
- 重新标记(需要STW):修正并发标记期间产生的标记变动的对象的标记记录(使用增量更新)
- 并发清除(不需要STW):清理死亡的对象,不需要移动存活对象,可以与用户线程并发执行
- 优点:并发收集,低停顿
- 三个缺点:
- 很吃处理器资源,处理器核心数量不足4个时,对用户线程影响比较大,应用程序速度会变慢。
- 在并发标记和并发清除阶段用户线程还在运行,还会产生浮动垃圾,CMS在这一次清理过程无法清理,只能留给下一次。同样,还需要在垃圾收集时给用户线程分配内存,所以不能等到老年代满了再收集,需要预留内存给用户线程,如果预留内存不足以分配新的对象,会产生“并发失败”导致暂停用户线程并且临时将Serial Old启动来收集老年代。
- 基于标记清除算法,会产生大量碎片,没有足够空间给大对象,提前触发Full GC。
3.5.7 Garbage First收集器(面向堆内存的任何部分)
- 面向服务端,平衡吞吐量和延迟,可以由用户制定期望的停顿时间。
- 面向局部收集的设计思路,衡量标准由分代转到哪块内存中存放垃圾多
- 将Java堆划分为多个大小相等的独立区域(region),每个Region可以有自己的新生代和老年代,不同Region有不同的策略来收集。
- Humongous区域来存储超过Region二分之一的大对象,超级大对象由几个连续的Humongous区域来存储
- 将Region作为单次回收的最小单元,并优先处理回收价值大的一些Region
- G1的记忆集是哈希表,Key是 别的Region的起始地址,Value是一个集合存储卡表的索引号。
- G1使用原始快照(STAB)处理并发标记阶段中用户线程出现改变对象引用关系的情况,使用了写前屏障跟踪指针变化情况。
- 再并发回收过程中还会有新对象创建,G1使用两个指针(TAMS Top at Mark Start),把Region中的一部分空间划分出来用于创建新对象,且新对象必须在两个指针位置以上,默认把新对象认为是存活对象不回收。
- G1通过记录回收耗时、每个Region记忆集里脏卡数量等可计算值,然后计算平均值等信息来决定哪些Region组成回收集。
- 过程可分为四步:
- 初始标记(需要STW,但耗时很短):标记GC Roots直接关联的对象,修改TAMS的值。
- 并发标记(不需要STW):从标记的对象递归扫描整个堆里的对象图,扫描完以后再处理STAB记录的有变动的对象
- 最终标记(需要STW):处理并发结束后扔遗留的STAB记录
- 筛选回收(需要STW,涉及对象移动):更新Region统计数据,并根据回收价值排序,根据用户期望停顿时间自由选择任意多个Region构成回收集,把决定存活的对象复制到空的Region,在清理掉整个旧的Region空间。
- 优点是不会产生内存碎片,因为整体看是标记整理算法,局部是标记复制算法,都不会产生。
- 缺点是G1的卡表比较复杂,记忆集占用很多堆空间。且写屏障更复杂,不仅写后屏障就因为卡表复杂而更繁琐,还要用写前屏障实现SATB,导致耗费更多资源,以至于需要把写屏障异步处理。
3.6 低延迟垃圾收集器
硬件提高有助于吞吐量,但反而对延迟不利,延迟变成了垃圾收集器最重视的指标。CMS和G1之前的所有垃圾收集器都需要STW,CMS使用标记清除算法,难逃碎片堆积导致的STW出现,G1虽然停顿时间不会过长,但也会暂停。
3.6.1 Shenandoah收集器
- Shenandoah不仅进行并发的垃圾标记,还并发进行对象清理后的整理。像是G1的继承者但有三点不同
- 回收阶段与用户线程并发
- 默认不使用分代收集
- 记忆集改为“连接矩阵”,连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记
- 九个阶段:
- 初始标记(需要STW):标记与GC Roots直接关联的对象
- 并发标记(不需要STW):遍历对象图,标记出全部可达的对象
- 最终标记(需要STW):处理剩余的SATB,并统计回收价值高的Region,构成回收集
- 并发清理(不需要STW):清理整个区域没有存活对象的Region
- 并发回收(不需要STW):把存活对象复制一份到空Region中,通过读屏障和转发指针
- 初始引用更新(需要STW):建立一个集合点,来确保并发回收完成任务。
- 并发引用更新(不需要STW):把指向旧对象的引用更新到新地址。
- 最终引用更新(需要STW):修正存在于GC Roots中的引用
- 并发清理(不需要STW):经过以上步骤,回收集中的Region已无存活对象,直接回收这些Region。
-
Brooks Pointer: 转发指针,在原有对象布局的结构的最前面加一个新的引用,指向自己,当对象有了新副本,该指针指向新副本,将所有该对象的访问转发到新的副本上。
会出现多线程竞争问题,所以要对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功,要覆盖全部对象的访问需要使用读屏障,读屏障性能开销太大,改为引用访问屏障,只拦截对引用的访问。
3.6.2 ZGC收集器
-
基于Region的堆内存布局,但Region具有动态性:动态创建和销毁以及动态区域大小。x64中,Region分为三种:
- 小型Region:容量固定为2MB,用于放置小于256KB的小对象。
- 中型Region:容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
- 大型Region:容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,不会被重分配。
-
染色指针:将少量信息直接存储到指针上的技术。将64位指针的剩余46位提取4位存储四个标志信息。
- 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,尤其是写屏障
-
GC的运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段
3.7 选择合适的垃圾收集器
★3.7.1 Epsilon收集器
运行负载极小、没有任何回收行为。适合只要运行数分钟甚至数秒的应用,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出。
★3.7.2 收集器的权衡
需要考虑:
- 应用程序的关注点:如果是数据分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点;如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点;而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的
- 运行应用的基础设施:系统架构,处理器数量,内存大小,操作系统
- JDK的发行商和版本
3.7.3 虚拟机及垃圾收集日志
-Xlog[:[selector][:[output][:[decorators][:output-options]]]]
-
由以上的指令控制,最重要的参数是选择器selector,由标签和日志级别共同组成。垃圾收集器的标签为“gc”,日志级别从低到高,共有Trace,Debug,Info,Warning,Error,Off六种级别,日志级别决定了输出信息的详细程度,默认级别为Info。
-
还可以使用修饰器(Decorator)来要求每行日志输出都附加上额外的内容,如果不指定,默认值是uptime、level、tags这三个,支持附加在日志行上的信息包括:
·time:当前日期和时间。
·uptime:虚拟机启动到现在经过的时间,以秒为单位。
·timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()的输出。
·uptimemillis:虚拟机启动到现在经过的毫秒数。
·timenanos:当前时间的纳秒数,相当于System.nanoTime()的输出。
·uptimenanos:虚拟机启动到现在经过的纳秒数。
·pid:进程ID。
·tid:线程ID。
·level:日志级别。
·tags:日志输出的标签集。
-
几个例子:
- 查看GC基本信息,在JDK 9之前使用
-XX:+PrintGC
,JDK 9后使用-Xlog:gc
: - 查看GC详细信息,在JDK 9之前使用-
XX:+PrintGCDetails
,在JDK 9之后使用-X-log:gc*
- 查看GC前后的堆、方法区可用容量变化,在JDK 9之前使用
-XX:+PrintHeapAtGC
,JDK 9之后使用-Xlog:gc+heap=debug
- 查看GC过程中用户线程并发时间以及停顿的时间,在JDK 9之前使用
-XX:+Print-GCApplicationConcurrentTime
以及-XX:+PrintGCApplicationStoppedTime
,JDK 9之后使用-Xlog:safepoint
- 查看收集器Ergonomics机制(自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收集器开始支持)自动调节的相关信息。在JDK 9之前使用
-XX:+PrintAdaptive-SizePolicy
,JDK 9之后使用-Xlog:gc+ergo*=trace
- 查看熬过收集后剩余对象的年龄分布信息,在JDK 9前使用
-XX:+PrintTenuring-Distribution
,JDK 9之后使用-Xlog:gc+age=trace
- 查看GC基本信息,在JDK 9之前使用
★3.8 实战:内存分配与回收策略
对象内存分配基本都是在堆上分配,经典分代情况下,新生对象会分配在新生代,少部分大对象会直接分配到老年代。分配规则不固定,取决于虚拟机设定和垃圾收集器。主要关注分析方法
以下使用Serial加Serial Old测试
3.8.1 对象优先在Eden分配
大多数情况,对象在Eden区分配,Eden没有足够空间会发生一次Minor GC
3.8.2 大对象直接进入老年代
- 大对象就是指需要大量连续内存空间的Java对象,典型的大对象是很长的字符串或者元素数量很庞大的数组
- 避免大对象的原因:容易提前触发垃圾收集,复制时产生大量开销
- HotSpot虚拟机提供了
-XX:PretenureSizeThreshold
参数(只对Serial和ParNew有效),指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。
3.8.3 长期存活的对象进入老年代
虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold
设置
3.8.4 动态对象年龄判定
HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
3.8.5 空间分配担保
在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure
参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的。如果小于,或者-XX:HandlePromotionFailure
设置不允许冒险,那这时就要改为进行一次Full GC。
冒险:把Survivor无法容纳的对象直接送入老年代,前提是老年代有空间容纳这些对象,但不确定被容纳的对象其中有多少能在回收中存活下来。只能取历史平均值来赌概率,但可以避免Full GC过于频繁。
3.9 小结
垃圾收集器在许多场景中都是影响系统停顿时间和吞吐能力的重要因素之一,虚拟机之所以提供多种不同的收集器以及大量的调节参数,就是因为只有根据实际应用需求、实现方式选择最优的收集方式才能获取最好的性能。没有固定收集器、参数组合,没有最优的调优方法,虚拟机也就没有什么必然的内存回收行为。因此学习虚拟机内存知识,如果要到实践调优阶段,必须了解每个具体收集器的行为、优势劣势、调节参数。