SimpleDateFormat#parse和format方法的线程安全问题及解决办法

导读:本篇文章讲解 SimpleDateFormat#parse和format方法的线程安全问题及解决办法,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

我经常使用SimpleDateFormat类,将日期在String和Date之间做转化。

  • 使用SimpleDateFormat#parse方法,可以将满足格式要求的字符串转换成Date对象
  • 使用SimpleDateFormat#format方法,可以将Date类型的对象转换成一定格式的字符串

同时,我也注意到 SimpleDateFormat 的某些方法 并非是线程安全的,也就是说在并发环境下,如果多个线程共享 SimpleDateFormat 对象(声明为全局变量或者静态全局变量等),就有可能会出现线程安全问题。比如典型的 i++问题

SimpleDateFormat 的 javadoc 的部分描述:

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实例,则必须在外部同步该实例。

《阿里巴巴开发手册》的描述:

yBVMg1.png

测试 SimpleDateFormat#parse 方法

public class SimpleDateFormatTest {

    public static void main(String[] args) {
        new SimpleDateFormatTest().threadUnsafeTest();
    }

    // SimpleDateFormat类的线程安全问题

    // 总任务数
    private static final int EXECUTE_COUNT = 100;
    // 同时执行的任务数的上限
    private static final int THREAD_COUNT = 20;
    // 日期格式化器
    // 这里非静态也可以,主要是让多个线程共享这个 SimpleDateFormat 对象
    private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");

    /**
     * 使用线程池结合Java并发包中的CountDownLatch类和Semaphore类来重现SimpleDateFormat的线程安全问题。
     *          1 CountDownLatch 类可以使一个线程等待其他线程各自执行完毕后再执行。
     *          2 Semaphore 类可以理解为一个计数信号量,必须由获取它的线程释放,经常用来限制访问某些资源的线程数量,例如限流等。
     *
     */
    public void threadUnsafeTest(){

        // 信号量,类似于 PV 操作的信号量
        final Semaphore semaphore = new Semaphore(THREAD_COUNT);
        //
        final CountDownLatch countDownLatch = new CountDownLatch(EXECUTE_COUNT);

        // CachedThreadPool 线程池:
        // 构造方法:
        // new ThreadPoolExecutor(0, Integer.MAX_VALUE,
        //                          60L, TimeUnit.SECONDS,
        //                          new SynchronousQueue<Runnable>());
        // 任务执行的流程:
        // 1 因为没有核心线程,所以,会直接向 SynchronousQueue 中提交任务;
        // 2 如果线程池中有空闲线程,就去取出任务执行;如果没有空闲线程,就新建一个;
        // 3 执行完任务的线程有 60 秒生存时间,如果在这个时间内可以接到新任务,就可以继续活下去,否则就拜拜;
        // 4 由于空闲 60 秒的线程会被终止,长时间保持空闲的 CachedThreadPool 不会占用任何资源。

        // SynchronousQueue 阻塞队列:
        // CachedThreadPool 使用的阻塞队列是 SynchronousQueue。
        // SynchronousQueue 是一个内部只能包含一个元素的队列。
        // 插入元素到队列的线程被阻塞,直到另一个线程从队列中获取了队列中存储的元素。
        // 同样,如果线程尝试获取元素并且当前不存在任何元素,则该线程将被阻塞,直到线程将元素插入队列。

        // 用途:
        // CachedThreadPool 用于并发执行大量短期的小任务,或者是负载较轻的服务器。
        ExecutorService executor = Executors.newCachedThreadPool();

        for (int i = 0; i < EXECUTE_COUNT; i++) {
            executor.execute(() ->{
                try {
                    // 限制最多同时只能有 20 个线程并发执行
                    semaphore.acquire();
                    try {
                        // String 字符串 解析为 Date对象
                        sdf.parse(LocalDate.now().toString());
                    } catch (ParseException e) {
                        System.out.println("线程 " + Thread.currentThread().getName() + "格式化日期失败");
                        e.printStackTrace();
                        System.exit(1);
                    }
                    // 释放信号量
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    System.exit(1);
                }
                //
                countDownLatch.countDown();
            });
        }
        try {
            // main 线程阻塞,阻塞到所有的线程执行完成为止
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 等待所有任务执行结束,再关闭线程池
            executor.shutdown();
        }
        System.out.println("所有日期格式化完成");
    }
}

