Java时间戳字符串的优化

今天这个是对前段时间在公司性能优化工作总结系列的第二篇,第一篇见 MMAP以及工程实战,今天要谈的是时间戳字符串的优化,有的小伙伴就可能开始急了,这个有什么好优化的,不就是调用系统的SimpleDateFormat就可以得到时间戳的字符串吗?我一开始也是这么想的,后来啪啪打脸,接着往下看。

1. 常规时间戳字符串获取

这个就很简单了,就是把时间戳根据自己需要的格式进行format即可,如果不是需要高频的获取字符串这样做是没什么问题的,顶多给SimpleDateFormat加到ThreadLocal里面做缓存,避免线程同步的问题也加快速度。

String time = SimpleDateFormat(FORMAT, Locale.US).format(System.currentTimeMillis())

但是如果涉及到高频的场景,比如写埋点、写日志等情况,一小时内可能需要几十万上百万甚至更多的频次,那么这个开销就会是个大头,小伙伴们可以在自己的工程里面抓下火焰图,看看性能消耗的大头,这个肯定是需要优化的大头。

写了个暴力测试的demo,总计10次循环,每个循环里面50w次循环获取时间戳的字符串,看下测试代码,其中
TimeToStringHelper和TimeCache是这次优化的核心,systemFormat就是常规的获取方式。

    public static final String FORMAT = "yyyy-MM-dd HH:mm:ss.SSS";
    private static final SimpleDateFormat dateFormat = new SimpleDateFormat(FORMAT, Locale.US);
    private static final TimeToStringHelper helper = new TimeToStringHelper();
    private static final TimeCache cache = new TimeCache();
    private static final long TEST_TIME = 500_000;
    private static final boolean isSystemTest = false;
    private static final int BENCH_TIME = 10;
    private static final List<Long> list = new ArrayList<>(BENCH_TIME);

    public static void main(String[] args) {
        String ret = "";
        System.out.println("===============start====================");
        for (int i = 0; i < BENCH_TIME; i++) {
            final long startTime = System.currentTimeMillis();
            ret = isSystemTest ? systemFormat() : customFormat();
            list.add(System.currentTimeMillis() - startTime);
        }
        long sum = 0;
        for (Long aLong : list) {
            sum += aLong;
        }
        System.out.println("===============end time: " + sum / 10 + ", ret = " + ret);
        list.clear();
    }

    private static String systemFormat() {
        int i = 0;
        String ret = "";
        while (i < TEST_TIME) {
            ret = dateFormat.format(System.currentTimeMillis());
            i++;
        }
        return ret;
    }

    private static String customFormat() {
        int i = 0;
        String ret = "";
        while (i < TEST_TIME) {
            ret = helper.getTimeString(cache.getTimeBean(System.currentTimeMillis()));
            i++;
        }
        return ret;
    }

先看下常规获取方式的50w次的平均耗时在433ms:

===============start====================
===============end time: 433ms, ret = 2021-07-31 18:22:31.861

2. 时间戳计算优化

看下优化的思路,其实年月日这部分是不要每次都去通过时间戳计算的,每一天计算一次缓存下来就可以,另外系统SimpleDateFormat每次format一个时间戳会创建一个buffer,频繁的创建销毁也有内存的抖动, 所以我们完全可以用一个char[] 的数组缓存,不需要每次去创建,这个数组打大小就是时间戳需要格式化后的长度,这里是 yyyy-MM-dd HH:mm:ss.SSS

    public final String format (Object obj) {
        return format(obj, new StringBuffer(), new FieldPosition(0)).toString();
    }

有了思路就可以开始写代码了,先看下日期计算的缓存,需要缓存两个主要的bean,一个显然就是年月日时分秒毫秒了,另外一个就是天的时间戳(不包括时分秒毫秒),初始时间通过Calendar计算得到然后放到TimeBean中缓存。

public class TimeCache {
    private TimeBean timeBean;
    private long mLastDayTime;


    private void config(long millisecond) {
        if (calendar == null) {
            calendar = Calendar.getInstance();
        }
        calendar.setTimeInMillis(millisecond);
        timeBean.year = calendar.get(Calendar.YEAR);
        timeBean.month = calendar.get(Calendar.MONTH) + 1;
        timeBean.day = calendar.get(Calendar.DAY_OF_MONTH);
        timeBean.hour = calendar.get(Calendar.HOUR_OF_DAY);
        timeBean.minute = calendar.get(Calendar.MINUTE);
        timeBean.second = calendar.get(Calendar.SECOND);
        timeBean.millisecond = calendar.get(Calendar.MILLISECOND);

        mLastDayTime = millisecond - (timeBean.hour * ONE_HOUR + timeBean.minute * ONE_MINUTE + timeBean.second * ONE_SECOND + timeBean.millisecond);
    }
}

