【优雅的避坑】不安全!别再共享SimpleDateFormat了

422 阅读5分钟

0x01 开场白

JDK文档中已经明确表明了SimpleDateFormat不应该用在多线程场景中:

Synchronization

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.

然而,并不是所有Javaer都关注到了这句话,依然使用如下的方式进行日期时间格式化:

private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");

@Test
public void longLongAgo() {
    String dateStr = sdf.format(new Date());
    System.out.println("当前时间:" + dateStr);
}

一个线程这样做当然是没问题的。

既然官方文档都说了在多线程访问的场景中必须使用synchronized同步,那么就来验证一下,多线程场景下使用SimpleDateFormat会出现什么问题。

0x02 重现多线程场景使用SimpleDateFormat问题

定义一个线程池,跑多个线程执行对当前日期格式化的操作

/**
 * 定义static的SimpleDateFormat,所有线程共享
 **/
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

/**
 * 定义线程池
 **/
private static ExecutorService threadPool = new ThreadPoolExecutor(16,
        100,
        0L,
        TimeUnit.MILLISECONDS,
        new LinkedBlockingDeque<>(1024),
        new ThreadFactoryBuilder().setNameFormat("[线程-%d]").build(),
        new ThreadPoolExecutor.AbortPolicy()
        );

@SneakyThrows
@Test
public void testFormat() {
    Set<String> results = Collections.synchronizedSet(new HashSet<>());
    // 每个线程都执行“给日期加上一个天数”的操作,每个线程加的天数均不一样,
    // 这样当THREAD_NUMBERS个线程执行完毕后,应该有THREAD_NUMBERS个结果才是正确的
    for (int i = 0; i < THREAD_NUMBERS; i++) {
        Calendar calendar = Calendar.getInstance();
        int addDay = i;
        threadPool.execute(() -> {
            calendar.add(Calendar.DATE, addDay);
            String result = sdf.format(calendar.getTime());
            results.add(result);
        });
    }
    //保证线程执行完
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
    //最后打印结果
    System.out.println("希望:" + THREAD_NUMBERS + ",实际:" + results.size());
}

正常情况下,以上代码results.size()的结果应该是THREAD_NUMBERS。但是实际执行结果是一个小于该值的数字。

上面是format()方法出现的问题,同样,SimpleDateFormatparse()方法也会出现线程不安全的问题:

@SneakyThrows
@Test
public void testParse() {
    String dateStr = "2020-10-22 08:08:08";
    for (int i = 0; i < 20; i++) {
        threadPool.execute(() -> {
            try {
                Date date = sdf.parse(dateStr);
                System.out.println(Thread.currentThread().getName() + "---" + date);
            } catch (ParseException e) {
                e.printStackTrace();
            }
        });
    }
    //保证线程执行完
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
}

运行结果:

[线程-0]---Thu May 22 08:00:08 CST 2228
[线程-3]---Sun Oct 22 08:08:08 CST 8000
[线程-4]---Thu Oct 22 08:08:08 CST 2020
[线程-5]---Thu Oct 22 08:08:08 CST 2020
Exception in thread "[线程-1]" Exception in thread "[线程-2]" java.lang.NumberFormatException: For input string: "101.E1012E2"
	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:1869)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at com.xblzer.tryout.SimpleDateFormatTest.lambda$testParse$1(SimpleDateFormatTest.java:78)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: For input string: "101.E1012E2"
	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:1869)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at com.xblzer.tryout.SimpleDateFormatTest.lambda$testParse$1(SimpleDateFormatTest.java:78)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)
[线程-8]---Wed Jan 22 08:09:28 CST 2020
[线程-11]---Sat Jan 25 16:08:08 CST 2020
[线程-9]---Thu Oct 22 08:08:08 CST 2020
Exception in thread "[线程-12]" java.lang.NumberFormatException: For input string: ""
[线程-10]---Thu Oct 22 08:08:08 CST 2020
	at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
	at java.lang.Long.parseLong(Long.java:601)
	at java.lang.Long.parseLong(Long.java:631)
	at java.text.DigitList.getLong(DigitList.java:195)
	at java.text.DecimalFormat.parse(DecimalFormat.java:2051)
	at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
[线程-13]---Thu Oct 22 08:08:08 CST 2020
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at com.xblzer.tryout.SimpleDateFormatTest.lambda$testParse$1(SimpleDateFormatTest.java:78)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
[线程-14]---Thu Oct 22 08:08:08 CST 2020
	at java.lang.Thread.run(Thread.java:748)
