多线程/并发编程——面试再也不怕 ThreadLocal 了

文章目录

    • 一、什么是ThreadLocal
    • 二、使用示例
    • 三、源码解析
      • 1、set() 方法
      • 2、get() 方法
      • 3、Entry 内部类
      • 4、remove() 方法
    • 四、ThreadLocal的应用
      • 1、声明式事务
    • 五、ThreadLocal的内存泄漏
      • 1、ThreadLocal 会有内存泄漏吗?
      • 2、内存泄漏的解决

在前面的文章中,我们已经知道了线程安全及实现机制:多线程——线程安全及实现机制,在 Java 中主要从以下三个方面来实现线程安全:

  • 互斥(阻塞)同步:多线程——深入剖析 Synchronized、多线程\并发编程——ReentrantLock 详解
  • 非阻塞同步:多线程/并发编程——CAS、Unsafe及Atomic
  • 无同步方案:ThreadLocal

要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步和线程安全两者并没有必然的联系。同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及多线程共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的,笔者这里主要介绍其中的 ThreadLocal

特别注意的是ThreadLocal与线程同步无关,并不是为了解决多线程共享变量问题!ThreadLocal官网解释:

This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g.,a user ID or Transaction ID)

翻译过来的大概意思就是:ThreadLocal类用来提供线程内部的局部变量。这些变量在多线程环境下访问(通过get或set方法访问)时能保证各个线程里的变量相对独立于其他线程内的变量,ThreadLocal实例通常来说都是private static类型。
总结:ThreadLocal不是为了解决多线程访问共享变量,而是为每个线程创建一个单独的变量副本,提供了保持对象的方法和避免参数传递的复杂性。

一、什么是ThreadLocal

线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内(通过 ThreadLocal 实现),这样,无需同步也能保证线程之间不会出现数据争用的问题

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程限制在一个线程中消费完,其中最重要的一种应用实例就是经典 WEB 交互模型中的 “一个请求对应一个服务器线程”的处理方式,这种处理方式的广泛应用使得很多 WEB 服务端应用都可以使用线程本地存储来解决线程安全问题。

线程封闭技术的一种常见应用是 JDBCConnection 对象。JDBC规范并不要求 Connection 对象必须是线程安全的。在典型的服务器应用程序中,线程从连接池中获得一个 Connection 对象,并且用该对象来处理请求,使用完后再将对象返回给连接池。由于大多数请求(例如 Servlet 请求)都是由单个线程采用同步的方式来处理,并且在 Connection 对象返回之前,连接池也不会再将它分配给其他线程。因此,这种连接管理模式在处理请求时隐含的将 Connection 对象封闭在线程中。

在 Java 语言中并没有强制规定某个变量必须由锁来保护,同样在 Java 语言中也无法强制将变量封闭在某个线程中,不过我们还是可以通过 java.lang.ThreadLocal 类来实现线程本地存储的功能

二、使用示例

首先看一下并发情况下会产生的问题:

public class ThreadLocal1 {
	volatile static Person p = new Person("zhangsan");
	
