跳到主要内容

JEP 373: 重新实现传统的 DatagramSocket API

QWen Max 中英对照 JEP 373: Reimplement the Legacy DatagramSocket API

总结

替换 java.net.DatagramSocketjava.net.MulticastSocket API 的底层实现,采用更简单、更现代化且易于维护和调试的实现方式。新的实现将易于调整以适配虚拟线程(目前正在 Project Loom 中探索)。这是对 JEP 353 的后续改进,后者已经重新实现了传统的 Socket API。

动机

java.net.DatagramSocketjava.net.MulticastSocket API 及其底层实现的代码库年代久远且脆弱:

  • 这些实现可以追溯到 JDK 1.0。它们是遗留的 Java 和 C 代码的混合体,难以维护和调试。

  • MulticastSocket 的实现尤其存在问题,因为它起源于 IPv6 仍在开发中的时代。许多底层的本地实现试图以难以维护的方式协调 IPv4 和 IPv6。

  • 该实现还存在多个并发问题(例如,异步关闭),需要进行彻底的修改才能正确解决。

此外,在虚拟线程的上下文中,它们选择“停车(park)”而不是在系统调用中阻塞底层的内核线程,当前的实现并不符合需求。随着基于数据报的传输协议再次受到关注(例如 QUIC),我们需要一种更简单且更易于维护的实现。

描述

目前,DatagramSocketMulticastSocket 类将所有套接字调用委托给一个 java.net.DatagramSocketImpl 实现,针对该实现存在不同的平台特定具体实现:在 Unix 平台上是 PlainDatagramSocketImpl,而在 Windows 平台上则是 TwoStackPlainDatagramSocketImplDualPlainDatagramSocketImpl。这个抽象的 DatagramSocketImpl 类可以追溯到 JDK 1.1,其规范非常不完善,并且包含一些过时的方法,这些方法成为基于 NIO 提供此类实现的障碍(有关替代方案,见下文讨论)。

此 JEP 并未提出为 DatagramSocketImpl 的实现提供类似于 JEP 353 中针对 SocketImpl 所做的直接替代方案,而是建议让 DatagramSocket 在内部包装另一个 DatagramSocket 实例,并将所有调用直接委托给它。被包装的实例可以是一个由 NIO 的 DatagramChannel::socket 创建的套接字适配器(新的实现),或者是旧版 DatagramSocket 类的一个克隆,后者随后会委托给旧版 DatagramSocketImpl 实现(目的是实现向后兼容开关)。如果应用程序安装了 DatagramSocketImplFactory,则会选择旧版实现;否则,默认选择并使用新的实现。

为了减少在二十多年后切换实现的风险,将保留旧的实现方式。引入了一个 JDK 特定的系统属性 jdk.net.usePlainDatagramSocketImpl,用于配置 JDK 使用旧的实现(参见下文的风险和假设)。如果在启动时未设置值或将其设置为值 "true",则使用旧的实现。否则,将使用新的(基于 NIO 的)实现。在未来的某个版本中,我们将移除旧的实现以及该系统属性。在某个时候,我们也可能会弃用并移除 DatagramSocketImplDatagramSocketImplFactory

新的实现默认启用。它通过直接使用选择器提供程序的平台默认实现(sun.nio.ch.SelectorProviderImplsun.nio.ch.DatagramChannelImpl),为数据报和多播套接字提供了不可中断的行为。因此,安装自定义选择器提供程序对 DatagramSocketMulticastSocket 不会产生任何影响。

替代方案

我们调查、制作了原型并丢弃了两种替代方法。

替代方案 1

创建一个 DatagramSocketImpl 的实现,该实现将其所有调用委托给一个包装的 DatagramChannelsun.nio.ch.DatagramSocketAdaptor。升级 sun.nio.ch.DatagramSocketAdaptor 以扩展 java.net.MulticastSocket

这种方法表明,基于 DatagramChannel 提供一个 DatagramSocketImpl 的实现相对较容易。测试通过了,但也突显出了一些限制:

  • 安全检查执行了两次,一次在 DatagramSocket 中,另一次在 DatagramChannel(或其套接字适配器)中。虽然有一些方法可以避免双重安全检查,但它们会显得繁琐。

  • DatagramSocket 层级实现的连接模拟也成为了阻碍,因为我们不希望在基于 NIO 的实现中执行这种模拟。

  • 正如上面提出的解决方案一样,此替代方案相较于下面的第二种替代方案的主要优势在于不需要新的原生代码,因为每个调用都可以委托给 DatagramChannel

  • 在评估这一替代方案时,很快便发现,在 DatagramSocket 层级而不是在 DatagramSocketImpl 层级重写方法会更简单和直接,这导致了本 JEP 中提出的解决方案。

替代方案 2

sun.nio.ch 包中创建一个调用底层 sun.nio.ch.Net 原语的 DatagramSocketImpl 实现。这使得该实现能够直接访问较低层的 NIO 原语,而无需依赖 DatagramChannel。这一做法在某种程度上类似于在 JEP 353 中重新实现 SocketServerSocket 的方式。

  • 该替代方案与第一个替代方案相比的主要优势在于,它避免了双重安全检查,因为该实现可以直接访问更底层的 NIO 原语。

  • 然而,新的实现必须复制 DatagramChannel 已经实现的非平凡状态和锁管理。

  • 它还需要添加新的本地代码以匹配 DatagramSocketImpl 接口。

  • 因此,此 JEP 中提出的解决方案显得更加简单、风险更低且更易于维护。

