1.8 随笔文章

Graal VM

视频公开课

本节笔者有公开课介绍:《GraalVM:云原生时代的 Java》

网上每隔一段时间就能见到几条“未来 X 语言将会取代 Java”的新闻,此处“X”可以用 Kotlin、Golang、Dart、JavaScript、Python……等各种编程语言来代入。这大概就是长期占据编程语言榜单第一位的烦恼,天下第一总避免不了挑战者相伴。

如果 Java 有拟人化的思维,它应该从来没有惧怕过被哪一门语言所取代,Java“天下第一”的底气不在于语法多么先进好用,而是来自它庞大的用户群和极其成熟的软件生态,这在朝夕之间难以撼动。不过,既然有那么多新、旧编程语言的兴起躁动,说明必然有其需求动力所在,譬如互联网之于 JavaScript、人工智能之于 Python,微服务风潮之于 Golang 等等。大家都清楚不太可能有哪门语言能在每一个领域都尽占优势,Java 已是距离这个目标最接近的选项,但若“天下第一”还要百尺竿头更进一步的话,似乎就只能忘掉 Java 语言本身,踏入无招胜有招的境界。

2018 年 4 月,Oracle Labs 新公开了一项黑科技:Graal VM,从它的口号“Run Programs Faster Anywhere”就能感觉到一颗蓬勃的野心,这句话显然是与 1995 年 Java 刚诞生时的“Write Once,Run Anywhere”在遥相呼应。

INFO

Graal VM 被官方称为“Universal VM”和“Polyglot VM”,这是一个在 HotSpot 虚拟机基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用,这里“任何语言”包括了 Java、Scala、Groovy、Kotlin 等基于 Java 虚拟机之上的语言,还包括了 C、C++、Rust 等基于 LLVM 的语言,同时支持其他像 JavaScript、Ruby、Python 和 R 语言等等。Graal VM 可以无额外开销地混合使用这些编程语言,支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好的本地库文件。

Graal VM 的基本工作原理是将这些语言的源代码(例如 JavaScript)或源代码编译后的中间格式(例如 LLVM 字节码)通过解释器转换为能被 Graal VM 接受的中间表示(Intermediate Representation,IR),譬如设计一个解释器专门对 LLVM 输出的字节码进行转换来支持 C 和 C++语言,这个过程称为“程序特化”(Specialized,也常称为 Partial Evaluation)。Graal VM 提供了 Truffle 工具集来快速构建面向一种新语言的解释器,并用它构建了一个称为 Sulong 的高性能 LLVM 字节码解释器。

以更严格的角度来看,Graal VM 才是真正意义上与物理计算机相对应的高级语言虚拟机,理由是它与物理硬件的指令集一样,做到了只与机器特性相关而不与某种高级语言特性相关。Oracle Labs 的研究总监 Thomas Wuerthinger 在接受 InfoQ 采访时谈到:“随着 Graal VM 1.0 的发布,我们已经证明了拥有高性能的多语言虚拟机是可能的,并且实现这个目标的最佳方式不是通过类似 Java 虚拟机和微软 CLR 那样带有语言特性的字节码”。对于一些本来就不以速度见长的语言运行环境,由于 Graal VM 本身能够对输入的中间表示进行自动优化,在运行时还能进行即时编译优化,往往使用 Graal VM 实现能够获得比原生编译器更优秀的执行效率,譬如 Graal.js 要优于 Node.js、Graal.Python 要优于 CPython,TruffleRuby 要优于 Ruby MRI,FastR 要优于 R 语言等等。

针对 Java 而言,Graal VM 本来就是在 HotSpot 基础上诞生的,天生就可作为一套完整的符合 Java SE 8 标准的 Java 虚拟机来使用。它和标准的 HotSpot 差异主要在即时编译器上,其执行效率、编译质量目前与标准版的 HotSpot 相比也是互有胜负。但现在 Oracle Labs 和美国大学里面的研究院所做的最新即时编译技术的研究全部都迁移至基于 Graal VM 之上进行了,其发展潜力令人期待。如果 Java 语言或者 HotSpot 虚拟机真的有被取代的一天,那从现在看来 Graal VM 是希望最大的一个候选项,这场革命很可能会在 Java 使用者没有明显感觉的情况下悄然而来,Java 世界所有的软件生态都没有发生丝毫变化,但天下第一的位置已经悄然更迭。


新一代即时编译器

对需要长时间运行的应用来说,由于经过充分预热,热点代码会被 HotSpot 的探测机制准确定位捕获,并将其编译为物理硬件可直接执行的机器码,在这类应用中 Java 的运行效率很大程度上是取决于即时编译器所输出的代码质量。

HotSpot 虚拟机中包含有两个即时编译器,分别是编译时间较短但输出代码优化程度较低的客户端编译器(简称为 C1)以及编译耗时长但输出代码优化质量也更高的服务端编译器(简称为 C2),通常它们会在分层编译机制下与解释器互相配合来共同构成 HotSpot 虚拟机的执行子系统。

自 JDK 10 起,HotSpot 中又加入了一个全新的即时编译器:Graal 编译器,看名字就可以联想到它是来自于前一节提到的 Graal VM。Graal 编译器是作为 C2 编译器替代者的角色登场的。C2 的历史已经非常长了,可以追溯到 Cliff Click 大神读博士期间的作品,这个由 C++写成的编译器尽管目前依然效果拔群,但已经复杂到连 Cliff Click 本人都不愿意继续维护的程度。而 Graal 编译器本身就是由 Java 语言写成,实现时又刻意与 C2 采用了同一种名为“Sea-of-Nodes”的高级中间表示(High IR)形式,使其能够更容易借鉴 C2 的优点。Graal 编译器比 C2 编译器晚了足足二十年面世,有着极其充沛的后发优势,在保持能输出相近质量的编译代码的同时,开发效率和扩展性上都要显著优于 C2 编译器,这决定了 C2 编译器中优秀的代码优化技术可以轻易地移植到 Graal 编译器上,但是反过来 Graal 编译器中行之有效的优化在 C2 编译器里实现起来则异常艰难。这种情况下,Graal 的编译效果短短几年间迅速追平了 C2,甚至某些测试项中开始逐渐反超 C2 编译器。Graal 能够做比 C2 更加复杂的优化,如“部分逃逸分析”(Partial Escape Analysis),也拥有比 C2 更容易使用“激进预测性优化”(Aggressive Speculative Optimization)的策略,支持自定义的预测性假设等等。

INFO

今天的 Graal 编译器尚且年幼,还未经过足够多的实践验证,所以仍然带着“实验状态”的标签,需要用开关参数去激活,这让笔者不禁联想起 JDK 1.3 时代,HotSpot 虚拟机刚刚横空出世时的场景,同样也是需要用开关激活,也是作为 Classic 虚拟机的替代品的一段历史。

Graal 编译器未来的前途可期,作为 Java 虚拟机执行代码的最新引擎,它的持续改进,会同时为 HotSpot 与 Graal VM 注入更快更强的驱动力。


向原生迈进

对不需要长时间运行的,或者小型化的应用而言,Java(而不是指 Java ME)天生就带有一些劣势,这里并不光是指跑个 HelloWorld 也需要百多兆的 JRE 之类的问题,而更重要的是指近几年从大型单体应用架构向小型微服务应用架构发展的技术潮流下,Java 表现出来的不适应。

在微服务架构的视角下,应用拆分后,单个微服务很可能就不再需要再面对数十、数百 GB 乃至 TB 的内存,有了高可用的服务集群,也无须追求单个服务要 7×24 小时不可间断地运行,它们随时可以中断和更新;但相应地,Java 的启动时间相对较长、需要预热才能达到最高性能等特点就显得相悖于这样的应用场景。在无服务架构中,矛盾则可能会更加突出,比起服务,一个函数的规模通常会更小,执行时间会更短,当前最热门的无服务运行环境 AWS Lambda 所允许的最长运行时间仅有 15 分钟。

