JVM
类加载器
这里我给搞丢了 笔记 直接背八股吧
JVM内存结构
JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。
Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。
- 线程私有的运行时数据区: 程序计数器、Java 虚拟机栈、本地方法栈。
- 线程共享的运行时数据区:Java 堆、方法区。
简述程序计数器
程序计数器表示当前线程所执行的字节码的行号指示器。
程序计数器不会产生StackOverflowError和OutOfMemoryError。
简述虚拟机栈
Java 虚拟机栈用来描述 Java 方法执行的内存模型。线程创建时就会分配一个栈空间,线程结束后栈空间被回收。
栈中元素用于支持虚拟机进行方法调用,每个方法在执行时都会创建一个栈帧存储方法的局部变量表、操作栈、动态链接和返回地址等信息。
虚拟机栈会产生两类异常:
StackOverflowError:线程请求的栈深度大于虚拟机允许的深度抛出。
OutOfMemoryError:如果 JVM 栈容量可以动态扩展,虚拟机栈占用内存超出抛出。
简述本地方法栈
本地方法栈与虚拟机栈作用相似,不同的是虚拟机栈为虚拟机执行 Java 方法服务,本地方法栈为本地方法服务。可以将虚拟机栈看作普通的java函数对应的内存模型,本地方法栈看作由native关键词修饰的函数对应的内存模型。
本地方法栈会产生两类异常:
StackOverflowError:线程请求的栈深度大于虚拟机允许的深度抛出。
OutOfMemoryError:如果 JVM 栈容量可以动态扩展,虚拟机栈占用内存超出抛出。
简述JVM中的堆
堆主要作用是存放对象实例,Java 里几乎所有对象实例都在堆上分配内存,堆也是内存管理中最大的一块。Java的垃圾回收主要就是针对堆这一区域进行。 可通过 -Xms 和 -Xmx 设置堆的最小和最大容量。
堆会抛出 OutOfMemoryError异常。
简述方法区
方法区用于存储被虚拟机加载的类信息、常量、静态变量等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
简述运行时常量池
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。运行时常量池存放常量池表,用于存放编译器生成的各种字面量与符号引用。
- Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。
- 运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是 String.intern()方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
为什么需要常量池?
一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。
简述直接内存
直接内存也称为堆外内存,就是把内存对象分配在JVM堆外的内存区域。这部分内存不是虚拟机管理,而是由操作系统来管理。 Java通过DriectByteBuffer对其进行操作,避免了在 Java 堆和 Native堆来回复制数据。
简述Java创建对象的过程
- 检查该指令的参数能否在常量池中定位到一个类的符号引用,并检查引用代表的类是否已被加载、解析和初始化,如果没有就先执行类加载。
- 通过检查通过后虚拟机将为新生对象分配内存。
- 完成内存分配后虚拟机将成员变量设为零值
- 设置对象头,包括哈希码、GC 信息、锁信息、对象所属类的类元信息等。
- 执行 init 方法,初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
垃圾回收
- 引用计数法
- 通过在对象头中分配一个空间来保存该对象被引用的次数。如果该对象被其它对象引用,则它的引用计数加 1,如果删除对该对象的引用,那么它的引用计数就减 1,当该对象的引用计数为 0 时,那么该对象就会被回收。
- 引用计数法无法解决循环依赖的问题。java没有选择使用,c++的shared_ptr使用了。
- 可达性分析
- 通过GC roots作为起点,然后向下搜索,搜索走过的路径被称为 Reference Chain(引用链),
当一个对象到 GC Roots 之间没有任何引用相连时,即从 GC Roots 到该对象节点不可达,则证明该对象是需要垃圾收集的。 - java中,GC roots包括以下几点:
- 虚拟机栈中的引用(方法的参数、局部变量等)。
- 本地方法栈中 JNI (Java Native Interface)的引用。
- 当调用 Java 方法时,虚拟机会创建一个栈帧并压入虚拟机栈,而当它调用本地方法时,虚拟机会通过动态链接直接调用指定的本地方法。
- 类静态变量
- 只要StaticFieldReference 类未被卸载,staticVar引用的对象都不会被垃圾回收。
- 如果StaticFieldReference 类被卸载(这通常发生在其类加载器被垃圾回收时),那么 staticVar 引用的对象也将有资格被垃圾回收(如果没有其他引用指向这个对象)。
- 运行时常量池中的常量(String 或 Class 类型)
- 通过GC roots作为起点,然后向下搜索,搜索走过的路径被称为 Reference Chain(引用链),
垃圾收集算法
-
标记清除算法
- 标记清除算法(Mark-Sweep)是最基础的一种垃圾回收算法,它分为 2 部分,先把内存区域中的这些对象进行标记,哪些属于可回收的标记出来(用前面提到的可达性分析法),然后把这些垃圾拎出来清理掉。
- 清理掉的垃圾就变成可使用的空闲空间,等待被再次使用。逻辑清晰,并且也很好操作,但它存在一个很大的问题,那就是内存碎片。碎片太多可能会导致当程序运行过程中需要分配较大对象时,因无法找到足够的连续内存而不得不提前触发新一轮的垃圾收集。
-
复制算法(标记-复制算法)
- 复制算法(Copying)是在标记清除算法上演化而来的,用于解决标记清除算法的内存碎片问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
- 当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样就保证了内存的连续性,逻辑清晰,运行高效。
- 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。
-
标记整理算法(老年代)
- 标记整理算法(Mark-Compact),标记过程仍然与标记清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。
- 标记整理算法一方面在标记-清除算法上做了升级,解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。看起来很美好,但内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法差很多。
-
分代收集算法
-
根据对象存活周期的不同会将内存划分为几块,一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
-
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
-
老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记清理或者标记整理算法来进行回收。
-
具体的:
-
java堆主要分为 2 个区域,年轻代与老年代,其中年轻代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 两个区。
-
Eden区
- 据 IBM 公司之前的研究表明,有将近 98% 的对象是朝生夕死,
所以针对这一现状,大多数情况下,对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,JVM 会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。
通过 Minor GC 之后,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区,如果 From 区不够,则直接进入 To 区。
- 据 IBM 公司之前的研究表明,有将近 98% 的对象是朝生夕死,
-
Survivor区
-
为啥需要 Survivor 区?
- 不就是新生代到老年代吗,直接 Eden 到 Old 不好了吗,为啥要这么复杂。
如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次 Minor GC 没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。
这时候移入老年区,很明显不是一个明智的决定。
所以,Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。Survivor 的预筛选保证,只有经历 16 次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。
- 不就是新生代到老年代吗,直接 Eden 到 Old 不好了吗,为啥要这么复杂。
-
Survivor 区为啥划分为两块?
-
设置两个 Survivor 区最大的好处就是解决内存碎片化,我们先假设一下,Survivor 只有一个区域会怎样。
Minor GC 执行后,Eden 区被清空,存活的对象放到了 Survivor 区,而之前 Survivor 区中的对象,可能也有一些是需要被清除的。那么问题来了,这时候我们怎么清除它们?
在这种场景下,我们只能标记清除,而我们知道标记清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化。
但因为 Survivor 有 2 个区域,所以每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,From 与 To 职责兑换,这时候会将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。
这种机制最大的好处就是,整个过程中,永远有一个 Survivor space 是空的,另一个非空的 Survivor space 是无碎片的。
那么,Survivor 为什么不分更多块呢?比方说分成三个、四个、五个?
显然,如果 Survivor 区再细分下去,每一块的空间就会比较小,容易导致 Survivor 区满,两块 Survivor 区可能是经过权衡之后的最佳方案。
-
-
-
old区
-
老年代占据着 2/3 的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。
由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记整理算法。 -
以下几种情况也会进入老年代:
1、大对象
大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及 2 个 Survivor 区之间发生大量的内存复制。当你的系统有非常多“朝生夕死”的大对象时,得注意了。
2、长期存活对象
虚拟机给每个对象定义了一个对象年龄(Age)计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,对象在 Survivor 区中每经历一次 Minor GC,年龄就增加 1 岁。当年龄增加到 15 岁时,这时候就会被转移到老年代。
3、动态对象年龄
JVM 并不强制要求对象年龄必须到 15 岁才会放入老年区,如果 Survivor 空间中某个年龄段的对象总大小超过了 Survivor 空间的一半,那么该年龄段及以上年龄段的所有对象都会在下一次垃圾回收时被晋升到老年代,无需等你“成年”。
-
-
-
假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。
-
分代算法怎么解决跨代引用的问题?
- 首先,我们知道跨代引用相对于同代引用来说仅占极少。
- 依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set)。
- 在HotSpot算法实现模块会对其详细介绍。
- 这个结构把老年代划分为若干个小块,标识出老年代哪一块内存会存在跨代引用。当发生 Minor GC 时,只有包含了跨代引用的小块内存中的老年代对象才会加入到 GC Roots 扫描中,避免整个老年代加入到 GC Roots 中。
-
HotSpot 算法实现
https://cloud.tencent.com/developer/article/2314544
根节点枚举
- 所有收集器在根节点枚举这一步是都必须暂停用户线程
- GC的时候一般通过可达性分析算法找出还有价值的对象,将他们复制保留,剩下的不在追溯链中的对象将被清理消灭。可达性分析算法的起点是一组称为GC Roots的东西。
- GCroot包括什么在上文讲的比较清楚,但基本记不住。可以记为下面一句。
- 固定的可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中。
- 那么知道了GcRoot是什么,也知道了在哪里,GC该如何找到它们呢?
- 就拿第一个栈中引用的对象举例,JVM中动辄几十个线程在运行,每个线程嵌套的函数栈帧少则十几层,多则几十上百层,该如何去把这些所有线程中存在的引用都找出来,能够想象这将是一件耗时耗力的工程。(https://www.elefans.com/category/jswz/34/1016370.html)
- 为了节约GC Roots遍历的时间,HotSpot在内的Java虚拟机采用了空间换时间的方法,提前将root的位置信息记录起来,之后再找。
- 准确式垃圾收集
- 准确式垃圾收集是指垃圾收集器能够精确地确定内存中哪些区域被对象引用,哪些区域已经不再使用,并且可以立即回收不再使用的内存。
- 在准确式垃圾收集中,垃圾收集器需要知道每一个引用类型变量(包括实例字段、静态字段、本地变量和输入参数等)在内存中的确切位置,以及这个位置是否正在被引用。
- 这样,当垃圾收集器需要进行回收时,它就可以精确地找到并回收那些不再有任何引用的对象所占用的内存。
- 为了实现准确式垃圾收集,必须知道所有的GcRoot信息,HotSpot使用OopMap记录栈上本地变量到堆上对象的引用关系,加快根节点枚举的速度。
安全点
- OopMap映射表记录了引用关系,但是引用关系变化比较频繁,如果引用每变化一次就更新对应的OopMap,开销很大。
- 引入了安全点的概念。指令只有到达安全点时才可以开始GC。
- 于是,流程变为了:
- 先是到达安全点,然后更新OopMp,然后进行根节点枚举,找到
GC Roots,开始GC。
- 先是到达安全点,然后更新OopMp,然后进行根节点枚举,找到
- 安全点的选举,一般在如下几个位置出现:
- 循环的末尾
- 方法临返回前
- 调用方法之后
- 抛异常的位置
- 那么还有一个问题,如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来?
- 有两种方法。
- 「抢先式中断」:不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
- 「主动式中断」:当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。
安全区域
- 但是线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己。
- JVM引入了安全区域。
- 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化。因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
- 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域。那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段)。
记忆集和卡表
- 新生代发生垃圾收集时(Minor GC),如果想确定这个新生代对象是否被年老代的对象引用,则需要扫描整个年老代,成本非常高。如果我们能知道哪一部分年老代可能存在对新生代的引用,就可以降低扫描范围。
- 记忆集就是这样一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
- 最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。但是我们无需知道每个对象的具体细节。因此,我们可以采用不同精读的记录方式。
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
- 卡精度就称为卡表。卡表通常就是一个byte数组,数组中每一个元素代表某一块内存,其值是1或者0:当发生跨代引用时,就表示该元素“dirty”了,那么将将其设置为1,否则就是0。
并发的可达性分析
- 并发可达性分析,使用三色标记法。
- 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
- 有两个条件发生会导致应该是黑色的对象被误标为白色
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
- 想要解决这种问题,只要破坏一个条件就可以。
- 增量更新(Incremental Update)
- 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
- 原始快照(Snapshot At The Beginning,SATB)
- 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

