目录

浅淡JVM和Java的未来 ——某次技术分享

转载请注明出处:https://www.jooks.cn/article/53

水平有限,见谅~

简介

在Java成熟之前,开发者如果希望程序在Linux、Windows等不同系统,或者是x86、x64、ARM等不同指令集架构上都能正常运行,就必须针对每种运行环境,编译出相应的二进制发行包。举个例子,C++在Windows平台中编译出来的exe文件,在Linux或者安卓上不能运行,这是因为Linux系统识别不了这种只能被Windows平台读懂的机器语言。

于是Java横空出世。“Write once, run anywhere"也就是:“一次编写,到处运行”。这是20多年前,Java出生时大喊的口号。Java通过语言层虚拟化的方式,令每一个Java应用都自动取得平台无关(Platform Independent)、架构中立(Architecture Neutral)的先天优势。

https://img.jooks.cn/img/20210506225247.png

一样的代码,编译成class文件后,可以在任意平台的Java虚拟机上运行。

这是Java虚拟机带来的好处之一。

另外一个好处就是带来了一个托管环境(Managed Runtime)。这个托管环境能够代替我们处理一些代码中冗长而且容易出错的部分。其中,广为人知的,当属自动内存管理和垃圾回收。当然,还有数据越界的判断、动态类型、安全权限等等动态检测。

Java虚拟机的英文全称是Java Virtual Machine,简称JVM,下面都直接称JVM。

JVM内存模型

https://img.jooks.cn/img/20210507213438.png

上面是祖传的图。

  • PC寄存器:又叫程序计数器,可以看作是当前线程所执行的字节码的行号指示器。字节码通过改变这个寄存器里保存的值,来选取下一条需要执行的字节码指令。

    如果执行的是一个Java方法,那计数器记录的是正在执行的虚拟机字节码指令的地址;

    如果正在执行的是一个Native方法,那么计数器中的值则应是Undefined;

    PC寄存器是《Java虚拟机规范》中唯一一个没有规定任何OOM (Out Of Memory)情况的区域。

  • Java方法栈:又叫Java虚拟机栈。当Java方法(函数)被执行时,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法被调用直至执行完毕的过程,就对应着一个栈帧出栈入栈的过程。

    局部变量表是用来保存编译器可知的各种方法内定义的基本数据类型对象引用和returnAdress类型。自定义的类对象不保存在这里!在堆里!

    字节码指令大全:https://docs.oracle.com/javase/specs/jvms/se13/jvms13.pdf

    字节码文件介绍:https://www.jooks.cn/article/37

    https://img.jooks.cn/img/20210507223152.png

    https://img.jooks.cn/img/20210507223229.png

    https://img.jooks.cn/img/20210507223249.png

  • 本地方法栈:与Java方法栈类似,不过只用来为Native方法(C/C++编写的)提供服务。

  • 堆:所有的自定义类对象都在这里分配内存,Java堆是垃圾收集器管理的内存区域。

  • 方法区:用于存储被虚拟机加载的类型信息、常量(基本类型的包装类、String类)、静态变量、即时编译器编译后的代码缓存等数据。

    运行时常量池 是方法区的一部分。Class文件中有一项是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容在类加载后存放到方法区的运行时常量池中。但是并非编译期才能往里面放数据,运行期间同样可以,比如String类的intern()方法。 https://img.jooks.cn/img/20210509082035.png

  • 直接内存:直接内存不在JVM范围内,NIO(No blocking I/O,非阻塞IO)引入了基于Channel和Buffer的I/O方式,可以使用Native函数库直接分配堆外内存,避免Java堆和Native堆来回复制数据。

    但是如果内存申请过多,超过了机器的上限,会造成操作系统层面的OOM异常。

垃圾回收(Garbage Collection, GC)

前面说了,垃圾管理主要在堆空间中。

Java 虚拟机的自动内存管理,将原本需要由开发人员手动回收的内存,交给垃圾回收器来自动回收。

怎么判断垃圾?

  1. 引用技术法 (古老的辨别方法):为每个对象添加一个引用计数器,来统计指向这个对象的引用个数。一旦某个对象的引用计数器为0,则说明该对象已经死亡,是个垃圾,占用的堆内存可以被回收~

    缺点:无法解决循环引用

  2. 可达性分析 (主流JVM采用的算法):设置一系列GC Roots,然后从GC Roots去遍历各个节点,同时进行标记。结束后,如果没有被标记的就是垃圾~

