我经常使用SimpleDateFormat
类,将日期在String和Date之间做转化。
- 使用
SimpleDateFormat#parse
方法,可以将满足格式要求的字符串转换成Date对象 - 使用
SimpleDateFormat#format
方法,可以将Date类型的对象转换成一定格式的字符串
同时,我也注意到 SimpleDateFormat 的某些方法 并非是线程安全的,也就是说在并发环境下,如果多个线程共享 SimpleDateFormat 对象(声明为全局变量或者静态全局变量等),就有可能会出现线程安全问题。比如典型的 i++问题。
SimpleDateFormat 的 javadoc 的部分描述:
Date formats are not synchronized.
It is recommended to create separate format instances for each thread.
If multiple threads access a format concurrently, it must be synchronized externally.
大致意思是:日期格式不是同步的。建议为每个线程创建单独的SimpleDateFormat实例。如果多个线程同时访问一种SimpleDateFormat实例,则必须在外部同步该实例。
《阿里巴巴开发手册》的描述:
测试 SimpleDateFormat#parse 方法
public class SimpleDateFormatTest {
public static void main(String[] args) {
new SimpleDateFormatTest().threadUnsafeTest();
}
// SimpleDateFormat类的线程安全问题
// 总任务数
private static final int EXECUTE_COUNT = 100;
// 同时执行的任务数的上限
private static final int THREAD_COUNT = 20;
// 日期格式化器
// 这里非静态也可以,主要是让多个线程共享这个 SimpleDateFormat 对象
private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
/**
* 使用线程池结合Java并发包中的CountDownLatch类和Semaphore类来重现SimpleDateFormat的线程安全问题。
* 1 CountDownLatch 类可以使一个线程等待其他线程各自执行完毕后再执行。
* 2 Semaphore 类可以理解为一个计数信号量,必须由获取它的线程释放,经常用来限制访问某些资源的线程数量,例如限流等。
*
*/
public void threadUnsafeTest(){
// 信号量,类似于 PV 操作的信号量
final Semaphore semaphore = new Semaphore(THREAD_COUNT);
//
final CountDownLatch countDownLatch = new CountDownLatch(EXECUTE_COUNT);
// CachedThreadPool 线程池:
// 构造方法:
// new ThreadPoolExecutor(0, Integer.MAX_VALUE,
// 60L, TimeUnit.SECONDS,
// new SynchronousQueue<Runnable>());
// 任务执行的流程:
// 1 因为没有核心线程,所以,会直接向 SynchronousQueue 中提交任务;
// 2 如果线程池中有空闲线程,就去取出任务执行;如果没有空闲线程,就新建一个;
// 3 执行完任务的线程有 60 秒生存时间,如果在这个时间内可以接到新任务,就可以继续活下去,否则就拜拜;
// 4 由于空闲 60 秒的线程会被终止,长时间保持空闲的 CachedThreadPool 不会占用任何资源。
// SynchronousQueue 阻塞队列:
// CachedThreadPool 使用的阻塞队列是 SynchronousQueue。
// SynchronousQueue 是一个内部只能包含一个元素的队列。
// 插入元素到队列的线程被阻塞,直到另一个线程从队列中获取了队列中存储的元素。
// 同样,如果线程尝试获取元素并且当前不存在任何元素,则该线程将被阻塞,直到线程将元素插入队列。
// 用途:
// CachedThreadPool 用于并发执行大量短期的小任务,或者是负载较轻的服务器。
ExecutorService executor = Executors.newCachedThreadPool();
for (int i = 0; i < EXECUTE_COUNT; i++) {
executor.execute(() ->{
try {
// 限制最多同时只能有 20 个线程并发执行
semaphore.acquire();
try {
// String 字符串 解析为 Date对象
sdf.parse(LocalDate.now().toString());
} catch (ParseException e) {
System.out.println("线程 " + Thread.currentThread().getName() + "格式化日期失败");
e.printStackTrace();
System.exit(1);
}
// 释放信号量
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
System.exit(1);
}
//
countDownLatch.countDown();
});
}
try {
// main 线程阻塞,阻塞到所有的线程执行完成为止
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 等待所有任务执行结束,再关闭线程池
executor.shutdown();
}
System.out.println("所有日期格式化完成");
}
}
运行后,报错如下:
Exception in thread "pool-1-thread-2" Exception in thread "pool-1-thread-1" 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 test.treadUnsafe.SimpleDateFormatTest.lambda$threadUnsafeTest$0(SimpleDateFormatTest.java:77)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
我发现,部分线程报 java.lang.NumberFormatException:multiple points错,可见并发环境下使用SimpleDateFormat#parse
方法,确实有线程安全问题!
线程安全问题的原因
在SimpleDateFormat
转换日期是通过Calendar
对象来操作的。
SimpleDateFormat
继承自DateFormat
类,DateFormat
类中有一个Calendar
对象属性,如下:
// `SimpleDateFormat`继承自`DateFormat`类
public class SimpleDateFormat extends DateFormat{
// 略
}
// `DateFormat`类中有一个`Calendar`对象属性
public abstract class DateFormat extends Format {
/**
* The {@link Calendar} instance used for calculating the date-time fields
* and the instant of time. This field is used for both formatting and
* parsing.
*
* <p>Subclasses should initialize this field to a {@link Calendar}
* appropriate for the {@link Locale} associated with this
* <code>DateFormat</code>.
* @serial
*/
// 注释的大致意思是:
// 此处Calendar实例被用来进行日期-时间计算,
// 既被用于format方法也被用于parse方法
protected Calendar calendar;
// 略
}
通过进入到源码中,我发现,SimpleDateFormat#parse(String)
实际继承自 DateFormat#parse(String)
:
sdf.parse(LocalDate.now().toString());
打开DateFormat#parse(String)
方法,如下所示:
public Date parse(String source) throws ParseException
{
ParsePosition pos = new ParsePosition(0);
// 这里有调用了 DateFormat#parse的重载方法
// public abstract Date parse(String source, ParsePosition pos);
// 可以发现,这是个抽线方法
// 这个重载方法是由 SimpaleDateFormat 实现的
Date result = parse(source, pos);
if (pos.index == 0)
throw new ParseException("Unparseable date: \"" + source + "\"" ,
pos.errorIndex);
return result;
}
可以看到,在DateFormat#parse(String)
方法中,再次调用了重载方法DateFormat#parse(String, ParsePosition)
方法来格式化日期,这个重载方法是抽象的,这个重载方法具体由其子类 SimpaleDateFormat
实现的。
通过对SimpleDateFormat#parse(String, ParsePosition)
方法的分析可以得知:
-
SimpaleDateFormat#parse(String, ParsePosition)方法中存在几处为
ParsePosition#index
赋值的操作。在高并发场景下,一个线程对ParsePosition#index
进行修改,势必会影响到其他线程对ParsePosition#index
的读操作。这就造成了线程的安全问题。 -
此外, SimpaleDateFormat#parse(String, ParsePosition)方法中调用了
CalenderBuilder#establish
来进行解析,这个方法中又调用了Calender#clear
方法来重置Calender
实例的属性。如果此时线程A执行Calender#clear,且没有设置新值,线程B也进入parse方法用到了SimpleDateFormat对象中的calendar对象,此时就会产生线程安全问题!
注:
CalenderBuilder
是 Calender类的构建器类
测试 SimpleDateFormat#format 方法
public class SimpleDateFormatTest2 {
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 初始化每个线程的时间
private static List<Date> list = new ArrayList<>();
static {
// 每个线程对应处理的Date对象
// 我将会开启10个线程
Date date1 = new Date(2011-1900, Calendar.JANUARY,1);
Date date2 = new Date(2012-1900, Calendar.JANUARY,1);
Date date3 = new Date(2013-1900, Calendar.JANUARY,1);
Date date4 = new Date(2014-1900, Calendar.JANUARY,1);
Date date5 = new Date(2015-1900, Calendar.JANUARY,1);
Date date6 = new Date(2016-1900, Calendar.JANUARY,1);
Date date7 = new Date(2017-1900, Calendar.JANUARY,1);
Date date8 = new Date(2018-1900, Calendar.JANUARY,1);
Date date9 = new Date(2019-1900, Calendar.JANUARY,1);
Date date10 = new Date(2020-1900, Calendar.JANUARY,1);
list.add(date1);
list.add(date2);
list.add(date3);
list.add(date4);
list.add(date5);
list.add(date6);
list.add(date7);
list.add(date8);
list.add(date9);
list.add(date10);
}
public static void main(String[] args) {
// FixedThreadPool 线程池:
// 构造方法:
// new ThreadPoolExecutor(nThreads, nThreads,
// 0L, TimeUnit.MILLISECONDS,
// new LinkedBlockingQueue<Runnable>());
// 可以看到,核心线程数 == 最大线程数,且 KeepLive == 0,
// KeepLive: 表示当核心线程满了(肯定没有超过最大线程数),新创建的线程,在没有执行任务的期间可以存活的最大时间
// KeepLive == 0,则表示不等待直接退出
// 工厂类传递的第一个参数和第二个参数都设置成了nThreads。即线程池的核心线程数和最大线程数相等。
// FixedThreadPool 线程池的工作流程:
// 1 如果当前运行的线程数量小于corePoolSize,则创建新线程来执行任务。
// 2 在线程池中当前运行的线程数量等于corePoolSize,由于无法在创建新的线程进行任务处理,所以会将任务加入到阻塞队列中进行排队等候处理。
// 3 当线程池中的线程执行完一个任务后,就会去阻塞队列中循环获取新的任务继续执行。
// 无边界阻塞队列:LinkedBlockingQueue
// 构造方法:
// public LinkedBlockingQueue() {
// this(Integer.MAX_VALUE);
// }
// 通过构造方法可以看到,这是一个 大小为 Integer.MAX_VALUE 的阻塞队列
// 注意:因为LinkedBlockingQueue是长度为Integer.MAX_VALUE的队列,可以认为是无界队列,
// 因此往队列中可以插入无限多的任务,在资源有限的时候容易引起OOM异常
ExecutorService service = Executors.newFixedThreadPool(10);
try {
// 提交 10个任务
for (int i = 0; i < 10; i++) {
final int t = i;
service.execute(() -> {
Date date = list.get(t % 10);
// 格式化 日期
System.out.println("[" + Thread.currentThread().getName() + "]: 格式化前" + date);
String res = sdf.format(list.get(t % 5));
System.out.println("[" + Thread.currentThread().getName() + "]: 格式化后" + res);
});
}
} finally {
// 等待上述的线程全部执行完,再关闭线程池
service.shutdown();
}
}
}
运行结果:
[pool-1-thread-3]: 格式化前Tue Jan 01 00:00:00 CST 2013
[pool-1-thread-2]: 格式化前Sun Jan 01 00:00:00 CST 2012
[pool-1-thread-4]: 格式化前Wed Jan 01 00:00:00 CST 2014
[pool-1-thread-6]: 格式化前Fri Jan 01 00:00:00 CST 2016
[pool-1-thread-1]: 格式化前Sat Jan 01 00:00:00 CST 2011
[pool-1-thread-5]: 格式化前Thu Jan 01 00:00:00 CST 2015
[pool-1-thread-2]: 格式化后2012-01-01 00:00:00
[pool-1-thread-7]: 格式化前Sun Jan 01 00:00:00 CST 2017
[pool-1-thread-7]: 格式化后2012-01-01 00:00:00
[pool-1-thread-6]: 格式化后2011-01-01 00:00:00
[pool-1-thread-5]: 格式化后2015-01-01 00:00:00
[pool-1-thread-8]: 格式化前Mon Jan 01 00:00:00 CST 2018
[pool-1-thread-4]: 格式化后2014-01-01 00:00:00
[pool-1-thread-1]: 格式化后2011-01-01 00:00:00
[pool-1-thread-3]: 格式化后2012-01-01 00:00:00
[pool-1-thread-8]: 格式化后2013-01-01 00:00:00
[pool-1-thread-9]: 格式化前Tue Jan 01 00:00:00 CST 2019
[pool-1-thread-9]: 格式化后2014-01-01 00:00:00
[pool-1-thread-10]: 格式化前Wed Jan 01 00:00:00 CST 2020
[pool-1-thread-10]: 格式化后2015-01-01 00:00:00
通过结果我们可以看出问题:
线程3最开始设置的时间是2013年,但是因为并发的问题,最终输出的格式化结果却是2012年。
[pool-1-thread-3]: 格式化前Tue Jan 01 00:00:00 CST 2013
[pool-1-thread-3]: 格式化后2012-01-01 00:00:00
结果显然,格式化日期出现线程安全问题。
线程安全问题的原因
SimpleDateFormat#format(Date)
方法的调用过程和 SimpleDateFormat#parse(String)
的十分类似。
同样的,SimpleDateFormat#format(Date)
方法 实际是继承自 DateFormat
;
同样的,DateFormat#format(Date)
也调用了自己的重载方法;
// SimpleDateFormat#format(Date)
sdf.format(list.get(t % 5));
// DateFormat#format(Date)
public final String format(Date date)
{
return format(date, new StringBuffer(),
DontCareFieldPosition.INSTANCE).toString();
}
这个重载方法也是抽象的,具体由其子类SimpleDateFormat
实现
// DateFormat#format(Date, StringBuffer, FieldPosition)
public abstract StringBuffer format(Date date, StringBuffer toAppendTo,
FieldPosition fieldPosition);
我们实际调用的format
方法就是这个重载方法,代码如下:
/**
* Formats the given <code>Date</code> into a date/time string and appends
* the result to the given <code>StringBuffer</code>.
*
* @param date the date-time value to be formatted into a date-time string.
* @param toAppendTo where the new date-time text is to be appended.
* @param pos the formatting position. On input: an alignment field,
* if desired. On output: the offsets of the alignment field.
* @return the formatted date-time string.
* @exception NullPointerException if the given {@code date} is {@code null}.
*/
@Override
public StringBuffer format(Date date, StringBuffer toAppendTo,
FieldPosition pos)
{
pos.beginIndex = pos.endIndex = 0;
return format(date, toAppendTo, pos.getFieldDelegate());
}
// 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);
// 略
}
可以看出,问题就出在calendart.setTime(date)
。
当多线程并发的时候,如果线程A首先设置了calendar的date,此时线程B获得锁,紧跟着又设置了一个date,对于同一线程A而言,它在不知情的情况下被修改了date。最终线程A执行format出来的结果变成了线程B的时间。
解决方案
问题的根源就是:SimpleDateFormat 被多个线程共享,它的
parse
和format
方法就像是 i++ 一样,它维持的Calender
等属性无法保证原子性。
-
使用局部变量,保证每个线程中都有一份SimpleDateFormat实例,那么,线程之间的 SimpleDateFormat实例 就没有任何关系了,不会互相影响了。
不过,也加重了创建对象的负担,会频繁地创建和销毁对象,效率较低。 -
使用 ThreadLocal
使用 ThreadLocal 实现 每个线程都可以得到单独的一个SimpleDateFormat的实例,那么自然也就不存在竞争问题了。
注意:这里我是重写了ThreadLocal#initialValue
方法,因为 每次调用 ThreadLocal#get()
时,都会执行一遍 initialValue
,相当于每个线程都new 了一份 SimpleDateFormt
实例,所以,才保证了线程安全。
本质上 ThreadLocal 并不是用来保证 线程安全的。
private static 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);
}
- 同步代码块 synchronized
- Lock锁方式,与synchronized锁方式实现原理相同,都是在高并发下通过JVM的锁机制来保证程序的线程安全。具体使用的是 Lock 的子类
ReentrantLock
(可重入锁) - 基于JDK1.8的 DateTimeFormatter
DateTimeFormatter 是线程安全的。
// 指定格式 静态方法 ofPattern()
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// DateTimeFormatter 自带的格式方法
LocalDateTime now = LocalDateTime.now();
// DateTimeFormatter 把日期对象,格式化成字符串
String strDate1 = formatter.format(now);
System.out.println(strDate1);
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/69728.html