并发编程之ThreadLocal类

373 阅读9分钟

“水能载舟,亦能覆舟”,形容ThreadLocal最为贴切,因为它的初衷是在线程并发时,解决变量共享的问题,但是由于过度设计,比如弱引用、哈希碰撞,导致理解难度大,使用成本高,反而成为故障高发点,容易出现内存泄漏,脏数据、共享对象更新等问题。也许你认为它包治共享变量的问题,然而并不是。下面从内存模型,弱引用,哈希算法角度分析:

讨论ThreadLocal用在什么地方前,我们先明确下,如果仅仅就一个线程,那么都不用谈ThreadLocal的,ThreadLocal是用在多线程的场景的!!!

1. 理解 ThreadLocal

ThreadLocal的作用是提供线程内的局部变量,这种变量在多线程环境下访问时能够保证各个线程里变量的独立性。

一般使用ThreadLocal,官方建议我们定义为private static ,至于为什么要定义成静态的,这和内存泄露有关,后面再讲。

1.1 基本使用

public class DemoThreadLocal {
    private static ThreadLocal<String> threadLocal = new ThreadLocal<String>() {
        @Override
        protected String initialValue() {
            return "hello world";
        }
    };
    private static ThreadLocal<User> threadLocalUser = new ThreadLocal<>();

    public static void main(String[] args) {
        new Thread(new MyRunnable(1)).start();
        
        User user = new User();
        user.setName("Java半颗糖");
        user.setAge(25);
        threadLocalUser.set(user); // 保存值
        System.out.println(threadLocalUser.get()); // 获取值
    }

    static class MyRunnable implements Runnable {

        private int num;

        public MyRunnable(int num) {
            this.num = num;
        }

        @Override
        public void run() {
            threadLocal.set(String.valueOf(num));
            System.out.println("threadLocalValue:" + threadLocal.get());
            //手动移除
            threadLocal.remove();
        }
    }

    @Data
    static class User {
        String name;
        Integer age;
    }
}

输出结果

threadLocalValue:1
DemoThreadLocal.User(name=Java半颗糖, age=25)

1.2 引用类型

对象在堆上创建之后所持有的引用其实是一种变量类型,引用之间通过赋值可以构成引用链。从GC Root开始遍历,判断引用是否可达,引用的可达性是判断能否被垃圾回收的基本条件。根据引用类型语义的强弱可以决定垃圾回收的阶段。

  • 强引用(Strong Reference):   Object object=new Object(); 这样的变量声明和定义就会产生对该对象的强引用。只要对象有强引用指向,并且GC Root可达,那么Java内存回收时,即使内存濒临耗尽,也不会回收该对象。
  • 软引用(Soft Reference):  引用力弱与强引用,用在非必须对象的场景。在即将OOM之前,垃圾回收器会把这些引用指向的对象加入回收范围,以获得更多的内存空间,让程序能够继续健康运行。主要用来缓存服务器中间计算结果及不需要实时保存的用户行为等。
  • 弱引用(Weak Reference): 引用强度较前两者更弱,也是用来描述非必须对象的。 如果弱引用指向的对象只存在弱引用这一条线路,则在下一次 Y GC (年轻代GC)时会被回收。由于YGC的时间不确定性,弱引用何时被回收也具有不确定性。弱引用主要用于指向某个易消失的对象,在强引用断开后,此引用不会劫持对象,调用weakReference.get() 会返回空。
  • 虚引用(phantom Reference) : 定义完成后,就无法通过该引用获取指向的对象,为对象设置虚引用的唯一目的,就是希望能在这个对象被回收时,收到一个系统通知。

注意 虚引用与软引用和弱引用的一个区别在于虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中

1.3 内存泄漏问题

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;
 
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

从上面源码可以看出,ThreadLocalMap使用ThreadLocal的弱引用作为Entry的key,如果一个ThreadLocal没有外部强引用来引用它,下一次系统GC时,这个ThreadLocal必然会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value。

