并发慎用——System.currentTimeMillis()

在高并发场景下,System.currentTimeMillis()的性能表现远低于预期,由于系统时钟源的争用,其耗时甚至超过对象创建。本文通过实验对比单线程与并发调用的耗时差异,深入探讨了其背后的原因,包括用户态到内核态的切换、时钟源的选择(HPET vs TSC)及全局时钟源的争用问题。

好记忆不如烂笔头,能记下点东西,就记下点,有时间拿出来看看,也会发觉不一样的感受.

System.currentTimeMillis()是极其常用的基础Java API,广泛地用来获取时间戳或测量代码执行时长等,在我们的印象中应该快如闪电。但实际上在并发调用或者特别频繁调用它的情况下(比如一个业务繁忙的接口,或者吞吐量大的需要取得时间戳的流式程序),其性能表现会令人大跌眼镜。直接看下面的Demo。

public class CurrentTimeMillisPerfDemo {
    private static final int COUNT = 100;

    public static void main(String[] args) throws Exception {
        long beginTime = System.nanoTime();
        for (int i = 0; i < COUNT; i++) {
            System.currentTimeMillis();
        }

        long elapsedTime = System.nanoTime() - beginTime;
        System.out.println("100 System.currentTimeMillis() serial calls: " + elapsedTime + " ns");

        CountDownLatch startLatch = new CountDownLatch(1);
        CountDownLatch endLatch = new CountDownLatch(COUNT);
        for (int i = 0; i < COUNT; i++) {
            new Thread(() -> {
                try {
                    startLatch.await();
                    System.currentTimeMillis();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    endLatch.countDown();
                }
            }).start();
        }

        beginTime = System.nanoTime();
        startLatch.countDown();
        endLatch.await();
        elapsedTime = System.nanoTime() - beginTime;
        System.out.println("100 System.currentTimeMillis() parallel calls: " + elapsedTime + " ns");
    }
}

执行结果如下图。

image.png

可见,并发调用System.currentTimeMillis()一百次,耗费的时间是单线程调用一百次的250倍。如果单线程的调用频次增加(比如达到每毫秒数次的地步),也会观察到类似的情况。实际上在极端情况下,System.currentTimeMillis()的耗时甚至会比创建一个简单的对象实例还要多,看官可以自行将上面线程中的语句换成new HashMap<>之类的试试看。

为什么会这样呢?来到HotSpot源码的hotspot/src/os/linux/vm/os_linux.cpp文件中,有一个javaTimeMillis()方法,这就是System.currentTimeMillis()的native实现。

jlong os::javaTimeMillis() {
  timeval time;
  int status = gettimeofday(&time, NULL);
  assert(status != -1, "linux error");
  return jlong(time.tv_sec) * 1000  +  jlong(time.tv_usec / 1000);
}

挖源码就到此为止,因为已经有国外大佬深入到了汇编的级别来探究,详情可以参见《The Slow currentTimeMillis()》这篇文章,我就不班门弄斧了。简单来讲就是:

  • 调用gettimeofday()需要从用户态切换到内核态;

  • gettimeofday()的表现受Linux系统的计时器(时钟源)影响,在HPET计时器下性能尤其差;

  • 系统只有一个全局时钟源,高并发或频繁访问会造成严重的争用。

HPET计时器性能较差的原因是会将所有对时间戳的请求串行执行。TSC计时器性能较好,因为有专用的寄存器来保存时间戳。缺点是可能不稳定,因为它是纯硬件的计时器,频率可变(与处理器的CLK信号有关)。关于HPET和TSC的细节可以参见https://en.wikipedia.org/wiki/High_Precision_Event_Timer与https://en.wikipedia.org/wiki/Time_Stamp_Counter。

另外,可以用以下的命令查看和修改时钟源。

~ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
tsc hpet acpi_pm
~ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
tsc
~ echo 'hpet' > /sys/devices/system/clocksource/clocksource0/current_clocksource

如何解决这个问题?最常见的办法是用单个调度线程来按毫秒更新时间戳,相当于维护一个全局缓存。其他线程取时间戳时相当于从内存取,不会再造成时钟资源的争用,代价就是牺牲了一些精确度。具体代码如下。

public class CurrentTimeMillisClock {
    private volatile long now;

    private CurrentTimeMillisClock() {
        this.now = System.currentTimeMillis();
        scheduleTick();
    }

    private void scheduleTick() {
        new ScheduledThreadPoolExecutor(1, runnable -> {
            Thread thread = new Thread(runnable, "current-time-millis");
            thread.setDaemon(true);
            return thread;
        }).scheduleAtFixedRate(() -> {
            now = System.currentTimeMillis();
        }, 1, 1, TimeUnit.MILLISECONDS);
    }

    public long now() {
        return now;
    }
    
    public static CurrentTimeMillisClock getInstance() {
        return SingletonHolder.INSTANCE;
    }

    private static class SingletonHolder {
        private static final CurrentTimeMillisClock INSTANCE = new CurrentTimeMillisClock();
    }
}

