日期的转换与格式化在项目中应该是比较常用的了,最近同事小刚出去面试实在是没想到被 SimpleDateFormat 给摆了一道...
面试官:项目中的日期转换怎么用的?SimpleDateFormat 用过吗?能说一下 SimpleDateFormat 线程安全问题吗,以及如何解决?
同事小刚:用过的,平时就是在全局定义一个 static 的 SimpleDateFormat,然后在业务处理方法(controller)中直接使用,至于线程安全... 这个... 倒是没遇到过线程安全问题。
哎,面试官的考察点真的是难以捉摸,吐槽归吐槽,一起来看看这个类吧。
SimpleDateFormat 类主要负责日期的转换与格式化等操作,在多线程的环境中,使用此类容易造成数据转换及处理的不正确,因为 SimpleDateFormat 类并不是线程安全的,但在单线程环境下是没有问题的。
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. 日期格式不同步。 建议为每个线程创建单独的格式实例。 如果多个线程同时访问一种格式,则必须在外部同步该格式。
来看看阿里巴巴 java 开发规范是怎么描述 SimpleDateFormat 的:
无码无真相,接下来我们创建一个线程来模拟 SimpleDateFormat 线程安全问题:
创建 MyThread.java 类:
public class MyThread extends Thread{ private SimpleDateFormat simpleDateFormat; /* 要转换的日期字符串 */ private String dateString; public MyThread(SimpleDateFormat simpleDateFormat, String dateString){ this.simpleDateFormat = simpleDateFormat; this.dateString = dateString; } @Override public void run() { try { Date date = simpleDateFormat.parse(dateString); String newDate = simpleDateFormat.format(date).toString(); if(!newDate.equals(dateString)){ System.out.println("ThreadName=" + this.getName() + " 报错了,日期字符串:" + dateString + " 转换成的日期为:" + newDate); } }catch (ParseException e){ e.printStackTrace(); } } }
创建执行类 Test.java 类:
public class Test { // 一般我们使用SimpleDateFormat的时候会把它定义为一个静态变量,避免频繁创建它的对象实例 private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("YYYY-MM-dd"); public static void main(String[] args) { String[] dateStringArray = new String[] { "2020-09-10", "2020-09-11", "2020-09-12", "2020-09-13", "2020-09-14"}; MyThread[] myThreads = new MyThread[5]; // 创建线程 for (int i = 0; i < 5; i++) { myThreads[i] = new MyThread(simpleDateFormat, dateStringArray[i]); } // 启动线程 for (int i = 0; i < 5; i++) { myThreads[i].start(); } } }
执行截图如下:
从控制台打印的结果来看,使用单例的 SimpleDateFormat 类在多线程的环境中处理日期转换,极易出现转换异常(java.lang.NumberFormatException:multiple points)以及转换错误的情况。
这个时候就需要看看源码了,format() 格式转换方法:
// 成员变量 Calendar protected Calendar calendar; 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; }
我们把重点放在 calendar ,这个 format 方法在执行过程中,会操作成员变量 calendar 来保存时间 calendar.setTime(date)
。
但由于在声明 SimpleDateFormat 的时候,使用的是 static 定义的,那么这个 SimpleDateFormat 就是一个共享变量,SimpleDateFormat 中的 calendar 也就可以被多个线程访问到,所以问题就出现了,举个例子:
假设线程 A 刚执行完 calendar.setTime(date)
语句,把时间设置为 2020-09-01,但线程还没执行完,线程 B 又执行了 calendar.setTime(date)
语句,把时间设置为 2020-09-02,这个时候就出现幻读了,线程 A 继续执行下去的时候,拿到的 calendar.getTime
得到的时间就是线程B改过之后的。
除了 format() 方法以外,SimpleDateFormat 的 parse 方法也有同样的问题。
至此,我们发现了 SimpleDateFormat 的弊端,所以为了解决这个问题就是不要把 SimpleDateFormat 当做一个共享变量来使用。
创建全局工具类 DateUtils.java
public class DateUtils { public static Date parse(String formatPattern, String dateString) throws ParseException { return new SimpleDateFormat(formatPattern).parse(dateString); } public static String format(String formatPattern, Date date){ return new SimpleDateFormat(formatPattern).format(date); } }
所有用到 SimpleDateFormat 的地方全部用 DateUtils 替换,然后看一下执行结果:
好家伙,异常+错误终于是没了,这种解决处理错误的原理就是创建了多个 SimpleDateFormat 类的实例,在需要用到的地方创建一个新的实例,就没有线程安全问题,不过也加重了创建对象的负担,会频繁地创建和销毁对象,效率较低。
synchronized 就不展开介绍了,不了解的小伙伴请移步 > synchronized的底层原理?
变更一下 DateUtils.java
public class DateUtils { private static final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public static Date parse(String formatPattern, String dateString) throws ParseException { synchronized (simpleDateFormat){ return simpleDateFormat.parse(dateString); } } public static String format(String formatPattern, Date date) { synchronized (simpleDateFormat){ return simpleDateFormat.format(date); } } }
简单粗暴,synchronized 往上一套也可以解决线程安全问题,缺点自然就是并发量大的时候会对性能有影响,因为使用了 synchronized 加锁后的多线程就相当于串行,线程阻塞,执行效率低。
ThreadLocal 是 java 里一种特殊的变量,ThreadLocal 提供了线程本地的实例,它与普通变量的区别在于,每个使用该线程变量的线程都会初始化一个完全独立的实例副本。
继续改造 DateUtils.java
public class DateUtils { private static ThreadLocal
ThreadLocal 可以确保每个线程都可以得到单独的一个 SimpleDateFormat 的对象,那么就不会存在竞争问题。
如果项目中还在使用 SimpleDateFormat 的话,推荐这种写法,但这样就结束了吗?
显然不是...
上边提到的阿里巴巴 java 开发手册给出了说明:如果是 JDK8 的应用,可以使用 Instant 代替 Date,LocalDateTime 代替 Calendar,DateTimeFormatter 代替 SimpleDateFormat,官方给出的解释:simple beautiful strong immutable thread-safe。
日期转换,SimpleDateFormat 固然好用,但是现在我们已经有了更好地选择,Java 8 引入了新的日期时间 API,并引入了线程安全的日期类,一起来看看。
新API还引入了 ZoneOffSet 和 ZoneId 类,使得解决时区问题更为简便。
解析、格式化时间的 DateTimeFormatter 类也进行了全部重新设计。
例如,我们使用 LocalDate 代替 Date,使用 DateTimeFormatter 代替 SimpleDateFormat,如下所示:
// 当前日期和时间 String DateNow = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss")); System.out.println(DateNow);
这样就避免了 SimpleDateFormat 的线程不安全问题啦。
此时的 DateUtils.java
public class DateUtils { public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); public static LocalDate parse(String dateString){ return LocalDate.parse(dateString, DATE_TIME_FORMATTER); } public static String format(LocalDate target) { return target.format(DATE_TIME_FORMATTER); } }
SimpleDateFormart 线程不安全问题
SimpleDateFormart 继承自 DateFormart,在 DataFormat 类内部有一个 Calendar 对象引用,SimpleDateFormat 转换日期都是靠这个 Calendar 对象来操作的,比如 parse(String),format(date) 等类似的方法,Calendar 在用的时候是直接使用的,而且是改变了 Calendar 的值,这样情况在多线程下就会出现线程安全问题,如果 SimpleDateFormart 是静态的话,那么多个 thread 之间就会共享这个 SimpleDateFormart,同时也会共享这个 Calendar 引用,那么就出现数据赋值覆盖情况,也就是线程安全问题。(现在项目中用到日期转换,都是使用的 java 8 中的 LocalDate,或者 LocalDateTime,本质是这些类是不可变类,不可变一定程度上保证了线程安全)。
解决方式
在多线程下可以使用 ThreadLocal 修饰 SimpleDateFormart,ThreadLocal 可以确保每个线程都可以得到单独的一个 SimpleDateFormat 的对象,那么就不会存在竞争问题。
项目中推荐的写法
java 8 中引入新的日期类 API,这些类是不可变的,且线程安全的。
以后面试官再问项目中怎么使用日期转换的,尽量就不要说 SimpleDateFormat 了。