Java如今还比C++执行慢吗?——JVM运行期优化

大纲

前言

是否是不少人的印象中,Java要比C++运行的慢?若是如今还停留在这个想法,那或许该更新下想法了,由于Java近几年在运行优化方面作了很是多的研究和改进,能够说已经基本不怎么输于C++的运行速度了。前端

咱们参照HotSpot虚拟机的优化来讲明,不一样虚拟机确定是不一样的,可是也有参考价值。java


个人全部文章同步更新与Github--Java-Notes,想了解JVM,HashMap源码分析,spring相关,剑指offer题解(Java版),能够点个star。能够看个人github主页,天天都在更新哟。git

邀请您跟我一同完成 repo程序员


解释器和即时编译器

所谓无风不起浪,**为啥你们以为Java的执行速度要比C++慢呢?**如今又怎么说两者如今的执行速度差异不太大了呢?github

由于早期的Java代码主要都是由解释器来完成,而且即便用到即时编译器,即时编译器的性能优化也作得不是太好,因此早期才有了上面说的"Java比C++执行慢",因此才说那是早期的思想。,如今Java优化作的很是好,咱们就挨个说下吧算法

如今主流的(不是所有)虚拟机都采用解释器编译器共存的架构,两者互相配合工做,以下图所示spring

两者互有优缺点,优缺点正好相对。后端

  • 解释器
    • 优势
      • 程序须要快速启动时,能够省去编译的时间
      • 不占用内存资源,由于不须要编译成本地代码
    • 缺点
      • 执行速度低
    • 还有个做用,做为**"激进优化"逃生门**,本篇后面会讲到
  • 编译器
    • 优势
      • 把更多的代码编译成本地代码,提升执行效率
    • 缺点
      • 不能当即启动,编译须要时间
      • 占用一些内存资源

因此啊,虚拟机才会配合两者进行使用。那些"热点代码"(这个概念后面会提到)会被即时编译器编译成本地代码,以此来提升执行速度,"非热点代码"就用解释器来执行。为啥这样呢?可能符合"二八定理"吧。20%的代码是热点代码,可是他们却可能占用80%的执行资源。数组

咱们看到上面的图片中,编译器有两种,一个是客户端的,一个是服务端的,前者被称为C1编译器,后者称为C2编译器,两个的参数设置也不同。优化手段也不太同样,后面会讲到缓存

编译对象与触发条件

对象

咱们以前提到,即时编译器只编译那些"热点代码",那么啥是热点代码呢?只有如下两类

  • 被屡次调用的方法
  • 被屡次执行的循环体

前者比较好理解,后者有点很差理解。若是一个方法中,存在循环屡次的循环体,那么这个循环体的代码也被执行了不少次,因此也认为是"热点代码"。可是虽然是因为循环体形成他是热点代码的,可是编译器编译的时候,是根据整个方法进行编译(而不是只编译循环体)。这种编译方式因为编译发生在方法执行过程当中,所以被形象的称为栈上替换(On Stack Replacement,简称OSR)。前者也是以整个方法做为编译对象

条件

那么啥是度呢?多少次才叫屡次?才会触发即时编译条件呢?

判断一段代码是否是热点代码,是否是须要触发即时编译,咱们称之为热点探测,主流的热点探测断定方式有两种

  • 基于采样的热点探测
  • 基于计数器的热点探测(JVM采用)
  • 看到这两个有没有想起之前学过的哪里和这个看上去很像?还记得断定对象是否死亡的两种方法吗?可达性分析和引用计数,其实第一种和可达性分析还真有些地方很像,他能够看调用关系,就像可达性分析的路径。(学知识,这样对照学才会印象深入,能够说有这样的想法,东西已经算是你的了)

基于采样的热点探测:采用这种方法的虚拟机会周期性的检查各个线程的栈顶,若是发现某个(或者某些)方法常常出如今栈顶,那这个方法就是"热点方法"。(有没有像可达性分析法搜索可达路径?)

