静态使用 SimpleDateFormat 的线程安全问题

0. 背景

有时候,在项目中为了避免过多创建对象,会把SimpleDateFormat的对象声明为静态的。但是这样却会导致在多线程的环境中出问题。

一般的用法是生命一个静态的SimpleDateFormat对象,然后提供一个工具方法来调用它的 format 方法,在多线程的环境下,就会出现一些意想不到的问题。

1
2
3
4
5
6
7
8
9
10
11
// 日期格式化工具类
class DateUtils {
// 使用静态的 SimpleDateFormat 对象来实现
private static SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
public static String format(long time) {
return format.format(time);
}
}

1. 原因分析如下:

SimpleDateFormat 的 format 方法的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Called from Format after creating a FieldDelegate
private StringBuffer format(Date date, StringBuffer toAppendTo,
FieldDelegate delegate) {
// 间接调用 Calendar 中的setTime 方法
// Convert input date to time field list
calendar.setTime(date);
boolean useDateFormatSymbols = useDateFormatSymbols();
for (int i = 0; i < compiledPattern.length; ) {
int tag = compiledPattern[i] >>> 8;
int count = compiledPattern[i++] & 0xff;
if (count == 255) {
count = compiledPattern[i++] << 16;
count |= compiledPattern[i++];
}
switch (tag) {
case TAG_QUOTE_ASCII_CHAR:
toAppendTo.append((char)count);
break;
case TAG_QUOTE_CHARS:
toAppendTo.append(compiledPattern, i, count);
i += count;
break;
default:
subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
break;
}
}
return toAppendTo;
}

可以看到内部的实现是依赖 Calendar 的 setTime 方法的:calendar.setTime(date) 。接下里,就涉及到 Calendar 类的线程安全问题了,但是很不幸,Calendar 的实现也是线程不安全的,它内部的实现原理是通过保存了一个从Unix纪元开始的时间偏移量来定位时间的,而且关键的是它是可变的,通过 setTime 方法可以改变它内部的时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Calendar 中的设置时间方法
/**
* Sets this Calendar's current time from the given long value.
*
* @param millis the new time in UTC milliseconds from the epoch.
* @see #setTime(Date)
* @see #getTimeInMillis()
*/
public void setTimeInMillis(long millis) {
// If we don't need to recalculate the calendar field values,
// do nothing.
// BEGIN Android-changed: Android doesn't have sun.util.calendar.ZoneInfo.
// if (time == millis && isTimeSet && areFieldsSet && areAllFieldsSet
// && (zone instanceof ZoneInfo) && !((ZoneInfo)zone).isDirty()) {
if (time == millis && isTimeSet && areFieldsSet && areAllFieldsSet) {
// END Android-changed: Android doesn't have sun.util.calendar.ZoneInfo.
return;
}
// 可以看到 time 变量是直接在调用者的线程中进行设置的
time = millis;
isTimeSet = true;
areFieldsSet = false;
computeFields();
areAllFieldsSet = areFieldsSet = true;
}

这样的话,问题的原因就清楚了,在多个线程共享该SimpleDateFormat 对象的时候,每一个线程内部都会调用 setTime 来改变这个共享对象,进而出现不可预测的后果。

2. 解决方案

有了前文的分析后,要解决这个问题,其实就是需要解决format 方法的线程同步问题,这就是一个简单的问题了,大致有以下方案可以解决:

  1. 把静态方法的调用为线程安全的,比如加上锁:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 日期格式化工具类
    class DateUtils {
    // 使用静态的 SimpleDateFormat 对象来实现
    private static SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
    // 使用 synchronized 来保证线程同步
    // 当然也可以使用其他的锁实现
    public synchronized static String format(long time) {
    return format.format(time);
    }
    }
  2. 使用ThreadLocal 对象保证 SimpleDateFormat 对象对于每一个线程都是有一个对应的变量的,由于SimpleDateFormat 中的 Caldendar 对象都是随着 SimpleDateFormat 对象的创建而创建的(调用Calendar.getInstance()实现,这个方法会触发创建新的 Calendar 对象),这样就能保证每一个线程中有一个唯一的 Calendar 对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 日期格式化工具类
    // 使用 ThreadLocal 实现
    class DateUtils {
    // 使用静态的 SimpleDateFormat 对象来实现
    private static ThreadLocal<SimpleDateFormat> format = new ThreadLocal<>();
    // 使用 synchronized 来保证线程同步
    // 当然也可以使用其他的锁实现
    public synchronized static String format(long time) {
    if (format.get() == null) {
    // 如果一个线程的对象没设置过的话,获取到的会是空的
    // 针对每一个线程进行初始化
    format.set(new SimpleDateFormat("yyyy-MM-dd"));
    }
    // 调用当前线程的SimpleDateFormat 对象的 format 方法
    return format.get().format(time);
    }
    }

参考资料:
https://zhuanlan.zhihu.com/p/59900271
https://bbs.csdn.net/topics/290031298

坚持原创技术分享,您的支持将鼓励我继续创作!