前言
SimpleDateFormat导致的多线程问题
一、问题的起因
SimpleDateFormat 是 Java 中一个非常常用的类,该类用来对日期字符串进行解析和格式化输出,由于DateFormat和SimpleDateFormat 类不都是线程安全的,在多线程环境下调用format()和parse()方法都会引发多线程安全问题。究其原因,是因为每个线程都更改了Calendar值。
format()和parse()方法也是线程不安全的,比如parse()实际调用的是CalenderBuilder的establish来进行解析,其方法中主要步骤不是原子操作。
二、解决方案
1. 方法内部创建SimpleDateFormat
不管是什么时候,将有线程安全的对象由共享变为私有局部变量都可以避免多线程问题,不过也加重了创建对象的负担,虽然随时创建SimpleDateFormat会造成一定的性能影响,而且会对GC产生一定的压力,但这并不是核心问题,只要能产生正确的结果。
private static final String FORMAT_PATTERN = "yyyy-MM-dd HH:mm:ss";
public static String format(Date date) {
if (date == null) {
return "";
}
return new SimpleDateFormat(FORMAT_PATTERN).format(date);
}
2. 将SimpleDateFormat进行同步使用
在每次执行时都对其加锁,这样也会影响性能,想要调用此方法的线程就需要block,当多线程并发量比较大时会对性能产生一定影响。
在任何公共的地方使用该类时,都需要对SimpleDateFormat进行加锁。
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static String formatDate(Date date)throws ParseException{
synchronized(sdf){
return sdf.format(date);
}
}
3. 使用ThreadLocal变量
用空间换时间,这样每个线程就会独立享有一个本地的SimpleDateFormat变量
private static final 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);
}
这样就可以保证每个线程的本地变量都是安全的,不同线程之间并不共享相同的SimpleDateFormat,从而避免了线程安全问题。
如果需要对性能比较敏感,可以采用这种方式,至少比前两种的速度要快,但是占用内存也会稍微大一点。
4. ※ 使用DateTimeFormatter代替SimpleDateFormat
DateTimeFormatter类是不可变的,也是线程安全的。官方文档
Implementation Requirements:
This class is immutable and thread-safe.
Since:
1.8
注意:import java.time.format.DateTimeFormatter;
使用举例:
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Test
public void test7() {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(FORMAT_PATTERN);
LocalDateTime localDateTime = LocalDateTime.now();
System.out.println(localDateTime.format(dateTimeFormatter));
LocalDateTime localDateTime2 = LocalDateTime.parse("2020-05-20 05:20:00", dateTimeFormatter);
System.out.println(localDateTime2);
}
5. ※ 使用优秀的第三方库,例如Joda-Time
Joda-Time
Joda-Time内部是通过ConcurrentHashMap实现来线程安全的,与我们自己手写ThreadLocal来保证线程安全类似。
下面开始介绍其使用。
1. 引入Maven依赖
<!-- https://mvnrepository.com/artifact/joda-time/joda-time -->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.10.10</version>
</dependency>
2. 核心类介绍
下面介绍5个最常用的date-time类:
- Instant - 不可变的类,用来表示时间轴上一个瞬时的点
- DateTime - 不可变的类,用来替换JDK的Calendar类
- LocalDate - 不可变的类,表示一个本地的日期,而不包含时间部分(没有时区信息)
- LocalTime - 不可变的类,表示一个本地的时间,而不包含日期部分(没有时区信息)
- LocalDateTime - 不可变的类,表示一个本地的日期-时间(没有时区信息)
注意:不可变的类,其对象是不可变的。即,不论对它进行怎样的改变操作,返回的对象都是新对象。典型代表:Java的String类型。
Instant比较适合用来表示一个事件发生的时间戳。不用去关心它使用的日历系统或者是所在的时区。
DateTime的主要目的是替换JDK中的Calendar类,用来处理那些时区信息比较重要的场景。
LocalDate比较适合表示出生日期这样的类型,因为不关心这一天中的时间部分。
LocalTime适合表示一个商店的每天开门/关门时间,因为不用关心日期部分。
2.1 DateTime类
DateTime类是Joda-Time很重要的一个类
2.1.1 DateTime()
这个无参的构造方法会创建一个在当前系统所在时区的当前时间,精确到毫秒。
DateTime dt1 = new DateTime();
System.out.println(dt1); // 2021-05-18T17:32:27.272+08:00
2.1.2 DateTime(int year, int monthOfYear, int dayOfMonth, int hourOfDay, int minuteOfHour, int secondOfMinute);
这个构造方法方便快速地构造一个指定的时间,这里精确到秒,类似地其它构造方法也可以传入毫秒。
DateTime dt2 = new DateTime(2020, 5, 20, 15, 20, 0, 33);
System.out.println(dt2); // 2020-05-20T15:20:00.033+08:00
2.1.3 DateTime(long instant):
这个构造方法创建出来的实例,是通过一个long类型的时间戳,它表示这个时间戳距1970-01-01T00:00:00Z的毫秒数。使用默认的时区。
DateTime dt3 = new DateTime(System.currentTimeMillis() - 100_000_000L);
System.out.println(dt3); // 2021-05-17T14:16:01.590+08:00
2.1.4 DateTime(Object instant):
这个构造方法可以通过一个Object对象构造一个实例。
这个Object对象可以是这些类型:ReadableInstant, String, Calendar和Date。
其中String的格式需要是ISO8601格式,详见:ISODateTimeFormat.dateTimeParser()
DateTime dt4 = new DateTime("2020-05-20T00:00:00.000+08:00");
System.out.println(dt4); // 2020-05-20T00:00:00.000+08:00
2.2 访问DateTime实例
当你有一个DateTime实例的时候,就可以调用它的各种方法,获取需要的信息。
2.2.1 with开头的方法(比如:withYear):
用来设置DateTime实例到某个时间,因为DateTime是不可变对象,所以没有提供setter方法可供使用,with方法也没有改变原有的对象,而是返回了设置后的一个副本对象。
下面这个例子,将2020-02-29的年份设置为2021。值得注意的是,因为2021年没有2月29日,所以自动转为了28日。
DateTime dt = new DateTime(2020,2,29,0,0,0);
System.out.println(dt); // 2020-02-29T00:00:00.000+08:00
DateTime dt2021Year = dt.withYear(2021);
System.out.println(dt2021Year); // 2021-02-28T00:00:00.000+08:00
DateTime dt2021Month = dt.withMonthOfYear(5);
System.out.println(dt2021Month); // 2020-05-29T00:00:00.000+08:00
2.2.2 plus/minus开头的方法(比如:plusDay, minusMonths):
用来返回在DateTime实例上增加或减少一段时间后的实例。
下面的例子:在当前的时刻加1天,得到了明天这个时刻的时间;在当前的时刻减1个月,得到了上个月这个时刻的时间。
注意:在增减时间的时候,想象成自己在翻日历,所有的计算都将符合历法,由Joda-Time自动完成,不会出现非法的日期(比如:3月31日加一个月后,并不会出现4月31日)。
System.out.println(dt.plusDays(1)); // 2020-03-01T00:00:00.000+08:00
System.out.println(dt.minusMonths(1)); // 2020-01-29T00:00:00.000+08:00
2.2.3 返回Property的方法:
Property是DateTime中的属性,保存了一些有用的信息。
Property对象中的一些方法在这里一并介绍。下面的例子展示了,我们可以通过不同Property中get开头的方法获取一些有用的信息:
DateTime dateTime = new DateTime(); //2021-05-18T14:12:48.624+08:00
System.out.println(dateTime.monthOfYear().getAsText()); //五月
System.out.println(dateTime.monthOfYear().getAsText(Locale.KOREAN)); //5월
System.out.println(dateTime.dayOfWeek().getAsShortText()); //星期二
System.out.println(dateTime.dayOfWeek().getAsShortText(Locale.CHINESE)); //星期二
2.2.4 有时我们需要对一个DateTime的某些属性进行置0操作
比如,我想得到当天的0点时刻。那么就需要用到Property中round开头的方法(roundFloorCopy)。如下面的例子所示:
// dateTime = 2021-05-18T14:18:45.624+08:00
System.out.println(dateTime.dayOfWeek().roundFloorCopy()); // 2021-05-18T00:00:00.000+08:00
System.out.println(dateTime.dayOfWeek().roundCeilingCopy()); // 2021-05-19T00:00:00.000+08:00
System.out.println(dateTime.minuteOfDay().roundFloorCopy()); // 2021-05-18T14:18:00.000+08:00
System.out.println(dateTime.minuteOfDay().roundCeilingCopy()); // 2021-05-18T14:19:00.000+08:00
2.2.5 其它:还有许多其它方法
比如dateTime.year().isLeap()来判断是不是闰年
它们的详细含义,请参照Java Doc。
System.out.println(dateTime.getYear() + "年是闰年:" + dateTime.year().isLeap()); // 2021年是闰年:false
2.3 日历系统和时区
Joda-Time默认使用的是ISO的日历系统,而ISO的日历系统是世界上公历的事实标准。然而,值得注意的是,ISO日历系统在表示1583年之前的历史时间是不精确的。
Joda-Time默认使用的是JDK的时区设置。如果需要的话,这个默认值是可以被覆盖的。
Joda-Time使用可插拔的机制来设计日历系统,而JDK则是使用子类的设计,比如GregorianCalendar。
下面的代码,通过调用一个工厂方法获得Chronology的实现:
Chronology coptic = CopticChronology.getInstance();
System.out.println(coptic); // CopticChronology[Asia/Shanghai]
时区是作为chronology的一部分来被实现的。
下面的代码获得一个Joda-Time chronology在东京的时区:
DateTimeZone zone = DateTimeZone.forID("Asia/Tokyo");
Chronology gregorian = GJChronology.getInstance(zone);
System.out.println(gregorian); // GJChronology[Asia/Tokyo]
2.4 Interval和Period
Joda-Time为时间段的表示提供了支持。
- Interval:它保存了一个开始时刻和一个结束时刻,因此能够表示一段时间,并进行这段时间的相应操作
- Period:它保存了一段时间,比如:6个月,3天,7小时这样的概念。可以直接创建Period,或者从Interval对象构建。
- Duration:它保存了一个精确的毫秒数。同样地,可以直接创建Duration,也可以从Interval对象构建。
虽然,这三个类都用来表示时间段,但是在用途上来说还是有一些差别。请看下面的例子:
TimeZone zone = TimeZone.getTimeZone("Europe/Chisinau");
System.out.println(zone.useDaylightTime()); //判断该时区是不是用的夏令时 // true
DateTimeZone dateTimeZone = DateTimeZone.forID("Europe/Chisinau");
DateTime dt = new DateTime(2005, 3, 26, 12, 0, 0, 0, dateTimeZone);
DateTime dt2 = new DateTime(2005, 4, 26, 12, 0, 0, 0, dateTimeZone);
System.out.println(dt); // 2005-03-26T12:00:00.000+02:00
// 不论使用什么时区,通过Period日期+1获取的时间永远是明天的这个时刻
DateTime plusPeriod = dt.plus(Period.days(1));
System.out.println(plusPeriod); // 2005-03-27T12:00:00.000+03:00
// 但是如果使用了夏令时时区(一天23h),则使用Duration加24小时获得的时间就不是明天的这个时刻,多加1h
DateTime plusDuration = dt.plus(new Duration(24L*60L*60L*1000L));
System.out.println(plusDuration); // 2005-03-27T13:00:00.000+03:00
Interval interval = new Interval(dt.getMillis(), dt2.getMillis());
System.out.println(interval); // 2005-03-26T18:00:00.000+08:00/2005-04-26T17:00:00.000+08:00
System.out.println(interval.contains(new DateTime(2005, 4, 10, 12, 0, 0, 0).getMillis())); // true
因为地区执行夏令时的原因,在添加一个Period的时候会添加23个小时。而添加一个Duration,则会精确地添加24个小时,而不考虑历法。所以,Period和Duration的差别不但体现在精度上,也同样体现在语义上。因为,有时候按照有些地区的历法 1天 不等于 24小时。
总结
- 推荐使用优秀的第三方库,例如Joda-Time;或者使用DateTimeFormatter代替SimpleDateFormat;来避免SimpleDateFormat在多线程环境下的时间异常;
- 本文Joda-Time部分的参考文档:Joda-Time的官方文档 Quick Start,
如你涉及到更多的需求和用法(比如“日期时间的格式化”等),可以参考官方文档 User Guide。