测试

jdk/jdk 仓库中现有的测试将用于测试新的实现。为确保平稳过渡,新实现应通过 tier2(jdk_netjdk_nio)回归测试套件以及 java_net/api 的 JCK 测试。多年来,jdk_net 测试组积累了大量针对网络边缘情况的测试。该测试组中的某些测试将被修改为运行两次,第二次使用 -Djdk.net.usePlainDatagramSocketImpl,以确保在 JDK 同时包含两种实现期间,旧实现不会退化。将根据需要添加新的测试,以扩大代码覆盖率并增强对新实现的信心。

将尽一切努力宣传该提案,并鼓励使用 DatagramSocketMulticastSocket 的开发者使用发布在 jdk.java.net 上的早期访问版本来测试他们的代码。

jdk/jdk 仓库中的微基准测试包括了对 DatagramChannel 的基准测试。如果缺少针对数据报套接字的类似基准测试,将会创建;如果已经存在,则会进行更新,以便于比较新旧实现之间的差异。

风险与假设

此提案的主要风险在于,现有代码依赖于在极端情况下未指定的行为,而新旧实现方式在这些情况下表现不同。为了尽量减少这种风险,一些澄清 DatagramSocketMulticastSocket 规范的准备工作,以及尽量减少这些类与 DatagramChannel::socket 适配器之间的行为差异的工作,已经在 JDK 14 和 JDK 15 中完成。然而,以下列出的一些微小差异可能仍然存在。这些差异在极端情况中可能会被观察到,但对绝大多数 API 用户来说应该是透明的。我们目前发现的差异列在这里;除了前两个差异外,其他差异都可以通过使用 -Djdk.net.usePlainDatagramSocketImpl-Djdk.net.usePlainDatagramSocketImpl=true 来缓解。

  • 自定义 API 或 DatagramSocketMulticastSocket 的子类如果在这些类的实例上进行同步,可能需要重新审视,因为 DatagramSocketMulticastSocket 不再在 this 上同步。任何锁定或同步都由委托对象负责,该委托对象无法在 java.net 包外访问,并且可以自由使用其认为合适的任何机制。

  • 同样,扩展 DatagramSocketMulticastSocket 并重写诸如 bindsetReuseAddress 等方法的自定义类,在构造期间不会调用被重写的方法。依赖此行为的人正在依赖未记录且特定于实现的行为。

  • 新的实现会在所有平台上使用原生的 connect 方法。旧的实现仍然在 macOS 上使用模拟。这意味着,特别是端口不可达的情况在旧实现中无法检测到,而在新实现中应该能够检测到。此外,如果原生 connect 失败,旧实现将回退到使用模拟;而新实现将报告错误。另外,新实现在连接时会刷新接收缓冲区,确保在调用 connect 之前缓冲的任何数据报都被丢弃。旧实现过去会保留由已连接对等方发送并在内核执行关联之前缓冲的数据报,但新实现将简单地丢弃它们。

  • 在 macOS 和 Linux 上,对新实现调用 disconnect 可能需要重新绑定底层套接字。这引入了重新绑定可能失败的可能性,并且底层实现可能会抛出异常,使底层套接字处于未指定状态。而旧实现可能默默地使套接字处于未指定状态,新实现则会抛出 UncheckedIOException

  • 在 macOS 上加入多播组时,如果没有设置默认的传出接口,并且没有提供传出网络接口,MulticastSocket::joinGroup 的旧实现在加入之前会选择一个默认的网络接口并错误地尝试将其设置为默认值,通过默默地设置 IP_MULTICAST_IF 选项。基于 NIO 的新实现不会这样做,因此 IP_MULTICAST_IF 选项永远不会作为加入的副作用被默默设置。

  • java.net 包定义了许多 SocketException 的子类。新实现将尝试在与旧实现相同的情况下抛出相同的异常,但可能存在不完全相同的情况。此外,可能存在异常消息不同的情况。例如,在 Windows 上,旧实现将 Windows 套接字错误代码映射为仅限英文的消息,而新的基于 NIO 的实现使用系统消息。

其他可观察到的行为差异:

  • 通过其公共构造函数创建的 DatagramSocket 支持为发送多播数据报设置选项。新的实现允许你在所有平台上的 DatagramSocket 基础实例上配置多播套接字选项。旧的实现在 Windows 上仍然使用双栈实现,而该实现不支持在基础 DatagramSocket 实例上设置多播套接字选项。在这种情况下,如果需要配置这些选项,则必须使用 MulticastSocket 的实例。

  • 新的实现修复了许多问题,例如 8165653,这仅仅是因为委托给了不存在这些问题的 NIO 实现。

除了行为差异之外,在运行某些工作负载时,新实现的性能可能与旧实现有所不同。本 JEP 将努力提供一些性能基准测试,以评估差异。

依赖

  • 替换 DatagramSocketMulticastSocket 的底层实现是 Project Loom 的先决条件。