SimpleDateFormat 类的线程安全问题
提起 SimpleDateFormat 类,想必做过 Java 开发的童鞋都不会感到陌生。没错,它就是 Java 中提供的日期时间的转化类。这里,为什么说SimpleDateFormat 类有线程安全问题呢?有些小伙伴可能会提出疑问:我们生产环境上一直在使用 SimpleDateFormat 类来解析和格式化日期和时间类型的数据,一直都没有问题啊!我的回答是:没错,那是因为你们的系统达不到 SimpleDateFormat 类出现问题的并发量,也就是说你们的系统没啥负载!
接下来,我们就一起看下在高并发下 SimpleDateFormat 类为何会出现线程安全问题,以及如何解决 SimpleDateFormat 类的线程安全问题。
一、重现 SimpleDateFormat 类的线程安全问题
为了重现 SimpleDateFormat 类的线程安全问题,一种比较简单的方式就是使用线程池结合 Java 并发包中的 CountDownLatch 类和 Semaphore 类来重现线程安全问题。
代码如下所示:
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class SimpleDateFormatTest {
/** SimpleDateFormat 对象 */
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd");
/** 执行总次数 */
private static final int EXECUTE_COUNT = 1000;
/** 同时运行的线程数量 */
private static final int THREAD_COUNT = 20;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(EXECUTE_COUNT);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < EXECUTE_COUNT; i++) {
executorService.execute(() -> {
try {
semaphore.acquire();
SDF.parse("2022-06-07");
} catch (InterruptedException e) {
System.out.println("获取信号量出错");
e.printStackTrace();
System.exit(1);
} catch (ParseException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
} catch (NumberFormatException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
}
semaphore.release();
latch.countDown();
});
}
latch.await();
executorService.shutdown();
System.out.println("所有线程格式化日期成功");
}
}
可以看到,在 SimpleDateFormatTest 类中,首先定义了两个常量,一个是程序执行的总次数,一个是同时运行的线程数量。程序中结合线程池和 CountDownLatch 类与 Semaphore 类来模拟高并发的业务场景。其中,有关日期转化的代码只有如下一行。
SDF.parse("2022-06-07");
当程序捕获到异常时,打印相关的信息,并退出整个程序的运行。当程序正确运行后,会打印“所有线程格式化日期成功”。
运行程序输出的结果信息如下所示。
线程:pool-1-thread-4 格式化日期失败
线程:pool-1-thread-5 格式化日期失败
线程:pool-1-thread-7 格式化日期失败
线程:pool-1-thread-1 格式化日期失败
线程:pool-1-thread-6 格式化日期失败
线程:pool-1-thread-8 格式化日期失败
线程:pool-1-thread-11 格式化日期失败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 SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:27)
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: 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:2084)
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 SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:27)
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: For input string: ""
Process finished with exit code 1
说明,在高并发下使用 SimpleDateFormat 类格式化日期时抛出了异常,SimpleDateFormat 类不是线程安全的!!!
接下来,我们就看下,SimpleDateFormat 类为何不是线程安全的。
二、SimpleDateFormat 类为何不是线程安全的
那么,接下来,我们就一起来看看真正引起 SimpleDateFormat 类线程不安全的根本原因。
通过查看 SimpleDateFormat 类的源码,我们得知:SimpleDateFormat 是继承自 DateFormat类,DateFormat 类中维护了一个全局的 Calendar 变量,如下所示:
/**
* 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
*/
protected Calendar calendar;
从注释可以看出,这个 Calendar 对象既用于格式化也用于解析日期时间。接下来,我们再查看parse() 方法接近最后的部分。
@Override
public Date parse(String text, ParsePosition pos)
{
// 此处省略部分代码...
Date parsedDate;
try {
parsedDate = calb.establish(calendar).getTime();
// If the year value is ambiguous,
// then the two-digit year == the default start year
if (ambiguousYear[0]) {
if (parsedDate.before(defaultCenturyStart)) {
parsedDate = calb.addYear(100).establish(calendar).getTime();
}
}
}
// An IllegalArgumentException will be thrown by Calendar.getTime()
// if any fields are out of range, e.g., MONTH == 17.
catch (IllegalArgumentException e) {
pos.errorIndex = start;
pos.index = oldStart;
return null;
}
return parsedDate;
}
可见,最后的返回值是通过调用 CalendarBuilder.establish() 方法获得的,而这个方法的参数正好就是前面的 Calendar 对象。
接下来,我们再来看看 CalendarBuilder.establish() 方法,如下所示:
Calendar establish(Calendar cal) {
boolean weekDate = isSet(WEEK_YEAR)
&& field[WEEK_YEAR] > field[YEAR];
if (weekDate && !cal.isWeekDateSupported()) {
// Use YEAR instead
if (!isSet(YEAR)) {
set(YEAR, field[MAX_FIELD + WEEK_YEAR]);
}
weekDate = false;
}
cal.clear();
// Set the fields from the min stamp to the max stamp so that
// the field resolution works in the Calendar.
for (int stamp = MINIMUM_USER_STAMP; stamp < nextStamp; stamp++) {
for (int index = 0; index <= maxFieldIndex; index++) {
if (field[index] == stamp) {
cal.set(index, field[MAX_FIELD + index]);
break;
}
}
}
if (weekDate) {
int weekOfYear = isSet(WEEK_OF_YEAR) ? field[MAX_FIELD + WEEK_OF_YEAR] : 1;
int dayOfWeek = isSet(DAY_OF_WEEK) ?
field[MAX_FIELD + DAY_OF_WEEK] : cal.getFirstDayOfWeek();
if (!isValidDayOfWeek(dayOfWeek) && cal.isLenient()) {
if (dayOfWeek >= 8) {
dayOfWeek--;
weekOfYear += dayOfWeek / 7;
dayOfWeek = (dayOfWeek % 7) + 1;
} else {
while (dayOfWeek <= 0) {
dayOfWeek += 7;
weekOfYear--;
}
}
dayOfWeek = toCalendarDayOfWeek(dayOfWeek);
}
cal.setWeekDate(field[MAX_FIELD + WEEK_YEAR], weekOfYear, dayOfWeek);
}
return cal;
}
在 CalendarBuilder.establish() 方法中先后调用了 cal.clear() 与 cal.set(),也就是先清除 cal 对象中设置的值,再重新设置新的值。由于 Calendar 内部并没有线程安全机制,并且这两个操作也都不是原子性的,所以当多个线程同时操作一个 SimpleDateFormat 时就会引起 cal 的值混乱。类似地, format() 方法也存在同样的问题。
因此, SimpleDateFormat 类不是线程安全的根本原因是:DateFormat 类中的 Calendar 对象被多线程共享,而 Calendar 对象本身不支持线程安全。
那么,得知了 SimpleDateFormat 类不是线程安全的,以及造成 SimpleDateFormat 类不是线程安全的原因,那么如何解决这个问题呢?接下来,我们就一起探讨下如何解决 SimpleDateFormat 类在高并发场景下的线程安全问题。
三、解决 SimpleDateFormat 类的线程安全问题
解决 SimpleDateFormat 类在高并发场景下的线程安全问题可以有多种方式,这里,就列举几个常用的方式供参考,大家也可以在评论区给出更多的解决方案。
3.1 局部变量法
最简单的一种方式就是将 SimpleDateFormat 类对象定义成局部变量,如下所示的代码,将 SimpleDateFormat 类对象定义在 parse(String) 方法的上面,即可解决问题。
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class SimpleDateFormatTest {
/** 执行总次数 */
private static final int EXECUTE_COUNT = 1000;
/** 同时运行的线程数量 */
private static final int THREAD_COUNT = 20;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(EXECUTE_COUNT);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < EXECUTE_COUNT; i++) {
executorService.execute(() -> {
try {
semaphore.acquire();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.parse("2022-06-07");
} catch (InterruptedException e) {
System.out.println("获取信号量出错");
e.printStackTrace();
System.exit(1);
} catch (ParseException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
} catch (NumberFormatException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
}
semaphore.release();
latch.countDown();
});
}
latch.await();
executorService.shutdown();
System.out.println("所有线程格式化日期成功");
}
}
此时运行修改后的程序,输出结果如下所示。
所有线程格式化日期成功
这种方式在高并发下会创建大量的 SimpleDateFormat 类对象,影响程序的性能,所以,这种方式在实际生产环境不太被推荐。
3.2 synchronized 锁方式
将 SimpleDateFormat 类对象定义成全局静态变量,此时所有线程共享 SimpleDateFormat 类对象,此时在调用格式化时间的方法时,对 SimpleDateFormat 对象进行同步即可,代码如下所示:
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class SimpleDateFormatTest {
/** SimpleDateFormat 对象 */
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd");
/** 执行总次数 */
private static final int EXECUTE_COUNT = 1000;
/** 同时运行的线程数量 */
private static final int THREAD_COUNT = 20;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(EXECUTE_COUNT);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < EXECUTE_COUNT; i++) {
executorService.execute(() -> {
try {
semaphore.acquire();
synchronized (SDF) {
SDF.parse("2022-06-07");
}
} catch (InterruptedException e) {
System.out.println("获取信号量出错");
e.printStackTrace();
System.exit(1);
} catch (ParseException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
} catch (NumberFormatException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
}
semaphore.release();
latch.countDown();
});
}
latch.await();
executorService.shutdown();
System.out.println("所有线程格式化日期成功");
}
}
此时,解决问题的关键代码如下所示:
synchronized (SDF) {
SDF.parse("2022-06-07");
}
运行程序,输出结果如下所示。
所有线程格式化日期成功
需要注意的是,虽然这种方式能够解决 SimpleDateFormat 类的线程安全问题,但是由于在程序的执行过程中,为 SimpleDateFormat 类对象加上了 synchronized 锁,导致同一时刻只能有一个线程执行 parse(String) 方法。此时,会影响程序的执行性能,在要求高并发的生产环境下,此种方式也是不太推荐使用的。
3.3 Lock 锁方式
Lock 锁方式与 synchronized 锁方式实现原理相同,都是在高并发下通过 JVM 的锁机制来保证程序的线程安全。此种方式同样会影响高并发场景下的性能,不太建议在高并发的生产环境使用。代码实现方式与 synchronized 类似,此处不再列出。
3.4 ThreadLocal 方式
使用 ThreadLocal 存储每个线程拥有的 SimpleDateFormat 对象的副本,能够有效的避免多线程造成的线程安全问题,使用 ThreadLocal 解决线程安全问题的代码如下所示:
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class SimpleDateFormatTest {
/** 执行总次数 */
private static final int EXECUTE_COUNT = 1000;
/** 同时运行的线程数量 */
private static final int THREAD_COUNT = 20;
private static final ThreadLocal<SimpleDateFormat> THREAD_LOCAL = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(EXECUTE_COUNT);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < EXECUTE_COUNT; i++) {
executorService.execute(() -> {
try {
semaphore.acquire();
SimpleDateFormat sdf = THREAD_LOCAL.get();
sdf.parse("2022-06-07");
} catch (InterruptedException e) {
System.out.println("获取信号量出错");
e.printStackTrace();
System.exit(1);
} catch (ParseException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
} catch (NumberFormatException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
}
semaphore.release();
latch.countDown();
});
}
latch.await();
executorService.shutdown();
System.out.println("所有线程格式化日期成功");
}
}
通过代码可以得知,将每个线程使用的 SimpleDateFormat 副本保存在 ThreadLocal 中,各个线程在使用时互不干扰,从而解决了线程安全问题。
运行程序,输出结果如下所示。
所有线程格式化日期成功
此种方式运行效率比较高,推荐在高并发业务场景的生产环境使用。
另外,使用 ThreadLocal 也可以写成如下形式的代码,效果是一样的。
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class SimpleDateFormatTest {
/** 执行总次数 */
private static final int EXECUTE_COUNT = 1000;
/** 同时运行的线程数量 */
private static final int THREAD_COUNT = 20;
private static final ThreadLocal<SimpleDateFormat> THREAD_LOCAL = new ThreadLocal<>();
private static SimpleDateFormat getSimpleDateFormat() {
SimpleDateFormat sdf = THREAD_LOCAL.get();
if (sdf == null) {
sdf = new SimpleDateFormat("yyyy-MM-dd");
THREAD_LOCAL.set(sdf);
}
return sdf;
}
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(EXECUTE_COUNT);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < EXECUTE_COUNT; i++) {
executorService.execute(() -> {
try {
semaphore.acquire();
SimpleDateFormat sdf = getSimpleDateFormat();
sdf.parse("2022-06-07");
} catch (InterruptedException e) {
System.out.println("获取信号量出错");
e.printStackTrace();
System.exit(1);
} catch (ParseException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
} catch (NumberFormatException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
}
semaphore.release();
latch.countDown();
});
}
latch.await();
executorService.shutdown();
System.out.println("所有线程格式化日期成功");
}
}
3.5 DateTimeFormatter 方式
DateTimeFormatter 是 Java8 提供的新的日期时间 API 中的类,DateTimeFormatter 类是线程安全的,可以在高并发场景下直接使用 DateTimeFormatter 类来处理日期的格式化操作。代码如下所示:
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class DateTimeFormatterTest {
/** DateTimeFormatter 对象 */
private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/** 同时运行的线程数量 */
private static final int THREAD_COUNT = 20;
/** 执行总次数 */
private static final int EXECUTE_COUNT = 1000;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(EXECUTE_COUNT);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < EXECUTE_COUNT; i++) {
executorService.execute(() -> {
try {
semaphore.acquire();
LocalDate.parse("2022-06-07", DTF);
} catch (InterruptedException e) {
System.out.println("获取信号量出错");
e.printStackTrace();
System.exit(1);
} catch (Exception e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
}
semaphore.release();
latch.countDown();
});
}
latch.await();
executorService.shutdown();
System.out.println("所有线程格式化日期成功");
}
}
运行程序,输出结果如下所示。
所有线程格式化日期成功
可以看到,DateTimeFormatter 类是线程安全的,可以在高并发场景下直接使用 DateTimeFormatter 类来处理日期的格式化操作。
使用 DateTimeFormatter 类来处理日期的格式化操作运行效率比较高,推荐在高并发业务场景的生产环境使用。
3.6 joda-time 方式
joda-time 是第三方处理日期时间格式化的类库,是线程安全的。如果使用 joda-time 来处理日期和时间的格式化,则需要引入第三方类库。这里,我以 Gradle 为例,如下所示引入 joda-time库。
dependencies {
implementation 'joda-time:joda-time:2.10.14'
}
引入 joda-time 库后,实现的程序代码如下所示。
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import org.joda.time.DateTime;
public class JodaTimeTest {
/** DateTimeFormatter 对象 */
private static final DateTimeFormatter DTF = DateTimeFormat.forPattern("yyyy-MM-dd");
/** 同时运行的线程数量 */
private static final int THREAD_COUNT = 20;
/** 执行总次数 */
private static final int EXECUTE_COUNT = 1000;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(EXECUTE_COUNT);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < EXECUTE_COUNT; i++) {
executorService.execute(() -> {
try {
semaphore.acquire();
DateTime.parse("2022-06-07", DTF);
} catch (InterruptedException e) {
System.out.println("获取信号量出错");
e.printStackTrace();
System.exit(1);
} catch (Exception e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
}
semaphore.release();
latch.countDown();
});
}
latch.await();
executorService.shutdown();
System.out.println("所有线程格式化日期成功");
}
}
这里,需要注意的是:DateTime 类是 org.joda.time 包下的类,DateTimeFormat 类和DateTimeFormatter 类都是 org.joda.time.format 包下的类,如下所示。
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import org.joda.time.DateTime;
运行程序,输出结果如下所示。
所有线程格式化日期成功
使用 joda-time 库来处理日期的格式化操作运行效率比较高,推荐在高并发业务场景的生产环境使用。
综上所示:在解决解决 SimpleDateFormat 类的线程安全问题的几种方案中,局部变量法由于线程每次执行格式化时间时,都会创建 SimpleDateFormat 类的对象,这会导致创建大量的 SimpleDateFormat 对象,浪费运行空间和消耗服务器的性能,因为 JVM 创建和销毁对象是要耗费性能的。所以,不推荐在高并发要求的生产环境使用。
synchronized 锁方式和 Lock 锁方式在处理问题的本质上是一致的,通过加锁的方式,使同一时刻只能有一个线程执行格式化日期和时间的操作。这种方式虽然减少了 SimpleDateFormat 对象的创建,但是由于同步锁的存在,导致性能下降,所以,不推荐在高并发要求的生产环境使用。
ThreadLocal 通过保存各个线程的 SimpleDateFormat 类对象的副本,使每个线程在运行时,各自使用自身绑定的 SimpleDateFormat 对象,互不干扰,执行性能比较高,推荐在高并发的生产环境使用。
DateTimeFormatter 是 Java 8 中提供的处理日期和时间的类,DateTimeFormatter 类本身就是线程安全的,经压测,DateTimeFormatter 类处理日期和时间的性能效果还不错。所以,推荐在高并发场景下的生产环境使用。
joda-time 是第三方处理日期和时间的类库,线程安全,性能经过高并发的考验,推荐在高并发场景下的生产环境使用。