ThreadLocal中 get、set、remove等方法,都会对key为null的Entry进行清除(expungeStaleEntry 方法,将Entry的value清空,等下一次垃圾回收时,这些Entry将会被彻底回收)。 但是如果当前线程一直在运行,并且一直不执行get、set、remove方法,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreadLocalMap -> Entry -> value,导致这些key为null的Entry的value永远无法回收,造成内存泄漏。

如何避免内存泄漏

  • 为了避免这种情况,我们可以在使用完ThreadLocal后,手动调用remove方法,以避免出现内存泄漏。
  • 官方建议把ThreadLocal定义为 private static,理由如下图,

image.png

2. ThreadLocal原理

Thread、ThreadLocalMap、ThreadLocal总览图: image.png

每一个Thread类有属性变量threadLocals (类型是ThreadLocal.ThreadLocalMap)的this引用,也就是说每个线程有一个自己的ThreadLocalMap ,所以每个线程往这个ThreadLocal中读写隔离的,并且是互相不会影响的。该变量的类型是hashMap,其中key为我们定义的ThreadLocal 变量的this引用,value是我们使用set() 方法设置的值.

每个线程的本地变量存放在线程自己的内存变量threallocals 中,如果当前线程一直不消亡,那么这些本地变量会一直存在,从而可能导致内存溢出,因此使用完后,需要调用ThreadLocal的remove()方法删除对应线程的 threallocals中的本地变量。

2.1 ThreadLocalMap 定义

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;
 
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

ThreadLocalMap是一个自定义哈希映射,仅用于维护线程本地变量值。ThreadLocalMap是ThreadLocal的内部类,主要有一个Entry对象数组,Entry对象的key为ThreadLocal,value为ThreadLocal对应的值。每个线程都有一个ThreadLocalMap类型的threadLocals变量。

2.2 set() 方法

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t); // 获取当前线程的ThreadLocalMap
    // 当前线程的ThreadLocalMap不为空则调用set方法, this为调用该方法的ThreadLocal对象
    if (map != null) 
        map.set(this, value);
    // map为空则调用createMap方法创建一个新的ThreadLocalMap, 并新建一个Entry放入该
    // ThreadLocalMap, 调用set方法的ThreadLocal和传入的value作为该Entry的key和value
    else
        createMap(t, value);    
}
 
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;	// 返回线程t的threadLocals属性
}
  1. 先拿到当前线程,再使用getMap方法拿到当前线程的threadLocals变量
  2. 如果threadLocals不为空,则将当前ThreadLocal作为key,传入的值作为value,调用set方法(见下文代码块1详解)插入threadLocals。
  3. 如果threadLocals为空则调用创建一个ThreadLocalMap,并新建一个Entry放入该ThreadLocalMap, 调用set方法的ThreadLocal和传入的value作为该Entry的key和value 注意此处的threadLocals变量是一个ThreadLocalMap,是Thread的一个局部变量,因此它只与当前线程绑定。

2.3 get() 方法

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 调用getEntry方法, 通过this(调用get()方法的ThreadLocal)获取对应的Entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        // Entry不为空则代表找到目标Entry, 返回该Entry的value值
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 该线程的ThreadLocalMap为空,或者没有找到目标Entry,则调用setInitialValue方法
    return setInitialValue();
}
  1. 跟set方法差不多,先拿到当前的线程,再使用getMap方法拿到当前线程的threadLocals变量
  2. 如果threadLocals不为空,则将调用get方法的ThreadLocal作为key,调用getEntry方法(见下文代码块5详解)找到对应的Entry。
  3. 如果threadLocals为空或者找不到目标Entry,则调用setInitialValue方法(见下文代码块4详解)进行初始化。

2.4 remove() 方法

public void remove() {
	// 获取当前线程的ThreadLocalMap 
	ThreadLocalMap m = getMap(Thread.currentThread()); 
	if (m != null)
               // 调用此方法的ThreadLocal作为入参,调用remove方法  
		m.remove(this);   
 }
 
private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    // 根据hashCode计算出当前ThreadLocal的索引位置
    int i = key.threadLocalHashCode & (len-1);  
    // 从位置i开始遍历,直到Entry为null
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {   // 如果找到key相同的
            e.clear(); 	// 则调用clear方法, 该方法会把key的引用清空
            expungeStaleEntry(i);//调用expungeStaleEntry方法清除key为null的Entry
            return;
        }
    }
}

