Java 这门语言与生俱来的显著特性就是“一次编译，到处运行”，这种功能得益于 JVM 平台的支持，Java 程序通常通过将其打包为 JAR 或 WAR 包，并依赖 JVM 和 Servlet 容器来运行。其底层运行时 JVM 采用 JIT（即时编译）模式来执行程序代码，JVM 会在运行时进行编译优化和动态执行代码，这通常会导致较高的内存占用。这样的好处是采用 JIT 可以热更新和热部署程序，并且 JVM 可以在运行期间对程序进行动态分析，来实时优化程序以达到最好的性能状态。Cloud Native 面临的问题随着云计算的发展，服务端程序的运行和部署方式发生了巨大变化，特别是在云原生（Cloud Native）场景下，服务器程序被打包成一个个容器运行。在这种环境下，传统方式容器化的 Java 程序面临着打包后的镜像文件过大的问题。镜像文件中需要包含 JRE 和各种第三方库，导致镜像体积庞大，占用更多的磁盘空间。目前云计算的计费方式也发生了改变，采用按需分配的实时计费方式，根据程序的内存占用和运行时间计费。这与传统 IDC 自托管式机房中长时间运行服务器程序形成鲜明对比，按需分配的计费方式显然更具性价比，因此优化 Java 程序在容器环境中的运行方式变得尤为重要。Serverless 面临的问题传统的 JVM 工作原理要求程序员先将编写的 Java 程序打包为 JAR 或 WAR 包，这是普通 Java 开发者所能接触到的程序编译和构建阶段。然而当程序运行起来之后，底层的 JVM 会进行许多复杂的操作，包括内存管理、垃圾回收、即时编译等。需要先启动 JRE 中的 JVM 来加载 Tomcat 服务器所需的字节码，再加载 WAR 包中的字节码文件到 Servlet 容器中运行服务，这个过程非常耗时。下图为整个传统 Java 程序从源代码到程序运行各个阶段的工作流程：下图为传统的 Java 运行时执行程序的生命周期，首先应用的开发者将 *.java 源代码编译为 bytecode 和 jar 依赖包通过 maven 构建打包为一个单独的 jar 包。当执行 java -jar xxx.jar 命令时，首先要启动 JRE 中的 JVM 程序，因为 JVM 本身也是一款单独程序对应着 $JAVA_HOME\bin 目录中的 java ，当 JVM 启动之后会在系统中创建一个进程，将要运行的 jar 程序包和 JRE 标准库中 class 文件加载入 JVM 内存中，并且找到 main 方法开始解释执行。当程序运行一段时间之后 JVM 充分获取了 Java 程序的一些执行数据信息，开始对程序进行 JIT 及时编译优化程序，当程序被 JIT 优化之后的程序才能达到和 C/C++ 这种直接编译为 Native Code 程序的性能。在云计算推崇的 Serverless 架构场景下，Java 的 JIT 模式并不适合，Serverless 应用程序需要能够快速启动以响应事件驱动的请求，并且在空闲时自动关机销毁，释放在服务请求期间所使用的内存。然而，这种需求与传统的 Java 程序运行模式背道而驰，使得 Java 在 Serverless 场景中显得不适用，这就是被业界称为的 冷启动 问题。为了解决这一痛点，Oracle Labs 推出一款新的 JDK 产品 GraalVM JDK ，相比传统的 JDK 该产品内置 Java 静态编译工具和一款新的虚拟机，和传统的 JVM 不一样 GraalVM 是支持多语言进行混合编程的虚拟机，这也是它一大亮点。GraalVM 中的静态编译工具不仅是支持将 Java 编译为二进制可执行文件，还支持 JavaScript 和 Python 等其他语言的编译优化功能，本文只关注于 GraalVM 对 Java 程序进行 AOT（Ahead-Of-Time）编译的解决方案。使用 GraalVM 对 Java 程序进行 AOT 编译的解决方案，可以显著缩短启动时间并降低内存占用，使 Java 程序更适合在 Serverless 环境中运行，通过 AOT 编译，编译器生成一个 Native Code 二进制文件，即对应平台架构的机器代码程序。这种方式无需像 JIT 那样依赖于 JVM 运行，在内存和磁盘占用上有显著改善。下图为 AOT 编译工作流程，在编译阶段会程序进行静态分析，通过它内置工具分析 Java 源代码中依赖关系，将其所有依赖和代码执行逻辑都进行提前编译为机器代码，缺点也很明显可能会失去在 JIT 模式下的动态反射功能。如上图，在 AOT 模式下将在运行时的过程放到在程序构建阶段，构建阶段会对 Java 程序执行静态代码分析和依赖可达性分析，将其所有的依赖软件包都编译生产所属平台对应的可执行二进制文件。AOT 编译优化对程序进行 AOT 编译特别适合云应用程序，被 AOT 编译优化之后的程序启动速度足够快，从而缩短启动时间并更直接地水平扩展云服务，内存占用相比 JVM 模式下要少很多，这对于在云中运行的容器初始化的微服务尤其有益。AOT 前提是完全封闭的运行空间，因为它消除了各种代码注入的可能性，例如 2021 年震惊网络的 Log4j 漏洞就是由于利用了 Java 中的动态类加载机制而发生的，在 AOT 编译之后程序就不会发生类似于漏洞。下面这张蜘蛛图详细展示了不同程序运行方式的区别：介于 Java 程序部署平台和主流的容器技术都是基于 Linux 系统，所以这里以 Ubuntu 发行版本作为基础 AOT 编译和运行打包环境。要对 Java 程序进行 AOT 编译的话首先要安装 GraalVM JDK 的发行版本，这个可以去它的官方网站 graalvm.org 上下载和安装，安装步骤和传统基于 OpenJDK 的发行版本步骤一致。在 Linux 中要是使用 GraalVM 提供的 AOT 静态编译功能，首先需要在操作系统中安装一些 C/C++ 编译器的工具链，在 Ubuntu 和其他基于 Debian 的系统中执行下面命令：sudo apt-get install build-essential libz-dev zlib1g-dev其中的 build-essential 是一个用于 C 语言的开发包，包含了 gcc make gdb 和 libc 函数库等很多工具，这些工具和库对于编译 C 和 C++ 程序是必不可少的。libz-dev 和 zlib1g-dev 是用于压缩和解压缩的开发库，这两个软件包提供了开发 zlib 库的头文件和开发工具，允许您在编写 C/C++ 程序时链接和使用 zlib 压缩库，GraalVM 的 AOT 编译依赖于这些工具来构建本地映像。默认情况下，GraalVM 的 AOT 编译在链接时通常使用系统的标准 glibc 库，但在小型嵌入式设备或对内存要求极低的环境中，glibc 并未针对这些场景做出特别优化。相比之下目前有另外一种专有的 musl 标准库实现，musl 被开发出来就是专注在内存受限的设备上表现优异，使用 musl 生产的二进制文件通常比使用 glibc 生产的文件体积更小，所以更推荐直接使用 musl 替代 glibc 去做编译链接优化。在 GraalVM 的 AOT 编译工具中提供自定义选项，允许开发者去自定义使用 musl 去做生成二进制文件优化。部分 Linux 发行版本系统可能没有内置 musl 库需要提前安装，通过源代码安装的方式可以去 musl.libc.org 官方网站下载源代码包编译安装。但这种方式的需要自己手动配置和编译步骤比较繁琐，建议使用下面这种方式，通过别人已经编译好的 x64 位的 x86_64-linux-musl-native 程序，下载压缩包到指定位置就可以使用。基于 musl 编译的前置条件是依赖于 musl 工具链，但在 GraalVM JDK 中并未提供相关的工具链的支持，需要提前下载 x86_64-linux-musl-native 和 zlib 文件，创建一个用于存放 musl 工具链的目录，将其解压到这个目录中，并且将 x86_64-linux-musl-gcc 和 zlib 进行整合编译安装，步骤如下：# 创建一个工具存储目录 mkdir /usr/local/graal-aot-tools # 进入到这个目录 cd /usr/local/graal-aot-tools # 下载 x86_64-linux-musl 工具链 wget http://more.musl.cc/10/x86_64-linux-musl/x86_64-linux-musl-native.tgz # 解压下载到的 musl 工具链包 tar -zxvf x86_64-linux-musl-native.tgz # 复制文件到工作根目录 cp -r x86_64-linux-musl-native/* . # 下载 zlib 依赖 wget https://zlib.net/current/zlib.tar.gz # 解压 zlib 依赖源码包 tar -zxvf zlib.tar.gz通过一条特定 TOOLCHAIN_DIR 环境变量来指定 musl 工具链安装路径，graalvm 的 aot 编译器会通过此变量来找到 x86_64-linux-musl 的位置：export TOOLCHAIN_DIR="/usr/local/graal-aot-tools" export PATH="$TOOLCHAIN_DIR/bin:$PATH" export CC="$TOOLCHAIN_DIR/bin/gcc"最后把 zlib 和 TOOLCHAIN_DIR 整合进行编译安装，进入 zlib 源代码安装目录中执行： ./configure --prefix=$TOOLCHAIN_DIR --static make make install配置完成之后可以通过 x86_64-linux-musl-gcc 命令检查 musl 工具链是否安装成功。如果使用的是 maven 来构建自动化项目，可以使用 pom 文件中配置 AOT 相关构建参数，来自动化来构建二进制文件： <plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> <version>${native.maven.plugin.version}</version> <extensions>true</extensions> <executions> <execution> <id>build-native</id> <goals> <goal>build</goal> </goals> <phase>package</phase> </execution> </executions> <configuration> <skip>false</skip> <imageName>${bin.file.name}</imageName> <mainClass>${app.main.class}</mainClass> <buildArgs> <buildArg>--gc=G1</buildArg> <buildArg>--static</buildArg> <buildArg>--libc=musl</buildArg> <buildArg>--no-fallback</buildArg> <buildArg>-march=compatibility</buildArg> </buildArgs> </configuration> </plugin>在项目的根目录中执行 mvn native:build 命令就可以调用位于 $JAVA_HOME/bin/native-image 工具执行静态分析并且生成二进制可执行文件。AOT 编译对计算机硬件资源的要求非常高，取决于项目的大小，大型项目编译时需要占用很多 GB 的内存和大量的 CPU 使用率，不过这会使用高性能的 CI/CD 服务器来完成自动化构建工作。通过 x86_64-linux-musl-gcc 和 musl 做链接编译生成的二进制可执行文件，完全使用的是静态链接的方式，从而提高了可移植性。在不同的 x86_64 的 Linux 系统上运行这些静态链接的二进制文件时，不需要任何额外的库，这对于部署和分发二进制文件非常有用。从编译日志输出可以看到，AOT 编译器启用了 PGO: ML-inferred 优化，这个默认在其他平台是不会启动的，只要在 Linux 平台下使用 x86_64-linux-musl-gcc 才会开启的，由于使用的完全静态链接的二进制文件，所以可以使用 upx 针对这个二进制文件进行压缩，压缩之后的二进制文件可以小到 3 MB 大小。这对于将其打包为 docker 容器镜像文件非常有利，意味着不需要任何其他基础镜像，直接打包可执行二进制文件放到容器中执行，基于 scratch 基础镜像打包之后镜像大小和二进制文件大小一致。# 使用一个轻量的基础镜像 FROM scratch # 复制静态链接的二进制文件到根目录 COPY target/vasedb / # 设定工作目录，这是可选的 WORKDIR / # 默认的启动命令 CMD ["/vasedb"]采用 AOT 和 JIT 模式通过这些数据，形成鲜明的对比，优势也展示出来了，以往采用 JIT 模式运行的 Java 被容器化之后镜像大小往往都是上百 MB 大小的。个人认为有 Serverless 更激进优化方案，在 AOT 编译之后程序会内嵌入一个微型的 SubstrateVM 运行时，这个运行时负责 Java 程序在运行阶段一些多线程和内存分配、垃圾回收功能，默认 AOT 编译使用的 serial 垃圾回收器是一种单线程垃圾回收器，适用于单线程应用或有少量线程的应用，另外一个可选的垃圾回收器是 G1 垃圾回收器，G1 用于多核处理器和大内存机器的高性能垃圾回收器。但是在 Serverless 应用场景中往往应用程序运行时间很短，在程序在运行一段时间之后就主动销毁了，这有点类似于在 AOT 编译阶段的编译程序本身步骤，当编译器编译完程序就销毁了，是一种一次性应用程序。熟悉 JVM 多个垃圾回收器实现应该知道其中有一个叫 Epsilon 垃圾回收器，该垃圾回收器不会执行任何垃圾回收操作，它只进行内存分配而不释放内存。所以我个人认为在某些特定的 Serverless 应用场景中完全可以使用 Epsilon 内存分配器的方案，因为部分场景程序运行时长还没有等到 GC 回收器触发的时间点就被底层基础设施销毁了，程序运行所分配的内存会被 OS 主动回收，而不需要执行垃圾回收器进行回收，去掉 SubstrateVM 中的垃圾回收器部分，打包应用程序体积会更小。引用资料文件Oracle (The Art of) (Java) Benchmarking PPTAOT vs. JIT Compilation in JavaGraalVM Native Image 快速入门
在我的 Java 编程生涯中使用过很多 Java 开发环境，从编写第一个 Java 程序使用的 Notepad++ 到 Eclipse 再到 IntelliJ IDEA。能编写 Java 的 IDE 产品有很多，如何选择一个好的 Java 开发环境？这对于从事 Java 程序设计的程序员来说是一个重要的问题。选择一个合适的 IDE 不仅能提高开发效率，还能减少调试和排错的时间。本篇文章将分享一些选择 Java 开发环境的关键考虑因素，并介绍如何通过 WSL + VSCode 打造一个混合开发基础 IDE 环境来开发 Java 程序。从事 Java 程序开发的程序员主流都是做服务器端程序开发。在编写程序项目源代码阶段，通常使用 Windows 系统的 PC 进行开发工作，而当程序部署上线之后，往往运行在 Linux 系统环境中。这就要求我们不仅要熟悉 Windows 系统，还要熟悉 Linux 系统，才能更好地编写和调试 Java 程序。这也是选择开发环境时需要考虑的一个重要因素，因为一个好的 IDE 应该能够方便地在不同操作系统之间切换，并提供良好的跨平台兼容性。目前主流的 Eclipse 和 IntelliJ IDEA 都是重量级的 Java 集成开发环境，并且针对不同系统架构开发了对应的版本。程序员在 IntelliJ IDEA for Windows 版本下开发，就必须使用 Windows 下的一些开发和调试技巧，而在 IntelliJ IDEA for Linux 中又需要掌握一些 Linux 下特有的功能和设置。那么有没有一种折中的解决方案，可以在 Windows 开发 Java 程序并且能获得 Linux 系统中的一些开发体验和特有功能呢？答案是有的。[scode type="green"]使用 Microsoft 推出的轻量级 IDE 产品 VSCode 和最新版本 Windows 系统所提供的 WSL（Windows Subsystem for Linux）功能就可以实现这种开发模式。我个人认为此种开发方式是更符合现在 Java 程序开发的方式，虽然 JVM 为 Java 程序提供强大跨平台能力，能让 Java 程序一次编写到处运行，但在开发阶段会不可避免的使用所在的开发机上特有命令和功能。对于需要跨平台开发和调试的需求，使用 VSCode 与 WSL 的组合可以在 Windows 环境中提供类似 Linux 的开发体验，能在够让 Windows 和 Linux 系统之间文件和命令互相操作，这种方式不仅简化了环境切换还能提高开发效率，目前最新版本的 IntelliJ IDEA 的 UI/UX 设计越来越像 VSCode 了，所以个人还是更推荐 VSCode 作为基础的 IDE 产品。[/scode]基础 WSL 配置使用 WSL 和 VSCode 作为基础开发环境，最低要求操作系统为 Windows 10。如果使用 Windows 11 则更为理想。在 Windows 11 系统中，WSL 功能已默认开启，但要正常使用还是需要一点额外的操作。WSL 本身不提供完整的 Linux 系统功能，只是提供了一个与 Linux 兼容的内核接口层和虚拟化技术在轻量级虚拟机 VM 中运行 Linux 内核，再配合其兼容层的功能使用户能够无缝结合使用 Windows 和 Linux 的功能。[scode type="yellow"]默认情况下，WSL 并未内置 Linux 系统镜像，需要开发者自行选择和安装相应的 Linux 发行版。[/scode]可以通过在终端中输入以下命令来查看 WSL 官方提供的 Linux 发行版：# 查看能安装的 WSL Linux 版本 wsl --list --online通过列出的版本信息，建议使用默认的 Ubuntu 即可，按照指定的 Linux 发行版本到 Windows 11 系统中：# 指定对应的 Linux 版本进行安装 wsl --install -d Ubuntu通过以上命令就可以安装 Linux 发行版本到 Windows 系统中，命令执行流程如下图：当有多 Linux 版本环境需求时，可以进行多个版本的选择性安装，同样使用 wsl --install -d <发行版本名称> 来获取相应的版本安装，查看已经安装的 Linux 发行版本可以使用 wsl --list --verbose 命令查看，下面是一组常用的命令：功能命令安装指定的版本wsl --install -d <发行版本名称>查看已经安装的版本wsl --list --verbose选择启动指定的 Linux 发行版wsl -d Ubuntu更新 WSL 程序wsl --update查看 WSL 状态wsl --status以某个身份用户运行wsl --user关闭 WSL 的 VMwsl --shutdown上面只列出一些相对常用的 WSL 命令，在其官网文档上有列出更多命令说明 WSL 的基本命令 。[scode type="yellow"]当 ubuntu 子系统安装完成之后建议更新一下系统，更新系统软件方便后面执行其他操作时能正常执行，执行以下命令可以更新 ubutnu 子系统的软件包到最新版本：[/scode]# 更新包列表和升级系统所有已安装的软件包 sudo apt update && sudo apt upgrade{ // Java 基础环境配置 "java.jdt.ls.java.home": "/Users/dings/.sdkman/candidates/java/current", // maven 使用默认的 Java Home 路径 "maven.terminal.useJavaHome": true, // maven 可执行文件文件路径 "maven.executable.path": "/Users/dings/devsoft/apache-maven-3.9.0/bin/mvn", // maven 全局配置文件 "maven.settingsFile": "/Users/dings/devsoft/apache-maven-3.9.0/conf/settings.xml", // 在菜单栏显示 maven "maven.showInExplorerContextMenu": true, }{ "version": "2.0.0", "tasks": [ // 编译 java 源代码 { "label": "Compiler Project", "type": "shell", "command": "javac", // javac 能使用的参数 "args": [ "-d", "${workspaceFolder}/bin", "${workspaceFolder}/src/**/*.java" ], "group": { "kind": "build", "isDefault": true }, "problemMatcher": [], "detail": "Compiles all Java files in the src directory" }, // 清理编译 class 文件 { "label": "Clean Project", "type": "shell", // 调用系统 rm 目录清理文件夹 "command": "rm", "args": [ "-rf", "${workspaceFolder}/bin" ], "group": "none", "problemMatcher": [], "detail": "Clean all Java files in the bin directory" } ] }其他资料什么是 WSL 2？
自从高中开始学编程已经使用过好几门（PL）编程语言了，目前主流最多使用的编程语言 C/C++ 、Java 、JavaScript 这些都属于结构式和命令式编程语言。但在 Java 在 8 版本发布之后引入了 Lambda 表达式方式对函数式编程的支持，从现代的 Java 设计角度上来说并不是一个纯粹的结构和命令式编程语言了，个人将其归属为一个综合性很强的多编程范式的语言。在过去一年的重新学习了基于 ES6 版本的 JavaScript 之后，让我对函数式编程语言更为感兴趣了。在此之前使用过 Go 语言的实现过一些类似于函数式编程的代码，但 Go 在语言设计的时候并没有添加三目运算符和 Lambda 表达式，使其在编程体验上逊色于 JavaScript 和 Java8 这类原生支持三目运算符和 Lambda 表达式的函数式语言，目前我已经从完全放弃了 Go 语言传统命令式进行编程了，除了几个极少历史遗留开源项目正在使用它进行维护之外。如果读者正在考虑深入学习一门 PL 编程语言的话，我建议学习并且使用现代 Java21 之后的版本，现代 Java 已经添加很多新特性，这篇文章我会介绍一下函数式编程的优势。面向过程程序员日常开发中使用的最多编程方式是面向过程和面向对象，面向对象 OOP 是从 C 语言的面向过程演变发展设计出来的；面向过程取决于程序员如何设计一个好的数据结构和用编写好的函数去操作数据完成逻辑计算，在面向过程编程代码逻辑过程中，函数是主要的组织单元，程序的执行是一系列的函数调用，使用结构体来组织数据，但是能操作这些结构体仍然是通过函数来进行的。[scode type="yellow"]编程范式最典型的例子，生活中一件事情抽象到计算机程序如何做？例如一个抽象的问题：如何把一头大象放到冰箱里面该如何做？换到人类的思维第一反应是怎么可能会有冰箱能装得下一头大象？在计算机里你只要想将具体逻辑告诉它，它能可以按照你逻辑去做某件事情，它很笨不会考虑这么多其他因素，不能去考虑大象是不是比冰箱大，除非在装填函数中编写了一些逻辑来检查是否合规。[/scode]下面就是使用 C 语言就编写一个 3 个函数把大象结构放到冰箱结构里，每个步骤对应着一个操作函数，面向过程的窜行代码：#include <stdio.h> #include <stdbool.h> // 定义大象的结构体 struct Elephant { char name[20]; int weight; }; // 定义冰箱的结构体 struct Refrigerator { bool isOpen; bool hasElephant; }; // 函数原型 void openRefrigerator(struct Refrigerator *fridge); void closeRefrigerator(struct Refrigerator *fridge); void placeElephantInRefrigerator(struct Elephant *elephant, struct Refrigerator *fridge); int main() { // 创建大象对象 struct Elephant elephant = {"大象", 5000}; // 创建冰箱对象 struct Refrigerator refrigerator = {false, false}; printf("将大象放到冰箱里面：\n"); // 打开冰箱 openRefrigerator(&refrigerator); // 将大象放到冰箱里 placeElephantInRefrigerator(&elephant, &refrigerator); // 关上冰箱 closeRefrigerator(&refrigerator); return 0; } // 打开冰箱的函数 void openRefrigerator(struct Refrigerator *fridge) { printf("打开冰箱门\n"); fridge->isOpen = true; } // 关闭冰箱的函数 void closeRefrigerator(struct Refrigerator *fridge) { printf("关闭冰箱门\n"); fridge->isOpen = false; } // 将大象放到冰箱里的函数 void placeElephantInRefrigerator(struct Elephant *elephant, struct Refrigerator *fridge) { if (fridge->isOpen) { printf("将 %s 放进冰箱\n", elephant->name); fridge->hasElephant = true; } else { printf("无法将大象放进冰箱，因为冰箱门没有打开\n"); } } [scode type="green"]面向过程重点就是数据和算法的组织，采用顺序执行函数的方式，通常会存在一些全局结构体变量，这些变量会在函数之间通过参数传递；面向过程编程解决问题是：如何设计解决问题的过程？ 这里的过程是指的是函数和函数之间的关系，将问题分解为一个一个小函数，而问题所需要的元数据信息则被抽象为了变量，变量可以传入到函数中执行改变其状态，一步一步顺序执行调用这些过程函数就可以解决问题。[/scode]面向对象典型的面向过程的编程语言是 C 语言，在很长一段历史时间内程序员唯一选择就是使用 C 语言进行面向过程进行程序设计，在复杂业务场景下没有更好的其他 PL 替代选择；只能把编程中所需要的数据变量简单的抽象，在不断的代码项目迭代过程中可能会代码的重用性和可维护性较差，编写的函数和结构体之间没有任何的关联，只能通过显示的函数实参进行调用，使得函数和具体结构体类型之间没有明确绑定关系，在需求不断变化过程中扩展能力差，不利于后期对代码结构进行扩展。直到后面 Smalltalk 、C++ 、Objective-C 、Java 等 Object-Oriented Programming 语言的出现给计算机编程领域加快发展，使得程序员可以用其他编程语言选择了。[scode type="yellow"]在面向对象编程中提倡的是程序员将有状态的变量和能修改变量状态的函数封装成一个单一对象，其对象被作为基本的程序操作单元，把变量和函数封装一个单独对象，通过调用对象上面的函数这里称之为对象的方法（method），通过方法来操作对象状态数据的变更。这些抽象的对象又可以彼此之间具有相互依赖性，对象可以包含另外一个对象，对象方法可以接受另外一个对象作为参数或者返回另一个对象，让程序员编程只需要关注对象之间关系，通过操作对象的实例来解决程序设计问题。[/scode]正如上图一辆汽车可以被抽象为一个 Class 名为 Car 基础类型，汽车的属性信息是可变的可以抽象为对应的对象属性字段，而汽车所应用基础功能可以抽象为功能方法。如果需要编程的过程中需要使用汽车的时候，就可以使用 Car 类型创建出来一辆自定义的汽车，每辆汽车可以具有不同的属性值，但都拥有相同的方法和功能。[scode type="green"]面向对象编程思想本质是现实世界中的事物进行抽象，通过抽象的表达来模拟现实世界中物理关系，此种编程方式更符合人类的直觉，编程过程中关注的是对象与对象之间的关系。对象是通过一个物抽象模版 Class 创建出来的实例，通过实例进行编程。在编程过程中程序员都是提倡如果一段代码被重复的使用，或者类似的代码不止出现于一次，可以考虑将其共同的特性抽象出来。这样对应了面向对象编程的 3 大特性：继承 、封装 、多态，笔者认为面向对象编程最终编程模式是面向接口 interface 编程，程序员更应该更关注是对象之间共同特性就其抽象出来，而不需要关注实现对象的数据结构和算法功能实现的细节，抽象的一个对象包含了数据和操作数据的函数。[/scode]函数编程在 OOP 编程风格中一个 Class 在程序运行中被实例化成为一个对象变量，变量对象中存储运行时所需要的数据信息，在运行时通过对象进行修改其内部属性的值，我们可以改变对象的状态，从而影响程序的行为。这在单线程编程环境很好的设计，但是在多线程环境未必很好设计，当程序在并行执行的时候会涉及到多线程的数据竞争问题，当多个线程同时访问和修改共享的对象状态时，可能会出现不确定的行为和错误的结果。在函数编程中提倡像数学中函数一样求值，强调函数的纯粹性、不可变性和无副作用性，避免修改可变状态和使用可变的数据。这个怎么理解呢？大家在编程中可能使用过类似于 int add(int x，int y){ ... } 这样的函数，该函数对传入的参数进行求值计算。这个 add(x，y) 函数体里面的逻辑就不会对外部的数据进行修改，而是进行某些数学计算得到另外一个结果，纯函数是指函数的输出只依赖于输入不会对外部状态造成影响，也不会产生副作用，即给定相同的输入永远得到相同的输出。下面是使用 C 语言编写一个纯函数例子：#include <stdio.h> // 定义一个纯函数 add，接受两个参数并返回它们的和 int add(int x, int y) { return x + y; } int main() { // 1. 使用 add 函数来执行整数相加的操作 // 2. 调用 add 函数并将返回值赋给 result int result = add(5, 3); // 3. 输出结果，输出结果：8 printf("%d\n", result); return 0; }在这段代码中声明一个 add(x,y) 纯函数，它接受两个参数并返回它们的和，不需要依赖外部的条件变量，而且不会改变任何外部状态。[scode type="yellow"]与传统指令式编程不同，函数编程是围绕着函数进行的，在传统的指令式编程范式中，程序通常被组织为一系列的语句和命令，这些命令会改变程序的状态和数据。而在函数编程中，程序的主要执行单元是函数，它们接受输入并产生输出，而不会改变外部状态或数据。函数之间可以被组合、嵌套和传递，从而实现复杂的逻辑和操作。[/scode]下面这段 C 语言代码就是使用函数式的方式抽象中缀表达式计算过程函数式编程，中缀表达式是数学和计算机科学中常见的表达式形式，其中运算符位于操作数之间。当中缀表达式被抽象为函数的时，每个函数就对应着每个运算符和具体计算步骤，总体整合就会形成一个函数调用嵌套，其结果和中缀表达式计算结果一致，抽象例子如下：#include <stdio.h> /* 数学表达式：(1 + 2) * 3 - 4 过程式和命令式： int add = 1 + 2; int mul = add * 3; int sub = mul - 4; 函数式： int result = subtract(multiply(add(1,2),3),4); */ int add(int x,int y); int mul(int x,int y); int sub(int x,int y); int add(int base,int n) { return base + n; } int mul(int base,int n) { return base * n; } int sub(int base,int n) { return base - n; } int main(){ int res = sub(mul(add(1,2),3),4); printf("(1 + 2) * 3 - 4 = %d",res); }const twoSum = (nums, target) => { const search = (index, complement, map) => { if (index >= nums.length) return []; if (map.has(complement)) return [map.get(complement), index]; map.set(nums[index], index); return search(index + 1, target - nums[index], map); }; return search(0, target - nums[0], new Map()); };
大多数开发者或多或少都使用过 Java 这门编程语言，Java 这门编程语言最大的特性就是一次编写到处运行，这就使得开发者在编写了 Java 源代码之后通过 Java Compiler 编译出来的程序就可以在不同平台架构上运行。不管是 x86 还是 ARM 的 CPU 都运行正常无误，熟悉计算机 CPU 架构都知道不同 CPU 核心使用的不同架构和指令集，如果想要编写一次程序到处运行那就得需要使用交叉编译来完成，但是 Java 打破这一点，这得益于 JVM 的存在，Java 源代码不是将源代码直接编译成为对应平台机器码，而是自己定义一套指令集规则也就是对应的 Java Bytecode 标准，再通过将其字节码文件放到 JVM 上运行，最终 JVM 充当一个翻译员将其通过 JIT 的方式运行程序。[scode type="green"]JVM 有自己完善的硬体架构，如处理器、堆栈、寄存器等，还具有相应的指令系统。JVM 屏蔽了与具体操作系统平台相关的信息；本质上就是对现有的物理硬件的软件抽象的实现，可以作为一种编程语言的虚拟机，实际上不只是专用于 Java 语言，只要生成的编译文件符合 JVM 对加载编译文件格式要求，任何语言都可以由 JVM 编译运行。目前实现这些标准虚拟机有很多，例如 HotSpot 、 JRockit 、 IBM OpenJ9 、 GraalVM 这些都是相关的虚拟机实现产品。[/scode]Java 跨平台原理编写 Java 程序的源文件都是以 .java 为后缀名的文件，下面则为一个 Java 源代码文件要想运行它必须通过 Java 编译器来生成对应的字节码 .class 文件，从而让不同平台的 JVM 加载 .class 文件来运行程序：这里值得注意的是 Java 现在每 6 个月就有一次更新，每次都会有新的语言特性添加，不同版本的 Java 编译器可能生成的代码文件在不同 JVM 有不同效果，例如低版本的 JVM 不能运行高版本的 Java 编译器生成的 .class 文件，反过来则是可以的，不过这些都是小问题可以通过 AOT 编译成对应平台机器码运行，也可以使用一些第三方提供程序版本代码迁移工具 EMT4J 来完成。在 JDK 整个开发工具包中其实有两套编译器，大多数程序员所使用的 javac 编译器，此编译器只是将编写的 java 源文件编译生成为 JVM 能认识的字节码；而在 JVM 中又有一套 JIT 编译器，针对的是更高性能程序场景使用的，会把字节码再次编译成为机器码。JVM 主体架构当 Java 程序需要运行的时，此时就得需要 JVM 了，JVM 内部大致会和下图接近，首先前面通过 Java Compiler 生成的 .class 文件会把加载到虚拟机内存中，首先头一次执行是通过 JVM 内置 Interpreter 解析执行代码，这个过程 JVM 的解析器会把字节码里面指令翻译成对应平台的指令进行运行。另外 JVM 中内置另外 2 个 JIT Compiler 分别为 C1 和 C2，这两个即时编译器是针对长时间运行的热点代码进行优化的编译器，JVM 的设计者在设计的就考虑到一些程序代码运行效率的问题，前面提到的通过解析器执行代码性能肯定不能和 C 这种原生编译成机器码的程序性能相比，所以设计者针对热点代码会采用 C1 和 C2 编译器进行编译较为底层次的机器码来执行。[scode type="yellow"]使用解释器执行时，当某一行代码被执行了几次，这行代码会被打上 warm 的标签，当某一行代码被执行了很多次，这行代码会被打上 Hot🔥 的标签成为热点代码，这和 JVM GC 分代设计方式类似，通过分代来解决不频繁 GC 放到老年代。[/scode]在运行时将热点函数编译为汇编代码，当程序再次运行到经过实时编译的函数时，就可以执行经过编译和优化的汇编代码，而不再需要解释执行了。由于编译是在运行时进行的，因此 JIT 编译器可以获得代码实际运行的路径、热点和变量值等信息，基于此可以做出非常激进的编译优化，从而获得执行效率更高的代码。何为热点代码？例如下面有一段代码就为热点代码，当然现实中程序热点代码可能更为复杂：public class HotSpotExample { public static void main(String[] args) { long startTime = System.currentTimeMillis(); int iterations = 100000000; for (int i = 0; i < iterations; i++) { // 热点代码块 int result = compute(i); if (result % 2 == 0) { System.out.println("Even"); } else { System.out.println("Odd"); } } long endTime = System.currentTimeMillis(); System.out.println("Time taken: " + (endTime - startTime) + " ms"); } // 模拟一个计算函数，这里只是一个简单的示例 private static int compute(int input) { return input * 2 + 1; } }在这个示例中 HotSpot 编译器会观察程序的运行情况，当 compute 方法被多次调用后，它会优化这个方法的执行，将其编译为本地机器代码，以提高执行效率。C1 编译器优化较少，但是编译所消耗资源也较少；C2 编译得到的代码性能最好，但是编译消耗的资源也较多，但是目前在新版本的 JDK 中采用了 Graal 替代了 C2，新版本的 Graal 采用的是 Java 编写，因为是 Java 某些方面能做出的优化比 C2 要强，C2 编译器是使用 C++ 编写的难以维护并且某些特性实现不了，例如函数的局部逃逸分析的优化上，这样也是 Oracle 官方的将其 JVM 命名为 Hotspot Virtual Machine 原因。JVM 核心组件当 JVM 被启动它所做的工作就是为 .class 服务，跑起来是一个进程会加载 jar 包或者 war 包，这里的 war 包是基于 Servlet 容器的，例如 Tomcat 容器，这些容器本身也是 Java 程序也是跑在 JVM 进程之上的，当不同请求过来会被 JVM 内部的线程进行调度，下面为 JVM 内部结构主要架构图：当 JVM 开始执行 Java 程序时，会找到程序入口 main 方法并且创建一个线程开始执行，也称之为主线程；如果在类中创建了其他对象内存会被分配到 Heap 中；当运行一个函数时此时就需要每个线程的执行栈，函数都在自己的栈空间里执行，栈空间里存储的是运行时产生的局部变量，当线程执行到那一条字节码指令时，这个信息指令会被存放在程序计数寄存器中。[scode type="yellow"]需要注意的是方法区，方法区中主要存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等的共享的区域，方法区和堆类似是不连续存储区域，JVM 启动的时候被创建；方法区的大小决定了系统可以保存多少个类，如果系统定义了太多的类，导致方法区溢出，虚拟机同样会抛出内存溢出的错误；例如启动一个 Tomcat 大量加载 jar 依赖和使用大量动态反射技术导致内存溢出崩溃。[/scode]在程序中有一部分内存是在执行过程中可以被多个线程共享的，例如 Heap 内存，在 JVM 中的 方法区 和 堆 的内存都是可以被共享的。这样就会使得多个线程能访问该区域的内存数据，只要涉及到共享数据就产生数据竞争问题，在编写 Java 程序同样要考虑的这些多线程数据竞争问题，如果了解 JVM 这些内部组件的关系就可以使得编写代码时能够避免一些数据竞争的问题。[scode type="yellow"]方法区不一定每个虚拟机都有，得看具体虚拟机实现。方法区和堆内存的数据是被共享着的，只要涉及共享数据就会发生数据竞争，在 Jakarta 中的 Servlet 做法是没有成员变量，多个线程共享访问 Servlet 就没有成员变量也不会发生数据冲突区情况，在运行时就不用考虑线程安全问题这种就为无状态对象。[/scode]JVM 的字节码中程序执行依靠着栈，JVM 实现的指令执行都是依靠着栈结构来设计的，和传统的直接面向 CPU 硬件指令寄存器不一样。因为 JVM 作为一层物理机器抽象层必须要适配很多不同架构的 CPU ，所以如果使用寄存器的架构就必须和本机的平台架构绑定到一起；而 JVM 基于栈的实现可以在多个不同硬件平台上执行程序，但是基于栈指令比基于寄存器的指令要多，传统寄存器只需要 2 条指令完成的操作，而基于栈的可能需要多个指令操作，下面为一个计算两数之和的 Java 源代码对应的字节码指令：当字节码指令被执行时，JVM 中的 Program Counter Register 就会保存着每个线程栈该执行对应字节码指令的位置地址。在多线程环境下 CPU 具体如何去调用这些线程是不确定的，线程之间是可以来回切换的，如果发生了切换线程操作，当线程被切换到 CPU 上继续执行时就可以从 PC 中获取对应指令地址；在每次执行一次字节码指令之后 PC 的值会对应将其加指令地址操作，具体增加多少的得看指令大小偏移量，此时 PC 就会保存着下一条需要被 JVM Engine 执行的字节码指令的地址。下图为执行大致的过程：栈是在运行时的单位，而堆是存储的单位，当程序被执行起来时每个线程都有相应的栈，对象和方法最后都是为字节码指令会被依次压入栈中进行操作，在栈顶的栈帧为正在执行的栈帧，当栈帧上栈帧被执行完成之后会销毁，依次执行直到栈为空程序结束执行。[scode type="red"]执行栈的退出方式有 2 种，第一种为程序正常 return 返回退出，另外一种则为遇到未知错误和异常的情况导致栈帧退出；JVM 的执行栈大小可以被设置，具体参数例如 -Xss128k ，如果运行时递归调用函数可能就会导致 StackOverflowError ，如果 Java 虚拟机堆栈可以动态扩展，如果没有足够的内存可用于为新线程创建初始 Java 虚拟机堆栈，则 Java 虚拟机机器抛出一个 OutOfMemoryError ，这些都是属于堆栈运行时错误问题。[/scode]在栈中的变量所使用的内存是不需要被 Garbage Collection) 所管理的，当栈帧销毁时变量所使用的内存就会被释放掉，这里针对的是基础数据类型 byte 、 short 、 int 、long 、float 、 double 、 char 、boolean 所分配的空间，而针对的自定义复合数据类型 Class 在使用时就被称之为引用类型，当使用 new 关键字创建对象时此时它所占用内存是在堆上分配，而在栈上也就是一个指向堆的内存地址引用，内存布局为下图： 当在不同的方法之间传递对象的时，对象的信息会在每个方法栈中，传递依靠着是局部变量表，局部变量表是 JVM 设计一个特殊的表格，会有 slot 槽位，槽位中存储的各个变量在栈帧中的信息，可以根据这些信息找到对应分配空间，例如在栈帧中操作堆内存上的数据。局部变量表的大小会影响的栈帧的大小，如果局部变量表很大的话可能会占用更多空间，而相应的能创建栈帧数就会减小，局部变量表还有重要的作用就是和垃圾回收相关的，它是垃圾回收器扫描时的根节点，如果局部变量表中的变量还在被使用，这个变量所占用的内存就不会被回收掉。垃圾回收前面介绍 JVM 内存部分了解到程序在运行过程中会不断的创建新的类对象，每个的对象都需要内存分配，而这些内存会从堆内存中分配，如果频繁的不断创建对象很有可能导致堆内存被占用满。计算机的内存不是无限的这时 JVM 的设计就要想办法收回一些不再使用的对象占用的内存空间，这里将不再使用的对象称之为垃圾，所谓的垃圾就是不再使用的对象引用，垃圾回收器核心功能就要找出存在堆中不再被使用的对象，清理掉占用的空间，再让这些空间再被二次使用。[scode type="yellow"]垃圾回收器核心算法就是要找出堆空间中不被程序所引用的对象，并将这些对象的内存空间回收，这里的回收在不同的 GC 算法实现有所不同，目前主要的 GC 算法还是通过查找引用并且标记对象，被标记的对象所占用的空间就为可以被二次使用，这是整个 GC 算法的核心功能，如上图所示在一段内存空间来找出那些内存片段是不被使用的，而那些内存片段是还在被使用的，这与操作系统的内存分页管理方式很类似。[/scode]目前在很多自动回收内存的编程语言中，都有垃圾回收器算法的踪影，这个取决于各种的垃圾回收器算法的实现大同小异，常见的垃圾回收器分为 3 种：串行垃圾回收器： 每当垃圾回收器的线程工作的时候，所有正在执行其他计算任务的线程必须停止工作，如果不停止其他线程会导致垃圾回收器标记的垃圾会引发混乱，当这些线程被停止之后就会引发程序停止的状态，这个过程也称之为 STOP THE WORLD 即世界被暂停了。并行垃圾回收器： 相较于串行垃圾回收器的工作方式稍微做了一些优化，采用多个垃圾回收器线程工作的方式进行垃圾扫描标记和清理工作，可以利用多核 CPU 的优势提高整个垃圾回收过程效率。并发垃圾回收器： 这种垃圾回收工作线程和其他计算任务线程可以并发的运行，我称之为渐进式垃圾回收算法，这和前面我博文中讲到的 Wisckey存储 扫描标记的方式类似，先对某块内存进行扫描划分区域性扫描，渐进式扫描整个堆空间直到完成。上图为 JVM 中的堆结构内存布局，JVM 的内存布局采用的分区的方式，分为新生代和老年代两种堆区域，这种分区的方式和执行引擎中针对热点的代码的方式很类似，如果某些代码被频繁的执行就会被进一优化成底层机器码。而 JVM 中堆内存布局也有这种设计方式，绝大多数的 Java 对象的生命周期一般都很短，对应这种对象都是归于新生代区域管理，垃圾回收器针对于新生代的堆空间 GC 可能比较频繁，但是不会去扫描整个堆区域的内存，从而提高 GC 效率。当年轻一代填满时，这会导致一次小的垃圾收集过程，假设年轻代区域的对象死亡率很高，就可以进行一次小规模的垃圾清理过程，在此过程中一些幸存的对象会老化并最终移至老年代，进行这样的一次过程被称之为 Minor GC。而老年代中则存放的存活时间较长的对象，长期存活的对象与经过多次 Minor GC 依然存活下来的对象，在老年代内存被占满时就会进行垃圾回收，而老年代进行垃圾回收过程称之为 Major GC 这个过程可能相较于 Minor GC 执行时间更长。新生代设计较为复杂，本分为了 3 个不同的区域分别为 Eden 和 S0 、S1 三个区域，Minor GC 会先扫描整个 Eden 区域如果有未引用的对象，会直接清理掉，而针对还有被引用的对象则会被移动复制到幸存者空间 S0 中。这样反复 Minor GC 之后如果还会存活，一旦所有幸存的对象都被移动到 S1，最后会直接移动到老年代上，新生代整个区域将会被清理掉。当数轮次要 Minor GC 之后当老化的对象达到某个年龄阈值时，它们会从年轻代提升到老年代，最终将对老年代执行主要 Major GC ，清理并压缩该空间，这就是整个 JVM 的垃圾回收器设计，通过分代的方式进行垃圾回收器清理这和 LSM-Tree 存储引擎的垃圾回收器的设计有很相似之处，非常精妙！最后永久代包则含 JVM 所需的元数据用于描述应用程序中使用的类和方法，由 JVM 在运行时根据应用程序使用的类填充，此外 JavaSE 的标准库类和方法可能存储在这里。Summary作为一名 Java 开发人员了解 JVM 相关的原理对于编写健壮的 Java 程序有很多帮助，当然 JVM 也不止适用于某个特定的编程语言，了解 JVM 也是帮助提升对计算机底层技术的认知，在 JDK 中包含了很多用于调式 JVM 的工具，例如查看内存情况的 jmap 和 垃圾回收器状态的 jstat 工具，还有一个单独的 VisualVM 项目，这些都是官方提供好的工具能帮助开发者在实际开发中解决问题，了解 JVM 架构之后当程序出现问题了之后就知道从哪里入手解决问题，不会出现束手无策的情况。其他资料What Does a Modern JVM Look Like, And How Does It Work?GraalVM + AWS Lambda or solving Java cold start problemAOT vs. JIT Compilation in JavaDeep Dive Into the New Java JIT Compiler – GraalCategories of Java HotSpot VM OptionsJava Language and Virtual Machine SpecificationsJava | Multithreading Part 1: Java Memory ModelJava Garbage Collection BasicsMinor GC vs Major GC vs Full GCOpenJDK vs. Dalvik/ART Virtual MachineJava Developer's Introduction to GraalVM / 郑雨迪浅析JAVA虚拟机结构与机制CORE JAVA WEEK 2022 | 面向云原生现代化Java实践与演进-李三红
什么是结构化并发？日常开发中我们编写的最多就是多线程程序，服务器端应用更是如此，传统的方式都是依靠着操作系统提供的 1:1 线程方式进行请求处理这对于管理和复用线程有很多挑战，如果一个普通线程大小 8MB 那么开启 1000 个线程，几乎是无法完成的，并且管理这些线程的状态也是很复杂的。今天这篇文章要介绍的是结构化并发，就是为解决并发编程中线程并发任务管理，传统的方式非常容易造成管理混乱。结构化并发解决的问题就是对统一的任务和统一作用域下的任务进行管理，可以统一启动和统一关闭，如果读过我之前的 Linux 进程组那篇文章的话，就完全可以理解是什么意思了，文章地址：Linux 进程树。在了解结构并发编程范式之前得先讲讲编程语言流程控制发展史，了解一件事的全部应该是去了解完整的历史，并且要找到正确的资料和原版资料去了解，而不是已经修改几个版本的资料，让我们回顾编程语言的一些历史：早期如果想在计算机上写程序必须使用很低级的编程语言去写程序，例如汇编语言，通过一条一条硬件指令去操作计算机，并且顺序执行的，这种编写程序的方式真是令人头疼的。这就使一些计算机界大佬想去重新设计一些编程语言，当时一些美籍计算机科学家们 John Warner Backus 和 Grace Hopper 开发了 Fortran 和 FLOW-MATIC初代的编译命令式编程语言，最后在这些基础之上开发了商业通用编程 COBOL 语言。有趣的事情是世界上的第一个 Bug 也是 Grace Hopper 所发现的，当时的计算机(Harvard Mark II)体积还很大。当时这台计算机在运算的时候老是出现问题，但是经过排查编写的程序指令是没有问题的，最后发现原来是一只飞蛾意外飞入电脑内部的继电器而造成短路如下图所示，他们把这只飞蛾移除后便成功让电脑正常运作，这就是世界上第一个计算机程序BUG。早期的 FLOW-MATIC 是第一种使用类似英语的语句来表达操作的编程语言，会预先定义输入和输出文件和打印输出，分为输入文件、输出文件和高速打印机输出，下面是一段程序代码的例子：看完上面的实例，会发现和现在开发者所使用的更高级的 Java 或者 C 语言还是有一些差距的，例如没有函数代码块，没有条件控制语句，在 FLOW-MATIC 被推出的时候这些现在高级语言的特性还没有被发明出来，在当时看来 FLOW-MATIC 应该是能满足编写程序需求。设想一下如果和输入指令一条一条执行程序是不是很麻烦，如果不能复用一些以有编写逻辑那就要重新编写一些代码逻辑会很费时费力，所以 FLOW-MATIC 的设计者在语言加入了 GOTO 语句块， goto 可以让程序在执行的时候执行到了 goto 然后去执行指定位置的代码块，本质上还是非结构化编程，不过可以做到程序的代码复用和重执行， goto 的加入 FLOW-MATIC 之后如下程序执行流程图：FLOW-MATIC 执行语句通常都是顺序执行的，但是下面这种情况就会发生跳转操作，它可以直接将控制权转移到其他地方，例如下面从 8 行跳转到第 4 行。极少量的 goto 语句是很清晰的，但是令人头疼的问题是程序代码逻辑量变多了之后就会产生很多无法通过正常人类思维所理解的代码跳转逻辑，并且跟踪代码的逻辑很困难。这种基于跳转的编程风格是 FLOW-MATIC 几乎直接从汇编语言继承而来的。它功能强大，非常适合计算机硬件的实际工作方式，但直接使用它会非常混乱。像上面图片中的箭头箭头太多了，就发明 Spaghetti Code 一词的原因，代码逻辑存在各种飞线关系，揉成一坨的代码逻辑。显然我们开发者需要更好的流程控制设计，而不是让代码逻辑写出来像意大利面条一样。当然目前讨论的话题是编程语言的结构化编程设计问题，这个不是本篇文章的重点，本篇文章更偏向的是一些编程语言在线程并发状态转播和控制管理上的一些问题，下面正式开始正文内容。非结构化并发介绍了早期编程语言中的 goto 关键字，可以在当前的执行控制流中开一个分支去执行另外的操作，和我们现在在高级编程语言中使用的 Thread 差不多，例如下面代码：package main import ( "fmt" "time" ) func f(from string) { for i := 0; i < 3; i++ { go fmt.Println(from, ":", i) } } func main() { f("direct") go f("goroutine") go func(msg string) { fmt.Println(msg) }("going") time.Sleep(2 * time.Second) fmt.Println("done") }在线运行代码地址： https://go.dev/play/p/wQ7Yz9mxXlu在这个例子中我使用的是 Go 语言的 goroutine 为例，在 Go 语言中想启动一个协程就可以使用 go 关键字，这和上面我们讨论的 goto 语句很接近，会从主控制流中分离出另一个代码逻辑执行分支，流程如下图：当然在 Go 语言中是保留 goto 跳转语句块的，例如下面这行代码就是 Go 中的 goto 语句块：package main import "fmt" func main() { /* 定义局部变量 */ var a int = 10 /* 循环 */ LOOP: for a < 20 { if a == 15 { /* 跳过迭代 */ a = a + 1 goto LOOP } fmt.Printf("a的值为 : %d\n", a) a++ } }在这个例子中 goto 代替了传统的 break 关键字的作用，那个例子准确来说应该是说类似于 continue 作用，看怎么用了，这里不接受任何反驳。直接跳过满足 a==15 的逻辑块。这就是目前高级语言中的跳转应用，当前这种还是在主程序流上运行的指令的，于 Go 语言中的 go func(){} 关键字去跑起一个协程做并行任务处理是完全不一样的，为此我特定花了一张图来比较两者的关系，如下：像上面这样的通过 go 关键字启动的协程就和一个不透明的盒子一样，你不知道被启动代码块里面是否还有 go 关键字启动其他协程，递归启动协程是一件很难控制的事件，这就和 MapReduce 思想很像，最终还是要汇总的每个协程中产生的数据和控制协程状态的，如下图：像上面这幅图中如果里面的每个圆圈 ⭕️ 都代表着一个正在并行处理任务的协程，我们要如何管理这些协程状态呢？当然 Go 语言在设计的时候就引入了 channel 概念，我们开发者可以显示将 channel 提供代码的方式嵌入到每个要执行协程任务代码块中；早期的 Go 版本中为了控制协程中的协程状态是直接嵌入 channel 然后再每个协程内部编写具体状态控制代码，如果上级发送了通知那么此协程会做出相应的动作，这是初步的 Go 版本状态控制。在最新 Go 语言设计的版本中为了管理这些协程，在语言默认标准库中通过了 context 包所提供功能来做并行协程上下文通讯和状态同步：package main import ( "context" "fmt" "time" ) func doSomething(ctx context.Context) { ctx, cancelCtx := context.WithCancel(ctx) printCh := make(chan int) go doAnother(ctx, printCh) for num := 1; num <= 3; num++ { printCh <- num } cancelCtx() time.Sleep(100 * time.Millisecond) fmt.Printf("doSomething: finished\n") } func doAnother(ctx context.Context, printCh <-chan int) { for { select { case <-ctx.Done(): if err := ctx.Err(); err != nil { fmt.Printf("doAnother err: %s\n", err) } fmt.Printf("doAnother: finished\n") return case num := <-printCh: fmt.Printf("doAnother: %d\n", num) } } }本示例代码在线地址：How To Use Contexts in Go关于结构化并发在 Go 语言中一些问题上面是我个人见解，还有一些关于 Go 中的结构化并发讨论的文章可以查看这篇文章：Go statement considered harmful，在这篇文章里面作者对现有的Go语言协程设计抛出很多观点值得一读。结构化并发设计在上面我介绍了一些关于非结构化并发的程序设计问题，如果单独创建协程没有做好错误处理或者异常情况下的处理，可能就会出现协程泄露问题，这就是本节要讲的结构化并发来做的并发控制设计。[scode type="yellow"]其结构化并发核心设计原则是：可以通过代码明确并发程序任务集的入口点和出口点，并确保所有衍生线程在退出之前完成的控制流构造来封装并发执行线程，能将子线程发生的错误传播到控制结构到父范围上，并且达到无子线程存在泄漏问题。[/scode]这里我会拿我目前还稍微熟悉一点的 Java 语言举例，例如在 Java19 中添加的结构体并发特性，所采用的线程控制就是结构化并发的应用，如下的示例代码：void serve(ServerSocket serverSocket) throws IOException, InterruptedException { try (var scope = new StructuredTaskScope<Void>()) { try { while (true) { var socket = serverSocket.accept(); scope.fork(() -> handle(socket)); } } finally { // If there's been an error or we're interrupted, we stop accepting scope.shutdown(); // Close all active connections scope.join(); } } }上面代码的逻辑就是一个简单的 socket 处理逻辑，采用的就是结构化并发，可以看到 finally 里面的异常处理逻辑和 scope 任务线程块，当然这些内容在 Oracle 公司的 Open JDK 设计草案里面就有地址如下：https://openjdk.org/jeps/428，我只是对这篇内容做了导读和个人见解分享，当然这里我拿几个语言作为例子不是为了讨论谁好谁坏，而是从语言设计角度来看每个不同语言面对这些问题是怎么解决的，瑕瑜互见。小结我个人认为结构化并发是未来的并发和并行程序设计方向，现在有结构化并发程序设计的语言 Kotlin 、Java 、 Swift 等，Rust 语言中也有这方面相关第三方实现目前还不够完善。由此可见通过作用域定义了主协程的子协程的生命周期和关系，事实证明，这一原则在协程中实施了层次结构。如果协程需要为自己创建子协程，那完全没问题，就像您如何将if语句嵌套在一起并理解分支如何嵌套一样，协程也可以嵌套，最顶级的协程不仅取决于他们的孩子完成，还取决于他们孩子的孩子，这就是一个多叉树型的结构，更多相关研究还要靠着开发者们一起探索，我下面给出一些这方面领先的技术文章分享链接。其他资料Go statement considered harmfulProject Loom And Structured ConcurrencySwiftUI and Structured ConcurrencySwift 结构化并发
最近由于 Java 整个社区正在朝着 Cloud Native 方向发展，本人已经好几年没有编写 Java 应用程序。最近准备捡起来玩玩，准备在一台 RHEL9 服务器配置一些 Java 的运行环境，这里运行环境可以是 JRE 但是我选择的整个 JDK 工具包的方式安装，另外 jar 依赖管理采用的是 Maven 所以还需要配置一些 Maven 开发环境。JDK 配置这里由于 Oracle 官方的 JDK 是收费的（如果是个人开发者使用是免费的），这里我采用的 Amazon Corretto JDK 发型版本，基于 OpenJDK 的发行版本有很多。下载 Corretto JDK 的话去 Amazon Corretto 官方网站下载，Linux 下使用 wget 命令下载即可：wget https://corretto.aws/downloads/latest/amazon-corretto-17-aarch64-linux-jdk.rpm这里使用的 RedHat Package Manager 软件包的方式，如果人 tar 包需要自己解压到指定位置，RPM 包直接使用命令安装即可：# 安装软件显示信息和进度条 rpm -ivh amazon-corretto-17-aarch64-linux-jdk.rpm # 支持直接从http和ftp协议拉取下载并且安装 rpm -ivh https://corretto.aws/downloads/latest/amazon-corretto-17-aarch64-linux-jdk.rpm使用的 RPM 默认其实可以不用配置 Java 环境变量信息的，但是部分比较老使用 Java 编写的软件可能会需要查找环境中变量信息，推荐还是配置一下即可：# 配置 Java 环境信息 vim /etc/profile在末尾最后一行添加下列信息，如下：# JAVA_HOME Java程序安装目录 export JAVA_HOME="/usr/lib/jvm/java-17-amazon-corretto" # 运行时需要CLASS信息 export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar # 运行时 export JRE_HOME=/usr/lib/jvm/jre # 导出到环境变量中 export PATH=$JAVA_HOME/bin:$PATH添加完成之后使用 source /etc/profile 刷新系统环境配置信息。Maven 配置光有 JVM 运行环境是不行的，如果要从源代码做自动化构建的话还需要 Maven 环境支持，直接去 Apache Maven 官网下载安装包，命令如下：# 通过 wget 下载安装包 wget https://dlcdn.apache.org/maven/maven-3/3.8.7/binaries/apache-maven-3.8.7-bin.zip # 解压安装到指定目录 unzip apache-maven-3.8.7-bin.zip -d /usr/local/解压完成之后鉴于某些地区网络问题，必须配置加速 Maven 地址，在 /usr/local/apache-maven-3.8.7/conf/settings.xml 添加阿里云地址：<mirror> <id>alimaven</id> <name>aliyun maven</name> <url>http://maven.aliyun.com/nexus/content/groups/public/</url> <mirrorOf>central</mirrorOf> </mirror>当配置完成之后，还需要将 Maven 的安装目录配置到环境变量中，方便其他程序查找使用：export MAVEN_HOME="/usr/local/apache-maven-3.8.7" export PATH=$MAVEN_HOME/bin:$PATH配置完成之后要刷新系统变量，使用命令 source /etc/profile ，最后查看是否全局生效，使用下面命令查看：[root@rhel9 ~]# mvn -v Apache Maven 3.8.7 (b89d5959fcde851dcb1c8946a785a163f14e1e29) Maven home: /usr/local/apache-maven-3.8.7 Java version: 17.0.6, vendor: Amazon.com Inc., runtime: /usr/lib/jvm/java-17-amazon-corretto Default locale: en_US, platform encoding: UTF-8 OS name: "linux", version: "5.14.0-162.6.1.el9_1.aarch64", arch: "aarch64", family: "unix"至此我们就配置完成整个 Java 运行环境和 Jar 依赖管理环境。
作为一名开发者最重要的工具就是编辑器和集成开发环境，我自从接触编程到现在也用过很多编辑器，从 Sublime、Atom、VSCode、IBM Eclipse、MyEclipse 到 Intellij IDEA 系列不下 5 种。用的最多就是 IBM Eclipse 这是最为古老的集成开发环境，作为 Java 程序员如果年龄老一点都会使用到，以前成熟的集成开发环境也就 Eclipse 和 Microsoft Visual Studio 的天下。现在已经有很多开发者工具可供选择了，可能某些比较 Geek 大佬还在使用 VIM 或者 Emacs 这些上古神器。我是被 Jetbrains 2018 年在国内开发日活动种草，目前已经转移到 JetBrains 全家桶旗下，Intellij IDEA 作为 Java 程序员应该是使用最广使用人数最多的，其他语言在此基础之上安装插件进行二次开发，本篇将介绍 IDEA 最常用的一些设置，本人已经使用 IDEA 快 5 年了，常用一些设置也就那么几个，工欲善其事必先利其器！基础配置日常开发过程中最常用的就是代码提示功能，IDEA 在这方面已经很智能了，但是日常还有一些自定义需求 IDEA 官方也通过允许开发者自己自定义的选项，可以允许用户自定义代码模板和注释模板，配置路径如下：Perferences/Editor/Live Template ，设置如下：如果想配置一个函数签名的注释模板可以使用如下配置，其中的 $params$ 为变量，相关的参数说明可以查看官方的文档:/** * @param $params$ * @return $return$ */这些使用 $ 引入的变量，可以设置通过何种方式获取值替换到具体的位置上，如下图:另外的方式就是通过注释模板来生成每个 Class 文件头的作者说明信息，模板如下：/** * @author LeonDing * @date ${DATE}-${TIME} * @since 0.1.0 * Comments text. */ public class ${NAME} { }设置方式如下图，位置在 Editor\File Code Eemplate：上面这种方式为实时代码补全，另外一种方式就是后缀补全，通过 . 分割符号激活的补全，这和 Eclipse 默认的补全方式类似，相关配置如下：配置完成之后每次新建一个 Class 文件时就会默认给你生成相关的注释信息。在编写代码的时候可能需要导入一些第三方包的 Class 文件，手动导入很麻烦可以让 IDEA 自动优化导入包，配置如下图：有时候导入的 Class 文件较多，程序会自动根据同一个包下的 Class 文件数量来整理导入的包，设置如下图：有时候我们的 Project 栏下面很多不需要展示的文件，可以使用下面的配置来过滤掉一些无用的文件：如果对默认灰色的代码注释快颜色不够满意，可以通过下面的方式进行设置：在编写代码的时候时不时会切换大小写，如果不想使用通过严格的大小写来激活代码提示功能，可以使用下面的方式忽略大小写：如果编写的函数和方法过多，可以设置每个函数之间的分割符号，如下图：如果项目中部分文件的原始编码和 IDEA 默认使用的编码不同可能会出现无法解析的情况，此时可以对某个特定的文件或者目录进行设置专属编码，如下图：环境配置作为 Java 项目开发可能难免会使用到多版本的 JDK 和不同 Java 版本的语言新特性，所以有必要熟悉这些配置的，第一个就是我们使用的 Java Compiler 的相关的设置，如下图：可以设置项目的字节码版本号和单个 Class 文件版本号，目前 Java 还通过 JVM 进行运行的，未来如果 Java 全生态拥抱 GraalVM 可能就不需要这些设置了采用原生机器码运行。另外就是一个项目导入多个版本 JDK ，我电脑已经安装了多个版本的 JDK ，直接导入如下图：另外就是 Jar 依赖导入，Apache Maven 项目依赖管理工具这里也要配置，提前下载好 Maven 并且解压到自己熟悉的目录下：如果在国内要使用阿里云的中央仓库源，内容如下：<mirror> <id>aliyunmaven</id> <mirrorOf>*</mirrorOf> <name>阿里云公共仓库</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>如果需要指定项目模块使用的某个 Java 语义化版本的支持，例如设置为 11 版本的，可以设置如下：大部分情况下都是我们运行项目的时候要会编译才能正常运行，可以设置使用什么 JDK 进行编译，如下图：如果是做基于 Servlet 容器企业级应用开发，就需要 Apache Tomcat 和配置运行环境，前提已经下载好 Tomcat Server 并且解压了，配置环境如下图：目前 Tomcat 最新版本已经是 10.0.27 版本，还有 Tomcat Native 相关的版本，此版本是近期发布的基于 Apache 的 APR 包来处理包括文件和网络IO操作，以提升性能。Maven 工程大部分工程师所做的工作是基于 Java 开发 WebService 应用程序，免不了使用 Maven 来管理和构建项目，Maven 创建项目之前首先会配置 Maven 环境上面已经介绍过，创建 Maven 项目很简单只需要在项目初始化时选择对应 Maven 项目组织骨架即可，下图则为创建一个 webapp 结构的项目骨架：创建过程中需要连接网络，Maven 会自动下载需要的依赖 Jar 包，创建完成之后现在的 Maven webapp 骨架没有以前老版本的会帮忙创建完整，部分目录需要手动创建 java 、resources 、test 等目录，设置如下图：如果一个项目由 Module 组成可以导入其他的子 Module ，导入如下图：大部分基于 Maven 的 webapp 项目都依赖于 Servlet 容器来运行的，可以是 Oracle Weblogic 、 Eclipse Jetty 、这以 Apache Tomcat 为例启动工程，需要将 Maven 打包好的 war 包发布到 Tomcat 中，在 IDEA 中配置如下：快捷键说明使用最多就是快捷键，下面是整理常用的快捷键，可以根据下面的列表查看对应的快捷键作用：快捷键作用说明alt + enter根据代码上下文补全ctrl + J显示目前所有可以使用的代码模板ctrl + alt + T生成 try-catch 代码包围ctrl + N生成构造方法和 getter 、setter 等类型基本方法ctrl + alt + V根据上下文生成函数的返回值ctrl + D快速向下复制当前行ctrl + Y删除当前所在的行shift + enter光标移动到下一行的空位上shift + atl + enter光标移动到上一行的空位上ctrl + shift + ↕上下移动当前代码块atl + shift + ↕上下移动当前代码行ctrl + P查看当前函数和方法的形参类型列表ctrl + atl + O根据代码上下文自动导包command + O 或者 双击 shift开始打开代码搜索工具框command + F12展示当前类的属性和方法信息列表command + option + L快速格式化当前的代码格式ctrl + I给当前的类实现某个接口类型command + atl + ↔向上翻页和向下翻页查看源代码是会用到ctrl + H查看当前类的结构关系command + atl + B快速定位到当前变量定义所在的位置ctrl + + atl + ↕快速切换不同到函数体fn + ↔快速移动光标到当前行起始位置和结尾位置command + atl + U查看当前类的 UML 图其他配置到此一些基础的开发环境就可以配置成功了，如果切换不同电脑需要使用相同的配置可以将配置信息导入，然后在要同步的电脑中导入即可：我推荐使用正版的，正版的可以使用 Jetbrains 账号所提供的配置信息同步功能，来在多台不同电脑中同步相关的配置信息。其他资料IntelliJ IDEA vs EclipseIntellij IDEA 官方使用文档腾讯大学：JetBrains开发日视频