JVM调优

内容纲要

JVM内存结构

1. 堆

  • 堆是jvm内存中最大的区域,大部分对象都是存储在堆中

2. 虚拟机栈

  • 虚拟机栈是线程独享的,当创建了一个线程后就会创建一个虚拟机栈

    3.本地方法栈

  • 本地方法栈都是由C语言实现

4. 程序计数器

  • 用来记录各个字节码执行的地址,例如分支,循环,跳转,异常,线程恢复操作等都需要依赖程序计数器

5. 方法区

常量池-静态常量池

  • 也叫class文件常量池,主要存放:
    • 字面量: 例如文本字符串、final修饰的常量
    • 符号引用:例如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符

常量池-运行时量池

  • 当类加载到内存中后,JVM就会将静态常量池中的内容存放到运行时常量池中;运行时常量池里面存储的主要是编译期间生成的字面量、符号引用等等。

常量池-字符串常量池

  • 字符串常量池,也可以理解成运行时常量池分出来的一部分,类加载到内存的时候,字符串,会存到字符串常量池里面

类加载的过程详解

链接-验证

  • 作用:验证class文件是不是符合规范
    1. 文件格式的验证
      • 是否是以0xCAFEBABE开头
      • 版本号是否合理
    2. 元数据验证
      • 是否有父类
      • 是否继承了final类(final类不能被继承,如果继承了就有问题了)
      • 非抽象类实现了所有抽象方法
    3. 字节码验证
      • 运行检查
      • 栈数据类型和操作码操作参数吻合(比如栈空间只有2字节,但其实却需要大于2字节,此时就认为这个字节码有问题的)
      • 跳转指令只想合理的位置
    4. 符号引用验证
      • 常量池中描述类是否存在
      • 访问的方法或字段是否存在且有足够的权限
    5. 可以使用-Xverify:node关闭验证

链接-准备

  • 作用:为类的静态变量分配内存,初始化为系统的初始值
    • final static 修饰的变量:直接赋值为用户定义的值,比如private final static int value = 123,直接赋值123
    • private static int value = 123,现阶段的值依然是0,没有进行赋值

链接-解析

  • 作用:符号引用(做了个标记,我想引用谁)转换成直接引用(直接去引用这个对象)

初始化

  1. 执行<clinit>方法,clinit方法由编辑器自动收集类里面的所有静态变量的赋值动作及静态语句块合并而成,也叫构造方法
    • 初始化的顺序和源文件中的顺序一致
    • 子类的<clinit>被调用前,会先调用父类的<clinit>
    • JVM会保证clinit方法的线程安全性

字节码的运行

  • 解释执行:由编译器一行一行的翻译执行
    • 优势在于没有编译的等待时间
    • 性能相对差一些
  • 编译执行:把字节码编译成机器码,直接执行机器码
    • 运行效率会高很多,一般认为比解释执行快一个数量级
    • 带来了额外的开销

查看运行模式

java -version
-Xint:设置JVM的执行模式为解释执行模式
-Xcomp:JVM优先以编译模式运行,不能编译的,以解释模式运行
-Xmixed:混合模式运行(默认)

一般情况下

  • 一开始一般由解释器解释执行
  • 当虚拟机发现某个方法或代码快的运行特别频繁的时候,就会认为这些代码是“热点代码”,为了提高热点代码的执行效率,会用即时编译器(也就是JIT)把这些热点代码编译成与本地平台相关的机器码,并进行层次的优化

Hostpot的即时编辑器

C1编译器

  • 是一个简单快速的编译器
  • 主要关注局部性的优化
  • 适用执行时间较短或对启动性能有要求的程序。例如,GUI应用对界面启动速度就有一定要求
  • 也被称为Clinent Compiler

C2编译器

  • 是为长期运行的服务端应用程序做性能调优的编译器
  • 使用于执行时间较长或对峰值性能有要求的程序
  • 也被称为是Server Compiler

分层编译

  1. 解释执行
  2. 简单C1编译:会用C1编译器进行一些简单的优化,不开启Profiling
  3. 受限的C1编译:仅执行带方法调用次数以及循环回边执行次数Profiling的C1编译
  4. 完全C1编译:会执行带有所有profiling的C1代码
  5. C2编译:使用C2编译器进行优化,该级别会启用一些编译耗时较长的优化,一些情况下会根据性能监控信息进行一些非常激进的性能优化
    级别越高,应用启动越慢,优化的开销越高,峰值性能也越高

JVM参数配置示例

  • 只想开启C2:-Xx:-TieredCompilation(禁用中间编译层(1 2 3层))
  • 只想开启C1: -XX:+TieredCompilation -XX:TieredStopAtLevel=1(想使用的级别)

热点代码

如何找到热点代码:

  • 基于采样的热点探测(方法在栈顶的次数)
  • 基于计数器的热点探测

Hotspot内置计数器