运行后,报错如下:

Exception in thread "pool-1-thread-2" Exception in thread "pool-1-thread-1" 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 test.treadUnsafe.SimpleDateFormatTest.lambda$threadUnsafeTest$0(SimpleDateFormatTest.java:77)
	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:multiple points错,可见并发环境下使用SimpleDateFormat#parse方法,确实有线程安全问题!

线程安全问题的原因

SimpleDateFormat转换日期是通过Calendar对象来操作的。

SimpleDateFormat继承自DateFormat类,DateFormat类中有一个Calendar对象属性,如下:

// `SimpleDateFormat`继承自`DateFormat`类
public class SimpleDateFormat extends DateFormat{
    // 略
}

// `DateFormat`类中有一个`Calendar`对象属性
public abstract class DateFormat extends Format {
    
    /**
     * 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
     */
    // 注释的大致意思是:
    // 此处Calendar实例被用来进行日期-时间计算,
    // 既被用于format方法也被用于parse方法
    protected Calendar calendar;
    // 略
}

通过进入到源码中,我发现,SimpleDateFormat#parse(String) 实际继承自 DateFormat#parse(String)

sdf.parse(LocalDate.now().toString());

打开DateFormat#parse(String)方法,如下所示:

public Date parse(String source) throws ParseException
{
    ParsePosition pos = new ParsePosition(0);
    // 这里有调用了 DateFormat#parse的重载方法
    // public abstract Date parse(String source, ParsePosition pos);
    // 可以发现,这是个抽线方法
    // 这个重载方法是由 SimpaleDateFormat 实现的
    Date result = parse(source, pos);
    if (pos.index == 0)
        throw new ParseException("Unparseable date: \"" + source + "\"" ,
            pos.errorIndex);
    return result;
}

可以看到,在DateFormat#parse(String)方法中,再次调用了重载方法DateFormat#parse(String, ParsePosition)方法来格式化日期,这个重载方法是抽象的,这个重载方法具体由其子类 SimpaleDateFormat 实现的。

通过对SimpleDateFormat#parse(String, ParsePosition)方法的分析可以得知:

  1. SimpaleDateFormat#parse(String, ParsePosition)方法中存在几处为 ParsePosition#index 赋值的操作。在高并发场景下,一个线程对 ParsePosition#index 进行修改,势必会影响到其他线程对ParsePosition#index 的读操作。这就造成了线程的安全问题。

  2. 此外, SimpaleDateFormat#parse(String, ParsePosition)方法中调用了CalenderBuilder#establish来进行解析,这个方法中又调用了 Calender#clear方法来重置 Calender 实例的属性。如果此时线程A执行Calender#clear,且没有设置新值,线程B也进入parse方法用到了SimpleDateFormat对象中的calendar对象,此时就会产生线程安全问题!

注:CalenderBuilder 是 Calender类的构建器类

测试 SimpleDateFormat#format 方法

public class SimpleDateFormatTest2 {

    private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    // 初始化每个线程的时间
    private static List<Date> list = new ArrayList<>();

    static {
        // 每个线程对应处理的Date对象
        // 我将会开启10个线程
        Date date1 = new Date(2011-1900, Calendar.JANUARY,1);
        Date date2 = new Date(2012-1900, Calendar.JANUARY,1);
        Date date3 = new Date(2013-1900, Calendar.JANUARY,1);
        Date date4 = new Date(2014-1900, Calendar.JANUARY,1);
        Date date5 = new Date(2015-1900, Calendar.JANUARY,1);
        Date date6 = new Date(2016-1900, Calendar.JANUARY,1);
        Date date7 = new Date(2017-1900, Calendar.JANUARY,1);
        Date date8 = new Date(2018-1900, Calendar.JANUARY,1);
        Date date9 = new Date(2019-1900, Calendar.JANUARY,1);
        Date date10 = new Date(2020-1900, Calendar.JANUARY,1);
        list.add(date1);
        list.add(date2);
        list.add(date3);
        list.add(date4);
        list.add(date5);
        list.add(date6);
        list.add(date7);
        list.add(date8);
        list.add(date9);
        list.add(date10);

    }