一直把软件服务作为重点领域的 Java 自然不可能对此视而不见,在最新的几个 JDK 版本的功能清单中,已经陆续推出了跨进程的、可以面向用户程序的类型信息共享(Application Class Data Sharing,AppCDS,允许把加载解析后的类型信息缓存起来,从而提升下次启动速度,原本 CDS 只支持 Java 标准库,在 JDK 10 时的 AppCDS 开始支持用户的程序代码)、无操作的垃圾收集器(Epsilon,只做内存分配而不做回收的收集器,对于运行完就退出的应用十分合适)等改善措施。而酝酿中的一个更彻底的解决方案,是逐步开始对提前编译(Ahead of Time Compilation,AOT)提供支持。

提前编译是相对于即时编译的概念,提前编译能带来的最大好处是 Java 虚拟机加载这些已经预编译成二进制库之后就能够直接调用,而无须再等待即时编译器在运行时将其编译成二进制机器码。理论上,提前编译可以减少即时编译带来的预热时间,减少 Java 应用长期给人带来的“第一次运行慢”不良体验,可以放心地进行很多全程序的分析行为,可以使用时间压力更大的优化措施。

但是提前编译的坏处也很明显,它破坏了 Java“一次编写,到处运行”的承诺,必须为每个不同的硬件、操作系统去编译对应的发行包。也显著降低了 Java 链接过程的动态性,必须要求加载的代码在编译期就是全部已知的,而不能再是运行期才确定,否则就只能舍弃掉已经提前编译好的版本,退回到原来的即时编译执行状态。

早在 JDK 9 时期,Java 就提供了实验性的 Jaotc 命令来进行提前编译,不过多数人试用过后都颇感失望,大家原本期望的是类似于 Excelsior JET 那样的编译过后能生成本地代码完全脱离 Java 虚拟机运行的解决方案,但 Jaotc 其实仅仅是代替掉即时编译的一部分作用而已,仍需要运行于 HotSpot 之上。

直到 Substrate VM 出现,才算是满足了人们心中对 Java 提前编译的全部期待。Substrate VM 是在 Graal VM 0.20 版本里新出现的一个极小型的运行时环境,包括了独立的异常处理、同步调度、线程管理、内存管理(垃圾收集)和 JNI 访问等组件,目标是代替 HotSpot 用来支持提前编译后的程序执行。它还包含了一个本地镜像的构造器(Native Image Generator)用于为用户程序建立基于 Substrate VM 的本地运行时镜像。这个构造器采用指针分析(Points-To Analysis)技术,从用户提供的程序入口出发,搜索所有可达的代码。在搜索的同时,它还将执行初始化代码,并在最终生成可执行文件时,将已初始化的堆保存至一个堆快照之中。这样一来,Substrate VM 就可以直接从目标程序开始运行,而无须重复进行 Java 虚拟机的初始化过程。但相应地,原理上也决定了 Substrate VM 必须要求目标程序是完全封闭的,即不能动态加载其他编译期不可知的代码和类库。基于这个假设,Substrate VM 才能探索整个编译空间,并通过静态分析推算出所有虚方法调用的目标方法。

Substrate VM 带来的好处是能显著降低内存占用及启动时间,由于 HotSpot 本身就会有一定的内存消耗(通常约几十 MB),这对最低也从几 GB 内存起步的大型单体应用来说并不算什么,但在微服务下就是一笔不可忽视的成本。根据 Oracle 官方给出的测试数据,运行在 Substrate VM 上的小规模应用,其内存占用和启动时间与运行在 HotSpot 相比有了 5 倍到 50 倍的下降,具体结果如下图所示:

内存占用对比

(此处原有内存占用对比图,描述不同应用在 HotSpot 与 Substrate VM 下的内存占用情况,Substrate VM 大幅度降低内存使用。)

启动时间对比

(此处原有启动时间对比图,显示 Substrate VM 应用的启动时间显著短于 HotSpot。)

Substrate VM 补全了 Graal VM“Run Programs Faster Anywhere”愿景蓝图里最后的一块拼图,让 Graal VM 支持其他语言时不会有重量级的运行负担。譬如运行 JavaScript 代码,Node.js 的 V8 引擎执行效率非常高,但即使是最简单的 HelloWorld,它也要使用约 20MB 的内存,而运行在 Substrate VM 上的 Graal.js,跑一个 HelloWorld 则只需要 4.2MB 内存而已,且运行速度与 V8 持平。Substrate VM 的轻量特性,使得它十分适合于嵌入至其他系统之中,譬如 Oracle 自家的数据库就已经开始使用这种方式支持用不同的语言代替 PL/SQL 来编写存储过程。


没有虚拟机的 Java

尽管 Java 已经看清楚了在微服务时代的前进目标,但是,Java 语言和生态在微服务、微应用环境中的天生的劣势并不会一蹴而就地被解决,通往这个目标的道路注定会充满荆棘;尽管已经有了放弃“一次编写,到处运行”、放弃语言动态性的思想准备,但是,这些特性并不单纯是宣传口号,它们在 Java 语言诞生之初就被植入到基因之中,当 Graal VM 试图打破这些规则的同时,也受到了 Java 语言和在其之上的生态的强烈反噬,笔者选择其中最主要的一些困难列举如下:

  • 反射等动态特性
    某些 Java 语言的特性,使得 Graal VM 编译本地镜像的过程变得极为艰难。譬如常见的反射,除非使用安全管理器去专门进行认证许可,否则反射机制具有在运行期动态调用几乎所有 API 接口的能力,且具体会调用哪些接口,在程序不会真正运行起来的编译期是无法获知的。反射显然是 Java 不能放弃不能妥协的重要特性,为此,只能由程序的开发者明确地告知 Graal VM 有哪些代码可能被反射调用(通过 JSON 配置文件的形式),Graal VM 才能在编译本地程序时将它们囊括进来。

    [ 
        { 
            "name": "com.github.fenixsoft.SomeClass", 
            "allDeclaredConstructors": true, 
            "allPublicMethods": true 
        }, 
        { 
            "name": "com.github.fenixsoft.AnotherClass", 
            "fields": [{"name": "foo"}, {"name": "bar"}], 
            "methods": [{ 
                "name": "<init>", 
                "parameterTypes": ["char[]"] 
            }] 
        }
        // something else ......
    ]

    这是一种可操作性极其低下却又无可奈何的解决方案,即使开发者接受不厌其烦地列举出自己代码中所用到的反射 API,但他们又如何能保证程序所引用的其他类库的反射行为都已全部被获知,其中没有任何遗漏?与此类似的还有另外一些语言特性,如动态代理等。另外,一切非代码性质的资源,如最典型的配置文件等,也都必须明确加入配置中才能被 Graal VM 编译打包。这导致了如果没有专门的工具去协助,使用 Graal VM 编译 Java 的遗留系统即使理论可行,实际操作也将是极度的繁琐。

  • 字节码生成与修改
    大多数运行期对字节码的生成和修改操作,在 Graal VM 看来都是无法接受的,因为 Substrate VM 里面不再包含即时编译器和字节码执行引擎,所以一切可能被运行的字节码,都必须经过 AOT 编译成为原生代码。请不要觉得运行期直接生成字节码会很罕见,误以为导致的影响应该不算很大。事实上,多数实际用于生产的 Java 系统都或直接或间接、或多或少引用了 ASM、CGLIB、Javassist 这类字节码库。举个例子,CGLIB 是通过运行时产生字节码(生成代理类的子类)来做动态代理的,长期以来这都是 Java 世界里进行类增强的主流形式,因为面向接口的增强可以使用 JDK 自带的动态代理,但对类的增强则并没有多少选择的余地。CGLIB 也是 Spring 用来做类增强的选择,但 Graal VM 明确表示是不可能支持 CGLIB 的,因此,这点就必须由用户(面向接口编程)、框架(Spring 这些 DI 框架放弃 CGLIB 增强)和 Graal VM(起码得支持 JDK 的动态代理,留条活路可走)来共同解决。自 Spring Framework 5.2 起,@Configuration 注解中加入了一个新的 proxyBeanMethods 参数,设置为 false 则可避免 Spring 对与非接口类型的 Bean 进行代理。同样地,对应在 Spring Boot 2.2 中,@SpringBootApplication 注解也增加了 proxyBeanMethods 参数,通常采用 Graal VM 去构建的 Spring Boot 本地应用都需要设置该参数。

  • 内部接口的消失
    一切 HotSpot 虚拟机本身的内部接口,譬如 JVMTI、JVMCI 等,都将不复存在了——在本地镜像中,连 HotSpot 本身都被消灭了,这些接口自然成了无根之木。这对使用者一侧的最大影响是再也无法进行 Java 语言层次的远程调试了,最多只能进行汇编层次的调试。在生产系统中一般也没有人这样做,开发环境就没必要采用 Graal VM 编译,这点的实际影响并不算大。

  • 部分语言特性的妥协
    Graal VM 放弃了一部分可以妥协的语言和平台层面的特性,譬如 Finalizer、安全管理器、InvokeDynamic 指令和 MethodHandles,等等,在 Graal VM 中都被声明为不支持的,这些妥协的内容大多倒并非全然无法解决,主要是基于工作量性价比的原因。能够被放弃的语言特性,说明确实是影响范围非常小的,所以这个对使用者来说一般是可以接受的。

