8

SimpleDateFormat 线程不安全的 5 种解决方案!

 4 years ago
source link: https://www.techug.com/post/five-solutions-to-thread-insecurity-of-simpledateformat.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client


img1621394963901285072.png

1.什么是线程不安全?

线程不安全也叫非线程安全,是指多线程执行中,程序的执行结果和预期的结果不符的情况就叫做线程不安全。​

线程不安全的代码

SimpleDateFormat 就是一个典型的线程不安全事例,接下来我们动手来实现一下。首先我们先创建 10 个线程来格式化时间,时间格式化每次传递的待格式化时间都是不同的,所以程序如果正确执行将会打印 10 个不同的值,接下来我们来看具体的代码实现:

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SimpleDateFormatExample {
    // 创建 SimpleDateFormat 对象
    private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");

    public static void main(String[] args) {
        // 创建线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(10);
        // 执行 10 次时间格式化
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            // 线程池执行任务
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    // 创建时间对象
                    Date date = new Date(finalI * 1000);
                    // 执行时间格式化并打印结果
                    System.out.println(simpleDateFormat.format(date));
                }
            });
        }
    }
}

我们预期的正确结果是这样的(10 次打印的值都不同):

然而,以上程序的运行结果却是这样的:

从上述结果可以看出,当在多线程中使用 SimpleDateFormat 进行时间格式化是线程不安全的。​

2.解决方案

SimpleDateFormat 线程不安全的解决方案总共包含以下 5 种:

  1. SimpleDateFormat 定义为局部变量;
  2. 使用 synchronized 加锁执行;
  3. 使用 Lock 加锁执行(和解决方案 2 类似);
  4. 使用 ThreadLocal
  5. 使用 JDK 8 中提供的 DateTimeFormat

接下来我们分别来看每种解决方案的具体实现。

① 将 SimpleDateFormat 变为局部变量

SimpleDateFormat 定义为局部变量时,因为每个线程都是独享 SimpleDateFormat 对象的,相当于将多线程程序变成“单线程”程序了,所以不会有线程不安全的问题,具体实现代码如下:

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SimpleDateFormatExample {
    public static void main(String[] args) {
        // 创建线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(10);
        // 执行 10 次时间格式化
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            // 线程池执行任务
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    // 创建 SimpleDateFormat 对象
                    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");
                    // 创建时间对象
                    Date date = new Date(finalI * 1000);
                    // 执行时间格式化并打印结果
                    System.out.println(simpleDateFormat.format(date));
                }
            });
        }
        // 任务执行完之后关闭线程池
        threadPool.shutdown();
    }
}

以上程序的执行结果为:

当打印的结果都不相同时,表示程序的执行是正确的,从上述结果可以看出,将 SimpleDateFormat 定义为局部变量之后,就可以成功的解决线程不安全问题了。​

② 使用 synchronized 加锁

锁是解决线程不安全问题最常用的手段,接下来我们先用 synchronized 来加锁进行时间格式化,实现代码如下:

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SimpleDateFormatExample2 {
    // 创建 SimpleDateFormat 对象
    private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");

    public static void main(String[] args) {
        // 创建线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(10);
        // 执行 10 次时间格式化
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            // 线程池执行任务
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    // 创建时间对象
                    Date date = new Date(finalI * 1000);
                    // 定义格式化的结果
                    String result = null;
                    synchronized (simpleDateFormat) {
                        // 时间格式化
                        result = simpleDateFormat.format(date);
                    }
                    // 打印结果
                    System.out.println(result);
                }
            });
        }
        // 任务执行完之后关闭线程池
        threadPool.shutdown();
    }
}

以上程序的执行结果为:

③ 使用 Lock 加锁

在 Java 语言中,锁的常用实现方式有两种,除了 synchronized 之外,还可以使用手动锁 Lock,接下来我们使用 Lock 来对线程不安全的代码进行改造,实现代码如下:

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * Lock 解决线程不安全问题
 */
public class SimpleDateFormatExample3 {
    // 创建 SimpleDateFormat 对象
    private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");

    public static void main(String[] args) {
        // 创建线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(10);
        // 创建 Lock 锁
        Lock lock = new ReentrantLock();
        // 执行 10 次时间格式化
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            // 线程池执行任务
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    // 创建时间对象
                    Date date = new Date(finalI * 1000);
                    // 定义格式化的结果
                    String result = null;
                    // 加锁
                    lock.lock();
                    try {
                        // 时间格式化
                        result = simpleDateFormat.format(date);
                    } finally {
                        // 释放锁
                        lock.unlock();
                    }
                    // 打印结果
                    System.out.println(result);
                }
            });
        }
        // 任务执行完之后关闭线程池
        threadPool.shutdown();
    }
}

以上程序的执行结果为:

从上述代码可以看出,手动锁的写法相比于 synchronized 要繁琐一些。

④ 使用 ThreadLocal

加锁方案虽然可以正确的解决线程不安全的问题,但同时也引入了新的问题,加锁会让程序进入排队执行的流程,从而一定程度的降低了程序的执行效率,如下图所示:

那有没有一种方案既能解决线程不安全的问题,同时还可以避免排队执行呢?​

答案是有的,可以考虑使用 ThreadLocalThreadLocal 翻译为中文是线程本地变量的意思,字如其人 ThreadLocal 就是用来创建线程的私有(本地)变量的,每个线程拥有自己的私有对象,这样就可以避免线程不安全的问题了,实现如下:

