有关 ThreadLocal 的都在这里了

本文深入探讨了ThreadLocal的原理和使用,对比Synchronized,解析Spring事务中ThreadLocal的应用,分析其内存泄漏问题及线程安全注意事项。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

ThreadLocal辨析

与Synchonized的比较

ThreadLocal和Synchonized都用于解决多线程并发访问。

  • Synchronized:利用锁的机制,使变量或代码块在某一时该仅仅能被一个线程访问。
  • ThreadLocal:为每个线程都提供了变量的副本,使得每个线程在某一时间访问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。

Spring的事务就借助了ThreadLocal类。Spring会从数据库连接池中获得一个connection,然会把connection放进ThreadLocal中,也就和线程绑定了,事务需要提交或者回滚,只要从ThreadLocal中拿到connection进行操作。

为何Spring的事务要借助ThreadLocal类

以JDBC为例,正常的事务代码可能如下:
dbc = new DataBaseConnection();//第1行
Connection con = dbc.getConnection();//第2行
con.setAutoCommit(false);// //第3行
con.executeUpdate(…);//第4行
con.executeUpdate(…);//第5行
con.executeUpdate(…);//第6行
con.commit();////第7行
上述代码,可以分成三个部分:
事务准备阶段:第1~3行
业务处理阶段:第4~6行
事务提交阶段:第7行
可以很明显的看到,不管我们开启事务还是执行具体的sql都需要一个具体的数据库连接。
现在我们开发应用一般都采用三层结构,如果我们控制事务的代码都放在DAO(DataAccessObject)对象中,在DAO对象的每个方法当中去打开事务和关闭事务,当Service对象在调用DAO时,如果只调用一个DAO,那我们这样实现则效果不错,但往往我们的Service会调用一系列的DAO对数据库进行多次操作,那么,这个时候我们就无法控制事务的边界了,因为实际应用当中,我们的Service调用的DAO的个数是不确定的,可根据需求而变化,而且还可能出现Service调用Service的情况。
如果不使用ThreadLocal,代码大概就会是这个样子:
在这里插入图片描述在这里插入图片描述
但是需要注意一个问题,如何让三个DAO使用同一个数据源连接呢?我们就必须为每个DAO传递同一个数据库连接,要么就是在DAO实例化的时候作为构造方法的参数传递,要么在每个DAO的实例方法中作为方法的参数传递。这两种方式无疑对我们的Spring框架或者开发人员来说都不合适。为了让这个数据库连接可以跨阶段传递,又不显示的进行参数传递,就必须使用别的办法。
Web容器中,每个完整的请求周期会由一个线程来处理。因此,如果我们能将一些参数绑定到线程的话,就可以实现在软件架构中跨层次的参数共享(是隐式的共享)。而JAVA中恰好提供了绑定的方法–使用ThreadLocal。
结合使用Spring里的IOC和AOP,就可以很好的解决这一点。
只要将一个数据库连接放入ThreadLocal中,当前线程执行时只要有使用数据库连接的地方就从ThreadLocal获得就行了。

ThreadLocal的使用

ThreadLocal类接口常用的4个方法:

  • void set(Object value)
    设置当前线程的线程局部变量的值。
  • public Object get()
    该方法返回当前线程所对应的线程局部变量。
  • public void remove()
    将当前线程局部变量的值删除,目的是为了减少内存的占用,该方法是JDK 5.0新增的方法。需要指出的是,当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显式调用该方法清除线程的局部变量并不是必须的操作,但它可以加快内存回收的速度。
  • protected Object initialValue()
    返回该线程局部变量的初始值,该方法是一个protected的方法,显然是为了让子类覆盖而设计的。这个方法是一个延迟调用方法,在线程第1次调用get()或set(Object)时才执行,并且仅执行1次。ThreadLocal中的缺省实现直接返回一个null。

public final static ThreadLocal< String > RESOURCE = new ThreadLocal< String >();
RESOURCE代表一个能够存放String类型的ThreadLocal对象。此时不论什么一个线程能够并发访问这个变量,对它进行写入、读取操作,都是线程安全的。

有示例如下:

/**
 *类说明:演示ThreadLocal的使用
 */
public class UseThreadLocal {
	
	private static ThreadLocal<Integer> intLocal
            = ThreadLocal.withInitial(() -> 1);