……

以上,是 Graal VM 在 Java 语言中面临的部分困难,在整个 Java 的生态系统中,数量庞大的第三方库才是真正最棘手的难题。可以预料,这些第三方库一旦脱离了 Java 虚拟机,在原生环境中肯定会暴露出无数千奇百怪的异常行为。Graal VM 团队对此的态度非常务实,并没有直接硬啃。要建设可持续、可维护的 Graal VM,就不能为了兼容现有 JVM 生态,做出过多的会影响性能、优化空间和未来拓展的妥协牺牲,为此,应该也只能反过来由 Java 生态去适应 Graal VM,这是 Graal VM 团队明确传递出对第三方库的态度:

QUOTE

3rd party libraries Graal VM native support needs to be sustainable and maintainable, that’s why we do not want to maintain fragile patches for the whole JVM ecosystem. The ecosystem为了推进 Java 生态向 Graal VM 兼容,Graal VM 主动拉拢了 Java 生态中最庞大的一个派系:Spring。从 2018 年起,来自 Oracle 的 Graal VM 团队与来自 Pivotal 的 Spring 团队已经紧密合作了很长的一段时间,共同创建了 Spring Graal Native 项目来解决 Spring 全家桶在 Graal VM 上的运行适配问题,在不久的将来(预计应该是 2020 年 10 月左右),下一个大的 Spring 版本(Spring Framework 5.3、Spring Boot 2.3)的其中一项主要改进就是能够开箱即用地支持 Graal VM,这样,用于微服务环境的 Spring Cloud 便会获得不受 Java 虚拟机束缚的更广阔舞台空间。


Spring over Graal

前面几部分,我们以定性的角度分析了 Graal VM 诞生的背景与它的价值,在最后这部分,我们尝试进行一些实践和定量的讨论,介绍具体如何使用 Graal VM 之余,也希望能以更加量化的角度去理解程序运行在 Graal VM 之上,会有哪些具体的收益和代价。

尽管需要到 2020 年 10 月正式发布之后,Spring 对 Graal VM 的支持才会正式提供,但现在的我们其实已经可以使用 Graal VM 来(实验性地)运行 Spring、Spring Boot、Spring Data、Netty、JPA 等等的一系列组件(不过 SpringCloud 中的组件暂时还不行)。接下来,我们将尝试使用 Graal VM 来编译一个标准的 Spring Boot 应用:

环境准备

  • 安装 Graal VM:你可以选择直接下载安装(版本选择 Graal VM CE 20.0.0),然后配置好 PATHJAVA_HOME 环境变量即可;也可以选择使用 SDKMAN 来快速切换环境。个人推荐后者,毕竟目前还不适合长期基于 Graal VM 环境下工作,经常手工切换会很麻烦。
  • 安装本地镜像编译依赖的 LLVM 工具链

WARNING

请注意,这里已经假设你机器上已有基础的 GCC 编译环境,即已安装过 build-essentiallibz-dev 等套件。没有的话请先行安装。对于 Windows 环境来说,这步是需要 Windows SDK 7.1 中的 C++编译环境来支持。我个人并不建议在 Windows 上进行 Java Graal VM 相关工作的。

# 安装SDKMAN 
$ curl -s "https://get.sdkman.io" | bash 
 
# 安装Graal VM 
$ sdk install java 20.0.0.r8-grl

gu命令来源于Graal VM的bin目录

$ gu install native-image 

Spring over Graal

如果说在 Linux 中编译一个本地镜像通常是为了打包到 Docker 然后发布到服务器中使用,那么在 Windows 上编译一个本地镜像你打算用它来干什么呢?

编译准备

首先,我们假设你准备编译的代码是“符合要求”的,即没有使用到 Graal VM 不支持的特性,譬如前面提到的 Finalizer、CGLIB、InvokeDynamic 这类功能。同时,由于我们使用的是 Graal VM 的 Java 8 版本,也必须假设编译所使用的 Java 语言级别在 Java 8 以内。

接着,我们需要使用尚未正式发布的 Spring Boot 2.3,目前最新的版本是 Spring Boot 2.3.0.M4。请将你的 pom.xml 中的 Spring Boot 版本修改如下(假设你使用 Maven 编译,若使用 Gradle 请自行调整):

<parent> 
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-parent</artifactId> 
    <version>2.3.0.M4</version> 
    <relativePath/>
</parent>

未发布版本

由于是未发布的 Spring Boot 版本,它在 Maven 中央仓库中找不到,需要手动添加 Spring 的私有仓库:

<repositories> 
    <repository> 
        <id>spring-milestone</id> 
        <name>Spring milestone</name> 
        <url>https://repo.spring.io/milestone</url> 
    </repository>
</repositories>

最后,尽管我们可以直接通过命令行(使用 native-image 命令)进行编译,这对于没有多少依赖的普通 Jar 包、写一个 HelloWorld 来说都是可行的,但对于 Spring Boot,光在命令行里填写 Classpath 就要忙碌一阵,因此建议使用 Maven 插件来驱动 Graal VM 编译。这个插件能根据 Maven 的依赖信息自动组织好 Classpath,你只需填写其他命令行参数即可。

因为并非每次编译都需要构建本地镜像,为了不干扰使用普通 Java 虚拟机的编译,建议在 Maven 中单独创建一个 Profile 来调用 Graal VM 插件,具体如下:

<profiles> 
  <profile> 
    <id>graal</id> 
    <build> 
      <plugins> 
        <plugin> 
          <groupId>org.graalvm.nativeimage</groupId> 
          <artifactId>native-image-maven-plugin</artifactId> 
          <version>20.0.0</version> 
          <configuration> 
            <buildArgs>-Dspring.graal.remove-unused-autoconfig=true --no-fallback -H:+ReportExceptionStackTraces --no-server</buildArgs> 
          </configuration> 
          <executions> 
            <execution> 
              <goals> 
                <goal>native-image</goal> 
              </goals> 
              <phase>package</phase> 
            </execution> 
          </executions> 
        </plugin> 
        <plugin> 
          <groupId>org.springframework.boot</groupId> 
          <artifactId>spring-boot-maven-plugin</artifactId> 
        </plugin> 
      </plugins> 
    </build> 
  </profile>
