抛弃性能不佳的System.currentTimeMillis(),手撸一个低开销获取时间戳工具

JAVA herman 211浏览
公告:“业余草”微信公众号提供免费CSDN下载服务(只下Java资源),关注业余草微信公众号,添加作者微信:codedq,发送下载链接帮助你免费下载!
本博客日IP超过2000,PV 3000 左右,急需赞助商。
极客时间所有课程通过我的二维码购买后返现24元微信红包,请加博主新的微信号:codedq,之前的微信号好友位已满,备注:返现
受密码保护的文章请关注“业余草”公众号,回复关键字“0”获得密码
所有面试题(java、前端、数据库、springboot等)一网打尽,请关注文末小程序
视频教程免费领

大家好,我是业余草,这是我的第 447 篇原创!

你或许听说过,在 Java 中调用 System.currentTimeMillis() 会有一些性能开销,在某些场景下,System.nanoTime() 更具优势!

比如,测试方法的耗时时间:

public void save(){
    long start = System.currentTimeMillis();
    // doSomething() ...
    System.out.println(System.currentTimeMillis() - start);
}

这里建议你System.currentTimeMillis()改为System.nanoTime()

public void save(){
    long start = System.nanoTime();
    // doSomething() ...
    System.out.println(System.nanoTime() - start);
}

原因我们下面慢慢展开。

昨天群里还有人说,可以使用 StopWatch。岂不知,StopWatch 背后也是System.currentTimeMillis()

public void save(){
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    // doSomething() ...
    stopWatch.stop();
    System.out.println(stopWatch.prettyPrint());
}
StopWatch底层时间获取
StopWatch底层时间获取

System.currentTimeMillis() 的缺点

System.currentTimeMillis()返回的是毫秒数,System.nanoTime()返回的是纳秒数。如果方法跑的比较快,毫秒的测试就更不准确了。

1000 皮秒 = 1纳秒 
1000000 皮秒 = 1微秒 
1000000000 皮秒 = 1毫秒 
1000000000000 皮秒 = 1秒

1s = 1000 ms 毫秒
1ms = 1000000 ns 纳秒

更何况,currentTimeMillis依赖底层操作系统,nanoTime则是有 JVM 维护。

展开来说就是,我们在 Java 中获取时间戳的方法是System.currentTimeMillis()返回的是毫秒级的时间戳。查看源码注释,写的比较清楚,虽然该方法返回的是毫秒级的时间戳,但精度取决于操作系统,很多操作系统返回的精度是 10 毫秒。

/**
* Returns the current time in milliseconds.  Note that
* while the unit of time of the return value is a millisecond,
* the granularity of the value depends on the underlying
* operating system and may be larger.  For example, many
* operating systems measure time in units of tens of
* milliseconds.
*
* <p> See the description of the class <code>Date</code> for
* a discussion of slight discrepancies that may arise between
* "computer time" and coordinated universal time (UTC).
*
* @return  the difference, measured in milliseconds, between
*          the current time and midnight, January 1, 1970 UTC.
* @see     java.util.Date
*/
public static native long currentTimeMillis();

以 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);
}

这是 C++ 写的,我也看不懂。我们直接拿老外的研究来学习:http://pzemtsov.github.io/2017/07/23/the-slow-currenttimemillis.html

总结起来原因是System.currentTimeMillis调用了gettimeofday()

  • 调用gettimeofday()需要从用户态切换到内核态;
  • gettimeofday()的表现受Linux系统的计时器(时钟源)影响,在 HPET 计时器下性能尤其差;
  • 系统只有一个全局时钟源,高并发或频繁访问会造成严重的争用。

我们测试一下System.currentTimeMillis()在不同线程下的性能,这里使用中间件常用的JHM来测试,测试 1 到 128 线程下获取 1000 万次时间戳需要的时间分别是多少,这里给出在我的电脑上的测试数据:

System.currentTimeMillis()性能测试
System.currentTimeMillis()性能测试

还有一个问题就是,currentTimeMillis 获取的是系统时间源。因此,系统时间变更,或者系统自动进行了时间同步,计算两次获取的差值,可能是负数。

