ThreadLocal看这一篇就够了

7.13 ThreadLocal

​ 自从学习Java并发以来,过一段时间在我的知乎推荐、csdn推荐或多或少都提到了ThreadLocal内存泄漏的问题,一直以来都没有详细地研究过这个ThreadLocal究竟是一个什么东西,趁这段时间好好地研究研究相关的知识。

​ 首先,我们先得了解什么是ThreadLocal。ThreadLocal类头顶上的注释写得很清楚了:

* This class provides thread-local variables.These variables differ from
* their normal counterparts in that each thread that accesses one (via its
* {@code get} or {@code set} method) has its own, independently initialized
* copy of the variable.  {@code ThreadLocal} instances are typically private
* static fields in classes that wish to associate state with a thread (e.g.,
* a user ID or Transaction ID).

这个类提供线程来持有一个属于线程自身的本地变量,这样的变量跟存储在线程工作内存中的共享变量副本不同。

​ 那么什么是“存储在线程工作内存中的共享变量副本”呢?

一、 线程共享变量的“不可见”

​ 线程对共享对象的操作本质上是在对自己的共享对象副本进行操作,当一个线程改变自己的变量时,从线程“本地内存“中的变量副本刷新到主内存中的时机是并不确定的,也就是说一个线程修改共享变量时另外一个线程对这个修改有可能是不可见的。

下面是一个例子:Task实现了Runnable接口,它有一个Integer对象且是public属性的,当这个对象不为0它就一直执行循环,直到该对象变为值为0;而main方法,也就是main线程则是去修改这个对象的;从理论上,对象实例都在JVM的堆上也就是说main线程只要改变了Integer对象,按照一般理解堆上的Integer对象的值就会发生改变,但上文已经提到了JMM规定了每个线程对共享对象的操作都是在“工作内存”上(这个工作内存是一个概念,并非明确地指内存的某一部分,而是包括CPU缓存(还有JVM栈,当然在栈上只是我个人的猜测和找了身边朋友讨论后的结果,并没有很细致地去考证,我猜测的理据是JVM中栈是每个线程独享的)),但是从“工作内存”将值刷新到堆上的时机是并不确定的,也有可能main线程将值刷新到了堆上而task线程并没有拿到最新的值,它的对象副本的值仍然是0。

下面是验证代码:

public class Test {
    public static void main(String[] args) throws InterruptedException {
        Task task = new Task();
        Thread t = new Thread(task);
        t.start();
        TimeUnit.SECONDS.sleep(1);
        task.testNum = 0;
        System.out.println("end main");
    }
    static class Task implements Runnable{
        public Integer testNum = 1;

        @Override
        public void run() {
            while(true){
                if(testNum==0){
                    break;
                }
            }
            System.out.println("end task");
        }
    }
}

它的结果就是task线程一直在循环中不能退出:

当然了,解决方法有很多,比如:用volatile修饰共享变量、用原子类(本质上跟前者相同)、还有用synchronized修饰的等等,其实在while循环中不断打印Integer对象的值也是一个选择,当然这个选择跟使用synchronized一样,因为System.out.println()中也有synchronized修饰代码块。

二、 ThreadLocal的作用

​ 也许看到这里,你会很兴奋的说:ThreadLocal是用来解决上述问题的,那么很抱歉我必须要泼一盆冷水,并不是这样的。先放上结论:ThreadLocal是用来设置线程本地变量,某些数据是以线程为作用域并且不同线程具有不同的数据的副本时就可以用ThreadLocal。按我的理解,ThreadLocal中最为重要的是其内部类ThreadLocalMap,这一个Map的对象才是每个线程真正用来存储线程本地变量。

public class ThreadLocal<T> {
	//...
    static class ThreadLocalMap {
        //...
    }
}

而这一个Map对象在哪呢?顾名思义,将其翻译过来即:线程本地散列表,这一个对象当然是在线程Thread里面了。

既然是散列表,那么很明显就会有Key-Value的结构吧,在ThreadLocalMap中也有一个内部类名为Entry,也就是散列表中的每一个节点的类,他继承了弱引用WeakReference,从构造器可以看的出Entry是以ThreadLocal作为Key,这么做有什么好处呢我们下文再解释。

static class ThreadLocalMap {

    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    
    private Entry[] table;
    //...
}

