并发编程之 ThreadLocal

时间:2022-11-01 10:08:04

前言

了解过 SimpleDateFormat 时间工具类的朋友都知道,该工具类非常好用,可以利用该类可以将日期转换成文本,或者将文本转换成日期,时间戳同样也可以。

以下代码,我们采用通用的 SimpleDateFormat 对象,在线程池 threadPool 中,将对应的 i 值调用 sec2Date 方法来实现日期转换,并且 sec2Date 方法是用 synchronized 修饰的,在多线程竞争的场景下,来达到线程安全的目的。

public class SynchronizedTest {

    public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
    public static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");

    public static void main(String[] args) {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            threadPool.submit(() -> System.out.println(finalI + "---" + new ThreadLocal2().sec2Date(finalI)));
        }
        threadPool.shutdown();
    }

    private synchronized String sec2Date(int seconds) {
        Date date = new Date(seconds * 1000L);
        String format = dateFormat.format(date);
        return format;
    }

}

输出结果:

并发编程之 ThreadLocal

但是在结果中,我们不难看出,还是会输出重复值,即使我们用了 synchronized 修饰方法,还是会出现线程不安全的情况。之所以出现这种现象,并非是我们编写的代码出了问题,毕竟在我们平时开发中,通过 synchronized 关键字确实能达到线程安全的目的,这里其实是 SimpleDateFormat 内部并不是线程安全的 导致的。

主要原因:当两个及以上线程同时使用相同的 SimpleDateFormat 对象(如 static 修饰)的话,就拿上面调用的 format 方法时,format 方法内部就会出现多个线程会同时调用 calendar.setTime 方法时,在多线程竞争的情况下,发生幻读,就会导致重复值的发生。

下面,我们去看下 SimpleDateFormat 的 format 源码,去探究下为什么会线程不安全。

并发编程之 ThreadLocal

以上源码就是 SimpleDateFormat 类下的 format 方法的源码,我们不需要过多了解里面具体的实现细节,我们只需要关注红色框住的内容,即 calendar.setTime(date);,该 calendar 是 SimpleDateFormat 的父类 DateFormat 定义的一个成员变量。
并发编程之 ThreadLocal

由此我们可以得到一个结论:在多线程竞争的情况下,它们就会共享这个 calendar 成员变量,并去调用它的 calendar.setTime(date) 修改值,这样就会导致 date 变量被其他线程给修改或覆盖掉,就会导致最终的结果会出现重复的情况,因此 SimpleDateFormat 是线程不安全的。

解决方案一:我们只需要用 synchronized 直接修饰 dateFormat 变量,让每次只有一个线程能够操作 dateFormat 的权利,说白了就是让 synchronized 修饰的这块代码去串行执行,就可以避免发生线程不安全的情况。

public class SynchronizedTest {

    public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
    public static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");


    public static void main(String[] args) {

        for (int i = 0; i < 1000; i++) {

            int finalI = i;
            threadPool.submit(() -> System.out.println(finalI + "---" + new SynchronizedTest().sec2Date(finalI)));

        }
        threadPool.shutdown();

    }

    private String sec2Date(int seconds) {
        Date date = new Date(seconds * 1000L);
        String format;
        synchronized (dateFormat) {
            format = dateFormat.format(date);
        }
        return format;
    }

}

解决方案二:原理如同方案一相同(一个是锁住 dateFormat 变量,另一个是锁着整个 SynchronizedTest 类 )

public class SynchronizedTest {

    public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
    public static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");


    public static void main(String[] args) {

        for (int i = 0; i < 1000; i++) {

            int finalI = i;
            threadPool.submit(() -> System.out.println(finalI + "---" + new SynchronizedTest().sec2Date(finalI)));

        }
        threadPool.shutdown();

    }

    private String sec2Date(int seconds) {
        Date date = new Date(seconds * 1000L);
        String format;
        synchronized (SynchronizedTest.class) {
            format = dateFormat.format(date);
        }
        return format;
    }

}

但是加 synchronized 这种方式虽然也能保证线程安全,但是这种方式效率会比较低,毕竟同一时刻下,只能有一个线程能够执行程序,这显然不是最好的方案,下面我们来了解下更高效的方式,就是利用 ThreadLocal 类来实现。

ThreadLocal

介绍:每个线程需要一个独享的对象,每个 Thread 内有自己的实例副本,这些实例副本是不共享的,让某个需要用到的对象在线程间隔离,即每个线程都有自己的独立的对象。

使用ThreadLocal 的好处

  • 达到线程安全
  • 不需要加锁,提高执行效率
  • 合理利用内存,节省开销

以下代码,我们构建了一个内部类 ThreadSafeFormatter 类,在类内部定义 ThreadLocal 的成员变量,并重写了 initialValue 方法,返回的参数就是 new 出来的 SimpleDateFormat 对象。

public class ThreadLocalTest {

    public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
    
    public static void main(String[] args) {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            threadPool.submit(() -> System.out.println(new ThreadLocalTest().sec2Date(finalI)));
        }
    }

    private String sec2Date(int seconds) {
        //	在 ThreadLocal 第一个 get 的时候把对象初始化出来,对象的初始化时机可以由我们控制
        SimpleDateFormat dateFormat = ThreadSafeFormatter.dateFormatThreadLocal2.get();
        return dateFormat.format(seconds * 1000);
    }

    static class ThreadSafeFormatter {
        //	方式一(原始方式)
        public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<SimpleDateFormat>() {
            // 初始化
            @Override
            protected SimpleDateFormat initialValue() {
                return new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
            }
        };
        //	方式二(Lambda表达式)
        public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal2 = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"));
    }
}