- 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
- 增量更新(Incremental Update)
jvm垃圾收集器
JVM 的垃圾收集器主要分为两大类:分代收集器和分区收集器,分代收集器的代表是 CMS,分区收集器的代表是 G1 和 ZGC
分代收集器cms(Concurrent Mark Sweep)
一种以获取最短回收停顿时间为目标的收集器,它是基于标记--清除算法实现。
- 初始标记,指的是寻找所有被 GCRoots 引用的对象,该阶段需要「Stop the World」。这个步骤仅仅只是标记一下 GC Roots 能直接关联到的对象,并不需要做整个引用的扫描,因此速度很快。
- 并发标记,指的是对「初始标记阶段」标记的对象进行整个引用链的扫描,该阶段不需要「Stop the World」。
对整个引用链做扫描需要花费非常多的时间,因此通过垃圾回收线程与用户线程并发执行,可以降低垃圾回收的时间。
这也是 CMS 能极大降低 GC 停顿时间的核心原因,但这也带来了一些问题,即:并发标记的时候,引用可能发生变化,因此可能发生漏标(本应该回收的垃圾没有被回收)和多标(本不应该回收的垃圾被回收)了。 - 重新标记,指的是对「并发标记」阶段出现的问题进行校正,该阶段需要「Stop the World」。正如并发标记阶段说到的,由于垃圾回收算法和用户线程并发执行,虽然能降低响应时间,但是会发生漏标和多标的问题。所以对于 CMS 来说,它需要在这个阶段做一些校验,解决并发标记阶段发生的问题。
- 并发清除,指的是将标记为垃圾的对象进行清除,该阶段不需要「Stop the World」。 在这个阶段,垃圾回收线程与用户线程可以并发执行,因此并不影响用户的响应时间。
cms优点:
- 并发收集、低停顿。
cms缺点:
- 对 CPU 资源非常敏感,因此在 CPU 资源紧张的情况下,CMS 的性能会大打折扣。
默认情况下,CMS 启用的垃圾回收线程数是(CPU数量 + 3)/4,当 CPU 数量很大时,启用的垃圾回收线程数占比就越小。但如果 CPU 数量很小,例如只有 2 个 CPU,垃圾回收线程占用就达到了 50%,这极大地降低系统的吞吐量,无法接受。 - CMS 采用的是「标记-清除」算法,会产生大量的内存碎片,导致空间不连续,当出现大对象无法找到连续的内存空间时,就会触发一次 Full GC,这会导致系统的停顿时间变长。
- CMS 无法处理浮动垃圾,当 CMS 在进行垃圾回收的时候,应用程序还在不断地产生垃圾,这些垃圾会在 CMS 垃圾回收结束之后产生,这些垃圾就是浮动垃圾,CMS 无法处理这些浮动垃圾,只能在下一次 GC 时清理掉。
分区收集器
G1
在 JDK 9 时取代 CMS 成为了默认的垃圾收集器。
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
- 为什么名字叫Garbage First?
- G1是一个并行回收器,它把堆内存分割为很多不相关的区域(region物理上不连续),把堆分为2048个区域。每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB, 4MB, 8MB, 16MB, 32MB。所有的Region大小相同,且在JVM生命周期内不会被改变。它将堆内存分为多个大小相等的区域(Region),每个区域都可以是 Eden 区、Survivor 区或者 Old 区。
- 每次根据允许的收集时间,优先回收价值最大的Region(每次回收完以后都有一个空闲的region,在后台维护一个优先列表)
- 由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先(Garbage First)
- 举个例子:
假设此时年老代空间共有800个Region区,并且都满了,所以此刻会触发GC。但根据GC的预期停顿时间值,本次GC只能允许停顿200ms,而G1经过前面的成本计算后,大致可推断出:本次GC回收600个Region区恰好停顿时间可控制在200ms左右,那么最终就会以「回收600个Region区」为基准触发GC,这样则能尽量确保GC导致的停顿时间可以被控制在我们指定的范围之内。 - G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域。主要用于存储大对象,如果超过1.5个region, 就放到H。对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。 G1的大多数行为都把H区作为老年代的一部分来看待。
- G1中的GC类型
- G1对于整个堆空间所有的Region区不会在一开始就全部分配完,无论是新生代、幸存区以及年老代在最开始都是会有初始数量的,在程序运行过程中会根据需求不断增加每个分代区域的Region数量。
- YoungGC
- YoungGC并非说Eden区放满了就会立马被触发,在G1中,当新生代区域被用完时,G1首先会大概计算一下回收当前的新生代空间需要花费多少时间,如果回收时间远远小于参数-XX:MaxGCPauseMills设定的值,那么不会触发YoungGC,而是会继续为新生代增加新的Region区用于存放新分配的对象实例。直至某次Eden区空间再次被放满并经过计算后,此次回收的耗时接近-XX:MaxGCPauseMills参数设定的值,那么才会触发YoungGC。
- G1收集器中的新生代收集,依旧保留了分代收集器的特性,当YoungGC被触发时,首先会将目标Region区中的存活对象移动至幸存区空间(被打着Survivor-from区标志的Region)。同时达到晋升年龄标准的对象也会被移入至年老代Region中存储。采用的是复制算法。
- MixedGC
- MixedGC翻译过来的意思为混合型GC,而并非是指FullGC。
- 当整个堆中年老代的区域占有率达到参数-XX:InitiatingHeapOccupancyPercent设定的值后触发MixedGC,发生该类型GC后,会回收所有新生代Region区、部分年老代Region区(会根据期望的GC停顿时间选择合适的年老代Region区优先回收)以及大对象Humongous区。采用的是复制算法
- FullGC
- 当整个堆空间中的空闲Region不足以支撑拷贝对象或由于元数据空间满了等原因触发,在发生FullGC时,G1首先会停止系统所有用户线程,然后采用单线程进行标记、清理和压缩整理内存,以便于清理出足够多的空闲Region来供下一次MixedGC使用。但该过程是单线程串行收集的,因此这个过程非常耗时的(ShenandoahGC中采用了多线程并行收集)。
- G1收集器垃圾回收过程
- 初始标记(InitialMark):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记(ConcurrentMarking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
- 最终标记(Remark):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 筛选回收(Cleanup):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
- G1中的记忆集
- G1划分为了多个region,region里存在的跨region对象如何解决。
- G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。卡表是“我指向谁”,这种结构还记录了“谁指向我”,这样要比原有的卡表更加复杂。同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。
- G1在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
- CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快照(SATB)算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。
- G1和CMS的区别
- G1垃圾回收器是compacting的,因此其回收得到的空间是连续的。这避免了CMS回收器因为不连续空间所造成的问题。
- G1回收器的内存与CMS回收器要求的内存模型有极大的不同。G1将内存划分一个个固定大小的region,每个region可以是年轻代、老年代的一个。内存的回收是以region作为基本单位的;
- G1还有一个及其重要的特性:软实时(soft real-time)。所谓的实时垃圾回收,是指在要求的时间内完成垃圾回收。“软实时”则是指,用户可以指定垃圾回收时间的限时,G1会努力在这个时限内完成垃圾回收,但是G1并不担保每次都能在这个时限内完成垃圾回收。通过设定一个合理的目标,可以让达到90%以上的垃圾回收时间都在这个时限内。
- G1的垃圾回收,哪里比较费时呢?
- G1的混合回收过程可以分为标记阶段、清理阶段和复制阶段。
- 标记阶段停顿分析
- 初始标记阶段:初始标记阶段是指从GC Roots出发标记全部直接子节点的过程,该阶段是STW的。由于GC Roots数量不多,通常该阶段耗时非常短。
- 并发标记阶段:并发标记阶段是指从GC Roots开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和GC线程可以同时活动。并发标记耗时相对长很多,但因为不是STW,所以我们不太关心该阶段耗时的长短。
- 再标记阶段:重新标记那些在并发标记阶段发生变化的对象。该阶段是STW的。
- 清理阶段停顿分析
- 清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是STW的。
- 复制阶段停顿分析
- 复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是STW的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。
- 四个STW过程中,初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题。
- G1的Young GC和CMS的Young GC,其标记-复制全过程STW,这里不再详细阐述。
ZGC
- ZGC是JDK11推出一款低延迟垃圾回收器,它的停顿时间不超过10ms。
- 普通的GC与什么问题呢?
- 很多低延迟高可用Java服务的系统可用性经常受GC停顿的困扰。GC停顿指垃圾回收期间STW(Stop The World),当STW时,所有应用线程停止活动,等待GC停顿结束。
- 以美团风控服务为例,部分上游业务要求风控服务65ms内返回结果,并且可用性要达到99.99%。但因为GC停顿,我们未能达到上述可用性目标。当时使用的是CMS垃圾回收器,单次Young GC 40ms,一分钟10次,接口平均响应时间30ms。通过计算可知,有(40ms + 30ms) * 10次 / 60000ms = 1.12%的请求的响应时间会增加0 ~ 40ms不等,其中30ms * 10次 / 60000ms = 0.5%的请求响应时间会增加40ms。可见,GC停顿对响应时间的影响较大。为了降低GC停顿对系统可用性的影响,我们从降低单次GC时间和降低GC频率两个角度出发进行了调优,还测试过G1垃圾回收器,但这三项措施均未能降低GC对服务可用性的影响。
- 在ZGC中,也会把堆空间划分为一个个的Region区域,但ZGC中的Region区不存在分代的概念,它仅仅只是简单的将所有Region区分为了大、中、小三个等级。三者内存不同,从小到大。
- 与 G1 和 CMS 类似,ZGC 也采用了复制算法,只不过做了重大优化,ZGC 在标记、转移和重定位阶段几乎都是并发的,这是 ZGC 实现停顿时间小于 10ms 的关键所在。
- ZGC只有三个STW阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。
- ZGC是怎么做到的?
- ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。
- 指针染色(Colored Pointer):一种用于标记对象状态的技术。
- 读屏障(Load Barrier):一种在程序运行时插入到对象访问操作中的特殊检查,用于确保对象访问的正确性。
- 这两种技术可以让所有线程在并发的条件下就指针的颜色 (状态) 达成一致,而不是对象地址。因此,ZGC 可以并发的复制对象,这大大的降低了 GC 的停顿时间。
- 在此之前所有的GC信息保存在对象头中,但ZGC中的GC信息保存在指针内。
- 指针染色
https://pdai.tech/md/java/jvm/java-jvm-gc-zgc.html- ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:
这里缺乏图片!!!
其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。
- ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:
- 与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第041位,而第4245位存储元数据,第47~63位固定为0。
- 在一个指针中,除了存储对象的实际地址外,还有额外的位被用来存储关于该对象的元数据信息。这些信息可能包括:
- 对象是否被移动了(即它是否在回收过程中被移动到了新的位置)。
- 对象的存活状态。
- 对象是否被锁定或有其他特殊状态。
- 通过在指针中嵌入这些信息,ZGC 在标记和转移阶段会更快,因为通过指针上的颜色就能区分出对象状态,不用额外做内存访问。
- 染色指针是一种直接将少量额外的信息存储在指针上的技术,Linux下64位指针的高18位不能用来寻址,ZGC的染色指针技术盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。
当然,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂)。

