行百里er 行百里er
首页
  • 分类
  • 标签
  • 归档
设计模式
  • JVM
  • Java基础
MySQL
Elastic Stack
Redis
  • Kafka
  • RocketMQ
分布式
Spring Cloud Alibaba
云原生
数据结构与算法
关于
GitHub (opens new window)

行百里er

Java程序员一枚
首页
  • 分类
  • 标签
  • 归档
设计模式
  • JVM
  • Java基础
MySQL
Elastic Stack
Redis
  • Kafka
  • RocketMQ
分布式
Spring Cloud Alibaba
云原生
数据结构与算法
关于
GitHub (opens new window)
  • JVM

  • Java基础

    • 【优雅的避坑】从验证码生成代码的优化到 JVM 栈和堆
    • 【优雅的避坑】HashMap正确的避免扩容带来的额外开销
    • 【优雅的避坑】不要轻易使用==比较两个Integer的值
    • 【优雅的避坑】为什么0.1+0.2不等于0.3了!?
    • 【优雅的避坑】不安全!别再共享SimpleDateFormat了
      • 0x01 开场白
      • 0x02 重现多线程场景使用SimpleDateFormat问题
      • 0x03 原因分析
      • 0x04 日期格式化的正确姿势
        • 姿势1 使用synchronized
        • 姿势2 将SimpleDateFormat设置成局部变量使用
        • 姿势3 使用ThreadLocal
        • 姿势4 使用DateTimeFormatter
      • 0x05 小结
      • 优雅的避坑-未完待续
      • 最后
    • new Object在内存中占多少字节?
    • Java 8之Lambda表达式的写法套路
    • Java 8 Stream API可以怎么玩?
    • Java最强大的技术之一:反射
    • 从一道面试题进入Java并发新机制---JUC
  • Java
  • Java基础
行百里er
2020-10-23
目录

【优雅的避坑】不安全!别再共享SimpleDateFormat了

作者:行百里er

博客:https://chendapeng.cn (opens new window)

提示

这里是 行百里er 的博客:行百里者半九十,凡事善始善终,吾将上下而求索!

# 0x01 开场白

JDK文档中已经明确表明了SimpleDateFormat不应该用在多线程场景中:

Synchronization

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.

然而,并不是所有Javaer都关注到了这句话,依然使用如下的方式进行日期时间格式化:

private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");

@Test
public void longLongAgo() {
    String dateStr = sdf.format(new Date());
    System.out.println("当前时间:" + dateStr);
}
1
2
3
4
5
6
7

一个线程这样做当然是没问题的。

既然官方文档都说了在多线程访问的场景中必须使用synchronized同步,那么就来验证一下,多线程场景下使用SimpleDateFormat会出现什么问题。

# 0x02 重现多线程场景使用SimpleDateFormat问题

定义一个线程池,跑多个线程执行对当前日期格式化的操作

/**
 * 定义static的SimpleDateFormat,所有线程共享
 **/
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

/**
 * 定义线程池
 **/
private static ExecutorService threadPool = new ThreadPoolExecutor(16,
        100,
        0L,
        TimeUnit.MILLISECONDS,
        new LinkedBlockingDeque<>(1024),
        new ThreadFactoryBuilder().setNameFormat("[线程-%d]").build(),
        new ThreadPoolExecutor.AbortPolicy()
        );

@SneakyThrows
@Test
public void testFormat() {
    Set<String> results = Collections.synchronizedSet(new HashSet<>());
    // 每个线程都执行“给日期加上一个天数”的操作,每个线程加的天数均不一样,
    // 这样当THREAD_NUMBERS个线程执行完毕后,应该有THREAD_NUMBERS个结果才是正确的
    for (int i = 0; i < THREAD_NUMBERS; i++) {
        Calendar calendar = Calendar.getInstance();
        int addDay = i;
        threadPool.execute(() -> {
            calendar.add(Calendar.DATE, addDay);
            String result = sdf.format(calendar.getTime());
            results.add(result);
        });
    }
    //保证线程执行完
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
    //最后打印结果
    System.out.println("希望:" + THREAD_NUMBERS + ",实际:" + results.size());
}
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