知道了实现方案之后,接下来我们使用具体的代码来演示一下 ThreadLocal 的使用,实现代码如下:

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * ThreadLocal 解决线程不安全问题
 */
public class SimpleDateFormatExample4 {
    // 创建 ThreadLocal 对象,并设置默认值(new SimpleDateFormat)
    private static ThreadLocal<SimpleDateFormat> threadLocal =
            ThreadLocal.withInitial(() -> new SimpleDateFormat("mm:ss"));

    public static void main(String[] args) {
        // 创建线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(10);
        // 执行 10 次时间格式化
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            // 线程池执行任务
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    // 创建时间对象
                    Date date = new Date(finalI * 1000);
                    // 格式化时间
                    String result = threadLocal.get().format(date);
                    // 打印结果
                    System.out.println(result);
                }
            });
        }
        // 任务执行完之后关闭线程池
        threadPool.shutdown();
    }
}

以上程序的执行结果为:

ThreadLocal 和局部变量的区别

首先来说 ThreadLocal 不等于局部变量,这里的“局部变量”指的是像 2.1 示例代码中的局部变量, ThreadLocal 和局部变量最大的区别在于:ThreadLocal 属于线程的私有变量,如果使用的是线程池,那么 ThreadLocal 中的变量是可以重复使用的,而代码级别的局部变量,每次执行时都会创建新的局部变量,二者区别如下图所示:

更多关于 ThreadLocal 的内容,可以访问磊哥前面的文章《ThreadLocal不好用?那是你没用对!》

⑤ 使用 DateTimeFormatter

以上 4 种解决方案都是因为 SimpleDateFormat 是线程不安全的,所以我们需要加锁或者使用 ThreadLocal 来处理,然而,JDK 8 之后我们就有了新的选择,如果使用的是 JDK 8+ 版本,就可以直接使用 JDK 8 中新增的、安全的时间格式化工具类 DateTimeFormatter 来格式化时间了,接下来我们来具体实现一下。

使用 DateTimeFormatter 必须要配合 JDK 8 中新增的时间对象 LocalDateTime 来使用,因此在操作之前,我们可以先将 Date 对象转换成 LocalDateTime,然后再通过 DateTimeFormatter 来格式化时间,具体实现代码如下:

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * DateTimeFormatter 解决线程不安全问题
 */
public class SimpleDateFormatExample5 {
    // 创建 DateTimeFormatter 对象
    private static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("mm:ss");

    public static void main(String[] args) {
        // 创建线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(10);
        // 执行 10 次时间格式化
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            // 线程池执行任务
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    // 创建时间对象
                    Date date = new Date(finalI * 1000);
                    // 将 Date 转换成 JDK 8 中的时间类型 LocalDateTime
                    LocalDateTime localDateTime =
                            LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());
                    // 时间格式化
                    String result = dateTimeFormatter.format(localDateTime);
                    // 打印结果
                    System.out.println(result);
                }
            });
        }
        // 任务执行完之后关闭线程池
        threadPool.shutdown();
    }
}

以上程序的执行结果为:

3.线程不安全原因分析

要了解 SimpleDateFormat 为什么是线程不安全的?我们需要查看并分析 SimpleDateFormat 的源码才行,那我们先从使用的方法 format 入手,源码如下:

private StringBuffer format(Date date, StringBuffer toAppendTo,
                                FieldDelegate delegate) {
    // 注意此行代码
    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;
}

也许是好运使然,没想到刚开始分析第一个方法就找到了线程不安全的问题所在。​

从上述源码可以看出,在执行 SimpleDateFormat.format 方法时,会使用 calendar.setTime 方法将输入的时间进行转换,那么我们想象一下这样的场景:

  1. 线程 1 执行了 calendar.setTime(date) 方法,将用户输入的时间转换成了后面格式化时所需要的时间;
  2. 线程 1 暂停执行,线程 2 得到 CPU 时间片开始执行;
  3. 线程 2 执行了 calendar.setTime(date) 方法,对时间进行了修改;
  4. 线程 2 暂停执行,线程 1 得出 CPU 时间片继续执行,因为线程 1 和线程 2 使用的是同一对象,而时间已经被线程 2 修改了,所以此时当线程 1 继续执行的时候就会出现线程安全的问题了。

正常的情况下,程序的执行是这样的:

非线程安全的执行流程是这样的:

在多线程执行的情况下,线程 1 的 date1 和线程 2 的 date2,因为执行顺序的问题,最终都被格式化成 date2 formatted,而非线程 1 date1 formatted 和线程 2 date2 formatted,这样就会导致线程不安全的问题。

4.各方案优缺点总结

如果使用的是 JDK 8+ 版本,可以直接使用线程安全的 DateTimeFormatter 来进行时间格式化,如果使用的 JDK 8 以下版本或者改造老的 SimpleDateFormat 代码,可以考虑使用 synchronizedThreadLocal 来解决线程不安全的问题。因为实现方案 1 局部变量的解决方案,每次执行的时候都会创建新的对象,因此不推荐使用。synchronized 的实现比较简单,而使用 ThreadLocal 可以避免加锁排队执行的问题。

关注公号「Java 中文社群」查看更多有意思、涨知识的并发编程文章。

本文作者: InfoQ


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK