侧边栏壁纸
博主头像
孔子说JAVA博主等级

成功只是一只沦落在鸡窝里的鹰,成功永远属于自信且有毅力的人!

  • 累计撰写 352 篇文章
  • 累计创建 135 个标签
  • 累计收到 10 条评论

目 录CONTENT

文章目录

ThreadLocal的原理及使用场景实例

孔子说JAVA
2022-10-05 / 0 评论 / 0 点赞 / 72 阅读 / 9,944 字 / 正在检测是否收录...
广告 广告

ThreadLocal意为线程本地变量,用于解决多线程并发时访问共享变量的问题,所谓的共享变量指的是在堆中的实例、静态属性和数组。ThreadLocal类可以帮助我们实现线程的安全性,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

1、线程安全问题与ThreadLocal

ThreadLocal 对共享数据的访问受Java的内存模型(JMM)的控制,JMM模型如下:

image-1664761213926

从图中可以看到每个线程都有属于自己的本地内存,堆中(即上图的主内存)的变量在被线程使用的时候会被复制一个副本线程的本地内存中,当线程修改了共享变量之后就会通过 JMM 管理控制写回到主内存中。

在多线程的场景下,当有多个线程对共享变量进行修改的时候,就会出现线程安全问题,即数据不一致问题。常用的解决方法是对访问共享变量的代码加锁(synchronized 或 Lock)。但是这种方式对性能的耗费比较大。在JDK1.2中引入了ThreadLocal类,来修饰共享变量,使每个线程都单独拥有一份共享变量,这样就可以做到线程之间对于共享变量的隔离问题。

1.1 线程安全问题

用两个线程实现两个窗口售卖5张票。

class ThreadSaleTicket extends Thread{
    public int ticket = 5;

    @Override
    public void run() {
        while(ticket > 0){
            sale();
        }
    }
    public void sale(){
        System.out.println(Thread.currentThread().getName() + "售出第: " + (5-ticket+1) + " 张票");
        ticket--;
    }
}

public class threadSecurityTest{
    public static void main(String[] args) {
        ThreadSaleTicket salethread1 = new ThreadSaleTicket();
        Thread thread1 = new Thread(salethread1,"窗口1");
        Thread thread2 = new Thread(salethread1,"窗口2");
        thread1.start();
        thread2.start();
    }
}

image-1664762072769

1.2 安全问题解决方案

synchronized(锁) ThreadLocal
原理 同步机制采用了时间换空间的方式,只提供一份变量,让不同线程排队访问(临界区排队) 采用空间换时间的方式,为每一个线程都提供一份变量的副本,从而实现同时访问而互不相干扰
侧重点 多个线程之间访问资源的同步 多线程中让每个线程之间的数据相互隔离

加锁(synchronized 、Lock):加锁可以解决线程安全的问题,但因为排队处理,所以会带来一定的性能消耗。

ThreadLocal线程本地变量:既可以避免加锁排队执行,又不会每次执行任务都需要重新创建私有变量,每个线程都会创建一个私有变量(如1000个任务的时间格式化,通过10个线程池来执行,使用ThreadLocal就是创建 10个 SimpleDateFormat 对象)。

1.2.1 上锁

Java提供关键字synchronized和Lock接口来实现排它锁,在可能会出现线程安全的代码上加上锁,防止某一时刻有多个线程进入修改同一共享变量出现线程安全问题,sychronized以时间换空间的方式,主要侧重点在于解决多个线程之间访问资源的同步。

class ThreadSaleTicket extends Thread{
       public int ticket = 5;
       public Object oj = new Object();
        @Override
        public void run() {
            int ticket = 5;
                while(ticket > 0){
                    sale();
                }
        }
        public void sale(){
            synchronized (oj){
                if(ticket > 0){
                    System.out.println(Thread.currentThread().getName() + "售出第: " + (5-ticket+1) + " 张票");
                    ticket--;
                }
            }
        }
}
public class threadSecurityTest{
        public static void main(String[] args) {
            ThreadSaleTicket salethread1 = new ThreadSaleTicket();
            Thread thread1 = new Thread(salethread1,"窗口1");
            Thread thread2 = new Thread(salethread1,"窗口2");
            thread1.start();
            thread2.start();
        }
}

image-1664762451657

1.2.2 使用ThreadLocal

threadLocal为每个使用该变量的线程提供独立的副本,所以每个线程都可以独立的改变自己的副本,而不会影响其他线程所对应的的副本。它采用空间换时间的方式,让多线程之间每个线程之间的数据相互隔离。

ThreadLocal实现原理:

threadlocal类中有一个map,用于存储每一个线程的变量副本,map中元素的键为线程对象,而值对应线程的变量副本。

class ThreadSaleTicket extends Thread{
       public  int ticket = 5;
       public static ThreadLocal<Integer> value = new ThreadLocal<>(){};
        @Override
        public void run() {
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            while(ticket > 0){
                    value.set(ticket--);
                    System.out.println(Thread.currentThread().getName() + "售出第: " + (5-value.get()+1) + " 张票");
                }
        }
}

public class threadSecurityTest{
        public static void main(String[] args) {
            ThreadSaleTicket salethread1 = new ThreadSaleTicket();
            Thread thread1 = new Thread(salethread1,"窗口1");
            Thread thread2 = new Thread(salethread1,"窗口2");
            thread1.start();
            thread2.start();
        }
}

image-1664762440340

1.3 选择ThreadLocal还是锁

需要看创建实例对象之后的复用率,复用率高就使用ThreadLocal。如:实现1000个任务的时间格式化。

  • ThreadLocal的使用不是为了能让多个线程共同使用某一对象,而是我有一个线程A,其中我需要用到某个对象o,这个对象o在这个线程A之内会被多处调用,而我不希望将这个对象o当作参数在多个方法之间传递,于是,我将这个对象o放到TheadLocal中,这样,在这个线程A之内的任何地方,只要线程A之中的方法不修改这个对象o,我都能取到同样的这个变量o。

2、ThreadLocal使用方式及场景

2.1 使用方式

一般都会将ThreadLocal声明成一个静态字段,同时初始化如下:

static ThreadLocal<Object> threadLocal = new ThreadLocal<>();

其中Object就是原本堆中共享变量的数据。例如,有个User对象需要在不同线程之间进行隔离访问,可以定义ThreadLocal如下:

public class Test {
    static ThreadLocal<User> threadLocal = new ThreadLocal<>();
}

2.2 常用方法

  • set(T value):设置线程本地变量的内容。
  • get():获取线程本地变量的内容。
  • remove():移除线程本地变量。注意在线程池的线程复用场景中在线程执行完毕时一定要调用remove,避免在线程被重新放入线程池中时被本地变量的旧状态仍然被保存。
public class Test {
    static ThreadLocal<User> threadLocal = new ThreadLocal<>();
    
    public void set(User user) {
        threadLocal.set(user);
    }
    
    public void get() {
        User user = threadLocal.get();
    }
    
    // 使用完清除
    public static void clear() {
        threadLocal.remove();
    }
}

2.3 使用场景

  1. 解决线程不安全的问题。

依赖于ThreadLocal本身的特性,对于需要进行线程隔离的变量可以使用ThreadLocal进行封装。例如在Spring的@Transaction事务声明的注解中就使用ThreadLocal保存了当前的Connection对象,避免在本次调用的不同方法中使用不同的Connection对象。

数据库连接对象获取示例:

/**
 * 数据库连接助手
 */
public class ConnectionUtils {
    private static ThreadLocal<DBSession> threadLocal = new ThreadLocal<>();

    /**
     * 获取 DBSession 连接对象
     *
     * @return
     * @throws Exception
     */
    public static DBSession getConection() throws Exception {
        DBSession conn = threadLocal.get();
        if (conn == null) {
            conn = Context.getDBSession();
            threadLocal.set(conn);
        }
        return conn;
    }

    /**
     * 使用完毕后需要清除掉ThreadLocal,避免内存泄露
     */
    public static void clear() {
        threadLocal.remove();
    }
}

使用完ThreadLocal,最好手动调用 remove() 方法,防止出现内存溢出。

  1. 线程级别的数据传送(可以实现一定程度的解耦,在重入方法中替代参数的显式传递)

假如在我们的业务方法中需要调用其他方法,同时其他方法都需要用到同一个对象时,可以使用ThreadLocal替代参数的传递或者static静态全局变量。这是因为使用参数传递造成代码的耦合度高,使用静态全局变量在多线程环境下不安全。当该对象用ThreadLocal包装过后,就可以保证在该线程中独此一份,同时和其他线程隔离。使用方式同上例。

  1. 全局存储用户信息

在现在的系统设计中,前后端分离已基本成为常态,分离之后如何获取用户信息就成了一件麻烦事,通常在用户登录后, 用户信息会保存在Session或者Token中。这个时候,我们如果使用常规的手段去获取用户信息会很费劲,拿Session来说,我们要在接口参数中加上HttpServletRequest对象,然后调用 getSession方法,且每一个需要用户信息的接口都要加上这个参数,才能获取Session,这样实现就很麻烦了。