[线程-16]---Thu Oct 22 08:08:08 CST 2020
[线程-18]---Thu Oct 22 08:08:08 CST 2020
[线程-16]---Thu Oct 22 08:08:08 CST 2020
[线程-18]---Thu Oct 22 08:08:08 CST 2020
Exception in thread "[线程-0]" java.lang.NumberFormatException: For input string: ""
[线程-16]---Thu Oct 22 08:08:08 CST 2020
[线程-17]---Thu Oct 22 08:08:08 CST 2020
	at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
	at java.lang.Long.parseLong(Long.java:601)
	at java.lang.Long.parseLong(Long.java:631)
	at java.text.DigitList.getLong(DigitList.java:195)
	at java.text.DecimalFormat.parse(DecimalFormat.java:2051)
	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 com.xblzer.tryout.SimpleDateFormatTest.lambda$testParse$1(SimpleDateFormatTest.java:78)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

不仅有的线程解析的结果不正确,甚至有一些线程还出现了异常!

0x03 原因分析

原因就是因为 SimpleDateFormat 作为一个非线程安全的类,被当做了static共享变量在多个线程中进行使用,这就出现了线程安全问题

来跟一下源码。

format(Date date)方法来源于类DateFormat中的如下方法:

public final String format(Date date)
{
    return format(date, new StringBuffer(),
                  DontCareFieldPosition.INSTANCE).toString();
}

调用abstract StringBuffer format(Date date, StringBuffer toAppendTo, FieldPosition fieldPosition)

public abstract StringBuffer format(Date date, StringBuffer toAppendTo,
                                        FieldPosition fieldPosition);

这是一个抽象方法,具体的实现看SimpleDateFormat类中的实现:

// Called from Format after creating a FieldDelegate
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来保存时间。这就是问题的关键所在。

由于我们在声明SimpleDateFormat sdf的时候,使用的是static 定义的,所以这个sdf就是一个共享的变量,那么SimpleDateFormat中的calendar也可以被多个线程访问到。

例如,[线程-1]刚刚执行完calendar.setTime 把时间设置成 2020-10-22,还没执行完呢,[线程-2]又执行了calendar.setTime把时间改成了 2020-10-23。此时,[线程-1]继续往下执行,执行calendar.getTime得到的时间就是[线程-2]改过之后的。也就是说[线程-1]的setTime的结果被无情的无视了...

0x04 日期格式化的正确姿势

姿势1 使用synchronized

synchronized对共享变量加同步锁,使多个线程排队按照顺序执行,从而避免多线程并发带来的线程安全问题。

@SneakyThrows
@Test
public void testWithSynchronized() {
    Set<String> results = Collections.synchronizedSet(new HashSet<>());
    for (int i = 0; i < THREAD_NUMBERS; i++) {
        Calendar calendar = Calendar.getInstance();
        int addDays = i;
        threadPool.execute(() -> {
            synchronized (sdf) {
                calendar.add(Calendar.DATE, addDays);
                String result = sdf.format(calendar.getTime());
                //System.out.println(Thread.currentThread().getName() + "---" + result);
                results.add(result);
            }
        });
    }
    //保证线程执行完
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
    //最后打印结果
    System.out.println("希望:" + THREAD_NUMBERS + ",实际:" + results.size());
}

姿势2 将SimpleDateFormat设置成局部变量使用

局部变量不会被多个线程共享,也可以避免线程安全问题。

@SneakyThrows
@Test
public void testWithLocalVar() {
    Set<String> results = Collections.synchronizedSet(new HashSet<>());
    for (int i = 0; i < THREAD_NUMBERS; i++) {
        Calendar calendar = Calendar.getInstance();
        int addDays = i;
        threadPool.execute(() -> {
            SimpleDateFormat localSdf = new SimpleDateFormat("yyyy-MM-dd");
            calendar.add(Calendar.DATE, addDays);
            String result = localSdf.format(calendar.getTime());
            //System.out.println(Thread.currentThread().getName() + "---" + result);
            results.add(result);
        });
    }
    //保证线程执行完
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
    //最后打印结果
    System.out.println("希望:" + THREAD_NUMBERS + ",实际:" + results.size());
}

每个线程都定义自己的变量SimpleDateFormat localSdf,格式化localSdf.format(calendar.getTime()),不会有线程安全问题。