    public static void main(String[] args) {

         // FixedThreadPool 线程池:
        // 构造方法:
        // new ThreadPoolExecutor(nThreads, nThreads,
        //                                       0L, TimeUnit.MILLISECONDS,
        //                                       new LinkedBlockingQueue<Runnable>());
        // 可以看到,核心线程数 == 最大线程数,且 KeepLive == 0,
        // KeepLive: 表示当核心线程满了(肯定没有超过最大线程数),新创建的线程,在没有执行任务的期间可以存活的最大时间
        // KeepLive == 0,则表示不等待直接退出
        // 工厂类传递的第一个参数和第二个参数都设置成了nThreads。即线程池的核心线程数和最大线程数相等。

        // FixedThreadPool 线程池的工作流程:
        // 1 如果当前运行的线程数量小于corePoolSize,则创建新线程来执行任务。
        // 2 在线程池中当前运行的线程数量等于corePoolSize,由于无法在创建新的线程进行任务处理,所以会将任务加入到阻塞队列中进行排队等候处理。
        // 3 当线程池中的线程执行完一个任务后,就会去阻塞队列中循环获取新的任务继续执行。

        // 无边界阻塞队列:LinkedBlockingQueue
        // 构造方法:
        // public LinkedBlockingQueue() {
        //     this(Integer.MAX_VALUE);
        // }
        // 通过构造方法可以看到,这是一个 大小为 Integer.MAX_VALUE 的阻塞队列

        // 注意:因为LinkedBlockingQueue是长度为Integer.MAX_VALUE的队列,可以认为是无界队列,
        //      因此往队列中可以插入无限多的任务,在资源有限的时候容易引起OOM异常
        ExecutorService service = Executors.newFixedThreadPool(10);

        try {
            // 提交 10个任务
            for (int i = 0; i < 10; i++) {
                final int t = i;
                service.execute(() -> {
                    Date date = list.get(t % 10);
                    // 格式化 日期
                    System.out.println("[" + Thread.currentThread().getName() + "]: 格式化前" + date);
                    String res = sdf.format(list.get(t % 5));
                    System.out.println("[" + Thread.currentThread().getName() + "]: 格式化后" + res);
                });
            }
        } finally {
            // 等待上述的线程全部执行完,再关闭线程池
            service.shutdown();
        }
    }
}

运行结果:

[pool-1-thread-3]: 格式化前Tue Jan 01 00:00:00 CST 2013
[pool-1-thread-2]: 格式化前Sun Jan 01 00:00:00 CST 2012
[pool-1-thread-4]: 格式化前Wed Jan 01 00:00:00 CST 2014
[pool-1-thread-6]: 格式化前Fri Jan 01 00:00:00 CST 2016
[pool-1-thread-1]: 格式化前Sat Jan 01 00:00:00 CST 2011
[pool-1-thread-5]: 格式化前Thu Jan 01 00:00:00 CST 2015
[pool-1-thread-2]: 格式化后2012-01-01 00:00:00
[pool-1-thread-7]: 格式化前Sun Jan 01 00:00:00 CST 2017
[pool-1-thread-7]: 格式化后2012-01-01 00:00:00
[pool-1-thread-6]: 格式化后2011-01-01 00:00:00
[pool-1-thread-5]: 格式化后2015-01-01 00:00:00
[pool-1-thread-8]: 格式化前Mon Jan 01 00:00:00 CST 2018
[pool-1-thread-4]: 格式化后2014-01-01 00:00:00
[pool-1-thread-1]: 格式化后2011-01-01 00:00:00
[pool-1-thread-3]: 格式化后2012-01-01 00:00:00
[pool-1-thread-8]: 格式化后2013-01-01 00:00:00
[pool-1-thread-9]: 格式化前Tue Jan 01 00:00:00 CST 2019
[pool-1-thread-9]: 格式化后2014-01-01 00:00:00
[pool-1-thread-10]: 格式化前Wed Jan 01 00:00:00 CST 2020
[pool-1-thread-10]: 格式化后2015-01-01 00:00:00

