【并发优化专题】-高并发下之系统时间误差优化

773 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情

Java中时间的获取方式,常用有 new Date()System.currentTimeMillis() 这两种。其中Date类底层还是调用 System.currentTimeMillis() 方法实现的,System.currentTimeMillis()是系统naive方法,是通过 JNI 调用操作系统时钟来获取操作系统的本地时间。

因此,在使用 System.currentTimeMillis() 过程中,会出现CPU上下文切换,且会发生用户态到内核态的切换。

因此,在多线程的情况下,可能会出现多个线程竞争同一个时间资源导致出现时间误差的问题(单线程频繁调用也会出现一定误差)。

1.获取时间的方式

new Date().getTime();

System.currentTimeMillis();

但实际上new Date()的源码中也将调用System.currentTimeMillis(),即:

public Date() {
    this(System.currentTimeMillis());
}

2.获取时间存在的性能隐患

最近在做滑动窗口的优化实现中,了解到在并发情况下System.currentTimeMillis()竟然有严重的性能问题,所以自己做测试测试下。在印象中我们感觉这是基于底层的api应该不会有什么大问题,也确实,在不是高并发下也不会出现什么问题。

System.currentTimeMillis()是native方法,即获取时间需要和操作系统进行交互(涉及到用户态与内核态的切换)。而获取时间完全依赖操作系统,有的操作系统甚至是以10ms为计量,所以这也导致了获取到的时间存在精确误差

"误差"测试

单线程测试

public class Main {
​
    public static void main(String[] args) {
      //执行一百次循环
        for (int t = 0; t < 100; t++) {
            StopWatch stopWatch = new StopWatch();
            stopWatch.start();
            //获取一千万次时间
            for (int i = 0; i < 10000000; i++) {
                System.currentTimeMillis();
            }
            stopWatch. stop();
            long totalTimeMillis = stopWatch.getTotalTimeMillis();
            System.out.println(totalTimeMillis);
        }
    }
}

我们知道cpu运算是非常快的,出现了50ms的误差,这个误差还是非常大的。

多线程测试

public class Main {
​
    public static void main(String[] args) throws InterruptedException {
        // 测试执行1次
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        for (int i = 0; i < 1; i++) {
            System.currentTimeMillis();
        }
        stopWatch.stop();
        long totalTimeNanos = stopWatch.getLastTaskTimeNanos();
        System.out.println(totalTimeNanos);
        System.out.println("=====================");
        System.out.println("=====================");
        //100个线程各执行一次
        CountDownLatch wait = new CountDownLatch(1);
        CountDownLatch threadLatch = new CountDownLatch(100);
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                try {
                    StopWatch watch = new StopWatch();
                    //先阻塞住所有线程
                    wait.await();
                    watch.start();
                    System.currentTimeMillis();
                    watch.stop();
                    System.out.println(watch.getTotalTimeNanos());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    threadLatch.countDown();
                }
            }).start();
        }
        //暂停1s保证线程创建完成
        Thread.sleep(1000);
        wait.countDown();
        threadLatch.await();
    }
}

数据基本上都集中在500ns左右,但是其中个别数据到了1000ns甚至2000ns以上。

所以我们不管是单线程还是多线程下,高频的调用System.currentTimeMillis()都会产生延迟。

3.误差产生的原因

在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的细节可以参见en.wikipedia.org/wiki/High_P…en.wikipedia.org/wiki/Time_S…

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

~ 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

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

优化代码参考实现

4.优化方案

思路:设置一个守护线程保存时间

使用一个单例 SystemClock对象,对象中开启一个后台线程,每隔一毫秒更新一次clock时间。

System.currentTimeMillis()之所以慢是因为去跟系统打了一次交道,因此可以后台定时更新时钟,JVM退出时,线程自动回收。

import java.sql.Timestamp;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
​
/**
 * 高并发场景下System.currentTimeMillis()的性能问题的优化
 * @author deepinsea
 */
public class SystemClock {
    
    /** 时钟更新间隔,单位毫秒 */
    private final long period;
    /** 现在时刻的毫秒数 */
    private volatile long now;
​
    /**
     * 构造
     * @param period 时钟更新间隔,单位毫秒
     */
    public SystemClock(long period) {
        this.period = period;
        this.now = System.currentTimeMillis();
        scheduleClockUpdating();
    }
​
    /**
     * 开启计时器线程
     */
    private void scheduleClockUpdating() {
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
            Thread thread = new Thread(runnable, "System Clock");
            thread.setDaemon(true);
            return thread;
        });
        scheduler.scheduleAtFixedRate(() -> now = System.currentTimeMillis(), period, period, TimeUnit.MILLISECONDS);
    }
​
    /**
     * @return 当前时间毫秒数
     */
    private long currentTimeMillis() {
        return now;
    }
    
    //------------------------------------------------------------------------ static
    /**
     * 单例
     * @author Looly
     *
     */
    private static class InstanceHolder {
        public static final SystemClock INSTANCE = new SystemClock(1);
    }
​
    /**
     * 单例实例
     * @return 单例实例
     */
    private static SystemClock instance() {
        return InstanceHolder.INSTANCE;
    }
​
    /**
     * @return 当前时间
     */
    public static long now() {
        return instance().currentTimeMillis();
    }
​
    /**
     * @return 当前时间字符串表现形式
     */
    public static String nowDate() {
        return new Timestamp(instance().currentTimeMillis()).toString();
    }
}

实现方案优点:

  • 采用守护线程作为定时任务:守护线程会随着用户线程的结束而结束,无需单独处理关闭问题(程序结束时,守护线程可以立即关闭,不同于用户线程,比如数据库操作线程,正在插入数据的时候,程序结束了,此时必须正常正确的优雅结束插入数据线程),为其他用户线程提供服务支持
  • 定时任务每毫秒调用一次System.currentTimeMillis(),并存入内存中,采用volatile保证不同线程间的可见性(由于采用了volatile所以禁止了重排序,导致CPU三级缓存无法用到,多线程单次调用下可能性能不佳,后面讲到)
  • 单例模式,防止new多个对象,导致起多个定时获取时间戳的守护线程。

注意:在竞争不是那么激烈的情况下,最好不要采用守护线程获取系统时间的方案,因为比正常获取系统时间的开销要大。在sentinel中也曾采用这种方式优化,但是在Github issues中有人提出这种方式获取时间的成本在实际中其实比不采用的方案要大,因此后面sentinel源码中取消了这部分优化。因此,还是需要根据实际情况考虑啊~

欢迎关注白羊🐏,感谢观看ヾ(◍°∇°◍)ノ゙