最近手头上的项目上了一个新功能,天天早上一到公司,就兴致勃勃地登上服务器去查看日志,“窥视”一下跑的正不正常。今天终于碰到“彩蛋”了:java
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号了么?git
冷静~ 咱们先来理一理业务场景:我这边调用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 打印调用该方法后的结果,尝试了几个不一样的时间串,发现始终得不到上面那些令我“穿越”的日期。并发
难道是别人也同时调用了该服务该方法?那为什么在我这边的服务器日志上打印出来了?不可能。app
仍是找找自身的问题吧,从我开始调用一步一来分析。。。咦?调用的时候,为了性能,我写了一行很简练的代码:ide
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,使用其余类库中的时间格式化类:
其中,方法一和二,简单好用,推荐;方法三性能更优。
这也提醒咱们在开发和设计系统的时候注意如下三点:
一、写工具类的时候,要对多线程调用状况下的后果在注释里进行明确说明
二、多线程环境下,对每个共享变量都要注意其线程安全性
三、咱们的类和方法在作设计的时候,要尽可能设计成无状态的