问题描述

在 C/S(Client/Server)架构中,为了减少频繁建立连接的开销,我们有时会长时间保持一个连接。但同时,通常会设置一个超时时间:若在该时间内连接未发起任何请求,则将其断开,以减少服务端负载并节约资源。

该机制一般建议在服务端实现。这是因为当客户端强制关闭或意外断开连接时,服务端往往无法立即感知。若将超时机制完全交由客户端实现,在上述异常情况下,该机制可能会失效。

这一问题看似普通,往往容易被忽视。但最近在项目中观察到一种该机制的糟糕实现,故在此深入分析其潜在问题及优化方案。

问题分析及解决方案

服务端通常需要维护大量连接,因此普遍做法是创建一个定时器,定期检查所有连接中哪些已超时。此外,关键在于:当收到客户端发来的数据时,如何高效地刷新该连接的超时信息?

初始实现与可见性问题

最近看到的一种实现方式如下:

public class Connection {
    private long lastTime;

    public void refresh() {
        lastTime = System.currentTimeMillis();
    }

    public long getLastTime() {
        return lastTime;
    }
    // ......
}

在每次收到客户端数据时,调用 refresh 方法更新最后活动时间。然后在定时器任务中,通过比较当前时间与每个连接的 getLastTime() 来判定是否超时:

public class TimeoutTask extends TimerTask {
    public void run() {
        long now = System.currentTimeMillis();
        for (Connection c : connections) {
            if (now - c.getLastTime() > TIMEOUT_THRESHOLD) {
                // timeout, do something
            }
        }
    }
}

熟悉并发编程的读者可能已经发现了问题:内存可见性。调用 refresh 方法的线程(通常是 IO 线程)与执行定时器的线程并非同一个线程。若 lastTime 未做同步处理,定时器线程读到的可能是旧值,导致将活跃连接误判为超时并断开。

性能考量与测试

有读者可能会想到使用 volatile 修饰 lastTime。这确实解决了可见性问题,但作为服务端,高性能往往是核心诉求。volatile 写操作以及频繁调用 System.currentTimeMillis() 是否存在性能瓶颈?

以下是我本地环境的一组测试数据,代码供参考:

public class PerformanceTest {
    private static long i;
    private volatile static long vt;
    private static final int TEST_SIZE = 10000000;

    public static void main(String[] args) {
        long time = System.nanoTime();
        for (int n = 0; n < TEST_SIZE; n++)
            vt = System.currentTimeMillis();
        System.out.println("volatile 写 + 取系统时间:" + (-time + (time = System.nanoTime())));

        for (int n = 0; n < TEST_SIZE; n++)
            i = System.currentTimeMillis();
        System.out.println("普通写 + 取系统时间:" + (-time + (time = System.nanoTime())));

        for (int n = 0; n < TEST_SIZE; n++)
            synchronized (PerformanceTest.class) { }
        System.out.println("空的同步块(synchronized):" + (-time + (time = System.nanoTime())));

        for (int n = 0; n < TEST_SIZE; n++)
            vt++;
        System.out.println("volatile 变量自增:" + (-time + (time = System.nanoTime())));

        for (int n = 0; n < TEST_SIZE; n++)
            vt = i;
        System.out.println("volatile 写:" + (-time + (time = System.nanoTime())));

        for (int n = 0; n < TEST_SIZE; n++)
            i = vt;
        System.out.println("volatile 读:" + (-time + (time = System.nanoTime())));

        for (int n = 0; n < TEST_SIZE; n++)
            i++;
        System.out.println("普通自增:" + (-time + (time = System.nanoTime())));

        for (int n = 0; n < TEST_SIZE; n++) 
            i = n;
        System.out.println("普通读写:" + (-time + (time = System.nanoTime())));
    }
}

测试一千万次,结果如下(耗时单位:纳秒,包含循环本身的时间):

  • 238,932,949 : volatile 写 + 取系统时间
  • 144,317,590 : 普通写 + 取系统时间
  • 135,596,135 : 空的同步块(synchronized)
  • 80,042,382 : volatile 变量自增
  • 15,875,140 : volatile 写
  • 6,548,994 : volatile 读
  • 2,722,555 : 普通自增
  • 2,949,571 : 普通读写

从数据可以看出,volatile 写操作配合获取系统时间的耗时非常高,甚至接近一次无竞争的同步块操作。获取系统时间本身也是一项相对耗时的操作。在高负载场景下,若每次刷新都执行此操作,成本过高。

优化方案

针对上述问题,优化思路如下:

  1. 同步问题:跨线程数据操作必须保证可见性。
  2. 减少耗时操作:能否不在每次刷新时都获取系统时间?因为刷新调用在高负载下非常频繁。

如果不直接在刷新时获取时间,该如何判定超时?我的方案是:将时间的掌控交给定时器,连接对象仅维护状态标志。

具体实现如下:为每个连接维护一个计数器,并在连接对象中设置一个 volatile boolean 变量 reset

  • 刷新时:仅设置 reset = true(成本极低)。
  • 定时检查时:若 resettrue,则计数器归零并将 reset 复位;否则计数器加一。若计数器超过阈值,则判定超时。

由于计数器仅由定时器线程维护,无需额外的同步处理。从测试数据来看,普通变量的操作成本极低。

public class Connection {
    int count = 0;
    volatile boolean reset = false;

    public void refresh() {
        // 仅在需要时写入,减少 volatile 写操作
        if (!reset) {
            reset = true;
        }
    }
}
public class TimeoutTask extends TimerTask {
    public void run() {
        for (Connection c : connections) {
            if (c.reset) {
                c.reset = false;
                c.count = 0;
            } else if (++c.count >= TIMEOUT_COUNT) {
                // timeout, do something
            }
        }
    }
}

代码中的 TIMEOUT_COUNT 等于超时时间除以定时器的周期。周期大小既影响定时器的执行频率,也会影响实际超时时间的波动范围(这种波动在第一种方案中同样存在,难以完全避免,且通常不需要毫秒级的精确度)。

方案分析

  1. 可见性保证reset 变量加上了 volatile 修饰,保证了多线程操作的可见性。虽然两个线程都可能对该变量进行写操作,但无论线程如何穿插执行,都不会影响逻辑的正确性。
  2. 性能优化:在 refresh 方法中,我增加了一个条件判断 if (!reset)。这看似多了一次 volatile 读操作,实则有益。

    • 在高负载下,refresh 会被频繁调用,意味着 reset 长时间处于 true 状态。
    • 加上条件后,大部分情况下只会执行读操作,不会执行写操作。
    • 从测试数据来看,volatile 读操作的性能显著优于写操作。
    • 仅在 resetfalse 时(即定时器的一个周期内最多一次)才会执行写操作。这对高负载情况下的性能优化更有意义。

综上所述,该方案在保证了线程安全的前提下,显著降低了高频调用下的性能开销。


说明:本文示例代码基于 Java 传统 Timer 机制编写。在实际生产环境中,建议优先使用 ScheduledExecutorService 替代 Timer,以获得更好的线程管理和异常处理能力。此外,涉及耗时统计时,System.nanoTime() 通常比 System.currentTimeMillis() 更适合用于测量时间间隔。