</profiles>

插件仓库

这个插件同样不在 Maven 中央仓库中,因此也需要加上前面提到的 Spring 私有仓库:

<pluginRepositories> 
    <pluginRepository> 
        <id>spring-milestone</id> 
        <name>Spring milestone</name> 
        <url>https://repo.spring.io/milestone</url> 
    </pluginRepository>
</pluginRepositories>

至此,编译环境的准备顺利完成。

程序调整

首先,如前面所述,Graal VM 不支持 CGLIB,只能使用 JDK 动态代理,所以应当把 Spring 对普通类的 Bean 增强关闭掉:

@SpringBootApplication(proxyBeanMethods = false)
public class ExampleApplication {

然后,这是最麻烦的一个步骤:你的程序里通过反射调用过哪些 API、使用了哪些资源、动态代理,以及哪些类型需要在编译期初始化的,都必须使用 JSON 配置文件逐一告知 Graal VM。前面也说过,这件事只有理论上的可行性,实际做起来几乎不可操作。

Graal VM 的开发团队当然也清楚这一点,所以这个步骤实际的处理途径有两种:

  • 第一种:假设你所依赖的第三方包都已经在 Jar 包中内置了编译所需的配置信息,这样你只需要提供你自己程序代码中用到的配置即可。如果你的程序没有使用反射、动态代理等,那就无需提供任何配置。
  • 第二种:Graal VM 计划提供一个 Native Image Agent 代理,只要将它挂载在程序中,以普通 Java 虚拟机运行一遍,把所有可能的代码路径都覆盖到,这个 Agent 就能自动根据程序实际运行情况生成编译所需的配置,这样无论是你自己的代码还是第三方代码,都不需要预先配置。

目前,第二种方式中的 Agent 尚未正式发布,只有方式一是可用的。幸好,Spring 与 Graal VM 共同维护的 Spring Graal Native 项目已经提供了大多数 Spring Boot 组件的配置信息(以及一些需要在代码层面处理的 Patch),我们只需简单依赖该工程即可。


Spring over Graal

public static void main(String[] args) { 
        SpringApplication.run(ExampleApplication.class, args); 
    }

1.8 随笔文章

Spring over Graal

另外还有一个小问题,由于目前 Spring Boot 嵌入的 Tomcat 中,WebSocket 部分在 JMX 反射上还有一些瑕疵,在修正该问题的 PR 被 Merge 之前,暂时需要手工去除掉这个依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <exclusions>
            <exclusion>
                <groupId>org.apache.tomcat.embed</groupId>
                <artifactId>tomcat-embed-websocket</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
</dependencies>

最后,在 Maven 中给出程序的启动类的路径:

<properties>
    <start-class>com.example.ExampleApplication</start-class>
</properties>

开始编译:

到此一切准备就绪,通过 Maven 进行编译:

$ mvn -Pgraal clean package

编译的结果默认输出在 target 目录,以启动类的名字命名。

因为 AOT 编译可以放心大胆地进行大量全程序的重负载优化,所以无论是编译时间还是空间占用都非常可观。笔者在 intel 9900K、64GB 内存的机器上,编译了一个只引用了 org.springframework.boot:spring-boot-starter-web 的 Helloworld 类型的工程,大约耗费了两分钟时间。

[com.example.exampleapplication:9839]   (typeflow):  22,093.72 ms,  6.48 GB
[com.example.exampleapplication:9839]    (objects):  34,528.09 ms,  6.48 GB
[com.example.exampleapplication:9839]   (features):   6,488.74 ms,  6.48 GB
[com.example.exampleapplication:9839]     analysis:  65,465.65 ms,  6.48 GB
[com.example.exampleapplication:9839]     (clinit):   2,135.25 ms,  6.48 GB
[com.example.exampleapplication:9839]     universe:   4,449.61 ms,  6.48 GB
[com.example.exampleapplication:9839]      (parse):   2,161.78 ms,  6.32 GB
[com.example.exampleapplication:9839]     (inline):   3,113.77 ms,  6.25 GB
[com.example.exampleapplication:9839]    (compile):  15,892.88 ms,  6.56 GB
[com.example.exampleapplication:9839]      compile:  25,044.34 ms,  6.56 GB
[com.example.exampleapplication:9839]        image:   6,580.71 ms,  6.63 GB
[com.example.exampleapplication:9839]        write:   1,362.73 ms,  6.63 GB
[com.example.exampleapplication:9839]      [total]: 120,410.26 ms,  6.63 GB
[INFO]
[INFO] --- spring-boot-maven-plugin:2.3.0.M4:repackage (repackage) @ exampleapplication ---
[INFO] Replacing main artifact with repackaged archive
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 02:08 min
[INFO] Finished at: 2020-04-25T22:18:14+08:00
[INFO] Final Memory: 38M/599M
[INFO] ------------------------------------------------------------------------

效果评估

笔者使用 Graal VM 编译一个最简单的 Helloworld 程序(就只在控制台输出个 Hello world,什么都不依赖),最终输出的结果大约 3.6MB,启动时间能低至 2ms 左右。如果用这个程序去生成 Docker 镜像(不基于任何基础镜像,即使用 FROM scratch 打包),产生的镜像还不到 3.8MB。而 OpenJDK 官方提供的 Docker 镜像,即使是 slim 版,其大小也在 200MB 到 300MB 之间。

使用 Graal VM 编译一个简单的 Spring Boot Web 应用,仅导入 Spring Boot 的 Web Starter 的依赖的话,编译结果有 77MB,原始的 Fat Jar 包大约是 16MB,这样打包出来的 Docker 镜像可以不依赖任何基础镜像,大小仍然是 78MB 左右(实际使用时最好至少也要基于 alpine 吧,不差那几 MB)。相比起空间上的收益,启动时间上的改进是更主要的,Graal VM 的本地镜像启动时间比起基于虚拟机的启动时间有着绝对的优势,一个普通 Spring Boot 的 Web 应用启动一般 2、3 秒之间,而本地镜像只要 100 毫秒左右即可完成启动,这确实有了数量级的差距。

不过,必须客观地说明一点,尽管 Graal VM 在启动时间、空间占用、内存消耗等容器化环境中比较看重的方面确实比 HotSpot 有明显的改进,尽管 Graal VM 可以放心大胆地使用重负载的优化手段,但如果是处于长时间运行这个前提下,至少到目前为止,没有任何迹象表明它能够超越经过充分预热后的 HotSpot。在延迟、吞吐量、可监控性等方面,仍然是 HotSpot 占据较大优势,下图引用了 DEVOXX 2019 中 Graal VM 团队自己给出的 Graal VM 与 HotSpot JIT 在各个方面的对比评估:

图:Graal VM 与 HotSpot 的对比

Graal VM 团队同时也说了,Graal VM 有望在 2020 年之内,在延迟和吞吐量这些关键指标上追评 HotSpot 现在的表现。Graal VM 毕竟是一个 2018 年才正式公布的新生事物,我们能看到它这两三年间在可用性、易用性和性能上持续地改进,Graal VM 有望成为 Java 在微服务时代里的最重要的基础设施变革者,这项改进的结果如何,甚至可能与 Java 的前途命运息息相关。


QCon2020 主题演讲:云原生时代,Java 的危与机

视频公开课

本节笔者有公开课介绍:《QCon2020 主题演讲:云原生时代,Java 的危与机》

今天,25 岁的 Java 仍然是最具有统治力的编程语言,长期占据编程语言排行榜的首位,拥有一千二百万的庞大开发者群体,全世界有四百五十亿部物理设备使用着 Java 技术,同时,在云端数据中心的虚拟化环境里,还运行着超过两百五十亿个 Java 虚拟机的进程实例(数据来自 Oracle 的 WebCast)。

以上这些数据是 Java 过去 25 年巨大成就的功勋佐证,更是 Java 技术体系维持自己“天下第一”编程语言的坚实壁垒。Java 与其他语言竞争,底气从来不在于语法、类库有多么先进好用,而是来自它庞大的用户群和极其成熟的软件生态,这在朝夕之间难以撼动。然而,这个现在看起来仍然是坚不可摧的 Java 帝国,其统治地位的稳固程度不仅没有高枕无忧,说是危机四伏也不为过。目前已经有了可预见的、足以威胁动摇其根基的潜在可能性正在酝酿,正在随云原生时代而降临。

Java 的危机

Java 与云原生的矛盾,来源于 Java 诞生之初,植入到它基因之中的一些基本的前提假设已经逐渐开始被动摇,甚至已经不再成立。

我举个例子,每一位 Java 的使用者都听说过“一次编写,到处运行”(Write Once, Run Anywhere)这句口号。20 多年前,Java 成熟之前,开发者如果希望程序能够在 Linux、Solaris、Windows 等不同平台,在 x86、AMD64、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 最具代表性的技术特征,都是为了便于长时间运行的程序能享受到硬件规模发展的红利。

另一方面,在微服务的背景下,提倡服务围绕业务能力而非技术来构建应用,不再追求实现上的一致,一个系统由不同语言,不同技术框架所实现的服务来组成是完全合理的;服务化拆分后,很可能单个微服务不再需要再面对数十、数百 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 的开发者和社区都没有退缩,它们在各自的领域给出了很多优秀的解决方案。涌现了如 QuarkusMicronautHelidon 等一大批以提升 Java 在云原生环境下的适应性为卖点的框架。不过,今天我们的主题将聚焦在由 Java 官方本身所推进的项目上,在围绕 Java 25 周年的研讨和布道活动中,官方的设定是以“面向未来的变革”(Innovating for the Future)为基调,你有可能在此之前已经听说过其中某个(某些)项目的名字和改进点,但这里我们不仅关心这些项目改进的是什么,还更关心它们背后的动机与困难,带来的收益,以及要付出的代价。

图:Innovating for the Future

(此处应有一张图示,展示 Java 面向未来的变革项目概览)

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 为目标对象进行的介绍:

  • 有一些功能,像反射这样的基础特性是不可能妥协的,折衷的解决办法是由用户在编译期,以配置文件或者编译器参数的形式,明确告知编译器程序代码中有哪些方法是只通过反射来访问的,编译器将方法的添加到静态编译的范畴之中。同理,所有使用到动态代理也的地方,也必须在事先列明,在编译期就将动态代理的字节码全部生成出来。其他所有无法通过程序指针分析(Points-To Analysis)得到的信息,譬如程序中用到的资源、配置文件等等,也必须照此处理。
  • 另一些功能,如动态生成字节码也十分常用,但用户自己也往往无法得知那些动态字节码的具体信息,就只能由用到 CGLib、javassist 等库的程序去妥协放弃。在 Java 世界中也许最典型的场景就是 Spring 用 CGLib 来进行类增强,默认情况下,每一个 Spring 管理的 Bean 都要用到 CGLib。从 Spring Framework 5.2 开始增加了 @proxyBeanMethods 注解来排除对 CGLib 的依赖,仅使用标准的动态代理去增强类。

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 15、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)大约需要耗费数百个时钟周期,而大部分简单指令的执行只需要一个时钟周期而已。因此,在程序执行性能这个问题上,如果编译器能减少一次内存访问,可能比优化掉几十、几百条其他指令都来得更有效果。

额外知识:冯诺依曼瓶颈

不同处理器(现代处理器都集成了内存管理器,以前是在北桥芯片中)的内存延迟大概是 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

虚拟线程是 Project Loom 引入的轻量级并发单元,基于有栈协程实现,可与现有 Java 并发 API 兼容。

同时,Loom 项目的另一个目标是要尽最大可能保持原有统一线程模型的交互方式,通俗地说就是原有的 ThreadJ.U.CNIOExecutorFutureForkJoinPool 等这些多线程工具都应该能以同样的方式支持新的虚拟线程,原来多线程中你理解的概念、编码习惯大多数都能够继续沿用。为此,虚拟线程将会与物理线程一样使用 java.lang.Thread 来进行抽象,只是在创建线程时用到的参数或者方法稍有不同(譬如给 Thread 增加一个 Thread.VIRTUAL_THREAD 参数,或者增加一个 startVirtualThread() 方法)。这样现有的多线程代码迁移到虚拟线程中的成本就会变得很低,而代价就是 Loom 的团队必须做更多的工作以保证虚拟线程在大部分涉及到多线程的标准 API 中都能够兼容,甚至在调试器上虚拟线程与物理线程看起来都会有一致的外观。但很难全部都支持,譬如调用 JNI 的本地栈帧就很难放到虚拟线程上,所以一旦遇到本地方法,虚拟线程就会被绑定(Pinned)到一条物理线程上。

Loom 的另一个重点改进是支持结构化并发(Structured Concurrency),这是 2016 年才提出的新的并发编程概念,但很快就被诸多编程语言所吸纳。它是指程序的并发行为会与代码的结构对齐,譬如以下代码所示,按照传统的编程观念,如果没有额外的处理(譬如无中生有地弄一个 await 关键字),那在 task1task2 提交之后,程序应该继续向下执行:

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、Debian 等动辄一百多 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 8e1658Successfully built 350dd3a72a7d

Java 的未来

云原生时代,Java 技术体系的许多前提假设都受到了挑战,“一次编译,到处运行”、“面向长时间大规模程序而设计”、“从开放的代码空间中动态加载”、“一切皆为对象”、“统一线程模型”,等等。技术发展迭代不会停歇,没有必要坚持什么“永恒的真理”,旧的原则被打破,只要合理,便是创新。

$ sudo docker tag 350dd3a72a7d jdk-13-musl/jdk-version:v1 
 
$ sudo docker images 
REPOSITORY                TAG                 IMAGE ID            CREATED

onController、ResourceQuota、Secret、Service、ServiceAccount、StatefulSet

Istio: 各种资源的名称及状态:AuthorizationPolicy、DestinationRule、EnvoyFilter、Gateway、PeerAuthentication、ProxyConfig、RequestAuthentication、ServiceEntry、Sidecar、Telemetry、VirtualService、WorkloadEntry、WorkloadGroup、WasmPlugin

  1. 上下文指令增强 Fenix-CLI 能够根据当前的上下文环境,对指令进行增强,提供更加便捷的操作。例如,在 Docker 环境下,可以直接使用 fenix docker inspect 查看容器的详细信息,而不必先查询容器 ID;在 Kubernetes 环境下,支持类似 kubectl exec -it <pod-name> -- /bin/bash 的交互式操作,且 pod 名称支持自动补全。此外,还提供了一些快捷指令,比如 fenix clean 可以清理未使用的 Docker 镜像、停止的容器、悬挂的卷等资源,帮助保持环境整洁。

  2. 多会话及分屏支持 Fenix-CLI 支持在同一个终端窗口内创建多个会话(Tab),并支持分屏(Split),允许用户同时监控不同的资源或执行不同的命令。例如,在一个分屏中查看 Pod 日志,在另一个分屏中执行 shell 命令,提升多任务处理的效率。

  3. 主题与自定义 Fenix-CLI 内置了多种配色主题,用户可根据喜好切换。同时支持自定义快捷键、默认环境等个性化设置,配置文件采用 YAML 格式,存储在 ~/.fenix/config.yaml 中。