所以线程的结构其实如下:

也就是说:ThreadLocalMap是以ThreadLocal作为Key存储线程本地变量,ThreadLocal对象是用来操作ThreadLocalMap的。

三、ThreadLocal的重要操作

​ 上文已经得出了结论:ThreadLocal对象并非真正存储线程本地变量,而是各个ThreadLocalMap来存储,而ThreadLocal对象是用来操作ThreadLocalMap。下文将会分析其中的操作的方法。

1.set方法
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

​ 以上就是ThreadLocal中的set方法,首先获得当前线程以及他的ThreadLocalMap,判断这个散列表是否为null,如果是就给这个线程创建一个本地变量散列表并将当前值存进散列表中;否则存入以当前ThreadLocal对象为Key的键值对。实际上设置线程本地变量就是调用ThreadLocalMap中的set方法,需要传入当前ThreadLocal对象以及value。下面就研究一下ThreadLocalMap当中的set方法。

ThreadLocalMap中的set方法
private void set(ThreadLocal<?> key, Object value) {
    //...
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
        if (k == key) {
            e.value = value;
            return;
        }
     //...
    }
}

​ 进入了这个方法,首先会算出当前ThreadLocal对象在散列表中可能出现的位置,并循环地寻找当前ThreadLocal对象在散列表中可能的位置,由于有可能产生哈希冲突,ThreadLocalMap解决哈希冲突的做法是开放地址法,即产生哈希冲突后往后每一个每一个位置地去试探哪个位置可能放得下当前键值对。散列表当前位置的Key是当前ThreadLocal对象,那么就将这个值覆盖到当前位置,完成set方法;

	//...
	if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
    }
	//...

如果当前位置的Keynull,并非散列表当前位置为null,有可能是在ThreadLocal对象被回收,那么EntryKey也会被回收,但这一时刻仍未发生gc,弱引用还未被回收,那么Entry对象还没回收也就是说散列表当前位置并不是为null,那么就将进入replaceStaleEntry方法;

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

如果扫描到散列表中某一位置为null那么就插入一个新的Entry对象,并做一些操作,这些操作下面都会一一细说。

ThreadLocalMap中的replaceStaleEntry方法
private void replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot) {
    //...
    for (int i = prevIndex(staleSlot, len);(e = tab[i]) != null;i = prevIndex(i, len))
         if (e.get() == null)
            slotToExpunge = i;
	//...
}

​ 进入这一个方法,因为我们获得插入的位置有可能是经过多次哈希冲突后的结果,所以进入这一方法后做的第一件事是在当前位置之前寻找是否有一些因为gc而产生的Keynull的位置,直到散列表中当前位置的Entrynull,这样做是为了减少因为还未发生gc回收而产生的重哈希,提高查询的命中能力,这个循环的任务就是寻找散列表中接近当前ThreadLocal对象哈希值的应该被回收的Entry对象的下标。

//...    
for (int i = nextIndex(staleSlot, len);(e = tab[i]) != null;i = nextIndex(i, len)) {
    ThreadLocal<?> k = e.get();
    if (k == key) {
        e.value = value;
        tab[i] = tab[staleSlot];
        tab[staleSlot] = e;
        if (slotToExpunge == staleSlot)
            slotToExpunge = i;
       	cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        return;
    }
    if (k == null && slotToExpunge == staleSlot)
        slotToExpunge = i;
} 

​ 接下来这一个循环尝试寻找散列表中是否有与当前ThreadLocal对象相同但因为哈希冲突跑到比现在理应插入位置staleSlot还要后的位置i(仔细回想上面第一部分哈希冲突时的过程),有的话将它的value覆盖后交换两个Entry的位置,然后将需要清理的位置slotToExpunge置为i(没有理由让我们辛辛苦苦插入之后回收位置为staleSlot的Entry对象吧),并调用cleanSomeSlots方法和expungeStaleEntry方法。

​ 下图为上述过程的一个描述:

​ 有的小伙伴可能会问了:在第二个循环中没有找到相同的Key时那这个键值对应该插入到哪里呢?答案是插入到一开始经过开放性地址法找到的应该插入的位置,即下面这一小段代码了,过多的东西就不解释了。

tab[staleSlot].value = null;//帮助gc回收原来散列表下标为staleSlot指向的Entry对象
tab[staleSlot] = new Entry(key, value);
if (slotToExpunge != staleSlot)
   cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
