JEP 353:重新实现传统的套接字 API
概述
使用更简单、更现代的实现来替换 java.net.Socket
和 java.net.ServerSocket
API 的底层实现,这种新实现将易于维护和调试。新的实现将很容易适配用于用户模式线程(也称为 fibers),这是目前正在 Project Loom 中探索的内容。
动机
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 以来就存在的服务提供者接口(Service Provider Interface,SPI)机制。内置的实现被称为“普通”实现,由非公共的 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
)通过将套接字更改为非阻塞模式并轮询套接字来实现。 -
当
SocketImpl
被垃圾回收且套接字未被显式关闭时,会使用java.lang.ref.Cleaner
机制来关闭套接字。 -
连接重置处理的实现方式与旧实现相同,以确保在连接重置后尝试读取时会一致失败。
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
的连接。 -
旧实现返回的
InputStream
和OutputStream
会先测试流是否到达文件末尾(EOF)并返回 -1,然后再进行其他检查。而新实现在检查流是否到达 EOF 之前会先进行null
和边界检查。虽然可能存在因检查顺序不同而导致问题的脆弱代码,但这种情况不太可能。 -
在接收队列中有未读字节的情况下关闭
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 Socket 错误代码映射为仅限英文的消息,而新实现则使用系统消息。
除了行为差异之外,在运行某些工作负载时,新实现的性能可能与旧实现有所不同。在旧的实现中,多个线程在 ServerSocket
上调用 accept
方法时会在内核中排队。而在新的实现中,一个线程将在 accept
系统调用中阻塞,其他线程则会排队等待获取一个 java.util.concurrent
锁。在其他场景中,性能特征也可能有所不同。
最后,可能会有一些检测代理或工具对非公共的 java.net.SocketInputStream
和 java.net.SocketOutputStream
类进行检测以获取 I/O 事件。这些类并没有被新的实现方式使用。