安装与使用 Fenix-CLI 提供了预编译的二进制文件,托管在 GitHub Releases 页面,支持 Linux、macOS 和 Windows 平台。下载后解压,将 fenix 可执行文件放入 PATH 路径中即可使用。启动时会在终端中呈现交互式界面,用户可通过方向键浏览历史命令,Tab 键自动补全,F1 查看帮助,F2 切换运行环境。

# 下载示例(Linux amd64)
wget https://github.com/fenixsoft/fenix-cli/releases/latest/download/fenix-linux-amd64.tar.gz
tar -xzf fenix-linux-amd64.tar.gz
sudo mv fenix /usr/local/bin/
fenix

进阶:自定义提示与扩展 Fenix-CLI 的数据感知功能依赖于内置的探针(Probe),这些探针会在启动时连接对应的运行环境 API 并缓存数据。对于复杂的 Kubernetes 集群,可以通过配置 kubeconfig 文件来连接多个集群。此外,Fenix-CLI 提供了插件机制,用户可以编写 Lua 脚本扩展自定义的感知数据源或增强指令,插件放置在 ~/.fenix/plugins/ 目录下即可被自动加载。

项目状态

Fenix-CLI 目前处于积极开发阶段,欢迎通过 GitHub 提交 Issue 或 Pull Request。项目遵循 Apache 2.0 协议开源。

ReplicationController、ResourceQuota、ServiceAccount、Container、Events
全局信息,如:集群上下文和命名空间等的提示
内部信息,如:服务的暴露的端口号、文件路径等的提示

https://icyfenix.cn

Fenix-CLI:交互式云原生客户端

4. 交互式的批量操作

为方便同时管理多项资源,Fenix-CLI提供了交互式的CUI操作,支持单选、复选、快速过滤等功能,以满足一次性对多个资源进行同类操作。

https://icyfenix.cn

Fenix-CLI:交互式云原生客户端

5. X指令

除了直接支持原版客户端全部标准指令之外,Fenix-CLI还额外扩展了一系列以x-开头的专有指令。这些X指令是Fenix-CLI的主要价值所在,具体可通过F1或者x-help进行查看。
Fenix-CLI中有不少X指令依赖于开源的Krew Plugin来支持,为最大限度简化插件安装,Fenix-CLI的代码已集成Kubernetes的Krew插件框架,因此无需任何额外操作即可使用全部Krew插件。下面对部分X指令功能列举如下:

切换Kubernetes集群上下文和名称空间

  • x-context 用于切换当前Kubernetes客户端所管理的集群,适用于同一个客户端管理多个服务端集群的场合。
  • x-namespace 用于切换当前Kubernetes客户端所使用的名称空间,以简化在每个命令中都要带有--namespace <ns>参数的繁琐操作。当前的名称空间将会在命令提示符之前列出。

https://icyfenix.cn

Fenix-CLI:交互式云原生客户端

批量管理资源

x-batch 用于批量管理资源,可以用于Docker环境下的容器和镜像,以及Kubernetes环境下的Pod、Deployment、Service等十余种常用资源。前面对交互式CUI的介绍中已演示了x-batch指令的用法。

网络流量跟踪

x-sniff 用于记录Pod的网络流量。对于Gateway节点的流量,我们通常可以很方便地在浏览器上进行查看,但对微服务集群内部节点的网络访问,则较为不便,通常需要专门的追踪工具。x-sniff在无需安装任何追踪系统的前提下,通过自动注入tcpdump,将流量信息发送到TShark或者Wireshark进行分析(所以你的机器上还是需要安装了TShark或者Wireshark的)。同时,为简化TShark的复杂参数,默认提供了summary(只显示调用请求摘要)和detail(显示HTTP Header、Body全文)两种显示形式。该指令基于sniff插件实现:https://github.com/eldadru/ksniff

https://icyfenix.cn

Fenix-CLI:交互式云原生客户端

查看各资源间关系

x-lens 指令用于通过Pod查询并显示相关资源之间的所有者关系。该指令基于pod-lens插件实现:https://github.com/sunny0826/kubectl-pod-lens

https://icyfenix.cn

Fenix-CLI:交互式云原生客户端

快速访问服务

x-open 用于根据服务暴露的端口,自动建立端口转发,并打开客户端中已安装的浏览器,直接访问该服务。该指令基于open-svc插件实现:https://github.com/superbrothers/kubectl-open-svc-plugin

查看集群服务状态

x-status 用于查看当前Kubernetes集群中哪些资源运行正常、哪些存在问题,简化了反复多次kubectl get的麻烦。该指令基于status插件实现:https://github.com/bergerx/kubectl-status

https://icyfenix.cn

Fenix-CLI:交互式云原生客户端

……

安装

自动安装

通过以下脚本,自动安装最新版的Fenix-CLI:

curl -sL https://icyfenix.cn/fenix-cli/dl.sh | sh - 

手动安装

如需其他版本,可在GitHub Release页面获取Fenix-CLI的可执行文件。

使用

安装后输入fenix-cli进行使用。

WARNING

Fenix-CLI仅支持Linux操作系统。

https://icyfenix.cn

Fenix-CLI:交互式云原生客户端

规划

Fenix-CLI后续版本主要特性规划如下:

  • 计划重构智能提示架构。目前静态指令的提示直接内置在程序代码之中,是以Docker v20.10.7(2021年6月)、Kubernetes v1.21(2021年4月)、Istio v1.10(2021年5月)为基准来实现的。随着官方客户端功能的不断发展扩充,靠程序代码去跟随必然难以为继。所幸目前主流的云原生客户端都是使用spf13/cobra作为CLI框架的,因此下个大版本计划重构Fenix-CLI的智能提示架构,支持通过外部DSL来进行驱动,并实现直接从运行机器上的Docker、Kubernetes等环境中实时取得指令与参数信息,自动生成DSL,以此达到自动跟随官方客户端升级的目的。
  • 计划支持更多云原生运行环境,如OpenShift、Rancher、Podman、Containerd等。
  • 计划支持更丰富的X指令,譬如:
    • x-log:自动聚合Pod的日志输出。目前kubectl logs只能监视单个Pod,计划提供一个命令,将微服务相关的多个Pod日志聚合到一个屏幕上滚动跟踪。
    • x-debug:容器高级调试能力。从Kubernetes 1.18起,提供了kubectl debug指令为Pod注入调试容器(1.15-1.17是Ephemeral Feature),计划寻找或制作一个带有常用网络工具又足够精简的瑞士军刀式调试镜像,让Fenix-CLI能够调用该镜像快速进入Pod进行问题诊断。
  • ……
  • 计划支持运行环境自动安装功能。由于中国网络状况限制,Kubernetes等环境需要访问Google仓库,安装十分不便,因此考虑在Fenix-CLI中提供运行环境一键部署的能力。此特性客户端的工作量不大,但服务端做自动从国外拉取镜像的机器人较为繁琐。
  • 计划支持多语言,至少会提供中文语言的支持,有一定翻译工作量。
  • 计划补全单元测试和E2E测试
  • 计划提供一些真实环境中使用Fenix-CLI去运维、诊断、出错的具体案例

交流

建议

如对Fenix-CLI本身的功能有问题或者建议,欢迎在此Repository中提出Issues。

https://icyfenix.cn

Fenix-CLI:交互式云原生客户端

如对Fenix-CLI所使用的Krew Plugin有问题或者建议,可在介绍中以列出这些Plugin的原作者,到作者的Repository中提出。

贡献

欢迎pull request、feature request,欢迎任何形式的协作。

致谢

特别致谢c-bata:Fenix-CLI项目的命令行提示框架基于c-bata/go-prompt实现,部分Kubernetes提示功能直接使用到了c-bata/kube-prompt代码。

协议

本文档代码部分采用 Apache 2.0 协议进行许可。遵循许可的前提下,你可以自由地对代码进行修改,再发布,可以将代码用作商业用途。但要求你:

  • 署名:在原有代码和衍生代码中,保留原作者署名及代码来源信息。
  • 保留许可证:在原有代码和衍生代码中,保留 Apache 2.0 协议文件。

