MyBatis Dynamic SQL 入门指南


MyBatis Dynamic SQL 是一种类型安全的 Java 领域特定语言(DSL),用于通过编程方式构建 SQL 查询,而非编写 SQL 字符串或基于 XML 的动态查询。它在运行时使用流畅的 Java 构建器生成 SQL,同时仍通过标准的 MyBatis 映射器执行。与手动拼接字符串或复杂的 XML 逻辑相比,这使得查询构建更安全、更易于重构,并且更不容易出错。

由于查询是用 Java 编写的,列名和表引用通过强类型的元数据类在编译时进行验证,这提供了更好的 IDE 支持并减少了运行时 SQL 错误。本文将解释 MyBatis Dynamic SQL,并展示如何在 Java 应用程序中使用它。

1. 使用 MyBatis Dynamic SQL 可以做什么?

MyBatis Dynamic SQL 支持大多数常见的 SQL 操作,包括 SELECTINSERTUPDATEDELETE,以及连接、子查询、分页、排序、条件过滤和批量操作。它允许我们逐步构建查询,仅在某些参数存在时添加条件,这使其成为搜索界面和过滤 API 的理想选择。

它直接与 MyBatis 映射器接口集成,意味着我们仍然可以受益于结果映射、事务处理和连接管理。由于它生成标准的 SQL,因此适用于 MyBatis 支持的任何数据库,没有供应商锁定的问题。

1.1 MyBatis Dynamic SQL 的工作原理

Dynamic SQL 基于两个组件:表元数据类和 DSL 构建器。元数据类用 Java 描述表和列。DSL 构建器使用这些类以流畅、类型安全的方式组装 SQL 语句。

DSL 不直接执行 SQL。相反,它生成语句提供者对象,例如 SelectStatementProviderInsertStatementProvider。这些对象被传递给使用 @SelectProvider@InsertProvider 及类似注解标注的映射器方法,然后 MyBatis 使用其正常的执行引擎来执行这些语句。

2. 项目设置与依赖

Maven 依赖

<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.5.15</version>
</dependency>

<dependency>
    <groupId>org.mybatis.dynamic-sql</groupId>
    <artifactId>mybatis-dynamic-sql</artifactId>
    <version>1.5.2</version>
</dependency>

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.4.240</version>
    <scope>runtime</scope>
</dependency>

这些依赖项包括 MyBatis 本身、Dynamic SQL DSL 以及一个用于测试的嵌入式数据库。

注意
数据库驱动可以替换为 MySQL、PostgreSQL 或任何其他支持的数据库。MyBatis Dynamic SQL 不依赖于数据库类型,仅依赖于标准 SQL 生成。

数据库模式

schema.sql

CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    email VARCHAR(100),
    age INT
);

INSERT INTO users(username, email, age) VALUES
('thomas', '[email protected]', 30),
('benjamin', '[email protected]', 22),
('charles', '[email protected]', 17);

此脚本创建一个简单的表并插入测试数据。内存中的 H2 数据库将在启动时执行此脚本,因此无需外部依赖即可测试查询。

领域模型

public class User {

    private Long id;
    private String username;
    private String email;
    private Integer age;

    // Getter 和 Setter 方法...
}

这个 POJO 代表数据库中的一行。MyBatis 会自动使用匹配的字段名将列映射到字段,因此本例不需要额外的结果映射。

3. 用于 Dynamic SQL 的表元数据

下面的类以类型安全的方式定义数据库表及其列,允许在构建查询时被 Dynamic SQL DSL 引用。它充当 Java 代码与实际数据库结构之间的桥梁,实现了列名和类型的编译时验证。

public final class UserDynamicSqlSupport {

    public static final User user = new User();

    public static final SqlColumn<Long> id = user.id;
    public static final SqlColumn<String> username = user.username;
    public static final SqlColumn<String> email = user.email;
    public static final SqlColumn<Integer> age = user.age;

    public static final class User extends SqlTable {

        public final SqlColumn<Long> id = column("id", JDBCType.BIGINT);
        public final SqlColumn<String> username = column("username", JDBCType.VARCHAR);
        public final SqlColumn<String> email = column("email", JDBCType.VARCHAR);
        public final SqlColumn<Integer> age = column("age", JDBCType.INTEGER);

        public User() {
            super("users");
        }
    }
}

这个类为 users 表定义了类型安全的元数据,使 MyBatis Dynamic SQL 可以在不使用原始 SQL 字符串的情况下构建查询。DSL 使用这些 Java 对象而非按名称引用列,从而提高了安全性和 IDE 支持。

内部类 User 继承 SqlTable,这将其标记为可用于 from(user) 和连接等子句的数据表。构造函数调用 super("users") 来告知 MyBatis 要在 SQL 语句(如 FROM users)中呈现的确切表名。

每个列都使用 SqlTable 中的 column() 方法定义,该方法注册列名及其 JDBC 类型。这会产生强类型的 SqlColumn<T> 对象,确保比较和条件在编译时使用正确的 Java 类型。

外部类公开了对表及其列的静态引用,以便于静态导入,使得查询读起来很自然,例如:select(id, username).from(user),同时保持完全的类型安全和重构友好。

映射器接口

@Mapper
public interface UserMapper {

    @SelectProvider(type = SqlProviderAdapter.class, method = "select")
    List<User> selectMany(SelectStatementProvider selectStatement);
}

@Mapper 注解告诉 MyBatis 此接口应注册为映射器并在运行时进行代理。MyBatis 会自动生成实现,因此不需要具体的类。

selectMany 方法接受一个 SelectStatementProvider,它封装了完全呈现的 SQL 语句及其参数。MyBatis 执行该语句并将每个结果行映射到 User 对象,将它们作为 List<User> 返回。

@SelectProvider 注解指定 SQL 将由 MyBatis Dynamic SQL 的一部分 SqlProviderAdapter 动态提供。实际的 SQL 是在运行时从使用 DSL 构建的 SelectStatementProvider 生成的,而不是在注解或 XML 中编写 SQL。

4. 构建动态查询

在这里,我们使用流畅的 Dynamic SQL DSL 构建 SQL 语句,而不是编写原始 SQL 字符串。

public static void main(String[] args) throws Exception {

    MyBatisUtil.runSchema();

    try (SqlSession session = MyBatisUtil.getSession()) {

        UserMapper mapper = session.getMapper(UserMapper.class);

        SelectStatementProvider select
                = select(id, username, email, age)
                        .from(user)
                        .where(age, isGreaterThan(18))
                        .and(username, isLike("%tho%"))
                        .orderBy(username)
                        .build()
                        .render(RenderingStrategies.MYBATIS3);

        List<User> users = mapper.selectMany(select);

        users.forEach(u
                -> System.out.println(u.getUsername() + " - " + u.getAge()));
    }
}

此代码使用流畅的 Dynamic SQL DSL 动态构建一个 SELECT 查询,并将其渲染为与 MyBatis 兼容的语句提供者。通过以编程方式添加条件,它能够以类型安全且可维护的方式创建复杂的过滤器。在本例中,查询选择 age 大于 18 岁且 username 包含 "tho" 的用户,然后按用户名字母顺序对结果进行排序。

MyBatis 工具类

public class MyBatisUtil {

    private static SqlSessionFactory factory;