方法很简单,拿到当前线程的threadLocals属性,如果不为空,则将key为当前ThreadLocal的键值对移除,并且会调用expungeStaleEntry方法清除key为空的Entry。

3. ThreadLocal 使用不当导致内存泄漏

3.1 为何会出现内存泄漏

image.png ThreadLocalMap 的Entry中key使用的是对ThreadLocal对象的弱引用,这在避免内存泄漏方面是一个进步,因为如果是强引用,即使其他地方没有对ThreadLocal对象的引用,ThreadLocalMap中的ThreadLocal对象还是不会被回收,而如果是弱引用则ThreadLocal引用会在GC的时候回收掉。

但是对应的Value不能被回收,这时候ThreadLocalMap里面就会存在key为null但是value不为null的entry项,虽然ThreadLocalMap提供了set、get、remove方法,可以在一些时机下对这些key进行清理,但是这是不及时的,也不是每次都会执行,所以一些情况下还是会发生内存泄漏。

因此,使用完毕后及时调用remove方法才是解决内存泄漏的好办法。

3.2 在线程池中使用ThreadLocal导致内存泄漏

public class ThreadPoolTest {
 
    static class LocalVariable {
        private Long[] a = new Long[1024*1024];
    }
 
    // (1)
    final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES,
            new LinkedBlockingQueue<>());
    // (2)
    final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<LocalVariable>();
 
    public static void main(String[] args) throws InterruptedException {
        // (3)
        for (int i = 0; i < 50; ++i) {
            poolExecutor.execute(new Runnable() {
                public void run() {
                    // (4)
                    localVariable.set(new LocalVariable());
                    // (5)
                    System.out.println("use local varaible");
                    // 暂时不 remove 进行测试
                    //localVariable.remove();
 
                }
            });
 
            Thread.sleep(1000);
        }
        // (6)
        System.out.println("pool execute over");
    }
  • 代码(1)创建了一个核心线程数和最大线程数为5的线程池,这个保证了线程池里面随时都有5个线程在运行。
  • 代码(2)创建了一个ThreadLocal的变量,泛型参数为LocalVariable,LocalVariable内部是一个Long数组。
  • 代码(3)向线程池里面放入50个任务
  • 代码(4)设置当前线程的localVariable变量,也就是把new的LocalVariable变量放入当前线程的threadLocals变量。

由于没有调用线程池的shutdown或者shutdownNow方法所以线程池里面的用户线程不会退出,进而JVM进程也不会退出。

运行代码中,在设置线程的localVariable变量后没有调用localVariable.remove() 方法,导致线程池里面的5个线程的threadLocals变量里面的new LocalVariable()实例没有被释放,虽然线程池里面的任务执行完毕了,但是线程池里面的5个线程会一直存在直到JVM退出。这里需要注意的是由于localVariable被声明了static,虽然线程的ThreadLocalMap里面是对localVariable的弱引用,localVariable也不会被回收。运行结果二的代码由于线程在设置localVariable变量后即使调用了localVariable.remove()方法进行了清理,所以不会存在内存泄露。

如果在线程池里面设置了ThreadLocal变量,则一定要及时清理,因为线程池里面的核心线程数一般是一直存在的,如果不及时清理,线程池的核心线程的threadlocals变量会一直持有ThrealLocal变量。

3.3 Tomcat的Servlet中使用ThreadLocal导致内存泄漏

public class HelloWorldExample extends HttpServlet {
 
    private static final long serialVersionUID = 1L;
 
    static class LocalVariable {
        private Long[] a = new Long[1024 * 1024 * 100];
    }
 
    //(1)
    final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<LocalVariable>();
 
    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
        //(2)
        localVariable.set(new LocalVariable());
 
        response.setContentType("text/html");
        PrintWriter out = response.getWriter();
 
        out.println("<html>");
        out.println("<head>");
 
