Skip to content

JEP 451: Prepare to Disallow the Dynamic Loading of Agents | 准备禁止动态加载代理

摘要

当代理被动态加载到正在运行的 JVM 中时发出警告。这些警告旨在让用户为将来的版本做好准备,该版本默认不允许动态加载代理,以便 默认提高完整性。在启动时加载代理的可服务性工具在任何版本中都不会引发警告。

目标

  • 为将来的 JDK 版本做好准备,该版本默认不允许将代理加载到正在运行的 JVM 中。

  • 重新评估可服务性(涉及对运行代码的临时更改)与完整性(假设运行代码不会任意更改)之间的平衡。

  • 确保大多数不需要动态加载代理的工具不受影响。

  • 使动态加载代理的能力与其他所谓的“超能力”功能(如 深度反射)保持一致。

非目标

  • 目标并非阻止通过 -javaagent-agentlib 命令行选项在 JVM 启动时加载代理,也并非在如此使用时发出警告。

  • 目标并非弃用或删除 Attach API 中动态加载代理的部分;目标只是为默认不允许使用它们做好准备。

  • 目标并非更改 Attach API 中允许可服务性工具连接到正在运行的 JVM 进行监控和管理的部分。jcmdjconsole 等工具将继续工作,无需命令行选项,也不会发出警告。

动机

Java 平台中的代理

代理 是一个组件,可以在应用程序运行时更改应用程序的代码。JDK 5 中通过 Java 平台剖析架构 引入了代理,作为工具(尤其是剖析器)对类进行 插桩 的一种方式。这意味着更改类中的代码,使其发出事件以供应用程序外部的工具使用,而不改变代码的其他行为。代理通过在类加载期间转换类,或重新定义之前加载的类来实现这一点。它们可以使用 java.lang.instrument API(“Java 代理”)编写为 Java 代码,或使用 JVM 工具接口(“JVM TI 代理”)编写为本机代码。

设计代理时考虑的是良性插桩,即插桩的添加不会影响应用程序的行为。然而,高级开发人员发现了诸如 面向切面编程 等用例,这些用例可以以任意方式改变应用程序的行为。同样,没有任何东西可以阻止代理更改应用程序外部的代码,例如 JDK 本身的代码。为了确保应用程序所有者批准使用代理,JDK 5 要求在命令行上使用 -javaagent-agentlib 选项指定代理,并在启动时立即加载代理。这代表了应用程序所有者明确授予的权限。

可服务性和动态加载的代理

可服务性 是指系统操作员在应用程序运行时对其进行监控、观察、调试和故障排除的能力。Java 平台的出色可服务性长期以来一直是其引以为豪的源泉。

为了支持可服务性工具,JDK 6 引入了 Attach API。Attach API 不是 Java 平台的一部分,而是 JDK API,支持外部使用。它允许具有适当操作系统权限的工具连接到本地或远程运行的 JVM,并与该 JVM 通信以观察和控制其操作。Attach API 默认启用,但可以通过命令行上的 -XX:+DisableAttachMechanism 选项禁用。

使用 Attach API 的工具示例包括:

  • 监控和管理工具,如 jcmdjconsole,它们观察应用程序指标并更改配置。例如,如果应用程序使用 java.util.logging API,则操作员可以使用 jconsole 动态更改日志级别。这些工具使用了专门的 jcmd 协议、JMXJDK Flight Recorder (JFR)

  • 调试器,它们需要在启动时通过 -agentlib:jdwp 选项启用的内置于 JVM 中的代理。然后,它们通过某种 IPC 通道与代理通信,但也可以利用 Attach API。

  • 剖析器,以及更一般的 应用程序性能监控(APM)工具,它们在启动时加载代理以对应用程序代码进行插桩,以便发出 JFR 事件供 JDK Mission Control 或其他客户端使用。

Attach API 还允许工具将代理动态加载到运行的 JVM 中。此功能支持涉及动态更改任意代码的高级用例。动态加载代理的工具示例包括:

  • 剖析器,它们连接到运行的 JVM 并动态加载代理以对应用程序代码进行插桩。

  • 临时故障排除工具,它们在运行时读取和写入应用程序状态。动态加载的代理使用 JVM TI 检查运行程序的状态,或转换和插桩已加载的类。

