参考链接

https://zhuanlan.zhihu.com/p/351216320

JAVA跨语言平台特性

flowchart TD A[HelloWorld.java] -->|javac| B[HelloWorld.class] B -->|java| C[JVM编译 提前编译或实时编译] C -->|windows机器码| D[windows] C -->|linux机器码| E[linux]

运行时内存区域

graph TB %% 样式定义 classDef privateFill fill:#e3f2fd,stroke:#1565c0,stroke-width:2px; classDef sharedFill fill:#fff3e0,stroke:#ef6c00,stroke-width:2px; classDef boxFill fill:#ffffff,stroke:#333,stroke-width:1px; classDef noteFill fill:#f9f9f9,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5; subgraph JVM_Process [JVM 进程] direction TB %% --- 线程私有区域 --- subgraph Thread_Private [线程私有区域] direction LR PC[程序计数器] VMStack[虚拟机栈] NativeStack[本地方法栈] PC_Desc[记录指令地址 唯一无 OOM] Stack_Desc[存栈帧 含局部变量表和操作数栈 异常 StackOverflowError] Native_Desc[服务 Native 方法 HotSpot 中与 VM 栈合并] Frame_Detail[栈帧四部分 局部变量表 操作数栈 动态链接 返回地址] end %% --- 线程共享区域 --- subgraph Thread_Shared [线程共享区域] direction TB Heap[堆 Heap] Meta[元空间 Metaspace] CodeCache[代码缓存] Heap_Desc[存对象和数组 GC 主要区域 异常 Java heap space] Meta_Desc[JDK8+ 取代永久代 存类元数据和静态变量 用本地内存] Code_Desc[存 JIT 编译后的机器码] end %% --- 逻辑分区 --- YoungGen[新生代 Eden 加 Survivor] OldGen[老年代] RunPool[运行时常量池] end %% 应用样式 class Thread_Private privateFill; class Thread_Shared sharedFill; class PC,VMStack,NativeStack,Heap,Meta,CodeCache,YoungGen,OldGen,RunPool boxFill; class PC_Desc,Stack_Desc,Native_Desc,Frame_Detail,Heap_Desc,Meta_Desc,Code_Desc noteFill; %% 连接关系 PC --- PC_Desc VMStack --- Stack_Desc VMStack --- Frame_Detail NativeStack --- Native_Desc Heap --- Heap_Desc Meta --- Meta_Desc CodeCache --- Code_Desc Heap ~~~ YoungGen Heap ~~~ OldGen Meta ~~~ RunPool VMStack -->|引用 | Heap NativeStack -->|调用 | Heap Meta -->|加载 | Heap linkStyle default stroke:#666,stroke-width:1px;

1.程序计数器

程序计数器(Program Counter Register)是对当前线程执行的字节码的行号指示器。程序计数器占用的内存空间很小,它是线程私有的。当字节码(class)被执行时,线程通过自己的程序计数器来选取下一条字节码指令。程序控制流(分支,循环,异常处理等等)和线程切换时的线程上下文恢复都需要依赖这个计数器。

2.虚拟机栈

线程私有,每压栈一个方法就放进去一个栈帧,栈帧的组成分为:

  • 局部变量表
    存储8种基本数据类型,和对象引用
  • 操作数栈
    所有运算数据都要放在操作数栈里,例:(a+b)/c
  • 动态链接
    栈帧中的动态链接是一个指向当前方法所属类的运行时常量池的引用,它的作用是在方法执行过程中,将字节码指令中的符号引用(类名、方法名等)实时解析为内存中的直接引用(地址或索引),支持方法调用和属性访问的正常执行。
  • 方法返回地址
    方法执行结束,不管是正常退出还是异常退出,都需要返回到该方法被调用的位置。

栈满了报 StackOverflowError

image-JDzI.png

3.本地方法栈

执行native方法的栈

4.堆区

存放几乎所有的对象实例

堆满了报:OutOfMemoryError: Java heap space

5.方法区

方法区是一个JVM标准定义的逻辑区域,不同虚拟机有不同实现,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

在Java8之前,HotSpot虚拟机将方法区实现为 永久代,能够通过分代收集的GC来管理其内存区域。但这种设计导致Java应用经常遇到内存溢出问题,很多JVM都需要在启动时添加参数 -XX:MaxPermSize来调整 永久代的大小。因此在Java7的时候,就先将方法区中的 字符串常量池,静态变量等转移到了Java堆中;而到了Java8,就直接移除了 永久代,将其中剩下的内容如类的元信息,方法元信息,class常量池,运行时常量池等移动到了一个新的区域 Metaspace元数据区,将JIT即时编译的代码缓存放到了 CodeCache区域。

不管是Java8之前的 永久代,还是Java8以后的 元数据区CodeCache,还是Java7以后堆中的 字符串常量池,它们在逻辑上都属于 方法区。只是不同JVM在不同版本中的具体实现不一样罢了。

方法区满了报:OutOfMemoryError: Metaspace

常量池

class文件常量池

class文件常量池是Class文件的一部分,它是一个静态的常量池,在编译阶段就已经确定。它主要存储字面量和符号引用两大类内容。字面量包括文本字符串、常量值(如int final常量);符号引用包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。class文件常量池占用了Class文件的空间,为后续JVM加载类时提供必要的信息。

运行时常量池

运行时常量池是JVM在类加载后,从class文件常量池转换而来的,属于方法区(元空间)的一部分。它具有动态性,可以在运行期间将新的常量放入池中。每个类都有对应的运行时常量池,它是全局共享的。String.intern()方法可以将字符串放入运行时常量池中,这是运行时常量池动态特性的体现。

字符串常量池

字符串常量池专门用于存储String对象,JVM启动时创建,位于堆内存中。它采用双例模式,相同的字符串字面量在池中只有一份,采用延迟加载策略,首次使用时才创建并存入池中。通过String s1 = "Hello"这种字面量声明的方式,会直接从字符串常量池获取;而使用new String("Hello")则会在堆中创建新对象。String.intern()方法可以将字符串加入字符串常量池,后续相同字面量会直接使用池中的对象。

