ThreadLocal详解

文章结构如下:


ThreadLocal思维导图

简介

ThreadLocal是为了解决线程安全而产生的。它解决线程安全的思路不同于synchronized:使多个线程对于共享资源的访问串行化,只有一个线程能够获取到对象锁,其他线程进入同步队列等待。也不同于volatile,通过lock指令生成内存屏障来使得其他线程访问变量时需要从主内存加载最新值,在线程写入值时能够立刻刷新到主内存,但是volatile不能保证原子性,因此使用时具有一定局限。ThreadLocal的解决思路是线程封闭,那么无论线程什么时候,在哪个方法里面访问ThreadLocal变量,都只会访问到自己线程的ThreadLocal值。避免了将参数通过方法进行传递,也无需担心其他线程会访问到本线程的变量值。

源码理解

我们经常使用的api是ThreadLocal的get(),set(),remove()方法,通过get方法切入,可以发现对于每个Java线程,都维护了一个ThreadLocalMap。

    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

ThreadLocalMap

想要读懂源码,就绕不开对于ThreadLocalMap的理解,ThreadLocalMap本质结构跟HashMap差不多,只不过Entry的组成不同,解决冲突的方式不同。

  1. 数据结构
    通过Thread获取到ThreadLocalMap,然后每个ThreadLocalMap的Entry存储着ThreadLocal对象与value的对象关系,当设置了多个ThreadLocal变量时,对于每一个ThreadLocal对象,会在每一个ThreadLocalMap中保存一份。


    image.png
       static class Entry extends WeakReference> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal k, Object v) {
                super(k);
                value = v;
            }
        }
  1. 为什么Entry中的ThreadLocal对象是弱引用?
    在Java中,定义了四种引用:
  • 强引用:Object obj = new Object(),对于这种引用,除非显示将obj=null,否则虚拟机不会将其回收
  • 软引用:用来描述一些还有用,但非必须的对象。在系统将要内存溢出之前,会把软引用对象列入回收范围进行第二次回收。
  • 弱引用:引用强度比软引用更弱,只能生存到下一次虚拟机垃圾回收之前。
  • 虚引用


    image.png

    我们在代码中实例化的对象引用是保存在虚拟机栈上,和Entry的key引用同一个对象,之所以要将Entry的key设置为弱引用的原因就是如果我们将外部引用设置为null,那么ThreadLocal的实例不再有强引用,只有弱引用,在下次虚拟机进行垃圾回收时就可以将其回收了。但是依然还存在内存泄漏问题,因为Entry不会被回收。

  1. ThreadLocalMap解决冲突的方式
    解决Hash冲突的方式主要有拉链法、开放地址法、二次hash法、建立公共溢出区。ThreadLocalMap解决冲突的方式是开放地址法,如果通过Hash函数算出下标已经存储过Entry了,它会线性环形搜索没有被使用的位置。我理解使用线性检测的原因是只要线程中的ThreadLocal对象不多,那么根据扩容因子算出的ThreadLocalMap的数组大小也不会很大,所以即使退化到最差的o(n),对性能的影响也不大,而且这种线性搜索相比链式方式而言更加节省空间。
    对于线性探测的结点增删、扩容可以参考:线性探测解决Hash冲突

内存泄漏

上面说到ThreadLocal设置为弱引用是为了防止内存泄漏,所谓内存泄漏就是指堆中已经不再使用的对象没有被回收,造成空间 的浪费,而且积累下去很可能会造成内存溢出。当Entry中的key被回收时,整个Entry就没有用了,但是由于value还持有虚拟机栈上的强引用,所以不会被回收,这样就还是会造成内存泄漏。但是ThreadLocal中的get()、set()、remove()方法都会调用replaceStaleEntry、cleanSomeSlots、expungeStaleEntry方法进行回收

  1. 清理方法:cleanSomeSlots


    搜索清除

下标i用来控制访问的范围,如果没有找到key为null的Entry,那么会遍历log2(n),i的下标环形递增。如果找到一个key不为null的位置,n会置为len,相当于是增大了范围