(非常高级的开发人员有时会通过在生产环境中编写一个修补错误代码的代理并动态加载该代理来修复错误。然而,这不是一个受支持的用例,也从未被推荐过。代理重新定义已加载类的能力 受到限制,因此通过修补来修复错误的能力是有限的。此外,代理无法持久保存其所做的更改,因此重新启动应用程序将还原更改。)

动态加载的代理为可服务性工具提供了改变正在运行的应用程序的超能力。然而,附加工具的操作是由具有适当操作系统凭据的人类操作员触发的。这个循环中的人类操作员授予了改变应用程序的权限,因此可服务性工具不受施加于其他代码上的完整性约束。因此,默认情况下允许代理的动态加载,尽管在 JDK 9 及以上版本中,可以通过命令行上的 -XX:-EnableDynamicAgentLoading 选项来禁止它。

代理和库

尽管库和工具在概念上存在关注点分离,但一些库提供了依赖于代理提供的代码修改超能力的功能。例如,模拟库可能会重新定义应用程序类以绕过业务逻辑的不变性,而白盒测试库可能会重新定义 JDK 类,以便始终允许对 private 字段的反射。为了获得这些功能,库可以使用一个代理,该代理从 JVM 获取一个功能强大的 Instrumentation 对象,并将其传递给库。

一些这样的库通过要求库的代理在命令行上使用 -javaagent 选项来指定,从而确保应用程序所有者授予改变应用程序的权限。这样做的库的一个例子是 Quasar,它是后来成为虚拟线程 (JEP 444) 的早期原型。

其他库则采取了更可疑的方法,未经应用程序所有者批准就获得了功能。它们使用 Attach API 静默地连接到它们在其中运行的 JVM,并动态加载代理,实际上伪装成可服务性工具。为了保持完整性,JDK 9 及更高版本默认阻止代码连接到当前 JVM。(可以通过 -Djdk.attach.allowAttachSelf=true 启用此类连接。)然而,这一措施证明是不够的:现在有些库会启动第二个 JVM,该 JVM 连接到第一个 JVM 并在那里加载代理,与库一起运行。

如果库使用代理静默地重新定义 JDK 类,从而绕过强封装,那么强封装强制执行的所有不变性都不可信。完整性将丢失。

迈向默认完整性

为了确保完整性,我们需要采取更强有力的措施来防止库滥用动态加载的代理。不幸的是,我们还没有找到一种简单且自动的方法来区分动态加载代理的可服务性工具和库。给予工具自由权限就意味着给予库自由权限,这等同于默认放弃完整性。

因此,我们提议要求应用程序所有者批准代理的动态加载——正如自 JDK 5 以来我们一直要求应用程序所有者批准代理的启动时加载一样。这一变化将使 Java 平台更接近 默认完整性 的长期愿景。从实际意义上讲,应用程序所有者必须通过命令行选项选择允许代理的动态加载。

幸运的是,大多数可服务性工具并不依赖于动态加载的代理。但是,默认情况下禁止代理的动态加载意味着需要动态加载代理的临时故障排除技术将不再开箱即用。如果需要动态加载代理,则必须重新启动 JVM 并带上适当的命令行选项以获得应用程序所有者的批准。

这一变化的影响将得到缓解,因为大多数现代服务器应用程序都设计为具有冗余性,因此可以根据需要使用命令行选项重新启动各个节点。特殊情况——例如,一个永不停机维护的 JVM,或是对新版本软件进行密切监控的金丝雀进程——通常可以提前识别出来,以便从一开始就启用代理的动态加载。

要求应用程序所有者批准动态加载的代理将使 Java 生态系统能够在不大幅限制可服务性的情况下实现默认完整性的愿景。

描述

在 JDK 21 中,允许动态加载代理,但当发生时 JVM 会发出警告。例如:

console
警告:已动态加载一个{Java,JVM TI}代理(file:/u/bob/agent.jar)
警告:如果正在使用可服务性工具,请使用-XX:+EnableDynamicAgentLoading运行以隐藏此警告
警告:如果未使用可服务性工具,请使用-Djdk.instrument.traceUsage运行以获取更多信息
警告:在未来的版本中,默认情况下将不允许动态加载代理

