ThreadLocal夺命11连问

作者: AI乔治 | 来源:发表于2023-05-25 14:51 被阅读0次

    前言

    前一段时间,有同事使用ThreadLocal踩坑了,正好引起了我的兴趣。

    所以近期,我抽空把ThreadLocal的源码再研究了一下,越看越有意思,发现里面的东西还真不少。

    我把精华浓缩了一下,汇集成了下面11个问题,看看你能顶住第几个?

    1. 为什么要用ThreadLocal?

    并发编程是一项非常重要的技术,它让我们的程序变得更加高效。

    但在并发的场景中,如果有多个线程同时修改公共变量,可能会出现线程安全问题,即该变量最终结果可能出现异常。

    为了解决线程安全问题,JDK出现了很多技术手段,比如:使用synchronized或Lock,给访问公共资源的代码上锁,保证了代码的原子性。

    但在高并发的场景中,如果多个线程同时竞争一把锁,这时会存在大量的锁等待,可能会浪费很多时间,让系统的响应时间一下子变慢。

    因此,JDK还提供了另外一种用空间换时间的新思路:ThreadLocal。
    它的核心思想是:共享变量在每个线程都有一个副本,每个线程操作的都是自己的副本,对另外的线程没有影响。

    例如:

    @Service
    public class ThreadLocalService {
        private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
    
        public void add() {
            threadLocal.set(1);
            doSamething();
            Integer integer = threadLocal.get();
        }
    }
    
    

    2. ThreadLocal的原理是什么?

    为了搞清楚ThreadLocal的底层实现原理,我们不得不扒一下源码。

    ThreadLocal的内部有一个静态的内部类叫:ThreadLocalMap。

    public class ThreadLocal<T> {
         ...
         public T get() {
            //获取当前线程
            Thread t = Thread.currentThread();
            //获取当前线程的成员变量ThreadLocalMap对象
            ThreadLocalMap map = getMap(t);
            if (map != null) {
                //根据threadLocal对象从map中获取Entry对象
                ThreadLocalMap.Entry e = map.getEntry(this);
                if (e != null) {
                    @SuppressWarnings("unchecked")
                    //获取保存的数据
                    T result = (T)e.value;
                    return result;
                }
            }
            //初始化数据
            return setInitialValue();
        }
        
        private T setInitialValue() {
            //获取要初始化的数据
            T value = initialValue();
            //获取当前线程
            Thread t = Thread.currentThread();
            //获取当前线程的成员变量ThreadLocalMap对象
            ThreadLocalMap map = getMap(t);
            //如果map不为空
            if (map != null)
                //将初始值设置到map中,key是this,即threadLocal对象,value是初始值
                map.set(this, value);
            else
               //如果map为空,则需要创建新的map对象
                createMap(t, value);
            return value;
        }
        
        public void set(T value) {
            //获取当前线程
            Thread t = Thread.currentThread();
            //获取当前线程的成员变量ThreadLocalMap对象
            ThreadLocalMap map = getMap(t);
            //如果map不为空
            if (map != null)
                //将值设置到map中,key是this,即threadLocal对象,value是传入的value值
                map.set(this, value);
            else
               //如果map为空,则需要创建新的map对象
                createMap(t, value);
        }
        
         static class ThreadLocalMap {
            ...
         }
         ...
    }
    

    ThreadLocal的get方法、set方法和setInitialValue方法,其实最终操作的都是ThreadLocalMap类中的数据。

    其中ThreadLocalMap类的内部如下:

    static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> {
            Object value;
    
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
       }
       ...
       private Entry[] table;
       ...
    }
    

    ThreadLocalMap里面包含一个静态的内部类Entry,该类继承于WeakReference类,说明Entry是一个弱引用。
    ThreadLocalMap内部还包含了一个Entry数组,其中:Entry = ThreadLocal + value。
    而ThreadLocalMap被定义成了Thread类的成员变量。

    public class Thread implements Runnable {
        ...
        ThreadLocal.ThreadLocalMap threadLocals = null;
    }
    
    

    下面用一张图从宏观上,认识一下ThreadLocal的整体结构:


    从上图中看出,在每个Thread类中,都有一个ThreadLocalMap的成员变量,该变量包含了一个Entry数组,该数组真正保存了ThreadLocal类set的数据。

    Entry是由threadLocal和value组成,其中threadLocal对象是弱引用,在GC的时候,会被自动回收。而value就是ThreadLocal类set的数据。

    下面用一张图总结一下引用关系:

    上图中除了Entry的key对ThreadLocal对象是弱引用,其他的引用都是强引用。

    需要特别说明的是,上图中ThreadLocal对象我画到了堆上,其实在实际的业务场景中不一定在堆上。因为如果ThreadLocal被定义成了static的,ThreadLocal的对象是类共用的,可能出现在方法区。

    3. 为什么用ThreadLocal做key?

    不知道你有没有思考过这样一个问题:ThreadLocalMap为什么要用ThreadLocal做key,而不是用Thread做key?

    如果在你的应用中,一个线程中只使用了一个ThreadLocal对象,那么使用Thread做key也未尝不可。

    @Service
    public class ThreadLocalService {
        private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
    }    
    

    但实际情况中,你的应用,一个线程中很有可能不只使用了一个ThreadLocal对象。这时使用Thread做key不就出有问题?

    @Service
    public class ThreadLocalService {
        private static final ThreadLocal<Integer> threadLocal1 = new ThreadLocal<>();
        private static final ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();
        private static final ThreadLocal<Integer> threadLocal3 = new ThreadLocal<>();
    }    
    
    

    假如使用Thread做key时,你的代码中定义了3个ThreadLocal对象,那么,通过Thread对象,它怎么知道要获取哪个ThreadLocal对象呢?

    如下图所示:

    因此,不能使用Thread做key,而应该改成用ThreadLocal对象做key,这样才能通过具体ThreadLocal对象的get方法,轻松获取到你想要的ThreadLocal对象。

    4. Entry的key为什么设计成弱引用?

    前面说过,Entry的key,传入的是ThreadLocal对象,使用了WeakReference对象,即被设计成了弱引用。

    那么,为什么要这样设计呢?

    假如key对ThreadLocal对象的弱引用,改为强引用。

    我们都知道ThreadLocal变量对ThreadLocal对象是有强引用存在的。

    即使ThreadLocal变量生命周期完了,设置成null了,但由于key对ThreadLocal还是强引用。
    此时,如果执行该代码的线程使用了线程池,一直长期存在,不会被销毁。

    就会存在这样的强引用链:Thread变量 -> Thread对象 -> ThreadLocalMap -> Entry -> key -> ThreadLocal对象。

    那么,ThreadLocal对象和ThreadLocalMap都将不会被GC回收,于是产生了内存泄露问题。
    为了解决这个问题,JDK的开发者们把Entry的key设计成了弱引用。

    弱引用的对象,在GC做垃圾清理的时候,就会被自动回收了。

    如果key是弱引用,当ThreadLocal变量指向null之后,在GC做垃圾清理的时候,key会被自动回收,其值也被设置成null。

    如下图所示:


    接下来,最关键的地方来了。

    由于当前的ThreadLocal变量已经被指向null了,但如果直接调用它的get、set或remove方法,很显然会出现空指针异常。因为它的生命已经结束了,再调用它的方法也没啥意义。

    此时,如果系统中还定义了另外一个ThreadLocal变量b,调用了它的get、set或remove,三个方法中的任何一个方法,都会自动触发清理机制,将key为null的value值清空。
    如果key和value都是null,那么Entry对象会被GC回收。如果所有的Entry对象都被回收了,ThreadLocalMap也会被回收了。

    这样就能最大程度的解决内存泄露问题。

    需要特别注意的地方是:

    1. key为null的条件是,ThreadLocal变量指向null,并且key是弱引用。如果ThreadLocal变量没有断开对ThreadLocal的强引用,即ThreadLocal变量没有指向null,GC就贸然的把弱引用的key回收了,不就会影响正常用户的使用?

    2. 如果当前ThreadLocal变量指向null了,并且key也为null了,但如果没有其他ThreadLocal变量触发get、set或remove方法,也会造成内存泄露。

    下面看看弱引用的例子:

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

    打印结果:

    java.lang.Object@1ef7fe8e
    null
    

    传入WeakReference构造方法的是直接new处理的对象,没有其他引用,在调用gc方法后,弱引用对象会被自动回收。

    但如果出现下面这种情况:

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

    执行结果:

    java.lang.Object@1ef7fe8e
    java.lang.Object@1ef7fe8e
    

    先定义了一个强引用object对象,在WeakReference构造方法中将object对象的引用作为参数传入。这时,调用gc后,弱引用对象不会被自动回收。

    我们的Entry对象中的key不就是第二种情况吗?在Entry构造方法中传入的是ThreadLocal对象的引用。

    如果将object强引用设置为null:

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

    执行结果:

    java.lang.Object@6f496d9f
    java.lang.Object@6f496d9f
    null
    

    第二次gc之后,弱引用能够被正常回收。

    由此可见,如果强引用和弱引用同时关联一个对象,那么这个对象是不会被GC回收。也就是说这种情况下Entry的key,一直都不会为null,除非强引用主动断开关联。

    此外,你可能还会问这样一个问题:Entry的value为什么不设计成弱引用?

    答:Entry的value如果只是被Entry引用,有可能咩被业务系统中的其他地方引用。如果将value改成了弱引用,被GC贸然回收了(数据突然没了),可能会导致业务系统出现异常。

    而相比之下,Entry的key,管理的地方就非常明确了。

    这就是Entry的key被设计成弱引用,而value没被设计成弱引用的原因。

    5. ThreadLocal真的会导致内存泄露?

    通过上面的Entry对象中的key设置成弱引用,并且使用get、set或remove方法清理key为null的value值,就能彻底解决内存泄露问题?

    答案是否定的。

    如下图所示:


    假如ThreadLocalMap中存在很多key为null的Entry,但后面的程序,一直都没有调用过有效的ThreadLocal的get、set或remove方法。

    那么,Entry的value值一直都没被清空。

    所以会存在这样一条强引用链:Thread变量 -> Thread对象 -> ThreadLocalMap -> Entry -> value -> Object。
    其结果就是:Entry和ThreadLocalMap将会长期存在下去,会导致内存泄露。

    6. 如何解决内存泄露问题?

    前面说过的ThreadLocal还是会导致内存泄露的问题,我们有没有解决办法呢?

    答:有办法,调用ThreadLocal对象的remove方法。

    不是在一开始就调用remove方法,而是在使用完ThreadLocal对象之后。列如:

    先创建一个CurrentUser类,其中包含了ThreadLocal的逻辑。

    public class CurrentUser {
        private static final ThreadLocal<UserInfo> THREA_LOCAL = new ThreadLocal();
        
        public static void set(UserInfo userInfo) {
            THREA_LOCAL.set(userInfo);
        }
        
        public static UserInfo get() {
           THREA_LOCAL.get();
        }
        
        public static void remove() {
           THREA_LOCAL.remove();
        }
    }
    
    

    然后在业务代码中调用相关方法:

    相关文章

      网友评论

        本文标题:ThreadLocal夺命11连问

        本文链接:https://www.haomeiwen.com/subject/knelortx.html