- JVM 可以从指针上直接看到对象的三色标记状态(Marked0、Marked1)、是否进入了重分配集(Remapped)、是否需要通过 finalize 方法来访问到(Finalizable)。
- 其位数分配为:
18位:预留给以后使用;
1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;
1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集 合);
1位:Marked1标识;
1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC;
42位:对象的地址(所以它可以支持2^42=4T内存)
- 在一个指针中,除了存储对象的实际地址外,还有额外的位被用来存储关于该对象的元数据信息。这些信息可能包括:
- ZGC为何仅支持4TB(JDK13拓展到了16TB),不是还有很多位没用吗?
- 前面分析指针后得知,ZGC在指针中预留了18位没使用,那为什么不全部用上,让ZGC支持超级巨大的堆空间。其实这跟硬件和OS有关,因为X86_64架构的硬件只有48条地址总线,硬件的主板地址总线最宽只有48bit,其中4个是颜色位,就只剩下了44位了,所以受限于目前的硬件,ZGC最大就只能支持2^44=16TB内存。
- 为什么会有两个Marked标识?
- 这是为了防止不同GC周期之间的标记混淆,所以搞了两个Marked标识,每当新的一次GC开始时,都会交换使用的标记位。例如:第一次GC使用M0,第二次GC就会使用M1,第三次又会使用M0.....,因为ZGC标记完所有分区的存活对象后,会选择分区进行回收,因此有一部分区域内的存活对象不会被转移,那么这些对象的标识就不会复位,会停留在之前的Marked标识(比如M0),如果下次GC还是使用相同M0来标记对象,那混淆了这两种对象。为了确保标记不会混淆,所以搞了两个Marked标识交替使用。
- 为什么ZGC运行过程中指针的标记位变动不会影响对象的内存寻址?
- 这是因为ZGC的染色指针用到了一种叫做多重映射的技术,也就是指多个虚拟地址指向同一个物理地址,不管指针地址是0001....还是0010.....,或者0100....等等,最终对应的都是同一个物理地址。
- ZGC 如何基于染色指针进行并发处理?
- 在第一次GC发生前,堆中所有对象的标识为:Remapped。
- 第一次GC被触发后,GC线程开始标记,开始扫描,如果对象是Remapped标志,并且该对象根节点可达的,则将其改为M0标识,表示存活对象。
- 如果标记过程中,扫描到的对象标识已经为M0,代表该对象已经被标记过,或者是GC开始后新分配的对象,这种情况下无需处理。
- 在GC开始后,用户线程新创建的对象,会直接标识为M0。
- 在标记阶段,GC线程仅标记用户线程可直接访问的对象还是不够的,实际上还需要把对象的成员变量所引用的对象都进行递归标记。
- 总归而言,在「标记阶段」结束后,对象要么是M0存活状态,要么是Remapped待回收状态。最终,所有被标记为M0状态的活跃对象都会被放入「活跃信息表」中。等到了「转移阶段」再对这些对象进行处理,流程如下:
- ZGC选择目标回收区域,开始并发转移。
- GC线程遍历访问目标区域中的对象,如果对象标识为M0并且存在于活跃表中,则把该对象转移到新的分区/页面空间中,同时将其标识修正为Remapped标志。
- GC线程如果扫描到的对象存在于活跃表中,但标识为Remapped,说明该对象已经转移过了,无需处理。
- 用户线程在「转移阶段」新创建的对象,会被标识为Remapped。
- 如果GC线程遍历到的对象不是M0状态或不在活跃表中,也无需处理。
- 最终,当目标区域中的所有存活对象被转移到新的分区后,ZGC统一回收原本的选择的回收区域。至此,一轮GC结束,整个堆空间会正常执行下去,直至触发下一轮GC。而当下一轮GC发生时,会采用M1作为GC辅助标识,而并非M0,具体原因在前面分析过了则不再阐述。
- 染色指针的优势
- 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指令向该Region的引用都被修正后才能清理。
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是在写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。
- 读屏障是什么?
- 读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
- ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。
- ZGC的读屏障是怎么操作的?
- 读屏障是在读取成员变量时,统统记录下来,这种做法是保守的,但也是安全的。
- 因为前述分析对象漏标问题时,曾谈到过:引发漏标问题必须要满足两个条件,其中条件二为:「已经标为黑色的对象重新与白色对象建立了引用关系」,而黑色对象想要与白色对象重新建立引用的前提是:得先读取到白色对象,此时读屏障的作用就出来了,可以直接记录谁读取了当前白色对象,然后在「再次标记」重新标记一下这些黑色对象即可。
JIT即时编译
- 常见的编译型语言如C++,通常会把代码直接编译成CPU所能理解的机器码来运行。而Java为了实现“一次编译,处处运行”的特性,把编译的过程分成两部分,首先它会先由javac编译成通用的中间形式——字节码,然后再由解释器逐条将字节码解释为机器码来执行。所以在性能上,Java通常不如C++这类编译型语言。
- 为了优化Java的性能 ,JVM在解释器之外引入了即时(Just In Time)编译器:当程序运行时,解释器首先发挥作用,代码可以直接执行。
- 随着时间推移,即时编译器逐渐发挥作用,把越来越多的代码编译优化成本地代码,来获取更高的执行效率。解释器这时可以作为编译运行的降级手段,在一些不可靠的编译优化出现问题时,再切换回解释执行,保证程序可以正常运行。
- 怎么样才会被认为是热点代码呢?
- JVM 中有一个阈值,当方法或者代码块的在一定时间内的调用次数超过这个阈值时就会被认定为热点代码,然后编译存入 codeCache 中。当下次执行时,再遇到这段代码,就会从 codeCache 中直接读取机器码,然后执行,以此来提升程序运行的性能。
JVM中的编译器
JVM中集成了两种编译器,Client Compiler和Server Compiler,它们的作用也不同。Client Compiler注重启动速度和局部的优化,Server Compiler则更加关注全局的优化,性能会更好,但由于会进行更多的全局分析,所以启动速度会变慢。
Client Compiler
HotSpot VM带有一个Client Compiler C1编译器。这种编译器启动速度快,但是性能比较Server Compiler来说会差一些。C1会做三件事:
- 局部简单可靠的优化,比如字节码上进行的一些基础优化,方法内联、常量传播等,放弃许多耗时较长的全局优化。
- 将字节码构造成高级中间表示(High-level Intermediate Representation,以下称为HIR),HIR与平台无关,通常采用图结构,更适合JVM对程序进行优化。
- 最后将HIR转换成低级中间表示(Low-level Intermediate Representation,以下称为LIR),在LIR的基础上会进行寄存器分配、窥孔优化(局部的优化方式,编译器在一个基本块或者多个基本块中,针对已经生成的代码,结合CPU自己指令的特点,通过一些认为可能带来性能提升的转换规则或者通过整体的分析,进行指令转换,来提升代码性能)等操作,最终生成机器码。
Server Compiler
Server Compiler主要关注一些编译耗时较长的全局优化,甚至会还会根据程序运行的信息进行一些不可靠的激进优化。这种编译器的启动时间长,适用于长时间运行的后台程序,它的性能通常比Client Compiler高30%以上。目前,Hotspot虚拟机中使用的Server Compiler有两种:C2和Graal。
C2 Compiler
在Hotspot VM中,默认的Server Compiler是C2编译器。
C2编译器在进行编译优化时,会使用一种控制流与数据流结合的图数据结构,称为Ideal Graph。 Ideal Graph表示当前程序的数据流向和指令间的依赖关系,依靠这种图结构,某些优化步骤(尤其是涉及浮动代码块的那些优化步骤)变得不那么复杂。
Graal Compiler
从JDK 9开始,Hotspot VM中集成了一种新的Server Compiler,Graal编译器。相比C2编译器,Graal有这样几种关键特性:
前文有提到,JVM会在解释执行的时候收集程序运行的各种信息,然后编译器会根据这些信息进行一些基于预测的激进优化,比如分支预测,根据程序不同分支的运行概率,选择性地编译一些概率较大的分支。Graal比C2更加青睐这种优化,所以Graal的峰值性能通常要比C2更好。
使用Java编写,对于Java语言,尤其是新特性,比如Lambda、Stream等更加友好。
更深层次的优化,比如虚函数的内联、部分逃逸分析等。
分层编译
java 7开始引入了分层编译的概念,它结合了C1和C2的优势,追求启动速度和峰值性能的一个平衡。分层编译将JVM的执行状态分为了五个层次。五个层级分别是:
- 解释执行。
- 执行不带profiling的C1代码。
- 执行仅带方法调用次数以及循环回边执行次数profiling的C1代码。
- 执行带所有profiling的C1代码。
- 执行C2代码。
ps:profiling就是收集能够反映程序执行状态的数据。其中最基本的统计数据就是方法的调用次数,以及循环回边的执行次数。
通常情况下,C2代码的执行效率要比C1代码的高出30%以上。C1层执行的代码,按执行效率排序从高至低则是1层>2层>3层。
这5个层次中,1层和4层都是终止状态,当一个方法到达终止状态后,只要编译后的代码并没有失效,那么JVM就不会再次发出该方法的编译请求的。
服务实际运行时,JVM会根据服务运行情况,从解释执行开始,选择不同的编译路径,直到到达终止状态。
https://tech.meituan.com/2020/10/22/java-jit-practice-in-meituan.html
这里缺图片和解释至此那个的方式。
编译优化技术
方法内联
方法内联,是指在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。JIT大部分的优化都是在内联的基础上进行的,方法内联是即时编译器中非常重要的一环。
Java服务中存在大量getter/setter方法,如果没有方法内联,在调用getter/setter时,程序执行时需要保存当前方法的执行位置,创建并压入用于getter/setter的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。内联了对 getter/setter的方法调用后,上述操作仅剩字段访问。在C2编译器 中,方法内联在解析字节码的过程中完成。当遇到方法调用字节码时,编译器将根据一些阈值参数决定是否需要内联当前方法的调用。如果需要内联,则开始解析目标方法的字节码。
逃逸分析
逃逸分析是“一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”。Java虚拟机的即时编译器会对新建的对象进行逃逸分析,判断对象是否逃逸出线程或者方法。即时编译器判断对象是否逃逸的依据有两种:
- 对象是否被存入堆中(静态字段或者堆中对象的实例字段),一旦对象被存入堆中,其他线程便能获得该对象的引用,即时编译器就无法追踪所有使用该对象的代码位置。
- 对象是否被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中,这种情况,可以直接认为方法调用的调用者以及参数是逃逸的。
即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。 - 锁消除
- 在学习Java并发编程时会了解锁消除,而锁消除就是在逃逸分析的基础上进行的。
- 如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没就有意义。因为线程并不能获得该锁对象。在这种情况下,即时编译器会消除对该不逃逸锁对象的加锁、解锁操作。实际上,编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于Java虚拟机即时编译的限制,上述条件被强化为证明锁对象不逃逸出当前编译的方法。不过,基于逃逸分析的锁消除实际上并不多见。
栈上分配 - 我们都知道Java的对象是在堆上分配的,而堆是对所有对象可见的。同时,JVM需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。如果逃逸分析能够证明某些新建的对象不逃逸,那么JVM完全可以将其分配至栈上,并且在new语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。

浙公网安备 33010602011771号