https://icyfenix.cn

ArchSummit2021主题演讲:从软件的历史看架构的未来

1972 年,Edsger Dijkstra在为图灵奖颁授典礼所写的感言文章中说到:“在没有计算机的时候,也就没有编程问题;当我们有了简单的计算机,编程只是个小问题;而现在我们有了算力规模庞大的计算机,那编程就成为了一个同样巨大的问题了”。半个世纪前,Dijkstra 已经敏锐洞见了机器算力的提升是编程方法发展的直接牵引,每当人类掌握了更强的算力,便按耐不住想去解决一些以前甚至都不敢去设想的新问题,由此引发软件设计模式的重大变革。

历史上的软件危机和契机

计算机刚诞生的年代,硬件规模还很小,甚至程序员仅凭大脑就足够记住数据在几 KB 内存中的布局情况,理解每条指令在电路中的运行逻辑。此时的计算机尽管运算速度比人类快,但内部却并没有什么人所不知道的事情;此时的软件开发并没有独立的“架构”可言,软件架构与硬件架构是直接物理对齐的。
随着计算机的快速发展,直接面向硬件进行的软件开发很快触碰到了瓶颈,人脑的生物局限显然无法跟上机器算力前进的步伐,当机器强大到世界上最聪明的人都无法为```java Set nodes = new LinkedHashSet(); nodes.add(new HostAndPort(“192.168.1.1”, 6379)); nodes.add(new HostAndPort(“192.168.1.2”, 6379)); JedisPoolConfig config = new JedisPoolConfig(); config.setMaxTotal(1); config.setMaxIdle(1); try (Jedis jedis = new JedisCluster(nodes, config)) { jedis.set(“key”, “value”); }


https://icyfenix.cn  
# ArchSummit2021主题演讲:从软件的历史看架构的未来

首先,这是一段操作 Redis 的代码,意味着你需要了解 Redis 的知识,不说实现原理,起码要知道它的 API 该如何使用,程序代码也必须引入 Redis 的客户端 SDK 作为依赖项。  
其次,这是一段可运行的 Java 代码,意味着你需要知道 Redis 的服务位置(如 Host 地址、端口等)、部署方式(如单点、集群、分片情况等)、链接信息(如鉴权方式、密码等),这些其实应该是 SRE 而不是 SDE 的职责。  
最后,这是一段在生产环境容易受到挑战的代码,生产可能还需要考虑额外的非功能属性:要不要启用连接池?并发策略是 first-write-wins 还是 last-write-wins ?是否需要支持事务?数据能保证什么级别的一致性?要批量操作该怎么办?假若这些非功能属性都反应到代码上,结果肯定要比现在看到的复杂上不少,其中有一些需求甚至仅凭应用代码是无法解决的。譬如要支持事务,用 Redis 可以,用 Memcached/Cassandra 就不行;要支持强一致性,用 Etcd/ZooKeeper 可以,用 Redis 就不行。  

以上问题,在今天看来其实都算不上真正的问题,去写程序就该懂得写程序的知识,但是作为一名业务开发人员,意图仅仅是想保存或者读取一个 K/V 值对而已,要用 Redis、Etcd、Memcached 或关系库作为存储、要用哪个云服务商提供的存储服务、要满足哪些非功能特性,本不该属于操作意图的一部分,都应该被隐藏起来。譬如下面这样来完成 K/V 值对的存储和访问:

```java
KeyValueService service = ServiceMesh.proxy(KeyValueService.class);
service.set("key", "value");

如果代码能写成这个样子,那意味着在编写程序时,开发者只需表达出操作数据的意图,而不必去关心存储的类型、依赖、位置、非功能属性等细节。这些细节会由 Service Mesh 在运行时自动完成。这种将非功能属性从代码中“外挂”出去的架构风格,我将其称为“云不可知”(Cloud-Agnostic)架构。

从云计算到云不可知

我们继续顺着“软件架构的演进由人与机器的矛盾所驱动,逐渐与算力规模对齐”这条线索,思考软件开发的下一个核心矛盾将会是什么?窥探下一个时代的软件架构会具备何种特征?

笔者认为,软件发展的下一个关键矛盾将会是算力规模超过人应掌握合理知识的极限。经过良好设计的分布式系统,拥有局部的可再生性,确实能在整体上展现出可靠的服务能力。然而,“良好地设计”一个分布式系统很不容易,今天无限火热的云原生、微服务、不可变基础设施、弹性计算、服务网格、无服务器架构、高低零代码,等等,背后都能展开成一整套成体系的开发或者设计方法。这些新的技术在为人们解决了更复杂软件问题的同时,也正在把编程这件事情本身的复杂度推向更高层次。一名刚刚走出校园的大学生,要掌握计算机与程序执行的基本原理,要消化完所用编程语言的核心细节,要掌握领域中常用的类库、框架和工具,要理解分布式系统的服务弹性、容错、限流等设计技巧,要接触容器、云原生、函数计算等运行架构层面的知识,耗费上十年时间都丝毫不奇怪。

在哲学里,有人曾经严肃研讨过“知识膨胀”的问题,说的是人类科学的前沿在不断拓展,触及到前沿所需的基础知识也不断增加,是否会陷入后来者终其一生都无法攒下足够基础,导致人类知识陷入止步不前的危机之中。在计算机科学里就更加现实了,知识膨胀直接表现为从毕业到“35 岁退休”(梗)之前,很多程序员恐怕都很难具备设计分布式架构所需的全面知识。

云与分布式时代,软件知识看来又到了该“打个结”的时刻,要设法把那些重要但普适的知识标准化并下沉。好比今天除非那些专门的领域,大多数程序员已经不再需要关注寄存器、信号、中断等与机器底层的细节,也不会太关注操作系统内存页/段、执行调度器、输入输出原理等操作系统底层的细节,等云数据中心彻底成熟,成为主流的程序部署运行环境后,云与分布式的复杂细节也同样会被隐藏起来。

未来软件如何使用云服务,现阶段还很难有定论,但有迹象表明,软件中的非功能属性会率先被外置出去,而不会继续像现在这样,在开发阶段镌刻定型于程序代码之中。软件是以单体还是以分布式运行,需要提供怎样的 SLA,具体与哪些技术组件进行协作,通讯中是否要容错限流,等等,都不必在开发期就锁定起来,也不必由业务开发人员去关注,他们只处理那些承载系统业务价值的功能属性。这种外挂式的软件架构风格,如同你要上战场便穿上军装,要游泳便穿上泳衣,去舞会便穿上礼服,不同的装备让人能适应不同的场景。而那些“可穿戴”的装备,都是由专业厂商设计,有质量保证,不需要每位编写代码的程序员都知道它们应该如何工作。

正在逐渐成熟的 Service Mesh 就展露出一些这方面的特征,Sidecar 以流量劫持的方式,能够为程序间的网络通讯额外附加上连接稳定性(如重试、熔断)、安全性(如鉴权、双向通讯加密)、可管理性和可观测性,既不依赖人专门去编码,也不依赖某款语言或者框架的预置能力。不过,Service Mesh 仅仅能满足与服务通讯能力相关的治理,而软件设计所需的能力并不止通讯这一项,开发者要依赖多种提供不同能力的运行时来搭建软件,譬如高级语言虚拟机提供执行能力、消息队列提供 Pub/Sub 通知能力、容器编排系统提供生命周期管理能力,等等。开发者使用这些能力时,也面临与通讯一样的治理需求。

