强引用和弱引用的Threadlocal

1,893 阅读9分钟

从SimpleDateFormat开始

首先看一个例子,创建20个线程,线程里就干一件事,就是转换时间

public class ThreadLoaclExample {

    //非线程安全的
    private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static Date parse(String strDate) throws ParseException {
        return sdf.parse(strDate);
    }

    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                try {
                    System.out.println(parse("2021-11-18 21:36:17"));
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
   
}

运行一下,报错了 image.png 原因是什么,原因就是SimpleDateFormat是非线程安全的,点进去看一下SimpleDateFormat的源码,在类的上面就写着一段话,DateFormat不是同步的,它被推荐创建独立的format实例给每个线程,如果多线程要同时访问的话,必须在外部加一个同步的。

image.png 这段话是什么意思呢,就是解决这个问题有两个办法,一个是加synchronized,代码如下:

public static synchronized Date parse(String strDate) throws ParseException {
    return sdf.parse(strDate);
}

但是这样做肯定会降低性能。还有一种方法就是做线程隔离,就是他注释上写的,为每个线程单独创建一个SimpleDateFormat对象,独一份的,线程独有的,这样就不会产生线程安全问题。这个就需要用到今天的主角ThreadLocal,代码如下:

public class ThreadLoaclExample {

    private static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<>();

    private static SimpleDateFormat getDateFormat() {
        SimpleDateFormat dateFormat = dateFormatThreadLocal.get();//从当前线程的范围内获得一个DateFormat
        if (dateFormat == null) {
            dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//在当前线程的范围内设置一个simpleDateFormat对象
            //Thread.currentThread();
            dateFormatThreadLocal.set(dateFormat);
        }
        return dateFormat;
    }