基于计数器的热点探测:为每个方法(甚至是代码块)创建一个计数器,统计是否超过阈值。还记得两种热点代码吗?他专门准备了两种相对的计数器。前者是方法调用计数器,后者是回边计数器

两个方法优劣对比:

  • 基于采样

    • 好处:
      • 实现简单、高效
      • 能够很容易获取方法调用关系(将调用栈展开便可)
    • 缺点
      • 难精确确认一个方法的热度,容易受到线程阻塞或别的因素影响
  • 基于计数器

    • 好处
      • 统计精确
    • 缺点
      • 要为每一个方法创建一个计数器
      • 不能获取调用关系

工做步骤

咱们先看图

方法调用计数器

当一个方法执行时,

  • 首先检查这个方法是否被JIT编译过(便是否存在被JIT编译过的代码版本)
    • 若是有,先执行编译过的代码版本
    • 没有,调用计数+1,判断两个计数器之和是否超过阈值(注意是两个计数器)
      • 超过阈值,向即时编译器提交该方法的编译请求
      • 没有超过,继续以解释器方式执行。
      • 若是不设置,那么他不会等到编译器编译完成,执行编译后的代码,而是执行解释器,下一次调用才执行编译后的代码

这里的方法调用计数器里面的值不是绝对值,而是一个相对的执行频率,即一段时间以内方法被调用的次数。超过必定时间,调用次数仍然未达到阈值,那么方法调用计数器的值就会减小一半,这个过程称为方法调用计数器热度的衰减。这个像不像原子的衰变?而后这个时间,他们就取名为半衰周期。可是这个是能够关掉的,相关参数设置在参数那段讲

回边计数器

啥是回边呢?在字节码中遇到控制流向后跳转的指令,被称为"回边",这个计数器也是为了触发OSR编译(这个概念,上文讲过)

执行步骤和方法调用计数器类似

不一样的是,这个计数器没有所谓的半衰

相关参数

  • -client/-server:指定JVM运行哪一种模式
  • -Xint:强制JVM使用解释模式,编译器不工做
  • -Xcomp:和上面不一样的是,他只是优先编译器工做,当编译没法进行的状况下,解释器仍是会介入
  • -XX: -UseCounterDecay:关闭热度衰减
  • -XX: CounterHalfLifeTime:设置半衰期时长,单位秒
  • -XX: CompileThreshold:设置方法调用阈值
  • -XX: BackEdgeThreshold:设置回边计数器阈值
  • -XX: -BackgroundCompilation:禁止后台编译,达到阈值,等到编译完再往下执行,且执行编译好的代码

方法在JVM中的内存布局

一行长度为32bit

后台编译过程

后台编译过程,Client Compiler (C1)和Server Compiler(C2)的工做是不同的,后者更为复杂,是全局优化,前者主要是局部性的优化

C1

共分为三个阶段:

  1. 一个平台独立的前端将字节码构形成一种高级中间代码表示(High-Level Intermediate Representation,HIR)
    • HIR使用静态单分配(SSA)的形式来表明代码值,这能够是的一些在HIR的构造过程之中和以后进行的优化动做更容易实现。
    • 再此以前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构形成HIR以前完成
  2. 一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation)
    • 而在此以前会在HIR上完成一些优化,如空值检查消除、范围检查消除等
  3. 平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上作窥孔(Peephole)优化,而后产生机器代码。

编译优化技术

一共有很是多的技术

不过最具备表明性的是如下四个:

  • 语言无关的经典优化技术之一:公共子表达式消除
  • 语言相关的经典优化技术之一:数组范围检查消除
  • 最重要的优化技术之一:方法内联
  • 最前沿的优化技术之一:逃逸分析(形成java实例不必定所有在堆中分配)

公共子表达式消除

若是一个表达式E已经计算过了,而且先前的计算到如今E中全部变量的值都没有发生变化,那么E的此次出现就成为了公共子表达式

若是有一下程序代码

int d = (c * b) * 12 + a + (a + b *c);