在实际的系统设计中,我们肯定不会采用上面所说的这种方式,而是使用ThreadLocal,我们会选择在拦截器的业务中, 获取到保存的用户信息,然后存入ThreadLocal,那么当前线程在任何地方如果需要拿到用户信息都可以使用ThreadLocal的get()方法 (异步程序中ThreadLocal是不可靠的)

当用户登录后,会将用户信息存入Token中返回前端,当用户调用需要授权的接口时,需要在header中携带 Token,然后拦截器中解析Token,获取用户信息,调用自定义的类(AuthNHolder)存入ThreadLocal中,当请求结束的时候,将ThreadLocal存储数据清空, 中间的过程无需在关注如何获取用户信息,只需要使用工具类的get方法即可。

public class AuthNHolder {
	private static final ThreadLocal<Map<String,String>> loginThreadLocal = new ThreadLocal<Map<String,String>>();

	public static void map(Map<String,String> map){
		loginThreadLocal.set(map);
	}
	public static String userId(){
    		return get("userId");
	}
	public static String get(String key){
    		Map<String,String> map = loginThreadLocal.get();
    		return map.get(key);
    }
	public static void clear(){
       loginThreadLocal.remove();
	}
	
}

2.4 慎用场景

  1. 线程池中线程使用 ThreadLocal: 由于线程池中对线程管理都是采用线程复用的方法。在线程池中线程非常难结束甚至于永远不会结束。这将意味着线程持续的时间将不可预測,甚至与JVM的生命周期一致。

  2. 异步程序中,ThreadLocal的參数传递是不靠谱的,由于线程将请求发送后。就不再等待远程返回结果继续向下运行了,真正的返回结果得到后,处理的线程可能是其他的线程。Java8中的并发流也要考虑这种情况。

  3. 使用完ThreadLocal,最好手动调用 remove() 方法,防止出现内存溢出,因为其中使用的 key 为ThreadLocal的弱引用, 如果ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候会被清理掉的,但是如果value是强引用,不会被清理, 这样一来就会出现 key 为 null 的 value。

3、ThreadLocal的原理及底层实现

image-1664765640571

3.1 ThreadLocal设计

在JDK早期的设计中,每个ThreadLocal都有一个map对象,将线程作为map对象的key,要存储的变量作为map的value,但是现在已经不是这样了。JDK8之后,每个Thread维护一个ThreadLocalMap对象,这个Map的key是ThreadLocal实例本身,value是存储的值要隔离的变量,是泛型,其具体过程如下:

  • 每个Thread线程内部都有一个Map(ThreadLocalMap::threadlocals);
  • Map里面存储ThreadLocal对象(key)和线程的变量副本(value);
  • Thread内部的Map由ThreadLocal维护,由ThreadLocal负责向map获取和设置变量值;
  • 对于不同的线程,每次获取副本值时,别的线程不能获取当前线程的副本值,就形成了数据之间的隔离。

JDK8之后设计的好处在于:

  • 每个Map存储的Entry的数量变少,在实际开发过程中,ThreadLocal的数量往往要少于Thread的数量,Entry的数量减少就可以减少哈希冲突。
  • 当Thread销毁的时候,ThreadLocalMap也会随之销毁,减少内存使用,早期的ThreadLocal并不会自动销毁。

3.2 实现原理

ThreadLocal的底层是由ThreadLocalMap实现的。
ThreadLocal的底层使用斐波那契数(黄金分割数) 实现hash算法。

如何实现在每个线程里面保存一份单独的本地变量呢? 在Java中线程是就是一个Thread类的实例对象,而一个实例对象中实例成员字段的内容肯定是这个对象独有的,所以可以将保存线程本地变量的ThreadLocal作为一个Thread类的成员字段,这个成员字段如下:

/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

是一个在ThreadLocal中定义的Map对象,保存了该线程中的所有本地变量。ThreadLocalMap中的Entry的定义如下:

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;
    // key为一个ThreadLocal对象,v就是我们要在线程之间隔离的对象
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

ThreadLocalMap和Entry都在ThreadLocal中定义。

set方法源码:

public void set(T value) {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 获取当前线程的threadLocals字段
    ThreadLocalMap map = getMap(t);
    // 判断线程的threadLocals是否初始化了
    if (map != null) {
        map.set(this, value);
    } else {
        // 没有则创建一个ThreadLocalMap对象进行初始化
        createMap(t, value);
    }
}

createMap方法源码:

void createMap(Thread t, T firstValue) {
	t.threadLocals = new ThreadLocalMap(this, firstValue);
}

map.set方法源码:

/**
 * 往map中设置ThreadLocal的关联关系
 * set中没有使用像get方法中的快速选择的方法,因为在set中创建新条目和替换旧条目的内容一样常见,
 * 在替换的情况下快速路径通常会失败(对官方注释的翻译)
*/
private void set(ThreadLocal<?> key, Object value) {
    // map中就是使用Entry[]数据保留所有的entry实例
    Entry[] tab = table;
    int len = tab.length;
    // 返回下一个哈希码,哈希码的产生过程与神奇的0x61c88647的数字有关
    int i = key.threadLocalHashCode & (len-1);

    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
        if (k == key) {
            // 已经存在则替换旧值
            e.value = value;
            return;
        }
        if (k == null) {
            // 在设置期间清理哈希表为空的内容,保持哈希表的性质
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    tab[i] = new Entry(key, value);
    int sz = ++size;
    // 扩容逻辑
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

Thread::get方法的原理

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 获取ThreadLocal对应保留在Map中的Entry对象
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            // 获取ThreadLocal对象对应的值
            T result = (T)e.value;
            return result;
        }
    }
    // map还没有初始化时创建map对象,并设置null,同时返回null
    return setInitialValue();
}

ThreadLocal::remove()方法原理

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    // 键在直接移除
    if (m != null) {
        m.remove(this);
    }
}

3.3 ThreadLocal内存泄露问题

内存泄露问题是指程序中动态分配的堆内存由于某种原因没有被释放或者无法释放,造成系统内存的浪费,导致程序运行速度减慢或者系统奔溃等严重后果。内存泄露堆积将会导致内存溢出。

ThreadLocal的内存泄露问题一般考虑和Entry对象有关,在上面的Entry定义可以看出ThreadLocal::Entry被弱引用所修饰。JVM会将弱引用修饰的对象在下次垃圾回收中清除掉。 这样就可以实现ThreadLocal的生命周期和线程的生命周期解绑。但实际上并不是使用了弱引用就A会发生内存泄露问题,考虑下面几个过程:

1)使用强引用

image-1664766278651

当ThreadLocal Ref被回收了,由于在Entry使用的是强引用,在Current Thread还存在的情况下就存在着到达Entry的引用链,无法清除掉ThreadLocal的内容,同时Entry的value也同样会被保留;也就是说就算使用了强引用仍然会出现内存泄露问题。

2)使用弱引用

image-1664766305331

当ThreadLocal Ref被回收了,由于在Entry使用的是弱引用,因此在下次垃圾回收的时候就会将ThreadLocal对象清除,这个时候Entry中的KEY=null。但是由于ThreadLocalMap中任然存在Current Thread Ref这个强引用,因此Entry中value的值任然无法清除。还是存在内存泄露的问题。

由此可以发现,使用ThreadLocal造成内存泄露的问题是因为:ThreadLocalMap的生命周期与Thread一致,如果不手动清除掉Entry对象的话就可能会造成内存泄露问题。因此,需要我们在每次在使用完之后需要手动的remove掉Entry对象。

为什么使用弱引用?

避免内存泄露的两种方式:使用完ThreadLocal,调用其remove方法删除对应的Entry或者使用完ThreadLocal,当前Thread也随之运行结束。第二种方法在使用线程池技术时是不可以实现的。所以一般都是自己手动调用remove方法,调用remove方法弱引用和强引用都不会产生内存泄露问题,使用弱引用的原因如下:

  • 在ThreadLocalMap的set/getEntry中,会对key进行判断,如果key为null,那么value也会被设置为null,这样即使在忘记调用了remove方法,当ThreadLocal被销毁时,对应value的内容也会被清空。多一层保障!

总结:存在内存泄露的有两个地方:ThreadLocal和Entry中Value;最保险还是要注意要自己及时调用remove方法!!!

4、总结

  1. ThreadLocal更像是对其他类型变量的一层包装,通过ThreadLocal的包装使得该变量可以在线程之间隔离和当前线程全局共享。

  2. 线程的隔离性和变量的线程全局共享性得益于在每个Thread类中的threadlocals字段。(从类实例对象的角度抽象的去看Java中的线程!!!)

  3. ThreadLocalMap中Entry的Key不管是否使用弱引用都有内存泄露的可能。引起内存泄露主要在于ThreadLocal对象和Entry中的Value对象,因此要确保每次使用完之后都remove掉Entry!

0

评论区