上一节我们介绍了什么事 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。这样,你可以重复读取缓冲区中的数据,而不必重新写入数据。