    static {
        try {
            Reader reader = Resources.getResourceAsReader("mybatis-config.xml");
            factory = new SqlSessionFactoryBuilder().build(reader);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public static SqlSession getSession() {
        return factory.openSession(true);
    }

    public static void runSchema() throws IOException, SQLException {
        try (SqlSession session = getSession()) {
            Connection conn = session.getConnection();
            Statement stmt = conn.createStatement();

            try (InputStream is = Resources.getResourceAsStream("schema.sql")) {
                String sql = new String(is.readAllBytes(), StandardCharsets.UTF_8);
                stmt.execute(sql);
            }
        }
    }
}

此工具类加载 MyBatis 配置,构建 SqlSessionFactory,并提供对数据库会话的访问。它还通过执行 SQL 脚本(schema.sql)手动初始化数据库模式。

5. 使用 Dynamic SQL 进行插入、更新和删除

MyBatis 中的 Dynamic SQL 允许我们使用流畅的 DSL 以编程方式构造 INSERT、UPDATE 和 DELETE 语句。在此,我们演示如何执行这些常见的数据操作。

// INSERT
User newUser = new User();
newUser.setUsername("andrew");
newUser.setEmail("[email protected]");
newUser.setAge(28);

InsertStatementProvider<User> insert
        = insert(newUser)
                .into(user)
                .map(username).toProperty("username")
                .map(email).toProperty("email")
                .map(age).toProperty("age")
                .build()
                .render(RenderingStrategies.MYBATIS3);

int inserted = mapper.insert(insert);
System.out.println("Rows inserted: " + inserted);

// UPDATE
UpdateStatementProvider update
        = update(user)
                .set(age).equalTo(35)
                .where(username, isEqualTo("thomas"))
                .build()
                .render(RenderingStrategies.MYBATIS3);

int updated = mapper.update(update);
System.out.println("Rows updated: " + updated);

// DELETE
DeleteStatementProvider delete
        = deleteFrom(user)
                .where(age, isLessThan(18))
                .build()
                .render(RenderingStrategies.MYBATIS3);

int deleted = mapper.delete(delete);
System.out.println("Rows deleted: " + deleted);

相同的 DSL 风格也用于写操作。语句以流畅的方式构建、渲染,然后由映射器提供者方法执行。

  • INSERT:创建一个新的 User 对象并填充值。使用 Dynamic SQL DSL,我们将其字段映射到表列并生成 InsertStatementProvider。映射器执行插入操作,返回受影响的行数。
  • UPDATE:DSL 构建一个更新语句,将用户名为 "thomas" 的用户的年龄设置为 35。这确保只修改目标行,映射器执行更新。
  • DELETE:删除语句移除所有年龄小于 18 岁的用户。在 DSL 中使用条件保证了类型安全并避免了字符串拼接。

更新后的映射器接口

为了支持这些操作,映射器接口必须包含用于 INSERT、UPDATE 和 DELETE 的方法,使用 MyBatis Dynamic SQL 提供者。

// INSERT
@InsertProvider(type = SqlProviderAdapter.class, method = "insert")
int insert(InsertStatementProvider<User> insertStatement);

// UPDATE
@UpdateProvider(type = SqlProviderAdapter.class, method = "update")
int update(UpdateStatementProvider updateStatement);

// DELETE
@DeleteProvider(type = SqlProviderAdapter.class, method = "delete")
int delete(DeleteStatementProvider deleteStatement);

映射器中的每个方法处理一个特定的 DML 操作(插入、更新或删除),并接受一个封装了生成的 SQL 及其参数的 InsertStatementProviderUpdateStatementProviderDeleteStatementProvider。这种方法允许所有写操作都在 Java 中以编程方式表达,而无需手动组合 SQL 字符串,同时仍能利用 MyBatis 高效地执行语句和映射结果。

6. 结论

在本文中,我们探讨了如何在 Java 应用程序中使用 MyBatis Dynamic SQL 来创建类型安全、可维护且可编程的 SQL 查询。通过将 SQL 构建与执行分离,MyBatis Dynamic SQL 简化了复杂查询逻辑的处理,降低了错误风险,并提高了代码可读性。这种方法非常适合查询需要动态变化或经常修改的应用程序。

7. 下载源代码

本文讨论了 MyBatis Dynamic SQL 及其在 Java 中的使用方法。

下载

您可以通过此处下载此示例的完整源代码:java mybatis dynamic sql


【注】本文译自:Getting Started with MyBatis Dynamic SQL

JExten:基于Java模块系统(JPMS)构建健壮的插件架构

JExten:基于Java模块系统(JPMS)构建健壮的插件架构

1. 动机:通往模块化隔离之路

在Java中构建可扩展应用程序时,开发者常常从一个简单的问题开始:"如何让用户无需重新编译核心应用程序就能添加功能?" 旅程通常始于标准的 java.util.ServiceLoader,它提供了一种发现接口实现的简单机制。

然而,随着应用程序的增长,一个关键问题出现了:"类路径地狱"。

想象一下,你有一个使用 library-v1 的主机应用程序。你创建了一个插件系统,有人写了一个需要 library-v2 的 "Twitter 插件"。如果所有东西都在同一个扁平的类路径上运行,就会产生冲突。要么主机因为得到错误的库版本而崩溃,要么插件失败。你无法在类路径上同时存在同一个库的两个版本而不面临运行时异常(如 ClassDefNotFoundErrorNoSuchMethodError)的风险。

这正是JExten背后的核心驱动力。我需要一种能够严格封装插件的方式,使得每个插件都可以定义自己的依赖,而不影响主机或其他插件。

引入JPMS(Java平台模块系统)

Java 9引入了模块系统(JPMS),它提供了强封装和显式的依赖关系图。它允许我们创建隔离的模块"层"。

  • 启动层:JVM和平台模块。
  • 主机层:核心应用程序及其依赖。
  • 插件层:在主机层之上动态创建的层。

通过利用JPMS的ModuleLayers,JExten允许插件A依赖于Jackson 2.14,而插件B依赖于Jackson 2.10,两者可以在同一个运行的应用程序中和睦共存。

2. 架构与设计

JExten设计为轻量级且基于注解驱动,抽象了原始ModuleLayers的复杂性,同时提供了依赖注入(DI)和生命周期管理等强大功能。

架构基于三个主要支柱:

扩展模型

核心在于,JExten在"契约"(API)和"实现"之间进行了清晰分离。

    1. 扩展点 (@ExtensionPoint):在主机应用程序(或共享API模块)中定义的接口,规定了哪些功能可以被扩展。
      @ExtensionPoint(version = "1.0")
      public interface PaymentGateway {
      void process(double amount);
      }
    1. 扩展 (@Extension):由插件提供的具体实现。
      @Extension(priority = Priority.HIGH)
      public class StripeGateway implements PaymentGateway {
      // ...
      }

      注意,你可以在没有PluginManager的情况下使用ExtensionManager。这在测试中或当你希望在非插件环境中使用JExten,且所有扩展都已经在模块路径中可用时非常有用。

管理器分离

为了分离关注点,该库将职责划分到两个不同的管理器:

    1. PluginManager("物理层")
      • 该组件处理原始工件(JAR/ZIP文件)。
      • 它使用SHA-256校验和验证完整性,确保插件未被篡改。
      • 它构建JPMS ModuleLayer 图。它读取 plugin.yaml 清单,解析依赖项(从本地缓存或Maven仓库),并构建类加载环境。
    1. ExtensionManager("逻辑层")
      • 一旦层构建完成,该组件接管。
      • 它在各个层中扫描带有 @Extension 注解的类。
      • 它管理这些扩展的生命周期(单例、会话或原型作用域)。
      • 它处理依赖注入。

依赖注入

由于插件在隔离的层中运行,标准的DI框架(如Spring或Guice)有时可能"太重"或在动态模块边界间配置起来很棘手。JExten包含一个内置的、轻量级的DI系统。

你可以简单地使用 @Inject 将扩展连接在一起:

@Extension
public class MyPluginService {
    @Inject
    private PaymentGateway gateway; // 自动注入最高优先级的实现
}

这在模块边界之间可以无缝工作。一个插件可以注入由主机提供的服务,甚至可以注入由另一个插件提供的服务(如果模块关系图允许的话)。

3. 使用示例

下面快速了解一下如何定义扩展点,在插件中实现它,并在应用程序中使用。

I. 定义一个扩展点

创建一个接口并用 @ExtensionPoint 注解。这是插件将实现的契约。

@ExtensionPoint(version = "1.0")
public interface Greeter {
    void greet(String name);
}

II. 实现一个扩展

在你的插件模块中,实现该接口并用 @Extension 注解。

@Extension
public class FriendlyGreeter implements Greeter {
    @Override
    public void greet(String name) {
        System.out.println("Hello, " + name + "!");
    }
}

III. 发现与使用

在你的主机应用程序中,使用 ExtensionManager 来发现和调用扩展。

public class Main {
    public static void main(String[] args) {
        // 初始化管理器
        ExtensionManager manager = ExtensionManager.create(pluginManager);

        // 获取Greeter扩展点的所有扩展
        manager.getExtensions(Greeter.class)
               .forEach(greeter -> greeter.greet("World"));
    }
}

IV. 将你的扩展打包为插件

最后,使用 jexten-maven-plugin Maven插件在编译时检查你的 module-info.java,并将你的扩展打包成一个包含所有依赖项和生成的 plugin.yaml 清单的ZIP包。

<plugin>
    <groupId>org.myjtools.jexten</groupId>
    <artifactId>jexten-maven-plugin</artifactId>
    <version>1.0.0</version>
    <executions>
        <execution>
            <goals>
                <goal>generate-manifest</goal>
                <goal>assemble-bundle</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <hostModule>com.example.app</hostModule>
    </configuration>
</plugin>

然后,你可以将生成的ZIP包安装到你的主机应用程序中:

public class Application {
    public static void main(String[] args) throws IOException {
        Path pluginDir = Path.of("plugins");

        // 创建插件管理器
        PluginManager pluginManager = new PluginManager(
            "org.myjtools.jexten.example.app", // 应用程序ID
            Application.class.getClassLoader(),
            pluginDir
        );

        // 从ZIP包安装插件
        pluginManager.installPluginFromBundle(
            pluginDir.resolve("my-plugin-1.0.0.zip")
        );

        // 创建支持插件的扩展管理器
        ExtensionManager extensionManager = ExtensionManager.create(pluginManager);

         // 从插件获取扩展
        extensionManager.getExtensions(Greeter.class)
            .forEach(greeter -> greeter.greet("World"));
    }
}

4. 与其他解决方案的比较

选择合适的插件框架取决于你的具体需求。以下是JExten与一些成熟替代方案的对比:

PF4J (Plugin Framework for Java)

PF4J是一个成熟的、轻量级的插件框架,依赖于ClassLoader隔离。

  • 隔离性:PF4J使用自定义ClassLoader隔离插件。JExten使用JPMS ModuleLayers。后者是自Java 9以来处理隔离的"原生"Java方式,在JVM级别严格执行封装。
  • 现代性:虽然PF4J非常优秀,但JExten是专门为现代模块化Java生态系统(Java 21+)设计的,利用模块描述符(module-info.java)来定义依赖关系,而不是自定义清单。

OSGi

OSGi是模块化的黄金标准,为Eclipse等IDE提供支持。

  • 复杂性:OSGi功能强大,但学习曲线陡峭且样板代码多(Manifest头、Activators、复杂的服务动态)。JExten通过专注于80%的用例——具有简单依赖注入的严格隔离扩展——提供了远低于OSGi的复杂性("精简版OSGi"),且不需要完整的OSGi容器。
  • 运行时:OSGi带来沉重的运行时。JExten是一个轻量级库,构建在标准JVM特性之上。

Layrry

Layrry是一个用于执行模块化Java应用程序的启动器和API。

  • 关注点:Layrry非常侧重于模块层的配置和组装(通常通过YAML/TOML),并充当运行器。JExten则侧重于这些层内的编程模型
  • 特性:Layrry擅长构建层,但它不提供固化的应用框架。JExten提供了"粘合"代码——扩展点、依赖注入和生命周期管理——这些是你在使用原始模块层或Layrry时必须自己编写的东西。
特性 JExten PF4J OSGi Layrry
隔离 JPMS 模块层 文件/类加载器 Bundle类加载器 JPMS 模块层
配置 Java 注解 属性/清单 Manifest 头部 YAML/TOML
依赖注入 内置 (@Inject) 外部 (Spring/Guice) 声明式服务 无 (ServiceLoader)
学习曲线

5. 结论

JExten是一个轻量级、基于注解驱动的插件框架,它利用JPMS模块层来提供隔离和依赖管理。其设计目标是易于使用和理解,注重简洁性和易用性。

最后,请记住JExten仍处于早期阶段,有很大的改进空间。欢迎在GitHub上为项目做贡献和/或在issue部分参与讨论。项目仓库链接在此处


【注】本文译自:JExten: Building a Robust Plugin Architecture with Java Modules (JPMS) – DEV Community

塑造2026年的六大软件开发与DevOps趋势


到2026年,软件团队将借助智能体AI、语义层、平台工程、供应链安全、可观测性以及FinOps,实现安全高效的规模化交付。

在2025年,许多团队在软件开发和DevOps领域尝试了新事物——AI编程助手、新平台、更多的自动化以及更严格的安全检查。其中一些成效显著,另一些则带来了新的混乱(工具泛滥、职责不清、云账单飙升以及“交付更快但故障更多”)。

进入2026年,焦点正从实验转向确保可靠性与可重复性。领导者与实践者都在思考同样的问题:我们如何在不牺牲质量的前提下快速前进?如何在保证系统安全的同时不拖慢团队速度?如何减少重复性工作、控制成本,并依然交付有价值的功能?

本文剖析了塑造未来一年的六大趋势:贯穿软件开发生命周期的智能体AI、为AI提供真实业务背景的语义层/本体论、基于内部开发者平台的平台工程、软件供应链安全、构建于标准遥测技术之上的可观测性,以及FinOps成为日常工程决策的一部分。这些趋势共同解决了一个核心问题:它们帮助团队实现规模化交付——减少混乱、降低意外、增强信心。

趋势一:贯穿SDLC的智能体AI

SDLC指软件开发生命周期(software development life cycle)——涵盖规划、构建、测试、部署和运维软件的端到端流程。它之所以重要,是因为大多数延迟并非仅发生在编码阶段,也存在于各步骤间的交接和“粘合工作”中。

智能体AI是指能够在有限监督下,通过规划步骤和使用工具(而不仅仅是生成文本)来朝着目标工作的AI。例如:“处理这个问题,进行修改,运行检查,并准备一个待审核的拉取请求。”

为何在2026年重要?

团队正疲于应对交付相关的重复性任务——问题分诊、更新配置、追踪不稳定的测试、修复CI流水线、撰写PR摘要、排查日志。智能体可以减少这些重复性劳动并缩短反馈循环,从而使工程师能将更多时间用于决策和设计(而非复制粘贴类工作)。例如,GitHub文档中展示了可以要求Copilot创建拉取请求的工作流,由开发者在执行前审批。

但需要注意:AI倾向于放大你工程系统中已存在的状况。如果你的基础稳固(测试良好、标准清晰、CI可靠),你将获得加速。如果事情一团糟,你可能会交付得更快……但遇到更多问题。这就是为什么2026年的重点将是智能体加上防护措施,而非仅有智能体。

如果GitHub Copilot对我们的用例来说功能不足,有一些可靠的开源替代品:

  • Continue (适用于VS Code/JetBrains的开源助手;可以连接不同的模型和上下文,并支持智能体式工作流)
  • Tabby (开源、自托管的编码助手,通常被视为Copilot的本地化替代方案)

如果我们想要“更多的智能体,更少的IDE自动补全”,这些项目值得关注:

  • OpenHands (智能体式开发者助手项目)
  • Aider (优先终端的编码智能体,通过git变更工作)

趋势二:面向AI背景的本体论/语义层(为真实业务含义提供语义基础)

语义层是数据架构的一部分,它将复杂数据转化为业务友好的术语,确保“收入”、“活跃客户”或“事件严重性”等概念在任何地方都具有相同的含义。

本体论是这个概念的更正式版本:一个具有明确定义和关系的共享领域模型(例如:客户拥有合同,合同关联产品,产品具有区域规则)。OWL是表示本体论的常用标准。

在底层,许多本体论/知识图谱方法构建于RDF之上,RDF将事实表示为简单的图语句。

这解决了什么问题? 数据质量问题确实存在(值缺失、记录不一致、数据过时)。但即使数据“足够好”,团队仍会遇到第二个问题:含义与一致性。相同的指标名称在不同团队、仪表板和服务中可能意义不同。当AI系统学习自相互矛盾的定义时,它们可能听起来自信满满,但仍然出错,且难以解释原因。语义层和本体论为AI提供了可靠的领域地图,使得答案基于共享的定义和关系,而非猜测。我们可以在图1中看到这一点。

图1. 本体论流程
图1. 本体论流程

为何在2026年重要?

随着我们在工程和运维中使用越来越多的AI助手和智能体,它们需要可信的上下文来做出安全的决策。基于图检索增强生成(Graph RAG)的方法正受到关注,因为它们能够结合文本与关系,而不仅仅是相似性搜索。GraphRAG就是这一方向的一个例子。

为使这种领域模型长期保持清晰,我们可以使用SHACL之类的约束规则来验证图数据,从而防止“领域真相”陷入混乱。

趋势三:平台工程2.0 / AI就绪的内部开发者平台

平台工程旨在构建内部开发者平台——这是一种共享的、自助式的基础设施和工具集合,可帮助团队更一致地构建、测试、部署和运维软件。与其让每个团队都重新发明自己的流水线,平台团队会创建“黄金路径”(预先批准、可重复的执行方式)。进入2026年,这些平台正从CI/CD自动化演进为AI就绪平台,旨在将智能、安全性和可观察性嵌入开发者体验之中。

为何在2026年重要?

许多团队在2024-2025年尝试了DIY自动化,现在正面临“集成税”:数十个自定义脚本、不一致的标准、不明确的职责归属以及新开发者上手缓慢。AI就绪的IDP旨在通过提供可在团队间扩展的模式、防护措施和智能默认配置来解决这些问题。它们可以提供上下文感知的建议(例如,运行哪些测试、应用哪些安全规则)、执行策略即代码、生成环境预览,并将AI助手直接集成到工作流中。这减少了开发者的认知负担,并在不牺牲质量或治理的前提下加速了交付。

解决了什么问题: 传统的DevOps流水线通常缺乏标准化和大规模的可视性。平台工程创建了一个共享基础,使团队无需在底层管道上花费时间,保持跨服务的一致性,并能更安全地采用新实践(如AI增强的工作流)。在2026年,这些平台还将通过内置最佳实践(而非将其作为可选附加项),帮助在生产力与合规性、成本和可靠性之间取得平衡。

链接与趋势信号:

趋势四:供应链安全成为新的DevSecOps基线

定义: 传统上,DevSecOps侧重于发现和修复代码或容器中的漏洞。在2026年,重点正扩展到软件供应链安全——这意味着我们不仅要保护自己的代码,还要保护构建、打包和交付软件过程中涉及的每一个环节:依赖项、构建系统、制品和部署流水线。软件物料清单、制品签名、来源追踪和证明框架(如SLSA)等实践正在成为基线要求,而非可选附加项。【来源:https://www.cisa.gov/resources-tools/resources/2025-minimum-elements-software-bill-materials-sbom

为何在2026年重要?

近年来的高调事件表明,攻击者常常利用应用程序代码库之外的漏洞——例如,受损的开源库或CI/CD流水线中的恶意更新。随着团队借助AI增强的工作流加速前进,风险组件更容易潜入发布版本中。加强供应链意味着在部署前验证每个制品的来源、签名者及其符合的策略。这减少了意外情况并限制了爆炸半径。【来源:https://www.itpro.com/software/enterprises-need-to-sharpen-up-on-software-supply-chain-security

解决了什么问题: 它同时解决了两个重大问题:防止不可信代码进入生产环境,并将合规性和可审计性融入日常工作流。在2026年,供应链安全将不再是“有空再做”的事情——它将成为交付流水线本身的一部分,让团队有信心实现快速而安全的交付。

链接与趋势信号:

  • CISA关于软件供应链基线SBOM要素的指南。
  • 企业要求供应链实践成熟化的压力。

趋势五:可观测性与遥测工程

定义: 可观测性是通过收集日志、指标和追踪等信号来理解生产系统行为的方法。在2026年,这正演变为遥测工程——一种更加有意识、标准化的方法,用于定义、收集、存储和使用跨服务与团队的观测数据。遥测工程将信号视为一等公民,对其进行设计、审查和治理,方式类似于代码或API,而不是采用零散随意的仪表板和日志。

为何在2026年重要?

随着架构变得更加分布式,且AI驱动的自动化触及技术栈的更多部分,盲点可能迅速演变为故障或用户体验下降。团队再也无法猜测系统状况;他们需要可靠、一致的信号来驱动自动化洞察,甚至为AI助手提供问题诊断依据。标准化工作(如OpenTelemetry)正在统一数据的收集和传输方式,使得关联追踪、指标和日志更加容易,并能自动化告警、根因分析和成本优化。【来源:https://opentelemetry.io/docs/

解决了什么问题: 传统的日志记录或监控常常导致信号孤岛——每个工具都有自己的格式和盲点。遥测工程通过统一共享模式、采样策略、标记约定、保留策略和成本控制来打破这些孤岛。这为工程团队提供了观察系统的一致视角,减少了噪声,并支持AI辅助调试和预测分析。

链接与趋势信号:

趋势六:FinOps融入DevOps(成本作为一等工程信号)

定义: FinOps是通过工程、财务和产品团队之间的共同责任来管理和优化云支出的实践。当FinOps融入DevOps时,成本不再仅仅是部署后审查的项目,而成为与性能、可靠性和安全性并列的日常工程决策的一部分。实际上,这意味着团队能更早、更频繁地看到成本影响,而不仅仅是在月度报告中。

为何在2026年重要: 云和AI成本不再可预测或线性。临时环境、GPU工作负载、托管服务和AI推理可能在几天内而非几个月内大幅改变支出。在2026年,将成本视为“他人问题”的团队将陷入困境。相反,DevOps流水线将越来越多地包含成本防护措施:预算告警、环境生存时间、规模调整检查,以及在变更进入生产环境前的成本回归检测。

解决了什么问题: 它弥合了速度与可持续性之间的差距。通过将成本可见性直接集成到DevOps工作流中,团队可以快速前进而不至于意外超支,领导者也能进行明确的权衡决策,而非被动应对。

链接与趋势信号:

结论

展望2026年,所有这些趋势都指向同一个理念:团队需要用更多的结构化,而非更多的工具,来扩展软件交付。只有当AI、平台、安全、可观测性和成本控制被融入工作方式,而非事后附加时,它们才能真正发挥作用。将这些领域连接起来的团队将以更少的压力和意外,实现更快的交付速度。

现在就可以开始的简单后续步骤:

  1. 试点一项AI工作流,例如辅助处理问题或拉取请求,并设定清晰的规则和人工审核。
  2. 投资于IDP[^2]的黄金路径,使安全性、可观测性和AI工具成为默认项,而非可选。
  3. 设定一个基础的供应链安全基线,包括SBOM和制品签名。
  4. 为某个业务领域创建一个小的语义“薄切片”,为AI提供共享上下文。
  5. 标准化遥测和成本防护措施,让团队尽早看到可靠性和成本影响,而非为时已晚。

这些步骤并不要求在第一天就进行大规模重构。但结合起来,它们将帮助团队在2026年构建更快、更安全、更可持续的软件。


【注】:

  1. 本文译自:6 Software Development and DevOps Trends Shaping 2026
  2. IDP:Internal Developer Platform (内部开发者平台),一个集成工具、服务和自助能力的内部平台,旨在提升开发者的体验和效率。

SJF4J 五分钟入门:Java 的实用 JSON 门面

Java 中的 JSON 处理很少是简单的。

在实际应用中,数据不断在以下形式之间流转:

  • POJO(普通Java对象)
  • Map / List
  • JSON 字符串
  • 配置文件
  • 模式不断演进的 API

Java 开发者常常被迫做出痛苦的选择:

要么要类型安全,要么要灵活性——二者不可兼得。

SJF4J(Java 简单 JSON 门面) 就是为了消除这种取舍而构建的。

SJF4J 是什么?

SJF4J 是流行 JSON 库(Jackson、Gson、Fastjson2)及相关格式(YAML、Properties)之上的一个轻量级门面。它提供一个用于结构化数据处理的统一语义层,完全基于 JSON 规范。它替代你的 JSON 解析器,而是在它们之上统一你的结构化数据处理方式。

核心理念:基于对象的节点树

SJF4J 没有引入自定义的 JSON AST(抽象语法树),而是将现有的 Java 对象视为 JSON 节点。这被称为基于对象的节点树[Object-Based Node Tree (OBNT)]

在 OBNT 中:

  • JSON 对象 → JsonObject、Map、POJO
  • JSON 数组 → JsonArray、List、数组
  • JSON 值 → Java 原生类型

一切都保持为普通的 Java 对象,只是在上层附加了 JSON 语义。

快速示例

JsonObject jo = JsonObject.fromJson("""
{
  "id": 1,
  "active": true
}
""");

int id = jo.getInt("id");              // 类型安全
String active = jo.asString("active"); // Boolean → String 转换

SJF4J 提供三种访问级别:

  • getNode → 原始访问
  • getXxx → 类型安全访问
  • asXxx → 语义访问,支持跨类型转换

你可以为每次调用选择严格程度。

基于路径的访问(符合 RFC 规范)

SJF4J 完全支持:

  • JSON 路径(RFC 9535)
  • JSON 指针(RFC 6901)
String name = jo.asByPath("$.user.name");
List<Integer> ids = jo.findByPath("$.items[*].id", Integer.class);

同样的路径 API 适用于:

  • JSON
  • Map / List
  • POJO
  • 混合对象图

动态 + 类型化:JOJO

SJF4J 引入了 JOJO(JSON 对象 Java 对象)—— 一个扩展了 JsonObject 的领域对象。

class User extends JsonObject {
    String name;
}
user.getName();               // 类型化访问
user.getString("age");        // 动态访问
user.findByPath("$..name");   // JSON 语义访问

你可以从动态访问开始,逐步添加结构 —— 而无需破坏 API。

使用 JsonPatch 进行声明式转换

SJF4J 支持:

  • JSON 补丁(RFC 6902)
  • JSON 合并补丁(RFC 7386)
JsonPatch patch = JsonPatch.diff(source, target);
patch.apply(source);

补丁操作在 POJO、Map、List 和 JSON 对象上统一工作。

SJF4J 何时大放异彩?

如果你符合以下情况,SJF4J 是理想选择:

  • 处理不断演进或半结构化的数据
  • 既需要灵活性又需要类型安全
  • 希望在多个 JSON 库上使用统一的 API
  • 关注 JSON 规范

总结

SJF4J 让面向 JSON 的 Java 开发成为可能 —— 无需过早锁定方案或编写过多样板代码。

它小巧、可组合,并且由规范驱动。

👉 GitHub: https://github.com/sjf4j-projects/sjf4j


【注】本文译自:SJF4J in 5 Minutes: A Practical JSON Facade for Java

Java的未来:2026年及以后的展望

1. 引言:Java的演进轨迹

随着2026年的临近,Java正处于一个引人入胜的转折点。该平台并非仅仅维持其地位,而是通过Valhalla、Panama、Amber和Vector API等项目经历着重大创新。这些举措不仅仅代表渐进式改进,它们从根本上重塑了Java处理数据、与本机代码互操作以及表达开发者意图的方式。

自Java 9引入的六个月发布节奏在保持稳定性的同时加速了创新。Java 21在发布数月内采用率已达到45%,这表明当新功能带来切实价值时,社区乐于接受。本文将探讨Java即将到来的最重要增强功能的理论基础、架构原理和战略意义。

2. Project Valhalla:重新思考对象模型

2.1 Java必须解决的内存问题

自1995年以来优雅而简单的Java对象模型,如今与现代硬件的现实严重不匹配。Java诞生之初,内存读取操作和算术运算的成本大致相当,但如今内存读取的成本比算术运算高出200到1000倍。硬件经济的这一根本性转变使得Java大量使用指针的数据表示方法问题日益突出。

以坐标或颜色值这类简单的数据结构为例。传统Java即使是针对最小的数据载体,也会创建具有身份标识和开销的完整堆对象。这种间接引用级联——对象数组包含指向分散堆位置的引用——破坏了缓存局部性,并迫使进行昂贵的内存遍历。

2.2 值类型:像类一样编码,像int一样工作

Project Valhalla引入了值类,它将面向对象的抽象与原始类型的性能特性相结合,允许存在没有身份标识且可以优化编码的对象。其概念性突破在于认识到并非所有对象都需要身份标识。许多数据结构——坐标、复数、元组——代表的是纯粹的值而非实体。

值类牺牲了依赖于身份标识的特性(通过==进行的引用相等性、同步),以换取显著的内存和性能改进。值类仍然支持null,因为它们是引用类型;而受到更严格约束的原始类则完全放弃了null支持,但允许进行更激进的优化。

2.3 架构影响

其影响远不止于单个对象。2025年10月的早期访问基准测试显示,在从一个包含5000万个LocalDate实例的数组中求和年份时,性能提升近3倍,相较于基于身份标识的对象,平均执行时间从约72毫秒减少到25毫秒。

这种性能增益源于扁平化——将值对象数据直接嵌入数组和字段,而不是存储引用。这样得到的是一个连续的实际数据块,而非包含指向分散堆对象指针的数组,完美适配现代CPU缓存架构。

2.4 增强泛型与具体化

Valhalla的范围还包括解决Java长期存在的泛型类型擦除限制。增强泛型旨在实现对对象引用、原始类型、值类型以及可能void的泛型支持,从而消除对装箱变通方案的需求。这意味着List<int>成为可能,无需包装对象,既消除了内存开销,也减轻了分配压力。

2.5 时间线与现状

截至2025年10月,JEP 401:值类与对象已提供早期访问构建版本,在JDK 26的早期访问构建中实现了具有预览功能的值类。预计将通过持续不断的增强功能在多个版本中交付,而非一次性的庞大更新。现实的稳定目标指向Java 26-27(2026-2027)达到生产就绪状态。

3. Project Panama:连接Java与本机代码

3.1 JNI问题陈述

几十年来,Java本地接口一直作为Java访问本地库的网关,但其复杂性带来了巨大成本。JNI要求同时具备Java和本地编程的专业知识,通常导致编写容易出错的胶水代码、频繁跨越边界带来的性能开销、手动内存管理带来的泄漏或崩溃风险,以及直接内存访问引发的安全问题。

3.2 外部函数与内存API架构

Project Panama的外部函数和内存API经过自Java 14以来的孵化,在Java 22中成为标准,为开发者提供了调用本地函数、分配本地内存和映射本地数据结构的直接方式。其架构优雅之处在于实现了类型安全的内存访问,同时不牺牲性能。

该API引入了几个关键抽象:

  • 内存段:对内存源(堆内或堆外)的有限、有时限、线程受限的视图。与原始指针不同,内存段携带大小信息和生命周期管理。
  • 链接器:JVM与C/C++本地代码之间的桥梁,为Win64、SysVx64、LinuxAArch64和MacOsAArch64提供了平台特定的实现。
  • 函数描述符:本地函数签名的类型安全规范,确保Java与本地约定之间正确的数据编排。
  • 基于竞技场的内存管理:作用域分配,自动释放资源,防止困扰手动JNI代码的内存泄漏。

3.3 性能与安全特性

安全性与原始性能之间的权衡值得审视。虽然互操作代码是用Java编写的,但不能认为是100%安全的,因为运行时必须信任开发者对本地函数的描述,这也是访问外部链接器是一项受限操作、需要foreign.restricted=permit标志的原因。

这种设计有意暴露了本地互操作的内在风险,同时为防止常见错误提供了护栏。该API通过编译时和运行时检查防止了许多类型的错误——缓冲区溢出、释放后使用、空指针解引用——但也承认本地代码边界代表着信任边界。

3.4 生产状态

截至2025年,FFM API经过自Java 19以来的严格改进,被认为是基本稳定的,具有简化的内存管理、增强的安全措施和定制功能。在Java 22中从预览版过渡到生产版标志着一个重要的里程碑,使Panama成为新开发中JNI的现代替代品。

4. Project Amber:表达能力的演进

4.1 减少仪式感理念

Project Amber的使命是通过一个常被称为“语言仪式感适度化”的过程,识别并孵化较小的、以提高生产力为导向的语言特性,使日常Java代码更易读、易写、易维护。与针对性能或互操作性的项目不同,Amber侧重于开发者的易用性和表达力。

4.2 已完成的功能

已交付的功能从根本上改变了现代Java代码的外观和感觉:

  • 局部变量类型推断:在编译器可以推断类型的地方消除冗余的类型声明,在不牺牲类型安全的前提下减少代码噪音。
  • Switch表达式:将switch从语句转变为具有详尽性检查和模式匹配能力的表达式,实现了简洁、安全的条件逻辑。
  • 文本块:尊重格式的多行字符串字面量,消除了为SQL、JSON和HTML进行的字符串拼接技巧。
  • 记录类:用于不可变数据载体的紧凑语法,自动派生equalshashCodetoString实现。
  • 密封类:通过明确许可哪些类型可以扩展或实现一个密封类型,来控制继承层次结构,从而支持详尽的模式匹配。
  • 模式匹配:增强的instanceofswitch以支持模式匹配,消除了类型检查后的显式强制转换,并支持复杂的数据解构。

4.3 当前演进中的功能

对于2025年,Amber专注于敲定四个预览功能:允许在super/this调用之前执行代码的灵活构造函数体、简化入口点的紧凑源文件和实例主方法、用于简洁包导入的模块导入声明,以及用于原始类型匹配的原始模式。

除此之外,探索性工作仍在继续:

  • 自定义解构器:将模式匹配扩展到记录类之外的任意类,允许在不要求记录类约束的情况下进行解构。
  • Withers:一种with表达式,将实例解构为变量,允许重新分配其值,然后调用构造函数生成修改后的副本。
  • 字符串模板:安全、高效的字符串插值机制,该功能在Java 23中为重新设计而移除,但仍在积极开发中。

4.4 面向数据的编程范式

Amber的开发努力与面向数据编程范式紧密契合,侧重于使数据不可变、将数据与行为分离,以及设计具有清晰、可预测结构的数据聚合。这代表了对传统面向对象编程的补充方法,而非替代,为那些透明数据建模比封装更有优势的场景提供了工具。

5. Vector API:显式SIMD编程

5.1 自动向量化的局限性

现代CPU提供SIMD能力,可以同时对多个数据元素执行操作。如今,编写本应向量化的标量操作的开发者需要了解HotSpot的自动向量化算法及其局限性,才能获得可靠的性能,并且在某些情况下可能无法编写可转换的标量操作。

这种对编译器启发式方法的依赖使得性能不可预测。看似语义相同的简单更改可能会阻止向量化,让开发者没有可靠的方式来表达可向量化的意图。

5.2 Vector API设计原则

Vector API提供了直接在Java中执行SIMD操作的向量类型、操作和工厂,具有清晰简洁的API,能够表达广泛的向量计算,且对向量大小通用,从而能在支持不同向量大小的硬件上实现可移植性。

其架构方法优先考虑:

  • 平台无关性:使用Vector API编写的代码可在x64、ARM和RISC-V平台上运行,并进行适当的专门化。
  • 可预测的编译:在具有能力的x64架构上,HotSpot C2应该将向量操作编译为相应的高效向量指令,开发者确信所表达的操作与相关向量指令紧密对应。
  • 优雅降级:当向量计算无法完全表达为向量指令时(可能是因为架构不支持所需的指令),实现会优雅降级且仍能运行。

5.3 性能特征

基准测试显示,在两个大整数数组求和的简化案例中,使用Vector API相比标量操作实现了超过4倍的性能提升。实际收益很大程度上取决于数据访问模式、缓存行为以及所涉及的具体操作。

性能方面包含重要注意事项。主内存访问每次访问成本为60-100+个CPU周期,因此随着数组大小超过CPU缓存容量,更多的内存访问会降低Vector API的优势。此外,JIT自动向量化有时能对简单模式达到类似效果,使得该API对于破坏自动向量化的复杂算法最有价值。

5.4 与Panama和Valhalla的集成

Vector API在x64上利用Intel短向量数学库,在ARM和RISC-V上利用SIMD初等函数求值库,使用Panama的外部函数和内存API链接到本地数学函数。此外,Vector API使用装箱类型作为原始类型的代理,这是受当前泛型限制所迫,预计在Valhalla引入能力更强的泛型后会有所改变。

5.5 现状与时间线

JEP 529:Vector API(第十一次孵化)目标定于2025年12月的JDK 26,表明在最终确定之前将继续完善。延长的孵化期反映了在保持API稳定性的同时,在多样化的硬件平台上实现一致性能的复杂性。现实的稳定目标可能随Java 26(2026)一起到来。


6. 模块系统:采用与现实

6.1 JPMS概念基础

Java平台模块系统是一种代码级结构,它不改变JAR打包方式,但通过module-info.java文件添加了更高层级的描述符,使开发者更容易组织大型应用程序和库,同时改进了平台结构和安全性。

模块系统解决了几个架构问题:

  • 类路径地狱:类路径最终变成了一个庞大无差别的大桶,所有依赖项都插入其中,模块路径在其上增加了一个层级,作为包的存储,并选择哪些包是可访问的。
  • 封装性:模块显式声明它们导出哪些包以及需要哪些其他模块,防止意外依赖内部API。
  • 显式依赖:模块依赖关系的编译时验证减少了运行时意外,并便于进行静态分析。

6.2 采用模式与挑战

截至2025年,JPMS在GraalVM本地镜像编译中的使用有所增长,其中模块化应用程序通过排除未使用的代码路径实现提前优化,从而产生适用于无服务器和边缘场景的紧凑可执行文件。

然而,企业采用呈现出审慎的速度。Spring Boot在JPMS集成方面已取得进展,在版本3中提供对模块化JAR的部分支持,并在Spring Boot 4中实现了自动配置的完全模块化,将大型构件拆分为有针对性的模块,以最小化类路径污染。

逐步采用反映了实际约束。许多组织维护着大量的遗留代码库,其中模块化意味着大量的重构投入。自动模块机制——为非模块化JAR提供隐式模块描述符——提供了迁移路径,但并未带来全部好处。

6.3 强封装的演进

从Java 16开始,强封装成为默认设置,除非通过标志明确允许,否则将非法访问转为错误,Java 17使–illegal-access选项过时并强制执行严格封装。这一进程平衡了迁移兼容性与安全目标。

强制执行时间线展示了Java的理念:提供警告和迁移路径,然后在社区适应后加强保证。依赖内部JDK API的库和框架面临破坏性变更,但延长的时间线允许有序过渡。

6.4 模块化Java的未来

企业采用与Java 21使用率的上升保持一致,调查显示43%的开发者在其项目中利用它,表明为了构建可维护、安全的系统,更广泛地集成了模块化实践。

模块系统的未来可能涉及更深入的工具集成、改进的IDE支持以及更清晰的最佳实践。Java 25的JEP 511引入了通过import module M;语法的模块导入声明,允许从导出包中导入所有公共类型以简化使用,显示了向开发者友好的模块交互方向持续演进。

7. 社区治理与OpenJDK流程

7.1 JCP与JEP的关系

JEP流程并未取代Java社区流程,JCP仍然是所有标准Java SE API及相关接口的管理机构,被接受的提案需要通过JSR在JCP中进行并行努力,以实现标准接口的变更。

这种双轨系统平衡了创新与标准化:

  • JEP:允许OpenJDK提交者在成为正式的Java规范请求之前更非正式地工作,充当JDK发布项目的长期路线图。
  • JSR:定义所有Java实现必须遵循的规范的正规提案,确保跨供应商的一致性。

7.2 决策结构

OpenJDK负责人最终决定将哪些JEP纳入路线图,但在评估提案时依赖评审者、组长和领域负责人所展示的专业知识。这种层级结构承认,没有人能在Java的庞大复杂性中保持专家级的理解。

成功的JEP需要通过评审者背书以及组长/领域负责人支持来建立共识。组长或领域负责人的背书应被视为相当有力的声明,相当于“我将主张这个JEP应该获得资助”。

7.3 六个月发布节奏的影响

JDK 25于2025年9月16日达到通用可用性,其功能和进度通过JEP流程(经JEP 2.0提案修订)进行跟踪。可预测的节奏从根本上改变了Java的创新模式。

此前多年的发布周期造成了将不成熟功能纳入发布窗口的压力。六个月模型允许功能在跨版本的多次预览轮次中成熟,而不会阻碍其他改进。这种能够培养耐心的结构被证明对Valhalla等复杂的举措至关重要,这些举措需要广泛的实际测试才能稳定。

7.4 社区参与

Java社区流程最初于1998年12月正式制定,Java的许多成功归功于该语言的演进方式以及全球社区在此演进中的协作方式。如今的参与机制包括邮件列表、早期访问构建和公共问题跟踪。

透明度要求已有所发展。JSR专家组必须在公共邮件列表上进行讨论,使用公共问题跟踪机制记录进度,并发布工作文档供所有人查看。这种开放性与早期更为封闭的开发模式形成对比。

8. Java的竞争定位

8.1 多范式格局

Java在多条战线上面临竞争,每种竞争都代表了不同的权衡:

  • Kotlin:Kotlin势头强劲,特别是在2017年谷歌宣布其为Android开发首选语言之后,不过根据Stack Overflow的2022年调查,Java仍远受欢迎得多(33.27% vs 9.16%)。Kotlin的吸引力在于互操作性——它运行在JVM上,并能与现有Java代码无缝集成,支持渐进式采用而非彻底重写。
  • Go:Go专为简单性和并发性设计,针对微服务和云基础设施领域,在这些领域中,快速编译和直接部署比丰富的生态系统更重要。Go的垃圾收集和缺乏泛型(直到最近)代表了倾向于简单性的有意权衡。
  • Rust:到2025年,Rust凭借其对内存安全和零成本抽象的强调,已确立自身作为系统编程、WebAssembly和性能关键型应用程序首选语言的地位。Rust陡峭的学习曲线和编译时严格性针对那些安全性和性能值得付出复杂性的场景。

8.2 Java的持久优势

截至2025年,Java仍占据约15%至16%的编程语言市场份额,68%的应用程序运行在Java或JVM上,99%的组织正在积极使用Java。几个因素维持了这一地位:

  • 生态系统成熟度:数十年的库开发、框架演进和工具改进创造了难以复制的网络效应。Spring、Jakarta EE、测试框架、构建工具、分析器——完整的开发栈已然存在且运行良好。
  • 企业稳定性:框架和库正转向支持或要求更新的Java版本,Java 17已成为新的基线,因为Spring、JUnit、Gradle 9及即将发布的Maven 4要求Java 17或更高版本。这种协调一致的演进在提升能力的同时保持了生态系统的连贯性。
  • 性能演进:Java的性能通过JIT进步、垃圾收集器创新以及现在的Project Valhalla和Vector API计划持续改进。对于许多工作负载,与低级语言的性能差距正在缩小。
  • 云原生适配:语言间的互操作性使开发者能够将Rust的高性能组件集成到现有的Java程序中,15%的开发者将Java与其Rust项目一起使用。这种多语言方法允许利用每种语言的优势,而非迫使做出全有或全无的选择。

8.3 人工智能与机器学习背景

像Embabel、Koog、Spring AI和LangChain4j这样的新框架推动了AI原生和AI辅助开发在Java中的快速采用。虽然Python在机器学习研究和实验领域占据主导地位,但在生产部署中——可靠性、监控以及与现有系统的集成至关重要的领域——Java的作用依然巨大。

8.4 威胁评估

最大的挑战并非被单一竞争对手取代,而是在专门细分领域中的碎片化。不同的语言针对不同的约束条件进行优化:

  • 开发速度:对于追逐产品-市场契合度的MVP和初创公司,Ruby、Python仍然更快
  • 本机性能:对于系统编程和嵌入式环境,Rust、C++保持优势
  • 简单性:对于优先考虑可维护性而非表达力的团队,Go的极简主义很有吸引力
  • 移动端:对于原生平台集成,Swift和Kotlin提供更优体验
    Java的战略似乎是扩大其范围,而非固守单一细分市场。Valhalla针对性能关键场景,Panama支持系统级集成,Amber提高开发者生产力,Vector API解决数值计算问题。这种多线并进的演进旨在使Java在尽可能广泛的使用场景中保持相关性。

9. 结论:我们的收获

在我们审视Java向2026年及以后的轨迹时,几个原则显现出来:

  1. 通过更智能的数据表示实现性能:Project Valhalla的值类型解决了Java对象模型与现代硬件之间的根本性架构不匹配问题,展示了3倍的性能改进,可能重塑Java在性能敏感领域的定位。
  2. 简化的本地互操作性:Project Panama的外部函数和内存API消除了JNI的复杂性,同时保持类型安全,最终为那些企业系统经常需要的本地集成提供了一种现代机制。
  3. 无需仪式感的表达力:Project Amber持续不断的增强——从模式匹配到记录类再到密封类——证明了生产力改进无需牺牲类型安全或运行时性能。
  4. 显式并行性:Vector API为开发者提供了对SIMD能力的直接访问,并具有平台无关的抽象,解决了自动向量化无法可靠处理的性能关键场景。
  5. 审慎的模块采用:虽然JPMS提供了架构上的好处,但实际采用显示出由特定用例驱动的渐进式进展,而非全盘迁移的压力。
  6. 透明的治理:JEP和JCP流程的结合,加上六个月发布节奏的加速,使得在保持稳定性和社区参与的同时能够快速创新。
  7. 通过演进保持竞争力:Java通过将能力扩展到相邻领域来维持相关性,而非固守历史优势,这些领域涵盖从低级性能到现代语言特性再到AI集成。

进入2026年的Java平台代表着比仅仅维护遗留系统更有趣的事物。通过对硬件现实、开发者易用性和生态系统演变的密切关注,Java将自己定位为一个能够随着需求变化而成长的平台,而非一个固守过去成功的平台。这种演进方式能否成功应对未来的挑战,将取决于执行情况、社区的响应以及将承诺的功能作为稳定、生产就绪的特性交付而非永久预览的能力。

未来的时间线需要耐心。Valhalla的值类型在2026-2027年之前不会投入生产。Vector API继续孵化以最终定稿。然而,这种审慎的节奏反映了一个成熟的平台,它重视稳定性而非将功能匆忙推向市场。对于构建旨在运行数十年的系统的组织而言,Java谨慎的演进和向后兼容性代表的是资产,而非负债。


【注】本文译自:The Future of Java: What to Expect in 2026 and Beyond

Java 中的 AI 与机器学习:TensorFlow、DJL 与企业级 AI

1. 引言:Java 意外的机器学习复兴

尽管 Python 主导了机器学习的研究与实验,但生产部署讲述着不同的故事。截至 2025 年,68% 的应用程序运行在 Java 或 JVM 上,那些已在 Java 生态系统投入巨资的企业面临一个关键问题:是重新培训团队并重写系统,还是将机器学习能力引入 Java?答案正日益倾向于后者。

Netflix 使用 Deep Java Library 进行分布式深度学习实时推理,通过字符级 CNN 和通用句子编码器模型处理日志数据,每个事件的延迟为 7 毫秒。这代表了一个更广泛的趋势——尽管 Python 在训练方面占主导地位,但 Java 在生产系统、多线程、稳定性和企业集成方面的优势,使其在机器学习部署上极具吸引力。

本文探讨 Java 在机器学习生命周期中的角色,比较各种框架,探索与 Python 生态系统的集成模式,并识别 Java 提供明显优势的场景。

2. Java ML 框架对比

2.1 Deep Java Library:引擎无关的方案

Deep Java Library 是一个开源的、高层次的、引擎无关的 Java 深度学习框架,它提供原生 Java 开发体验,功能如同任何其他常规 Java 库。由 AWS 创建的 DJL,其架构理念以抽象为核心——开发者编写一次代码,即可在 PyTorch、TensorFlow、MXNet 或 ONNX Runtime 之间切换而无需修改。

该框架由五层架构组成。高层 API 层提供符合 Java 习惯的接口,供开发者直接交互。引擎抽象层与底层框架通信,隐藏实现差异。NDManager 管理表示张量的 NDArray 的生命周期,在处理后自动释放张量内存以防止泄漏或崩溃。数据处理层提供为模型准备数据的实用工具。最后,原生引擎层通过对 C++ 实现的 JNA 调用执行实际计算。

DJL 与 TensorFlow、PyTorch、MXNet 等各种深度学习框架无缝集成,提供一个高层次 API 以便于在 Java 环境中轻松构建、训练和部署模型,并且与 AWS 服务紧密集成。其 Model Zoo 提供了来自 GluonCV、HuggingFace、TorchHub 和 Keras 的 70 多个预训练模型,支持单行命令加载模型。

优势:

  • 引擎灵活性:允许根据部署需求切换后端(研究模型用 PyTorch,生产用 MXNet,跨平台用 ONNX)。
  • 原生多线程支持:与 Akka、Akka Streams 及并发 Java 应用程序自然集成。
  • 自动 CPU/GPU 检测:无需配置即可确保最佳硬件利用率。
  • 通过 DJL Spring starters 集成 Spring Boot:简化企业采用。

局限:

  • 训练功能存在,但不如推理功能成熟
  • 文档侧重于推理而非训练工作流
  • 社区规模小于 Python 优先的框架

2.2 Deeplearning4j:JVM 原生解决方案

Eclipse Deeplearning4j 是为 Java 虚拟机编写的编程库,是一个广泛支持深度学习算法的框架,包括受限玻尔兹曼机、深度信念网络、深度自动编码器、堆叠降噪自动编码器、递归神经张量网络、word2vec、doc2vec 和 GloVe 的实现。

DL4J 于 2014 年问世,目标客户是已投入 Java 基础设施的企业。Eclipse Deeplearning4j 项目包括 Samediff(一个类似 TensorFlow/PyTorch 的框架,用于执行复杂计算图)、Python4j(一个 Python 脚本执行框架,用于将 Python 脚本部署到生产环境)、Apache Spark 集成以及 Datavec(一个将原始输入数据转换为张量的数据转换库)。

该框架的分布式计算能力使其区别于其他方案。Deeplearning4j 包含与 Apache Hadoop 和 Spark 集成的分布式并行版本。对于处理大规模数据的组织,DL4J 提供了无需 Python 依赖的原生 JVM 解决方案。

优势:

  • 完整的 ML 生命周期支持——训练、推理和部署完全在 Java 中完成。
  • 分布式训练:使用 Spark 或 Hadoop 在集群中扩展。
  • ND4J 提供支持 GPU 加速的、类似 NumPy 的 n 维数组。
  • SameDiff 提供类似 TensorFlow 的“先定义后运行”图执行方式。
  • Keras 模型导入:支持 h5 文件,包括 tf.keras 模型。

局限:

  • 文档和社区资源落后于 TensorFlow 和 PyTorch。
  • 与高层次框架相比学习曲线更陡峭。
  • 采用范围较窄,主要集中在重度使用 Java 的企业。

2.3 TensorFlow Java:官方但功能有限

TensorFlow Java 可在任何 JVM 上运行以构建、训练和部署机器学习模型,支持 CPU 和 GPU 在图模式或即时执行模式下的运行,并提供了在 JVM 环境中使用 TensorFlow 的丰富 API。作为 TensorFlow 的官方 Java 绑定,它提供了对 TensorFlow 计算图执行的直接访问。

TensorFlow 的 Java 语言绑定已移至其独立的代码库,以便独立于官方 TensorFlow 版本进行演进和发布,大多数构建任务已从 Bazel 迁移到 Maven。这种分离允许在不等待 TensorFlow 核心发布的情况下进行 Java 特定的改进。

优势:

  • 与 TensorFlow 生态系统和工具直接集成。
  • SavedModel 格式兼容性支持从 Python 到 Java 的无缝模型移交。
  • TensorFlow Lite 支持面向移动和边缘部署。
  • 通过原生 TensorFlow 运行时支持 GPU 和 TPU 加速。

局限:

  • TensorFlow Java API 不在 TensorFlow API 稳定性保证范围内。
  • 对 Keras on Java 几乎无官方支持,迫使开发者必须在 Python 中定义和训练复杂模型以供后续导入 Java。
  • 与 DJL 甚至 DL4J 相比,较低级别的 API 需要编写更多代码。




3. 框架对比表

标准 Deep Java Library Deeplearning4j TensorFlow Java
主要用例 推理与模型服务 完整 ML 生命周期 模型服务
引擎支持 PyTorch, TensorFlow, MXNet, ONNX 原生 JVM 仅 TensorFlow
训练能力 有限 完全支持 有限
分布式计算 通过引擎(如 MXNet 上的 Spark) 原生 Spark/Hadoop 通过 TensorFlow
模型导入 PyTorch, TensorFlow, Keras, ONNX Keras, TensorFlow, ONNX 仅 TensorFlow
预训练模型 Model Zoo 中 70+ 社区模型 TensorFlow Hub
Spring Boot 集成 原生 starters 手动 手动
学习曲线 中-高
内存管理 NDManager(自动) ND4J(堆外) 手动会话
企业就绪度 非常高
社区规模 增长中 小众 大(Python)
最适合 云原生推理 大数据 ML 流水线 TensorFlow 生态系统

决策矩阵:

  • 选择 DJL 用于:微服务、无服务器函数、Spring Boot 应用、引擎灵活性、AWS 生态系统。
  • 选择 DL4J 用于:分布式训练、Spark/Hadoop 集成、完整的纯 Java 技术栈、企业数据流水线。
  • 选择 TensorFlow Java 用于:现有的 TensorFlow 投资、TPU 部署、直接的 Python 模型兼容性。

4. 与 Python ML 生态系统的集成

4.1 多语言生产模式

最优的企业 ML 工作流通常结合 Python 的研究能力和 Java 的生产优势。数据科学家在熟悉的 Python 环境中使用 TensorFlow、PyTorch 或 scikit-learn 训练模型。工程师随后将这些模型部署在每天处理数百万请求的 Java 应用程序中。

模型导出格式:

  • ONNX:这个通用的交换格式支持大多数框架。在 PyTorch 中训练,导出到 ONNX,通过 DJL 或 DL4J 导入。这种方法支持与框架无关的部署流水线。
  • TensorFlow SavedModel:对于长期生产服务,导出到中立格式(如 ONNX)或针对服务优化的框架特定生产格式(SavedModel、TorchScript)。SavedModel 将计算图、变量值和元数据打包到单个目录结构中。
  • TorchScript:PyTorch 模型通过脚本或追踪序列化为 TorchScript。DJL 的 PyTorch 引擎直接加载这些模型,保持完整的计算图。
  • Keras H5:DL4J 导入 Keras 模型(包括 tf.keras 变体),保留层配置和训练好的权重。

4.2 Python4j:在 Java 中嵌入 Python

DL4J 的 Python4j 模块解决了需要 Java 中不可用的 Python 库的场景。Python4j 是一个 Python 脚本执行框架,简化了将 Python 脚本部署到生产环境的过程。该方法将 CPython 解释器嵌入到 JVM 进程中,实现双向调用。

用例包括:

  • 在 Java 推理前使用 scikit-learn 流水线进行预处理。
  • 从 Java 数据流水线调用专门的 Python 库(NumPy, SciPy)。
  • 在 Java 模型服务旁边运行基于 Python 的特征工程。

权衡之处在于需要管理 Python 运行时依赖项和潜在的 GIL 限制。对于高吞吐量场景,模型导出仍然优于运行时 Python 执行。

5. 模型服务与部署模式

5.1 实时推理架构

面向用户的应用,其生产 ML 系统需要低于 100 毫秒的延迟。Java 的线程模型和 JVM 优化在此背景下表现出色。在生产中无需 Python 即可提供 TensorFlow 模型服务,每次预测延迟低于 10 毫秒,并像任何 Spring Boot 服务一样水平扩展。

同步 REST API:

@RestController
public class PredictionController {
    private final Predictor<Image, Classifications> predictor;

    @PostMapping("/predict")
    public Classifications predict(@RequestBody Image image) {
        return predictor.predict(image); // <10ms 典型延迟
    }
}

Spring Boot 的自动配置、健康检查和指标与 DJL 或 DL4J 的预测器实例无缝集成。水平扩展遵循标准的微服务模式——在负载均衡器后部署多个实例。

异步处理:
对于非关键预测,异步处理可提高吞吐量。Java 的 CompletableFutureReactor 或 Kotlin 协程支持并发预测批处理:

// 异步批量预测
List<CompletableFuture<Result>> futures = images.stream()
    .map(img -> CompletableFuture.supplyAsync(
        () -> predictor.predict(img), executor))
    .collect(Collectors.toList());

5.2 批量推理模式

批量作业可以容器化并部署到作业调度器或流水线(如 Airflow/Prefect、Kubeflow Pipelines、云数据管道服务),而在线模型则部署到服务基础设施(Web 服务器、Kubernetes)。

DL4J 的 Spark 集成处理海量数据集:

// Spark 上的分布式批量评分
JavaRDD<DataSet> testData = loadTestData();
JavaRDD<INDArray> predictions = SparkDl4jMultiLayer
    .predict(model, testData);

该模式将推理分布在集群节点上,高效处理数百万条记录。对于拥有 Hadoop 或 Spark 基础设施的组织,这种原生集成消除了 Python 桥接开销。

5.3 边缘与移动端部署

DJL 支持部署到边缘设备和移动平台。对于 Android,DJL 提供了针对 ARM 处理器优化的 TensorFlow Lite 和 ONNX Runtime 引擎。自动 CPU/GPU 检测可适应可用硬件。

用例包括:

  • 移动应用中的设备端图像分类。
  • 无需云连接的 IoT 传感器异常检测。
  • 需要本地推理的边缘计算场景。

该方法降低了延迟,提高了隐私性(数据保留在本地),并消除了网络依赖。

6. 可扩展性考量

6.1 容器化与编排

使用 Docker 进行容器化,允许将模型及其代码连同所有必需的库和依赖项打包到一个自包含的单元中,该单元可以在任何地方运行(您的笔记本电脑、云虚拟机、Kubernetes 集群中)。

Java ML 服务与传统 Spring Boot 应用的容器化方式相同:
Dockerfile 模式:

FROM eclipse-temurin:21-jre-alpine
COPY target/ml-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Kubernetes 编排处理扩展、健康检查和滚动更新。这种统一性意味着现有的 DevOps 流水线无需特殊处理即可扩展到 ML 服务。

6.2 性能优化策略

  • 模型量化:通过将 float32 权重转换为 int8 来减少模型大小和推理时间。TensorFlow Lite 和 ONNX Runtime 支持量化,且精度损失最小。典型收益:模型缩小 4 倍,推理速度加快 2-3 倍。
  • 批处理:将预测分组以分摊开销。DJL 和 DL4J 支持批处理输入,利用 SIMD 指令,并将每项预测的延迟从 10 毫秒降低到批量 32 条时的每条 2-3 毫秒。
  • 模型编译:ONNX Runtime 和 TensorFlow XLA 将模型编译为优化的执行图。在容器构建期间进行预编译可消除运行时编译开销。
  • 内存管理:DJL 通过其特殊的内存收集器 NDManager 解决了内存泄漏问题,该管理器及时收集 C++ 应用程序内部的陈旧对象,在测试 100 小时连续推理不崩溃后,在生产环境中提供稳定性。
  • 连接池:对于调用外部模型服务器(TensorFlow Serving、Triton)的服务,维护连接池以减少 TCP 握手开销。

6.3 水平扩展模式

Java ML 服务的扩展方式与无状态 Web 服务相同:

  • 在负载均衡器后部署多个实例。
  • 基于 CPU、内存或自定义指标(推理队列深度)使用 Kubernetes HorizontalPodAutoscaler。
  • 实施熔断器以优雅地处理下游故障。
  • 使用 Redis 或 Caffeine 缓存频繁的预测结果。

推理的无状态特性(给定模型版本)使得无需协调开销即可实现弹性扩展。

7. Java 应用的 MLOps

7.1 持续训练与部署

MLOps 团队的目标是自动将 ML 模型部署到核心软件系统中或作为服务组件,自动化整个 ML 工作流步骤,无需任何人工干预。

  • Level 0(手动):许多团队拥有能够构建先进模型的数据科学家和 ML 研究人员,但他们构建和部署 ML 模型的过程完全是手动的,每个步骤都需要手动执行和手动过渡。这代表了 2025 年 35% 的 Java ML 部署。
  • Level 1(ML 流水线自动化):自动化训练流水线根据新数据重新训练模型。Jenkins、GitHub Actions 或 GitLab CI 触发训练作业,将模型导出到工件仓库(Nexus、Artifactory),并通知部署系统。版本化的模型自动部署到预发布环境。
  • Level 2(ML 的 CI/CD):持续集成通过添加测试和验证数据和模型来扩展对代码和组件的测试和验证;持续交付关注自动部署另一个 ML 模型预测服务的 ML 训练流水线的交付;持续训练自动重新训练 ML 模型以重新部署。

在 Java 上下文中,这意味着:

  • 数据流水线和预处理的自动化单元测试。
  • 确保模型预测符合预期输出的集成测试。
  • 金丝雀部署(5% 流量导向新模型版本)。
  • 性能下降时的自动化回滚。

7.2 模型版本控制与注册

将模型视为一等工件:

models/
  fraud-detection/
    v1.0.0/
      model.onnx
      metadata.json
    v1.1.0/
      model.onnx
      metadata.json

元数据包括训练日期、数据集版本、性能指标(准确率、F1 分数)和依赖版本。可以使用 Maven 坐标引用模型版本:

<dependency>
    <groupId>com.company.ml</groupId>
    <artifactId>fraud-detection-model</artifactId>
    <version>1.1.0</version>
    <classifier>onnx</classifier>
</dependency>

这种方法将标准的依赖管理实践应用于 ML 模型,从而实现可重复的构建和可审计的部署。

7.3 监控与可观察性

ML 模型部署后,需要进行监控以确保其按预期执行。Java 的可观察性生态系统自然地扩展到 ML 服务:

要跟踪的指标:

  • 推理延迟:通过 Micrometer 统计 p50、p95、p99 百分位数。
  • 吞吐量:每秒预测数、每秒请求数。
  • 错误率:失败的预测、模型加载失败。
  • 数据漂移:通过统计测试检测到的输入分布变化。
  • 模型性能:生产数据上的准确率、精确率、召回率(当标签可用时)。

与现有工具的集成:
Spring Boot Actuator 暴露 ML 特定指标:

@Component
public class PredictionMetrics {
    private final MeterRegistry registry;

    public void recordPrediction(long latencyMs, String modelVersion) {
        registry.timer("prediction.latency", 
            "model", modelVersion)
            .record(Duration.ofMillis(latencyMs));
    }
}

Prometheus 抓取这些指标,Grafana 可视化趋势,并在出现异常(延迟峰值、准确率下降)时触发告警。

7.4 测试 ML 系统

  • 单元测试:验证数据预处理、特征工程和后处理逻辑。标准的 JUnit 测试即可满足。
  • 集成测试:测试 ML 模型是否成功加载到生产服务中,并且对真实数据的预测符合预期;测试训练环境中的模型与服务环境中的模型给出相同的分数。
  • 性能测试:使用 JMeter 或 Gatling 模拟负载,在真实流量模式下测量吞吐量和延迟。建立基线并检测回归。
  • 影子部署:将新模型版本与现有版本并行运行,记录预测而不影响用户。在全面部署前比较结果以识别意外行为。

8. Java 在机器学习中表现出色的用例

8.1 企业集成场景

  • 金融服务中的欺诈检测:拥有成熟 Java 生态系统的企业越来越寻求将 ML/AI 模型直接集成到其后端系统的方法,而无需启动单独的基于 Python 的微服务。银行每天通过 Java 系统处理数百万笔交易。将 DJL 预测器直接嵌入交易处理流水线中,无需外部服务调用即可实现低于 10 毫秒的欺诈评分。
  • 实时推荐:基于 Spring Boot 构建的电子商务平台集成 DJL 进行产品推荐。会话数据流经现有的 Java 服务,预测在进程内进行,结果无需网络延迟即可呈现。
  • 日志分析与聚类:Netflix 的可观察性团队使用 DJL 在生产中部署迁移学习模型,以对应用程序日志数据进行实时聚类和分析,通过字符级 CNN 和通用句子编码器模型处理日志行,每条约 7 毫秒。基于 DJL 的流水线分配保留相似性的聚类 ID,从而实现告警量减少和存储效率提高。

8.2 大数据 ML 工作流

使用 Spark 或 Hadoop 每天处理 TB 级数据的组织受益于 DL4J 的原生集成。在历史数据上训练模型、对新记录进行评分以及更新模型——所有这些都在 Spark 流水线内完成,无需 Python 桥接。

示例工作流:

  1. 从 HDFS 或 S3 将数据读入 Spark DataFrames。
  2. 使用 Spark SQL 进行特征工程。
  3. 在集群上分布式训练 DL4J 模型。
  4. 使用训练好的模型对新数据评分。
  5. 将结果写回数据仓库。
    整个端到端流程保持在 JVM 中,避免了序列化开销和 Python 互操作的复杂性。

8.3 微服务与云原生应用

Spring Boot 应用程序主导着企业微服务架构。通过 DJL starters 添加 ML 能力可无缝集成:

  • 熔断器:Resilience4j 模式保护 ML 服务免受级联故障影响。
  • 服务发现:Eureka 或 Consul 注册 ML 预测服务。
  • 配置:Spring Cloud Config 管理模型端点和参数。
  • 追踪:Zipkin 或 Jaeger 追踪通过 ML 流水线的请求。
    这种统一性简化了运维——ML 服务与业务逻辑服务以相同的方式部署、扩展和监控。

8.4 边缘计算与物联网

Java 的“一次编写,随处运行”理念扩展到边缘设备。为 ARM 处理器编译的 DJL 模型可以在 Raspberry Pi、NVIDIA Jetson 和工业 IoT 网关上运行。用例包括:

  • 预测性维护:本地分析传感器数据,异常时触发警报。
  • 视频分析:在边缘处理安防摄像头视频流,减少带宽。
  • 智能家居设备:设备端语音识别和自然语言理解。
    GraalVM 原生镜像编译生成独立的可执行文件,内存占用小(< 50MB),启动速度快(< 100ms),非常适合资源受限的环境。

8.5 法规与合规要求

随着欧盟《人工智能法案》等法规的收紧,集成重点转向模型的左移安全性——在流水线中扫描偏见、可解释性和合规性。Java 的强类型、显式异常处理和成熟的日志记录框架便于审计追踪和满足可解释性要求。

金融和医疗保健行业通常要求所有代码(包括 ML 模型)通过经过验证的、带有审批工作流的流水线进行部署。与引入 Python 运行时依赖相比,Java ML 服务能更自然地与现有的治理流程集成。

9. 结论:我们的收获

Java 在机器学习中的作用代表了务实的生产工程,而非研究创新。我们分析得出的主要见解:

  1. 框架选择取决于上下文:DJL 在推理和模型服务方面表现出色,具有引擎灵活性,是云原生微服务的理想选择。DL4J 提供了与大数据框架集成的完整 ML 生命周期功能,适用于需要分布式培训的组织。TensorFlow Java 服务于深度投入 TensorFlow 生态系统、需要直接模型兼容性的团队。
  2. 多语言模式行之有效:在 Python 中训练并在 Java 中部署,利用了每种语言的优势。ONNX 和 SavedModel 格式支持无缝交接。Python4j 在必要时弥合差距,但出于性能考虑,模型导出仍是首选。
  3. 生产性能至关重要:Netflix 7 毫秒的推理延迟证明 Java ML 服务能够满足实时性能要求。适当的内存管理(NDManager、ND4J)、模型优化(量化、编译)和水平扩展提供了生产级系统。
  4. MLOps 成熟度参差不齐:只有 20% 的 Java ML 部署达到了 Level 2 CI/CD 成熟度,具备自动重新训练和监控。机会在于将已建立的 DevOps 实践——容器、编排、可观察性——应用于 ML 工作流。
  5. Java 在特定场景中表现出色:企业集成(欺诈检测、推荐)、大数据 ML 流水线(Spark/Hadoop)、微服务架构、边缘计算和法规合规代表了 Java 的特性——稳定性、线程处理、生态系统成熟度——相比以 Python 为中心的方法提供优势的领域。
  6. 内存管理区分了框架:DJL 的 NDManager 解决了管理 JVM 应用程序中本机内存的关键挑战,实现了 100 小时以上的生产运行而无内存泄漏。这种生产就绪性将企业可行的框架与实验性绑定区分开来。
  7. 差距正在缩小:虽然 Java 不会取代 Python 在 ML 研究中的地位,但像 DJL 和 DL4J 这样的框架已经足够成熟,可用于生产部署。生态系统现在支持完整的推理生命周期,性能可与 Python 解决方案相媲美。

未来可能涉及更深层次的集成——Spring AI 为 Java 带来 LLM 能力,GraalVM 原生镜像为无服务器 ML 实现即时启动,以及 MLOps 和 DevOps 实践之间持续的融合。对于拥有大量 Java 投资的组织,问题从“我们能用 Java 做 ML 吗?”转变为“我们如何优化 Java ML 部署?”。

随着 ML 在企业系统中变得无处不在,Java 的生产优势——稳定性、性能、工具成熟度和操作熟悉度——使其成为推理层的务实选择,即使 Python 在训练和实验中仍占主导地位。多语言方法——在 Python 中训练,在 Java 中部署——代表的不是妥协,而是对每个平台独特优势的优化。


【注】本文译自:AI and Machine Learning in Java: TensorFlow, DJL, and Enterprise AI

让我们从Spring AI开始

Spring AI:使用Java迈入生成式AI的第一步

基于Java的企业系统通常难以与Python库及相关工具链协同工作。为此,Spring AI应运而生——这是一个旨在简化整合人工智能功能(特别是大型语言模型)应用开发的开源框架,它采用了Spring生态系统中大家熟悉的模式。

如果您是一名Java开发者,希望将ChatGPT或Google Gemini等强大功能集成到企业应用程序中,而又不想费力研究各提供商特定的SDK,那么Spring AI是您的理想工具。

什么是Spring AI?

Spring AI的核心是充当AI模型的通用抽象层

可以将其类比于Spring Data JPA之于数据库的关系:正如Spring Data抽象了SQL和数据库的具体细节一样,Spring AI则抽象了不同AI提供商(如OpenAI、Google、Azure、Anthropic等)之间的差异。

这种方法带来了两大显著优势:

  1. 可移植性:您只需极少的代码改动即可在不同AI模型和提供商之间切换,从而为您的用例选择最具成本效益或性能最佳的模型。
  2. 熟悉度:它使用了依赖注入、自动配置和流式API(如WebClientJdbcClient)等标准的Spring概念,使得数以百万计的现有Spring开发者能够轻松上手。

为什么选择Spring AI而不是LangChain?

尽管LangChain是一个强大且与提供商无关的框架,并因LLM调用的“链式”编排而广受欢迎,但它主要为Python生态系统构建。相比之下,Spring AI则是从零开始构建,遵循Java语言习惯,并能与Spring Boot应用无缝集成。

以下是Java企业开发者应该认真考虑使用Spring AI的原因:

符合Java习惯”的优势

对于一个Java团队来说,选择Spring AI意味着:

  • 无需多语言复杂性:您可以避免在生产Java环境中引入Python依赖、虚拟环境以及进程间通信带来的麻烦。
  • 性能:Spring AI原生运行在Java虚拟机(JVM)内,充分利用其卓越的垃圾回收和性能优化能力。
  • 工具链:您可以享受到静态类型检查、强大的调试支持以及Java测试框架(如JUnit、Mockito)完整生态系统的益处。
    简而言之,如果您的应用程序是用Java编写并使用Spring Boot,那么Spring AI就是集成生成式AI最自然、阻力最小的选择。

Spring AI的核心概念

要构建一个基本的AI应用,您需要理解三个核心组件:

构建一个简单的聊天服务

让我们创建一个极简的Spring Boot应用程序,它使用ChatClient根据用户的消息生成回复。在本示例中,我们将使用OpenAI模型。

1. 项目设置(Maven)

将以下内容添加到您的pom.xml文件中:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
  </dependency>
</dependencies>
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-bom</artifactId>
      <version>1.0.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

2. 配置(application.properties)

您需要提供AI提供商的API密钥。将其放在src/main/resources/application.properties文件中。

# 用您实际的OpenAI API密钥替换
spring.ai.openai.api-key=<YOUR_OPENAI_API_KEY>

3. 控制器(AiController.java)

这个类定义了一个REST端点,用于接收消息并使用注入的ChatClient获取响应。

package com.example.aidemo;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AiController {
    private final ChatClient chatClient;
    /**
     * Spring Boot会根据依赖项和属性自动配置并注入ChatClient。
     */
    public AiController(ChatClient.Builder chatClientBuilder) {
        // 使用注入的构建器构建ChatClient实例
        this.chatClient = chatClientBuilder.build();
    }
    @GetMapping("/generate")
    public String generate(@RequestParam(value = "message", defaultValue = "Tell me a short, friendly joke.") String message) {
        // 使用流式API定义提示词并调用模型
        return chatClient.prompt()
            .user(message) // 设置用户的输入消息
            .call()       // 执行对AI模型的调用
            .content();   // 从响应中提取纯文本内容
    }
}

4. 运行与测试

  • 运行您的Spring Boot应用程序。
  • 测试端点:http://localhost:8080/generate?message=Explain%20Spring%20AI%20in%20one%20sentence

【注】本文译自:Lets start with Spring AI

停止编写Excel规格文档:企业级Java开发的Markdown先行方法

将设计规格从Excel转移到Markdown,利用AI生成Java代码,从而防止设计与代码脱节,并将开发时间缩短55%。

在企业级Java开发中,设计文档通常被困在诸如Excel或Word之类的二进制孤岛中,导致它们与实际代码渐行渐远。本文展示了一种模式,即通过使用结构化的Markdown生成式AI,将设计文档视为源代码。

我们都经历过这种情况:架构团队向开发团队交付一份详细设计文档([Detailed Design Document]DDD)。它是一个50页的Word文件,或者更糟,是一个包含多个标签页、用于定义Java类、字段和验证规则的大型Excel电子表格。

当你写下第一行代码时,这份文档就已经过时了。

二进制文件几乎无法进行版本控制,差异对比不切实际,并且将定义复制粘贴到Javadoc中非常繁琐。在企业级规模下,这种"代码漂移"(即实现与设计脱节)成为了技术债务的主要来源。

通过将设计文档转移到结构化的Markdown并利用生成式AI,我们可以将文档完全视为源代码。这在架构师的意图和开发人员的集成开发环境(IDE)之间架起了一座桥梁。

问题:二进制壁垒

在传统的瀑布模型或混合开发环境中,设计存在于Office文档(Word/Excel)中,而代码则以文本格式(Java/YAML)存在。由于格式不兼容,自动化流程中断。你无法轻易地将Excel表格"编译"成Java POJO,当然也无法对Word文档进行单元测试。

为了弥合这一差距,设计信息需要具备以下特点:

  • 基于文本(以便于Git版本控制)。
  • 结构化(以便于机器解析)。
  • 人类可读(以便于审查和协作)。
    解决方案就是使用结构化Markdown

解决方案:将Markdown作为数据源

我们不应仅将Markdown视为编写README文件的方式,而应将其作为一种结构化的规格说明格式。通过标准化标题和布局,Markdown文件就成为一种一致且对机器友好的数据源,生成式AI工具(GitHub Copilot、ChatGPT等)可以解析它来生成样板代码、图表,甚至为利益相关者生成遗留的Excel报告。

1. 目录结构

为使此方法有效,设计文档必须与代码存放在一起,并镜像包结构,以便它们同步演进。

模式示例:

/project-root
    /src
        /main/java/com/app/backend/RegisteredUser.java
    /design-docs
        /backend
            RegisteredUser.md
            OrderService.md
    /diagrams
        architecture.mermaid

通过将.md文件与.java文件保存在相同的仓库结构中,我们在规格说明和实现之间建立了直接、可追溯的联系。

2. 结构化规格说明

关键在于将Markdown写成实际的规格说明,而不是博客文章。我们使用特定的标题(例如## Class Summary, ## Members)作为自动化工具的挂钩点。

示例RegisteredUser.md

# RegisteredUser

## Class Summary
Represents a user who has completed the registration process.
Manages user credentials and validation status.

## Members
| Name | Type | Description |
| :--- | :--- | :--- |
| userId | String | Unique identifier for the user. |
| userPassword | String | Encrypted password string. |

## Methods
+ fn isValidUserId(): bool
  - Logic: Returns true if userId length >= 3.
+ fn isValidUserPassword(): bool
  - Logic: Returns true if password length >= 8.

这种格式对产品负责人是可读的,同时对大语言模型(LLM)来说也足够结构化,足以解释类型定义和逻辑约束。

实施:从文本到Java

一旦我们用结构化的Markdown表达了设计,生成式AI就可以弥合与Java代码之间的鸿沟。在富士通的案例研究中,他们利用一个VS Code扩展和OpenAI API来读取这些Markdown规格说明,并生成初始的类框架。

你可以使用任何生成式AI编码助手来复制这个工作流。由于提示上下文包含一个严格、可预测的结构,幻觉率会显著下降。

提示上下文示例:

"请阅读以下Markdown设计文档。根据’Methods’部分中的描述,生成一个包含Javadoc、字段和验证逻辑的Java类。"

生成输出示例:

package com.app.backend;

/**
 * Represents a user who has completed the registration process.
 * Manages user credentials and validation status.
 */
public class RegisteredUser {

    private String userId;
    private String userPassword;

    public RegisteredUser(String userId, String userPassword) {
        this.userId = userId;
        this.userPassword = userPassword;
    }

    /**
     * Logic: Returns true if userId length >= 3.
     */
    public boolean isValidUserId() {
        return this.userId != null && this.userId.length() >= 3;
    }

    /**
     * Logic: Returns true if password length >= 8.
     */
    public boolean isValidUserPassword() {
        return this.userPassword != null && this.userPassword.length() >= 8;
    }
}

AI不会猜测;它会严格按照书面描述实现指定的业务规则(>=3, >=8)。如果设计发生变更,你只需更新Markdown,然后重新生成代码。

可视化架构

当不再使用Excel、Visio或其他图表工具时,一个常见的担忧是失去了"绘制"系统的能力。但既然我们的设计现在存在于结构化的文本中,我们就可以将其编译成图表。

利用标准化的Markdown标题,我们可以通过简单地扫描目录来自动生成Mermaid.js类图。

输入(Markdown标题):
Class: RegisteredUser depends on Class: UserProfile

#Mermaid Diagram
classDiagram
    class RegisteredUser {
        +String userId
        +String userPassword
        +isValidUserId()
    }
    class UserProfile {
        +String email
    }
    RegisteredUser --> UserProfile

这确保了你的架构图始终反映设计文档的当前状态,而不是架构师三个月前绘制的内容。

"Excel"需求

许多企业仍然需要Excel文件用于正式签核或非技术利益相关者。

但现在,既然真实来源是结构化的文本(Markdown),生成Excel就变得微不足道。一个简单的脚本(甚至一个AI提示)就可以解析标题并自动填充CSV或XLSX模板。

  • 旧方式:主文件是Excel -> 开发人员手动编写Java。
  • 新方式:主文件是Markdown -> 自动生成Java并为管理层自动生成Excel。

结果与投资回报率

转向Markdown先行方法不仅仅是整理你的仓库。在分析的案例研究中,团队看到了明确的生产力提升:

  • 开发速度加快55%:样板代码(类、测试)直接从Markdown规格说明生成。
  • 减少沟通开销:AI辅助的Markdown转换比处理Excel单元格更快、更准确。
  • 真正的差异对比能力:Git现在能准确显示谁在何时更改了业务规则(通过Git提交历史)。

结论

文档常常沦为事后的补救措施,因为我们用于设计的工具(Office套件)与我们用于开发的工具(IDE)格格不入。通过采用Markdown作为一种正式的规范语言,我们将设计工作直接拉入了DevOps流程。

所以,下次当你被要求编写详细设计时,请跳过电子表格。打开一个.md文件,定义一个清晰的结构,然后让代码从中流淌而出。


【注】本文译自:Markdown-First Approach to Enterprise Java

透过独立变化原则审视错误处理

最近我一直在思考错误处理——不是语法争论或"哪种语言做得更好"的辩论,而是更深层次的问题。是什么让某种方法在架构上优于其他方法?

有一个原则为这些决策提供了客观基础:独立变化原则(PIV)【Principle of Independent Variation】。该原则指出:独立变化的事物应该分离,共同变化的事物应该组合。看似简单,但它能告诉你哪种设计在技术上更优越——尽管具体场景可能仍需权衡。

让我通过将其应用于四种错误处理策略来阐明我的观点。

四种处理方式

返回码(C风格)

int read_file(const char* path, char** content) {
    if (!path) return -1;
    if (!exists(path)) return -2;
    return 0;
}

受检异常

public String readFile(String path) throws IOException, SecurityException {
    // 编译器强制你处理这些异常
}

非受检异常(Python、C#、Java RuntimeException)

def read_file(path):
    # 可能引发 FileNotFoundError、PermissionError...
    # 函数签名不会告知这些信息

Result单子(Rust、Haskell;可通过库在Java、C#、Python中使用)

fn read_file(path: &str) -> Result<String, FileError> {
    // 不处理Result就无法访问值
}

变化驱动因素是什么?

PIV首先要问:这段代码可能变化的独立原因有哪些?

对于错误处理,我认为有三类:

  1. 错误类型演变(出现新的失败模式,旧的被移除)
  2. 错误处理逻辑变化(不同的恢复策略)
  3. 业务逻辑变化(与错误无关)

这些变化确实是独立的。添加新错误类型不应涉及业务逻辑。改变网络故障的恢复方式不应影响存在的错误类型。

为什么错误处理策略必须独立变化

这并非空谈。考虑相同的业务操作——"获取用户资料"——及其在不同上下文中处理失败的不同方式:

恢复策略:

  • 退避重试 — 瞬时网络故障,再次尝试
  • 使用缓存/陈旧数据 — 可接受的降级
  • 返回默认值 — 必须继续运行
  • 快速失败 — 如果失败则无需继续

可观测性策略:

  • 记录日志并继续 — 非关键问题,仅记录
  • 通知值班人员 — 需要人工立即关注
  • 增加指标 — 为SLO仪表板跟踪
  • 追踪关联 — 附加到分布式追踪

传播策略:

  • 吸收 — 不让调用方知晓
  • 转换 — 转换为领域特定错误
  • 原样传播 — 让其向上冒泡
  • 熔断 — 停止调用故障服务

在微服务架构中,这点更加有趣。相同的下游故障可能需要:

  • 服务A:重试3次,然后返回缓存数据(面向用户,延迟敏感)
  • 服务B:立即失败,将消息加入死信队列(异步作业,正确性至关重要)
  • 服务C:记录警告,返回部分结果(聚合器,尽力而为)

业务逻辑——"获取用户资料"——是相同的。错误处理根据操作上下文、SLA和各服务的角色而变化。这些关注点因不同原因不同时间变化。PIV指出:它们必须是可分离的

四种方法对比

变化 返回码 受检异常 非受检异常 Result
添加错误类型 涉及所有调用方 更新调用栈上的每个throws子句 无影响 编译器对不完整匹配发出警告
移除错误类型 静默—常量闲置 编译错误(好!) 无影响 编译错误(好!)
更改处理策略 分散在各调用方 重构try-catch块 可集中处理 交换一个组合子
更改业务逻辑 与错误检查纠缠 埋在try块中 清晰—直接更改 清晰—直接更改

详细分析变化驱动因素

变化驱动因素1:错误类型演变

当你添加新错误类型时会发生什么?

受检异常创建耦合——throws子句具有传染性:

// 你向此方法添加DatabaseException...
void saveUser(User u) throws IOException, DatabaseException

// ...现在这个方法需要更新...
void processRegistration(Form f) throws IOException, DatabaseException

// ...还有这个...
void handleRequest(Request r) throws IOException, DatabaseException

// ...一直波及整个调用栈

我见过团队通过在每层捕获和包装来应对——这完全违背了初衷。或者更糟,到处声明throws Exception

返回码静默失败。你添加新常量-3,但现有调用方仍只检查-1和-2。新错误作为垃圾数据传播。

非受检异常完全隐藏变化。调用方直到运行时崩溃才知道新异常存在。

Result类型像非受检异常一样传播——?操作符向上传递错误而无需中间层签名更改。但与非受检异常不同,错误在类型中可见。当你添加新变体时,编译器标记每个决策点(你匹配的地方),而不是整个调用链。两全其美:轻量级传播,显式处理

变化驱动因素2:处理策略变化

这里差异最明显。Result类型提供组合子——在不展开Result的情况下转换它们的小函数。这让你可以在组合时插入不同策略。

相同业务逻辑,不同恢复策略:

// 策略1:快速失败
let user = fetch_user(id)?;

// 策略2:失败时返回缓存数据
let user = fetch_user(id)
    .or_else(|_| get_cached_user(id))?;

// 策略3:退避重试
let user = fetch_user(id)
    .or_else(|_| { sleep(100); fetch_user(id) })
    .or_else(|_| { sleep(500); fetch_user(id) })?;

// 策略4:默认值
let user = fetch_user(id)
    .unwrap_or_else(|_| User::anonymous());

相同业务逻辑,不同可观测性:

// 记录日志并继续
let user = fetch_user(id)
    .map_err(|e| { log::warn!("获取失败: {e}"); e })?;

// 增加指标
let user = fetch_user(id)
    .map_err(|e| { metrics::increment("user_fetch_error"); e })?;

// 通知值班人员(针对关键路径)
let user = fetch_user(id)
    .map_err(|e| { pagerduty::alert("用户获取失败"); e })?;

相同业务逻辑,不同传播:

// 吸收错误,返回部分结果
let profile = fetch_user(id).ok()
    .map(|u| Profile::from(u))
    .unwrap_or(Profile::empty());

// 转换为领域错误
let user = fetch_user(id)
    .map_err(|e| DomainError::UserUnavailable(e))?;

// 熔断模式
let user = circuit_breaker.call(|| fetch_user(id))?;

业务逻辑——fetch_user(id)——从不改变。处理策略围绕它组合,而不是与它交织。你可以在不触及核心操作的情况下交换策略。

现在用异常实现相同的四种策略:

// 策略1:快速失败
User user = fetchUser(id);  // 抛出,调用方处理

// 策略2:失败时返回缓存数据
User user;
try {
    user = fetchUser(id);
} catch (IOException e) {
    user = getCachedUser(id);
}

// 策略3:退避重试
User user;
int retries = 0;
while (true) {
    try {
        user = fetchUser(id);
        break;
    } catch (IOException e) {
        if (++retries >= 3) throw e;
        Thread.sleep(100 * retries);
    }
}

// 策略4:默认值
User user;
try {
    user = fetchUser(id);
} catch (IOException e) {
    user = User.anonymous();
}

每种策略都是不同的控制流结构。重试需要while循环。回退需要try-catch。默认值需要带赋值的try-catch。它们不能组合——你需要从头重建。

组合策略更糟(重试然后回退到缓存):

User user;
int retries = 0;
while (true) {
    try {
        user = fetchUser(id);
        break;
    } catch (IOException e) {
        retries++;
        if (retries >= 3) {
            try {
                user = getCachedUser(id);
                break;
            } catch (CacheException ce) {
                throw new UserFetchException("所有策略都失败", e);
            }
        }
        Thread.sleep(100 * retries);
    }
}

与Result比较:fetch_user(id).or_else(retry).or_else(retry).or_else(cache)fetchUser调用在两者中都没有被触及,但使用异常时它被埋在20行嵌套控制流中。

使用返回码(相同策略):

User* user = NULL;
int result, retries = 0;

while (retries < 3) {
    result = fetch_user(id, &user);
    if (result == 0) break;
    retries++;
    sleep_ms(100 * retries);
}

if (result != 0) {
    result = get_cached_user(id, &user);
}

if (result != 0) {
    log_error("所有策略都失败");
    return ERR_USER_FETCH;
}

同样情况:fetch_user未被触及,但埋在条件判断中。每个策略更改都意味着重写围绕它的if检查。如果你忘记某个检查,错误就会作为垃圾数据静默传播。

差异不仅仅是语法Result策略线性组合——你可以链式调用它们。异常策略层次嵌套——try-catch内部嵌套try-catch。返回码策略条件分散——到处都是if检查。

注意在任何方法中都不变的是:各个操作(fetchUsergetCachedUser)保持不变。问题在于组合方式。使用异常和返回码时,你组合操作的方式与处理它们失败的方式纠缠在一起。想添加第三个回退?需要重构整个代码块。想更改重试次数?编辑定义操作序列的相同代码。

使用Result时,组合和策略是独立的关注点。fetch_user(id).or_else(f).or_else(g)读起来像管道,每个组合子都是独立的。将.or_else(cache)换为.unwrap_or(default)而无需触及其他部分。

变化驱动因素3:业务逻辑变化

当你需要更改核心操作本身时会发生什么——比如fetch_user现在需要一个额外参数,或者你要用fetch_user_v2替换它?

返回码迫使你在错误检查条件语句中导航以找到实际调用。业务逻辑与错误处理纠缠:

// 实际操作在哪里?埋在这里:
while (retries < 3) {
    result = fetch_user(id, &user);  // <-- 在杂音中找到这个
    if (result == 0) break;
    retries++;
    sleep_ms(100 * retries);
}

受检异常将你的逻辑埋在try块中。低内聚——业务操作与其错误处理交织:

try {
    user = fetchUser(id);  // <-- 实际工作
    break;
} catch (IOException e) {
    // 10行恢复逻辑
}

非受检异常和Result类型都保持业务逻辑清晰。操作独立存在:

let user = fetch_user(id)?;  // 清晰显示发生了什么
user = fetch_user(id)  # 同样清晰

区别在于:使用Result时,你知道错误处理存在于链中的某个地方。使用非受检异常时,你相信某个地方的某人会处理它。

评分卡

每种方法在多大程度上允许你独立变化这三个关注点?

变化驱动因素 返回码 受检异常 受检异常 结果
错误类型演变 ❌ 静默失败 ❌ 传染性签名 ⚠️ 不可见 ✅ 穷尽匹配
处理策略变化 ❌ 重写条件判断 ❌ 重构try-catch ⚠️ 可集中但隐式 ✅ 交换组合子
业务逻辑变化 ❌ 与检查纠缠 ⚠️ 埋在try块中 ✅ 清晰 ✅ 清晰

应用PIV

Result类型胜出不是因为语法或时尚,而是因为它们尊重变化的实际运作方式。成功和失败是独立的关注点——它们来自不同的利益相关者,在不同的时间线上演变,响应不同的压力。PIV指出:分离它们

更深刻的见解是PIV基于业务现实。变化驱动因素不是技术抽象——它们是产品需求、合规更新、操作事件、扩展需求。业务不关心你的try-catch嵌套。它关心的是你能在下一次中断前添加熔断器,或者无需两周重构就能交换重试策略。

唯一不变的是变化本身。与这一现实抗争的软件会积累摩擦。拥抱它的软件——通过分离独立变化的事物——保持可塑性。

要自行应用PIV:

  1. 识别变化驱动因素。这段代码可能变化的独立原因是什么?不是抽象类别——真实的力量。谁要求这些变化?频率如何?在什么时间线上?

  2. 追踪耦合。对于每个驱动因素,还有什么必须改变?如果触及一个关注点会波及另一个,你就耦合了本应独立变化的事物。

  3. 检查内聚性。相关代码是否分散?如果共同变化的代码存在于五个不同文件中,你就碎片化了本应组合的内容。

  4. 比较设计。最小化跨关注点耦合同时保持每个关注点内聚的设计就是PIV推崇的。这不是观点——这是变更成本更低的设计。


【注】本文译自:Error Handling Through the Lens of the Principle of Independent Variation

作用域值:Java开发者期待已久的ThreadLocal现代替代方案

如果你曾编写过多线程Java应用程序,很可能接触过ThreadLocal变量。自Java 1.2引入以来,它已存在超过25年,帮助开发者在线程内共享数据而无需通过每个方法参数传递。但时代在变,Java也在演进。随着虚拟线程和Project Loom的出现,传统的ThreadLocal方式已显陈旧。作用域值(Scoped Values)应运而生:一种更简洁、高效且安全的替代方案,正在重塑我们对线程局部数据的认知。

ThreadLocal的问题:为何需要变革

想象一下:你正在构建一个处理数千个并发请求的Web应用程序。每个请求需要在处理生命周期(从Web控制器到服务层,再到数据访问组件)中携带用户认证信息。传统做法是使用ThreadLocal。

public class ThreadLocalExample {
    private static final ThreadLocal<String> userContext = new ThreadLocal<>();

    public static void main(String[] args) {
        userContext.set("Admin");
        System.out.println("User: " + userContext.get());
        userContext.remove(); // 千万别忘记这一步!
    }
}

但问题在于:ThreadLocal在现代应用中存在以下日益突出的缺陷:

  1. 内存泄漏隐患
    必须手动调用remove(),否则可能导致内存泄漏。忘记清理?随着线程不断累积废弃值,你的应用会逐渐耗尽内存。这就像离开每个房间后都不关灯——最终会导致电路过载。

  2. 可变性混乱
    ThreadLocal值可在任意位置、任意时间被修改。这种“远距离幽灵作用”(借用爱因斯坦描述量子力学的比喻)使得调试极其困难。哪个方法修改了值?何时修改?为何修改?在复杂的调用链中追踪这些问题如同大海捞针。

  3. 昂贵的继承开销
    当父线程使用InheritableThreadLocal创建子线程时,整个线程局部映射会被复制。对于虚拟线程(可能同时存在10万个线程),这种内存压力将无法承受。

  4. 生命周期模糊
    ThreadLocal值会持续存在于线程的整个生命周期,除非显式移除。数据应在何处可访问、何处不可访问的边界并不清晰。

作用域值:我们需要的解决方案

作为Java 20的孵化器功能引入,并在Java 21和22中持续优化,作用域值解决了ThreadLocal的所有痛点,同时为虚拟线程时代量身定制。

作用域值的不同之处
可将作用域值视为ThreadLocal更智能、更规范的“兄弟”。其核心变革如下:

import java.lang.ScopedValue;

public class ScopedValuesExample {
    private static final ScopedValue<String> USER_CONTEXT = ScopedValue.newInstance();

    public static void main(String[] args) {
        ScopedValue.where(USER_CONTEXT, "Admin").run(() -> {
            System.out.println("User: " + USER_CONTEXT.get());
            processRequest(); // 嵌套调用中值仍可访问
        });
        // USER_CONTEXT 在此自动清理!无需手动清除
    }

    private static void processRequest() {
        System.out.println("仍可访问: " + USER_CONTEXT.get());
    }
}

这里发生了什么?值“Admin”仅在run() lambda作用域内绑定到USER_CONTEXT。一旦作用域结束,值会自动消失。无需手动清理,没有内存泄漏,只有清晰可靠的行为。

核心原则:为何作用域值更优

  1. 设计上的不可变性
    一旦将值绑定到ScopedValue,在该作用域内就无法更改。这不是限制,而是特性——它消除了因意外变更导致的整类错误。
ScopedValue.where(USER_CONTEXT, "Admin").run(() -> {
    // 不存在set()方法!
    // 值在整个作用域内保持为"Admin"
    callServiceLayer();
    callDataAccessLayer();
    // 所有方法看到相同且不变的值
});
  1. 显式的有限生命周期
    代码的语法结构清晰展示了数据的可访问范围。看到那些花括号了吗?那就是作用域,也是值的存活范围。超出之后,值即消失。

这不仅是内存管理问题,更关乎认知负荷。你可以一眼理解数据流,无需在代码中费力追踪ThreadLocal值可能被修改或清理的位置。

  1. 极速性能
    由于不可变性,JVM可以积极优化作用域值的访问。无论方法调用嵌套多深,通过get()读取作用域值的速度通常堪比读取局部变量。该实现采用轻量级缓存机制,使得重复访问几乎零开销。

对虚拟线程而言,这一点至关重要。在可能存在数百万个并发线程的情况下,每个字节和每个CPU周期都至关重要。

  1. 零成本继承
    当将作用域值与结构化并发(Java 21的另一预览功能)结合使用时,子线程会自动继承父线程的值。但神奇之处在于:由于值不可变,没有复制开销,本质上只是传递指针。
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

ScopedValue.where(REQUEST_ID, "REQ-12345").run(() -> {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // 子线程自动看到REQUEST_ID
        scope.fork(() -> processPartA());
        scope.fork(() -> processPartB());
        scope.join();
    }
});

processPartA()processPartB()都能看到“REQ-12345”且无需任何复制。用ThreadLocal可无法高效实现这一点!

实际案例:构建Web框架上下文

让我们通过一个实际场景(处理包含用户认证和事务管理的Web请求)来展示作用域值的优势:

public class WebFramework {
    private static final ScopedValue<Principal> LOGGED_IN_USER = ScopedValue.newInstance();
    private static final ScopedValue<Connection> DB_CONNECTION = ScopedValue.newInstance();

    public void handleRequest(Request request) {
        Principal user = authenticate(request);
        Connection conn = getConnection();

        ScopedValue.where(LOGGED_IN_USER, user)
                   .where(DB_CONNECTION, conn)
                   .run(() -> {
                       processRequest(request);
                   });

        // conn和user自动清理
    }

    private void processRequest(Request request) {
        // 调用链中的任何方法均可访问这些值
        Principal currentUser = LOGGED_IN_USER.get();
        Connection db = DB_CONNECTION.get();

        // 业务逻辑在此处理
        serviceLayer.process();
        dataAccessLayer.save();
    }
}

注意我们无需进行以下操作:

  • 无需通过每个方法参数传递user和conn
  • 无需手动清理代码
  • 无需担心其他方法修改这些值
  • 即使抛出异常也不会内存泄漏

框架处理请求、绑定必要上下文、通过各层处理请求,并在完成后自动清理。非常优雅。

多线程示例:随机数生成

以下完整示例展示不同线程如何获取各自的作用域值:

import java.lang.ScopedValue;
import java.util.concurrent.Executors;

public class MultiThreadExample {
    private static final ScopedValue<Integer> RANDOM_NUMBER = ScopedValue.newInstance();

    public static void main(String[] args) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10; i++) {
                executor.submit(() -> {
                    int randomValue = (int) (Math.random() * 100);
                    ScopedValue.where(RANDOM_NUMBER, randomValue).run(() -> {
                        System.out.println(Thread.currentThread().getName() + 
                                         ": 随机数: " + RANDOM_NUMBER.get());
                        doSomeWork();
                    });
                });
            }
        }
    }

    private static void doSomeWork() {
        // 此处仍可访问RANDOM_NUMBER
        System.out.println("正在处理: " + RANDOM_NUMBER.get());
    }
}

每个虚拟线程获取自己的随机数,并在其作用域内可访问。简单、清晰、高效。

作用域值 vs. ThreadLocal:何时使用

以下情况,使用作用域值

