• ThreadLocal原理讲解


    概念介绍

    对象中的数据被称为该对象的状态。一个对象中的成员变量静态变量被称为该对象的状态变量。如果一个类的同一个实例被多个线程共享,并存在共享状态,则该实例被称为有状态对象。反之,如果一个类的同一个实例即使被多个线程共享,但不会出现并发问题,则该实例被称为无状态对象

    在多线程共享同一个有状态对象时,如果想要保证线程的安全性,一是可以采用锁,二是让每个线程有独立的一份该对象的实例,并且每个线程无法访问其他线程中该对象的实例。这种对象被称为线程特有对象,这种线程被称为对象的持有线程

    ThreadLocal类相当于当前线程持对象的代理。多个线程访问同一个ThreadLocal实例时,其实访问的是同一个类,但不同的实例对象,一个线程访问多个ThreadLocal实例时,访问的是不同的对象实例。如果使用了ThreadLocal来修饰对象,这些ThreadLocal实例对于线程来说就像是方法中的局部变量一样,所以ThreadLocal实例也被称为线程局部变量。其示意图如下:
    在这里插入图片描述

    实战

    我们知道java中SimpleDateFormat类是一个线程不安全的类,当多个线程同时访问同一个SimpleDateFormat实例时,就会出现问题,下面同时用20个线程进行测试一下:

    public class ThreadLocalDemo {
    
       private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
       
        public static void main(String[] args) throws ParseException {
            for (int i = 0; i < 20; i++) {
                new Thread(() -> {
                    Date parse = null;
                    try {
                        parse = simpleDateFormat.parse("2022-10-21");
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                    System.out.println(parse);
                }).start();
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    打印结果如下:
    在这里插入图片描述
    SimpleDateFormat为什么是线程不安全的,我们跟一下parse源码,一直跟到java.text.CalendarBuilder类下的establish方法,在该方法中依次执行clear和set,所以在多线程环境中,如果a线程还未执行完毕,b线程就clear掉了Calendar对象,并且该Calendar对象还是SimpleDateFormat父类中的成员变量,则就会出现线程安全问题。
    在这里插入图片描述
    在这里插入图片描述

    为了解决上述线程不安全的问题,我们可以采用加锁的方式(使用synchronized来修饰共享变量或者使用可重入锁),但如果想要避免锁的争用,我们可以使用ThreadLocal:

    public class ThreadLocalDemo {
        private static ThreadLocal<SimpleDateFormat> sdfThreadLocal = new ThreadLocal<SimpleDateFormat>() {
            @Override
            protected SimpleDateFormat initialValue() {
                return new SimpleDateFormat("yyyy-MM-dd");
            }
        };
    
        //以上方法等同于下面的lambda
    //    private static ThreadLocal sdfThreadLocal = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
        public static void main(String[] args) throws ParseException {
            for (int i = 0; i < 20; i++) {
                new Thread(() -> {
                    Date parse = null;
                    try {
                        parse = sdfThreadLocal.get().parse("2022-10-21");
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                    System.out.println(parse);
                }).start();
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    下面介绍ThreadLocal中常用的四个方法

    方法作用
    public T get()获取当前线程的特有对象
    public void set(T value)给当前线程的ThreadLocal实例重新关联该线程的特有对象
    protected T initialValue()初始状态下当前ThreadLocal实例对应的当前线程特有对象
    public void remove()移除当前线程下ThreadLocal实例对应的当前线程特有对象

    当一个线程初次执行get方法时,会调用initialValue方法,然后返回当前线程的一个特有对象(之所以上面的例子要在ThreadLocal初始化时执行initialValue方法,因为如果不这样做,则get返回的是个null),以后这个线程无论执行多少次get方法,返回的都是这一个实例,除非该线程在中途执行了set方法,设置了新的对象。java8开始,初始化的时候支持lambda,如上例子所示。

    注意:
    ThreadLocal因为是线程安全的,所以常作为静态成员变量来使用,如果作为局部变量(或非静态成员变量),虽然也可以保证线程安全性,但是每创建一个线程(或每创建一个对象)都需要创建一个ThreadLocal实例,这样会增加内存的成本。

    ThreadLocal带来的问题以及解决方法

    数据错乱

    如果存在同一个线程处理多个任务,但是这些任务都用到了同一个ThreadLocal实例,该实例就变成了这些任务的”共享变量“,但如果该线程特有对象是有状态对象,则下一个任务是可以看到上一个任务修改的数据,从而导致了数据错乱。要想解决该问题,可以在每个任务执行前重新关联一个线程特有对象(使用threadLocal.set()或者threadLocal.remove()方法),但是这样做,其实失去了ThreadLocal的意义,线程特有对象又退化成了任务特有对象。下面举一个例子来说明使用ThreadLocal时如何避免数据错乱。

    public abstract class AbsParentTask {
    
        protected static ThreadLocal<HashMap<String, String>> map = ThreadLocal.withInitial(HashMap::new);
    
        protected void clear() {
            map.get().clear();
        }
    
        protected abstract void doSomething();
    
        protected  HashMap<String, String> getValue(){
            return map.get();
        }
    
    }
    
    public class SonTask1 extends AbsParentTask{
    
        @Override
        protected void doSomething() {
            map.get().put("key1", "task1");
            //模拟doSomething...
            System.out.println(map.get());
        }
    }
    
    public class SonTask2 extends AbsParentTask {
        @Override
        protected void doSomething() {
            //模拟doSomething...但在使用map之前先清空,否则会拿到其他任务的数据
            clear();
            System.out.println(map.get());
        }
    }
    
    public class Client {
        public static void main(String[] args) {
            AbsParentTask son1 = new SonTask1();
            AbsParentTask son2 = new SonTask2();
    
            son1.doSomething();
            son2.doSomething();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44

    多线程环境下使用ThreadLocal可以很好的避免数据不一致问题。但是多线程+每个线程有多个任务情况时,要注意数据错乱的情况。

    内存泄露

    内存泄露是指由于对象永远无法被垃圾回收导致其占用的java虚拟机内存无法被释放。
    在此之前先看一下ThreadLocal的内部实现机制。每一个线程(Thread)内部都维护着一个ThreadLocalMap,其类似一个HashMap,里面放有多个entry条目,该线程就被称为这些条目的属主线程,entry的key是一个ThreadLocal实例,value是线程的特有对象,因此,entry的作用是为该线程与ThreadlLocal实例建立联系,再将ThreadLocal实例和线程特有对象建立联系。entry对key的引用是弱引用,当没有地方使用key时,该key的实例会被垃圾回收,进而将key置为null,这个entry就变成了无效entry。entry对value的使用是强引用,即任何时候都不会被垃圾回收,那就会存在key为null,但是value不为null的情况,但是通过为null的key,也获取不到value,该value就无法被垃圾回收,从而导致了内存泄露。
    下面是ThreadLocal的内部实现:
    在这里插入图片描述
    要想解决ThreadLocal内存泄露的问题,我们可以使用threadLocal.remove()方法。下面简单分析一下remove源码:

      public void remove() {
      		//获取当前线程的ThreadLocalMap
             ThreadLocalMap m = getMap(Thread.currentThread());
             if (m != null)
             	//移除当前ThreadLocal实例以及对象
                 m.remove(this);
         }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    m.remove(this)方法:

     private void remove(ThreadLocal<?> key) {
                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)]) {
                    if (e.get() == key) {
                    	//移除key,也就是当前ThreadLocal实例
                        e.clear();
                        //移除value,也就是当前线程特有对象
                        expungeStaleEntry(i);
                        return;
                    }
                }
            }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    expungeStaleEntry(i)方法:

     private int expungeStaleEntry(int staleSlot) {
                Entry[] tab = table;
                int len = tab.length;
    
                //将value置为null
                tab[staleSlot].value = null;
                tab[staleSlot] = null;
                size--;
    
                // Rehash until we encounter null
                Entry e;
                int i;
                ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    entry的key之所以使用弱引用,是因为当线程特有对象被删除后,不用处理该ThreadLcoal实例,它也会被垃圾回收。如果使用强引用,则默认不会被垃圾回收,不强制回收会导致内存泄露。而entry的value,因为放的是对象,所以肯定是强引用的。

    小总结

    ThreadLocal相当于一个保姆,它替线程来管理线程的特有对象。与他起到同样的作用的还有锁,这二者有时可以作为替代关系。需要注意的是使用TheadLocal时尽量声明为static成员变量,以减少内存开销,同时也要避免内存泄露,记得remove。
    最后感觉map是万能的,很多看似高深的技术都是通过map做映射来实现的,比如spring的三级缓存,哈哈哈。。。

  • 相关阅读:
    【每日一题Day220】LC1439有序矩阵中的第 k 个最小数组和 | 堆
    说说 Redis 缓存删除策略
    C#中使用Bitmap 传递图到C++
    系统移植开发阶段部署
    射频电路设计的常见问题及五大经验总结
    (183)Verilog HDL:设计一个移位功能Rotate100
    C++ 新特性 | C++ 11 | 移动语义与右值引用
    JAVA面试(三)
    WSUS 修补程序管理的替代方法
    kali linux安装redis
  • 原文地址:https://blog.csdn.net/aaaPostcard/article/details/127588575