目录
文章目录
1.基本介绍
Java自带日期格式化工具DateFormat ,但是DateFormat 的所有实现,包括 SimpleDateFormat 都不是线程安全的,因此你不应该在多线程序中使用,除非是在对外线程安全的环境中使用,如 将 SimpleDateFormat 限制在 ThreadLocal 中。如果你不这么做,在解析或者格式化日期的时候,可能会获取到一个不正确的结果。因此,从日期、时间处理的所有实践来说,我强力推荐使用joda-time 库 或者使用Apache的 FastDateFormat。
2.JDK中的SimpleDateFormat
这里主要是针对jdk7之前的,在jdk8中jdk也提供了线程安全的日期工具类,我们后面会提到。
2.1问题复现
以下代码是复现 SimpleDateFormat中的线程不安全问题:
import java.text.ParseException;
import java.text.SimpleDateFormat;
public class TestSimpleDateFormat {
//(1)创建单例实例
static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
//(2)创建多个线程,并启动
for (int i = 0; i <10 ; ++i) {
Thread thread = new Thread(new Runnable() {
public void run() {
try {
//(3)使用单例日期实例解析文本
System.out.println(sdf.parse("2021-12-13 15:17:27"));
} catch (ParseException e) {
e.printStackTrace();
}
}
});
thread.start();//(4)启动线程
}
}
}
上面代码做了如下几件事:
- 代码(1)创建了SimpleDateFormat的一个实例
- 代码(2)创建10个线程,每个线程都公用同一个sdf对象对文本日期进行解析,多运行几次就会抛出java.lang.NumberFormatException异常,加大线程的个数有利于该问题复现。
- 代码(3)使用单例的simpleDateFormat解析文本
- 代码(4)启动线程
运行以上代码会抛出以下异常:
Exception in thread "Thread-4" Exception in thread "Thread-6" Exception in thread "Thread-7" Exception in thread "Thread-0" Exception in thread "Thread-5" Exception in thread "Thread-8" Exception in thread "Thread-2" Exception in thread "Thread-1" Exception in thread "Thread-3" Exception in thread "Thread-9" java.lang.NumberFormatException: empty String
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1842)
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.njit.dateformat.jdk.TestSimpleDateFormat$1.run(TestSimpleDateFormat.java:16)
at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: multiple points
出现了线程安全问题,导致程序无法正常执行结束。
2.2问题解析
这里我先贴以下SimpleDateFormat的类图结构:
可知每个SimpleDateFormat实例里面有一个Calendar对象,从后面会知道其实SimpleDateFormat之所以是线程不安全的就是因为Calendar是线程不安全的,后者之所以是线程不安全的是因为其中存放日期数据的变量都是线程不安全的,比如里面的fields,time等。
通过追踪源码:
public Date parse(String text, ParsePosition pos)
{
//(1)解析日期字符串放入CalendarBuilder的实例calb中
.....
Date parsedDate;
try {//(2)使用calb中解析好的日期数据设置calendar
parsedDate = calb.establish(calendar).getTime();
...
}
catch (IllegalArgumentException e) {
...
return null;
}
return parsedDate;
}
Calendar establish(Calendar cal) {
...
//(3)重置日期对象cal的属性值
cal.clear();
//(4) 使用calb中中属性设置cal
...
//(5)返回设置好的cal对象
return cal;
}
-
代码(1)主要的作用是解析字符串日期并把解析好的数据放入了 CalendarBuilder的实例calb中,CalendarBuilder是一个建造者模式,用来存放后面需要的数据。
-
代码(3)重置Calendar对象里面的属性值,如下代码:
public final void clear()
{
for (int i = 0; i < fields.length; ) {
stamp[i] = fields[i] = 0; // UNSET == 0
isSet[i++] = false;
}
areAllFieldsSet = areFieldsSet = false;
isTimeSet = false;
}
}
- 代码(4)使用calb中解析好的日期数据设置cal对象
- 代码(5) 返回设置好的cal对象
从上面步骤可知步骤(3)(4)(5)操作不是原子性操作,当多个线程调用parse
方法时候比如线程A执行了步骤(3)(4)也就是设置好了cal对象,在执行步骤(5)前线程B执行了步骤(3)清空了cal对象,由于多个线程使用的是一个cal对象,所以线程A执行步骤(5)返回的就可能是被线程B清空后的对象,当然也有可能线程B执行了步骤(4)被线程B修改后的cal对象。从而导致程序错误。
2.3解决方案
2.3.1方案一:每次都实例化
每次使用时候new一个SimpleDateFormat的实例,这样可以保证每个实例使用自己的Calendar实例,但是每次使用都需要new一个对象,并且使用后由于没有其它引用,就会需要被回收,开销会很大。
2.3.2方案二:使用synchronized同步
究其原因是因为多线程下步骤(3)(4)(5)三个步骤不是一个原子性操作,那么容易想到的是对其进行同步,让(3)(4)(5)成为原子操作,可以使用synchronized进行同步,具体如下:
import java.text.ParseException;
import java.text.SimpleDateFormat;
public class TestSimpleDateFormatImprove {
// (1)创建单例实例
static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
// (2)创建多个线程,并启动
for (int i = 0; i < 10; ++i) {
Thread thread = new Thread(new Runnable() {
public void run() {
try {// (3)使用单例日期实例解析文本
synchronized (sdf) {
System.out.println(sdf.parse("2021-12-13 15:17:27"));
}
} catch (ParseException e) {
e.printStackTrace();
}
}
});
thread.start();// (4)启动线程
}
}
}
使用同步意味着多个线程要竞争锁,在高并发场景下会导致系统响应性能下降。
2.3.3方案三:使用ThreadLocal
这样每个线程只需要使用一个SimpleDateFormat实例相比第一种方式大大节省了对象的创建销毁开销,并且不需要对多个线程直接进行同步,使用ThreadLocal方式代码如下:
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
public class TestSimpleDateFormatThreadLocal {
// (1)创建threadlocal实例
static ThreadLocal<DateFormat> safeSdf = new ThreadLocal<DateFormat>(){
@Override
protected SimpleDateFormat initialValue(){
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
}
};
public static void main(String[] args) {
// (2)创建多个线程,并启动
for (int i = 0; i < 10; ++i) {
Thread thread = new Thread(new Runnable() {
public void run() {
try {// (3)使用单例日期实例解析文本
System.out.println(safeSdf.get().parse("2021-12-13 15:17:27"));
} catch (ParseException e) {
e.printStackTrace();
}
}
});
thread.start();// (4)启动线程
}
}
}
代码(1)创建了一个线程安全的SimpleDateFormat实例,步骤(3)在使用的时候首先使用get()方法获取当前线程下SimpleDateFormat的实例,在第一次调用ThreadLocal的get()方法适合会触发其initialValue方法用来创建当前线程所需要的SimpleDateFormat对象。
3.Joda-Time工具
由于Joda-Time很优秀,在Java 8出现前的很长时间内成为Java中日期时间处理的事实标准,用来弥补JDK的不足。在Java 8中引入的java.time
包是一组新的处理日期时间的API,遵守JSR 310
。值得一提的是,Joda-Time的作者Stephen Colebourne
和Oracle一起共同参与了这些API的设计和实现。
3.1使用
3.1.1引入依赖
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.10.5</version>
</dependency>
下面介绍5个最常用的date-time类:
- Instant - 不可变的类,用来表示时间轴上一个瞬时的点
- DateTime - 不可变的类,用来替换JDK的Calendar类
- LocalDate - 不可变的类,表示一个本地的日期,而不包含时间部分(没有时区信息)
- LocalTime - 不可变的类,表示一个本地的时间,而不包含日期部分(没有时区信息)
- LocalDateTime - 不可变的类,表示一个本地的日期-时间(没有时区信息)
注意:不可变的类,表明了正如Java的String类型一样,其对象是不可变的。即,不论对它进行怎样的改变操作,返回的对象都是新对象。
Instant比较适合用来表示一个事件发生的时间戳。不用去关心它使用的日历系统或者是所在的时区。
DateTime的主要目的是替换JDK中的Calendar类,用来处理那些时区信息比较重要的场景。
LocalDate比较适合表示出生日期这样的类型,因为不关心这一天中的时间部分。
LocalTime适合表示一个商店的每天开门/关门时间,因为不用关心日期部分。
3.1.2使用
JodaTime包中的是线程安全的,日期格式化使用了自己的类来替换了JDK实现中的线程不安全类Calendar,从而保证了线程安全,具体使用如下:
import org.joda.time.*;
public class TestJodaTime {
static DateTimeFormatter baseFormat = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
// 日期格式化
// 方式一
DateTimeFormatter formatter = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss");
DateTime dt = new DateTime();
System.out.println(dt.toString(formatter));
//日期格式化 方式二
String dateFormat = "yyyy-MM-dd HH:mm:ss";
System.out.println(dt.toString(dateFormat));
// 测试线程安全问题
for (int i = 0; i <100 ; ++i) {
Thread thread = new Thread(new Runnable() {
public void run() {
DateTime dateTime = baseFormat.parseDateTime("2021-12-19 17:07:05");
System.out.println(dateTime);
}
});
thread.start();
}
// 获取当前时间
DateTime dt = new DateTime();
System.out.println(dt.toString("yyyy-MM-dd HH:mm:ss"));
// 获取月份及年
dt = new DateTime();
System.out.println(dt.monthOfYear().getAsText());
System.out.println(dt.year().getAsText());
// 获取10年后
dt = new DateTime();
DateTime dtyear10 = dt.year().addToCopy(10);
System.out.println(dtyear10.toString("yyyy-MM-dd HH:mm:ss"));
// 获取当天的零点
dt=new DateTime().withMillisOfDay(0);
System.out.println(dt.toString("yyyy-MM-dd HH:mm:ss"));
//获取明天的零点
dt=new DateTime().withMillisOfDay(0).plusDays(1);
System.out.println(dt.toString("yyyy-MM-dd HH:mm:ss"));
//获取每个月的第一天和最后一天
dt=new DateTime();
System.out.println(dt.dayOfMonth().withMinimumValue().dayOfMonth().get());
System.out.println(dt.dayOfMonth().withMaximumValue().dayOfMonth().get());
// 获取当天的12:30
dt=new DateTime().withHourOfDay(12).withMinuteOfHour(30).withSecondOfMinute(0);
System.out.println(dt.toString("yyyy-MM-dd HH:mm:ss"));
// 获取每个月10号12:30
dt=new DateTime().withDayOfMonth(10).withHourOfDay(12).withMinuteOfHour(30).withSecondOfMinute(0);
System.out.println(dt.toString("yyyy-MM-dd HH:mm:ss"));
// 获取每年3月10号12:30
dt=new DateTime().withMonthOfYear(3).withDayOfMonth(10).withHourOfDay(12).withMinuteOfHour(30).withSecondOfMinute(0);
System.out.println(dt.toString("yyyy-MM-dd HH:mm:ss"));
// 时间差
DateTime start=new DateTime(2021,2,25,9,0);
DateTime end=new DateTime(2021,3,27,13,10);
Period period=new Period(start,end);
System.out.println("month:"+period.getMonths());
System.out.println("days:"+period.getDays());
System.out.println("hours:"+period.getHours());
System.out.println("minutes:"+period.getMinutes());
System.out.println("second:"+period.getSeconds());
// 相差天数
DateTime start2=new DateTime(2021,3,25,9,0);
DateTime end2 =new DateTime(2021,3,27,13,10);
System.out.println(Days.daysBetween(start2,end2).getDays());
//相差小时数 1
DateTime start3=new DateTime(2021,3,25,9,0);
DateTime end3 =new DateTime(2021,3,27,13,10);
System.out.println(Hours.hoursBetween(start3,end3).getHours());
//相差小时数 2
DateTime start4=new DateTime(2021,3,25,9,0);
DateTime end4=new DateTime(2021,3,27,13,10);
Duration duration=new Duration(start4,end4);
System.out.println(duration.getStandardHours());
// 相差分钟数
DateTime start5=new DateTime(2021,3,25,9,0);
DateTime end5 =new DateTime(2021,3,27,13,10);
System.out.println(Minutes.minutesBetween(start5,end5).getMinutes());
// 相差秒数
DateTime start6=new DateTime(2021,3,25,9,0);
DateTime end6 =new DateTime(2021,3,27,13,10);
System.out.println(Seconds.secondsBetween(start6,end6));
// 判断时间是否在某个范围内
DateTime start7=new DateTime(2021,2,25,9, 0);
DateTime end7=new DateTime(2021,3,27,13,10);
Interval interval=new Interval(start7,end7);
System.out.println(interval.contains(new DateTime(2021,3,27,13,00)));
System.out.println(interval.contains(new DateTime(2021,3,27,13,30)));
//是否为闰月
dt = new DateTime();
DateTime.Property month = dt.monthOfYear();
System.out.println("是否闰月:" + month.isLeap());
}
}
3.1.3补充说明
JodaTime中创建DateTime对象的方式有如下几种,更推荐使用第二种方式:
//方法一
DateTime dateTime = new DateTime();
//方法二(推荐使用此方法)
DateTime dateTime = SystemFactory.getClock().getDateTime();
//方法三
DateTime dateTime = new DateTime(
2000, //year
1, // month
1, // day
0, // hour (midnight is zero)
0, // minute
0, // second
0 // milliseconds
);
3.2封装JodaTime工具类
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.joda.time.Months;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.concurrent.TimeUnit;
public class DateTimeUtil {
private static final String STANDARD_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static String dateToStr(Date date, String formatStr) {
if (date == null) {
return StringUtils.EMPTY;
}
DateTime dateTime = new DateTime(date);
return dateTime.toString(formatStr);
}
public static Date strToDate(String dateTimeStr, String formatStr) {
DateTimeFormatter dateTimeFormatter = DateTimeFormat.forPattern(formatStr);
DateTime dateTime = dateTimeFormatter.parseDateTime(dateTimeStr);
return dateTime.toDate();
}
public static String dateToStr(Date date) {
if (date == null) {
return StringUtils.EMPTY;
}
DateTime dateTime = new DateTime(date);
return dateTime.toString(STANDARD_FORMAT);
}
public static Date strToDate(String dateTimeStr) {
DateTimeFormatter dateTimeFormatter = DateTimeFormat.forPattern(STANDARD_FORMAT);
DateTime dateTime = dateTimeFormatter.parseDateTime(dateTimeStr);
return dateTime.toDate();
}
public static String dateToStr(long timestamp, String pattern) {
DateTime dateTime = new DateTime(timestamp);
return dateTime.toString(pattern);
}
public static String dateToStr(long timestamp) {
return dateToStr(timestamp, STANDARD_FORMAT);
}
/**
* 前month月的时间
*
* @param months 月数
* @return date
*/
public static Date minusMonth(Months months) {
DateTime dateTime = new DateTime();
DateTime minus = dateTime.minus(months);
return minus.toDate();
}
public static long currentTime() {
return System.currentTimeMillis();
}
public static void sleep(TimeUnit timeUnit, int timeout) {
try {
timeUnit.sleep(timeout);
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
public static long strToMills(String dateTimeStr) {
DateTimeFormatter dateTimeFormatter = DateTimeFormat.forPattern(STANDARD_FORMAT);
DateTime dateTime = dateTimeFormatter.parseDateTime(dateTimeStr);
return dateTime.getMillis();
}
}
4.JDK8中的日期库
jdk8中的日期库吸收了JodaTime库的优秀方案(JodaTime作者参与API的设计和实现),在使用中两个使用的差别并不是很大。(你可以认为JDK8官方就是借鉴JodaTime)。在JodaTime官网也推荐大家在KDJ8之后迁移到JDK中的日期库中。
4.1使用简单案例
因为和JodaTime的使用差别不是很大,这里只做简单的demo:
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class TestJDK8Time {
static DateTimeFormatter baseFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
// 日期格式化
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime localDateTime = LocalDateTime.now();
System.out.println(formatter.format(localDateTime));
// 日期增减
LocalDateTime localDateTime2 = LocalDateTime.now();
localDateTime2 = localDateTime.plusDays(2); // 增加两天
localDateTime2 = localDateTime.plusHours(2); // 增加两个小时
localDateTime2 = localDateTime.minusDays(1); //减少一天
localDateTime2 = localDateTime2.minusHours(1); // 减少一个小时
// 测试线程安全问题
for (int i = 0; i <100 ; ++i) {
Thread thread = new Thread(new Runnable() {
public void run() {
LocalDateTime parse = LocalDateTime.parse("2021-12-19 17:07:05", baseFormat);
System.out.println(parse);
}
});
thread.start();
}
}
4.2和JodaTime对比
具体使用的区别可参考:Java基础之如何取舍Joda与 Java8 日期库
5.Apache的 FastDateFormat
我们知道,SimpleDateFormat 是线程不安全的,主要原因是 format 方法内部调用 calendar.setTime 方法,整个过程都是没有加锁或同步的,如果同时有多个线程调用到这一步,则会出现线程安全问题。
public final String format(Date date) {
return format(date, new StringBuffer(), DontCareFieldPosition.INSTANCE).toString();
}
// 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);
...
}
所以,大部分时候则是在方法内部 new 出新的 DateFormat 对象再做格式化,如:DateFormat df = new SimpleDateFormat("yyyy-MM-dd");
。
但在高访问量的情况下,频繁创建实例也会导致内存开销大和 GC 频繁问题。
Apache 的 commons-lang 包下有个 FastDateFormat 可以方便的解决上述问题。它主要是用来进行日期的转化的,具体使用如下:
首先引入依赖:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.10</version>
</dependency>
使用代码如下:
import org.apache.commons.lang3.time.FastDateFormat;
import java.text.ParseException;
public class TestFastDateFormat {
static FastDateFormat fdf = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
// 测试线程安全问题
for (int i = 0; i <100 ; ++i) {
Thread thread = new Thread(new Runnable() {
public void run() {
try {
System.out.println(fdf.parse("2021-12-13 18:17:27"));
} catch (ParseException e) {
e.printStackTrace();
}
}
});
thread.start();
}
}
}
原理解析
首先我们看他是如何避免掉线程安全问题:
//FastDateFormat
@Override
public String format(final Date date) {
return printer.format(date);
}
@Override
public String format(final Date date) {
final Calendar c = Calendar.getInstance(timeZone, locale);
c.setTime(date);
return applyRulesToString(c);
}
源码中 Calender 是在 format 方法里创建的,肯定不会出现 setTime 的线程安全问题。这样线程安全疑惑解决了。
然后我们看下他是如何提升性能的,看下对应的源码:
/**
* 获得 FastDateFormat实例,使用默认格式和地区
*
* @return FastDateFormat
*/
public static FastDateFormat getInstance() {
return CACHE.getInstance();
}
/**
* 获得 FastDateFormat 实例,使用默认地区<br>
* 支持缓存
*
* @param pattern 使用{@link java.text.SimpleDateFormat} 相同的日期格式
* @return FastDateFormat
* @throws IllegalArgumentException 日期格式问题
*/
public static FastDateFormat getInstance(final String pattern) {
return CACHE.getInstance(pattern, null, null);
}
这里有用到一个CACHE,看来用了缓存,往下看
private static final FormatCache<FastDateFormat> CACHE = new FormatCache<FastDateFormat>(){
@Override
protected FastDateFormat createInstance(final String pattern, final TimeZone timeZone, final Locale locale) {
return new FastDateFormat(pattern, timeZone, locale);
}
};
//
abstract class FormatCache<F extends Format> {
...
private final ConcurrentMap<MultipartKey, F> cInstanceCache
= new ConcurrentHashMap<>(7);
private static final ConcurrentMap<MultipartKey, String> cDateTimeInstanceCache
= new ConcurrentHashMap<>(7);
...
}
在getInstance 方法中加了ConcurrentMap 做缓存,提高了性能。且我们知道ConcurrentMap 也是线程安全的。
6.总结
如果是使用JDK8以及之后的版本,建议使用JDK自带的时间库来处理日期时间的转换等场景,如果是JDK8之前的版本,则建议使用JodaTime库来处理。