这个Cache中肯定需要有一个接口通过传入时间戳然后获取到对应时间的TimeBean, 这就需要判断是不是同一天了,如果是同一天只需要计算时分秒毫秒即可,不是同一个再重新config下

    public TimeBean getTimeBean(long millisecond) {
        if (!isInSameDay(millisecond)) {
            config(millisecond);
        } else {
            configNowTime(millisecond);
        }
        return timeBean;
    }

判断同一天就是通过当前时间减去上面缓存的天时间戳看是否在一天的毫秒数之内:

    private static final int ONE_DAY = 24 * 60 * 60 * 1000;
    private boolean isInSameDay(long millisecond) {
        if (millisecond < mLastDayTime) {
            return false;
        } else {
            return millisecond - mLastDayTime < ONE_DAY;
        }
    }

而只计算时分秒毫秒也简单,

    private void configNowTime(long millisecond) {
        int nowTime = (int) (millisecond - mLastDayTime);
        timeBean.hour = nowTime / ONE_HOUR;
        timeBean.minute = (nowTime % ONE_HOUR) / ONE_MINUTE;
        timeBean.second = (nowTime % ONE_MINUTE) / ONE_SECOND;
        timeBean.millisecond = nowTime % ONE_SECOND;
    }

上面就是时间戳的计算优化,主要是把年月日做个缓存,只计算时分秒毫秒,这样理论上就减少了40%左右的计算时间。

3. 时间戳转化为字符串优化

时间戳转化为字符串也是有耗时和空间的,这里面耗时主要就是在字符串的拼接了,而且每个字符串的产生也是需要消耗内存,所以接下来看怎么优化时间戳到字符串的转化,在这里就是上面的TimeBean转化为String的操作。

首先是做一个char的缓存:

private static final int LENGTH = "yyyy-MM-dd HH:mm:ss.SSS".length();
public class TimeToStringHelper {
    private char[] chars = new char[LENGTH];
    private int position;  
}

然后就是根据TimeBean计算字符,分成年月日时分秒毫秒计算,年月日显然也是可以缓存在chars数组中

    public void init(final TimeBean timeBean) {
        position = 0;

        handleYear(timeBean.year);
        handleMonth(timeBean.month);
        handleDay(timeBean.day);
        handleHour(timeBean.hour);
        handleMinute(timeBean.minute);
        handleSecond(timeBean.second);
        handleMillisecond(timeBean.millisecond);
    }

每一个的计算逻辑大体都相似的,就来看一下年的计算逻辑,其他的就不再贴出了。

    private void handleYear(int year) {
        if (year == this.year) {
            position += 5;
            return;
        }

        if (year < 2000 || year >= 2100) {
            char[] charArray = Integer.toString(year).toCharArray();
            if (charArray.length < 4) {
                Arrays.fill(charArray, '0');
            }
            System.arraycopy(charArray, charArray.length - 4, chars, 0, 4);
            position += 4;
        } else {
            chars[position++] = '2';
            chars[position++] = '0';
            chars[position++] = DIGITS_TEN[year - 2000];
            chars[position++] = DIGITS_ONE[year - 2000];
        }
        chars[position++] = '-';
        this.year = year;
    }

这里也做了个数字转字符的优化,比如简单说0-9怎么转化成字符呢?有多种方式,比如拼接i+‘0’或者Character.forDigit(i, 10),最快速的方式是下面这样,用空间换时间:

char digits[] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
char aChar = digits[i];

上面的年也是如此,对于10位数的获取,有下面的数组, 按10进位:

    private static final char[] DIGITS_TEN = {
            '0', '0', '0', '0', '0', '0', '0', '0', '0', '0',
            '1', '1', '1', '1', '1', '1', '1', '1', '1', '1',
            '2', '2', '2', '2', '2', '2', '2', '2', '2', '2',
            '3', '3', '3', '3', '3', '3', '3', '3', '3', '3',
            '4', '4', '4', '4', '4', '4', '4', '4', '4', '4',
            '5', '5', '5', '5', '5', '5', '5', '5', '5', '5',
            '6', '6', '6', '6', '6', '6', '6', '6', '6', '6',
            '7', '7', '7', '7', '7', '7', '7', '7', '7', '7',
            '8', '8', '8', '8', '8', '8', '8', '8', '8', '8',
            '9', '9', '9', '9', '9', '9', '9', '9', '9', '9',
    };

而对于个位数的获取,有下面的数组,也是按10进位,只不过是 按10重复:

    private static final char[] DIGITS_ONE = {
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
    };

这样分别做月日时分秒毫秒的计算,就能得到时间戳的字符数组chars了。

===============start====================
===============end time: 29ms, ret = 2021-07-31 19:05:56.065

4.总结

做完上面两个优化,用上面的demo跑下具体的耗时,从433下降到29ms,优化了93%。积小成多,尤其在对性能敏感的操作上面,高频的操作往往是优化的最佳切入点。今天这个分享就到这,觉得有帮助的帮忙点赞和关注。后面还有第三篇的实操记录,欢迎关注。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容