private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}
  1. expungeStaleEntry的清除逻辑
    cleanSomeSlots函数,在key为null的结点进入expungeStaleEntry方法,将当前槽位的value和Entry都设为null,并且还继续往下环形搜索,一直到table[i]为null才退出,搜索过程中,遇到key为null的结点就进行清除,如果key不为null,就对结点进行rehash,rehash的目的就是为了让结点离hash函数的下标更近,这样查找的时候就不会在线性搜索浪费时间了。remove和get时,都会调用该方法进行清理。
 private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;

            // expunge entry at staleSlot
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;

            // Rehash until we encounter null
            Entry e;
            int i;
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal k = e.get();
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    int h = k.threadLocalHashCode & (len - 1);
                    if (h != i) {
                        tab[i] = null;

                        // Unlike Knuth 6.4 Algorithm R, we must scan until
                        // null because multiple entries could have been stale.
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
        }
  1. replaceStaleEntry方法
    这个方法在set()过程,当key为null时调用。从i开始首先前环向搜索脏 entry,一直到table[i]=null结束。然后从下标i开始向后搜索,如果有key相同的就覆盖,并和脏entry交换。根据不同情况,设置cleanSomeSlots清除节点的范围
        private void replaceStaleEntry(ThreadLocal key, Object value,
                                       int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;
            Entry e;
//向前找到第一个key为null的entry
            int slotToExpunge = staleSlot;
            for (int i = prevIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = prevIndex(i, len))
                if (e.get() == null)
                    slotToExpunge = i;

            for (int i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal k = e.get();
////如果在向后环形查找过程中发现key相同的entry就覆盖并且和脏entry进行交换
                if (k == key) {
                    e.value = value;

                    tab[i] = tab[staleSlot];
                    tab[staleSlot] = e;

         //如果在查找过程中还未发现脏entry,那么就以当前位置作为cleanSomeSlots
            //的起点
                    if (slotToExpunge == staleSlot)
                        slotToExpunge = i;
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                    return;
                }
       //如果向前未搜索到脏entry,则在查找过程遇到脏entry的话,后面就以此时这个位置
        //作为起点执行cleanSomeSlots
                if (k == null && slotToExpunge == staleSlot)
                    slotToExpunge = i;
            }
//如果在查找过程中没有找到可以覆盖的entry,则将新的entry插入在脏entry
            tab[staleSlot].value = null;
            tab[staleSlot] = new Entry(key, value);

            // If there are any other stale entries in run, expunge them
            if (slotToExpunge != staleSlot)
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        }

对于下面这个例子,插入的位置是4,从下标4向前搜索到3就停止,更新slotExpunge为3.再从4向后遍历寻找可覆盖的entry,当前例子未找到,于是以slotExpunge为下标调用cleanExpunge清理脏entry。


image.png

最佳实践

使用场景

  1. 数据库连接
    Hibernate的数据库连接池就是将connection放进threadlocal实现的
  2. 用户Session等信息
  3. 对请求的requestBody,requestUrl等进行处理
    代码中ThreadLocal修饰了requestBody变量,因为服务器使用的是Tomcat,所以一个请求会交给一个线程来处理,那么requestBody的get()和set方法设置的就是当前线程的请求体的值,跟其他线程互不影响。
public class ReqLogInterceptor implements HandlerInterceptor {
ThreadLocal requestBody = new ThreadLocal();
  @Override
  public boolean preHandle(HttpServletRequest httpServletRequest,
      HttpServletResponse httpServletResponse, Object o) throws Exception {
    requestBody.set("");
    return true;
  }
  @Override
  public void postHandle(HttpServletRequest httpServletRequest,
      HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView)
      throws Exception {
    if (httpServletResponse instanceof ContentCachingResponseWrapper) {
      responseBody.set("");
      byte[] body = ((ContentCachingResponseWrapper) httpServletResponse).getContentAsByteArray();
      responseBody.set(new String(body, httpServletResponse.getCharacterEncoding()));
    }
  }

  }

及时remove

及时调用ThreadLocal的remove方法,可以避免内存泄漏问题,更重要的是防止造成业务逻辑的错乱,因为通常会使用线程池管理线程,如果一个用户登录之后的name相关的ThreadLocal对象,没有及时remove,那么其他用户登录进来之后,会发现自己的用户名显示错误。

父子线程通信

你可能感兴趣的:(ThreadLocal详解)