当这段代码进入虚拟机即时编译器后,他将进行以下优化:

  • 检测到 c * b 与 b*c 是同样的
  • 而且计算期间值没有变化
  • 就会被视为 int d = E * 12 + a + (a+E)
  • 也可能发生以下变化
    • int d =E * 13 + a *2

数组边界检查消除

咱们知道,相同的代码,Java要比C++执行的慢,这也是人们会有一开始的那种想法的缘由之一。那么为何呢?

Java程序因为有虚拟机,不少东西都会替你来作,好比越界检查,除零检查,垃圾回收这种。

好比数组 nums[],Java中若是你要访问其中一个元素,你的下标必定是在[0,num.length-1]的,若是超出了,他就会报错java.lang.ArrayIndexOutOfBoundsException.可是C或者C++,使用的是裸指针,所以这种判断须要程序员本身操做。

Java中,若是你要对数组进行读写操做,那么就必定要检查他的范围是否超出,这是由JVM进行,可是大量的执行步骤中,都会带有这个,哪怕你知道不可能超出,可是JVM可不知道。

提早到编译期检查

因此能不能告诉他,"咱们的必定不会超出,你别再检查了呢?"

事实上,不行,,由于数组越界检查是必须作的。可是能够跟他谈判,让他减小次数提早到编译期检查一次

好比咱们有下面的程序 nums[3],只要在编译期间根据数据流分析来肯定nums.length的值,而且判断下标 3并无越界,那么执行的时候就不必判断了。

隐式异常处理

用于:

  • Java中空指针检查
  • 算术运算符中除数为0的检查

若是有下列代码

if(num != null){
    return num.getVal();
}else {
    throw new NullPointerException();
}
复制代码

使用隐式异常优化后

try{
  return num.val;
}catch(segment_fault){
  uncommon_trap();
}
复制代码

虚拟机会注册一个Segment Fault信号的异常处理器,

  • 当num不为空的话,对val的访问是不会额外消耗一次对num判空的开销的。
  • 当num为空
    • 必须转到异常处理器中恢复,而且抛出NullPointerException异常
      • 这个过程必须从用户态转到内核态中处理
      • 结束后在返回用户态处理,速度更慢
  • 因此当,空值极少时,这种隐式异常优化是值得的

方法内联

他的重要意义是为其余优化手段打下基础,为啥这样说呢,请看下面的示例:

public class DeadCode {
    public static void testInline(String[] args) {
        Object object = null;
        foo(object);
    }
    
    public static void foo(Object object){
        if(object != null){
            System.out.println("do something");
        }
    }
}
复制代码

咱们本身看下就应该知道,testInline()方法中的代码根本没有一点意义。咱们称这种代码是"Dead Code"

若是不进行内联,虚拟机根本不会发现,即便他进行了无用代码消除,他在即时编译的时候也不会消除这些代码。由于分开来看,这两个方法都是有意义的啊

方法内联的行为看起来彷佛很简单

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

可是真的这么简单吗?不是的,根据经典编译原理的优化理论,大部分的Java方法都不能够内联

咱们知道方法的调用分为:解析和分派,尤为是分派中的动态分派,编译期是不能知道调用的是哪一个类的方法(能够参考个人这篇文章,重载和重写的区别-方法调用层次)

好比下面的代码

public class DeadCode {
    
    static class ParentB{
        int val;
        public int getVal(){
            return val;
        }
        public ParentB(int val){
            this.val = val;
        }
        public ParentB(){
        }

    }
    static class SubB extends ParentB{
        public int getVal(){
            return val;
        }
        public SubB(int val){
            this.val = val;
        }

        public SubB() {
        }
    }

    public static void main(String[] args) {
        ParentB subB = new SubB(5);
        System.out.println(subB.getVal());
    }
}
复制代码

编译期,他是不会知道 getVal()调用的是 sub的仍是Parent的方法,只有运行期才会知道

那怎么办呢?

解决方法