正常情况下,以上代码results.size()的结果应该是THREAD_NUMBERS。但是实际执行结果是一个小于该值的数字。

上面是format()方法出现的问题,同样,SimpleDateFormat的parse()方法也会出现线程不安全的问题:

@SneakyThrows
@Test
public void testParse() {
    String dateStr = "2020-10-22 08:08:08";
    for (int i = 0; i < 20; i++) {
        threadPool.execute(() -> {
            try {
                Date date = sdf.parse(dateStr);
                System.out.println(Thread.currentThread().getName() + "---" + date);
            } catch (ParseException e) {
                e.printStackTrace();
            }
        });
    }
    //保证线程执行完
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

运行结果:

[线程-0]---Thu May 22 08:00:08 CST 2228
[线程-3]---Sun Oct 22 08:08:08 CST 8000
[线程-4]---Thu Oct 22 08:08:08 CST 2020
[线程-5]---Thu Oct 22 08:08:08 CST 2020
Exception in thread "[线程-1]" Exception in thread "[线程-2]" java.lang.NumberFormatException: For input string: "101.E1012E2"
	at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
	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:2056)
	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.xblzer.tryout.SimpleDateFormatTest.lambda$testParse$1(SimpleDateFormatTest.java:78)
	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: "101.E1012E2"
	at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
	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:2056)
	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.xblzer.tryout.SimpleDateFormatTest.lambda$testParse$1(SimpleDateFormatTest.java:78)
	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)
[线程-8]---Wed Jan 22 08:09:28 CST 2020
[线程-11]---Sat Jan 25 16:08:08 CST 2020
[线程-9]---Thu Oct 22 08:08:08 CST 2020
Exception in thread "[线程-12]" java.lang.NumberFormatException: For input string: ""
[线程-10]---Thu Oct 22 08:08:08 CST 2020
	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:2051)
	at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
[线程-13]---Thu Oct 22 08:08:08 CST 2020
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at com.xblzer.tryout.SimpleDateFormatTest.lambda$testParse$1(SimpleDateFormatTest.java:78)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
[线程-14]---Thu Oct 22 08:08:08 CST 2020
	at java.lang.Thread.run(Thread.java:748)
[线程-16]---Thu Oct 22 08:08:08 CST 2020
[线程-18]---Thu Oct 22 08:08:08 CST 2020
[线程-16]---Thu Oct 22 08:08:08 CST 2020
[线程-18]---Thu Oct 22 08:08:08 CST 2020
Exception in thread "[线程-0]" java.lang.NumberFormatException: For input string: ""
[线程-16]---Thu Oct 22 08:08:08 CST 2020
[线程-17]---Thu Oct 22 08:08:08 CST 2020
	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:2051)
	at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at com.xblzer.tryout.SimpleDateFormatTest.lambda$testParse$1(SimpleDateFormatTest.java:78)
	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)
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

不仅有的线程解析的结果不正确,甚至有一些线程还出现了异常!

# 0x03 原因分析

原因就是因为 SimpleDateFormat 作为一个非线程安全的类,被当做了static共享变量在多个线程中进行使用,这就出现了线程安全问题。

来跟一下源码。

format(Date date)方法来源于类DateFormat中的如下方法:

public final String format(Date date)
{
    return format(date, new StringBuffer(),
                  DontCareFieldPosition.INSTANCE).toString();
}
1
2
3
4
5

调用abstract StringBuffer format(Date date, StringBuffer toAppendTo, FieldPosition fieldPosition)

public abstract StringBuffer format(Date date, StringBuffer toAppendTo,
                                        FieldPosition fieldPosition);
1
2

这是一个抽象方法,具体的实现看SimpleDateFormat类中的实现:

// 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);

    boolean useDateFormatSymbols = useDateFormatSymbols();

    for (int i = 0; i < compiledPattern.length; ) {
        int tag = compiledPattern[i] >>> 8;
        int count = compiledPattern[i++] & 0xff;
        if (count == 255) {
            count = compiledPattern[i++] << 16;
            count |= compiledPattern[i++];
        }

        switch (tag) {
        case TAG_QUOTE_ASCII_CHAR:
            toAppendTo.append((char)count);
            break;

        case TAG_QUOTE_CHARS:
            toAppendTo.append(compiledPattern, i, count);
            i += count;
            break;

        default:
            subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
            break;
        }
    }
    return toAppendTo;
}
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

大家看到了吧,format方法在执行过程中,会使用一个成员变量 calendar来保存时间。这就是问题的关键所在。

由于我们在声明SimpleDateFormat sdf的时候,使用的是static 定义的,所以这个sdf就是一个共享的变量,那么SimpleDateFormat中的calendar也可以被多个线程访问到。

例如,[线程-1]刚刚执行完calendar.setTime 把时间设置成 2020-10-22,还没执行完呢,[线程-2]又执行了calendar.setTime把时间改成了 2020-10-23。此时,[线程-1]继续往下执行,执行calendar.getTime得到的时间就是[线程-2]改过之后的。也就是说[线程-1]的setTime的结果被无情的无视了...

# 0x04 日期格式化的正确姿势

# 姿势1 使用synchronized

用synchronized对共享变量加同步锁,使多个线程排队按照顺序执行,从而避免多线程并发带来的线程安全问题。

