SimpleDateFormat在多线程下的安全问题
目录
情景重现
SimpleDateFormat解析
解决方案
局部变量
加锁
使用线程变量
使用DateTimeFormatter
情景重现
SimpleDateFormat类是Java开发中的一个日期时间的转化类。它可以满足绝大多数的开发场景,但是在高并发下会出现并发问题。接下来查看下文中的案例。
public class TestSimpleDateFormat {
public static void main(String[] args) {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 5; i++) {
new Thread(()->{
try {
Date parse = format.parse("2003-01-01");
System.out.println(parse);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
上面代码简单来说就是创建了一个SimpleDateFormat类对象,该对象被后续会被五个线程使用,去转化日期格式并打印。我们来查看输出结果。
java.lang.NumberFormatException: empty String
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1842)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2056)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at test.lambda$main$0(test.java:21)
at java.lang.Thread.run(Thread.java:745)
java.lang.NumberFormatException: multiple points
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2056)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at test.lambda$main$0(test.java:21)
at java.lang.Thread.run(Thread.java:745)
java.lang.NumberFormatException: multiple points
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2056)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at test.lambda$main$0(test.java:21)
at java.lang.Thread.run(Thread.java:745)
java.lang.NumberFormatException: empty String
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1842)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2056)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at test.lambda$main$0(test.java:21)
at java.lang.Thread.run(Thread.java:745)
Fri Nov 01 00:00:00 CST 2222
可以看到,只输出了一次时间转化,并且该输出格式还是错误的。接下来我们来查看为什么SimpleDateFormat类是线程不安全的。
SimpleDateFormat解析
我们根据parse()方法,查看SimpleDateFormat是如何进行格式转换的。
我们可以看到,返回结果是根据另一个方法获取到的,接下来我们接着查看该parse()源码。
这是一个抽象方法,接着我们去查看它的具体实现。
可以看到该方法很长,但是我们只关注返回如何结果,直接拉到最后查看该方法如何返回一个日期格式 。上图中,最后一次修改parseDate对象是在箭头的位置。那么我们查看getTime()方法。
可以看到该方法是由Calendar类提供的,该类名翻译为中文就是日历的意思,并且返回结果也是我们需要的日期格式,那么我们就可以确定该方法用于给parseDate对象提供返回值的,接下来回退一下查看其他方法哪个是提供Calendar对象来调用getTime()方法的。
现在我们清楚了Calendar类对象是由establish()方法提供的了,该方法中需要一个参数calendar对象。该对象由SimpleDateFormat的父类DateFormat来维护。
此时我们或许大概明白是因为SimpleDateFormat类之所以线程不安全的问题是因为在多线程下共享了calendar对象。接下来我们继续查看establish()方法,验证是否是这样,下面是具体源码
Calendar establish(Calendar cal) {
boolean weekDate = isSet(WEEK_YEAR)
&& field[WEEK_YEAR] > field[YEAR];
if (weekDate && !cal.isWeekDateSupported()) {
// Use YEAR instead
if (!isSet(YEAR)) {
set(YEAR, field[MAX_FIELD + WEEK_YEAR]);
}
weekDate = false;
}
cal.clear();
// Set the fields from the min stamp to the max stamp so that
// the field resolution works in the Calendar.
for (int stamp = MINIMUM_USER_STAMP; stamp < nextStamp; stamp++) {
for (int index = 0; index <= maxFieldIndex; index++) {
if (field[index] == stamp) {
cal.set(index, field[MAX_FIELD + index]);
break;
}
}
}
if (weekDate) {
int weekOfYear = isSet(WEEK_OF_YEAR) ? field[MAX_FIELD + WEEK_OF_YEAR] : 1;
int dayOfWeek = isSet(DAY_OF_WEEK) ?
field[MAX_FIELD + DAY_OF_WEEK] : cal.getFirstDayOfWeek();
if (!isValidDayOfWeek(dayOfWeek) && cal.isLenient()) {
if (dayOfWeek >= 8) {
dayOfWeek--;
weekOfYear += dayOfWeek / 7;
dayOfWeek = (dayOfWeek % 7) + 1;
} else {
while (dayOfWeek <= 0) {
dayOfWeek += 7;
weekOfYear--;
}
}
dayOfWeek = toCalendarDayOfWeek(dayOfWeek);
}
cal.setWeekDate(field[MAX_FIELD + WEEK_YEAR], weekOfYear, dayOfWeek);
}
return cal;
}
可以看到,在该方法中,对cal对象执行了clear()方法与set()方法,我们查看clear方法是做什么的
该方法提供了类似初始化的功能,将上一次的格式转化保存的cal属性清除。
而set()方法将本次的格式转换需要的数据更新。因此,我们可以确定了SimpleDateFormat类之所以线程不安全就是因为共享了calendar对象。
解决方案
为了避免SimpleDateFormat格式转换带来的并发问题,我们可以采取以下几个措施
局部变量
我们已经知道了产生线程安全问题的原因是共享了相同属性,那么我们只要让每个线程都包含自己的属性就可以避免该问题的发生。具体实现代码如下
public class TestSimpleDateFormat {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(()->{
try {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
Date parse = format.parse("2003-01-01");
System.out.println(parse);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
运行结果如下
Wed Jan 01 00:00:00 CST 2003
Wed Jan 01 00:00:00 CST 2003
Wed Jan 01 00:00:00 CST 2003
Wed Jan 01 00:00:00 CST 2003
Wed Jan 01 00:00:00 CST 2003
这种方式不太推荐,因为会创建大量的SimpleDateFormat对象,占用内存空间。
加锁
除了让每个线程都拥有自己独立的对象外,我们也可以保证在同一时刻下,只有一个线程对共享属性进行修改,那就是加锁。具体实现代码如下
public class TestSimpleDateFormat{
public static void main(String[] args) {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
Date parse;
synchronized (format){
parse = format.parse("2003-01-01");
}
System.out.println(parse);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
但是这种方法不太推荐,因为能够出现格式转化错误的情况已经是很大的并发了,如果还使用同步锁的话会影响性能。
使用线程变量
public class test {
private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>() {
@Override
protected DateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd");
}
};
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
Date parse = threadLocal.get().parse("2023-01-01");
System.out.println(parse);
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
}
使用DateTimeFormatter
在JDK8之后提供了线程安全的格式转化DateTimeFormatter类,使用方法如下
public class test {
public static void main(String[] args) {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
TemporalAccessor parse = dateTimeFormatter.parse("2003-06-03");
System.out.println(parse);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
输出结果为
{},ISO resolved to 2003-06-03
{},ISO resolved to 2003-06-03
{},ISO resolved to 2003-06-03
{},ISO resolved to 2003-06-03
{},ISO resolved to 2003-06-03