    public static Date parse(String strDate) throws ParseException {
        return getDateFormat().parse(strDate);
    }

    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                try {
                    System.out.println(parse("2021-11-18 21:36:17"));
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

运行一下,不报错了

image.png

当然上面还有个优化点就是20个线程,当1000个线程的时候,每个线程都有自己独立的SimpleDateFormat副本,这样会创建1000个SimpleDateFormat对象,会很浪费空间,所以改写成线程池的方式:

public static void main(String[] args) {
    ExecutorService executorService = Executors.newFixedThreadPool(16);
    for (int i = 0; i < 1000; i++) {
        executorService.execute(() -> {
            try {
                System.out.println(parse("2021-11-18 21:36:17"));
            } catch (ParseException e) {
                e.printStackTrace();
            }
        });
    }
}

这样的话有个好处就是用16个SimpleDateFormat对象即可完成1000个任务。

image.png 以上就是第一种非常典型的适合使用 ThreadLocal 的场景。

第二种场景

第二个作用就是起到一个上下文的作用,有这样一个应用场景,当一个请求过来service-1把user的信息计算出来,后面的方法service-2,service-3,service-4都需要用到user信息,这时的做法就是把user作为参数,不停的往后传,这样的做法导致代码十分冗余。 image.png

有一个解决办法就是把user信息放在内存中,比如hashmap,这样service-1把user信息put进去,service-2,service-3,service-4直接get就能把user信息获取出来,这样可以避免把user作为参数不停的传。 image.png

那么随之而来就会产生另一个线程并发安全问题,当个线程同时请求访问的时候呢?那我们就是要使用 synchronized 或者 ConcurrentHashMap来保证hashmap的安全,它对性能都是有所影响的。 image.png

那么最终解决方案就是使用ThreadLocal,它使得每个线程独享自己的user信息,保证了线程安全,使用的时候也只要在service-1里面存进去,service-2,service-3,service-4里面取出来即可。 image.png 这个就是第二个作用,起到上下文的作用UserContextHolder,避免了传参。

ThreadLocal的存储位置

首先来看下Thread、 ThreadLocal 及 ThreadLocalMap 三者存储的位置。 image.png 在Thread类里面有个ThreadLocalMap变量,如下图,因为存在线程里面,这样才能做到线程独有。

image.png 在ThreadLocalMap里面有很多个Entry,这个Entry的key就是弱引用的threadlocal,value就是需要存储的值。

image.png

为什么在ThreadLocalMap里会有多个Entry呢,因为我们在使用的时候可以定义多个ThreadLocal,而这些值最终的存储就是一个一个的Entry。

image.png 有了上面宏观上的感受,我们再来看下源码分析,首先看set方法:

public void set(T value) {
   //得到当前线程,保证隔离性
    Thread t = Thread.currentThread();
    //根据线程得到ThreadLocalMap,没有初始化则进行初始化
    ThreadLocalMap map = getMap(t);
    //如果map不为空,则将值set进去
    if (map != null)
        map.set(this, value);
    else   //否则的话创建map
        createMap(t, value);
}

如果map为空的话先进行创建

image.png 初始化的过程也比较简单,新创建一个数组,根据hash值计算位置,然后把key和value放到该位置上

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    //默认长度为16的数组
    table = new Entry[INITIAL_CAPACITY];
    //计算数组下标
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    //把key和value放到i的位置上
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    setThreshold(INITIAL_CAPACITY);
}

我们再看下map.set方法,set的时候也是先计算位置,如果位置上已经有值的,就是我之前这个key,则把value的值进行替换,如果是null则执行replaceStaleEntry方法,否则的话就移动到下一个位置。

private void set(ThreadLocal<?> key, Object value) {

    // We don't use a fast path as with get() because it is at
    // least as common to use set() to create new entries as
    // it is to replace existing ones, in which case, a fast
    // path would fail more often than not.

    Entry[] tab = table;
    int len = tab.length;
    //计算数组下标
    int i = key.threadLocalHashCode & (len-1);

    //线性探测
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
        //i位置已经有值了,直接替换
        if (k == key) {
            e.value = value;
            return;
        }
        //如果key==null,则进行replaceStaleEntry(替换空余的数组)
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

我们知道Hashmap当发生冲突的时候,采用的是拉链法(也叫链地址法),而我们这的ThreadLocalMap采用的是线性探测法,如果发生冲突,并不会用链表的形式往下链,而是会继续寻找下一个空的格子。感兴趣的小伙伴可以看下《ConcurrentHashMap源码精讲》

image.png

我们再来看下get方法,这个方法也很简单,先从线程中拿到ThreadLocalMap,然后再从map中传入this自己作为key,来拿到Entry,再从Entry中拿到value。

public T get() {
    //获取到当前线程
    Thread t = Thread.currentThread();
    //获取到当前线程内的 ThreadLocalMap 对象
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        //获取 ThreadLocalMap 中的 Entry 对象并拿到 Value
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    //如果线程内之前没创建过 ThreadLocalMap,就创建
    return setInitialValue();
}

如果map为空的话则进行初始化操作setInitialValue,这个跟上面的set方法里面的逻辑是一样的。

private T setInitialValue() {
    T value = initialValue();
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

强引用和弱引用

标题中已经提到了强引用和弱引用,还有上面讲到的Entry里面的key是ThreadLocal的弱引用,那么具体什么是强引用,什么是弱引用,这里做下介绍。

先看下强引用的代码:

public class ReferenceExample {

    static Object object = new Object();

    public static void main(String[] args) {
        Object strongRef = object;
        object = null;
        System.gc();
        System.out.println(strongRef);
    }
}

运行一下,没有被回收掉

image.png

我画了个示意图大家看下,一开始object和strongRef都指向了堆区的new Object()对象。

image.png

后来执行object = null,相当于栈和堆之间的连线断掉了,所以在System.gc()以后,由于strongRef还连接着new Object(),所以就没有被释放掉。 image.png 再看下弱引用的代码:

public class ReferenceExample {

    static Object object = new Object();

    public static void main(String[] args) {
        WeakReference<Object> weakRef = new WeakReference<>(object);
        object = null;
        System.gc();
        System.out.println(weakRef.get());
    }
}

再执行一下,结果为null,已经被回收掉了 image.png

弱引用的连接就很弱,这根虚线等于没有,形同虚设,在回收的时候new Object()一看没人在引用了,那么就直接回收掉了,所以打印weakRef的时候就为null。

image.png

所以在上面看源码中会出现k==null的判断,就是因为threadlocal是弱引用,当我们在业务代码中执行了 ThreadLocal instance = null 操作,我们想要清理掉这个 ThreadLocal 实例,由于是弱引用,就像上面的例子一样,经过垃圾回收以后key会变为null,那么这个Entry一直在数组里占着是不行的,所以会把key==null的给清理掉。

对于垃圾回收不是很懂的小伙伴可以看下《一篇文章搞懂GC垃圾回收》

内存泄露/remove()方法

首先说下用完ThreadLocal一定要调用remove()方法!一定要调用remove()方法!一定要调用remove()方法! 否则就是会造成内存泄露。

内存泄漏指的是,当某一个对象不再有用的时候,占用的内存却不能被回收,这就叫作内存泄漏

Key 的泄漏

在上面提到过key是弱引用,如果是强引用的话,当执行ThreadLocal instance = null的时候,key还在引用着threadlocal,这时候就不会释放内存,那么这个Entry就一直存在数组中,得不到清理,越堆越多。

但是如果采用弱引用,key会变为null,JDK帮我们考虑了这一点,在执行 ThreadLocal 的 get、set、remove、rehash 等方法时,它都会扫描 key 为 null 的 Entry,如果发现某个 Entry 的 key 为 null,则代表它所对应的 value 也没有作用了,所以它就会把对应的 value 置为 null, 这样,value 对象就可以被正常回收了,防止内存泄露。

value的泄露

虽然解决了key的泄露,但是我们知道value是强引用,我们看下下面的调用链: image.png

Thread Ref → Current Thread → ThreadLocalMap → Entry → Value → 可能泄漏的value实例。

这条链路是随着线程的存在而一直存在的,如果线程执行耗时任务而不停止,而ThreadLocal的get、set、remove、rehash 方法也没有被调用的话,那么这个value指向的内存也一直存在,一直占着。解决这种情况,就是使用remove方法。看下源码:

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

还有一种危险,如果线程是线程池的话,在线程执行完代码的时候并没有结束,只是归还给线程池,那么这个线程中的value就一直被占着,得不到回收,造成内存泄露。所以我们在编码中要养成良好的习惯,不再使用ThreadLocal的时候就要调用remove()方法,及时释放内存。最后感谢大家的收看~