上一节我们介绍了什么事 NTP 时间,本章开始我们来具体看看怎么获取 NTP 时间,以及如何从网络的相关知识出发去优化这个 NTP 时钟同步器。 另外 这里放了这里面用到的Java代码,文章中为了逻辑清晰,特意采用了伪代码,所以这里放了这里面用到的Java代码
一、初窥 NTP Client
我们先来看最原始的 NTP 时间获取方法
java
public class NTPClient {
public static void getNTPTime(){
try {
NTPUDPClient client = new NTPUDPClient();
// 设置超时时间
client.setDefaultTimeout(5000);
// 公共NTP服务器
InetAddress server = InetAddress.getByName("pool.ntp.org");
// 发送请求
TimeInfo timeInfo = client.getTime(server);
// 计算延迟和偏移量
timeInfo.computeDetails();
// 本地时钟偏差(毫秒)
long offset = timeInfo.getOffset();
System.out.println("服务器时间: " + timeInfo.getMessage().getTransmitTimeStamp().toDateString());
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
getNTPTime();
}
}
}
但是这连一百个请求都使用不了,谈何业务上的使用?
这就自然而然的引出了几个问题:
- 为什么这样子性能差?性能差的底层原理是什么?
- 我们怎么去优化?通过哪几种方式去优化?
(一) 为什么这样子性能差?
其实在这里看可以看出主要是因为 5 个方面:
- 客户端实例重复创建 :每次调用
getNTPTime()
都新建NTPUDPClient
实例,导致100次循环中产生100次UDP套接字创建/销毁开销。增加了系统调用和资源分配消耗(如端口占用、内核缓冲区申请)。 - 高频率短间隔发送请求,就算端口你是复用的,但也会很快
- 打满 Socket 缓冲区。
- 单线程阻塞模型,同步阻塞的 getTime() 方法导致每次循环必须等待 5 秒超时或者响应。我们可以看出在网络波动最大的时候,100 次请求可以达到 500 s 的延迟。
- 到达机子的时候,会首先存放在内核缓冲区,然后再拷贝到堆内存中。
- 程序通过 UDP 向 NTP 发起网络请求,包括:创建 Socket,DNS 解析,数据包收发以及相对应的系统调用
等等等
这似乎很多,我们是否可以复用一些东西,减少一些东西来提升性能呢?
本篇我们先从网络的角度来思考怎么提升性能。
二、使用 NIO 来提升
(一) 连接复用
这里 DNS 解析进行缓存,和将客户端设置为全局变量这两个个最简单改变开始进行优化。
我们先来弄明白为什么这两个问题会存在性能瓶颈
每次循环都新建NTPUDPClient
实例会触发UDP套接字的创建和销毁过程。尽管UDP是无连接的协议,但底层操作仍需通过系统调用创建套接字描述符并绑定端口。
而频繁的套接字操作会导致:1️⃣系统文件描述符耗尽风险(尤其在并发场景下)2️⃣ 内核态与用户态切换的额外开销 3️⃣网络缓冲区的重复初始化成本。
DNS 解析就不必多费口舌了,其解析过程到从根域名服务器到本地域名服务器的请求,确实浪费时间。
scss
// [version_1_1] 简化的NTP客户端实现(基于连接复用)
class SimpleNTPClient {
// 配置参数
static SERVER = "pool.ntp.org" // 默认NTP服务器[1](@ref)
static TIMEOUT = 5000 // 请求超时时间(毫秒)
static REQUEST_INTERVAL = 100 // 请求间隔(仿原代码)
// 核心组件(单例模式)
private ntpClient // 复用UDP客户端实例
private serverAddr // 缓存服务器地址
constructor() {
// 初始化网络组件(网页1:NTP服务基础配置)
ntpClient = createUDPClient(TIMEOUT) // 创建带超时的客户端
serverAddr = resolveAddress(SERVER) // DNS解析只执行一次
}
// 主功能方法
func getTime() {
try {
response = ntpClient.query(serverAddr) // 发送NTP协议请求
details = calculateTimeOffset(response) // 计算时间偏移
return convertNTPtoDate(details) // 转换为Java时间
} catch (TimeoutException) {
throw "请求超时:" + TIMEOUT + "ms"
}
}
// 资源清理
func close() {
if (ntpClient != null) {
ntpClient.release() // 释放网络连接
}
}
// 测试入口
static main() {
client = new SimpleNTPClient()
try {
// 高频测试循环(仿原代码100次请求)
for (i in 1..100) {
startTime = currentTimeMillis()
time = client.getTime()
print("NTP时间: " + time
+ " 耗时:" + (currentTimeMillis()-startTime) + "ms")
sleep(REQUEST_INTERVAL) // 控制请求频率[1](@ref)
}
} finally {
client.close()
}
}
}
(二) 基于 NIO 初步实现非阻塞实现
传统的DatagramSocket是基于阻塞IO的,每次请求可能会阻塞线程,导致资源浪费。这里就实现 BIO 到 NIO 的跳跃。
但是 DatagramChannel 的优势远不止非阻塞的实现,也包括:
- 连接复用:单个通道可处理多个请求响应循环,避免每次创建新Socket的端口绑定开销(实测吞吐量提升30%+)
- 资源可控 :通过
open()
创建的通道可精准控制缓冲区大小(SO_RCVBUF
/SO_SNDBUF
)
ByteBuffer
另外,提到了 NIO,那就不得不说一下NIO 进行高效 I/O 操作的核心工具 - ByteBuffer 。
DatagramChannel的send()
和receive()
方法仅接受ByteBuffer作为参数,这是由NIO通道的设计机制决定的。所以,必须通过ByteBuffer传递数据。
普通ByteBuffer(Heap ByteBuffer)分配在JVM堆内存中,其底层数据存储为byte[]
。
这里也涉及到了零拷贝的知识,但是这里就不进行细讲了,留到第三篇,操作系统的角度会详细介绍。
ByteBuffer 相关方法的使用已经放在了附录中,可供查询。
对比维度 | 原方案(NTPUDPClient) | NIO优化方案 |
---|---|---|
内存分配 | 隐含多次内存拷贝 | 单次直接缓冲区 |
协议解析效率 | 反射式字段解析 | 直接偏移量读取 |
字节序控制 | 依赖库实现 | 显式声明BIG_ENDIAN |
GC压力 | 产生临时对象 | 通过 clear 可复用ByteBuffer |
scss
// [verison_2_0] NIO Selector模式NTP客户端核心流程
class NIONTPClient {
// 配置参数
static SERVER = "pool.ntp.org" // NTP服务器地址[2](@ref)
static PORT = 123 // 标准NTP端口[2](@ref)
static TIMEOUT = 5000 // 超时时间(毫秒)
static PACKET_SIZE = 48 // NTP协议包长度[2](@ref)
// NIO组件
private channel // 复用DatagramChannel[1](@ref)
private serverAddr // 缓存服务器地址
constructor() {
channel = DatagramChannel.open() // 创建非阻塞通道[1](@ref)
serverAddr = resolveAddress(SERVER, PORT) // DNS解析[2](@ref)
}
// 主功能方法
func getTime() {
buffer = allocateBuffer(PACKET_SIZE) // 创建NTP请求包[2](@ref)
buffer.put(0, 0x1B).rewind() // 设置协议头[RFC2030][2](@ref)
channel.send(buffer, serverAddr) // 发送请求[1](@ref)
buffer.clear()
startTime = currentTimeMillis()
while (notTimeout(startTime)) { // 超时控制循环
responseAddr = channel.receive(buffer) // 非阻塞接收[1](@ref)
if (validResponse(responseAddr)) {
buffer.flip()
return parseNTP(buffer) // 转换时间戳[2](@ref)
}
sleep(10) // 防止CPU空转[1](@ref)
}
throw TimeoutException()
}
// 时间戳解析(伪代码实现)
private parseNTP(buffer) {
buffer.position(40) // 定位到传输时间戳字段[2](@ref)
seconds = read32bit(buffer) // 读取高32位
fraction = read32bit(buffer) // 读取低32位
// 计算时间偏移(1900->1970共2208988800秒)[2](@ref)
ntpMillis = (seconds * 1000) + (fraction * 1000 / 0x100000000L)
return new Date(ntpMillis - 2208988800000L)
}
// 资源释放
func close() {
channel.close() // 关闭NIO通道[1](@ref)
}
// 测试用例
static main() {
client = new NIONTPClient()
try {
// 高频请求测试(仿原代码100次循环)
for (i in 1..100) {
start = currentTimeMillis()
time = client.getTime()
print("时间: ${time} 耗时:${currentTimeMillis()-start}ms")
sleep(100) // 控制请求频率[1](@ref)
}
} finally {
client.close()
}
}
}
(三) 使用 Selector
NIO 的三个核心组件引入了两个:Buffer 和 Channel。
那剩下的一个组件 Selector 似乎没什么用,或者换另一个角度来思考,Selectot 有什么用?
version2.0 的代码其实还存在一个致命缺陷 ------ 轮询机制不够高效。
代码通过while
循环不断调用channel.receive(buffer)
并配合Thread.sleep(10)
实现超时控制。
我们其实可以通过Selector
监听通道就绪事件( OP_READ
) ,从而避免CPU空转轮询。
scss
// [version 2_1] NIO Selector模式NTP客户端核心流程
class NtpClient {
// 初始化阶段
init() {
channel = createNonBlockingUdpChannel() // 创建非阻塞UDP通道[1](@ref)
selector = createSelector() // 多路复用器[1](@ref)
channel.register(selector, 0) // 初始无事件监听
serverAddr = resolveAddress("pool.ntp.org:123")
}
// 主流程
getTime() {
sendNtpRequest(channel, serverAddr) // 发送NTP协议包
// 事件监听配置
channel.register(selector, OP_READ) // 切换为读监听模式[1](@ref)
start = now()
while (now() - start < TIMEOUT) {
readyChannels = selector.wait(TIMEOUT) // 事件等待[1](@ref)
if (readyChannels.isEmpty()) continue
for event in readyChannels {
if (event.isReadable) {
data = receiveData(channel) // 非阻塞接收[1](@ref)
return parseNtpPayload(data) // 解析时间戳
}
}
}
throw TimeoutException()
}
}
/* 示例调用流程
client = new NtpClient()
for 5次 {
开始计时
time = client.getTime()
输出"时间: "+time+" 耗时:"+(结束计时)+"ms"
等待1秒
}
*/
三、为什么使用 NIO 而不是 Netty 继续优化
(一) Netty 的优势
我们这里就不讲封装之类的东西了,我们从一些高一点的层面去审视这个问题。
线程+Selector的模型可同时管理多个通道,相比传统阻塞IO的多线程模式,资源消耗更低。用户代码通过selector.wait(TIMEOUT)
实现非阻塞轮询。但是单Selector线程处理所有事件(如代码中的selector.select(TIMEOUT)
),高并发时易成瓶颈。Netty 的主从多线程模型(NioEventLoopGroup
)优化了这一点,Boss线程处理连接,Worker线程处理IO事件,提升吞吐量。
但是其实在这个项目不存在上述这个顾虑。
Selector的select()
方法在JDK NIO中可能因底层epoll bug导致CPU空转(如Linux下Selector的"空轮询"问题),需额外监控线程状态。而 Netty 线程池自动管理Selector事件循环,避免空轮询问题。
(二) Linux 下的 Epoll Bug
我们来在这里详细介绍什么是 Linux 中的 Epoll BUG。
Java NIO在Linux下默认是epoll机制,但是JDK中epoll的实现却是有漏洞的。
JDK的NIO实现中 Select 的阻塞逻辑依赖于Epoll的阻塞行为,Selector
未对POLLHUP
(连接挂起)或POLLERR
(错误状态)事件进行过滤或特殊处理,导致即使没有实际读写事件,Selector.select()
仍被内核唤醒并返回空事件集合(selectedKeys
数量为0)。
严重的情况可能会导致 CPU 的占用率达到 100%。
Netty 怎么解决 Epoll Bug 的呢?
Netty在NioEventLoop
中引入selectCnt
计数器,每当Selector的select()
方法返回 0 就绪事件 时(即没有实际IO事件触发),计数器自增。若存在有效事件或异步任务处理(如ranTasks || strategy > 0
),计数器会被重置为0,避免误判。
当selectCnt
达到默认阈值512次 (可通过io.netty.selectorAutoRebuildThreshold
参数调整),Netty判定触发了Epoll空轮询Bug。该阈值设计基于大量生产环境验证,平衡了检测灵敏度和性能损耗。
触发阈值后,Netty立即创建一个新的Selector实例,同时保证该操作在EventLoop线程中执行以避免线程竞争。将旧Selector上注册的所有Channel逐个注销 ,并重新注册到新Selector上,确保事件监听不中断。此过程通过rebuildSelector0()
方法实现,保证原子性。
关闭旧Selector并释放相关资源,防止内存泄漏。
在重建过程中,若发现其他线程发起重建请求,Netty会通过execute()
方法将任务提交到EventLoop队列,确保线程安全。
重建后相当于把之前有效的连接重新设置到新的sekector上,这样新的selector就不会有之前触发空轮询的连接了。
ini
// NioEventLoop类中的关键逻辑
protected void run() {
int selectCnt = 0;
while (!hasTasks() && selectCnt < SELECTOR_AUTO_REBUILD_THRESHOLD) {
int selectedKeys = selector.select(timeoutMillis);
selectCnt++;
if (selectedKeys > 0 || hasTasks()) {
selectCnt = 0; // 重置计数器
} else if (selectCnt >= threshold) {
rebuildSelector(); // 触发重建
selectCnt = 0;
}
}
}
四、附录
(一) ByteBuffer
UDP头部仅8字节,NTP协议规范要求数据包要求为48字节。所以我们就需要学习关于 ByteBuffer 的三个重要参数。
- flip()
当你写入完数据后,需要读取这些数据。调用 flip() 方法会将当前位置(position)设置为0,并将限制(limit)设置为之前写入数据的结束位置。这样,接下来的读取操作就只能读取到实际写入的数据,避免读取到未写入的空间。 - clear()
clear() 方法会重置缓冲区的状态,将 position 设置为0,将 limit 设置为缓冲区的容量(capacity)。这表示你准备重新写入数据,之前的数据会被视为无效,但并不会真的清除内存中的内容。 - rewind() :如果你已经读取了一部分数据,但需要再次从头开始读取,可以调用 rewind() 方法。它会将 position 设置为0,但不会改变 limit。这样,你可以重复读取缓冲区中的数据,而不必重新写入数据。