下面两段测试代码(思考):

@org.junit.Test
    public void test135() {
//        String x = "ab";

        String s = new String("a") + new String("b");
        String intern = s.intern();

        System.out.println(intern == "ab");
        System.out.println(s == "ab");
        System.out.println(intern == s);
    }
@org.junit.Test
    public void test136() {
        String s1 = new StringBuilder().append("ja").append("va1").toString();
        String s2 = s1.intern();
        System.out.println(s1==s2);

        String s5 = "dmz";
        String s3 = new StringBuilder().append("d").append("mz").toString();
        String s4 = s3.intern();
        System.out.println(s3 == s4);

        String s7 = new StringBuilder().append("s").append("pring").toString();
        String s8 = s7.intern();
        String s6 = "spring";
        System.out.println(s7 == s8);
        System.out.println(s6 == s7);
    }

test135:true,false,false

jdk1.8 test:true,false,true,true

jdk常量池1.6和1.7变化

核心变化:存放位置不同

JDK 1.6​:字符串常量池存放在**方法区(永久代)**中。

JDK 1.7​:字符串常量池被移到了中。

为什么移动?

JDK 1.6 中,方法区(永久代)有固定的最大大小,难以调优。如果程序中有大量字符串,容易导致 PermGen Space OOM

JDK 1.7 将字符串常量池移到堆中,堆内存可以动态扩展,且更容易调优,减少了 OOM 的发生。

intern()方法的变化

JDK 1.6 中,intern() 方法的作用是:如果字符串常量池中不存在该字符串,就复制一份到常量池中,并返回常量池中的引用。
JDK 1.7 中,intern() 方法的行为发生了变化:如果字符串常量池中不存在该字符串,就​把堆中对象的引用复制到常量池中​,而不是复制字符串内容本身。

基本类型包装类常量池

基本类型包装类常量池包含Byte、Short、Integer、Long、Character、Boolean等包装类,用于缓存特定范围内的对象,避免频繁创建和销毁。Boolean缓存true和false,Byte、Short、Integer、Long缓存-128到127范围内的值,Character缓存0到127范围内的值。只有valueOf()和自动装箱会使用缓存,直接使用new创建新对象不会使用缓存。因此在使用包装类进行比较时,建议使用equals()而非==,除非确定在缓存范围内。

垃圾收集

内存分配

垃圾收集(GC Garbage Collection)算法决定内存分配,收集区域为JVM的堆和方法区。
其中,程序计数器,虚拟机栈,本地方法栈这三个区域是线程私有的,其内存分配多少与生命周期基本上是编译期可知的,所以它们的内存分配和回收是比较固定的。因此它们不在JVM的垃圾收集策略的范围内。

而Java堆与方法区是线程共享的,它们内部存储的是对象,常量,类信息等等,它们是动态的,不确定的,只有到了运行期才能知道具体加载了多少类,创建了多少对象。因此JVM的垃圾收集策略针对的就是堆和方法区的内存回收。

年轻代gc叫Minor GC/Young GC,老年代叫Major GC/Old GC,全堆清理叫Full GC

对象存活判断

引用计数法

被引用就+1,两个相互引用的垃圾无法处理

可达性分析法

选一些对象作为GC根节点,从根节点出发,根据引用链(强引用)决定是否可达。

GC根节点可能的对象:

  • 虚拟机栈中引用的对象,即各个线程当下压入栈中的栈帧(即方法)的参数变量,局部变量所引用到的对象。(可以回顾一下《JVM知识梳理之一_JVM运行时内存区域与Java内存模型》中对虚拟机栈的梳理。)
  • 方法区中的静态变量与常量所引用的对象,比如引用类型静态变量,字符串的字面量。(可以回顾一下《JVM知识梳理之二_JVM的常量池》中对字符串常量池的梳理。)
  • 本地方法栈中JNI引用的对象。
  • JVM内部的引用,比如类加载器,一些常驻的异常对象(空指针,内存溢出等),基本数据类型对应的Class对象。
  • 被同步锁(synchronized)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVM TI中注册的回调、本地代码缓存等。
  • 分代收集和局部收集的场景里,如果只针对部分区域进行收集,还要考虑关联区域中对象对本区域对象的引用。

常见引用类型

  • 强引用:Object obj = new Object();
  • 软引用:OOM前优先回收,SoftReference softRef = new SoftReference<>(new byte[1024 * 1024]);
  • 弱引用:每次GC都回收,WeakReference<Object> weakRef = new WeakReference<>(new Object());ThreadLocal、WeakHashMap、监听器等使用弱引用。
  • 虚引用:最弱的引用类型。​无法通过虚引用获取对象​,唯一目的是在被回收时收到系统通知。PhantomReference

最终存活

第一次标记

标记的前提是对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链。

  1. 第一次标记并进行一次筛选
  2. 筛选的条件是此对象是否有必要执行 finalize() 方法
  3. 当对象没有覆盖 finalize() 方法,或者 finalize() 方法​已经被虚拟机调用过​,对象将直接被回收

第二次标记

  1. 如果这个对象覆盖了 finalize() 方法且尚未被执行,它会被放入 F-Queue 队列
  2. 虚拟机会自动建立一个低优先级的 Finalizer 线程去执行队列中对象的 finalize() 方法
  3. finalize() 方法是对象逃脱死亡命运的最后一次机会——如果对象要在 finalize() 中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this 关键字)赋值给某个类变量或对象的成员变量
  4. 稍后 GC 会对 F-Queue 中的对象进行第二次标记。如果对象在 finalize() 中成功自救,那在第二次标记时它将被移出"即将回收"的集合;如果对象这时候还没逃脱,那基本上它就真的被回收了