怎么回收垃圾?

  1. 标记-清除算法

    先标记出需要回收的对象,然后统一回收所有被标记的对象。算法简单,但效率不高,且容易产生内存碎片,降低内存的利用率。

    https://img.jooks.cn/img/20210507231350.png

  2. 标记-复制算法

    将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完,就进行一次标记,将存活着的对象复制到另一块上面,然后把原来的空间整个清理掉。算法简单,减少了内存碎片,但可用内存减少了。且当对象存活率较高时需要复制的操作较多,效率会变低。

    https://img.jooks.cn/img/20210507231618.png

  3. 标记-整理算法

    先标记,标记后,将存活的对象移动到一块区域,然后直接清理掉这块区域外的内存。这样可以避免内存碎片产生,且无需只使用一半的内存,但是这种算法的开销比较大。

那现在主流的JVM是怎么做的呢?

现在主流的JVM (如HotSpot)使用的是分代收集方式。Java虚拟机将堆划分为新生代和老年代,其中新生代又被划分为Eden区,以及两个大小相同的Survivor区,如下图所示。

https://img.jooks.cn/img/20210507232909.png

新生代和老年代有相应的垃圾收集器,不同垃圾收集器所执行的具体垃圾回收算法不同。感觉篇幅太长,这里不展开。

当新建对象时,jvm会在Eden区中划出一块来存储对象。当Eden耗尽之后,Java虚拟机会触发一次 Minor GC,对新生代进行一次垃圾回收,本质是标记-复制算法。将Eden和Survivors(其中一块)的存活对象复制到另一块Survivors空间中,然后把Eden、Survivors(其中一块)整块清除掉。

且后面不断循环进行上面过程。

这里一般Eden都会比Survivors大,因为根据二八定律,程序中大部分的对象都会被回收掉,留下的对象会比较少。

默认情况下,JVM采取的是动态分配空间的策略,根据对象的生成速率,以及Survivor区的使用情况,动态调整Eden和Survivor的比例。

当然,也可以通过虚拟机参数-XX:SurvivorRatio来指定这个比例。

JVM在这里为了处理多线程并发新建对象可能产生的冲突,使用了TLAB(Thread Local Allocation Buffer)技术。感兴趣自行百度~

当某个对象在Survivor区中来回复制了15次(默认)时,该对象将会晋升到老年代。

另外,如果单个Survivor区被占用至50%(默认),拥有相对较高复制次数的对象也会晋升到老年代。(缓解新生代的内存压力)

老年代的垃圾回收器中,Serial Old 和 Parallel Old采用标记-整理算法,CMS采用标记-清除算法。

即时编译

class文件在Java虚拟机中首先会被解释执行,之后反复执行的热点代码会被即时编译器编译成机器码,以更高的效率直接运行在底层硬件上。

HotSpot虚拟机包含多个即时编译器:C1、C2和Graal。

  • C1编译器:又叫Client编译器,设计初衷是面向要求启动速度快的客户端GUI程序,采用的优化手段相对简单,因此编译时间短。
  • C2编译器:又叫Server编译器,设计初衷是面向对峰值性能有要求的服务器端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高。
  • Graal编译器:Java10引入的实验性即时编译器,不太了解。。。

Java 7开始,HotSpot虚拟机默认采用分层编译的方式,大致流程是:首先解释执行,有需要再用C1编译器编译,若还有需要再用C2编译器编译。

为了不干扰应用的正常运行,HotSpot即时编译器是在额外的编译线程中进行的,HotSpot会根据CPU的数量设置编译线程的数目,且按照 1:2 的比例配置给C1和C2编译器。

分层编译将Java虚拟机的 执行状态 分为5层,如下:

  1. 解释执行;
  2. 执行不带profiling的C1代码;
  3. 执行仅带方法调用次数以及循环回边执行次数profiling的C1代码;
  4. 执行带所有profiling的C1代码;
  5. 执行C2代码。

下图是执行状态的4个不用的编译路径(还有更多)

https://img.jooks.cn/img/20210508185553.png

这里的profiling是指能够反映程序执行状态的数据。

比如:方法的调用次数、循环回变执行次数、分支跳转次数、非私有实例方法调用指令、强制类型转换checkcast指令、类型测试instanceof指令、引用类型的数组存储aastore指令。

也就是说,2、3执行前的即时编译阶段都会进行profile的收集,为4执行前的C2编译做准备。

这里的1和4状态都是终止状态,不会再往其他状态转变。

什么时候会触发即时编译呢?

在分层编译中,会有一套阈值系统,阈值的大小会根据 当前待编译的方法数目编译线程的数目动态调整。

举个即时编译优化的例子

public static int foo(boolean f, int in) {
    int v;
    if (f) {
        v = in;
    } else {
        v = (int) Math.sin(in);
    }
    if (v == in) {
        return 0;
    } else {
        return (int) Math.cos(v);
    }
}

给这段代码画一个流程图如下

https://img.jooks.cn/img/20210508200319.png

假如我一直执行foo(true, 1);,达到一定阈值,会触发比较激进的C2即时编译器编译(因为分支profile是给C2编译器处理的),生成的机器码里的逻辑如下图。

https://img.jooks.cn/img/20210508201230.png

这里正常情况下会是true,然后一直return 0。但是如果进来一个false呢?

其实这个地方的false会变成一个陷阱 (trap)。在机器码中,一旦掉入这个陷阱,就会执行 去优化,返回解释执行阶段 或者 直接重新编译(需要根据进入陷阱的原因判断)。

Java的危机与未来

下面内容主要参(zhai)考(chao)周志明前辈的《云原生时代的Java》

简书上查到的:云原生 = 微服务 + DevOps + 持续交付 + 容器化

微服务:将原来的一个大应用拆分成一个个小模块,分布式部署,达到整体的高可用。(为了处理海量的数据,大数据架构应运而生)

DevOps:开发运维一体化,减少运维工作负担。

持续交付:在不影响用户使用服务的前提下,频繁把新功能发布给用户使用。

容器化:运维搭建环境时无需关心平台架构,程序直接运行在容器上。

云原始时代的到来给Java造成了很大的冲击。

这种冲击一方面体现在容器(即docker)上。Docker提出的口号是"Build Once,Run Anywhere”,即“一次构建,到处运行”。也就是说,我用C++、Golang写的程序,编译生成可执行文件后,可以直接放到任意系统中正常运行,只要这个系统支持Docker。

但这只是优势的削弱,并不足以成为Java的直接威胁,更迫在眉睫的风险是来自于微服务特性的冲击。

  1. Java总体上时面向大规模、长时间的服务端应用而设计的,Java的天性:严(luo)谨(suo)的语法利于所有人写出较为一致的代码;静态类型动态链接的语言结构,利于多人协作开发;即时编译、垃圾回收等JVM技术,让Java程序能长时间可靠地运行。但微服务的出现,使得整套服务系统,语言、框架上变得宽松,比如我这个微服务单体可以用golang写,那个微服务单体可以用python写,同样可以通过微服务框架实现良好的协调管理,做出大规模服务端应用。同时,可以通过搭建服务集群,使得单个服务压力减小。

    不过这也还好,毕竟只是其他语言通过微服务一定程度上实现了对Java的替代,而且Java也有自己的微服务框架。

  2. 微服务同时也对应用的容器化亲和性提出了新的要求,比如单个微服务的镜像体积要小、内存消耗要小、启动速度要快、预热期要短等等。而上面提到的每一条,都是Java的弱项。Java需要做的,且正在做的,就是对这些弱项进行优化。

https://img.jooks.cn/img/20210507110310.png

  • Leyden:放弃Java的动态特性,对jvm进行挑战,把字节码文件直接编译成可以脱离jvm的原生代码。这是提升Java应用的容器化亲和性的一种思路。目前处于刚刚开始的阶段,几乎没有公开的资料。

  • Portola:将OpenJDK向Alpine Linux移植,极大减小Java容器环境的大小。

  • Amber:在语法上进行优化,提高程序员编码效率。

  • Valhalla:打破Java出生时就有的“一切皆为对象”的特性,减少内存占用,提高运行效率。举个例子:要描述空间中的一个正方体,Java目前只能定义好Point类和Line类进行管理,但C/C++不同,轻量的结构体就能解决。所以Valhalla项目的核心就是提供类似C/C++中结构体的值类型的支持。

  • 。。。

引用周志明前辈的话(可不敢自己乱讲这种话):“技术发展迭代不会停歇,没有必要坚持什么‘永恒的真理’,旧的原则被打破,只要合理,便是创新。”

Java语言意识到了挑战,也意识到了要面向未来而变革。上面提到的Amber 和 Portola 已经在 2021 年 3 月的 Java 16 中发布。

Java的下一个长期稳定版是17(目前最新的是11),如果Java 17能够成功集成Amber、Portola、Valhalla、Loom 和 Panama的新能力、新特性于一身,那么Java 17大概率是一个里程碑式的版本,将带领整个Java生态从大规模服务端应用,向新的云原生时代软件系统转型 (Golang、Python等小众语言的死亡时刻)

但是,如果Java不能加速发展,那么Java强大生态体系的护城河将会被消耗殆尽,市场份额被Golang、Rust这样的新生语言,以及C、C++、C#、Python等老对手蚕食,从“天下第一”编程语言宝座中退位。

Java的未来是再攀高峰,还是由盛转衰,你我拭目以待。