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

it2026-04-10  3

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()方法出现的问题,同样,SimpleDateFormat的parse()方法也会出现线程不安全的问题:

@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类:

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

结合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:

最新回复(0)