概述
SimpleDateFormat是Java提供的一个格式化和解析日期的工具类,在日常开发中经常会用到,但是由于它是线程不安全的,所以多线程共用一个SimpleDateFormat实例对日期进行解析或者格式化会导致程序出错。
这里来揭示它为何是线程不安全的,以及如何避免该问题。
复现问题
import java.text.ParseException; import java.text.SimpleDateFormat; /** * @author 小工匠 * @version 1.0 * @description: TODO * @date 2021/11/21 14:56 * @mark: show me the code , change the world */ public class SimpleDateFormatTest { // 1 创建单例实例 private static SimpleDateFormat sdf = new SimpleDateFormat("YYYY-MM-dd HH:mm:ss"); public static void main(String[] args) { // 2 开启多个线程,并且欧东 for (int i = 0; i < 10; i++) { Thread thread = new Thread(() -> { try { // 3 使用单例日期解析文本 System.out.println(sdf.parse("2021-11-19 15:15:00")); } catch (ParseException e) { e.printStackTrace(); } }); thread.start(); } } }
代码(1)创建了SimpleDateFormat的一个实例
代码(2)创建10个线程,每个线程都共用同一个sdf对象对文本日期进行解析。
多运行几次代码就会抛出java.lang.NumberFormatException异常,增加线程的个数有利于复现该问题
Exception in thread "Thread-0" Exception in thread "Thread-2" Exception in thread "Thread-1" Exception in thread "Thread-6" Exception in thread "Thread-4" Exception in thread "Thread-8" Exception in thread "Thread-9" Exception in thread "Thread-5" Exception in thread "Thread-7" java.lang.NumberFormatException: For input string: "" 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:2084) 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.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26) at java.lang.Thread.run(Thread.java:748) java.lang.ArrayIndexOutOfBoundsException: 20 at java.text.DigitList.append(DigitList.java:151) at java.text.DecimalFormat.subparse(DecimalFormat.java:2278) at java.text.DecimalFormat.parse(DecimalFormat.java:2036) 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.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26) at java.lang.Thread.run(Thread.java:748) 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:2089) 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.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26) at java.lang.Thread.run(Thread.java:748) java.lang.NumberFormatException: For input string: "" 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:2084) 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.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26) at java.lang.Thread.run(Thread.java:748) 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:2089) 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.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26) at java.lang.Thread.run(Thread.java:748) 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:2089) 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.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26) at java.lang.Thread.run(Thread.java:748) java.lang.ArrayIndexOutOfBoundsException: 19 at java.text.DigitList.append(DigitList.java:151) at java.text.DecimalFormat.subparse(DecimalFormat.java:2278) at java.text.DecimalFormat.parse(DecimalFormat.java:2036) 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.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26) at java.lang.Thread.run(Thread.java:748) 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:2089) 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 .....
源码分析
为了便于分析,首先来看SimpleDateFormat的类图结构
可以看到,每个SimpleDateFormat实例里面都有一个Calendar对象,后面我们就会知道,SimpleDateFormat之所以是线程不安全的,就是因为Calendar是线程不安全的。
Calendar之所以是线程不安全的,是因为其中存放日期数据的变量都是线程不安全的,比如fields、time等。
下面从代码层面来看下parse方法做了什么事情
public Date parse(String source) throws ParseException { ParsePosition pos = new ParsePosition(0); Date result = parse(source, pos); if (pos.index == 0) throw new ParseException("Unparseable date: \"" + source + "\"" , pos.errorIndex); return result; } @Override public Date parse(String text, ParsePosition pos) { ....... // 1 解析日期字符串,将解析好的数据放入CalendarBuilder对象中 CalendarBuilder calb = new CalendarBuilder(); ....... ....... Date parsedDate; try { // 2 使用calb中解析好的日期数据设置calendar parsedDate = calb.establish(calendar).getTime(); } catch (IllegalArgumentException e) { ....... ....... return null; } ....... ....... return parsedDate; }
- 代码(1)的主要作用是解析日期字符串并把解析好的数据放入 CalendarBuilder的实例calb中。CalendarBuilder是一个建造者模式,用来存放后面需要的数据。
- 代码(2)使用calb中解析好的日期数据设置calendar。
calb.establish的代码如下
Calendar establish(Calendar cal) { ..... // 3 重置日期对象cal的属性值 cal.clear(); // 4 使用calb中的属性设置cal // 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); } // 5 返回cal return cal; }
- 代码(3)重置Calendar对象里面的属性值,如下所示。
public final void clear() { for (int i = 0; i < fields.length; ) { stamp[i] = fields[i] = 0; // UNSET == 0 isSet[i++] = false; } areAllFieldsSet = areFieldsSet = false; isTimeSet = false; }
- 代码(4)使用calb中解析好的日期数据设置cal对象。
- 代码(5) 返回设置好的cal对象。
从以上代码可以看出,代码(3)、代码(4)和代码(5)并不是原子性操作。
当多个线程调用parse 方法时,比如线程A执行了代码(3)和代码(4),也就是设置好了cal对象,但是在执行代码(5)之前,线程B执行了代码(3),清空了cal对象。
由于多个线程使用的是一个cal对象,所以线程A执行代码(5)返回的可能就是被线程B清空的对象,当然也有可能线程B执行了代码(4),设置被线程A修改的cal对象,从而导致程序出现错误。
How to Fix ?
每次使用时new一个SimpleDateFormat的实例
每次使用时new一个SimpleDateFormat的实例,这样可以保证每个实例使用自己的Calendar实例,但是每次使用都需要new一个对象,并且使用后由于没有其他引用,又需要回收,开销会很大。
加锁
出错的根本原因是因为多线程下代码(3)、代码(4)和代码(5)三个步骤不是一个原子性操作,那么容易想到的是对它们进行同步,让代码(3)、代码(4)和代码(5)成为原子性操作。
可以使用synchronized进行同步,具体如下。
进行同步意味着多个线程要竞争锁,在高并发场景下这会导致系统响应性能下降。
使用ThreadLocal
使用ThreadLocal,这样每个线程只需要使用一个SimpleDateFormat实例,这相比第一种方式大大节省了对象的创建销毁开销,并且不需要使多个线程同步。
使用ThreadLocal方式的代码如下
import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; /** * @author 小工匠 * @version 1.0 * @description: TODO * @date 2021/11/21 14:56 * @mark: show me the code , change the world */ public class SimpleDateFormatTest { // 1 创建ThreadLocal实例 static ThreadLocal<DateFormat> threadLocal= ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); // static ThreadLocal<DateFormat> threadLocal2 = new ThreadLocal<DateFormat>(){ // @Override // protected DateFormat initialValue() { // return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); // } // }; public static void main(String[] args) { // 2 开启多个线程,并且欧东 for (int i = 0; i < 10; i++) { Thread thread = new Thread(() -> { try { // 3 使用单例日期解析文本 System.out.println(threadLocal.get().parse("2021-11-19 15:15:00")); } catch (ParseException e) { e.printStackTrace(); }finally { // 4 使用完毕,一定要remove threadLocal.remove(); } }); thread.start(); } } }
代码(1)创建了一个线程安全的SimpleDateFormat实例
代码(3)首先使用get()方法获取当前线程下SimpleDateFormat的实例。在第一次调用ThreadLocal的get()方法时,会触发其initialValue方法创建当前线程所需要的SimpleDateFormat对象。
另外需要注意的是,在代码(4)中,使用完线程变量后,要进行清理,以避免内存泄漏。
换API - JodaTime or JDK1.8的时间类
Java 8的日期和时间类包含LocalDate、LocalTime、Instant、Duration以及Period,这些类都包含在java.time包中.
新的日期API中提供了一个DateTimeFormatter类用于处理日期格式化操作,它被包含在java.time.format包中,Java 8的日期类有一个format()方法用于将日期格式化为字符串,该方法接收一个DateTimeFormatter类型参数.
public static void main(String[] args) { // 2 开启多个线程, for (int i = 0; i < 10; i++) { Thread thread = new Thread(() -> { // 3 使用单例日期解析文本 System.out.println(LocalDateTime.parse("2021-11-19 15:15:00", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }); thread.start(); } }
小结
我们这里简单介绍SimpleDateFormat的原理解释了为何SimpleDateFormat是线程不安全的,应该避免在多线程下使用SimpleDateFormat的单个实例。