JEP 353: Reimplement the Legacy Socket API | 重新实现传统套接字 API
摘要
用更简单、更现代的实现来替换 java.net.Socket
和 java.net.ServerSocket
API 所使用的底层实现,这种新实现易于维护和调试。新的实现将很容易适应与当前在 Project Loom 中探索的用户模式线程(也称为 fibers)一起工作。
动机
java.net.Socket
和 java.net.ServerSocket
API 及其底层实现可以追溯到 JDK 1.0。这些实现是遗留的 Java 和 C 代码的混合体,难以维护和调试。该实现使用线程栈作为 I/O 缓冲区,这种方法已经多次导致需要增加默认的线程栈大小。该实现使用了一个本地数据结构来支持异步关闭,这是多年来可靠性和可移植性问题的根源。此外,该实现还存在一些并发问题,需要彻底修改才能妥善解决。在未来使用 fibers(而不是在本地方法中阻塞线程)的世界中,当前的实现已经不再适用。
描述
java.net.Socket
和 java.net.ServerSocket
API 将所有套接字操作委托给 java.net.SocketImpl
,这是一个自 JDK 1.0 以来就存在的服务提供者接口(SPI)机制。内置的实现被称为“plain”实现,由非公开的 PlainSocketImpl
以及支持类 SocketInputStream
和 SocketOutputStream
实现。PlainSocketImpl
被 JDK 内部的其他两个实现所扩展,这两个实现支持通过 SOCKS 和 HTTP 代理服务器进行连接。默认情况下,Socket
和 ServerSocket
(有时为延迟创建)会使用基于 SOCKS 的 SocketImpl
创建。对于 ServerSocket
来说,使用 SOCKS 实现是一个历史遗留的奇特现象,可以追溯到 JDK 1.4 中对服务器连接代理的实验性(且之后被移除)支持。
新的实现 NioSocketImpl
是 PlainSocketImpl
的即插即用替代品。它被开发为易于维护和调试。它与新 I/O(NIO)实现共享相同的 JDK 内部基础设施,因此不需要自己的本地代码。它与现有的缓冲缓存机制集成,因此不需要使用线程栈进行 I/O。它使用 java.util.concurrent
锁而不是 synchronized
方法,以便将来能够与 fibers 很好地协作。在 JDK 11 中,NIO 的 SocketChannel
和其他 SelectableChannel
实现大多也带着相同的目标进行了重新实现。
以下是关于新实现的几个要点:
SocketImpl
是一个遗留的 SPI 机制,且规范非常不详细。新实现试图通过模拟未指定的行为和异常(在适用的情况下)来与旧实现兼容。下面的“风险和假设”部分详细说明了新旧实现之间的行为差异。使用超时的套接字操作(
connect
、accept
、read
)是通过将套接字更改为非阻塞模式并轮询套接字来实现的。使用了
java.lang.ref.Cleaner
机制,当SocketImpl
被垃圾回收且套接字尚未被显式关闭时,该机制会负责关闭套接字。连接重置的处理方式与旧实现相同,以确保在连接重置后尝试读取会一致地失败。
ServerSocket
被修改为默认使用 NioSocketImpl
(或 PlainSocketImpl
)。它不再使用 SOCKS 实现。
支持 SOCKS 和 HTTP 代理服务器的 SocketImpl
实现被修改为委托模式,以便它们可以与旧实现和新实现一起工作。
Java Flight Recorder 中的套接字 I/O 的仪器化支持被修改为不依赖于 SocketImpl
,以便在运行新实现、旧实现或自定义实现时都能记录套接字 I/O 事件。
为了减少在二十多年后切换实现的风险,旧实现将不会被移除。旧实现将保留在 JDK 中,并将引入一个系统属性来配置 JDK 使用旧实现。用于切换到旧实现的特定于 JDK 的系统属性是 jdk.net.usePlainSocketImpl
。如果在启动时设置该属性,或将其值设置为 true
,则会使用旧实现。某些未来的版本将移除 PlainSocketImpl
和该系统属性。
本 JEP 目前不提议提供 DatagramSocketImpl
的替代实现(DatagramSocketImpl
是 java.net.DatagramSocket
实例委托给的基础实现)。内置的默认实现(PlainDatagramSocketImpl
)是一个维护和(跨平台)移植的负担,可能会成为另一个 JEP 的主题。
测试
现有的 jdk/jdk
仓库中的测试将用于测试新实现。多年来,jdk_net
测试组已经积累了大量针对网络边缘情况的测试。该测试组中的某些测试将被修改以运行两次,第二次使用 -Djdk.net.usePlainSocketImpl
以确保在 JDK 同时包含新旧实现期间,旧实现不会“腐烂”。
当今许多代码直接或间接地使用那些使用 java.nio.channels
中定义的 API 而不是 java.net.Socket
和 java.net.ServerSocket
API 的库。我们将尽一切努力提高对该提案的认识,并鼓励使用 Socket
和 ServerSocket
的开发者使用发布在 jdk.java.net 或其他地方的早期访问构建来测试他们的代码。
jdk/jdk
仓库中的微基准测试包括套接字读写和流传输的基准测试。这些基准测试已经得到了改进,以便轻松比较新旧实现。目前的情况是,新实现在套接字读写测试上与旧实现大致相同或好 1-3%。
风险与假设
本提案的主要风险在于,现有代码可能依赖于在特定情况下(新旧实现行为不同时)未明确指定的行为。目前已识别的差异列在这里;除了前两个差异外,其余差异都可以通过使用 -Djdk.net.usePlainSocketImpl
来减轻。
PlainSocketImpl
的getInputStream()
和getOutputStream()
方法返回的InputStream
和OutputStream
分别扩展自java.io.FileInputStream
和java.io.FileOutputStream
。存在依赖于此的现有代码是可能的,但不太可能。使用自定义
SocketImpl
的ServerSocket
无法接受返回带有平台SocketImpl
的Socket
的连接。类似地,使用平台SocketImpl
的ServerSocket
无法接受返回带有自定义SocketImpl
的Socket
的连接。旧实现在测试流是否到达 EOF(文件结束符)之前会先测试流是否已到达 EOF 并返回 -1。新实现会先进行
null
和边界检查,然后再检查流是否到达 EOF。存在因检查顺序问题而出错的脆弱代码是可能的,但不太可能。在接收队列中还有未读字节的情况下关闭
Socket
将优雅地关闭底层套接字。在 Microsoft Windows 以外的平台上,使用旧实现进行相同操作将导致中止 / 硬关闭。Oracle Solaris 特定:Oracle Solaris 在向应用程序报告“连接重置”的方式上与其他平台不同。例如,当网络错误发生时,对
setsockopt
或ioctl
的调用可能会失败。可以通过在/etc/system
中配置xnet_skip_checks
设置来禁用此行为(在实时系统上,使用echo "xnet_skip_checks/W 1" | mdb -kw
)。旧实现在ioctl(FIOREAD)
失败时处理这种情况,以便在available
失败后尝试读取将始终失败并显示“连接重置”。这是脆弱且不可维护的,新实现不会尝试模拟此行为。Oracle Solaris 特定:Oracle Solaris 不允许在 TCP 套接字连接后更改
IPV6_TLCASS
套接字选项。旧实现通过缓存setTrafficClass
方法指定的值来隐藏这一点。java.net
包定义了SocketException
的许多子类。新实现将尝试抛出与旧实现相同的特定SocketException
,但可能存在它们不相同的情况。此外,异常消息也可能有所不同。例如,在 Microsoft Windows 上,旧实现将 Windows 套接字错误代码映射为仅英语的消息,而新实现使用系统消息。
除了行为差异外,新实现在运行某些工作负载时的性能可能与旧实现不同。在旧实现中,多个线程在 ServerSocket
上调用 accept
方法将在内核中排队。在新实现中,一个线程将在 accept
系统调用中阻塞,其他线程将排队等待获取 java.util.concurrent
锁。在其他场景下,性能特性也可能有所不同。
最后,可能存在一些检测代理或工具,它们对非公开的 java.net.SocketInputStream
和 java.net.SocketOutputStream
类进行插桩以获取 I/O 事件。这些类在新实现中并未使用。