输出结果:

并发编程之 ThreadLocal

结果中我们可以看出,没有输出重复的时间值(可以多运行几次观察下),因此我们通过 ThreadLocal 这种方式就达到了线程安全,并且还节省了系统的开销,合理利用了内存。

由此我们可以得到一个结论:每个线程的 SimpleDateFormat 是独立的,一共有 10 个。每个线程会平均执行 100 个任务,每个线程之间都是复用一个 SimpleDateFormat 对象。

ThreadLocal 源码分析

在了解 ThreadLocal 源码之前,我们先了解以下 Thread,ThreadLocalMap 以及 ThreadLocal 三者之间的关系。

首先,我们创建的每一个 Thread 对象中都持有一个 ThreadLocalMap 成员变量,而 ThreadLocalMap 中可以存放着很多的 key 为 ThreadLocal 的键值对。

并发编程之 ThreadLocal

并发编程之 ThreadLocal

主要方法介绍

  • T initialValue() : 初始化,返回当前线程对应的“初始值”,这是一个延迟加载的方法,只有在调用get的时候,才会触发。
  • void set(T t) : 为这个线程设置一个新值。
  • T get() : 得到这个线程对应的value。如果是首次调用 get() ,则会调用 initialize 来得到这个值。
  • void remove() :删除对应这个线程的值。

initialValue

SimpleDateFormat dateFormat = ThreadSafeFormatter.dateFormatThreadLocal2.get();

在上述代码,我们并没有显式地调用这个 initialValue 方法,而是调用了 get 方法,而在 get 方法中,它会去调用

setInitialValue 方法,在 该方法内部它才会去调用我们重写的 initialValue 方法。

并发编程之 ThreadLocal

并发编程之 ThreadLocal

如果没有重写 initialValue 时,默认会返回 null

并发编程之 ThreadLocal

如果线程先前调用了set方法,在这种情况下,不会为线程调用本 initialValue 方法,而是直接用之前 set 进去的值。

在通常情况下,每个线程最多只能调用一次 initialValue 方法,但是如果已经调用了 remove 方法之后,再调用 get 方法,则可以再次调用 initialValue 方法。

get

get 方法是先取出当前线程的 ThreadLocalMap ,然后调用 map.getEntry 方法,把本 ThreadLocal 的引用作为参数传入,取出 map 中属于本 ThreadLocal 的value。

public T get() {
    //	获取当前线程
    Thread t = Thread.currentThread();
    //	获取当前线程的  threadLocals 成员变量
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // this 指的是 ThreadLocal 对象,通过 map.getEntry 来获取我们通过 set 方法设置进去的 value 值
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

set

跟 get 一样,同样是先获取当前线程的引用,然后再获取当前线程的 threadLocals 成员变量,如果 threadLocals 为null ,即还未初始化,就会执行 createMap 方法来进行初始化。

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        //	this 指的是 ThreadLocal 对象,value 就是想要设置进去的值
        map.set(this, value);
    else
        createMap(t, value);
}
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

map.set(this, value); 需要注意的是,这个 map 以及 map 中的 key 和 value 都是保存在 Thread 线程中的,而不是保存在 ThreadLocal 中。

remove

原理跟 get 和 set 类似,这里就不赘述了。

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

ThreadLocal 的内存泄露

内存泄漏:当某个对象不再有引用,但是所占用的内存不能被回收。

下面我们来看 ThreadLocal 的静态内部类 ThreadLocalMap ,ThreadLocalMap 的 Entry 其实就是存放每一个ThreadLocal 和 value 键值对的集合。

并发编程之 ThreadLocal

并发编程之 ThreadLocal

Entry 静态类的构造方法,分别执行了 super(k); value = v; 其中 super(k) 去父类中进行初始化,而从 Entry extends 的父类我们可以看出,WeakReference 父类是一个弱引用类,则说明了 k 值是一个弱引用的, 而 value 就是一个强引用。

强引用:任何时候都不会被回收,即使发生 GC 的时候也不会被回收(赋值就是一种强引用)

弱引用:对象只被弱引用关联,在下一次 GC 时会被回收。(可以理解为只要触发一次GC,就可以扫描到并被回收掉)

由此我们可以得知,ThreadLocalMap 的每一个 Entry 都是一个对 key 的弱引用,但是每一个 Entry 都包含了一个对 value 的强引用。而由于线程池中的线程池存活时间都比较长,那么 Entry 的 key 是可以被回收掉的,但是 value 无法被回收,就会发生内存泄漏。

JDK 的设计者也考虑到了这个不足之处,所以在经常调用的方法,比如 set, remove, rehash 会主动去扫描 key 为 null 的 Entry,并把对应的 value 设置 null,这样 value 对象也可以被 GC 给回收掉。

另外在阿里巴巴 Java 开发手册也明确指出,应该显式地调用 remove 方法,删除 Entry 对象,避免内存泄漏。

【强制】 必须回收自定义的 ThreadLocal 变量,尤其在线程池场景下,线程经常会被复用,如果不清理自定义的 ThreadLocal 变量,可能会影响到后续业务逻辑和造成内存泄漏等问题。尽量在代码中使用 try-finally 块进行回收。

objThreadLocal.set(someObject);
try{
	...
} finally {
 objThreadLocal.remove();
}