高性能格式化LocalDateTime代码分享

文章讨论了LocalDateTime.parse方法在高频调用时性能较差的问题,原因是其内部包含大量解析和合法性校验逻辑。通过Benchmark测试,发现使用LocalDateTime.of、MethodHandler和反射创建LocalDateTime实例的性能差异显著,其中MethodHandler性能最佳。最终,作者提出并实现了一个自定义的FastDateTimeFormatter,通过预解析时间格式串来提升性能,测试结果显示其解析速度比LocalDateTime.parse快15倍。

文章目录

起因

起因是在技术群内有人在排查某个高频方法耗时时,发现通过LocalDateTime.parse方法解析时间字符串消耗了大量时间,进一步查看源码,可以看到该方法做了大量的解析逻辑和合法判断,以支持大量占位符同时保证时间合法。然而在大多数业务场景下,时间格式都是固定的,常用的格式有ISO-8601yyyy-MM-ddTHH:mm:ss.SSS)或者空格分割(yyyy-MM-dd HH:mm:ss)等,并不会用到其他更多的占位符格式,并且格式一旦确定下来后期基本不会更改,因此LocalDateTime.parse的实现显得比较“重”,性能不高。

经过大家讨论,解决方案基本都选择了自行解析时间字符串,并创建LocalDateTime实例这一方式,主要讨论的点在LocalDateTime创建方式上,LocalDateTime的构造方法不包含任何逻辑判断,但是该构造方法是私有的,正常情况下只能通过静态方法LocalDateTime.of(int, int, int, int, int, int, int)来创建,而这个of方法中包含了时间合法性的校验,性能不够极致(。

于是我写了一个Benchmark测试,分别测试了通过LocalDateTime.ofMethodHandler获取构造方法反射获取构造方法 这3种方式下的性能(由于Unsafe构造对象的相关方法在未来会被移除,故不考虑该方式),代码如下(基于OpenJDK 22.0.1,JMH 1.3.7):

package com.example;
import com.example.util.DateTimeUtil;
import org.junit.jupiter.api.Assertions;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
@State(Scope.Thread)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 100, time = 100, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 100, time = 100, timeUnit = TimeUnit.MILLISECONDS)
@Threads(8)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class FastParseBenchTest {
private static final LocalDateTime now = LocalDateTime.now();
// 原生of静态方法,带时间校验
@Benchmark
public void test_LocalDateTime_of() {
LocalDateTime t = LocalDateTime.of(1970, 1, 1, 0, 0, 0, 0);
Assertions.assertTrue(t.isBefore(now));
}
// 方法句柄获取构造器,不带时间校验
@Benchmark
public void test_MethodHandler() throws Throwable {
LocalDateTime t = DateTimeUtil.methodHandler(1970, 1, 1, 0, 0, 0, 0);
Assertions.assertTrue(t.isBefore(now));
}
// 反射获取构造器,不带时间校验
@Benchmark
public void test_Reflect() throws Throwable {
LocalDateTime t = DateTimeUtil.reflect(1970, 1, 1, 0, 0, 0, 0);
Assertions.assertTrue(t.isBefore(now));
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(FastParseBenchTest.class.getSimpleName())
.output("./construct_LocalDateTime.log")
.forks(2)
.build();
new Runner(opt).run();
}
}
package com.example.util;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Constructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
public class DateTimeUtil {
static final MethodHandle DATE_CONSTRUCTOR;
static final MethodHandle TIME_CONSTRUCTOR;
static final Constructor<LocalDate> DATE_CONSTRUCTOR2;
static final Constructor<LocalTime> TIME_CONSTRUCTOR2;
static {
try {
var lookup = MethodHandles.privateLookupIn(LocalDate.class, MethodHandles.lookup());
DATE_CONSTRUCTOR = lookup.findConstructor(LocalDate.class, MethodType.methodType(void.class, int.class, int.class, int.class));
lookup = MethodHandles.privateLookupIn(LocalTime.class, MethodHandles.lookup());
TIME_CONSTRUCTOR = lookup.findConstructor(LocalTime.class, MethodType.methodType(void.class, int.class, int.class, int.class, int.class));
DATE_CONSTRUCTOR2 = LocalDate.class.getDeclaredConstructor(int.class, int.class, int.class);
DATE_CONSTRUCTOR2.setAccessible(true);
TIME_CONSTRUCTOR2 = LocalTime.class.getDeclaredConstructor(int.class, int.class, int.class, int.class);
TIME_CONSTRUCTOR2.setAccessible(true);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static LocalDateTime methodHandler(int year, int month, int day, int hour, int minute, int second, int milli) throws Throwable {
return LocalDateTime.of(
(LocalDate) DATE_CONSTRUCTOR.invoke(year, month, day),
(LocalTime) TIME_CONSTRUCTOR.invoke(hour, minute, second, milli)
);
}
public static LocalDateTime reflect(int year, int month, int day, int hour, int minute, int second, int milli) throws Throwable {
return LocalDateTime.of(
DATE_CONSTRUCTOR2.newInstance(year, month, day),
TIME_CONSTRUCTOR2.newInstance(hour, minute, second, milli)
);
}
}

Benchmark结果如下(仅供参考):

Benchmark Mode Cnt Score Error Units
FastParseBenchTest.test_LocalDateTime_of thrpt 200 2688555.251 ± 42472.191 ops/ms
FastParseBenchTest.test_MethodHandler thrpt 200 8641022.507 ± 149300.689 ops/ms
FastParseBenchTest.test_Reflect thrpt 200 6624552.853 ± 63160.076 ops/ms

结果真的让人大跌眼镜啊,LocalDateTime.of性能最差,不到反射的1/2,而方法句柄性能最佳,最接近原生构造器。经过测试可以得知,时间校验是造成解析耗时长的原因之一,但为了安全性,校验逻辑应当保留,并且使用方法句柄和反射获取标准库私有构造器需要在VM参数中加上--add-opens java.base/java.time=ALL-UNNAMED,所以只能从别的地方下手了。

自行实现解析逻辑

既然时间校验应当保留,那么我们只能从解析阶段下手,上面提到业务中时间格式一般定下来之后就不会更改,所以我们可以自己写一个DateTimeFormatter,实现构建DateTimeFormatter时解析一次时间格式串,后续parse/format不用再解析的效果。整体流程如下图:

时间格式串解析

最后代码如下,使用方式和java.time.DateTimeFormatter一致,但仅支持LocalDateTime、LocalDate和LocalTime:

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalAccessor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 快速时间格式化工具,支持时间字符串解析和格式化
* 支持的pattern格式有
* <table class="striped">
* <thead>
* <tr>
* <th scope="col">符号</th> <th scope="col">含义</th> <th scope="col">示例</th>
* </tr>
* </thead>
* <tbody>
* <tr>
* <th>yyyy</th> <td>年份</td> <td>1970</td>
* </tr>
* <th>MM</th> <td>月份</td> <td>01</td>
* </tr>
* </tr>
* <th>dd</th> <td>日期</td> <td>02</td>
* </tr>
* </tr>
* <th>HH</th> <td>时</td> <td>03</td>
* </tr>
* </tr>
* <th>mm</th> <td>分</td> <td>04</td>
* </tr>
* </tr>
* <th>ss</th> <td>秒</td> <td>05</td>
* </tr>
* </tr>
* <th>SSS</th> <td>毫秒数</td> <td>789</td>
* </tr>
* </tbody>
* </table>
*
* @author brooke_zb
* @since 2024/6/17
*/
public class FastDateTimeFormatter {
private final Node[] nodes;
private static final Map<Character, Integer> PATTERN_INDEX = Arrays.stream(NodeType.values())
.filter(e -> e != NodeType.TEXT)
.collect(Collectors.toMap(e -> e.pattern.charAt(0), Enum::ordinal));
private FastDateTimeFormatter(String pattern) {
int[] count = new int[7]; // y, M, d, H, m, s, S
List<MutableNode> nodes = new ArrayList<>();
int start = 0;
char last = 0;
for (int i = 0; i < pattern.length(); i++) {
char c = pattern.charAt(i);
if (PATTERN_INDEX.containsKey(c)) {
int index = PATTERN_INDEX.get(c);
count[index]++;
NodeType type = NodeType.values()[index];
if (type.length == count[index]) {
addNode(nodes, NodeType.values()[index], null);
clearExcept(count, index);
start = i + 1;
} else {
if (last != c || i == pattern.length() - 1) {
addNode(nodes, NodeType.TEXT, pattern.substring(start, i));
start = i;
}
}
} else {
addNode(nodes, NodeType.TEXT, pattern.substring(start, i + 1));
clearExcept(count, -1);
start = i + 1;
}
last = c;
}
this.nodes = nodes.stream().map(MutableNode::toNode).toArray(Node[]::new);
}
private static void addNode(List<MutableNode> nodes, NodeType type, String value) {
if (type == NodeType.TEXT) {
if (value.isEmpty()) {
return;
}
if (nodes.isEmpty() || nodes.get(nodes.size() - 1).type != NodeType.TEXT) {
nodes.add(new MutableNode(type, value));
return;
}
nodes.get(nodes.size() - 1).append(value);
return;
}
nodes.add(new MutableNode(type, value));
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (Node node : nodes) {
if (node.type == NodeType.TEXT) {
sb.append(node.value);
} else {
sb.append(node.type.pattern);
}
}
return sb.toString();
}
/**
* 从格式串创建FastDateTimeFormatter实例
* 支持的格式有yyyy, MM, dd, HH, mm, ss, SSS
*/
public static FastDateTimeFormatter ofPattern(String pattern) {
if (pattern == null || pattern.isEmpty()) {
throw new IllegalArgumentException("Invalid pattern: " + pattern);
}
return new FastDateTimeFormatter(pattern);
}
/**
* 将时间字符串解析为LocalDateTime
*/
public LocalDateTime parse(CharSequence text) {
int[] values = parseDateTimeValues(text);
return LocalDateTime.of(values[0], values[1], values[2], values[3], values[4], values[5], values[6] * 1000000);
}
/**
* 将时间字符串解析为LocalDate
*/
public LocalDate parseDate(CharSequence text) {
int[] values = parseDateTimeValues(text);
return LocalDate.of(values[0], values[1], values[2]);
}
/**
* 将时间字符串解析为LocalTime
*/
public LocalTime parseTime(CharSequence text) {
int[] values = parseDateTimeValues(text);
return LocalTime.of(values[3], values[4], values[5], values[6] * 1000000);
}
private int[] parseDateTimeValues(CharSequence text) {
int[] values = new int[7];
int start = 0;
for (Node node : nodes) {
if (node.type == NodeType.TEXT) {
start += node.value.length();
} else {
int end = start + node.type.length;
int val = 0;
for (int i = start; i < end; i++) {
val = val * 10 + (text.charAt(i) - '0');
}
values[node.type.ordinal()] = val;
start = end;
}
}
return values;
}
/**
* 格式化为字符串
*/
public String format(TemporalAccessor dateTime) {
StringBuilder sb = new StringBuilder();
for (Node node : nodes) {
if (node.type == NodeType.TEXT) {
sb.append(node.value);
} else {
switch (node.type) {
case YEAR:
sb.append(toAlignNumber(dateTime.get(ChronoField.YEAR), 4));
break;
case MONTH:
sb.append(toAlignNumber(dateTime.get(ChronoField.MONTH_OF_YEAR), 2));
break;
case DAY:
sb.append(toAlignNumber(dateTime.get(ChronoField.DAY_OF_MONTH), 2));
break;
case HOUR:
sb.append(toAlignNumber(dateTime.get(ChronoField.HOUR_OF_DAY), 2));
break;
case MINUTE:
sb.append(toAlignNumber(dateTime.get(ChronoField.MINUTE_OF_HOUR), 2));
break;
case SECOND:
sb.append(toAlignNumber(dateTime.get(ChronoField.SECOND_OF_MINUTE), 2));
break;
case MILLISECOND:
sb.append(toAlignNumber(dateTime.get(ChronoField.MILLI_OF_SECOND), 3));
break;
}
}
}
return sb.toString();
}
private String toAlignNumber(int value, int length) {
String s = String.valueOf(value);
if (s.length() >= length) {
return s;
}
return "0".repeat(length - s.length()) + s;
}
private void clearExcept(int[] count, int exceptIndex) {
for (int i = 0; i < count.length; i++) {
if (i != exceptIndex) {
count[i] = 0;
}
}
}
private static final class Node {
final NodeType type;
final String value;
Node(NodeType type, String value) {
this.type = type;
this.value = value;
}
}
private static class MutableNode {
NodeType type;
StringBuilder value;
MutableNode(NodeType type, String value) {
this.type = type;
if (type == NodeType.TEXT) {
this.value = new StringBuilder(value);
}
}
void append(String s) {
value.append(s);
}
Node toNode() {
if (type == NodeType.TEXT) {
return new Node(type, value.toString());
}
return new Node(type, null);
}
}
private enum NodeType {
YEAR("yyyy", 4),
MONTH("MM", 2),
DAY("dd", 2),
HOUR("HH", 2),
MINUTE("mm", 2),
SECOND("ss", 2),
MILLISECOND("SSS", 3),
TEXT("", 0),
;
public final String pattern;
public final int length;
NodeType(String pattern, int length) {
this.pattern = pattern;
this.length = length;
}
}
}

附上一个Benchmark结果,解析时间字符串场景下比LocalDateTime.parse要快上15倍左右(仅供参考):

Benchmark Mode Cnt Score Error Units
DateTimeUtilTest.testFastDateTimeFormatter_parse thrpt 200 90359.275 ± 557.471 ops/ms
DateTimeUtilTest.testLocalDateTime_parse thrpt 200 6018.009 ± 39.635 ops/ms