清理方法

  • 标记-清除
    会产生内存碎片,导致大对象分配失败和分配效率降低,适合存活率高的老年代。
  • 标记-复制
    把存活的直接复制到另一个区域,适合新生代,朝生夕死,大量对象要清除。
  • 标记-整理
    可达性分析标记存活对象,将存活对象向内存区域的一端移动,这样存活对象在内存区域中就变成了连续分布,然后直接将存活对象边界之外的部分直接清除即可。

垃圾收集器

image-XBuf.png

Serial(-XX:+UseSerialGC -XX:+UseSerialOldGC)

新生代标记-复制,老年代标记-整理

image-IXsW.png

Parallel Scavenge(-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代))

新生代标记-复制,老年代标记-整理,jdk8默认,重吞吐量

image-diMC.png

ParNew(-XX:+UseParNewGC)

ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。
它是许多运行在Server模式下的虚拟机的首要选择

CMS

七个阶段,重用户响应,延迟低

阶段 名称 是否STW 说明
1 初始标记 ✅ 是 标记GC Roots直接关联的对象
2 并发标记 ❌ 否 遍历整个对象图,GC Roots Tracing
3 并发预清理 ❌ 否 处理并发标记阶段新发现的引用变化,减少重新标记的工作量
4 最终标记 ✅ 是 修正并发标记期间的变动,处理卡表中的脏页
5 并发清除 ❌ 否 清除死亡对象
6 并发重置 ❌ 否 重置CMS内部数据结构,为下次GC做准备
7 并发整理 ❌ 否 可选阶段,进行内存碎片整理(当启用时)

关键补充说明:

阶段3 - 并发预清理 的作用:

  • 利用 Card Table(卡表)记录并发标记期间发生了引用变化的区域
  • 避免阶段4重新标记时扫描整个堆
  • 可以减少最终标记阶段的STW时间

阶段4 - 最终标记 优化:

  • 使用 增量更新 算法
  • 处理并发预清理阶段扫描到的脏卡
  • 通过 CMSPrecleanEnabledCMSPrecleanDenominator 参数调优

jdk9之后已经删除了,推荐用G1
CMS的相关核心参数

  1. -XX:+UseConcMarkSweepGC:启用cms
  2. -XX:ConcGCThreads:并发的GC线程数
  3. -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)
  4. -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
  5. -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
  6. -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定, JVM仅在第一次使用设定值,后续则会自动调整
  7. -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,降低CMS GC标记阶段(也会对年轻代一起做标记,如果在 minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)时的开销,一般CMS的GC耗时 80%都在标记阶段
  8. -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
  9. -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;

G1

G1(Garbage-First)是JDK 7u4版本开始正式发布的一款面向服务端的垃圾收集器,它在JDK 9中成为默认的垃圾收集器。
G1的设计目标是在延迟可控的情况下,尽可能提高吞吐量。它打破了传统的物理分代设计,采用了Region分区的创新方式。

Region分区特点:
  • 将整个堆划分为多个大小相等的独立区域(Region)
  • 每个Region大小在1MB~32MB之间,且是2的N次幂
  • Region类型包括:Eden、Survivor、Old、Humongous(存放大对象)
  • 没有物理上的分代隔离,是逻辑上的分代
    -XX:G1HeapRegionSize=n调整Region大小(1mb-32mb)
Young GC(年轻代收集)

触发条件:Eden区被耗尽
目标:回收所有Eden和Survivor区,部分对象晋升到老年代

Concurrent Cycle(并发标记周期)
阶段 是否STW 说明
初始标记 ✅ STW 标记GC Roots直接可达的对象
并发标记 ❌ 并发 从GC Roots开始遍历标记
最终标记 ✅ STW 处理SATB队列中的残留数据
筛选回收 ✅ STW 统计各Region价值,制定回收计划
Mixed GC(混合收集)

在并发标记完成后,同时回收所有Young Region和部分Old Region,根据"垃圾价值"排序,优先回收垃圾最多的Region,要回收的Region都放在Cset容器里

垃圾收集算法底层实现

三色标记

在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。漏标的问 题主要引入了三色标记算法来解决。
三色标记算法是把Gc roots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:

  • 黑色:表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。
  • 白色:表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段,仍然是白色的对象, 即代表不可达。

image-ibbw.png
上图是漏标问题(严重),多标无所谓,下次再清理

多标-浮动垃圾

在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃 圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影 响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可 能也会变为垃圾,这也算是浮动垃圾的一部分。

漏标-读写屏障

上图中的漏标问题目前有且只有这一种情况会触发,所以要破坏漏标的两个条件:
1.黑色对象插入新的指向白色对象的引用关系
2.灰色对象要删除指向白色对象的引用关系
这俩不能同时成立就好了

漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决,有两种解决方案: 增量更新(Incremental Update) 和原 始快照(Snapshot At The Beginning,SATB) 。
增量更新就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些 记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就 变回灰色对象了。

原始快照就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记 录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象 在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)

以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。

这里的读写屏障就是在读之前做点事和写之前做点事
为什么G1用原始快照,CMS用增量更新?

G1的SATB和CMS的增量更新选择,本质上是各自内存结构和设计目标决定的——G1的Region化设计允许容忍浮动垃圾并追求高效标记,而CMS的Full GC代价过高必须追求标记精度。

记忆集与卡表

分代收集理论建立在三个经验假设之上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的,生命周期很短。
  2. 强分代假说:熬过越多次垃圾收集过程(多次可达性分析均可达)的对象就越难以消亡。
  3. 跨代引用假说:跨代引用相对于同代引用来说占比极小。