首先JVM团队想到CHA(类型继承关系分析,Class Hierarchy Analysis)技术

一种基于整个应用程序的类型肥西技术,用于肯定在目前已加载的类中,某个接口是否有多于一种的实现,某各种是否存在子类、子类是否为抽象类等信息。

  • 编译器在内联时,若是是非虚方法(若是不知道这个概念,能够参考个人这篇方法调用的文章,重载和重写的区别-方法调用层次),那么直接内联就行

  • 若是遇到虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,

    • 若是查询结果结果只有一个版本,能够进行内联,不过这个是激进优化(能够类比内存分配策略的空间担保),须要预留一个"逃生门"(可能由解释器或者没有解释器版本的虚拟机用没有进行激进优化的C1做为逃生门),称为"内联守护"
      • 若是程序的后续执行过程当中,JVM一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就能够一直使用下去。
      • 若是出现了那个新类,那就须要抛弃已经编译好的代码,退回到解释状态执行,或者C1从新编译
  • 若是向CHA查询的结果是有多个版本的目标方法能够选择,

    • 编译器会进行最后一次尝试,使用内联缓存完成内联

      ​ 这个是创建在目标方法正常入口以前的缓存

    • 未发生方法调用以前,内联缓存为空

    • 发生第一次调用,缓存记录下方法接收者的版本信息,而且每次调用都比较接收者版本

      • 若是之后每次进来的方法接收者版本都是同样的,那这个内联还能够进行下去
      • 不一致,则说明程序真正使用了虚方法的多态特性,这时候才会取消内联,查找虚方法表进行分派(这个表也能够在这找到重载和重写的区别-方法调用层次)

逃逸分析

他和上面的CHA(类型关系继承分析)同样,都不是直接优化的手段,而是为其余优化手段提供依据的分析技术

干什么

逃逸分析的基本行为就是:分析对象动态做用域

  • 方法逃逸:当一个对象在方法中被定义后,他可能被外部方法引用,例如做为调用参数传递到其余方法
  • 线程逃逸:上述方法甚至还能被外部线程访问到,譬如给类变量赋值能够在其余线程访问到的实例变量

优化手段

若是能证实一个对象不会有方法逃逸和线程逃逸,那就能够对其进行高效的优化:

  • 栈上分配
    • 正常来讲对象是在Java堆上进行分配的(不知道的能够看我这篇 JVM运行时数据区)。可是放到堆上就意味着垃圾回收的时候要进行判断还要进行回收,而且还要整理空间。可是咱们知道若是他不会逃逸,那么就在栈上分配,工做完随着栈销毁就好了,不须要垃圾回收器那些工做。
  • 同步消除
    • 和上面同样,若是一个变量不发生逃逸的,就不会存在线程间的竞争,那么就不必对其进行同步措施了
  • 标量替换
    • 概念
      • 标量:指一个数据已经没法再分解成更小的数据来表示了,Java中的原始数据(int,long、reference等)
      • 聚合量:相对于标量。一个数据能够进行分解。好比Java对象
      • 标量替换:若是把一个Java对象分解,根据程序的访问状况,将其使用到的成员变量恢复原始数据来访问
    • 若是根据逃逸分析证实一个对象不会逃逸,而且能够拆分,
      • 那么就可能不会建立对象,直接建立若干个他的(对象)被这个方法用到的成员变量来代替
      • 而且这些成员变量不只是分配在栈上,并且能够作进一步优化

总结

  • 通常主流的虚拟机有两种部分
    • 解释器
    • 编译器
      • 编译对象——热点代码
  • HotSpot采用的热点探测
    • 基于计数器的热点探测
      • 计数器
        • 方法调用计数器
        • 回边计数器
  • 编译优化技术(表明)
    • 公共子表达式消除
    • 数组边界检查消除
      • 提早到编译期
      • 隐式异常处理
    • 方法内联
    • 逃逸分析
      • 栈上分配
      • 标量替换
      • 同步消除
相关文章
相关标签/搜索