ShardingSphere的作者张亮曾经在 InfoQ 撰文,提出过 Database Mesh 的设想,把数据库发现、访问路由、数据分片、读写分离、负载均衡等特性从程序代码中拿出去,也交给 Sidecar 来实现。既然 Service 和 Database 可以 Mesh 化,那 Cache Mesh、Message Queue Mesh、Storage Mesh……等自然都有了登场的理由。更进一步,分布式中那些复杂却有共性的处理技巧,如并行、并发、状态、共识,等等,是不是也可以从程序代码中独立出去,由 Sidecar 引导至合适的、不特定的部件中妥善处理?最后,一旦云计算服务提供商的技术货架中大多数部件和能力被 Mesh 抹掉了差异化特性,剩下都是一致的标准操作,那 Serverless 一直倡导的“后端即服务”(BaaS)便立刻有了无比广泛的基础。此时,云数据中心就仿佛是一部拥有无限算力的机器与一套有标准接口的操作系统,开发者无需关心程序在哪里执行(FaaS),也不再关心程序有哪些依赖(BaaS)。

结语

软件开发的历史已经走过了单机、网络、分布式的阶段,每一次演进都源于解决机器算力与人类能力之间的矛盾。未来,随着算力规模继续膨胀,架构的演进方向必然是将复杂性下沉到基础设施层,让开发者只需关注业务意图本身。当这一天来临时,我们或许将迎来真正的“软件无架构”时代——并非没有架构,而是架构已经完全融入云之中,对开发者透明不可见。

后续代码与调用示例

String result = jedis.set("icyfenix", "{\"name\":\"zzm\", 
\"email\":\"icyfenix@gmail.com\"}"); 
 
return ok(result);
} catch (Exception e) { 
 
log.error("Redis error:{}", ExceptionTools.getExceptionStackTrace(e)); 
 
return false;

使用 curl 调用无服务器计算平台状态存储接口的示例:

curl -X POST http://localhost:3500/v1.0/state/users \
  -H "Content-Type: application/json" \
  -d '[ 
        { 
          "key": "icyfenix", 
          "value": {"name":"zzm", "email":"icyfenix@gmail.com"} 
        } 
      ]'

关于服务地址与 Sidecar 的职责

至于为什么会存在 “http://localhost:3500” 这样的服务地址,后面连接的具体是什么存储服务,这些是 Sidecar 而不是业务开发人员需要关心的事情。 不同产品与不同云计算服务商之间的差异,被隐藏在相同的**操作原语(Primitives)**和代表服务标准含义的接口(如 HTTP URL)之下。 这样云计算就自然而然地打破了目前各厂商之间和产品之间的隔阂,顺利步入到 云不可知(Cloud Agnostic) 的阶段。这便是对云计算与分布式架构“打个结”的具体动作。

虽然迄今为止,上述设想距离现实还很遥远,理论不够成熟,能在生产环境中使用的多运行时框架仍处于十分早期阶段,譬如上面用于演示的代码是基于微软的 DAPR 框架,它在上周才刚刚进入 CNCF 孵化。对这个演示 DAPR 目前也仅仅能处理 K/V 存储,其它存储类型(如更为常用的关系库)目前都还完全没有考虑,但笔者愿意相信这是未来架构演进的一个主要方向,必须把复杂的问题尽量关进笼子,由专业人员去看护,才能让普通程序员更好参与软件开发,甚至通过低/零代码工具的支持,让那些没有太多编程知识,却有丰富领域知识的业务专家,也能够独立制造出优秀的软件产品。

软件,架构与人

第一次软件危机在 1950 年代末期初现端倪,结构化编程思想在 1970 年才被正式提出;第二次软件危机(连同“软件危机”这个概念)是在 1970 年NATO 会议上被定义的,要一直到 1990 年代面向对象的设计方法成为主流,以及 Scrum、XP 等软件工程方法被提出后,这次危机才算是画上句号。从 2010 年左右开始兴起的云计算是程序的运行环境继“大型计算机”转变到“客户端‑服务器”之后的又一场巨变,与前两次软件危机带来的变革契机一样,现有的许多软件架构和开发方法,一定也会在以十年计数单位的时间段内逐渐被颠覆,今天你我所谈的云原生、微服务等话题,仅仅是这次变革浪潮的开端。

原文出处

本文内容源自 ArchSummit2021 主题演讲:《从软件的历史看架构的未来》
原发布地址:https://icyfenix.cn

1.8 随笔文章

![Image 7238 on Page 556]
(图:7238,原文第556页)

curl http://localhost:3500/v1.0/state/users/icyfenix \ 
  -H "Content-Type: application/json" 
{"name":"zzm", "email":"icyfenix@gmail.com"}
https://icyfenix.cn

ArchSummit2021主题演讲:从软件的历史看架构的未来

与技术变革相伴的,是它对行业以及对程序员这个群体的影响。第一次软件危机期间,世界上最聪明的科学家/工程学家在开发软件;第二次软件危机期间,社会中的高智商高学历的精英群体在开发软件;云与分布式的时代,软件开发者恐怕也不可避免会受到下一轮冲击。未来的软件架构对普通程序员应该会是更友善更简单的,但是对普通程序员友善与简单的背后,预示着未来的信息技术行业很可能会出现“阶级分层”的现象,由于更先进的软件架构已经允许更平庸的开发者也同样能写出可运行、可用于生产的软件产品,同时又对精英开发者提出更多、更复杂的技术要求,长此以往,在开发者群体中会出现比现在还要更显著的马太效应,迫使开发者逐渐分层,从如今所有开发者都普遍被认为是“高智商群体”的状态,转变为大部分工业化软件生产工人加上小部分软件设计专家的金字塔结构,就如同现在的建筑工人与建筑设计师的关系一般,今天我们经常自嘲的 CRUD Boy,随着软件产业日趋成熟,恐怕还会真的会成为现实。

本篇文章里,笔者刻意在避免使用“第三次软件危机”这样有哗众取宠嫌疑的表述,危机总是与契机同时出现,未来的软件的一定是越来越贴近于普通平民百姓的软件,但软件的未来也一定有大量的挑战与机会在等待着优秀的程序员与架构师去承担。

软件架构与硬件算力规模对齐

原文引用

As long as there were no machines, programming was no problem at all; when we had a few weak computers, programming became a mild problem, and now we have gigantic computers, programming has become an equally gigantic problem.

在没有计算机的时候,也就没有编程问题;当我们有了简单的计算机,编程只是个小问题;而现在我们有了算力规模庞大的计算机,那编程就成为了一个同样巨大的问题了。

— Edsger Dijkstra, Communications of the ACM, 1972

![Image 7266 on Page 562]
(图:7266,原文第562页)
![Image 7267 on Page 562]
(图:7267,原文第562页)
![Image 7313 on Page 574]
(图:7313,原文第574页)
![Image 7338 on Page 578]
(图:7338,原文第578页)
![Image 7339 on Page 578]
(图:7339,原文第578页)
![Image 7353 on Page 580]
(图:7353,原文第580页)
![Image 7359 on Page 581]
(图:7359,原文第581页)
![Image 7376 on Page 583]
(图:7376,原文第583页)
![Image 7404 on Page 588]
(图:7404,原文第588页)
![Image 7410 on Page 589]
(图:7410,原文第589页)
![Image 7414 on Page 590]
(图:7414,原文第590页)
![Image 7415 on Page 590]
(图:7415,原文第590页)
![Image 7462 on Page 600]
(图:7462,原文第600页)
![Image 7463 on Page 600]
(图:7463,原文第600页)
![Image 7473 on Page 601]
(图:7473,原文第601页)
![Image 7477 on Page 602]
(图:7477,原文第602页)
![Image 7481 on Page 603]
(图:7481,原文第603页)
![Image 7486 on Page 604]
(图:7486,原文第604页)
![Image 7491 on Page 605]
(图:7491,原文第605页)
![Image 7496 on Page 606]
(图:7496,原文第606页)
![Image 7502 on Page 607]
(图:7502,原文第607页)