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%。积小成多,尤其在对性能敏感的操作上面,高频的操作往往是优化的最佳切入点。今天这个分享就到这,觉得有帮助的帮忙点赞和关注。后面还有第三篇的实操记录,欢迎关注。