为了使工具能够无警告地动态加载代理,用户必须在命令行上运行 -XX:+EnableDynamicAgentLoading 选项。

使用 -Djdk.instrument.traceUsage 运行时,java.lang.instrument API 的方法在使用时会打印一条消息和堆栈跟踪。这有助于识别错误地使用动态加载代理而不是在启动时加载代理的库。鼓励动态加载代理的库维护者更新其文档,以说明用户如何在启动时加载代理;各种部署选项由 java.lang.instrument API 给出。

在未来的某个版本中,默认情况下将不允许动态加载代理。开箱即用,使用 Attach API 动态加载代理的任何用法都将导致抛出异常:

console
com.sun.tools.attach.AgentLoadException: 无法加载代理库:\
未启用动态代理加载。请使用-XX:+EnableDynamicAgentLoading\
来启动目标VM。

为了在默认情况下不允许动态加载代理时允许其加载,用户必须在命令行上运行 -XX:+EnableDynamicAgentLoading

为了准备未来版本中更改的默认设置,JDK 9 或任何更高版本的用户可以通过在命令行上运行 -XX:-EnableDynamicAgentLoading 来明确禁止动态加载代理。

在启动时加载代理的工具不受这些更改的影响。-javaagent 选项、-agentlib 选项和 Launcher-Agent-Class JAR 文件属性的含义和操作均未更改。

使用 Attach API 进行动态加载代理之外用途的工具不受这些更改的影响。

库不得动态加载代理。使用代理的库必须在启动时通过 -javaagent/-agentlib 选项加载它。

历史说明

默认情况下不允许动态加载代理最初是在 2017 年提出的,作为在 JDK 9 中将 模块 添加到平台的一部分。该提案是:

在未来的版本中,默认情况下将禁用 JVM TI 代理的动态加载。为了准备这一更改,我们建议允许动态代理的应用程序开始使用 -XX:+EnableDynamicAgentLoading 选项来明确启用该加载。

2017 年的共识是将这一更改从 JDK 9 推迟到后续版本,以便工具维护者有时间通知其用户。然而,过去我们在加强封装性时,会在前一个版本中发出警告,以便建立对即将进行的更改的认识。本 JEP 遵循相同的程序。

风险和假设

  • 我们假设大多数可服务性场景涉及使用 jcmdjconsole、调试器、JFR 和 APM 工具,这些工具不会动态加载代理,因此不会受到影响。

  • 我们假设动态加载代理的库维护者将更新其文档,要求应用程序所有者使用 -javaagent 选项在启动时加载代理,或者通过 -XX:+EnableDynamicAgentLoading 选项启用代理的动态加载。

未来工作

  • 高级分析器在分析本地代码时,仅使用 JVM TI 代理来访问可以支持分析的 HotSpot 内部机制。在生产环境中分析应用程序时,它们可能会动态加载代理。这种情况最好通过扩展 JFR 的功能来解决,以便在完全不需要代理的情况下执行任务。JFR 能够与 HotSpot 的 JIT 编译器合作,以比通过 JVM TI API 或高级分析器常用的内部未记录 AsyncGetCallTrace 方法所能提供的更高效的方式捕获大量堆栈跟踪。

  • 通过直接向库提供尊重封装的 Instrumentation 对象(无需代理),可以解决代码操作的一些有趣用例。这将允许库转换或重新定义对其模块开放的模块中的类。

替代方案

  • 默认情况下,仅对动态加载的本地 JVM TI 代理发出警告,并默认限制动态加载的 Java 代理的功能(即,当未指定 -XX:+EnableDynamicAgentLoading 选项时),以便在它们尝试修改命名模块中的类时发出警告,但允许它们修改未命名模块中的类而不发出警告。

    这种方法更复杂,并且不支持更多实用的工具代理。此外,它不排除 Java 代理使用 JNI 来为自己授予更多权限。

  • 采用一种身份验证机制,以区分由人类操作的工具和伪装成工具的库,默认情况下允许工具动态加载代理而不发出警告,但当库尝试动态加载代理时发出警告。

    我们已经探索了这些方面的几种方法,但所有方法要么复杂,要么需要在命令行上进行特殊设置,这不会减少对动态加载代理的工具的影响。