    /**
     * 运行3个线程
     */
    public void StartThreadArray(){
        Thread[] runs = new Thread[3];
        for(int i=0;i<runs.length;i++){
            runs[i]=new Thread(new TestThread(i));
        }
        for(int i=0;i<runs.length;i++){
            runs[i].start();
        }
    }
    
    /**
     *类说明:测试线程,线程的工作是将ThreadLocal变量的值变化,并写回,看看线程之间是否会互相影响
     */
    public static class TestThread implements Runnable{
        int id;
        public TestThread(int id){
            this.id = id;
        }
        public void run() {
            System.out.println(Thread.currentThread().getName()+":start");
            Integer s = intLocal.get();
            s = s+id;
            intLocal.set(s);
            System.out.println(Thread.currentThread().getName()
                    +":"+ intLocal.get());
        }
    }

    public static void main(String[] args){
    	new UseThreadLocal().StartThreadArray();
    }
}

结果:

Thread-1:start
Thread-2:start
Thread-0:start
Thread-1:2
Thread-2:3
Thread-0:1
源码解析

在这里插入图片描述

set(T value)
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
    	//第一次set需要创建一个ThreadLocalMap 
        createMap(t, value);
}
createMap
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}
ThreadLocalMap

ThreadLocalMap 是 ThreadLocal 的静态内部类。
每一个 Thread,都有一个自己的 ThreadLocalMap。
ThreadLocalMap 里是一个 Entry 数组。

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
						//16
    table = new Entry[INITIAL_CAPACITY];
    //找到下标
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    			//扩容阈值:INITIAL_CAPACITY* 2 / 3	
    setThreshold(INITIAL_CAPACITY);
}

为什么要有数组?因为可以创建很多 ThreadLocal 与当前线程绑定,所以需要创建一个数组存储不同的 ThreadLocal 与副本值的对应关系。

Entry

Entry 是 ThreadLocalMap 的静态内部类。
ThreadLocal 与副本对象,做成了 key-value 的形式。
Entry 它继承了 WeakReference ,是一个弱引用对象,表示有一个弱引用指向自己的 key,ThreadLocal。

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

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

在这里插入图片描述

nextHashCode

如何根据 ThreadLocal 计算它的 hash 值

private final int threadLocalHashCode = nextHashCode();

private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

private static AtomicInteger nextHashCode =
        new AtomicInteger();
        
private static final int HASH_INCREMENT = 0x61c88647;

可以看到:计算ThreadLocal 的 hash 值,并没有用到它的 hashcode(),而是直接赋值,从 0x61c88647 开始,依次是它的 1 倍、2 倍 、3 倍。这么做的好处是,不会发生 hash 冲突。示例如下:

public class HashDemo {

    private static final int HASH_INCREMENT = 0x61c88647;

    public static void main(String[] args) {
        System.out.println(HASH_INCREMENT);
        magicHash(16);
        magicHash(32);
    }

    private  static void magicHash(int size){
        int hashCode = 0;
        for(int i=0;i<size;i++){
            hashCode += HASH_INCREMENT;
            System.out.print((hashCode&(size-1))+"  ");
        }
        System.out.println();
    }

}

运行结果

1640531527
7  14  5  12  3  10  1  8  15  6  13  4  11  2  9  0  
7  14  21  28  3  10  17  24  31  6  13  20  27  2  9  16  23  30  5  12  19  26  1  8  15  22  29  4  11  18  25  0  

1640531527 == 232 * 黄金分割比
黄金分割比 :(Math.sqrt(5) - 1)/2,约等于0.618
这种散列方法也叫斐波那契散列

那么真的不会发生冲突了吗?并不是。
ThreadLocal 在各种方法里面安排了清理key==null的Entry的方法,导致在扩容前插入次数超过16,这样就会冲突了(细品)。

get
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();
    }
getEntry
private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    				//Reference的方法,能拿到referent,这里是ThreadLocal
    if (e != null && e.get() == key)
        return e;
    else
    	//线性探测
        return getEntryAfterMiss(key, i, e);
}
set(ThreadLocal<?> key, Object value)
private void set(ThreadLocal<?> key, Object value) {

    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
	//开放寻址(线性探测),从i往后环形查找
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
		//如果数组里的ThreadLocal和要设置的ThreadLocal是同一个,直接赋值
        if (k == key) {
            e.value = value;
            return;
        }
		
		//原来的Entry里的key(k)为null
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
	//i位置是空的,直接创建一个新Entry放进去
    tab[i] = new Entry(key, value);
    int sz = ++size;
    //清理key==null的Entry(引发内存泄漏的Entry,脏Entry)
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}
replaceStaleEntry

