面试回答
在日常开发中,我们经常用到时间,我们有很多办法在 Java 代码中获取时间。但是不同的方法获取到的时间的格式都不尽相同,这时候就需要一种格式化工具,把时间显示成我们需要的格式。
最常用的方法就是使用 SimpleDateFormat
类。这是一个看上去功能比较简单的类,但是,一旦使用不当也有可能导致很大的问题。
在阿里巴巴 Java 开发手册中,有如下明确规定:
- 【强制】SimpleDateFormat 是线程不安全的类,一般不要定义为 static 变量,如果定义为 static ,必须家锁,或者使用 DateUitls 工具类。
也就是说 SimpleDateFormat
是非线程安全的,所以在多线程场景中,不能使用 SimpleDateFormat
作为共享变量。
因为 SimpleDateFormat
中的 format
方法在执行过程中,会使用一个成员变量 calendar
来保存时间。
如果我们在声明 SimpleDateFormat
的时候,使用的是 static 定义的。那么这个 SimpleDateFormat
就是一个共享变量,随之,SimpleDateFormat
中的 calendar
也就可以被多个线程访问到。
假设线程1刚刚执行完 calendar.setTime
把时间设置成 2022-11-11,还没登执行完,线程2又执行了 calendar.setTime
把时间改成了 2022-12-12。这时间线程1继续往下执行,拿到的 calendar.getTime
得到的时间就是线程2改过之后的。
想要保证线程安全,要么就是不要把 SimpleDateFormat
设置成成员变量,只设置成局部变量就行了,要不然就是加锁避免并发,或者使用 JDK 1.8 的 DateTimeFormatter。
知识扩展
SimpleDateFormat 用法
SimpleDateFormat
是 Java 提供的一个格式化和解析日期的工具类。它允许进行格式化(日期 -> 文本)、解析(文本 -> 日期)和规范化。SimpleDateFormat
使得可以选择任何用户定义的日期-时间格式的模式。
在 Java 中,可以使用 SimpleDateFormat
的 format
方法,将一个 Date
类型转换成 String
类型,并且可以指定输出格式。
// Date 转 String
Date date=new Date();
SimpleDateFormat sdf=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String dateSr=sdf.format(date);
System.out.println(dateSr);
以上代码,转换的结果是:2023-07-13 14:36:20,日期和时间格式由“日期和时间格式”字符串指定。如果你想要转换成其他格式,只要指定不同的时间模式就行了。
在 Java 中,可以使用 SimpleDateFormat
的 parse
方法,将一个 String
类型转换成 Date
类型。
//String 转 Date
System.out.println(sdf.parse(dateSr));
日期和时间模式表达方式
在使用 SimpleDateFormat
的时候,需要通过字母来描述时间元素,并组装成想要的日期和时间模式。常用的时间元素和字母的对应表如下:
输出不同时区的时间
时区是地球上的区域使用同一个时间定义。以前,人们通过观察太阳的位置(时角)决定时间,这就使得不同精度的地方的时间有所不同(地方时)。1863 年,首次使用时区的概念。时区通过设立一个区域的标准时间部分地解决了这个问题。
世界各个国家位于地球不同位置上,因此不同国家,特别是东西跨度大的国家日出、日落时间必定有所偏差。这些偏差就是所谓的时差。
现今全球共分为 24 个时区。由于实用上常常 1 个国家,或 1个省份同时跨着2个或更多时区,为了照顾到行政上的方便,常将1个国家或1个省份划在一起。所以时区并不严格按南北直线来划分,而是按自然条件来划分。例如,中国幅员宽广,差不多跨5个时区,但为了使用方便简单,实际上在只用东八时区的标准时即北京时间为准。
由于不同的时区的时间是不一样的,甚至同一个国家的不同城市时间都可能不一样,所以,在 Java 中想要获取时间的时候,要重点关注一下时区问题。
默认情况下,如果不指明,在创建日期的时候,会使用当前计算机所在的时区作为默认时区,这也是为什么我们通过只要使用 new Date()
就可以获取中国的当前时间的原因。
那么,如果在 Java 代码中获取不同时区的时间呢? SimpleDateFormat
可以实现这个功能。
SimpleDateFormat sdf=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
sdf.setTimeZone(TimeZone.getTimeZone("America/Los_Angeles"));
System.out.println(sdf.format(Calendar.getInstance().getTime()));
以上代码转换的结果是:2023-07-12 23:00:00。即中国的时间是 7月13日的15点,而美国洛杉矶时间比中国北京时间慢了 16 个小时(这还和冬夏令时有关系,就不详细展开了)。
当然,这不是显示其他时区的唯一方法,不过本文主要为了介绍 SimpleDateFormat
,其他方法暂不介绍了。
SimpleDateFormat 线程安全性
由于 SimleDateFormat 比较常用,而且在一般情况下,一个应用中的时间显示模式都是一样的,所以很多人愿意使用如下方式定义 SimleDateFormat:
public class Main {
private static SimpleDateFormat sdf=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
sdf.setTimeZone(TimeZone.getTimeZone("America/New_York"));
System.out.println(sdf.format(Calendar.getInstance().getTime()));
}
}
这种定义方式,存在很大的安全隐患。
问题重现
我们来看一段代码,以下代码使用线程池来执行时间输出。
// 定义一个全局的 SimpleDateFormat
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 使用 ThreadFactory 定义一个线程池
private static ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setNameFormat("demo-pool-%d")
.build();
private static ExecutorService pool = new ThreadPoolExecutor(5, 200,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingDeque<Runnable>(1024),
threadFactory,
new ThreadPoolExecutor.AbortPolicy());
//定义一个 CountDownLatch ,保证所有子线程执行完之后主线程再执行
private static CountDownLatch countDownLatch = new CountDownLatch(100);
public static void main(String[] args) throws InterruptedException {
// 定义一个线程安全的 HashSet
Set<String> dates= Collections.synchronizedSet(new HashSet<String>());
for (int i = 0; i <100 ; i++) {
// 获取当前时间
Calendar calendar=Calendar.getInstance();
int finalI=i;
pool.execute(()->{
// 时间增加
calendar.add(Calendar.DATE,finalI);
// 通过 SimpleDateFormat 把时间转换成字符串
String dateString=sdf.format(calendar.getTime());
// 把字符串放入 Set 中
dates.add(dateString);
// countDownLatch
countDownLatch.countDown();
});
}
// 阻塞,直到 countDownLatch 数量为 0
countDownLatch.await();
System.out.println(dates.size());
}
以上代码,其实比较简单,很容易理解。就是循环一百次,每次循环的时候都在当前时间基础上增加一个天数(这个天数随着循环次数而变化),然后把所有日期放入一个线程安全的、带有去重功能的 Set 中,然后输出 Set 中元素个数、
上面的例子我特意写的稍微复杂了一些不过我几乎都加了注释。这里面设计到了线程池的创建、CountDownLatch、lambda 表达式、线程安全的 HashSet 等知识。刚性取得朋友可以逐一了解一下。
正常情况下,以上代码输出结果应该是 100。但是实际执行结果是一个小于 100 的数字。
原因就是因为 SimpleDateFormat
作为一个非线程安全的类,被当做了共享变量在多个线程中进行使用,这就出现了线程安全问题。
在阿里巴巴 Java 开发手册的第一章第六节——并发处理中关于这一点也有明确说明:
- 【强制】SimpleDateFormat 是线程不安全的类,一般不要定义为 static 变量,如果定义为 static ,必须家锁,或者使用 DateUitls 工具类。
正例:注意线程安全,使用 DateUitls。亦推荐如下处理:
private static final ThreadLocal<DateFormat> df=new ThreadLocal<DateFormat>(){
@Override
protected DateFormat initialValue(){
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
}
};
那么,接下来我们就来看下到底是为什么,以及该如何解决?
线程不安全的原因
通过以上代码,我们发现了在并发场景中使用 SimpleDateFormat
会有线程安全问题。其实,JDK 文档中已经明确表明了 SimpleDateFormat
不应该用在多线程场景中:
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
类中 format
方法的实现其实就能发现端倪。
SimpleDateFormat
的 format
方法在执行过程中,会使用一个成员变量 calendar
来保存时间。这其实就是问题的关键。
由于我们在声明 SimpleDateFormat
的时候,使用的是 static
定义的。那么这个 SimpleDateFormat
就是一个共享变量,随之,SimpleDateFormat
的calendar
也就可以被多个线程访问到。
假设线程1刚刚执行完 calendar.setTime
把时间设置成 2022-11-11,还没登执行完,线程2又执行了 calendar.setTime
把时间改成了 2022-12-12。这时间线程1继续往下执行,拿到的 calendar.getTime
得到的时间就是线程2改过之后的。
除了 format
方法以外,SimpleDateFormat
的 parse
方法也有同样的问题。
所以,不要把 SimpleDateFormat
作为一个共享变量使用。
如何解决
前面介绍过了 SimpleDateFormat
存在的问题以及问题存在的原因,那么有什么办法解决这种问题呢?
解决方法有很多,这里介绍三个比较常用的方法。
使用局部变量
public static void main(String[] args) throws InterruptedException {
// 定义一个线程安全的 HashSet
Set<String> dates= Collections.synchronizedSet(new HashSet<String>());
for (int i = 0; i <100 ; i++) {
// 获取当前时间
Calendar calendar=Calendar.getInstance();
int finalI=i;
pool.execute(()->{
// 时间增加
calendar.add(Calendar.DATE,finalI);
// SimpleDateFormat 声明成局部变量
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 通过 SimpleDateFormat 把时间转换成字符串
String dateString=sdf.format(calendar.getTime());
// 把字符串放入 Set 中
dates.add(dateString);
// countDownLatch
countDownLatch.countDown();
});
}
// 阻塞,直到 countDownLatch 数量为 0
countDownLatch.await();
System.out.println(dates.size());
}
SimpleDateFormat
变成了局部变量,就不会被多个线程同时访问到了,就避免了线程安全问题。
加同步锁
除了改成局部变量以外,还有一种方法大家可能比较熟悉的,就是对于共享变量进行加锁。
public static void main(String[] args) throws InterruptedException {
// 定义一个线程安全的 HashSet
Set<String> dates= Collections.synchronizedSet(new HashSet<String>());
for (int i = 0; i <100 ; i++) {
// 获取当前时间
Calendar calendar=Calendar.getInstance();
int finalI=i;
pool.execute(()->{
synchronized (sdf){
// 时间增加
calendar.add(Calendar.DATE,finalI);
// 通过 SimpleDateFormat 把时间转换成字符串
String dateString=sdf.format(calendar.getTime());
// 把字符串放入 Set 中
dates.add(dateString);
// countDownLatch
countDownLatch.countDown();
}
});
}
// 阻塞,直到 countDownLatch 数量为 0
countDownLatch.await();
System.out.println(dates.size());
}
通过加锁,使多个线程排队顺序执行。避免了并发导致的线程安全问题。
其实以上代码还有可以改进的地方,就是可以把锁的粒度在设置的小一些,可以只对 sdf.format
这一行加锁,这样效率更高一些。
使用 ThreadLocal
第三种方式,就是使用 ThreadLocal
。ThreadLocal
可以确保每个线程都可以得到单独的SimpleDateFormat
的对象,那么自然也就不存在竞争问题了。
public class Main {
// 使用 ThreadLocal 定义一个全局的 SimpleDateFormat
private static final ThreadLocal<DateFormat> df= ThreadLocal.withInitial(()
-> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
// 使用 ThreadFactory 定义一个线程池
private static ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setNameFormat("demo-pool-%d")
.build();
private static ExecutorService pool = new ThreadPoolExecutor(5, 200,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingDeque<Runnable>(1024),
threadFactory,
new ThreadPoolExecutor.AbortPolicy());
//定义一个 CountDownLatch ,保证所有子线程执行完之后主线程再执行
private static CountDownLatch countDownLatch = new CountDownLatch(100);
public static void main(String[] args) throws InterruptedException {
// 定义一个线程安全的 HashSet
Set<String> dates= Collections.synchronizedSet(new HashSet<String>());
for (int i = 0; i <100 ; i++) {
// 获取当前时间
Calendar calendar=Calendar.getInstance();
int finalI=i;
pool.execute(()->{
// 时间增加
calendar.add(Calendar.DATE,finalI);
// 通过 SimpleDateFormat 把时间转换成字符串
String dateString=df.get().format(calendar.getTime());
// 把字符串放入 Set 中
dates.add(dateString);
// countDownLatch
countDownLatch.countDown();
});
}
// 阻塞,直到 countDownLatch 数量为 0
countDownLatch.await();
System.out.println(dates.size());
}
}
用 ThreadLocal
来实现其实是有点类似于缓存的思路,每个线程都有一个独享的对象,避免了频繁创建对象,也避免了多线程的竞争。
当然,以上代码也有改进空间,就是,其实 SimpleDateFormat
的创建过程可以改为延迟加载。这里就不详细介绍了。
使用 DateTimeFormatter
如果是 Java8 应用,可以使用 DateTimeFormatter
代替 SimpleDateFormat
,这是一个线程安全的格式化工具类。就像官方文档中说的,这个类 This class is immutable and thread-safe.
。
public static void main(String[] args) {
// 解析日期
String dateStr = "2023-07-12 23:00:00";
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime date = LocalDateTime.parse(dateStr, formatter);
System.out.println(date);
//日期转换为字符串
LocalDateTime now=LocalDateTime.now();
String nowStr=now.format(formatter);
System.out.println(nowStr);
}