@SneakyThrows
@Test
public void testWithSynchronized() {
    Set<String> results = Collections.synchronizedSet(new HashSet<>());
    for (int i = 0; i < THREAD_NUMBERS; i++) {
        Calendar calendar = Calendar.getInstance();
        int addDays = i;
        threadPool.execute(() -> {
            synchronized (sdf) {
                calendar.add(Calendar.DATE, addDays);
                String result = sdf.format(calendar.getTime());
                //System.out.println(Thread.currentThread().getName() + "---" + result);
                results.add(result);
            }
        });
    }
    //保证线程执行完
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
    //最后打印结果
    System.out.println("希望:" + THREAD_NUMBERS + ",实际:" + results.size());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 姿势2 将SimpleDateFormat设置成局部变量使用

局部变量不会被多个线程共享,也可以避免线程安全问题。

@SneakyThrows
@Test
public void testWithLocalVar() {
    Set<String> results = Collections.synchronizedSet(new HashSet<>());
    for (int i = 0; i < THREAD_NUMBERS; i++) {
        Calendar calendar = Calendar.getInstance();
        int addDays = i;
        threadPool.execute(() -> {
            SimpleDateFormat localSdf = new SimpleDateFormat("yyyy-MM-dd");
            calendar.add(Calendar.DATE, addDays);
            String result = localSdf.format(calendar.getTime());
            //System.out.println(Thread.currentThread().getName() + "---" + result);
            results.add(result);
        });
    }
    //保证线程执行完
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
    //最后打印结果
    System.out.println("希望:" + THREAD_NUMBERS + ",实际:" + results.size());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

每个线程都定义自己的变量SimpleDateFormat localSdf,格式化localSdf.format(calendar.getTime()),不会有线程安全问题。

# 姿势3 使用ThreadLocal

ThreadLocal的目的是确保每个线程都可以得到一个自己的 SimpleDateFormat的对象,所以也不会出现多线程之间的竞争问题。

/**
 * 定义线程数量
 **/
private static final int THREAD_NUMBERS = 50;

/**
 * 定义ThreadLocal<SimpleDateFormat>,每个线程都有一个独享的对象
 **/
private static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<>();

/**
 * 定义线程池
 **/
private static ExecutorService threadPool = new ThreadPoolExecutor(16,
        100,
        0L,
        TimeUnit.MILLISECONDS,
        new LinkedBlockingDeque<>(1024),
        new ThreadFactoryBuilder().setNameFormat("[线程-%d]").build(),
        new ThreadPoolExecutor.AbortPolicy()
        );

/**
 * 延迟加载SimpleDateFormat
 **/
private static SimpleDateFormat getDateFormat() {
    SimpleDateFormat dateFormat = dateFormatThreadLocal.get();
    if (dateFormat == null) {
        dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        dateFormatThreadLocal.set(dateFormat);
    }
    return dateFormat;
}

@SneakyThrows
@Test
public void testFormatWithThreadLocal() {
    Set<String> results = Collections.synchronizedSet(new HashSet<>());
    // 每个线程都执行“给日期加上一个天数”的操作,每个线程加的天数均不一样,
    // 这样当THREAD_NUMBERS个线程执行完毕后,应该有THREAD_NUMBERS个结果才是正确的
    for (int i = 0; i < THREAD_NUMBERS; i++) {
        Calendar calendar = Calendar.getInstance();
        int addDay = i;
        threadPool.execute(() -> {
            calendar.add(Calendar.DATE, addDay);
            //获取ThreadLocal中的本地SimpleDateFormat副本
            String result = getDateFormat().format(calendar.getTime());
            results.add(result);
        });
    }
    //保证线程执行完
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
    //最后打印结果
    System.out.println("希望:" + THREAD_NUMBERS + ",实际:" + results.size());
}
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

关键点就是

getDateFormat().format(calendar.getTime());
1

getDateFormat()拿到属于自己线程的SimpleDateFormat对象。

运行结果:

# 姿势4 使用DateTimeFormatter

Java 8之后,JDK提供了DateTimeFormatter类:

DateTimeFormatter

它也可以进行事件、日期的格式化,并且它是不可变的、线程安全的。

结合Java 8的LocalDateTime时间操作工具类进行测试验证:

Java 8的LocalDate、LocalTime、LocalDateTime进一步加强了对日期和时间的处理。

/**
 * 定义线程数量
 **/
private static final int THREAD_NUMBERS = 50;

private static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

/**
 * 定义线程池
 **/
private static ExecutorService threadPool = new ThreadPoolExecutor(16,
        100,
        0L,
        TimeUnit.MILLISECONDS,
        new LinkedBlockingDeque<>(1024),
        new ThreadFactoryBuilder().setNameFormat("[线程-%d]").build(),
        new ThreadPoolExecutor.AbortPolicy()
);

@SneakyThrows
@Test
public void testDateTimeFormatter() {
    Set<String> results = Collections.synchronizedSet(new HashSet<>());
    for (int i = 0; i < THREAD_NUMBERS; i++) {
        //这样写为了能用Lambda表达式
        LocalDateTime[] now = {LocalDateTime.now()};
        int addDay = i;
        threadPool.execute(() -> {
            now[0] = now[0].plusDays(addDay);
            //System.out.println(Thread.currentThread().getName() + "====" + now[0]);
            String result = now[0].format(formatter);
            results.add(result);
        });
    }
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
    System.out.println("希望:" + THREAD_NUMBERS + ",实际:" + results.size());
}
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

结果验证:

# 0x05 小结

SimpleDateFormat存在线程安全问题,使用以下几种方式解决该问题。

  • 加synchronized同步锁。并发量大的时候会有性能问题,线程阻塞。
  • 将SimpleDateFormat设置为局部变量。会频繁的创建和销毁对象,性能较低。
  • 使用ThreadLocal。推荐使用。
  • 使用Java 8新特性DateTimeFormatter。推荐使用。

# 优雅的避坑-未完待续

# 最后

欢迎并感谢关注我的微信公众号 行百里er,

二维码

回复 java 关键字,您将获得避坑系列原创文章:

还有Java精品pdf:

#Java
上次更新: 2022/10/04, 18:14:30
【优雅的避坑】为什么0.1+0.2不等于0.3了!?
new Object在内存中占多少字节?

← 【优雅的避坑】为什么0.1+0.2不等于0.3了!? new Object在内存中占多少字节?→

最近更新
01
重要数据不能丢!MySQL数据库定期备份保驾护航!
05-22
02
分布式事务解决方案之 Seata(二):Seata AT 模式
09-09
03
Seata 番外篇:使用 docker-compose 部署 Seata Server(TC)及 K8S 部署 Seata 高可用
09-05
更多文章>
Theme by Vdoing | Copyright © 2020-2023 行百里er | MIT License | 豫ICP备2022020385号-1
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式