最近手头上的项目上了一个新功能,每天早上一到公司,就兴致勃勃地登上服务器去查看日志,“窥视”一下跑的正不正常。今天终于碰到“彩蛋”了:
Invalid Date in Date Math String:'2187-02-31T16:00:00Z' ... Invalid Date in Date Math String:'0001-09-31T16:00:00Z' 复制代码
这是什么鬼?怎么会有这样的日期?一会穿越到一百年后,一会穿越到原始社会,我想问那时的2月和9月都有31号了么?
冷静~ 我们先来理一理业务场景:我这边调用S团队的服务,接口参数传了String类型的开始日期和结束日期,格式:yyyy-MM-dd。既然报了“Invalid Date ...”错误,那是不是服务方对它们进行解析时出了问题呢?登上对方的服务器看日志去,发现很多 NumberFormatException:
2019-01-10 00:31:22 380 [com.xxx.xxx.xxx.xxx.util.DataTool]-[WARN] 2019-01-09 00:00:00 parse err java.lang.NumberFormatException: For input string: ".109E2.109E2" 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:2162) at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514) at java.text.DateFormat.parse(DateFormat.java:364) at com.xxx.xxx.xxx.xxx.util.DataTool.CCTToUTC(DataTool.java:29) 2019-01-10 00:31:22 415 [com.xxx.xxx.xxx.xxx.util.DataTool]-[WARN] 2019-01-10 00:00:00 parse err 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:2051) 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.xxx.xxx.xxx.xxx.util.DataTool.CCTToUTC(DataTool.java:29) 复制代码
嗯,"2019-01-09 00:00:00" 和 “2019-01-10 00:00:00” 是我传过来的参数值,对应开始日期和结束日期。这应该没什么问题。那检查一下 DataTool.java 类 CCTToUTC 这个方法的第29行:
public class DataTool { private static Logger logger = Logger.getLogger(DataTool.class); private static SimpleDateFormat dateSdf = new SimpleDateFormat("yyyy-MM-dd"); private static SimpleDateFormat timezoneSdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); public static String CCTToUTC(String timeString) { try { Date date = dateSdf.parse(timeString); // 第29行 Calendar calendar = Calendar.getInstance(); Date tgtDate = new Date(date.getTime() - calendar.getTimeZone().getRawOffset()); return timezoneSdf.format(tgtDate); } catch (Exception e) { logger.warn(timeString+" parse err", e); return timezoneSdf.format(new Date()); } } } 复制代码
代码很简单,定义全局变量 SimpleDateFormat,在 CCTToUTC(String timeString) 中用它对传入的日期进行解析和格式化。但在第一行 parse 的时候就报错了并被捕获到,而后打印了一行 warn 日志,并返回了当前时间 format 后的时间字符串。这不是我们想要的结果。
我怀疑是不是我传入的时间有问题,于是在本类写了个 main 方法,简单 sout 打印调用该方法后的结果,尝试了几个不同的时间串,发现始终得不到上面那些令我“穿越”的日期。
难道是别人也同时调用了该服务该方法?那为何在我这边的服务器日志上打印出来了?不可能。
还是找找自身的问题吧,从我开始调用一步一来分析。。。咦?调用的时候,为了性能,我写了一行很简练的代码:
ids.parallelStream().forEach(id -> invokeMethod(id)); 复制代码
哦,并行处理?-> 并发?-> 线程安全?-> parse?-> SimpleDateFormat类?
是不是找到点线索?如果要进一步真正找到“嫌疑人”,那就还原一下现场嘛。。
package com.jessehuang.dateformat; import java.text.ParseException; import java.util.Date; 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("2019-01-10 00:00:00")); } 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" Exception in thread "Thread-0" 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.jessehuang.SimpleDateFormatTest.parse(SimpleDateFormatTest.java:21) at com.jessehuang.SimpleDateFormatTest$TestSimpleDateFormatThreadSafe.run(SimpleDateFormatTest.java:34) 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.jessehuang.SimpleDateFormatTest.parse(SimpleDateFormatTest.java:21) at com.jessehuang.SimpleDateFormatTest$TestSimpleDateFormatThreadSafe.run(SimpleDateFormatTest.java:34) Thread-2:Sat Jan 10 00:00:00 CST 2201 Thread-2:Thu Jan 10 00:00:00 CST 2019 Thread-2:Thu Jan 10 00:00:00 CST 2019 Thread-2:Thu Jan 10 00:00:00 CST 2019 复制代码
看到了吗?2201这种年份出现了。Thread-1和Thread-0报java.lang.NumberFormatException: multiple points错误,直接挂死,没起来;Thread-2 虽然没有挂死,但输出的时间是有错误的,比如我们输入的时间是:2019-01-10 00:00:00 ,但会输出:Sat Jan 10 00:00:00 CST 2201 这样的令人“穿越”的日期。
是的,破案了,凶手就是你 —— SimpleDateFormat
SimpleDateFormat 是 Java 中一个相当常用的类,该类用于对日期字符串进行解析和格式化,但如果使用不当会导致非常微妙和难以调试的问题,因为它不是线程安全的,在多线程环境下调用 format() 和 parse() 方法很容易产生问题。就像上面我一旦使用 JDK8 的 parallelStream() 来遍历,它就不好使了。
“知其然,必知其所以然” 。我们来分析一下为什么会输出奇怪的“穿越”日期。
我们打开 Dash 来查阅一下 JDK 文档 对于 SimpleDateFormat 的描述:
下面通过源码来看看为什么 SimpleDateFormat 和 DateFormat 类不是线程安全的真正原因:
SimpleDateFormat 继承自 DateFormat,在 DateFormat 中定义了一个 protected 属性的 Calendar 类对象:calendar。因为 Calendar 类牵扯到了时区与本地化,JDK 的实现中使用了成员变量来传递参数,这就造成在多线程的时候会出现错误。
在 format() 方法里,有这样一段代码:
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.setTime(date) 这条语句改变了 calendar ,然后,calendar 还在 subFormat() 方法里被用到,而这就是引发问题的根源。想象一下,在一个多线程环境下,有两个线程持有了同一个SimpleDateFormat 的实例,分别调用format方法:
分析一下 format() 的实现,我们不难发现,用到成员变量 calendar,唯一的好处,就是在调用 subFormat() 时,少了一个参数,却带来了这许多的问题。其实,只要在这里用一个局部变量,一路传递下去,所有问题都将迎刃而解。
方法一:
public class DataTool { private static Logger logger = Logger.getLogger(DataTool.class); public static String CCTToUTC(String timeString) { try { Date date = getDateSdf().parse(timeString); Calendar calendar = Calendar.getInstance(); Date tgtDate = new Date(date.getTime() - calendar.getTimeZone().getRawOffset()); return getTimeZoneSdf().format(tgtDate); } catch (Exception e) { logger.warn(timeString + " parse err", e); return getTimeZoneSdf().format(new Date()); } } private static SimpleDateFormat getTimeZoneSdf() { return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); } private static SimpleDateFormat getDateSdf() { return new SimpleDateFormat("yyyy-MM-dd"); } } 复制代码
在需要用到 SimpleDateFormat 的地方就新建一个实例。不管什么时候,将有线程安全问题的对象由共享变为局部私有都能避免多线程问题,不过也加重了创建对象的负担。在一般情况下,这样其实对性能影响也不是那么明显。
方法二:
public class DateUtil { private static Logger logger = Logger.getLogger(DataTool.class); private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); private static SimpleDateFormat timeZoneSdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); public static String CCTToUTC(String timeString) { try { Date date = parse(timeString); Calendar calendar = Calendar.getInstance(); Date tgtDate = new Date(date.getTime() - calendar.getTimeZone().getRawOffset()); return formatDate(tgtDate); } catch (Exception e) { logger.warn(timeString + " parse err", e); return formatDate(new Date()); } } private static Date parse(String strDate) throws ParseException { synchronized(sdf){ return sdf.parse(strDate); } } private static String formatDate(Date date) throws ParseException { synchronized(timeZoneSdf){ return sdf.format(date); } } } 复制代码
当线程较多时,当一个线程调用该方法时,其他想要调用此方法的线程就要 block,多线程并发量大的时候会对性能有一定的影响。
方法三:
public class DateUtil { private static Logger logger = Logger.getLogger(DataTool.class); private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>() { @Override protected DateFormat initialValue() { return new SimpleDateFormat("yyyy-MM-dd"); } }; private static ThreadLocal<DateFormat> threadLocal2 = new ThreadLocal<DateFormat>() { @Override protected DateFormat initialValue() { return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); } }; public static String CCTToUTC(String timeString) { try { Date date = parse(timeString); Calendar calendar = Calendar.getInstance(); Date tgtDate = new Date(date.getTime() - calendar.getTimeZone().getRawOffset()); return formatDate(tgtDate); } catch (Exception e) { logger.warn(timeString + " parse err", e); return formatDate(new Date()); } } private static Date parse(String dateStr) throws ParseException { return threadLocal.get().parse(dateStr); } private static String formatDate(Date date) throws ParseException { return threadLocal2.get().format(date); } } 复制代码
方法四:抛弃JDK,使用其他类库中的时间格式化类:
其中,方法一和二,简单好用,推荐;方法三性能更优。