        out.println("<title>" + "title" + "</title>");
        out.println("</head>");
        out.println("<body bgcolor=\"white\">");
        //(3)
        out.println(this.toString());
        //(4)
        out.println(Thread.currentThread().toString());
 
        out.println("</body>");
        out.println("</html>");
    }
}
  • 代码(1)创建一个localVariable对象,
  • 代码(2)在servlet的doGet方法内设置localVariable值
  • 代码(3)打印当前servlet的实例
  • 代码(4)打印当前线程

修改tomcat的conf下sever.xml配置如下:

    <Executor name="tomcatThreadPool" namePrefix="catalina-exec-" 
        maxThreads="10" minSpareThreads="5"/>
 
    <Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1" 
               connectionTimeout="20000" 
               redirectPort="8443" />

image.png Tomcat中Connector组件负责接受并处理请求,其中Socket acceptor thread 负责接受用户的访问请求,然后把接受到的请求交给Worker threads pool线程池进行具体处理,后者就是我们在server.xml里面配置的线程池。Worker threads pool里面的线程则负责把具体请求分发到具体的应用的servlet上进行处理。

有了上述知识,下面启动tomcat访问该servlet多次,会发现有可能输出下面结果

HelloWorldExample@2a10b2d2 Thread[catalina-exec-5,5,main]
HelloWorldExample@2a10b2d2 Thread[catalina-exec-1,5,main]
HelloWorldExample@2a10b2d2 Thread[catalina-exec-4,5,main]

其中前半部分是打印的servlet实例,这里都一样说明多次访问的都是一个servlet实例,后半部分中catalina-exec-5,catalina-exec-1,catalina-exec-4,说明使用了connector中线程池里面的线程5,线程1,线程4来执行serlvet的。

如果在访问该servlet的同时打开了jconsole观察堆内存会发现内存会飙升,究其原因是因为工作线程调用servlet的doGet方法时候,工作线程的threadLocals变量里面被添加了new LocalVariable()实例,但是没有被remove,另外多次访问该servlet可能用的不是工作线程池里面的同一个线程,这会导致工作线程池里面多个线程都会存在内存泄露。

4. ThreadLocal 拓展

4.1 ThreadLocal 不支持继承性

为了解决这个问题,InheritableThreadLocal 应用而生。InheritableThreadLocal 继承自ThreadLocal ,并提供了一个特性,就是让子线程可以访问在父线程中设置的本地变量。

5. 总结

  1. 每个线程都有一个ThreadLocalMap 类型的 threadLocals 属性。
  2. ThreadLocalMap 类相当于一个Map,key 是 ThreadLocal 本身,value 就是我们的Thread 值
  3. 当我们通过 threadLocal.set(new Integer(123)); ,我们就会在这个线程中的 threadLocals 属性中放入一个键值对
    • key 是 这个 threadLocal.set(new Integer(123))的threadlocal
    • value 就是值new Integer(123)。
  4. 当我们通过 threadlocal.get() 方法的时候,首先会根据这个线程得到这个线程的 threadLocals 属性,然后由于这个属性放的是键值对,我们就可以根据键 threadlocal 拿到值。 注意,这时候这个键 threadlocal 和 我们 set 方法的时候的那个键 threadlocal 是一样的,所以我们能够拿到相同的值。
  5. ThreadLocalMap 的get/set/remove方法跟HashMap的内部实现都基本一样,通过 "key.threadLocalHashCode & (table.length - 1)" 运算式计算得到我们想要找的索引位置,如果该索引位置的键值对不是我们要找的,则通过nextIndex方法计算下一个索引位置,直到找到目标键值对或者为空。
  6. hash冲突:在HashMap中相同索引位置的元素以链表形式保存在同一个索引位置;而在ThreadLocalMap中,没有使用链表的数据结构,而是将(当前的索引位置+1)对length取模的结果作为相同索引元素的位置:源码中的nextIndex方法,可以表达成如下公式:如果i为当前索引位置,则下一个索引位置 = (i + 1 < len) ? i + 1 : 0。

参考

  1. Java并发:ThreadLocal详解
  2. ThreadLocal类 解决共享变量的问题
  3. 《并发编程之美》