当单独对一个区域比如新生代进行垃圾收集时,由于新生代的对象有可能被老年代的对象所引用,因此需要在GC Roots中添加所有老年代的对象。反过来一样,对老年代进行垃圾收集时,需要将新生代的对象加入GC Roots。毫无疑问的是,将关联区域的所有对象加入GC Roots会给垃圾收集带来很大的性能负担。因此又有了上面的第三个假说。根据第三个假说,跨代引用比较少,只用在新生代建立一个全局的数据集,将老年代划分为若干小块,标识哪些块上存在跨代引用;当新生代GC时,不用将老年代的所有对象都加入GC Roots,只需要将有跨代引用的块加入即可。当然,这种方法需要在对象引用关系创建或改变时同时维护这个全局数据集,增加了部分性能开销,但相比将整个老年代加入GC Roots进行可达性分析来说,还是很划算的。

为了记录跨代引用的对象,hotspot对记忆集的实现是卡表技术。

卡表用一个字节数组实现:CARD_TABLE[],每个元素对应其标识的内存区域一块特定大小的内存块,称为卡页,hotspot使用的卡页是2^9大小,即512字节。

一个卡页中包含多个对象,只要页里有一个对象存在跨代指针,整个卡页就是脏卡,这种粗粒度设计牺牲了一点扫描精度,但换取了极低的维护开销和高效的GC性能

JVM调试工具

jps查看进程id

image-CfNK.png

jmap此命令可以用来查看内存信息,实例个数以及占用内存大小

jmap -histo 12472 #查看历史生成的实例
jmap -histo:live 12472  #查看当前存活的实例,执行过程中可能会触发一次full gc