ThreadLocalMap中的expungeStaleEntry方法与cleanSomeSlots方法

​ 这两个方法只讲述它们干了什么活,就不详细地一步一步分析了。

private int expungeStaleEntry(int staleSlot) {
    //...
	//帮助gc回收散列表中下标为staleSlot的Entry对象
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;
    // ...
    //从回收位置向后,直到找到null的Entry对象
    for (i = nextIndex(staleSlot, len);(e = tab[i]) != null;i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        if (k == null) {
            //帮助gc回收key为null的Entry
            //...
        } else {
            //重新计算哈希值
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
            	//如果计算到的哈希值不是当前下标,那么就将当前下标的Entry挪到一个合适的tab[h],
                //并将散列表下标为i的位置置为null
                tab[i] = null;
                //如果哈希冲突了就使用开放地址法
                while (tab[h] != null)
                    h = nextIndex(h, len);
                //直到找到相应的下标
                tab[h] = e;
            }
        }
    }
    //返回一个散列表中下标位置指向null的下标
    return i;
}

以上是expungeStaleEntry方法,比较简单就不多赘述了。

接下来是cleanSomeSlots方法

private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    //i就是上面指向null的下标
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        //尝试将key为null而对象不为null的Entry回收
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}

可能有的小伙伴会发现这里的循环条件是(n >>>= 1) != 0,这里尝试回收的时间复杂度最优为O(log n),也就是向后走log n次都是不需要进行回收的;最坏的时间复杂度为O(nlog n),也就是说每一步都需要回收对象,并且下一个null对象离当前很远。

2. get方法
public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

​ 首先,该方法获得当前线程的ThreadLocalMap对象,当该对象不为null,尝试获得以这一个ThreadLocal对象为Key的元素e,如果不为null那么就返回他的value值,否则的话会将一个以当前ThreadLocal对象为Key,值为null的对象newEntry尝试插入当前ThreadLocalMap对象中(如果Mapnull就创建一个Map对象)并返回newEntry。通过上面的代码可以看到,这一个get方法本质上是调用ThreadLocalMap中的getEntry方法,接下来我们来看一下这一个方法。

ThreadLocalMap中的getEntry方法
private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}

​ 这段代码也很简单,直接能找得到Entry对象就返回,否则调用getEntryAfterMiss使用开放地址法寻找对应的Entry对象,值得注意的是getEntryAfterMiss方法在使用开放地址法寻找的时候遇到KeynullEntry时也会帮忙调用expungeStaleEntry方法。

四、ThreadLocal的使用危机

​ 我们在搜索引擎搜索ThreadLocal时往往有一个结果是"ThreadLocal内存泄漏",这究竟是什麽一回事呢?我们先看看什么是内存泄漏:

内存泄漏(Memory leak)是在计算机科学中,由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。

​ ——维基百科

​ 前文已经提到过,在ThreadLocalMapEntry是继承了弱引用,是引用各种ThreadLocal对象,假如现在ThreadLocalMap里存有一个Entry,它是ThreadLocal t的弱引用,这是时将t置为null(也就是说把在堆中的对象t的强引用给消除掉),因为弱引用的对象并定在下一次发生gc的时候堆中的对象t将会被回收。因为我们将t对象的强引用断掉,也就是说现在t==null,所以就会产生KeynullEntry,这样的Entry是无法被获得到它的Value值的。由于Entry对应的散列表仍持有对它的强引用,因此如果不手动设置断开强引用这样的Entry是没有办法被回收掉的,”导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费“。

​ 因此,我们使用ThreadLocal的时候必须要小心谨慎地使用:第一,防止KeynullEntry出现,使用ThreadLocal的时候尽量不要手动将ThreadLocal的引用手动设置为null,即使expunge方法能回收这些KeynullEntry对象,但避免手动设置null可以降低内存泄漏的几率;第二,防止ThreadLocal对象长时间存在,我们可以在线程池(线程池可以看看这篇文章)中使用ThreadLocal方便我们进行传参,但是因为线程池的核心池中的线程并不会销毁,如果在这些核心线程中使用到了ThreadLocal那么这些对象也不会被回收,也就意味着ThreadLocal、对应的Entry以及它的Value对象都不会被gc回收,我们本希望这些对象随着线程的消亡而被垃圾收集器回收掉而这种情况下并没有发生,也就是产生了内存泄漏的问题了。

