JVM调优篇-01
1.如何判断对象是否存活的?
主要通过以下两种方法:
- 引用计数算法:无法解决相互引用问题
- 可达性分析算法
可达性分析算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
可达性分析算法是需要一个理论上的前提的:该算法的全过程都需要基于一个能保障一致性的快照中才能够分析,这意味着必须全程冻结用户线程的运行。
并发标记会产生浮动垃圾和出现对象消失的问题,都可以解决.
2.GC Roots 是什么?
在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:
- 在
虚拟机栈
中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。 - 在
方法区
中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。 - 在
方法区
中常量引用的对象,譬如字符串常量池(String Table)里的引用。 - 在
本地方法栈
中 JNI(即通常所说的 Native 方法)引用的对象。
Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、Out Of MemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized 关键字)持有的对象。
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等
3.GC Roots 举例
a.java虚拟机栈中的引用的对象:
- 我们知道,每个方法执行的时候,jvm 都会创建一个相应的栈帧
- 栈帧包括(操作数栈、局部变量表、运行时常量池的引用)
- 栈帧中包含这个方法内部使用的所有对象的引用(这就是虚拟机栈中的引用对象)
- 一旦该方法执行完后,该栈帧就会从虚拟机栈中弹出,这样一来这些局部(临时)对象的引用也就不存在了,或者说没有任何 GCRoot 指向这些临时对象,所以这些对象在下一次 gc 时就会被回收掉
b.方法区中的类静态属性引用的对象(一般指被static修饰的对象,加载类的时候就加载到内存中。):
第一种写法:
方法区中的类静态属性引用的对象
- 因为该类是属于 JvmTest 类的全局属性(类属性),所以会存在于方法区中,每个线程共享
- 这种写法一般会用在单例模式的饿汉模式中
- 所以此类对象可以作为 GCRoot 对象
- 注意:不止这一种写法,还可以下面那样写
- private static User user = new User();
第二种写法:
方法区中的类静态属性引用的对象
- 这种写法一般会用在单例模式的懒汉模式中
- 所以此类对象也可以作为 GCRoot 对象
- private static User user1 ;
c.方法区中的常量引用的对象:
- 因为该类是属于 JvmTest 类的全局属性(类属性),所以会存在于方法区中,每个线程共享
- 该常量属性被 final 修饰,再第一次赋值之后不允许更改
- 所以此对象可以作为 GCRoot 对象
4.说说根节点枚举?
迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的 CMS 、 G1 、ZGC 等收集器,枚举根节点时也是必须要停顿的。
由于目前主流 Java 虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机是有办法直接得到哪些地方存放着对象引用的。在 HotSpot 的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找。
5.java 中对象的引用类型有什么?
在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。
- 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 版之后提供了 SoftReference 类来实现软引用。
- 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 版之后提供了 WeakReference 类来实现弱引用。
- 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。
6.如果对象不可达,会立即回收吗?
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
- 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记
- 随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。
- 假如对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。会真正回收这个对象.
如果这个对象被判定为确有必要执行 finalize()方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize()方法。
7.方法区有垃圾回收吗?
在《Java 虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,方法区垃圾收集的“性价比”通常也是比较低的:在 Java 堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收 70%至 99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型(类型卸载)。
8.什么是无用的类?
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
9.垃圾收集的算法有哪几种?
标记-清除算法:
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。
它的主要缺点有两个:第一个是执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
标记-复制算法:
常被简称为复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,也称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费。
HotSpot 虚拟机的 Serial 、ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor 。发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。 HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8 ∶1
标记-整理算法:
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存.
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。
分代收集算法:
分为新生代和老年代,新生代使用复制算法,老年代使用标记整理算法.
10.什么是 jvm 的安全点?
JVM 的安全点(Safe Point)是指在程序执行过程中,JVM 会选取一些特定位置作为安全点,当线程到达这些位置时,JVM 会确保线程的栈和堆处于稳定状态,使得线程能够被安全地挂起。安全点的存在是为了支持 Java 的线程安全机制和垃圾回收。
在 JVM 中,线程在运行时,可能存在以下情况:
- 垃圾回收:JVM 的垃圾回收器需要检查和清理不再使用的对象。在执行垃圾回收时,需要暂停正在运行的线程,否则可能会导致垃圾回收器的工作出现问题。
- 线程安全机制:Java 中的线程安全机制涉及到一些原子操作、锁机制和同步操作。当线程进行这些操作时,需要保证线程处于稳定状态,防止数据不一致或竞态条件等问题。
为了实现以上功能,JVM 会在适当的时机,将线程挂起到安全点。JVM 会将安全点放在一些特定位置,例如:
- 方法调用:在方法调用开始和结束时。
- 循环跳转:在循环的跳转点。
- 同步点:在进入和退出同步块时。
- 异常抛出:在抛出异常时。
当线程到达安全点时,JVM 会暂停线程的执行,并确保线程的栈和堆处于一致状态,以便进行垃圾回收或执行线程安全机制。一旦操作完成,线程会被唤醒,继续执行。
11.如何让所有线程都跑到最近的安全点?
抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)是两种不同的线程调度方式,用于控制线程在执行过程中的暂停和恢复。
抢先式中断(Preemptive Suspension):
- 抢先式中断是由操作系统或线程调度器来决定何时暂停当前线程的执行,并切换到另一个线程执行。
- 在抢先式中断中,线程没有主动权,线程的暂停和恢复是由外部控制的。当一个线程运行时间片用完、有更高优先级的线程进入、或者出现了 I/O 等待等情况时,操作系统会强制中断当前线程的执行,将 CPU 资源分配给其他线程。
- 抢先式中断保证了高优先级的任务优先执行,但也可能导致上下文切换的开销。
主动式中断(Voluntary Suspension):
- 主动式中断是由线程自己决定何时暂停自己的执行,并让出 CPU 资源给其他线程执行。
- 在主动式中断中,线程具有主动权,线程可以根据自己的需要选择在何时主动挂起,并通过相应的方式(如
Thread.sleep()
、Object.wait()
等)暂停自己的执行。 - 主动式中断适用于一些需要控制自己执行频率或让出 CPU 资源给其他线程的情况,能够减少不必要的上下文切换。
总结:
- 抢先式中断是由操作系统或线程调度器决定线程何时暂停执行,线程没有主动权,通常用于保证高优先级任务优先执行。
- 主动式中断是由线程自己决定何时暂停执行,线程具有主动权,通常用于线程之间的协调和资源控制。
12.程序不执行的时候如何到达安全点?
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
13.垃圾回收是如何处理跨代引用问题的?
跨代引用举例:假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。
并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如 G1 、ZGC 收集器,都会面临相同的问题, JVM 为了用尽量少的资源消耗解决跨代引用下的垃圾回收问题,引入了记忆集
。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构
。
在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。目前最常用的一种记忆集实现形式种称为“卡表”
,卡表中的每个记录精确到一块内存区域(每块内存区域称之为卡页),该区域内有对象含有跨代指针。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0 。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。
14.说说垃圾回收的三色标记理论?
标记阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器。
-
白色:
表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。 -
黑色:
表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。 -
灰色:
表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
15.说说什么是对象消失?
对象消失的问题,即原本应该是黑色的对象被误标为白色
当且仅当以下两个条件同时满足时,会产生“对象消失”的问题:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)
增量更新:
要破坏的是第一个条件,当黑色对象
插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了(因为新加入的白色节点未被扫描过)。
原始快照:
要破坏的是第二个条件,当灰色对象
要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照
来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如, CMS 是基于增量更新来做并发标记的, G1 则是用原始快照实现的.
-
增量更新用的是写后屏障(Post-Write Barrier),记录了所有新增的引用关系。
-
原始快照用的是写前屏障(Pre-Write Barrier),将所有即将被删除的引用关系的旧引用记录下来。
16.为什么要赋值为 null?
变量 d 重用了变量 a 的 Slot,这样就节约了内存空间。
placeHolder
没有被回收的原因:System.gc();触发 GC 时,main()方法的运行时栈中,还存在有对args
和placeHolder
的引用,GC 判断这两个对象都是存活的,不进行回收。 也就是说,代码在离开 if 后,虽然已经离开了placeHolder
的作用域,但在此之后,没有任何对运行时栈的读写,placeHolder
所在的索引
还没有被其他变量重用
,所以GC
判断其为存活。
public static void main(String[] args) {
if (true) {
byte[] placeHolder = new byte[64 * 1024 * 1024];
System.out.println(placeHolder.length / 1024);
placeHolder = null;//关键
}
System.gc();
}
- 点赞
- 收藏
- 关注作者
评论(0)