forked from hollischuang/toBeTopJavaer
-
Notifications
You must be signed in to change notification settings - Fork 0
/
simpledateformat-thread-safe.md
280 lines (186 loc) · 14.1 KB
/
simpledateformat-thread-safe.md
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
在日常开发中,我们经常会用到时间,我们有很多办法在Java代码中获取时间。但是不同的方法获取到的时间的格式都不尽相同,这时候就需要一种格式化工具,把时间显示成我们需要的格式。
最常用的方法就是使用SimpleDateFormat类。这是一个看上去功能比较简单的类,但是,一旦使用不当也有可能导致很大的问题。
在阿里巴巴Java开发手册中,有如下明确规定:
<img src="https://www.hollischuang.com/wp-content/uploads/2018/11/规约1.png" alt="" width="1862" height="154" class="aligncenter size-full wp-image-3043" />
那么,本文就围绕SimpleDateFormat的用法、原理等来深入分析下如何以正确的姿势使用它。
### SimpleDateFormat用法
SimpleDateFormat是Java提供的一个格式化和解析日期的工具类。它允许进行格式化(日期 -> 文本)、解析(文本 -> 日期)和规范化。SimpleDateFormat 使得可以选择任何用户定义的日期-时间格式的模式。
在Java中,可以使用SimpleDateFormat的format方法,将一个Date类型转化成String类型,并且可以指定输出格式。
// Date转String
Date data = new Date();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String dataStr = sdf.format(data);
System.out.println(dataStr);
以上代码,转换的结果是:2018-11-25 13:00:00,日期和时间格式由"日期和时间模式"字符串指定。如果你想要转换成其他格式,只要指定不同的时间模式就行了。
在Java中,可以使用SimpleDateFormat的parse方法,将一个String类型转化成Date类型。
// String转Data
System.out.println(sdf.parse(dataStr));
#### 日期和时间模式表达方法
在使用SimpleDateFormat的时候,需要通过字母来描述时间元素,并组装成想要的日期和时间模式。常用的时间元素和字母的对应表如下:
![-w717][1]
模式字母通常是重复的,其数量确定其精确表示。如下表是常用的输出格式的表示方法。
![-w535][2]
#### 输出不同时区的时间
时区是地球上的区域使用同一个时间定义。以前,人们通过观察太阳的位置(时角)决定时间,这就使得不同经度的地方的时间有所不同(地方时)。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()));
以上代码,转换的结果是: 2018-11-24 21:00:00 。既中国的时间是11月25日的13点,而美国洛杉矶时间比中国北京时间慢了16个小时(这还和冬夏令时有关系,就不详细展开了)。
> 如果你感兴趣,你还可以尝试打印一下美国纽约时间(America/New_York)。纽约时间是2018-11-25 00:00:00。纽约时间比中国北京时间早了13个小时。
当然,这不是显示其他时区的唯一方法,不过本文主要为了介绍SimpleDateFormat,其他方法暂不介绍了。
## SimpleDateFormat线程安全性
由于SimpleDateFormat比较常用,而且在一般情况下,一个应用中的时间显示模式都是一样的,所以很多人愿意使用如下方式定义SimpleDateFormat:
public class Main {
private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
simpleDateFormat.setTimeZone(TimeZone.getTimeZone("America/New_York"));
System.out.println(simpleDateFormat.format(Calendar.getInstance().getTime()));
}
}
**这种定义方式,存在很大的安全隐患。**
#### 问题重现
我们来看一段代码,以下代码使用线程池来执行时间输出。
/** * @author Hollis */
public class Main {
/**
* 定义一个全局的SimpleDateFormat
*/
private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
/**
* 使用ThreadFactoryBuilder定义一个线程池
*/
private static ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("demo-pool-%d").build();
private static ExecutorService pool = new ThreadPoolExecutor(5, 200,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());
/**
* 定义一个CountDownLatch,保证所有子线程执行完之后主线程再执行
*/
private static CountDownLatch countDownLatch = new CountDownLatch(100);
public static void main(String[] args) {
//定义一个线程安全的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 = simpleDateFormat.format(calendar.getTime());
//把字符串放入Set中
dates.add(dateString);
//countDown
countDownLatch.countDown();
});
}
//阻塞,直到countDown数量为0
countDownLatch.await();
//输出去重后的时间个数
System.out.println(dates.size());
}
}
以上代码,其实比较简单,很容易理解。就是循环一百次,每次循环的时候都在当前时间基础上增加一个天数(这个天数随着循环次数而变化),然后把所有日期放入一个**线程安全的**、**带有去重功能**的Set中,然后输出Set中元素个数。
> 上面的例子我特意写的稍微复杂了一些,不过我几乎都加了注释。这里面涉及到了[线程池的创建][3]、[CountDownLatch][4]、lambda表达式、线程安全的HashSet等知识。感兴趣的朋友可以逐一了解一下。
正常情况下,以上代码输出结果应该是100。但是实际执行结果是一个小于100的数字。
原因就是因为SimpleDateFormat作为一个非线程安全的类,被当做了共享变量在多个线程中进行使用,这就出现了线程安全问题。
在阿里巴巴Java开发手册的第一章第六节——并发处理中关于这一点也有明确说明:
<img src="https://www.hollischuang.com/wp-content/uploads/2018/11/guiyue2.png" alt="" width="1878" height="546" class="aligncenter size-full wp-image-3044" />
那么,接下来我们就来看下到底是为什么,以及该如何解决。
#### 线程不安全原因
通过以上代码,我们发现了在并发场景中使用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方法的实现其实就能发现端倪。
![][5]
SimpleDateFormat中的format方法在执行过程中,会使用一个成员变量calendar来保存时间。这其实就是问题的关键。
由于我们在声明SimpleDateFormat的时候,使用的是static定义的。那么这个SimpleDateFormat就是一个共享变量,随之,SimpleDateFormat中的calendar也就可以被多个线程访问到。
假设线程1刚刚执行完`calendar.setTime`把时间设置成2018-11-11,还没等执行完,线程2又执行了`calendar.setTime`把时间改成了2018-12-12。这时候线程1继续往下执行,拿到的`calendar.getTime`得到的时间就是线程2改过之后的。
除了format方法以外,SimpleDateFormat的parse方法也有同样的问题。
所以,不要把SimpleDateFormat作为一个共享变量使用。
#### 如何解决
前面介绍过了SimpleDateFormat存在的问题以及问题存在的原因,那么有什么办法解决这种问题呢?
解决方法有很多,这里介绍三个比较常用的方法。
**使用局部变量**
for (int i = 0; i < 100; i++) {
//获取当前时间
Calendar calendar = Calendar.getInstance();
int finalI = i;
pool.execute(() -> {
// SimpleDateFormat声明成局部变量
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//时间增加
calendar.add(Calendar.DATE, finalI);
//通过simpleDateFormat把时间转换成字符串
String dateString = simpleDateFormat.format(calendar.getTime());
//把字符串放入Set中
dates.add(dateString);
//countDown
countDownLatch.countDown();
});
}
SimpleDateFormat变成了局部变量,就不会被多个线程同时访问到了,就避免了线程安全问题。
**加同步锁**
除了改成局部变量以外,还有一种方法大家可能比较熟悉的,就是对于共享变量进行加锁。
for (int i = 0; i < 100; i++) {
//获取当前时间
Calendar calendar = Calendar.getInstance();
int finalI = i;
pool.execute(() -> {
//加锁
synchronized (simpleDateFormat) {
//时间增加
calendar.add(Calendar.DATE, finalI);
//通过simpleDateFormat把时间转换成字符串
String dateString = simpleDateFormat.format(calendar.getTime());
//把字符串放入Set中
dates.add(dateString);
//countDown
countDownLatch.countDown();
}
});
}
通过加锁,使多个线程排队顺序执行。避免了并发导致的线程安全问题。
其实以上代码还有可以改进的地方,就是可以把锁的粒度再设置的小一点,可以只对`simpleDateFormat.format`这一行加锁,这样效率更高一些。
**使用ThreadLocal**
第三种方式,就是使用 ThreadLocal。 ThreadLocal 可以确保每个线程都可以得到单独的一个 SimpleDateFormat 的对象,那么自然也就不存在竞争问题了。
/**
* 使用ThreadLocal定义一个全局的SimpleDateFormat
*/
private static ThreadLocal<SimpleDateFormat> simpleDateFormatThreadLocal = new ThreadLocal<SimpleDateFormat>() {
@Override
protected SimpleDateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
}
};
//用法
String dateString = simpleDateFormatThreadLocal.get().format(calendar.getTime());
用 ThreadLocal 来实现其实是有点类似于缓存的思路,每个线程都有一个独享的对象,避免了频繁创建对象,也避免了多线程的竞争。
当然,以上代码也有改进空间,就是,其实SimpleDateFormat的创建过程可以改为延迟加载。这里就不详细介绍了。
**使用DateTimeFormatter**
如果是Java8应用,可以使用DateTimeFormatter代替SimpleDateFormat,这是一个线程安全的格式化工具类。就像官方文档中说的,这个类 simple beautiful strong immutable thread-safe。
//解析日期
String dateStr= "2016年10月25日";
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日");
LocalDate date= LocalDate.parse(dateStr, formatter);
//日期转换为字符串
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy年MM月dd日 hh:mm a");
String nowStr = now .format(format);
System.out.println(nowStr);
### 总结
本文介绍了SimpleDateFormat的用法,SimpleDateFormat主要可以在String和Date之间做转换,还可以将时间转换成不同时区输出。同时提到在并发场景中SimpleDateFormat是不能保证线程安全的,需要开发者自己来保证其安全性。
主要的几个手段有改为局部变量、使用synchronized加锁、使用Threadlocal为每一个线程单独创建一个等。
希望通过此文,你可以在使用SimpleDateFormat的时候更加得心应手。
[1]: https://www.hollischuang.com/wp-content/uploads/2018/11/15431240092595.jpg
[2]: https://www.hollischuang.com/wp-content/uploads/2018/11/15431240361504.jpg
[3]: https://www.hollischuang.com/archives/2888
[4]: https://www.hollischuang.com/archives/290
[5]: https://www.hollischuang.com/wp-content/uploads/2018/11/15431313894397.jpg