image-HCqg.png

  • num:序号
  • instances:实例数量
  • bytes:占用空间大小
  • class name:类名称,[C is a char[],[S is a short[],[I is a int[],[B is a byte[],[[I is a int[][]
    jmap -heap 查看堆信息

image-iTwP.png

jmap -dump:format=b,file=eureka.hprof 12472堆内存dump

image-VDiq.png

也可以设置内存溢出自动导出dump文件(内存很大的时候,可能会导不出来)-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./(路径)

可以用jvisualvm.exe查看dump出来的文件

jstack 11964jstack用于生成虚拟机当前时刻的线程快照。生成线程快照主要是为了定位长时间停顿的线程,比如线程间死锁、死循环、请求外部资源超时等等。通过jstack可以查看到各个线程的调用堆栈信息,就可以知道线程目前运行在哪一句代码,在做什么事情或者等待什么资源。

image-EoIg.png

jinfo -flags 4576查看jvm的参数

image-QYPD.png

jinfo -sysprops 4576查看系统参数

image-usZs.png

jstat -gc 4576垃圾回收统计

image-Ypsj.png

  • S0C:第一个幸存区的大小,单位KB
  • S1C:第二个幸存区的大小
  • S0U:第一个幸存区的使用大小
  • S1U:第二个幸存区的使用大小
  • EC:伊甸园区的大小
  • EU:伊甸园区的使用大小
  • OC:老年代大小
  • OU:老年代使用大小
  • MC:方法区大小(元空间)
  • MU:方法区使用大小
  • CCSC:压缩类空间大小
  • CCSU:压缩类空间使用大小
  • YGC:年轻代垃圾回收次数
  • YGCT:年轻代垃圾回收消耗时间,单位s
  • FGC:老年代垃圾回收次数
  • FGCT:老年代垃圾回收消耗时间,单位s
  • GCT:垃圾回收消耗总时间,单位s
    jstat -gccapacity 4576堆内存统计

image-OWNO.png

  • NGCMN:新生代最小容量
  • NGCMX:新生代最大容量
  • NGC:当前新生代容量
  • S0C:第一个幸存区大小
  • S1C:第二个幸存区的大小
  • EC:伊甸园区的大小
  • OGCMN:老年代最小容量
  • OGCMX:老年代最大容量
  • OGC:当前老年代大小
  • OC:当前老年代大小
  • MCMN:最小元数据容量
  • MCMX:最大元数据容量
  • MC:当前元数据空间大小
  • CCSMN:最小压缩类空间大小
  • CCSMX:最大压缩类空间大小
  • CCSC:当前压缩类空间大小
  • YGC:年轻代gc次数
  • FGC:老年代GC次数

jstat -gcnew 4576新生代垃圾回收统计

image-alnK.png

  • S0C:第一个幸存区的大小
  • S1C:第二个幸存区的大小
  • S0U:第一个幸存区的使用大小
  • S1U:第二个幸存区的使用大小
  • TT:对象在新生代存活的次数
  • MTT:对象在新生代存活的最大次数
  • DSS:期望的幸存区大小
  • EC:伊甸园区的大小
  • EU:伊甸园区的使用大小
  • YGC:年轻代垃圾回收次数
  • YGCT:年轻代垃圾回收消耗时间

jstat -gcnewcapacity 4576新生代内存统计

image-Qlcv.png

  • NGCMN:新生代最小容量
  • NGCMX:新生代最大容量
  • NGC:当前新生代容量
  • S0CMX:最大幸存1区大小
  • S0C:当前幸存1区大小
  • S1CMX:最大幸存2区大小
  • S1C:当前幸存2区大小
  • ECMX:最大伊甸园区大小
  • EC:当前伊甸园区大小
  • YGC:年轻代垃圾回收次数
  • FGC:老年代回收次数

jstat -gcold 4576老年代垃圾回收统计

image-NFZW.png

  • MC:方法区大小
  • MU:方法区使用大小
  • CCSC:压缩类空间大小
  • CCSU:压缩类空间使用大小
  • OC:老年代大小
  • OU:老年代使用大小
  • YGC:年轻代垃圾回收次数
  • FGC:老年代垃圾回收次数
  • FGCT:老年代垃圾回收消耗时间
  • GCT:垃圾回收消耗总时间

jstat -gcoldcapacity 4576老年代内存统计

image-DRsP.png

  • OGCMN:老年代最小容量
  • OGCMX:老年代最大容量
  • OGC:当前老年代大小
  • OC:老年代大小
  • YGC:年轻代垃圾回收次数
  • FGC:老年代垃圾回收次数
  • FGCT:老年代垃圾回收消耗时间
  • GCT:垃圾回收消耗总时间

jstat -gcmetacapacity 4576元空间内存统计

image-qxzU.png

  • MCMN:最小元数据容量
  • MCMX:最大元数据容量
  • MC:当前元数据空间大小
  • CCSMN:最小压缩类空间大小
  • CCSMX:最大压缩类空间大小
  • CCSC:当前压缩类空间大小
  • YGC:年轻代垃圾回收次数
  • FGC:老年代垃圾回收次数
  • FGCT:老年代垃圾回收消耗时间
  • GCT:垃圾回收消耗总时间

jstat -gcutil 4576百分比查看垃圾回收状态

image-lKkA.png

  • S0:幸存1区当前使用比例
  • S1:幸存2区当前使用比例
  • E:伊甸园区使用比例
  • O:老年代使用比例
  • M:元数据区使用比例
  • CCS:压缩使用比例
  • YGC:年轻代垃圾回收次数
  • FGC:老年代垃圾回收次数
  • FGCT:老年代垃圾回收消耗时间
  • GCT:垃圾回收消耗总时间

JVM运行情况预估

用 jstat gc -pid 命令可以计算出如下一些关键数据,有了这些数据就可以采用之前介绍过的优化思路,先给自己的系统设置一些初始性的JVM参数,比如堆内存大小,年轻代大小,Eden和Survivor的比例,老年代的大小,大对象的阈值,大龄对象进入老年代的阈值等。

年轻代对象增长的速率

可以执行命令 jstat -gc pid 1000 10 (每隔1秒执行1次命令,共执行10次),通过观察EU(eden区的使用)来估算每秒eden大概新增多少对象,如果系统负载不高,可以把频率1秒换成1分钟,甚至10分钟来观察整体情况。注意,一般系统可能有高峰期和日常期,所以需要在不同的时间分别估算不同情况下对象增长速率。

Young GC的触发频率和每次耗时

知道年轻代对象增长速率我们就能推根据eden区的大小推算出Young GC大概多久触发一次,Young GC的平均耗时可以通过 YGCT/YGC 公式算出,根据结果我们大概就能知道系统大概多久会因为Young GC的执行而卡顿多久。

每次Young GC后有多少对象存活和进入老年代

这个因为之前已经大概知道Young GC的频率,假设是每5分钟一次,那么可以执行命令 jstat -gc pid 300000 10 ,观察每次结果eden,survivor和老年代使用的变化情况,在每次gc后eden区使用一般会大幅减少,survivor和老年代都有可能增长,这些增长的对象就是每次Young GC后存活的对象,同时还可以看出每次Young GC后进去老年代大概多少对象,从而可以推算出老年代对象增长速率。

Full GC的触发频率和每次耗时

知道了老年代对象的增长速率就可以推算出Full GC的触发频率了,Full GC的每次耗时可以用公式 FGCT/FGC 计算得出。

优化思路其实简单来说就是尽量让每次Young GC后的存活对象小于Survivor区域的50%,都留存在年轻代里。尽量别让对象进入老年代。尽量减少Full GC的频率,避免频繁Full GC对JVM性能的影响。

还有一个Arthas诊断工具,官网地址:
https://arthas.aliyun.com/doc/

gceasy(https://gceasy.io)工具可以上传gc文件,然后他会利用可视化的界面来展现GC情况。

类加载

java类加载机制

  1. 类缓存:每个类加载器对他加载过的类都有一个缓存。
  2. 双亲委派:向上委托查找,向下委派加载。
  3. 沙箱保护机制:不允许应用程序加载JDK内部的系统类。

类加载体系

  • Bootstrap ClassLoader(启动类加载器):c++实现,加载<JAVA_HOME>/lib库,所有类加载的父容器
  • Extention ClassLoader(扩展类加载器):加载扩展目录(<JAVA_HOME>/lib/extjava.ext.dirs 指定路径)下的 JAR 包
  • Application ClassLoader(应用程序类加载器):加载用户类路径(ClassPath)下的类,即我们日常编写的应用程序代码和第三方依赖包
  • Custom ClassLoader(自定义类加载器):继承 java.lang.ClassLoader 并重写 findClass() 方法。

类加载器核心方法

//类加载器的核心方法
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 每个类加载起对他加载过的类都有一个缓存,先去缓存中查看有没有加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {
               //没有加载过,就走双亲委派,找父类加载器进行加载。
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }

                if (c == null) {
                    long t1 = System.nanoTime();
                   // 父类加载器没有加载过,就自行解析class文件加载。
                    c = findClass(name);
                
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
           //这一段就是加载过程中的链接Linking部分,分为验证、准备,解析三个部分。
           // 运行时加载类,默认是无法进行链接步骤的。
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

沙箱保护机制

双亲委派机制有一个最大的作用就是要保护JDK内部的核心类不会被应用覆盖。而为了保护JDK内部的核心类,JAVA在双亲委派的基础上,还加了一层保险。就是ClassLoader中的下面这个方法。

private ProtectionDomain preDefineClass(String name,                                             ProtectionDomain pd)
    {
        if (!checkName(name))
            throw new NoClassDefFoundError("IllegalName: " + name);
        // 不允许加载核心类
        if ((name != null) && name.startsWith("java.")) {
            throw new SecurityException
                ("Prohibited package name: " +
                 name.substring(0, name.lastIndexOf('.')));
        }
        if (pd == null) {
            pd = defaultDomain;
        }
        if (name != null) checkCerts(name, pd.getCodeSource());
        return pd;
    }

Linking 链接过程

在ClassLoader的loadClass方法中,还有一个不起眼的步骤,resolveClass。这是一个native方法。而其实现的过程称为linking-链接。

类加载整体流程:

  1. 加载:通过类的全限定名获取其二进制字节流,并将该静态存储结构转换为方法区的运行时数据结构。
  2. 验证:确保 Class 文件的字节流符合 JVM 规范,不会危害虚拟机安全。
  3. 准备:为类变量(static 变量)在方法区分配内存并设置初始零值(如 int 为 0,引用为 null)。
  4. 解析:将常量池中的符号引用(一组符号描述)替换为直接引用(内存地址指针或句柄)。
  5. 初始化:首次“主动使用”类时(如 new、调用静态方法、访问静态字段等)。
  6. 使用:类完成初始化后,程序可以通过 Class 对象创建实例、调用方法、访问静态成员等。
  7. 卸载:满足以下三个条件时,类可以被 JVM 回收:
  • 该类的所有实例都已被垃圾回收。
  • 加载该类的 ClassLoader 实例已被回收。
  • 该类的 Class 对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

验证、准备、解析通常被合称为**连接(Linking)**阶段

class Apple{
    static Apple apple = new Apple(10);
    static double price = 20.00;
    double totalpay;

    public Apple (double discount) {
        System.out.println("===="+price);
        totalpay = price - discount;
    }
}
public class PriceTest01 {
    public static void main(String[] args) {
        System.out.println(Apple.apple.totalpay);
    }
}

这段代码结果为-10,根据上面的定义思考

类隔离

类隔离需要打破双亲委派机制,自定义类加载器先自己加载,没有再向上委托查找。tomcat的webapps就是相互隔离的,tomcat的jsp页面还用了轻量级的类加载器,文件更新就重新创建类加载器,实现热更新。

加载类能不能不用反射

可以用java spi机制,但注意对象不要强转,强转后会报自己不能转换成自己。

对象创建与内存分配

对象创建的主要流程:

image-MHQN.png

类加载检查

虚拟机遇到一条new指令时,首先去检查能否在运行时常量池中定位到这个类的符号引用,并检查这个类的符号引用代表的类是否已经被加载,解析和初始化过。如果没有,要先执行类加载过程。

分配内存

类加载检查通过后,要在堆上给对象分配内存,对象所需内存大小在类加载后就可以确定(引用类型的对象只存指针,对象在堆的其它空间),为对象分配空间就是把一块确定大小的内存从堆中划分出来。

如何划分内存?

  • 指针碰撞
    如果java堆内存是规整的,所有用过的内存放在一边,空闲内存放在另外一边,中间放着一个指针隔离,分配内存就是把指针向空闲内存防线移动一段和对象大小相等的距离,新生代eden区用的就是指针碰撞。
  • 空闲列表
    如果java堆内存不规整,虚拟机就要维护一个列表记录哪块内存可用,分配的时候从列表中找到一块足够的的空间给对象分配实例,并更新列表记录,Survivor区和CMS的老年代会用空闲列表,full gc用Serial Old标记整理算法,分配对象就用指针碰撞。

多线程并发安全问题

  • CAS:使用硬件级原子指令,分配失败自旋解决
  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
    每个线程预先在java堆中划出一部分内存,供对象分配使用。过-XX:+/-UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启-XX:+UseTLAB),-XX:TLABSize 指定TLAB大小。

初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间(实例变量部分)初始化为零值,对象头部分需要单独进行初始化。如果使用 TLAB,这一工作过程也可以提前至 TLAB 分配时进行。

这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

补充说明

  • 类变量(静态变量):在类加载的准备阶段已经分配内存并设置零值
  • 实例变量:在对象创建时的分配内存后进行零值初始化
  • 对象头:包含 Mark Word 和类型指针,需要单独设置(类型指针指向类元数据等)

设置对象头

初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头实例数据对齐填充

HotSpot 虚拟机的对象头包括两部分信息:

第一部分是 Mark Word,用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。这部分数据在 32 位和 64 位虚拟机中分别为 4 字节和 8 字节。Mark Word 是动态定义的,根据对象的状态(无锁、偏向锁、轻量级锁、重量级锁等)存储不同的内容。

第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。在 64 位虚拟机中,如果开启了指针压缩(-XX:+UseCompressedOops),类型指针占用 4 字节,否则占用 8 字节。

注意:如果对象是数组,对象头中还需要额外存储数组长度(4 字节),用于确定数组的大小。

32位对象头

image-rTMe.png
64位对象头

image-kopu.png

执行init方法

就是执行程序员写的或者默认的构造方法。

对象大小与指针压缩

对象大小可以用jol-core包查看,引入依赖

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>
import org.openjdk.jol.info.ClassLayout;

/**
 * 计算对象大小
 */
public class JOLSample {

    public static void main(String[] args) {
        ClassLayout layout = ClassLayout.parseInstance(new Object());
        System.out.println(layout.toPrintable());

        System.out.println();
        ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
        System.out.println(layout1.toPrintable());

        System.out.println();
        ClassLayout layout2 = ClassLayout.parseInstance(new A());
        System.out.println(layout2.toPrintable());
    }

    // -XX:+UseCompressedOops           默认开启的压缩所有指针
    // -XX:+UseCompressedClassPointers  默认开启的压缩对象头里的类型指针Klass Pointer
    // Oops : Ordinary Object Pointers
    public static class A {
                       //8B mark word
                       //4B Klass Pointer   如果关闭压缩-XX:-UseCompressedClassPointers或-XX:-UseCompressedOops,则占用8B
        int id;        //4B
        String name;   //4B  如果关闭压缩-XX:-UseCompressedOops,则占用8B
        byte b;        //1B 
        Object o;      //4B  如果关闭压缩-XX:-UseCompressedOops,则占用8B
    }
}


运行结果:
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)    //mark word
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)    //mark word   
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)    //Klass Pointer
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total


[I object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
     12     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16     0    int [I.<elements>                             N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total


com.tuling.jvm.JOLSample$A object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           61 cc 00 f8 (01100001 11001100 00000000 11111000) (-134165407)
     12     4                int A.id                                      0
     16     1               byte A.b                                       0
     17     3                    (alignment/padding gap)              
     20     4   java.lang.String A.name                                    null
     24     4   java.lang.Object A.o                                       null
     28     4                    (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total

什么是java指针压缩

  1. JDK 1.6 update 14 开始​,在 64 位操作系统中,JVM 支持指针压缩
  2. JVM 配置参数​:UseCompressedOops,其中 compressed 表示"压缩",oop 表示"ordinary object pointer"(普通对象指针)
  3. 启用指针压缩​:-XX:+UseCompressedOops(64 位 JVM 默认开启),​禁止指针压缩​:-XX:-UseCompressedOops

为什么要进行指针压缩

  1. 问题背景​:在 64 位平台中,如果不使用指针压缩,对象引用占用 8 字节,而 32 位 JVM 只需要 4 字节。这导致:

    • 内存使用增加约 1.5 倍
    • CPU 缓存命中率下降(同样缓存大小能容纳的对象变少)
    • GC 压力增大(需要扫描和移动更多内存)
  2. 解决方案​:通过指针压缩,让 64 位 JVM 也能使用 32 位(4 字节) 的对象引用,减少内存消耗

  3. 技术原理​:

    • 由于 HotSpot 默认采用 ​8 字节对齐​,对象地址的低 3 位始终为 0
    • JVM 存储指针时省略这 3 位,相当于地址右移 3 位
    • 读取时再左移 3 位恢复,相当于 地址 × 8
    • 因此 32 位偏移量可以表示 2³² × 8 = 32 GB 的地址空间
  4. 内存范围与指针压缩的关系​:

    堆内存大小 指针压缩状态 说明
    < 4 GB 开启或关闭 可以不用压缩,但开启仍有益
    4 GB ~ 32 GB 开启(推荐) 指针压缩正常工作
    > 32 GB 自动失效 强制使用 64 位指针
  5. 最佳实践​:堆内存建议控制在 32 GB 以内,以获得指针压缩带来的性能优势

对象内存分配

image-hrZx.png

栈上分配

为了减少对象在堆上的分配,JVM 通过​逃逸分析​(Escape Analysis)判断对象是否会被外部访问:

  • 如果对象​不会逃逸​(即对象的作用域仅限于方法内部),JVM 可以进行优化:
    • 标量替换​:将对象拆解为成员变量(标量),在栈上分配
    • 同步消除​:如果对象有同步块且不会逃逸,可以消除同步操作
  • 如果对象​会逃逸​,则在堆上分配(优先使用 TLAB)

这样,栈上分配的对象内存可以随栈帧出栈而自动销毁,大大减轻了垃圾回收的压力。

通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启。

标量与聚合量:标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。
结论:栈上分配依赖于逃逸分析和标量替换

对象在eden上分配

大多数情况下,对象在eden上分配,eden区没空间时,会触发minor gc,Eden与Survivor0和Survivor1比例默认是8:1:1,因为大量对象朝生夕死的假设,所以这样设计。
JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。

比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代

为什么要这样呢?

为了避免为大对象分配内存时的复制操作而降低效率。

长期存活的对象进入老年代

虚拟机给每个对象记录了年龄,每次minor gc存活下来年龄+1,年龄到达阈值就晋升老年代(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),可以通过参数 -XX:MaxTenuringThreshold 来设置。

对象动态年龄判断

当minor gc过程中,当对象需要复制到Survivor区时,JVM进行动态年龄判断,从年龄1开始依次累加对象的总大小,当累计值超过Survivor区的-XX:TargetSurvivorRatio(默认 50%)时,当前年龄及以上的所有对象晋升到老年代。

老年代空间分配担保

-XX:+HandlePromotionFailure:宽松担保,更激进,先做 Minor GC,失败了再 Full GC
-XX:-HandlePromotionFailure:保守策略,更保守,先 Full GC 再说
年轻代在每次Minor GC之前,JVM都会计算老年代剩余可用空间。

如果这个空间小于年轻代现有所有对象大小之和(包括垃圾对象),就会看是否允许担保失败。

在 ​JDK 6 及之前​,可以通过 -XX:+HandlePromotionFailure 参数来设置是否允许担保失败(JDK 7+ 后该参数已移除,默认总是允许担保失败)。

如果允许担保失败,就会看老年代可用内存大小,是否大于之前每一次Minor GC后进入老年代的平均大小。

如果是小于,或者不允许担保失败,则直接Full GC,如果回收完还是没空间,就会报错OOM。

如果是大于历史minor gc平均大小,先尝试minor GC堵GC后的对象能放进老年代,如果GC后还不行,也是Full GC,如果回收完还是没空间,就会报错OOM。

JVM执行引擎

前端编译和后端编译

前端编译:.java到.class的过程
后端编译:java字节码到机器指令的过程

解释执行与编译执行

运行时java字节码实时翻译成机器码,进行解释执行,这样的效率不如c语言。
那么提前编译好机器码应该会更快,但是提前编译就意味着确定了要运行在什么机器上,失去了跨平台优势。再就是很多代码只是启动时运行一次,热点代码并不多,提前都编译好会浪费内存,启动时间也会变长。
JVM维护了一个code cache,启动后会进行热点代码识别,标记的热点代码后端编译后缓存起来,这个过程会影响性能,缓存后运行速度就变快了,这个过程叫​即时编译器 JIT​(Just In Time Compiler)。

使用java -verison就知道使用的是什么编译模式

image-XQPr.png

可以看到默认用的是混合模式

热点代码识别

使用JIT编译重要的就是热点代码识别,识别的行为称为热点探测。
hotspot虚拟机使用了两种热点探测方法:方法调用计数器和回边探测。
超过阈值触发即时编译请求
比如这个方法计数器的默认阈值,就可以使用 java -XX:+PrintFlagsInitial -version 指令查询。

image-GZAX.png

方法计数器维度是方法,不够细,所以也要有回边计数器参与。
回边计数器是统计一个方法内循环体内代码执行次数,在字节码中遇到控制流向后跳转称为回边(Back Edge)。

阈值计算公式(有兴趣可以了解一下):回边计数器阈值 =方法调用计数器阈值(CompileThreshold)×(OSR比率(OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage)/100

其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如果都取默认值,那Server模式虚拟机回边计数器的阈值为10700。回边计数器阈值 =10000×(140-33)=10700

当解释器遇到一条回边指令时,会去先查有没有已经编译好的版本,如果没有就把回边计数器的值+1,然后判断有没有超过阈值,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。

客户端编译和服务端编译

HotSpot虚拟机中有两个即时编译器,其中前两个编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),简称为C1编译器和​C2编译器​(部分资料和JDK源码中C2也叫Opto编译器)。

C1就相当于是一个初级翻译。编译过程中,C1会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。启动快,占用内存小。但是翻译出来的机器码优化程度不太高。比较适合于一些小巧的桌面应用,因此也称为客户端编译器

​ C2就相当于是一个高级翻译。编译过程中,C2会对字节码进行更激进的优化,优化后的佮代码执行效率更高。但是相应的,工作量也变得更大了。C2的启动更慢,占用内存也更多。进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。启动慢,占用内存多,执行效率高。比较适合于一些资源充裕的服务级应用,因此也称为​服务端编译器​。

排名 层级 启动速度
1 Level 0(解释执行) 最快
2 Level 1(C1) 很快
3 Level 2(C1)
4 Level 3(C1 + Profiling) 较快
5 Level 4(C2) 最慢
情况 可能使用的层级
刚启动,代码还不热 Level 0
开始变热,需要尽快提速 Level 1/2/3
很热,且需要收集画像 Level 3
非常热,画像充足,值得深度优化 Level 4
不够热或不值得优化 停留在低层甚至解释执行

​ JDK8 中提供了参数 -XX:TieredStopAtLevel=1 可以指定最高使用哪一层编译模型。

后端编译优化技术

方法内联

方法内联就是把目标方法的代码放到发起调用的方法里,减少创建栈帧的开销。

public class InlineDemo {
    public static void foo(Object obj){
        if(obj !=null){
            System.out.println("do something");
        }
    }
    //方法内联之后会继续进行无用代码消除
    public static void testInline(){
        Object obj= null;
        foo(obj);
    }
    public static void main(String[] args) {
        long l = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            testInline();
        }
        System.out.println(">>>>>>>>"+(System.currentTimeMillis()-l));
    }
}

这段代码里的testInline方法就是死代码(Dead Code),在内联过程中会抹除死代码。

在JDK8中,提供了多个跟Inline内联相关的参数,可以用来干预内联行为。

  • -XX:+Inline 启用方法内联。默认开启。
  • -XX:InlineSmallCode=size 用来判断是否需要对方法进行内联优化。如果一个方法编译后的字节码大小大于这个值,就无法进行内联。默认值是1000bytes。
  • -XX:MaxInlineSize=size 设定内联方法的最大字节数。如果一个方法编译后的字节码大于这个值,则无法进行内联。默认值是35byt
  • -XX:FreqInlineSize=size 设定热点方法进行内联的最大字节数。如果一个热点方法编译后的字节码大于这个值,则无法进行内联。默认值是325bytes。
  • -XX:MaxTrivialSize=size 设定要进行内联的琐碎方法的最大字节数(Trivial Method:通常指那些只包含一两行语句,并且逻辑非常简单的方法。比如像这样的方法
public int getTrivialValue() {  
    return 42;  
}

)。默认值是6bytes。

  • -XX:+PrintInlining 打印内联决策,通过这个指令可以看到哪些方法进行了内联。默认是关闭的。另外,这个参数需要配合-XX:+UnlockDiagnosticVMOptions 参数使用。

写代码的时候可以提高内联概率的方法

1、 在编程中,尽量多写小方法,避免写大方法。方法体超过 -XX:MaxInlineSize(默认35字节)的方法,在非热点情况下无法被内联;超过 -XX:FreqInlineSize(默认325字节)的方法,即使在热点情况下也无法被内联。此外,大方法成为热点方法编译后,会占用更多的CodeCache空间。

2、 在内存不紧张的情况下,可以通过调整JVM参数让更多方法进行内联:增加 -XX:MaxInlineSize 可以让更多普通方法内联;增加 -XX:FreqInlineSize 可以让更多热点方法内联。注意,减少 -XX:CompileThreshold 影响的是方法何时被JIT编译成为热点方法,与方法是否能内联无直接关系。

3、 尽量使用 finalprivatestatic 关键字修饰方法。static 方法使用 invokestatic 指令,private 方法使用 invokespecial 指令,final 方法虽然是虚方法但不可重写,这三类方法在运行时都能确定唯一调用目标,因此容易被JIT编译器内联。对于非 final 的虚方法,HotSpot VM通过内联缓存和类层次分析(CHA),在单态调用场景下也可能实现去虚拟化并内联,但多态调用会增加内联难度。

逃逸分析

一个对象在方法里被定义后,可能被外部方法引用(比如参数传递),这个叫方法逃逸。还可能被其它线程访问到,这个叫线程逃逸。可以添加参数 -XX:-DoEscapeAnalysis 主动关闭逃逸分析。

如果能证明一个对象不会逃逸到方法或者线程之外,就可以进行后续的优化:

  • 标量替换:对象分配章节有解释
  • 栈上分配:对象分配章节有解释

锁消除

当JVM检测到一段加锁的代码不存在锁竞争时,会进行锁消除操作。

多线程并发资源竞争是一个很复杂的场景,所以通常要检测是否存在多线程竞争是非常麻烦的。
但是有一种情况很简单,如果一个方法没有发生逃逸,那么他内部的锁都是不存在竞争的。