五、 ThreadLocal在本博客项目中的使用

​ 在本博客项目中,由于作者本人比较懒惰,并没有自己编写一套用户系统而是直接接入Github的第三方登录只保存评论、用户token等信息,由于Github api并没有提供批量查询用户信息的接口,因此目前的做法是比较笨的一种:先在数据库中读出文章相关评论,接着循环使用restTemplate发送请求。这样做的并发度并不高(虽然没人评论的当下可以凑合着来使用),但是在学习完ThreadLocal以及线程池之后我萌生了优化这个系统的想法。

​ 我的想法如下:如果评论数增多之后,由于网络访问是耗时较长,串行获得token的用户信息效率比较低下;我可以在数据库中增加一张表,存储tokentoken对应的平台以及用户信息存进去;读取评论的时候就现在数据库中寻找对应的token,剩下不能在数据库中寻找到对应的token就使用网络访问第三方用户信息的接口,为了提高并发度可以使用多线程,但由于直接创建多线程的控制度比较小,因此选择线程池是一个不错的选择;不使用ThreadLocal的话就需要使用Future以及使用线程池的submit方法,接收对应的POJO;而使用ThreadLocal可以方便的将POJO的引用传入对应的线程中,无需考虑返回值的问题,但是使用ThreadLocal的时候必须要注意上述的危害。

如果上述文章有任何错误还希望各位大佬指出,感谢阅读。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
ThreadLocalJava中一个非常重要的线程封闭技术。它可以让每个线程都拥有自己的变量副本,避免了线程间的竞争和数据泄露问题。在本文中,我们将详细介绍ThreadLocal的定义、用法及其优点。 1. ThreadLocal的定义 ThreadLocalJava中一个用来实现线程封闭技术的类。它提供了一个本地线程变量,可以在多线程环境下使每个线程都拥有自己的变量副本。每个线程都可以独立地改变自己的副本,而不会影响到其他线程的副本。ThreadLocal的实现是基于ThreadLocalMap的,每个ThreadLocal对象都对应一个ThreadLocalMap,其中存储了线程本地变量的值。 2. ThreadLocal的用法 使用ThreadLocal非常简单,只需要创建一个ThreadLocal对象,然后调用其get()和set()方法即可。get()方法用来获取当前线程的变量副本,如果当前线程还没有变量副本,则会创建一个新的副本并返回。set()方法用来设置当前线程的变量副本,如果当前线程已经有了变量副本,则会覆盖原来的副本。 下面是一个简单的例子,演示了如何使用ThreadLocal来实现线程封闭: ```java public class ThreadLocalTest { private static ThreadLocal<String> threadLocal = new ThreadLocal<>(); public static void main(String[] args) throws InterruptedException { new Thread(() -> { threadLocal.set("Thread A"); System.out.println("Thread A: " + threadLocal.get()); }).start(); new Thread(() -> { threadLocal.set("Thread B"); System.out.println("Thread B: " + threadLocal.get()); }).start(); Thread.sleep(1000); System.out.println("Main: " + threadLocal.get()); } } ``` 运行结果如下: ``` Thread A: Thread A Thread B: Thread B Main: null ``` 从输出结果可以看出,每个线程都拥有自己的变量副本,互不影响。而在主线程中,由于没有设置过变量副本,所以返回null。 3. ThreadLocal的优点 ThreadLocal的优点主要体现在以下几个方面: (1)线程安全:ThreadLocal可以避免线程间的竞争和数据泄露问题,每个线程都可以独立地修改自己的变量副本,不会影响其他线程。 (2)高效性:ThreadLocal使用起来非常简单,而且性能也非常高,比如在Web开发中,可以将用户信息存储在ThreadLocal中,从而避免在每个方法中都去查询数据库。 (3)易用性:ThreadLocal的使用非常灵活,可以根据实际需要自由地定义数据类型和访问方式。 总的来说,ThreadLocalJava中一个非常重要的线程封闭技术,可以帮助开发人员避免线程间的竞争和数据泄露问题,提高程序的安全性和性能。在实际开发中,我们应该充分利用ThreadLocal的优点,合理地运用它来解决各种线程安全问题。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值