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()); };
对于写代码和编写程序来说，放到今天稍微学习一下 Python 和 JavaScript 这类编程语言就可以实现满足自己需求的应用程序，因为已经有很多很好封装好的框架共我们开发者使用，乃至一个中学生都不需要懂一些数学知识，调用相关的 Math 包就可以进行高等数学相关计算。现在有许多高级的编程语言和优秀的框架，使得开发变得更加容易和高效，Python 和 JavaScript 等语言拥有丰富的生态系统和强大的社区支持，为开发者提供了各种各样的工具和框架，使得构建复杂的应用程序变得更加容易。上文提到从需求变成编程再到程序，需求是代码编写者对于软件系统所需功能和特性的描述，例如实现一个四则运算计算器程序，可以是在程序直接编写 console.log("20 + 4 = ",20 + 4) 这种形式来完成需求，也可以是实现一个漂亮的界面完成对应的需求。从 IDEA 到 Coding 到 Product 这个过程中 coding 环节是属于技术实现环节，实现某个功能可以有很多种技术方案，但是方案这么多作为程序员如何选？主流开发的软件都属于应用软件，目前跑在电脑上和跑在手机上的应用软件并没有一些本质区别，都是程序员将自己的大脑逻辑抽象通过代码的形式告诉计算机，当执行这段代码运行之后该程序就会按照你编写的逻辑去交给 CPU 执行，只不过 CPU 要比我们脑子里面计算快得多。这个过程就是 Coding 阶段不能确保程序员大脑里的逻辑一定是正确的，当不合规的逻辑被编写到程序代码中，就可能会产生很严重的后果，例如程序崩溃导致的事件。在电脑和手机只要你开发的不是一些基础的软件，应用软件遇到问题最多就是卡死重启一下，现在的服务器端软件更是搞出来主从或者高可用架构来解决程序员在编码阶段留下的 bug 的问题。在工业时代崇尚机器自动化，现在计算机程序已经渗透到各行各业中了，天上飞的地上跑的水下游的，飞机汽车潜艇这些设备上都靠着计算机程序在辅助人类完成一些操作。机械工程师在设计一些机械结构时犯下的错可能会导致飞机会坠毁，即使是设计合理的机械结构现在控制这些机械装置的已经不是人类了，是跑在计算机中的程序在控制这些机械结构。所以一架飞机或者自动化驾驶的汽车出了事故，责任不完全在机械工程师身上，更有可能的是编写软件的工程师导致的。即现在从一名普通人转行为一名程序员太容易了，普通人通过特定的 IT 培训经过几个月的训练就可以写代码，此种工程师可能不具备专业的 CS 知识就可以动手写程序了，带了问题就上面所说的不可靠软件，如果需要开发专业型的软件还是需要专业型的知识作为铺垫的，例如让一个学土木工程或者做建筑设计的去做软件工程师，那就对应上了中文的俗语 “隔行如隔山” 。一些知名计算机科学界的先驱们就提出了点观点 Dijkstra 曾说过：[scode type="yellow"]我们所使用的工具对我们的思维习惯有着深远的影响，因此也对我们的思维能力有着深远的影响。......使用 COBOL 会削弱人的思维；因此，教授 COBOL 应被视为刑事犯罪。[/scode]简单的工具容易被使用，但是会让使用者不去思考背后的原理，导致缺乏逻辑思维和不去思考事物的本质。这在日常生活中使用的智能手机可以拍照可以打电话可以玩游戏，但是发明和制造一台手机所需要的知识远远不是一个人就能做到的，是一个跨学科和跨领域合作的产品。现在用电脑写一篇文章的时候打错字可以回退修改，但是如果在纸上你写错了一个字可能就报废一张纸，解决办法则你可以使用铅笔进行写字配合着橡皮擦使用，古代人使用毛笔更需要思维和手法练习才发展出来了书法行业。现在已经很少有人去使用了 COBOL 语言进行程序设计了，虽然我本人也是没有写过，但是在研究一些 PL 过程中了解到 COBOL 这门 PL 语言属于结构化加命令式结合产物。但是新的流行语言例如脚本语言和弱语言 JavaScript 和 Python 这些只有在运行的时候才能确定某个变量的类型，还有 C/C++ 这类有空指针设计的 PL ，也会带来底层软件的 Bug ：使用这里语言开发的程序如果程序在开发期没有有效找出 bug 或者程序员的逻辑错误导致 bug 产生，当程序发布之后这些带有 bug 程序会被发布到线上环境，可能在未来的某一时刻会因为这些 bug 导致程序出现故障从而影响到整个系统。一些隐秘的 bug 不一定尽快被修复，例如一个计算闰年和数据类型存储精度丢失的 bug ，要导致触发这个 bug 条件是当前是时间是处于闰年或者闰秒才会发生错误。这类隐秘的 bug 是开发阶段程序员逻辑错误导致的没有处理好，在修复的这些 bug 过程中需要花大量的时间解决，如果在开发这些涉及相关领域知识软件系统时应该提前去查看一些相关领域的资料文件，使用一些业绩主流的解决方案，例如被使用最广工业级时间库，来减少不必要的找 bug 和修复 bug 的时间浪费。现在内存安全的编程语言研究有很多了，但是能作为系统层开发的编程语言屈指可数，除了 C/C++ 这两门语言以外近些年也出现了能替代他们的编程语言例如 Zig 、Rust 这些编程语言，当时目前具备这样的能力程序员还很少，还另外一种解决方案使用静态程序分析产品来提高代码和软件的质量。应用层的内存安全编程语言更不用说，目前主流的 Python 、Java 、C# 、Ruby 这些都属于应用层的编程语言，但是应用层编程语言大部分都是脚本和解释性语言，能作为工业级程序设计的也就只有 Java 语言为主流了。很多人在开始一个新项目之前都会尝试使用新比较时髦技术去开始试错，尝试比较新的编程语言去写新的项目，风险比较大，而且很多解决方案也没有一个主流统一的企业开发标准，例如 Java 是有企业开发标准的。最典型的例子就是 Go 语言，Go 语言在很早之前连一个包管理工具也没有，统一包管理工具也没有了，这就导致每个团队都各自搞各种的开发依赖包解决方案；包括现在依赖包管理工具也不是那么成熟，很多开源的第三方库，从 v1 版本升级到 v2 版本时，是直接弃用掉 v1 方式的 API 设计，导致再次使用新版本时会重新去学习新的 API 使用方式和在源代码里面更改 v1 包路径映射到 v2 版本上，这也是浪费大大时间的。在程序员技术圈里经常流行一些鼓吹某些编程语言和技术方案的案例，实质为其争吵起来。一些编程语言脑残粉那种会鼓吹某个编程语言，当然一些也是抱着相关技术布道的目的地。很多某个语言脑残粉就会盲目的推崇某个语言，在大公司里有足够的人力和财力，开源让一些人去试错，但是试错是有代价的。选择 PL 还是要根据场景来的，例如开发高性能的网络 IM 聊天服务器应用，只要能实现网络通信的 PL 都可以去实现。那如何去选择对应的 PL 呢？但是如果从事迹开发角度来说你考虑内存占用吗？考虑实时性要求吗？考虑性能要求吗？还是考虑团队综合技术水平和开发效率时间快慢问题？不要试图去说服和你观点不一致的人，因为很多人它的认知和所需要的领域知识不及你全面，而且讨论问题的你还要帮助他去科普某些知识细节，交流起来很费劲。某些脑残粉给予给你扣上一个帽子，某些情况下一部分人就是故意在讨论中参入一些不相关的话题扯开话题或者故意添加干扰原本话题源的质量；参入讨论的前提跟你是一个层级的人或者说是有具备相关领域知识的人，如果观点不一致的双方可以互相尊重交流和倾听求同存异的面对观点进行交流沟通。能够正常沟通前提是双方获取的信息来源都是相同一致的，很多系统在设计阶段采用的方案因为信息不对称信息获取问题，导致此用一些不是主流解决方案和技术栈，这会导致自己在某些新技术栈下走其他成熟技术栈道老路，出现了问题和 bug 也找不到官方的文档和相关的问题解决方案。那如何选择技术栈呢？我一直坚持一个观点就是从源头获取信息，例如通过 Google 搜索统计和当下流行技术栈，还有招聘网站上 JD 大多数公司所使用的技术栈来判断。例如使用 spring 框架可以去 spring 官方技术博客和文档上获取，而不是在互联网上看一些其他人已经生产的二手文章和二手信息。通过数据来说话，主流的技术用什么，解决了什么样的问题，以前有没有这样的方案，二者对比弊端呢，再配合自己应用场景做选择。在中国大陆因为一些特殊原因不能正常访问国际互联网，这也是程序员获取信息源的阻碍，可以尝试着在 GitHub 和 Twitter 找到相关领域专家进行交流，一般这些技术框架或者开源软件都会提供相关负责人的邮箱地址，通过此种方式就能和一线的技术专家交流了。很多国内程序员喜欢通过技术图书来学习国外新的技术，其实很多一部分技术都提供了官方网站和官方文档，通过官方资料去获取信息是最为正确的方式，但是弊端是官方的文档只是介绍如何使用这个技术和为什么要这个技术，还有一些使用此技术优势。但是对应真正开始尝试使用这些技术时，又不知道如何和现有系统做整合。此时可能就会在互联网上查别人的资料和购买技术图书，我不推荐国内的技术图书，很多都是将官方文档抄袭就出版了。我个人比较推荐使用所使用技术栈方案对官方出版的图书进行学习，因为这是官方团队成员所写的，没有谁能比这项技术的作者更为了解这项技术本身，所以推荐找到学习源头获取技术知识。
大多数开发者或多或少都使用过 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实践与演进-李三红
在程序设计中有一种编程风格叫 Duck Typing ，这种编程风格实际上是面向对象编程语言中所提倡的多态的表现，通过将某一类对象的行为抽象成为一个集合，当前对象的类型由这些抽象层所决定的，这和前面一篇我博文中提到抽象概念类似。典型的应用就是 Go 语言也提供了接口类型，使得我们可以面向接口编程，将实现和接口分离。在我看来，软件的抽象之美也应该以此来表达，和 Java 语言不同的是 Go 并不是那么 强制 ，它使用了一种鸭子类型的方式让动态类型成为可能。目前 Go 只是某方面代码写的少有着较明显的特性，但是如果一个超大型项目未必有 Java 做的好，另外 Java 最新的设计草案 Implicit Classes and Enhanced Main Methods (Preview) 开始考虑把一些关键字默认隐式的使用，并且帮助学生和教育工作者能快速的了解 PL 设计。Duck Typing在 Go 中没有 implements 和 extends 这种关键字，这对我们而言反倒轻松了一些，它认为 Go 的接口就像鸭子测试里的描述：当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子，那么这只鸟就可以被称为鸭子。在使用鸭子类型编程语言中，更应该关注的是对象的行为，对能干什么？对象有什么样的能力，这和上篇文章中介绍的多态和抽象接口是类似的，如果一组对象有相似的行为功能可以抽象的划分成为一类。在通常编程过程中我们编写一个函数，函数接受某个类型的对象，并在函数内部调用它的行为，那这个函数只能接受某个特定类型的对象。而在提倡使用鸭子类型的语言中，这个函数可能就被修改成接受某个行为集合的对象作为参数，只要某个类型对象具备这个行为功能，那么就能被这个函数准确调用和运行，反之则会出现运行时错误。抽象鸭子我们用 Go 语言来实现一个鸭子🦆类型：type Duck interface { Swim() // 游泳 Feathers() // 羽毛 }这里使用 Go 提供的 interface 关键字定义了一个鸭子接口类型，这个接口中提供了鸭子的两种行为：游泳和羽毛是什么样的，但是没有提供实现。我们见过的鸭子类型可多了去了，下面是你可能见过的：组合接口现在我们给鸭子再添加一种嘎嘎叫的能力，一种方式是在原有的接口上添加 “嘎嘎叫” 方法，这样做的话就表示所有的鸭子都应该拥有此能力，假设我们的玩具鸭并不能开口嘎嘎叫，所以它没有这种能力。这时候我们可以将会嘎嘎叫的鸭子单独定义一种类型，在 Go 可以使用组合的方式来实现：type QuackDuck interface { Quack() // 嘎嘎叫 Duck // 嵌入接口 }这样 QuackDuck 类型就拥有了之前 Duck 提供的两种抽象能力，同时还应该拥有嘎嘎叫的能力。接口实现前面我们只给出了鸭子的能力定义，还没有任何实现，由于 Go 中没有继承和实现的关键字，想成为上述接口的实现非常简单，只要实现它们定义的方法就可以了。// RealDuck - 真正的鸭子 type RealDuck struct { } func (RealDuck) Swim() { fmt.Println("用鸭璞向后划水") } func (RealDuck) Feathers() { fmt.Println("遇到水也不会湿的羽毛") } func (RealDuck) Quack() { fmt.Println("嘎~ 嘎~ 嘎~") } // ToyDuck - 玩具鸭 type ToyDuck struct { } func (ToyDuck) Swim() { fmt.Println("以固定的速度向前移动") } func (ToyDuck) Feathers() { fmt.Println("白色的固定的塑料羽毛") }可以看到我们定义了两种鸭子类型，一种是真正的鸭子，它还多实现了一种嘎嘎叫方法，另一个玩具鸭子只有游泳和羽毛这两种行为。这个编程方式和我们写普通的结构体方法没什么区别，只是对应的方法签名相同，其实这种方式在 Go 语言的标准库中有特别多的应用，比如：io.Reader、io.Writer 和 io.Closer。接口使用接下来我们可以使用一下这个类型了：var duck Duck duck = ToyDuck{} duck.Swim() duck.Feathers()输出以固定的速度向前移动 白色的固定的塑料羽毛 由于玩具鸭没有嘎嘎叫的能力，所以如果你这么写编译无法通过错误实现，我们也可以用一种工厂的方式来进行调用：func Factory(name string) Duck { switch name { case "toy": return &ToyDuck{} case "real": return &RealDuck{} default: panic("No such duck") } } func main() { duck := Factory("toy") duck.Swim() duck.Feathers() }Summary其实这就是 Go 中的多态体现，仔细体会其中的味道，在阅读其他源码的时候你会更加熟练，但是也有一个问题当代码工程庞大的时候你很难知道一个接口体到底实现了哪些接口，不过有幸的是我们生在最智能的 IDE 时代，在 GoLand 中可以帮你提示，还有一些大佬可能某些开源项目中看到这样的代码 var _ Person = (*Student)(nil) 这就是通过编译器一些检测来帮助查看某个类型是否实现某个接口巧妙的代码，如果当前类型没有实现这个接口编译器则会提示。其他资料Go Data Structures: Interfaces