thread-safe之3:SimpleDateFormat安全的时间格式化
想必大家对SimpleDateFormat并不陌生。SimpleDateFormat 是 Java 中一个非常常用的类,该类用来对日期字符串进行解析和格式化输出,但如果使用不小心会导致非常微妙和难以调试的问题,因为 DateFormat 和 SimpleDateFormat 类不都是线程安全的,在多线程环境下调用 format() 和 parse() 方法应该使用同步代码来避免问题。下面我们通过一个具体的场景来一步步的深入学习和理解SimpleDateFormat类。
一.引子
我们都是优秀的程序员,我们都知道在程序中我们应当尽量少的创建SimpleDateFormat 实例,因为创建这么一个实例需要耗费很大的代价。在一个读取数据库数据导出到excel文件的例子当中,每次处理一个时间信息的时候,就需要创建一个SimpleDateFormat实例对象,然后再丢弃这个对象。大量的对象就这样被创建出来,占用大量的内存和 jvm空间。代码如下:
package com.dxz.simpledateformat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; public class DateUtil { public static String formatDate(Date date)throws ParseException{ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); return sdf.format(date); } public static Date parse(String strDate) throws ParseException{ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); return sdf.parse(strDate); } }
你也许会说,OK,那我就创建一个静态的simpleDateFormat实例,然后放到一个DateUtil类(如下)中,在使用时直接使用这个实例进行操作,这样问题就解决了。改进后的代码如下:
package com.dxz.simpledateformat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; public class DateUtil { private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public static String formatDate(Date date)throws ParseException{ return sdf.format(date); } public static Date parse(String strDate) throws ParseException{ return sdf.parse(strDate); } }
当然,这个方法的确很不错,在大部分的时间里面都会工作得很好。但当你在生产环境中使用一段时间之后,你就会发现这么一个事实:它不是线程安全的。在正常的测试情况之下,都没有问题,但一旦在生产环境中一定负载情况下时,这个问题就出来了。他会出现各种不同的情况,比如转化的时间不正确,比如报错,比如线程被挂死等等。我们看下面的测试用例,那事实说话:
package com.dxz.simpledateformat; import java.text.ParseException; public class DateUtilTest { public static class TestSimpleDateFormatThreadSafe extends Thread { @Override public void run() { while (true) { try { this.join(2000); } catch (InterruptedException e1) { e1.printStackTrace(); } try { System.out.println(this.getName() + ":" + DateUtil.parse("2013-05-24 06:02:20")); } catch (ParseException e) { e.printStackTrace(); } } } } public static void main(String[] args) { for (int i = 0; i < 3; i++) { new TestSimpleDateFormatThreadSafe().start(); } } }
执行输出如下:
Exception in thread "Thread-1" java.lang.NumberFormatException: multiple points at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1082) at java.lang.Double.parseDouble(Double.java:510) at java.text.DigitList.getDouble(DigitList.java:151) at java.text.DecimalFormat.parse(DecimalFormat.java:1302) at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1589) at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1311) at java.text.DateFormat.parse(DateFormat.java:335) at com.peidasoft.orm.dateformat.DateNoStaticUtil.parse(DateNoStaticUtil.java:17) at com.peidasoft.orm.dateformat.DateUtilTest$TestSimpleDateFormatThreadSafe.run(DateUtilTest.java:20) Exception in thread "Thread-0" java.lang.NumberFormatException: multiple points at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1082) at java.lang.Double.parseDouble(Double.java:510) at java.text.DigitList.getDouble(DigitList.java:151) at java.text.DecimalFormat.parse(DecimalFormat.java:1302) at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1589) at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1311) at java.text.DateFormat.parse(DateFormat.java:335) at com.peidasoft.orm.dateformat.DateNoStaticUtil.parse(DateNoStaticUtil.java:17) at com.peidasoft.orm.dateformat.DateUtilTest$TestSimpleDateFormatThreadSafe.run(DateUtilTest.java:20) Thread-2:Mon May 24 06:02:20 CST 2021 Thread-2:Fri May 24 06:02:20 CST 2013 Thread-2:Fri May 24 06:02:20 CST 2013 Thread-2:Fri May 24 06:02:20 CST 2013
说明:Thread-1和Thread-0报java.lang.NumberFormatException: multiple points错误,直接挂死,没起来;Thread-2 虽然没有挂死,但输出的时间是有错误的,比如我们输入的时间是:2013-05-24 06:02:20 ,当会输出:Mon May 24 06:02:20 CST 2021 这样的灵异事件。
二.原因
作为一个专业程序员,我们当然都知道,相比于共享一个变量的开销要比每次创建一个新变量要小很多。上面的优化过的静态的SimpleDateFormat版,之所在并发情况下回出现各种灵异错误,是因为SimpleDateFormat和DateFormat类不是线程安全的。下面我们通过看JDK源码来看看为什么SimpleDateFormat和DateFormat类不是线程安全的真正原因:
SimpleDateFormat继承了DateFormat,在DateFormat中定义了一个protected属性的 Calendar类的对象:calendar,在格式化日期时,Calendar变量会首先被设置为用户输入的date变量:在parse/format方法里,有这样一段代码:
protected Calendar calendar; // 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);
calendar.setTime(date)这条语句改变了calendar,稍后,calendar还会用到(在subFormat方法里),而这就是引发问题的根源。想象一下,在一个多线程环境下,有两个线程持有了同一个SimpleDateFormat的实例,分别调用format方法:
这种线程非安全使得线程能看到其他线程的数据,后续的业务操作也引用了其他请求的数据,导致系统出现严重的错误。
三.解决办法
1.需要的时候创建新实例:
说明:在需要用到SimpleDateFormat 的地方新建一个实例,不管什么时候,将有线程安全问题的对象由共享变为局部私有都能避免多线程问题,不过也加重了创建对象的负担。在一般情况下,这样其实对性能影响比不是很明显的。
2.使用同步:同步SimpleDateFormat对象
public static String formatDate(Date date)throws ParseException{ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); return sdf.format(date); }
说明:当线程较多时,当一个线程调用该方法时,其他想要调用此方法的线程就要block,多线程并发量大的时候会对性能有一定的影响。
3.使用ThreadLocal:
package com.peidasoft.dateformat; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; public class ConcurrentDateUtil { private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>() { @Override protected DateFormat initialValue() { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); } }; public static Date parse(String dateStr) throws ParseException { return threadLocal.get().parse(dateStr); } public static String format(Date date) { return threadLocal.get().format(date); } }
另外一种写法:
package com.peidasoft.dateformat; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; public class ThreadLocalDateUtil { private static final String date_format = "yyyy-MM-dd HH:mm:ss"; private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>();
public static DateFormat getDateFormat() { DateFormat df = threadLocal.get(); if(df==null){ df = new SimpleDateFormat(date_format); threadLocal.set(df); } return df; } public static String formatDate(Date date) throws ParseException { return getDateFormat().format(date); } public static Date parse(String strDate) throws ParseException { return getDateFormat().parse(strDate); } }
说明:使用ThreadLocal, 也是将共享变量变为独享,线程独享肯定能比方法独享在并发环境中能减少不少创建对象的开销。如果对性能要求比较高的情况下,一般推荐使用这种方法。
4.抛弃JDK,使用其他类库中的时间格式化类:
1.使用Apache commons 里的FastDateFormat,宣称是既快又线程安全的SimpleDateFormat, 可惜它只能对日期进行format, 不能对日期串进行解析。
2.使用Joda-Time类库来处理时间相关问题
做一个简单的压力测试,方法一最慢,方法三最快,但是就算是最慢的方法一性能也不差,一般系统方法一和方法二就可以满足,所以说在这个点很难成为你系统的瓶颈所在。从简单的角度来说,建议使用方法一或者方法二,如果在必要的时候,追求那么一点性能提升的话,可以考虑用方法三,用ThreadLocal做缓存。
Joda-Time类库对时间处理方式比较完美,建议使用。
5.基于JDK1.8的DateTimeFormatter
jdk1.8中新增了 LocalDate 与 LocalDateTime等类来解决日期处理方法,同时引入了一个新的类DateTimeFormatter来解决日期格式化问题(也是《阿里巴巴开发手册》建议方案)
例如:
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy MM dd"); LocalDate date = LocalDate.parse("2017 06 17", formatter); System.out.println(formatter.format(date));
DateTimeFormatter.java 类
/** * This class is immutable and thread-safe. * * @since 1.8 */ public final class DateTimeFormatter {
源码上作者也加注释说明了,他的类是不可变的,并且是线程安全的。
参考资料:
1.http://dreamhead.blogbus.com/logs/215637834.html
2.http://www.blogjava.net/killme2008/archive/2011/07/10/354062.html