姿势3 使用ThreadLocal

ThreadLocal的目的是确保每个线程都可以得到一个自己的 SimpleDateFormat的对象,所以也不会出现多线程之间的竞争问题。

/**
 * 定义线程数量
 **/
private static final int THREAD_NUMBERS = 50;

/**
 * 定义ThreadLocal<SimpleDateFormat>,每个线程都有一个独享的对象
 **/
private static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<>();

/**
 * 定义线程池
 **/
private static ExecutorService threadPool = new ThreadPoolExecutor(16,
        100,
        0L,
        TimeUnit.MILLISECONDS,
        new LinkedBlockingDeque<>(1024),
        new ThreadFactoryBuilder().setNameFormat("[线程-%d]").build(),
        new ThreadPoolExecutor.AbortPolicy()
        );

/**
 * 延迟加载SimpleDateFormat
 **/
private static SimpleDateFormat getDateFormat() {
    SimpleDateFormat dateFormat = dateFormatThreadLocal.get();
    if (dateFormat == null) {
        dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        dateFormatThreadLocal.set(dateFormat);
    }
    return dateFormat;
}

@SneakyThrows
@Test
public void testFormatWithThreadLocal() {
    Set<String> results = Collections.synchronizedSet(new HashSet<>());
    // 每个线程都执行“给日期加上一个天数”的操作,每个线程加的天数均不一样,
    // 这样当THREAD_NUMBERS个线程执行完毕后,应该有THREAD_NUMBERS个结果才是正确的
    for (int i = 0; i < THREAD_NUMBERS; i++) {
        Calendar calendar = Calendar.getInstance();
        int addDay = i;
        threadPool.execute(() -> {
            calendar.add(Calendar.DATE, addDay);
            //获取ThreadLocal中的本地SimpleDateFormat副本
            String result = getDateFormat().format(calendar.getTime());
            results.add(result);
        });
    }
    //保证线程执行完
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
    //最后打印结果
    System.out.println("希望:" + THREAD_NUMBERS + ",实际:" + results.size());
}

关键点就是

getDateFormat().format(calendar.getTime());

getDateFormat()拿到属于自己线程的SimpleDateFormat对象。

运行结果:

姿势4 使用DateTimeFormatter

Java 8之后,JDK提供了DateTimeFormatter类:

DateTimeFormatter

它也可以进行事件、日期的格式化,并且它是不可变的、线程安全的

结合Java 8的LocalDateTime时间操作工具类进行测试验证:

Java 8的LocalDate、LocalTime、LocalDateTime进一步加强了对日期和时间的处理。

/**
 * 定义线程数量
 **/
private static final int THREAD_NUMBERS = 50;

private static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

/**
 * 定义线程池
 **/
private static ExecutorService threadPool = new ThreadPoolExecutor(16,
        100,
        0L,
        TimeUnit.MILLISECONDS,
        new LinkedBlockingDeque<>(1024),
        new ThreadFactoryBuilder().setNameFormat("[线程-%d]").build(),
        new ThreadPoolExecutor.AbortPolicy()
);

@SneakyThrows
@Test
public void testDateTimeFormatter() {
    Set<String> results = Collections.synchronizedSet(new HashSet<>());
    for (int i = 0; i < THREAD_NUMBERS; i++) {
        //这样写为了能用Lambda表达式
        LocalDateTime[] now = {LocalDateTime.now()};
        int addDay = i;
        threadPool.execute(() -> {
            now[0] = now[0].plusDays(addDay);
            //System.out.println(Thread.currentThread().getName() + "====" + now[0]);
            String result = now[0].format(formatter);
            results.add(result);
        });
    }
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
    System.out.println("希望:" + THREAD_NUMBERS + ",实际:" + results.size());
}

结果验证:

0x05 小结

SimpleDateFormat存在线程安全问题,使用以下几种方式解决该问题。

  • 加synchronized同步锁。并发量大的时候会有性能问题,线程阻塞。
  • 将SimpleDateFormat设置为局部变量。会频繁的创建和销毁对象,性能较低。
  • 使用ThreadLocal。推荐使用。
  • 使用Java 8新特性DateTimeFormatter。推荐使用。

优雅的避坑-未完待续

最后

欢迎并感谢关注我的微信公众号 行百里er,回复 java 关键字,您将获得避坑系列原创文章:

还有Java精品pdf: