本文转载自:https://mp.weixin.qq.com/s/fV...java
Java 诞生距今已有 25 年,但它仍然长期占据着“天下第一”编程语言的宝座。只是其统治地位并不是坚如盘石,反倒能够说是危机四伏。云原生时代,Java 技术体系的许多前提假设都受到了挑战,目前已经有可预见的、足以威胁动摇其根基的潜在可能性正在酝酿。同时,像 Golang、Rust 这样的新生语言,以及 C、C++、C#、Python 等老对手也都对 Java 的市场份额虎视眈眈。面对危机,Java 正在尝试哪些变革?将来,Java 是会继续向前、再攀高峰,仍是由盛转衰?在今天由极客邦科技举办的 QCon 全球软件开发大会 2020(深圳站)上,远光软件研究院院长、《深刻理解 Java 虚拟机》系列书籍做者周志明发表了主题演讲《云原生时代的 Java》,如下内容为演讲整理。 linux
今天,25岁的Java仍然是最具备统治力的编程语言,长期占据编程语言排行榜的首位,拥有一千二百万的庞大开发者群体,全世界有四百五十亿部物理设备使用着Java技术,同时,在云端数据中心的虚拟化环境里,还运行着超过两百五十亿个Java虚拟机的进程实例 (数据来自Oracle的WebCast)。程序员
以上这些数据是Java过去25年巨大成就的功勋佐证,更是Java技术体系维持本身“天下第一”编程语言的坚实壁垒。Java与其余语言竞争,底气历来不在于语法、类库有多么先进好用,而是来自它庞大的用户群和极其成熟的软件生态,这在朝夕之间难以撼动。然而,这个如今看起来仍然坚如盘石的Java帝国,其统治地位的稳固程度不只没有高枕无忧,反而说是危机四伏也不为过。目前已经有了可预见的、足以威胁动摇其根基的潜在可能性正在酝酿,并随云原生时代而降临。docker
Java 的危机数据库
Java与云原生的矛盾,来源于Java诞生之初,植入到它基因之中的一些基本的前提假设已经逐渐开始被动摇,甚至已经再也不成立。编程
我举个例子,每一位Java的使用者都据说过“一次编写,处处运行”(Write Once, Run Anywhere)这句口号。20多年前,Java成熟以前,开发者若是但愿程序在Linux、Solaris、Windows等不一样平台,在x8六、AMD6四、SPARC、MIPS、ARM等不一样指令集架构上都能正常运行,就必须针对每种组合,编译出对应的二进制发行包,或者索性直接分发源代码,由使用者在本身的平台上编译。浏览器
面对这个问题,Java经过语言层虚拟化的方式,令每个Java应用都自动取得平台无关(Platform Independent)、架构中立(Architecture Neutral)的先天优点,让同一套程序格式得以在不一样指令集架构、不一样操做系统环境下都能运行且获得一致的结果,不只方便了程序的分发,还避免了各类平台下内存模型、线程模型、字节序等底层细节差别对程序编写的干扰。在当年,Java的这种设计带有使人趋之若鹜的强大吸引力,直接开启了托管语言(Managed Language,如Java、.NET)的一段兴盛期。服务器
面对相同的问题,今天的云原生选择以操做系统层虚拟化的方式,经过容器实现的不可变基础设施去解决。不可变基础设施这个概念出现得比云原生要早,本来是指该如何避免因为运维人员对服务器运行环境所作的持续的变动而致使的意想不到的反作用。但在云原生时代,它的内涵已再也不局限于方便运维、程序升级和部署的手段,而是升华一种为向应用代码隐藏环境复杂性的手段,是分布式服务得以成为一种可广泛推广的普适架构风格的必要前提。数据结构
将程序连同它的运行环境一块儿封装到稳定的镜像里,现已经是一种主流的应用程序分发方式。Docker一样提出过“一次构建,处处运行”(Build Once, Run Anywhere)的口号,尽管它只能提供环境兼容性和有局限的平台无关性(指系统内核功能以上的ABI兼容),且彻底不可能支撑架构中立性,因此将“一次构建,处处运行”与“一次编写,处处运行”对立起来并不严谨恰当,可是无能否认,今天Java技术“一次编译,处处运行”的优点,已经被容器大幅度地削弱,再也不是大多数服务端开发者技术选型的主要考虑因素了。多线程
若是仅仅是优点的削弱,并不足以成为Java的直接威胁,充其量只是一个潜在的不利因素,但更加迫在眉睫的风险来自于那些与技术潮流直接冲突的假设。譬如,Java整体上是面向大规模、长时间的服务端应用而设计的,严(luō)谨(suō)的语法利于约束全部人写出较一致的代码;静态类型动态连接的语言结构,利于多人协做开发,让软件触及更大规模;即时编译器、性能制导优化、垃圾收集子系统等Java最具表明性的技术特征,都是为了便于长时间运行的程序能享受到硬件规模发展的红利。
另外一方面,在微服务的背景下,提倡服务围绕业务能力而非技术来构建应用,再也不追求实现上的一致,一个系统由不一样语言,不一样技术框架所实现的服务来组成是彻底合理的;服务化拆分后,极可能单个微服务再也不须要再面对数10、数百GB乃至TB的内存;有了高可用的服务集群,也无须追求单个服务要7×24小时不可间断地运行,它们随时能够中断和更新。
同时,微服务又对应用的容器化亲和性,譬如镜像体积、内存消耗、启动速度,以及达到最高性能的时间等方面提出了新的要求。这两年的网红概念Serverless也进一步增长这些因素的考虑权重,而这些却正好都是Java的弱项:哪怕再小的Java程序也要带着完整的虚拟机和标准类库,使得镜像拉取和容器建立效率下降,进而使整个容器生命周期拉长。基于Java虚拟机的执行机制,使得任何Java的程序都会有固定的基础内存开销,以及固定的启动时间,并且Java生态中普遍采用的依赖注入进一步将启动时间拉长,使得容器的冷启动时间很难缩短。
软件工业中已经出现过不止一块儿因Java这些弱点而致使失败的案例,如JRuby编写的Logstash,本来是同时承担部署在节点上的收集端(Shipper)和专门转换处理的服务端(Master)的职责,后来由于资源占用的缘由,被Elstaic.co用Golang的Filebeat代替了Shipper部分的职能;又如Scala语言编写的边车代理Linkerd,做为服务网格概念的提出者,却最终被Envoy所取代,其主要弱点之一也是因为Java虚拟机的资源消耗所带来的劣势。
虽然在云原生时代依然有不少适合Java发挥的领域,可是具有弹性与韧性、随时能够中断重启的微型服务的确已经造成了一股潮流,在逐步蚕食大型系统的领地。正是因为潮流趋势的改变,新一代的语言与技术尤为重视轻量化和快速响应能力,大多又从新回归到了原生语言(Native Language,如Golang、Rust)之上。
Java 的变革
面对挑战,Java的开发者和社区都没有退缩,它们在各自的领域给出了不少优秀的解决方案,涌现了如Quarkus、Micronaut、Helidon等一大批以提高Java在云原生环境下的适应性为卖点的框架。
不过,今天咱们的主题将聚焦在由Java官方自己所推动的项目上。在围绕Java 25周年的研讨和布道活动中,官方的设定是以“面向将来的变革”(Innovating for the Future)为基调,你有可能在此以前已经据说过其中某个(某些)项目的名字和改进点,但这里咱们不只关心这些项目改进的是什么,还更关心它们背后的动机与困难、带来的收益,以及要付出的代价。
Innovating for the Future
Project Leyden
对于原生语言的挑战,最有力最完全的反击手段无疑是将字节码直接编译成能够脱离Java虚拟机的原生代码。若是真的可以生成脱离Java虚拟机运行的原生程序,将意味着启动时间长的问题可以完全解决,由于此时已经不存在初始化虚拟机和类加载的过程;也意味着程序立刻就能达到最佳的性能,由于此时已经不存在即时编译器运行时编译,全部代码都是在编译期编译和优化好的(以下图所示);没有了Java虚拟机、即时编译器这些额外的部件,也就意味着可以省去它们本来消耗的那部份内存资源与镜像体积。
Java Performance Matrices(图片来源)
但同时,这也是风险系数最高、实现难度最大的方案。
Java并不是没有尝试走过这条路,从Java 2以前的GCJ(GNU Compiler for Java),到后来的Excelsior JET,再到2018年Oracle Labs启动的GraalVM中的SubstrateVM模块,最后到2020年中期刚创建的Leyden项目,都在朝着提早编译(Ahead-of-Time Compilation,AOT)生成原生程序这个目标迈进。
Java支持提早编译最大的困难在于它是一门动态连接的语言,它假设程序的代码空间是开放的(Open World),容许在程序的任什么时候候经过类加载器去加载新的类,做为程序的一部分运行。要进行提早编译,就必须放弃这部分动态性,假设程序的代码空间是封闭的(Closed World),全部要运行的代码都必须在编译期所有可知。这一点不只仅影响到了类加载器的正常运做,除了没法再动态加载外,反射(经过反射能够调用在编译期不可知的方法)、动态代理、字节码生成库(如CGLib)等一切会运行时产生新代码的功能都再也不可用,若是将这些基础能力直接抽离掉,Helloworld仍是能跑起来,但Spring确定跑不起来,Hibernate也跑不起来,大部分的生产力工具都跑不起来,整个Java生态中绝大多数上层建筑都会轰然崩塌。
要得到有实用价值的提早编译能力,只有依靠提早编译器、组件类库和开发者三方一块儿协同才可能办到。因为Leyden刚刚开始,几乎没有公开的资料,因此下面我是以SubstrateVM为目标对象进行的介绍:
2019年起,Pivotal的Spring团队与Oracle Labs的GraalVM团队共同孵化了Spring GraalVM Native项目,这个目前仍处于Experimental / Alpha状态的项目,可以让程序先以传统方式运行(启动)一次,自动化地找出程序中的反射、动态代理的代码,代替用户向编译器提供绝大部分所需的信息,并能将容许启动时初始化的Bean在编译期就完成初始化,直接绕过Spring程序启动最慢的阶段。这样从启动到程序能够提供服务,耗时竟可以低于0.1秒。
Spring Boot Startup Time(数据来源)
以原生方式运行后,缩短启动时间的效果立竿见影,通常会有数十倍甚至更高的改善,程序容量和内存消耗也有必定程度的降低。不过至少目前而言,程序的运行效率仍是要弱于传统基于Java虚拟机的方式,虽然即时编译器有编译时间的压力,但因为能够进行基于假设的激进优化和运行时性能度量的制导优化,使得即时编译器的效果仍要优于提早编译器,这方面须要GraalVM编译器团队的进一步努力,也须要从语言改进上入手,让Java变得更适合被编译器优化。
Project Valhalla
Java语言上可感知的语法变化,多数来自于Amber项目,它的项目目标是持续优化语言生产力,近期(JDK 1五、16)会有不少来自这个项目的特性,如Records、Sealed Class、Pattern Matching、Raw String Literals等实装到生产环境。
然而语法不只与编码效率相关,与运行效率也有很大关系。“程序=代码+数据”这个提法至少在衡量运行效率上是合适的,不管是托管语言仍是原生语言,最终产物都是处理器执行的指令流和内存存储的数据结构。Java、.NET、C、C++、Golang、Rust等各类语言谁更快,取决于特定场景下,编译器生成指令流的优化效果,以及数据在内存中的结构布局。
Java即时编译器的优化效果拔群,可是因为Java“一切皆为对象”的前提假设,致使在处理一系列不一样类型的小对象时,内存访问性能很是拉垮,这点是Java在游戏、图形处理等领域一直难有建树的重要制约因素,也是Java创建Valhalla项目的目标初衷。
这里举个例子来讲明此问题,若是我想描述空间里面若干条线段的集合,在Java中定义的代码会是这样的:
public record Point(float x, float y, float z) {} public record Line(Point start, Point end) {} Line[] lines;
面向对象的内存布局中,对象标识符(Object Identity)存在的目的是为了容许在不暴露对象结构的前提下,依然能够引用其属性与行为,这是面向对象编程中多态性的基础。在Java中堆内存分配和回收、空值判断、引用比较、同步锁等一系列功能都会涉及到对象标识符,内存访问也是依靠对象标识符来进行链式处理的,譬如上面代码中的“若干条线段的集合”,在堆内存中将构成以下图的引用关系:
Object Identity / Memory Layout
计算机硬件通过25年的发展,内存与处理器虽然都在进步,可是内存延迟与处理器执行性能之间的冯诺依曼瓶颈(Von Neumann Bottleneck)不只没有缩减,反而还在持续加大,“RAM Is the New Disk”已经从嘲讽梗逐渐成为了现实。
一次内存访问(将主内存数据调入处理器Cache)大约须要耗费数百个时钟周期,而大部分简单指令的执行只须要一个时钟周期而已。所以,在程序执行性能这个问题上,若是编译器能减小一次内存访问,可能比优化掉几10、几百条其余指令都来得更有效果。
额外知识:冯诺依曼瓶颈
不一样处理器(现代处理器都集成了内存管理器,之前是在北桥芯片中)的内存延迟大概是40-80纳秒(ns,十亿分之一秒),而根据不一样的时钟频率,一个时钟周期大概在0.2-0.4纳秒之间,如此短暂的时间内,即便真空中传播的光,也仅仅可以行进10厘米左右。
数据存储与处理器执行的速度矛盾是冯诺依曼架构的主要局限性之一,1977年的图灵奖得主John Backus提出了“冯诺依曼瓶颈”这个概念,专门用来描述这种局限性。
编译器的确在努力减小内存访问,从JDK 6起,HotSpot的即时编译器就尝试经过逃逸分析来作标量替换(Scalar Replacement)和栈上分配(Stack Allocations)优化,基本原理是若是能经过分析,得知一个对象不会传递到方法以外,那就不须要真实地在对中建立完整的对象布局,彻底能够绕过对象标识符,将它拆散为基本的原生数据类型来建立,甚至是直接在栈内存中分配空间(HotSpot并无这样作),方法执行完毕后随着栈帧一块儿销毁掉。
不过,逃逸分析是一种过程间优化(Interprocedural Optimization),很是耗时,也很难处理那些理论有可能但实际不存在的状况。相同的问题在C、C++中却并不存在,上面场景中,程序员只要将Point和Line都定义为struct便可,C#中也有struct,是依靠.NET的值类型(Value Type)来实现的。Valhalla项目的核心改进就是提供相似的值类型支持,提供一个新的关键字(inline),让用户能够在不须要向方法外部暴露对象、不须要多态性支持、不须要将对象用做同步锁的场合中,将类标识为值类型,此时编译器就可以绕过对象标识符,以平坦的、紧凑的方式去为对象分配内存。
有了值类型的支持后,如今Java泛型中使人诟病的不支持原数据类型(Primitive Type)、频繁装箱问题也就随之迎刃而解,如今Java的包装类,理所固然地会以表明原生类型的值类型来从新定义,这样Java泛型的性能会获得明显的提高,由于此时Integer与int的访问,在机器层面看彻底能够达到一致的效率。
Project Loom
Java语言抽象出来隐藏了各类操做系统线程差别性的统一线程接口,这曾经是它区别于其余编程语言(C/C++表示有被冒犯到)的一大优点,不过,统一的线程模型不见得永远都是正确的。
Java目前主流的线程模型是直接映射到操做系统内核上的1:1模型,这对于计算密集型任务这很合适,既不用本身去作调度,也利于一条线程跑满整个处理器核心。但对于I/O密集型任务,譬如访问磁盘、访问数据库占主要时间的任务,这种模型就显得成本高昂,主要在于内存消耗和上下文切换上:64位Linux上HotSpot的线程栈容量默认是1MB,线程的内核元数据(Kernel Metadata)还要额外消耗2-16KB内存,因此单个虚拟机的最大线程数量通常只会设置到200至400条,当程序员把数以百万计的请求往线程池里面灌时,系统即使能处理得过来,其中的切换损耗也至关可观。
Loom项目的目标是让Java支持额外的N:M线程模型,请注意是“额外支持”,而不是像当年从绿色线程过渡到内核线程那样的直接替换,也不是像Solaris平台的HotSpot虚拟机那样经过参数让用户二选其一。
Loom项目新增长一种“虚拟线程”(Virtual Thread,之前以Fiber为名进行宣传过,但由于要频繁解释啥是Fiber因此如今放弃了),本质上它是一种有栈协程(Stackful Coroutine),多条虚拟线程能够映射到同一条物理线程之中,在用户空间中自行调度,每条虚拟线程的栈容量也可由用户自行决定。
Virtual Thread
同时,Loom项目的另外一个目标是要尽最大可能保持原有统一线程模型的交互方式,通俗地说就是原有的Thread、J.U.C、NIO、Executor、Future、ForkJoinPool等这些多线程工具都应该能以一样的方式支持新的虚拟线程,原来多线程中你理解的概念、编码习惯大多数都可以继续沿用。
为此,虚拟线程将会与物理线程同样使用java.lang.Thread来进行抽象,只是在建立线程时用到的参数或者方法稍有不一样(譬如给Thread增长一个Thread.VIRTUAL_THREAD参数,或者增长一个startVirtualThread()方法)。这样现有的多线程代码迁移到虚拟线程中的成本就会变得很低,而代价就是Loom的团队必须作更多的工做以保证虚拟线程在大部分涉及到多线程的标准API中都可以兼容,甚至在调试器上虚拟线程与物理线程看起来都会有一致的外观。但很难所有都支持,譬如调用JNI的本地栈帧就很难放到虚拟线程上,因此一旦遇到本地方法,虚拟线程就会被绑定(Pinned)到一条物理线程上。
Loom的另外一个重点改进是支持结构化并发(Structured Concurrency),这是2016年才提出的新的并发编程概念,但很快就被诸多编程语言所吸纳。它是指程序的并发行为会与代码的结构对齐,譬如如下代码所示,按照传统的编程观念,若是没有额外的处理(譬如无中生有地弄一个await关键字),那在task1和task2提交以后,程序应该继续向下执行:
ThreadFactory factory = Thread.builder().virtual().factory(); try (var executor = Executors.newThreadExecutor(factory)) { executor.submit(task1); executor.submit(task2); } // blocks and waits
可是在结构化并发的支持下,只有两个并行启动的任务线程都结束以后,程序才会继续向下执行,很好地以同步的编码风格,来解决异步的执行问题。事实上,“Code like sync,Work like async”正是Loom简化并发编程的核心理念。
Project Portola
Portola项目的目标是将OpenJDK向Alpine Linux移植。Alpine Linux是许多Docker容器首选的基础镜像,由于它只有5 MB大小,比起其余Cent OS、Debain等动辄一百多MB的发行版来讲,更适合用于容器环境。不过Alpine Linux为了尽可能瘦身,默认是用musl做为C标准库的,而非传统的glibc(GNU C library),所以要以Alpine Linux为基础制做OpenJDK镜像,必须先安装glibc,此时基础镜像大约有12 MB。Portola计划将OpenJDK的上游代码移植到musl,并经过兼容性测试。使用Portola制做的标准Java SE 13镜像仅有41 MB,不只远低于Cent OS的OpenJDK(大约396 MB),也要比官方的slim版(约200 MB)要小得多。
$ sudo docker build . Sending build context to Docker daemon 2.56kB Step 1/8 : FROM alpine:latest as build latest: Pulling from library/alpine bdf0201b3a05: Pull complete Digest: sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913 Status: Downloaded newer image for alpine:latest ---> cdf98d1859c1 Step 2/8 : ADD https://download.java.net/java/early_access/alpine/16/binaries/openjdk-13-ea+16_linux-x64-musl_bin.tar.gz /opt/jdk/ Downloading [==================================================>] 195.2MB/195.2MB ---> Using cache ---> b1a444e9dde9 Step 3/7 : RUN tar -xzvf /opt/jdk/openjdk-13-ea+16_linux-x64-musl_bin.tar.gz -C /opt/jdk/ ---> Using cache ---> ce2721c75ea0 Step 4/7 : RUN ["/opt/jdk/jdk-13/bin/jlink", "--compress=2", "--module-path", "/opt/jdk/jdk-13/jmods/", "--add-modules", "java.base", "--output", "/jlinked"] ---> Using cache ---> d7b2793ed509 Step 5/7 : FROM alpine:latest ---> cdf98d1859c1 Step 6/7 : COPY --from=build /jlinked /opt/jdk/ ---> Using cache ---> 993fb106f2c2 Step 7/7 : CMD ["/opt/jdk/bin/java", "--version"] - to check JDK version ---> Running in 8e1658f5f84d Removing intermediate container 8e1658f5f84d ---> 350dd3a72a7d Successfully built 350dd3a72a7d $ sudo docker tag 350dd3a72a7d jdk-13-musl/jdk-version:v1 $ sudo docker images REPOSITORY TAG IMAGE ID CREATED SIZE jdk-13-musl/jdk-version v1 350dd3a72a7d About a minute ago 41.7MB alpine latest cdf98d1859c1 2 weeks ago 5.53M
Java 的将来
云原生时代,Java技术体系的许多前提假设都受到了挑战,“一次编译,处处运行”、“面向长时间大规模程序而设计”、“从开放的代码空间中动态加载”、“一切皆为对象”、“统一线程模型”,等等。技术发展迭代不会停歇,没有必要坚持什么“永恒的真理”,旧的原则被打破,只要合理,即是创新。
Java语言意识到了挑战,也意识到了要面向将来而变革。文中提到的这些项目,Amber和Portola已经明确会在2021年3月的Java 16中发布,至少也会达到Feature Preview的程度:
至于更受关注,同时也是难度更高的 Valhalla 和 Loom 项目,目前仍然没有明确的版本计划信息,尽管它们已经开发了数年时间,很是但愿可以赶在 Java 17 这个 LTS 版本中面世,但前路仍是困难重重。
至于难度最高、建立时间最晚的 Leyden 项目,目前还彻底处于特性讨论阶段,连个胚胎都算不上。对于 Java 的原生编译,咱们中短时间内只可能寄但愿于 Oracle 的 GraalVM。
将来一段时间,是Java重要的转型窗口期,若是做为下一个LTS版的Java 17,可以成功集Amber、Portola、Valhalla、Loom和Panama(用于外部函数接口访问,本文没有提到)的新能力、新特性于一身,GraalVM也能给予足够强力支持的话,那Java 17 LTS大几率会是一个里程碑式的版本,带领着整个Java生态从大规模服务端应用,向新的云原生时代软件系统转型。可能成为比肩当年从面向嵌入式设备与浏览器Web Applets的Java 1,到确立现代Java语言方向(Java SE/EE/ME和JavaCard)雏形的Java 2转型那样的里程碑。
可是,若是Java不能加速本身的发展步伐,那由强大生态所构建的护城河终究会消耗殆尽,被Golang、Rust这样的新生语言,以及C、C++、C#、Python等老对手蚕食掉很大一部分市场份额,以致被迫从“天下第一”编程语言的宝座中退位。
Java的将来是继续向前,再攀高峰,仍是由盛转衰,锋芒挫缩,你我拭目以待。