在数组的 i 位置的 Entry 的 key == null 时,调用此方法

slotToExpunge 始终表示要开始清理的位置
1. 从 staleSlot-1 开始,往前环形遍历,到 Entry == null 为止,不断更新 slotToExpunge 为 key == null 的下标
2. 从 staleSlot+1 开始,往后环形遍历,到 Entry == null 为止,
	2.1 如果要插入的 key == 槽中的 key,赋新值,交换当前槽和旧槽 staleSlot 中的 Entry
		2.1.1 如果 1 中没有更新过 slotToExpunge,更新 slotToExpunge 为当前槽
	2.2  从 slotToExpunge 开始往后清理脏 Entry
3. 旧槽赋新值
4. 如果 slotToExpunge != staleSlot,说明遇到过脏 Entry,从 slotToExpunge 开始清理脏 Entry
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                       int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    int slotToExpunge = staleSlot;
    //在Entry不为null的条件下,往回环形查找key==null的槽位,标记为slotToExpunge 
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;

    //在Entry不为null的条件下,往后环形遍历
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

		//如果key相等
        if (k == key) {
        	//赋新值
            e.value = value;
			
			//与进入本方法的槽位交换Entry
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            //如果往回遍历的时候,没有发现key==null的Entry
            if (slotToExpunge == staleSlot)
            	//标记现在的i为slotToExpunge 
                slotToExpunge = i;
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }
        
        //槽位中key==null,且前面没有发现key==null的Entry
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    tab[staleSlot].value = null;
    //旧槽赋新值
    tab[staleSlot] = new Entry(key, value);

	//需要清理
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
cleanSomeSlots

如果 i+1 开始,清理脏数据。如果一直没碰到脏数据,只用遍历 logn 次;如果碰到脏数据,再遍历 logn 次。

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
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}
expungeStaleEntry

清除标记的脏Entry,并往后环形遍历清除。

private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    //清除脏数据
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // Rehash until we encounter null
    Entry e;
    int i;
    //在Entry不为null的条件下,往后环形遍历
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        //清理脏Entry
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
        	//重新计算索引下标,为了下次找到Entry更快
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;
				//线性探测
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}
remove
public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        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) {
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

public void clear() {
   	this.referent = null;
}
引发的内存泄漏分析
预备知识

引用
Object o = new Object();
这个o,我们可以称之为对象引用,而new Object()我们可以称之为在内存中产生了一个对象实例。
当写下 o=null时,只是表示o不再指向堆中object的对象实例,不代表这个对象实例不存在了。

  • 强引用:指在程序代码之中普遍存在的,类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象实例。
  • 软引用:用来描述一些还有用但并非必需的对象。仅软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象实例列进回收范围之中进行回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。
  • 弱引用:用来描述非必需对象的,但是它的强度比软引用更弱一些,仅被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
  • 虚引用:也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象实例是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象实例被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。
内存泄漏的现象

示例如下:

**
 * 类说明:ThreadLocal造成的内存泄漏演示
 */
public class ThreadLocalOOM {
    private static final int TASK_LOOP_SIZE = 500;
	//我们启用一个线程池,大小固定为5个线程
    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大小的数组*/
    }

    final static ThreadLocal<LocalVariable> localVariable
            = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
        /*5*5=25*/
        for (int i = 0; i < TASK_LOOP_SIZE; ++i) {
            poolExecutor.execute(new Runnable() {
                public void run() {
                	//首先只简单的在每个任务中new出一个数组
                    new LocalVariable();
                    //localVariable.set(new LocalVariable());
                    //localVariable.remove();
                }
            });

            Thread.sleep(100);
        }
        System.out.println("pool execute over");
    }

}

并将堆内存大小设置为-Xmx256m:
在这里插入图片描述
结果:
在这里插入图片描述
可以看到内存的实际使用控制在25M左右:因为每个任务中会不断new出一个5M的数组,5*5=25M,这是很合理的。
当我们启用了ThreadLocal以后:

//new LocalVariable();
localVariable.set(new LocalVariable());

结果:
在这里插入图片描述
内存占用最高升至150M,一般情况下稳定在90M左右,那么加入一个ThreadLocal后,内存的占用真的会这么多?
于是,我们加入一行代码:

localVariable.remove();

结果:
在这里插入图片描述
可以看见最高峰的内存占用也在25M左右,和我们不加ThreadLocal表现一样。
这就充分说明,确实发生了内存泄漏。