另外System.currentTimeMillis()返回自纪元(即自 1970 年 1 月 1 日 UTC 午夜以来的毫秒数)。如果你的系统设置的时间小于这个时间,那么 currentTimeMillis 的取值也可能是负数。当然几乎没人会这么设置时间,除非是黑客。

设置系统时间
设置系统时间

小总结:使用System.currentTimeMillis()要注意精度、性能开销、时间同步影响准确性、时间不安全可能是负数、高并发场景随机数不均衡等问题。

System.nanoTime() 的缺点

System.nanoTime()是 JDK 1.5 才推出的,因此 1.5 之前的办法无法使用。

第二,源码注释中描述它是安全的。但在老外的使用过程中发现,它有时候也不安全,返回的也可能是负数。

另外官方建议,可以使用它来测量 elapsed time,不能用来当作 wall-clock time 或 system time。

This method can only be used to measure elapsed time and is not related to any other notion of system or wall-clock time.❞

网上还暴露出,多核处理器不同核心的启动时间可能不完全一致,这样可能会造成System.nanoTime()计时错误。参考:https://stackoverflow.com/questions/510462/is-system-nanotime-completely-useless

手撸一个 currentTimeMillis

先定义一个工具类:TimeUtil。


/**
 * 弱精度的计时器,考虑性能不使用同步策略。
 */
public class TimeUtil {
    private static long CURRENT_TIME = System.currentTimeMillis();

    public static final long currentTimeMillis() {
        return CURRENT_TIME;
    }

    public static final void update() {
        CURRENT_TIME = System.currentTimeMillis();
    }

}

然后起一个定时器,定时更新维护时间。

import java.util.Timer;
import java.util.TimerTask;

public class TimerServer {
    private static final TimerServer INSTANCE = new TimerServer();
    private final Timer timer;
    
    private TimerServer(){
        timer = new Timer("业余草Timer", true);
        timer.schedule(updateTime(), 0L, 20L);
    }

    // 系统时间定时更新任务
    private TimerTask updateTime() {
        return new TimerTask() {
            @Override
            public void run() {
                TimeUtil.update();
            }
        };
    }

    public static final TimerServer getInstance() {
        return INSTANCE;
    }
}

或者直接用一个 TimeUtil 类搞定。

public final class TimeUtil {

    private static volatile long currentTimeMillis;

    static {
        currentTimeMillis = System.currentTimeMillis();
        Thread daemon = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    currentTimeMillis = System.currentTimeMillis();
                    try {
                        TimeUnit.MILLISECONDS.sleep(1);
                    } catch (Throwable e) {

                    }
                }
            }
        });
        daemon.setDaemon(true);
        daemon.setName("业余草-time-tick-thread");
        daemon.start();
    }

    public static long currentTimeMillis() {
        return currentTimeMillis;
    }
}

这样做的好处就是,在高并发场景下,对时间要求较高的场景,则可以自己维护系统时钟。

经过 JMH 测试对比(测试代码可以加我微信:codedq,免费获取),我们手撸的 TimeUtil 在 1-128 线程下的性能表现非常强劲,比系统自带的System.currentTimeMillis()高出近 876 倍。

低开销获取时间戳
低开销获取时间戳

比如:阿里的 Sentinel,Cobar等。Twitter 的 Snowflake(很多人在实现 Snowflake 时,采用了 System.currentTimeMillis())。

总结

虽然缓存时间戳性能能提升很多,但这也仅限于非常高的并发系统中,一般比较适用于高并发的中间件,如果一般的系统来做这个优化,效果并不明显。性能优化还是要抓住主要矛盾,解决瓶颈,切忌不可过度优化。

参考资料

业余草公众号

最后,欢迎关注我的个人微信公众号:业余草(yyucao)!可加作者微信号1:xmtxtt(5000人已满),微信号2:xttblog(5000人已满),微信号3:codedq(超3800)。备注:“1”,添加博主微信拉你进微信群。备注错误不会同意好友申请。再次感谢您的关注!后续有精彩内容会第一时间发给您!原创文章投稿请发送至532009913@qq.com邮箱。商务合作也可添加作者微信进行联系!

本文原文出处:业余草: » 抛弃性能不佳的System.currentTimeMillis(),手撸一个低开销获取时间戳工具