通过结果我们可以看出问题:

线程3最开始设置的时间是2013年,但是因为并发的问题,最终输出的格式化结果却是2012年。

[pool-1-thread-3]: 格式化前Tue Jan 01 00:00:00 CST 2013
[pool-1-thread-3]: 格式化后2012-01-01 00:00:00

结果显然,格式化日期出现线程安全问题。

线程安全问题的原因

SimpleDateFormat#format(Date) 方法的调用过程和 SimpleDateFormat#parse(String) 的十分类似。

同样的,SimpleDateFormat#format(Date) 方法 实际是继承自 DateFormat;

同样的,DateFormat#format(Date) 也调用了自己的重载方法;

// SimpleDateFormat#format(Date)
sdf.format(list.get(t % 5));

// DateFormat#format(Date)
public final String format(Date date)
{
    return format(date, new StringBuffer(),
                    DontCareFieldPosition.INSTANCE).toString();
}

这个重载方法也是抽象的,具体由其子类SimpleDateFormat实现

// DateFormat#format(Date, StringBuffer, FieldPosition)
public abstract StringBuffer format(Date date, StringBuffer toAppendTo,
                                    FieldPosition fieldPosition);

我们实际调用的format方法就是这个重载方法,代码如下:

/**
 * Formats the given <code>Date</code> into a date/time string and appends
 * the result to the given <code>StringBuffer</code>.
 *
 * @param date the date-time value to be formatted into a date-time string.
 * @param toAppendTo where the new date-time text is to be appended.
 * @param pos the formatting position. On input: an alignment field,
 * if desired. On output: the offsets of the alignment field.
 * @return the formatted date-time string.
 * @exception NullPointerException if the given {@code date} is {@code null}.
 */
@Override
public StringBuffer format(Date date, StringBuffer toAppendTo,
                            FieldPosition pos)
{
    pos.beginIndex = pos.endIndex = 0;
    return format(date, toAppendTo, pos.getFieldDelegate());
}

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

    // 略
}

可以看出,问题就出在calendart.setTime(date)

当多线程并发的时候,如果线程A首先设置了calendar的date,此时线程B获得锁,紧跟着又设置了一个date,对于同一线程A而言,它在不知情的情况下被修改了date。最终线程A执行format出来的结果变成了线程B的时间。

解决方案

问题的根源就是:SimpleDateFormat 被多个线程共享,它的parseformat方法就像是 i++ 一样,它维持的 Calender 等属性无法保证原子性。

  1. 使用局部变量,保证每个线程中都有一份SimpleDateFormat实例,那么,线程之间的 SimpleDateFormat实例 就没有任何关系了,不会互相影响了。
    不过,也加重了创建对象的负担,会频繁地创建和销毁对象,效率较低。

  2. 使用 ThreadLocal

使用 ThreadLocal 实现 每个线程都可以得到单独的一个SimpleDateFormat的实例,那么自然也就不存在竞争问题了。

注意:这里我是重写了ThreadLocal#initialValue方法,因为 每次调用 ThreadLocal#get() 时,都会执行一遍 initialValue,相当于每个线程都new 了一份 SimpleDateFormt 实例,所以,才保证了线程安全。

本质上 ThreadLocal 并不是用来保证 线程安全的。

private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>() {
    @Override
    protected DateFormat initialValue() {
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    }
};

public static Date parse(String dateStr) throws ParseException {
    return threadLocal.get().parse(dateStr);
}

public static String format(Date date) {
    return threadLocal.get().format(date);
}
  1. 同步代码块 synchronized
  2. Lock锁方式,与synchronized锁方式实现原理相同,都是在高并发下通过JVM的锁机制来保证程序的线程安全。具体使用的是 Lock 的子类 ReentrantLock (可重入锁)
  3. 基于JDK1.8的 DateTimeFormatter

DateTimeFormatter 是线程安全的。

// 指定格式 静态方法 ofPattern()
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// DateTimeFormatter 自带的格式方法
LocalDateTime now = LocalDateTime.now();
// DateTimeFormatter 把日期对象,格式化成字符串
String strDate1 = formatter.format(now);
System.out.println(strDate1);

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/69728.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!