	public static void main(String[] args) {
				
		new Thread(()->{
			try {
				//模拟并发,让另一个线程修改数据
				TimeUnit.SECONDS.sleep(2);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			//期望获得原始person信息:zhangsan;但是获得的信息是另一个线程修改过的:lisi
			System.out.println(p.name);
		}).start();
		
		new Thread(()->{
			try {
				TimeUnit.SECONDS.sleep(1);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			p.name = "lisi";
		}).start();
	}


	static class Person {
		String name;
		public Person(String name) {
			this.name = name;
		}
	}
}

一个线程期望获得Person的原始属性,但是并发情况下,另一个线程会修改数据,导致无法取到理想数据

而使用 ThreadLocal 可以让每个线程各自保留一个副本,每个的线程对于副本的修改不影响别的线程

public class ThreadLocal2 {
    
	//多个线程共同操作的对象ThreadLocal tl
	private static ThreadLocal<Person> tl = new ThreadLocal<>();
	public static void main(String[] args) {
				
		new Thread(()->{
			tl.set(new Person("zhangsan"));
			try {
				TimeUnit.SECONDS.sleep(2);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			//本线程休眠期间,另一个线程修改的张三不影响本线程,本线程只能获取本线程修改的数值
			//另一个线程修改的李四,也只在另一个线程本地可见
            
			System.out.println(tl.get().name);//输出结果 张三
		}).start();
		
		new Thread(()->{
			try {
				TimeUnit.SECONDS.sleep(1);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			tl.set(new Person("lisi"));
		}).start();
		
	}

	static class Person {
		String name;
		public Person(String name) {
			this.name = name;
		}
	}
}

使用 ThreadLocal 的时候,每个线程对于 ThreadLocal 共享对象的修改只在本线程可见,所以不会产生数据争用问题,即线程安全

三、源码解析

首先来看一下 ThreadLocal 的类图:

多线程/并发编程——面试再也不怕 ThreadLocal 了_第1张图片

类图解释:

每一个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的 K-V 对,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的 threadLocalHashCode 值,使用这个值就可以在线程的 K-V 键值对中找回对应的本地线程变量。K-V 键值对在 ThreadLocalMap 中以内部类 Entry 格式组织。

多线程/并发编程——面试再也不怕 ThreadLocal 了_第2张图片

ThreadLocal可以看做是一个容器,容器里面存放着属于当前线程的变量。ThreadLocal类提供了四个对外开放的接口方法,这也是用户操作ThreadLocal类的基本方法:

  • initialValue():返回此线程局部变量的当前线程的“初始值”。
  • set():将此线程局部变量的当前线程副本中的值设置为指定值。
  • get():返回此线程局部变量的当前线程副本中的值。
  • remove():移除此线程局部变量当前线程的值。

可以通过上述的几个方法实现ThreadLocal中变量的访问,数据设置,初始化以及删除局部变量,那ThreadLocal内部是如何为每一个线程维护变量副本的呢?在ThreadLocal类中有一个静态内部类ThreadLocalMap(其类似于Map),用键值对的形式存储每一个线程的变量副本,ThreadLocalMap中元素的key为当前ThreadLocal对象,而value对应线程的变量副本,每个线程可能存在多个ThreadLocal。


源代码:

1、set() 方法

ThreadLocal 中 set 方法:

public void set(T value) {
    //拿到当前线程
    Thread t = Thread.currentThread();
    //获取当前线程的 map 结构
    ThreadLocalMap map = getMap(t);
    if (map != null)
        //向map结构设值:key 就是当前线程 hashcode,value 就是我们想要设置的变量值
        map.set(this, value);
    else
        createMap(t, value);
}

源码解释:可以发现 set() 方法内部执行是 Thread.currentThread.map(ThreadLocal, value),即设置值到当前线程的 map 中,所以另外的线程当然就读不到了

还发现 ThreadLocal 中 set 方法会调用 ThreadLocalMap 的 set 方法,那我们再来看一下 ThreadLocalMap 的 set 方法源码:

private void set(ThreadLocal<?> key, Object value) {

        ThreadLocal.ThreadLocalMap.Entry[] tab = table;
        int len = tab.length;

        // 根据 ThreadLocal 的散列值,查找对应元素在数组中的位置
        int i = key.threadLocalHashCode & (len-1);

        // 采用“线性探测法”,寻找合适位置
        for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
            e != null;
            e = tab[i = nextIndex(i, len)]) {

            ThreadLocal<?> k = e.get();

            // key 存在,直接覆盖
            if (k == key) {
                e.value = value;
                return;
            }

            // key == null,但是存在值(因为此处的e != null),说明之前的ThreadLocal对象已经被回收
            if (k == null) {
                // 用新元素替换陈旧的元素
                replaceStaleEntry(key, value, i);
                return;
            }
        }

        // ThreadLocal对应的key实例不存在也没有陈旧元素,new 一个
        tab[i] = new ThreadLocal.ThreadLocalMap.Entry(key, value);

        int sz = ++size;

        // cleanSomeSlots 清楚陈旧的Entry(key == null)
        // 如果没有清理陈旧的 Entry 并且数组中的元素大于了阈值,则进行 rehash
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
    }

2、get() 方法

ThreadLocal 中 get() 方法源码:

public T get() {
        // 获取当前线程
        Thread t = Thread.currentThread();

        // 获取当前线程的成员变量 threadLocal
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            // 从当前线程的ThreadLocalMap获取相对应的Entry
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")

                // 获取目标值        
                T result = (T)e.value;
                return result;
            }
        }
    //若当前线程还未创建ThreadLocalMap,则返回调用此方法并在其中调用createMap方法进行创建并返回初始值
    return setInitialValue();
}

源码解释:可以发现 get() 方法内部执行是 Thread.currentThread.map.getEntry(ThreadLocal, value),即从当前线程的 map 结构中获取 Entry 对象 e(Entry 类型就是 K-V 数据),然后调用 e.value 即可获得所请求的值

3、Entry 内部类

从前面的分析我们可以知道 K-V 键值对数据,最后都是被组织成 Entry 类型存储的,下面就来看一下 Entry 类的源码:

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

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

可以发现 Entry 继承自 WeakReference 虚引用,Entry 存放 K-V 数据,key 就是当前 ThreadLocal 的hashcode 值,v 就是要存放的变量数值

有关强、软、弱、虚四种引用的具体介绍,请移步:GC入门超详解

这里简单的说明以下强、软、弱三种引用的特点:

  • 强引用:最常见的引用,强引用指向的对象不会被回收;
  • 软引用:仅有软引用指向的对象,只有发生gc且内存不足,才会被回收(使用用作缓存);
  • 弱引用:仅有弱引用指向的对象,只要发生gc就会被回收(适合用在容器中)。

4、remove() 方法

ThreadLocal 中 remove() 方法:

//删除当前线程中ThreadLocalMap对应的ThreadLocal
public void remove() {
       ThreadLocalMap m = getMap(Thread.currentThread());
       if (m != null)
           m.remove(this);
}

源码解释:remove 方法用于安全删除当前线程中 ThreadLocalMap 对应的 ThreadLocal

public void remove() 将当前线程局部变量的值删除,目的是为了减少内存的占用,该方法是JDK 5.0新增的方法。需要指出的是,当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显式调用该方法清除线程的局部变量并不是必须的操作,但它可以加快内存回收的速度。

四、ThreadLocal的应用

1、声明式事务

事务的配置整个是写在配置文件里的,而配置里的事务实际上是管理了一堆的方法的,假设每个方法的执行都需要去配置文件,获取数据库连接(数据库连接一般是写在配置文件里的)。

声明式事务是把这些方法合在一起,视为一个完整的事务。如果每一个方法获取的连接不是同一个连接,连接connection一般是放在连接池里面,如果每一个方法获取的连接甚至都不是同一个连接connection,Spring能把这些方法形成一个完整的事务吗?百分之一万的不可能!!!

多线程/并发编程——面试再也不怕 ThreadLocal 了_第3张图片

那么如何才能确保当前线程调用不同的方法获取的是同一个数据库连接connection呢?使用ThreadLocal——当这个线程调用方法第一次获取连接的时候,把这个connection放到线程的本地对象ThreadLocal里,以后的方法再拿的时候,实际上是从ThreadLocal里面直接拿connection,这样就保证这个线程里面所有的方法使用的都是同一个连接,所有的方法执行就可以视为一个完整的事务。

多线程/并发编程——面试再也不怕 ThreadLocal 了_第4张图片

五、ThreadLocal的内存泄漏

一般面试中问道 ThreadLocal 的话,最后都会问到有关 ThreadLocal 的内存泄漏问题,以及内存泄漏如何解决。下面我们就来详细探讨以下 ThreadLocal 的内存泄漏问题

1、ThreadLocal 会有内存泄漏吗?

内存泄漏产生的原因:长生命周期对象持有短生命周期的对象导致短生命周期对象无法被释放

我们知道 ThreadLocal 仅仅只是个管理类而已,真正的对象存储在 Thread 里。ThreadLocal 会被当作ThreadLocalMap 的 key,而 Thread 持有 ThreadLocalMap,进而间接持有 ThreadLocal,正常情况下这就可能有内存泄漏的风险(Thread长周期 ThreadLocal短周期)。

对此ThreadLocalMap对此做了预防——Entry的key使用了弱引用。ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 没有外部关联的强引用,那么在虚拟机进行垃圾回收时,这个 ThreadLocal 会被回收,这样,ThreadLocalMap 中就会出现 key 为 null 的 Entry,这些 key 对应的 value 也就再无妨访问,但是 value 却存在一条从 CurrentThread 过来的强引用链,该强引用链如下:

CurrentThread Ref -> Thread -> ThreadLocalMap -> Entry -> value

因此只有当 CurrentThread 销毁时,value才能得到释放,但是如果当前线程是一个服务器端线程,会长期对外提供服务,会长期存在,就会导致 value 一直不会被释放,产生内存泄漏

多线程/并发编程——面试再也不怕 ThreadLocal 了_第5张图片

2、内存泄漏的解决

既然 CurrentThread 会长期存在,而且会带来 value 的内存泄漏,那么 Java 是如何解决这个问题的呢?

在获取 key 对应的 value 时,会调用 ThreadLocalMap 的 getEntry(ThreadLocal key) 方法,该方法源码如下:

private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}

