引子
最近手头上的项目上了一个新功能,每天早上一到公司,就兴致勃勃地登上服务器去查看日志,“窥视”一下跑的正不正常。今天终于碰到“彩蛋”了:
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方法:
- 线程1调用 format 方法,改变了 calendar 这个字段。
- 中断来了。
- 线程2开始执行,它也改变了 calendar。
- 又中断了。
- 线程1回来了,此时,calendar 已然不是它所设的值,而是走上了线程2设计的道路。如果多个线程同时争抢 calendar 对象,则会出现各种问题。比如时间不对,线程挂死等等。
分析一下 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,使用其他类库中的时间格式化类:
- 使用 Apache commons 里的 FastDateFormat,“官宣”是既快又线程安全的 SimpleDateFormat, 可惜它只能对日期进行format(), 不能对日期串进行parse()
- 使用 Joda-Time 类库
其中,方法一和二,简单好用,推荐;方法三性能更优。
总结
这也提醒我们在开发和设计系统的时候注意以下三点:
1、写工具类的时候,要对多线程调用情况下的后果在注释里进行明确说明
2、多线程环境下,对每一个共享变量都要注意其线程安全性
3、我们的类和方法在做设计的时候,要尽量设计成无状态的