方法调用计数器(Invocation Counter)

  • 用于统计方法被调用的次数,在不开启分层编译的情况下,在C1编译器下的默认值为1500次,在C2模式下是10000次。也可以使用-XX:CompileThreshold=X指定阈值

方法计数器的执行流程

  • 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分的方法都会被编译成本地代码。另外,可以使用-XX:CounterHalfLifeTime 设置半衰周期的时间,单位是秒

回边计数器(Back Edge Counter)

  • 用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为"回边"(Back Edge)。在不开启分层编译的情况下,C1编辑器默认值是139995,C2默认为10700,可以使用-XX:OnStackReplacePercentage=x指定阈值
  • 建立回边计数器的主要目的是为了触发OSR(OnStackReplacement)编译,参考文档:https://www.zhihu.com/question/45910849/answer/100636125

回边计数器执行流程

  • 开启分层编译时,JVM会根据当前待编译的方法数以及编译线程数来动态调整阈值,-XX:CompileThreshold、-XX:OnStackReplacePercentage都会失效

编辑器优化-方法内联

  • 把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用

    条件

  • 方法体足够小
    • 热点方法:如果方法体小于325字节会尝试内联,可用-XX:FreqInlineSize 修改大小
    • 非热点方法:如果方法体小于35字节,会尝试内联,可用XX:MaxInlineSize 修改大小
  • 被调用方法运行时的实现被可以唯一确定
    • static方法、private方法及final方法,JIT可以唯一确定具体的实现代码
    • public的实例方法,指向的实现可能是自身、父类、子类的代码当且仅当JVM能够唯一确定方法的具体实现时,才有可能完成内联

注意点

  • 尽量让方法体小一些
  • 尽量使用final, private,static关键字修饰方法,避免因为多态,需要对方法做额外的检查
  • 一些场景下,可以通过JVM参数修改阈值,从而让更多方法内联

内联代码的问题

  • 空间换时间的方法
  • CodeCache的溢出,导致JVM退化成解释执行模式(JDK8只有240MB)

内联相关参数

参数名 默认 说明
-XX:MaxTrivialSize=n 6 如果方法的字节码少于该值,则直接内联,单位字节
-XX:MinlnliningThreshold=n 250 如果目标方法的调用次数低于该值,则不去内联
-XX:LiveNodeCountInliningCutoff=n 40000 编译过程中最大活动节点数(IR节点)的上限,仅对C2编译器有效
-XX:InlineFrequencyCount=n 100 如果方法的调用点(call site)的执行次数超过该值,则触发内联
-XX:MaxRecursivelnlineLevel=n 1 递归调用大于这么多次就不内联
-XX:+lnlineSynchronizedMethods 开启 是否允许内联同步方法

逃逸分析、标量替换、栈上分配

逃逸分析

  • 分析变量能否逃出它的作用域
    • 全局变量赋值逃逸:局部变量赋值给静态变量
    public static SomeClass someClass;
    //全局逃逸
    public void globalVariablePointerEscape(){
        someClass = new SomeClass();
    }
  • 方法返回值逃逃逸
    //方法返回值逃逸
    //someMethod(){
    //    SomeClass someClass = methodPointerEscape();
    //}
    public SomeClass methodPointerEscape(){
        return new SomeClass();
    }
  • 实例引用逃逸
//实例引用传递逃逸
public void instancePassPointerEscape(){
        this.methodPointerEscape().printClassName(this);
}
class SomeClass{
   public void printClassName(EscapeTest1 escapeTest1){
        System.out.println(escapeTest1.getClass().getName());
   }
}
  • 线程逃逸
    • 赋值给类变量或者可以在其他线程中访问的实例变量

逃逸状态标记

  • 全局级别逃逸:一个对象可能从方法或者当前线程中逃逸
  • 对象被作为方法的返回值
  • 对象作为静态字段(static field)或者成员变量(field)
  • 如果重写了某个类的finalize()方法,那么这个类的对象都会被标记为全局逃逸状态并且一定会放在堆内存中

标量替换

  • 标量:不能被进行步分解的量
    • 基础数据类型
    • 对象引用
  • 聚合量:可以进一步分解的量
  • 通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是创建它的成员变量来代替
public void someTest(){
    SomeTest someTest = new SomeTest();
    someTest.age = 1;
    someTest.id = 1;

    //开启标量替换后
    int age = 1;
    int id = 1;
}
  • -XX:+EliminateAllocations开启标量替换(JDK 8默认开启)

栈上分配

  • 通过逃逸分析,能够确认对象不会被外部访问,就在栈上分配对象

相关JVM参数

参数 默认值(JDK8) 作用
-XX:+DoEscapeAnalysis 开启 是否开启逃逸分析
-XX:+EliminateAllocations 开启 是否开启标量替换
-XX:+EliminateLocks 开启 是否开启锁消除
THE END
分享
二维码
< <上一篇
下一篇>>