SimpleDateFormat 的线程安全说起

SimpleDateFormat 是 Java 中非常常用的一个类,用于解析和格式化日期字符串。这个类想必大家都有用过,但是 SimpleDateFormat 在多线程环境中并不是线程安全的。我刚知道这一点的时候也觉得很奇怪,因为 SimpleDateFormat 就是个工具类而已,为什么还会存在线程安全的问题呢。下面我们具体来看一下。

首先,我们写个简单的例子来验证一下:

 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
35
36
37
38
39
40
41
42
43
44
45
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.CountDownLatch;

public class DateUtil {
    private static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static String format(Date date) {
        return dateFormat.format(date);
    }

    public static Date parse(String dateStr) throws ParseException {
        return dateFormat.parse(dateStr);
    }

    public static void main(String[] args) {
        final CountDownLatch latch = new CountDownLatch(1);
        final String[] strs = new String[] {"2016-01-01 10:24:00", "2016-01-02 20:48:00", "2016-01-11 12:24:00"};
        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        latch.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                    for (int i = 0; i < 10; i++){
                        try {
                            System.out.println(Thread.currentThread().getName()+ "\t" + parse(strs[i % strs.length]));
                            Thread.sleep(100);
                        } catch (ParseException e) {
                            e.printStackTrace();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }).start();
        }
        latch.countDown();
    }
}

输出的部分内容是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Thread-9	Fri Jan 01 10:24:00 CST 2016
Thread-1	Sat Feb 25 00:48:00 CST 20162017
Thread-5	Sat Feb 25 00:48:00 CST 20162017
Exception in thread "Thread-4" Exception in thread "Thread-6" java.lang.NumberFormatException: For input string: "2002.E20022E"
	at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
	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:2162)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at DateUtil.parse(DateUtil.java:24)
	at DateUtil$2.run(DateUtil.java:45)

可以看到,其中的有些线程抛出了运行时异常 NumberFormatException,而有些则输出了奇怪的日期结果 20162017。很明显,在多线程环境中 SimpleDateFormat 确实存在线程安全的问题。

为什么 SimpleDateFormat 不是线程安全的?

在 JDK 的文档中提到了 SimpleDateFormat 的线程安全问题:

Date formats are not synchronized. It is recommended to create separate format instances for each thread. If multiple threads access a format concurrently, it must be synchronized externally.

那么,原因又是什么呢?我们来简单地看一下 SimpleDateFormat 的源码:

 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
    private StringBuffer format(Date date, StringBuffer toAppendTo,
                                FieldDelegate delegate) {
        // 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;
    }

可以看到,在 format() 方法中先将日期存放到一个 Calendar 对象中,而这个 Calender 对象在 SimpleDateFormat 中还是以成员变量存在的。在随后调用 subFormat() 时会再次用到成员变量 calendar。这就是引发问题的根源。在 parse() 方法中也会存在相应的问题。

试想,在多线程环境下,如果两个线程都使用同一个 SimpleDateFormat 实例,那么就有可能存在其中一个线程修改了 calendar 后紧接着另一个线程也修改了 calendar,那么随后第一个线程用到 calendar 时已经不是它所期待的值了。

SimpleDateFormat 其实是有状态的,它使用一个 Calendar 成员变量来保存状态;如果要求 SimpleDateFormatparse()format() 是线程安全的,那么它其实应该是无状态的。将 Calendar 对象作为局部变量,内部在进行方法调用时每次都把它作为参数进行传递,其实就应该可以做到线程安全了。JDK 中 SimpleDateFormat 的实现之所以没有这样做可能是出于性能上的考虑,可以节约每次方法调用时都要创建 Calendar 对象的开销。但这种有状态的设计在某些场景下却反而带来了使用上的不便。

如何保证 SimpleDateFormat 的线程安全

最简单的方法就是每次要使用 SimpleDateFormat 时都创建一个局部的 SimpleDateFormat 对象。局部变量,自然就不存在线程安全的问题了。但如果需要频繁进行调用的话,每次都要创建新的对象,开销太大。

第二种方式,就是对 SimpleDateFormat 进行加锁,这样可以确保同一时间只有一个线程可以持有锁,进而解决线程安全的问题。但是这种方法在多线程竞争激烈的时候会带来效率问题。

第三种方式,就是使用 ThreadLocalThreadLocal 可以确保每个线程都可以得到单独的一个 SimpleDateFormat 的对象,那么自然也就不存在竞争问题了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class DateUtil {
    private static ThreadLocal<SimpleDateFormat> local = new ThreadLocal<SimpleDateFormat>() {
        @Override
        protected SimpleDateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        }
    };

    public static String format(Date date) {
        return local.get().format(date);
    }

    public static Date parse(String dateStr) throws ParseException {
        return local.get().parse(dateStr);
    }
}

ThreadLocal 来实现其实是有点类似于缓存的思路,每个线程都有一个独享的对象,避免了频繁创建对象,也避免了多线程的竞争。

也可以将 SimpleDateFormat 对象的创建进行延迟加载:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class DateUtil {
    private static ThreadLocal<SimpleDateFormat> local = new ThreadLocal<SimpleDateFormat>();

    private static SimpleDateFormat getDateFormat() {
        SimpleDateFormat dateFormat = local.get();
        if (dateFormat == null) {
            dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            local.set(dateFormat);
        }
        return dateFormat;
    }

    public static String format(Date date) {
        return getDateFormat().format(date);
    }

    public static Date parse(String dateStr) throws ParseException {
        return getDateFormat().parse(dateStr);
    }
}