分析

根据我们前面对ThreadLocal的分析,我们可以知道每个Thread 维护一个 ThreadLocalMap,这个映射表的 key 是 ThreadLocal实例本身,value 是真正需要存储的 Object,也就是说 ThreadLocal 本身并不存储值,它只是作为一个 Key 来让线程从 ThreadLocalMap 获取 value。仔细观察ThreadLocalMap,这个map是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。
因此使用了ThreadLocal后,引用链如图所示:
在这里插入图片描述
图中的虚线表示弱引用。
这样,当把 ThreadLocalRef 变量置为 null 以后,只有一个虚引用引用指向 ThreadLocal 实例,所以Threadlocal 将会被 GC 回收。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value,如果当前线程再迟迟不结束的话,这些 key 为 null 的Entry 的 value 就会一直存在一条强引用链:Current Thread Ref -> Current Thread -> ThreaLocalMap -> Entry -> value,而这块 value 永远不会被访问到了,所以存在着内存泄露。
只有当前线程结束以后,Current Thread Ref 就不会存在栈中,强引用断开,Current Thread、Map、 Entry、value 将全部被 GC 回收。最好的做法是不在需要使用 ThreadLocal 变量后,都调用它的remove()方法,清除数据。
其实考察ThreadLocal的实现,我们可以看见,无论是get()、set()在某些时候,调用了expungeStaleEntry方法用来清除Entry中Key为null的Value,但是这是不及时的,也不是每次都会执行的,所以一些情况下还是会发生内存泄露。只有remove()方法中显式调用了expungeStaleEntry方法。

从表面上看内存泄漏的根源在于使用了弱引用,但是另一个问题也同样值得思考:为什么使用弱引用而不是强引用?
下面我们分两种情况讨论:
key 使用强引用:ThreadLocalMap 一直持有 ThreadLocal 的强引用,如果没有手动置空这个强引用,ThreadLocal 的对象实例不会被回收,导致 ThreadLocal 和 Entry 一直不能被回收。
key 使用弱引用:ThreadLocalMap 持有 ThreadLocal 的弱引用,ThreadLocal 的对象如果不再被强引用指向,可以被回收。Entry 不能被回收,但在下一次 ThreadLocalMap 调用 set,get,remove 都有机会被回收。
比较两种情况,我们可以发现:由于 ThreadLocalMap 的生命周期跟 Thread 一样长,如果都没有手动删除 Entry ,都会导致内存泄漏,但是使用弱引用可以让 ThreadLocal 被回收。
因此,ThreadLocal 内存泄漏的根源是:由于 ThreadLocalMap 的生命周期跟 Thread 一样长,如果没有手动删除 ThreadLocalMap 里的 Entry 就会导致内存泄漏,而不是因为弱引用。

总结

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

关于弱引用弱引用对象的区别,请看 有关“引用”、“对象”、“引用对象”的误解和看过源码说明后的理解

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

示例如下:

/**
 * 类说明:ThreadLocal的线程不安全演示
 */
public class ThreadLocalUnsafe implements Runnable {

    public static Number number = new Number(0);

    public void run() {
        //每个线程计数加一
        number.setNum(number.getNum()+1);
        //将其存储到ThreadLocal中
        value.set(number);
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //输出num值
        System.out.println(Thread.currentThread().getName()+"="+value.get().getNum());
    }

    public static ThreadLocal<Number> value = new ThreadLocal<Number>() {
    };

    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-3=5
Thread-0=5
Thread-1=5
Thread-4=5
Thread-2=5

为什么每个线程都输出5而不是1?难道他们没有独自保存自己的Number副本吗?为什么其他线程还是能够修改这个值?
仔细考察ThreadLocal和Thead的代码,我们发现ThreadLocalMap中保存的其实是对象的一个引用,这样的话,当有其他线程对这个引用指向的对象实例做修改时,其实也同时影响了所有的线程持有的对象引用所指向的同一个对象实例。这也就是为什么上面的程序为什么会输出一样的结果:5个线程中保存的是同一Number对象的引用(因为被static修饰了),在线程睡眠的时候,其他线程将num变量进行了修改,而修改的对象Number的实例是同一份,因此它们最终输出的结果是相同的。
而上面的程序要正常的工作,应该的用法是让每个线程中的ThreadLocal都应该持有一个新的Number对象(最简单的方法是去掉static)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值