  • 使用虚拟线程(Project Loom)
  • 需要共享不可变上下文数据(用户信息、请求ID、事务上下文)
  • 需要自动清理和有限生命周期
  • 使用结构化并发并需要高效的子线程继承
  • 希望代码更易理解和维护

以下情况,继续使用ThreadLocal

  • 需要真正的每线程可变存储
  • 缓存创建成本高的对象(如DateFormat实例)
  • 处理无法重构的遗留系统
  • 数据确实需要在线程整个生命周期内持久存在

技术深度解析:实际工作原理

在底层,作用域值采用复杂但轻量的实现,涉及两个关键组件:

  • 载体(Carrier):保存ScopedValue与其实际值之间的绑定
  • 快照(Snapshot):在特定时间点捕获所有绑定的状态

当你调用ScopedValue.where(KEY, value).run(...)时,JVM会创建一个新的载体来关联该键值对。ScopedValue对象本身如同映射键——一个用于在载体栈中查找值的唯一指针。

其精妙之处在于缓存:首次调用get()时,会搜索外围作用域以找到绑定,然后将结果缓存到小型线程局部缓存中。后续访问速度极快——可能堪比读取局部变量。

专业提示:如需绑定多个值,可创建记录类来保存它们,并将单个ScopedValue绑定到该记录实例。这能最大化缓存效率:

record RequestContext(Principal user, Connection db, String requestId) {}

private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();

ScopedValue.where(CONTEXT, new RequestContext(user, conn, "REQ-123"))
           .run(() -> processRequest());

现状与未来

截至2025年11月,作用域值已从孵化器(Java 20)经过预览状态(Java 21、22),并有望成为永久功能。该API在多个预览周期中保持稳定,表明其已接近正式发布。

在Java 21-23中使用作用域值,当前需要启用预览功能:

javac --release 23 --enable-preview YourProgram.java
java --enable-preview YourProgram

一旦功能定稿(可能在Java 24或25),将不再需要预览标志。

结论

作用域值代表了对多线程Java应用中数据共享方式的根本性重新思考。通过拥抱不可变性、显式作用域和性能优化,它们解决了ThreadLocal的所有主要弱点,同时完美适应虚拟线程革命。

你无需急于替换代码库中的每个ThreadLocal。但对于新代码——尤其是为虚拟线程和结构化并发设计的代码——作用域值应是默认选择。它们更安全、更快速、更清晰、更易维护。

明确的信息是:对于面向虚拟线程和结构化并发的新Java应用,作用域值应成为共享上下文数据的首选。它们使代码更安全、更快速,且显著更易理解。

Java并发的未来已至,而且拥有精美的作用域。

本文要点总结

在服务超过25年后,ThreadLocal在现代Java应用中显现出明显局限性。手动清理要求带来内存泄漏风险,可变性导致意外状态变化,继承机制开销昂贵,生命周期模糊不清——这些问题在使用虚拟线程时尤为突出。

作用域值通过其核心设计解决了这些问题。它们提供自动生命周期管理以消除内存泄漏顾虑,不可变性防止意外变更,有限作用域使数据生命周期显式化。性能表现强劲,通过优化缓存实现快速访问,高效的继承机制允许与子线程零成本共享值。

编程模型以显式作用域为核心。值通过ScopedValue.where(KEY, value).run(...)绑定,在嵌套方法调用中自动可访问,并在作用域结束时自动清理。这种方法与虚拟线程(JEP 444)和结构化并发(JEP 453)良好集成,为现代并发编程创建了连贯的基础。

性能特性适合实际使用。通过智能缓存,读取作用域值的速度接近局部变量,轻量级实现可处理数百万虚拟线程而无ThreadLocal的内存压力。常见用例包括Web请求上下文、用户认证数据、事务管理、请求追踪,以及任何需要通过调用链传播不可变上下文的场景。

作用域值和ThreadLocal之间的选择取决于需求。作用域值适用于现代代码中的不可变上下文共享,而ThreadLocal仍适用于每线程可变缓存和遗留系统。该功能从Java 20的孵化器状态演进至Java 21-23的预览阶段,API稳定性表明它即将成为永久功能。

当需要绑定多个值时,将它们分组到记录类并绑定单个ScopedValue可最大化缓存效率并简化代码。更广泛地说,作用域值代表了向更安全、更易理解的并发编程的转变,与Java向轻量级、高并发应用发展的方向一致。


【注】本文译自:Scoped Values: The Modern Alternative to ThreadLocal That Java Developers Have Been Waiting For