ThreadLocal详解以及其导致的内存泄漏

为什么要有ThreadLocal

        比如我们的应用与数据库的交互,通常从一个连接池中获取数据库连接,而连接的使用跨了方法,这是为了保证使用的是同一个数据库连接,需要用到ThreadLocal

public class DataSourceTransactionManager extends AbstractPlatformTransactionManager
		implements ResourceTransactionManager, InitializingBean {
		...
		protected void doBegin(Object transaction, TransactionDefinition definition) {
			...
			txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
			...
			if (con.getAutoCommit()) {
				txObject.setMustRestoreAutoCommit(true);
				if (logger.isDebugEnabled()) {
					logger.debug("Switching JDBC Connection [" + con + "] to manual commit");
				}
				con.setAutoCommit(false);
			}
			...
			// Bind the connection holder to the thread.
			if (txObject.isNewConnectionHolder()) {
				TransactionSynchronizationManager.bindResource(obtainDataSource(), txObject.getConnectionHolder());
			}
			...
		}
}

我们来看看bindResource的内容

public abstract class TransactionSynchronizationManager {
	private static final ThreadLocal<Map<Object, Object>> resources =
			new NamedThreadLocal<>("Transactional resources");
	...
	public static void bindResource(Object key, Object value) throws IllegalStateException {
		Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);
		...
		Map<Object, Object> map = resources.get();
		// set ThreadLocal Map if none found
		if (map == null) {
			map = new HashMap<>();
			resources.set(map);
		}
		Object oldValue = map.put(actualKey, value);
		...
	}
}

        现在我们可以对ThreadLocal下一个比较确切的定义了
        此类提供线程局部变量。这些变量与普通对应变量的不同之处在于, 访问一 个变量的每个线程(通过其 get 或 set 方法)都有自己独立初始化的变量副本。 ThreadLocal 实例通常是希望将状态与线程(例如, 用户 ID 或事务 ID)相关联 的类中的私有静态字段。
        也就是说 ThreadLocal 为每个线程都提供了变量的副本,使得每个线程在某 一时间访问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。
        同是解决并发问题的方法,synchronized是利用锁机制,ThreadLocal则是副本机制。

ThreadLocal的使用

ThreadLocal只有四个方法

  • void set(Object value):设置当前线程的线程局部变量的值
  • public Object get():该方法返回当前线程对应的线程局部变量。
  • public void remove():将当前线程局部变量的值删除,目的是为了减少内存的占用。
  • protected Object initialValue():返回该线程局部变量的初始值。这个方法是一个延迟调用方法,在线程第一次调用get()或set(Object)时才执行,并且只执行一次。ThreadLocal的缺省实现直接返回一个null

实现解析

实现分析

        怎么实现 ThreadLocal,既然说让每个线程都拥有自己变量的副本,最容易 的方式就是用一个Map 将线程的副本存放起来, Map 里 key 就是每个线程的唯 一性标识,比如线程 ID ,value 就是副本值, 实现起来也很简单:
        可以看到 ThreadLocal 的性能远超类似 synchronize 的锁实现 ReentrantLock, 比我们后面要学的AtomicInteger 也要快很多,即使我们把 Map 的实现更换为Java 中专为并发设计的 ConcurrentHashMap也不太可能达到这么高的性能。
        怎么样设计可以让 ThreadLocal 达到这么高的性能呢?最好的办法则是让变量副本跟随着线程本身, 而不是将变量副本放在一个地方保存, 这样就可以在存取时避开线程之间的竞争。
        同时,因为每个线程所拥有的变量的副本数是不定的, 有些线程可能有一个, 有些线程可能有2个甚至更多, 则线程内部存放变量副本需要一个容器, 而且容 器要支持快速存取, 所以在每个线程内部都可以持有一个 Map 来支持多个变量 副本,这个 Map 被称为 ThreadLocalMap。

具体实现

        上面先取到当前线程,然后调用 getMap 方法获取对应的 ThreadLocalMap, ThreadLocalMap 是一个声明在 ThreadLocal 的静态内部类, 然后 Thread 类中有一 个这样类型成员变量,也就是ThreadLocalMap 实例化是在 Thread 内部,所以getMap 是直接返回 Thread 的这个成员。
        看下 ThreadLocal 的内部类 ThreadLocalMap 源码,这里其实是个标准的 Map 实现,内部有一个元素类型为Entry的数组, 用以存放线程可能需要的多个副本变量。
        可以看到有个 Entry 内部静态类,它继承了 WeakReference,总之它记录了 两个信息, 一个是ThreadLocal<?>类型, 一个是Object类型的值。getEntry方法则是获取某个ThreadLocal对应的值,set 方法就是更新或赋值相应的 ThreadLocal 对应的值。
        回顾我们的 get 方法, 其实就是拿到每个线程独有的ThreadLocalMap然后再用 ThreadLocal 的当前实例,拿到 Map 中的相应的 Entry,然后就可 以拿到相应的值返回出去。当然,如果Map为空,还会先进行map的创建,初始化等工作。

Hash 冲突的解决

        什么是 Hash ,就是把任意长度的输入(又叫做预映射, pre-image),通过 散列算法, 变换成固定长度的输出, 该输出就是散列值,输入的微小变化会导致 输出的巨大变化。所以 Hash 常用在消息摘要或签名上, 常用 hash 消息摘要算法 有:(1)MD4(2) MD5 它对输入仍以 512 位分组,其输出是 4 个 32 位字的级联 (3)SHA-1 及其他。
        Hash 转换是一种压缩映射, 也就是, 散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出, 所以不可能从散列值来确定唯一的输入值。 比如有 10000 个数放到 100 个桶里, 不管怎么放, 有个桶里数字个数一定是大于 2 的。
        所以 Hash 简单的说就是一种将任意长度的消息压缩到某一固定长度的消息 摘要的函数。常用HASH 函数:直接取余法、乘法取整法、平方取中法。 Java 里的 HashMap 用的就是直接取余法。
        我们已经知道 Hash 属于压缩映射,一定能会产生多个实际值映射为一个 Hash 值的情况, 这就产生了冲突,常见处理 Hash 冲突方法:

开放定址法:

        基本思想是,出现冲突后按照一定算法查找一个空位置存放,根据算法的不 同又可以分为线性探测再散列、二次探测再散列、伪随机探测再散列。
        线性探测再散列即依次向后查找,二次探测再散列, 即依次向前后查找, 增 量为 1 、2 、3 的二次方,伪随机,顾名思义就是随机产生一个增量位移。
        ThreadLocal里用的则是线性探测再散列

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
            Entry[] tab = table;
            int len = tab.length;

            while (e != null) {
                ThreadLocal<?> k = e.get();
                if (k == key)
                    return e;
                if (k == null)
                    expungeStaleEntry(i);
                else
                    i = nextIndex(i, len);
                e = tab[i];
            }
            return null;
        }
private static int nextIndex(int i, int len) {
            return ((i + 1 < len) ? i + 1 : 0);
        }
链地址法:

        这种方法的基本思想是将所有哈希地址为 i 的元素构成一个称为同义词链的单链表, 并将单链表的头指针存在哈希表的第 i 个单元中, 因而查找、插入和删 除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。Java里的 HashMap 用的就是链地址法,为了避免 hash 洪水攻击,1.8 版本开始还引 入了红黑树。

再哈希法:

        这种方法是同时构造多个不同的哈希函数: Hi=RH1(key) i=1 ,2 ,… ,k 当哈希地址 Hi=RH1(key)发生冲突时,再计算 Hi=RH2(key)……,直到冲突 不再产生。这种方法不易产生聚集,但增加了计算时间。

建立公共溢出区

        这种方法的基本思想是: 将哈希表分为基本表和溢出表两部分, 凡是和基本 表发生冲突的元素, 一律填入溢出表。

引发内存泄漏分析

内存泄漏现象

public class ThreadLocalMemoryLeak {
    private static final int TASK_LOOP_SIZE = 500;

    /*线程池*/
    final static ThreadPoolExecutor poolExecutor
            = new ThreadPoolExecutor(5, 5, 1,
            TimeUnit.MINUTES,
            new LinkedBlockingQueue<>());

    static class LocalVariable {
        private byte[] a = new byte[1024*1024*5];/*5M大小的数组*/
    }

    ThreadLocal<LocalVariable> threadLocalLV;

    public static void main(String[] args) throws InterruptedException {
        SleepTools.ms(4000);
        for (int i = 0; i < TASK_LOOP_SIZE; ++i) {
            poolExecutor.execute(new Runnable() {
                public void run() {
                    SleepTools.ms(500);
                    LocalVariable localVariable = new LocalVariable();


                    ThreadLocalMemoryLeak oom = new ThreadLocalMemoryLeak();
                    oom.threadLocalLV = new ThreadLocal<>();
                    oom.threadLocalLV.set(new LocalVariable());

                   oom.threadLocalLV.remove();

                    System.out.println("use local varaible");

                }
            });

            SleepTools.ms(100);
        }
        System.out.println("pool execute over");
    }

}

执行如上的 ThreadLocalMemoryLeak,并将堆内 存大小设置为-Xmx256m,我们启用一个线程池,大小固定为 5 个线程
场景 1,首先任务中不执行任何有意义的代码, 当所有的任务提交执行完成 后,可以看见,我们这个应用的内存占用基本上为 25M 左右
场景 2,然后我们只简单的在每个任务中 new 出一个数组, 执行完成后我们 可以看见,内存占用基本和场景 1 同
场景 3,当我们启用了 ThreadLocal 以后:执行完成后我们可以看见,内存占用变为了 100 多 M
场景 4,于是,我们加入一行代码,再执行,看看内存情况:可以看见,内存占用基本和场景 1 同。这就充分说明,场景 3,当我们启用了 ThreadLocal 以后确实发生了内存泄 漏。

分析

        根据我们前面对 ThreadLocal 的分析,我们可以知道每个 Thread 维护一个 ThreadLocalMap,这个映射表的 key 是 ThreadLocal 实例本身, value 是真正需要存储的 Object,也就是说 ThreadLocal 本身并不存储值, 它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。仔细观察ThreadLocalMap,这个 map 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。
        这样, 当把 threadlocal 变量置为 null 以后,没有任何强引用指向 threadlocal 实例,所以threadlocal 将会被 gc 回收。这样一来, ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的value,如果当前 线程再迟迟不结束的话,这些 key 为 null 的Entry 的 value 就会一直存在一条强 引用链: Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value ,而这块 value 永 远不会被访问到了,所以存在着内存泄露。
        只有当前 thread 结束以后, current thread 就不会存在栈中,强引用断开, Current Thread 、Mapvalue 将全部被 GC 回收。最好的做法是不在需要使用ThreadLocal 变量后,都调用它的 remove()方法,清除数据。
        所以回到我们前面的实验场景, 场景 3 中,虽然线程池里面的任务执行完毕 了,但是线程池里面的 5 个线程会一直存在直到 JVM 退出,我们 set 了线程的 localVariable 变量后没有调用localVariable.remove()方法,导致线程池里面的 5 个 线程的 threadLocals 变量里面的 new LocalVariable()实例没有被释放。
        其实考察 ThreadLocal 的实现,我们可以看见,无论是 get() 、set()在某些时 候,调用了expungeStaleEntry 方法用来清除 Entry 中 Key 为 null 的 Value ,但是 这是不及时的,也不是每次都会执行的,所以一些情况下还是会发生内存泄露。 只有 remove()方法中显式调用了expungeStaleEntry方法。
        从表面上看内存泄漏的根源在于使用了弱引用, 但是另一个问题也同样值得 思考:为什么使用弱引用而不是强引用?
        下面我们分两种情况讨论:
        key 使用强引用: 对 ThreadLocal 对象实例的引用被置为 null 了,但是ThreadLocalMap 还持有这个 ThreadLocal 对象实例的强引用, 如果没有手动删除, ThreadLocal 的对象实例不会被回收,导致 Entry 内存泄漏。
        key 使用弱引用: 对 ThreadLocal 对象实例的引用被被置为 null 了,由于ThreadLocalMap 持有 ThreadLocal 的弱引用, 即使没有手动删除, ThreadLocal 的 对象实例也会被回收。value 在下一次ThreadLocalMap 调用set,get ,remove都有机会被回收。
        比较两种情况,我们可以发现:由于 ThreadLocalMap 的生命周期跟 Thread 一样长, 如果都没有手动删除对应 key,都会导致内存泄漏, 但是使用弱引用可 以多一层保障。
        因此, ThreadLocal 内存泄漏的根源是:由于 ThreadLocalMap 的生命周期跟 Thread 一样长, 如果没有手动删除对应 key 就会导致内存泄漏, 而不是因为弱引 用。