通过key.threadLocalHashCode & (table.length - 1)来计算存储key的Entry的索引位置,然后判断对应的key是否存在,若存在,则返回其对应的value,否则,调用getEntryAfterMiss(ThreadLocal, int, Entry)方法,源码如下:

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;

}

ThreadLocalMap采用线性探查的方式来处理哈希冲突,所以会有一个while循环去查找对应的key,在查找过程中,若发现key为null,即通过弱引用的key被回收了,会调用expungeStaleEntry(int)方法,其源码如下:

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

    // expunge entry at staleSlot
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;
     
    // Rehash until we encounter null
    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len);
            (e = tab[i]) != null;
            i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;
     
                // Unlike Knuth 6.4 Algorithm R, we must scan until
                // null because multiple entries could have been stale.
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;

}

通过上述代码可以发现,若key为null,则该方法通过下述代码来清理与key对应的value以及Entry:

// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;

此时,CurrentThread Ref不存在一条到Entry对象的强引用链,Entry到value对象也不存在强引用,那在程序运行期间,它们自然也就会被回收。expungeStaleEntry(int)方法的后续代码就是以线性探查的方式,调整后续Entry的位置,同时检查key的有效性。

在ThreadLocalMap中的set()/getEntry()方法中,都会调用expungeStaleEntry(int)方法,但是如果我们既不需要添加value,也不需要获取value,那还是有可能产生内存泄漏的。所以很多情况下需要使用者手动调用ThreadLocal的remove()函数,手动删除不再需要的ThreadLocal,防止内存泄露。若对应的key存在,remove()方法也会调用expungeStaleEntry(int)方法,来删除对应的Entry和value。

其实,最好的方式就是将ThreadLocal变量定义成private static的,这样的话ThreadLocal的生命周期就更长,由于一直存在ThreadLocal的强引用,所以ThreadLocal也就不会被回收,也就能保证任何时候都能根据ThreadLocal的弱引用访问到Entry的value值,然后remove它,可以防止内存泄露。


关联文章:

多线程—Java内存模型与线程

多线程——Volatile 关键字详解

多线程——线程安全及实现机制

多线程——深入剖析 Synchronized

多线程\并发编程——ReentrantLock 详解

多线程/并发编程——CAS、Unsafe及Atomic

多线程/并发编程——两万字详解AQS

多线程/并发编程——同步工具类(CountDownLatch、Semaphore、ReadWriteLock、CyclicBarrier )

你可能感兴趣的:(多线程与高并发,多线程,java,并发编程,面试,ThreadLocal)