使用的时候,直接CurrentTimeMillisClock.getInstance().now()就可以了。不过,在System.currentTimeMillis()的效率没有影响程序整体的效率时,就不必忙着做优化,这只是为极端情况准备的。

`private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);` 是 Java 中用于创建一个**可调度线程池**的常见代码,常用于执行定时或周期性任务。下面我们来详细解释这行代码的含义、使用方法、注意事项以及相关示例。 --- ### ✅ 代码详解 ```java private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); ``` #### 1. `private final` - **`private`**:表示该字段只能在当前类中访问,封装性好。 - **`final`**:表示该引用一旦初始化后不能指向其他对象(但可以调用其方法执行任务)。 > 注意:`final` 不代表线程池本身不可变或不能关闭,只是引用不可变。 #### 2. `ScheduledExecutorService` - 是 `ExecutorService` 的子接口,专门用于支持**延迟执行**和**周期性执行**任务。 - 提供了以下核心方法: - `schedule(Runnable command, long delay, TimeUnit unit)` 延迟一定时间后执行一次。 - `scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)` 在初始延迟后以固定频率重复执行(例如每 5 秒一次)。 - `scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)` 上一次执行结束后,等待固定延迟再执行下一次。 #### 3. `Executors.newScheduledThreadPool(1)` - 工厂方法,创建一个**包含一个线程的调度线程池**。 - 参数 `1` 表示核心线程数为 1。 - 特点: - 只有一个工作线程来执行所有调度任务。 - 所有任务按顺序执行(不会并发),避免资源竞争。 - 如果设置大于 1,可以并行调度多个任务。 > ⚠️ 注意:即使你设置了更大的线程数,**周期性任务如果耗时超过周期时间**,仍然会按照设定方式处理(见后面说明)。 --- ### ✅ 使用示例 #### 示例 1:延迟执行任务 ```java public class SchedulerExample { private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); public void start() { scheduler.schedule(() -> { System.out.println("任务在 3 秒后执行: " + Thread.currentThread().getName()); }, 3, TimeUnit.SECONDS); } public static void main(String[] args) { new SchedulerExample().start(); } } ``` 输出: ``` 任务在 3 秒后执行: pool-1-thread-1 ``` --- #### 示例 2:固定频率执行(scheduleAtFixedRate) ```java public void startFixedRate() { scheduler.scheduleAtFixedRate(() -> { System.out.println("周期任务开始: " + System.currentTimeMillis()/1000); try { Thread.sleep(2000); // 模拟耗时操作 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println("周期任务结束"); }, 1, 3, TimeUnit.SECONDS); } ``` - 初始延迟 1 秒,之后每 3 秒触发一次。 - 即使任务执行耗时 2 秒,只要不超过周期 3 秒,下一次仍会在正确的时间点启动。 - 若任务执行时间 > 周期时间,则下一次会**立即开始**(不等待)。 --- #### 示例 3:固定延迟执行(scheduleWithFixedDelay) ```java public void startWithFixedDelay() { scheduler.scheduleWithFixedDelay(() -> { System.out.println("任务开始: " + System.currentTimeMillis()/1000); try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println("任务结束"); }, 1, 3, TimeUnit.SECONDS); } ``` - 上一次任务**完成之后**,再等 3 秒才开始下一次。 - 更适合任务执行时间不确定的情况。 --- ### ✅ 关闭线程池(重要!) 如果不手动关闭,JVM 不会退出(因为线程是非守护线程)。 ```java public void shutdown() { scheduler.shutdown(); try { if (!scheduler.awaitTermination(800, TimeUnit.MILLISECONDS)) { scheduler.shutdownNow(); } } catch (InterruptedException e) { scheduler.shutdownNow(); Thread.currentThread().interrupt(); } } ``` --- ### ✅ 为什么用 `newScheduledThreadPool(1)` 而不是 `newSingleThreadScheduledExecutor()`? 两者功能几乎相同: | 方式 | 等价性 | 区别 | |------|--------|-------| | `Executors.newScheduledThreadPool(1)` | ✅ 是 | 可扩展,后续可改为多线程 | | `Executors.newSingleThreadScheduledExecutor()` | ✅ 是 | 更明确语义,单线程 | 推荐写法(更清晰): ```java private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); ``` > 两者底层都是 `ScheduledThreadPoolExecutor`,区别不大。 --- ### ✅ 注意事项与最佳实践 1. **不要忘记关闭线程池**,否则可能导致内存泄漏或应用无法退出。 2. **异常处理**:如果任务抛出未捕获异常,后续任务将停止。建议包装 Runnable: ```java Runnable safeTask = () -> { try { yourTask.run(); } catch (Exception e) { e.printStackTrace(); // 或记录日志 } }; ``` 3. **避免长时间阻塞主线程**:调度任务应在子线程中运行,不影响主流程。 4. **慎用 `scheduleAtFixedRate` 处理长任务**:可能导致任务堆积。 --- ###
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值