总结

        JVM 利用设置 ThreadLocalMap 的 Key 为弱引用,来避免内存泄露。 JVM 利用调用 remove 、get、set 方法的时候,回收弱引用。
        当 ThreadLocal 存储很多 Key 为 null 的 Entry 的时候,而不再去调用 remove、 get 、set 方法,那么将导致内存泄漏。
        使用线程池+ThreadLocal时要小心, 因为这种情况下, 线程是一直在不断的重复运行的,从而也就造成了 value 可能造成累积的情况。

错误使用 ThreadLocal 导致线程不安全

public class ThreadLocalUnsafe implements Runnable {

    public static Number number = new Number(0);
    public static ThreadLocal<Number> value = new ThreadLocal<Number>()/*{
        @Override
        protected Number initialValue() {
            return new Number(0);
        }
    }*/;

    public void run() {
        Random r = new Random();
        //Number number = value.get();
        //每个线程计数加随机数
        number.setNum(number.getNum()+r.nextInt(100));
      //将其存储到ThreadLocal中
        value.set(number);
        SleepTools.ms(2);
        //输出num值
        System.out.println(Thread.currentThread().getName()+"="+value.get().getNum());
    }

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            new Thread(new ThreadLocalUnsafe()).start();
        }
    }

    private static class Number {
        public Number(int num) {
            this.num = num;
        }

        private int num;

        public int getNum() {
            return num;
        }

        public void setNum(int num) {
            this.num = num;
        }

        @Override
        public String toString() {
            return "Number [num=" + num + "]";
        }
    }

}

运行后的结果为:
Thread-2=174
Thread-3=174
Thread-1=174
Thread-0=174
Thread-4=174
        为什么每个线程都输出 174?难道他们没有独自保存自己的 Number 副本吗? 为什么其他线程还是能够修改这个值?仔细考察 ThreadLocal 和 Thead 的代码,我们发现 ThreadLocalMap 中保存的其实是对象的一个引用,这样的话,当有其 他线程对这个引用指向的对象实例做修改时, 其实也同时影响了所有的线程持有 的对象引用所指向的同一个对象实例。这也就是为什么上面的程序为什么会输出 一样的结果。
        而上面的程序要正常的工作,应该的用法是让每个线程中的 ThreadLocal 都 应该持有一个新的Number 对象。
正确代码如下:

public class ThreadLocalUnsafe implements Runnable {

    //public static Number number = new Number(0);
    public static ThreadLocal<Number> value = new ThreadLocal<Number>(){
        @Override
        protected Number initialValue() {
            return new Number(0);
        }
    };

    public void run() {
        Random r = new Random();
        Number number = value.get();
        //每个线程计数加随机数
        number.setNum(number.getNum()+r.nextInt(100));
        //将其存储到ThreadLocal中
        value.set(number);
        SleepTools.ms(2);
        //输出num值
        System.out.println(Thread.currentThread().getName()+"="+value.get().getNum());
    }

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            new Thread(new ThreadLocalUnsafe()).start();
        }
    }

    private static class Number {
        public Number(int num) {
            this.num = num;
        }

        private int num;

        public int getNum() {
            return num;
        }

        public void setNum(int num) {
            this.num = num;
        }

        @Override
        public String toString() {
            return "Number [num=" + num + "]";
        }
    }

}
  • 21
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值