Java并发编程之导致JVM内存泄露的ThreadLocal详解

1. 对ThreadLocal的理解

1.1. 为什么要有 ThreadLocal

我们首先来看一段最原生 JDBC 代码

public class UseJdbc {
    /**获得数据库连接*/
    private static Connection getConn() {
        String driver = "com.mysql.jdbc.Driver";
        String url = "jdbc:mysql://localhost:3306/dbtest1";
        String username = "root";
        String password = "123";
        Connection conn = null;
        try {
            Class.forName(driver); //classLoader,加载对应驱动
            conn = (Connection) DriverManager.getConnection(url, username, password);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return conn;
    }

    /**insert数据*/
    private static int insert(String student) {
        Connection conn = getConn();
        int i = 0;
        String sql = "insert into students (name,age,sex) values(?,?,?)";
        PreparedStatement pstmt;
        try {
            pstmt = (PreparedStatement) conn.prepareStatement(sql);
            pstmt.setString(1, student);
            i = pstmt.executeUpdate();
            pstmt.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
        return i;
    }

    /**查询数据*/
    private static Integer getAll() {
        Connection conn = getConn();
        String sql = "select * from students";
        PreparedStatement pstmt;
        try {
            pstmt = (PreparedStatement)conn.prepareStatement(sql);
            ResultSet rs = pstmt.executeQuery();
            while (rs.next()) {
                /*读取数据做业务处理*/
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        return null;
    }

}

可以看到, 上面在使用 JDBC 时, 我们首先要配置并创建好后再拿到 JDBC 连接, 然后在增删改查的业务方法中拿到这个连接,并把我们的 SQL 语句交给 JDBC 连接,发送到真实的 DB 上执行。

在实际的工作中, 我们不会在每次执行 SQL 语句的时候,才临时去建立连接,而是会借助数据库连接池。 同时因为实际业务的复杂性, 为了保证数据的一致性, 我们还会引入事务操作,于是上面的代码就会变成:

但是上面的代码又包含什么样的问题呢?分析代码我们可以发现, 执行业务方法 business 时, 为了启用事务, 我们从数据库连接池中拿了一个连接, 但是在具体的 insert 方法和 getAll 方法中, 在执行具体的 SQL 语句时, 我们从数据库连接池中又拿一个连接, 这就是说执行事务和执行 SQL 语句完全是不同的数据库连接, 这 会导致什么问题?事务失效了!数据库执行事务时,事务的开启和提交、语句 的执行等都是必须在一个连接中的。实际上,上面的代码要保证数据的一致性, 就必须要启用分布式事务。

怎么解决这个问题呢? 有一个解决思路是,把数据库连接作为方法的参数, 在方法之间进行传递,比如下面这样:

现在可以对 ThreadLocal 下一个比较确切的定义了,源码上面的注释:

This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable. 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).

这个类提供线程局部变量。这些变量与普通变量的不同之处在于,访问一个变量的每个线程(通过它的get或set方法)都有它自己的独立初始化的变量副本。 ThreadLocal实例通常是类中的私有静态字段,希望将状态与线程(例如,用户ID或事务ID)相关联。

也就是说 ThreadLocal 为每个线程都提供了变量的副本,使得每个线程在某一时间访问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。由此也可以看出 ThreadLocal 和 Synchonized 都用于解决多线程并发访问。可是 ThreadLocal 与 synchronized 有本质的差别。synchronized 是利用锁的机制, 使变量或代码块在某一时该仅仅能被一个线程访问, ThreadLocal 则是副本机制。此时不论多少线程并发访问都是线程安全的。

ThreadLocal 的一大应用场景就是跨方法进行参数传递,比如 Web 容器中, 每个完整的请求周期会由一个线程来处理。 结合 ThreadLocal 再使用 Spring 里的 IOC 和 AOP,就可以很好的解决我们上面的事务的问题。只要将一个数据库连接放入ThreadLocal 中,当前线程执行时只要有使用数据库连接的地方就从ThreadLocal 获得就行了。

再比如,在微服务领域, 链路跟踪中的 traceId 传递也是利用了 ThreadLocal。

1.2. 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。

当调用get()方法的时候,若是与当前线程关联的ThreadLocal值已经被设置过,则不会调用initialValue()方法;否则,会调用initialValue()方法来进行初始值的设置。通常initialValue()方法只会被调用一次,除非调用了remove()方法之后又调用get()方法,此时,与当前线程关联的ThreadLocal值处于没有设置过的状态(其状态体现在源码中,就是线程的ThreadLocalMap对象是否为null),initialValue()方法仍会被调用。

initialValue()方法是protected类型的,很显然是建议在子类重载该函数的,所以通常该方法都会以匿名内部类的形式被重载,以指定初始值,例如:

我们先通过一个例子来看一下ThreadLocal的基本用法:

public class UseThreadLocal {

    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    /**
     * 运行3个线程
     */
    public void StartThread(){
        for(int i=0;i<3;i++){
            new TestThread(i).start();
        }
    }
    
  
    public static class TestThread extends Thread{
        int id;
        public TestThread(int id){
            this.id = id;
        }
        public void run() {
            String threadName = Thread.currentThread().getName();
            //给每个线程都设置自己独有的String类型编号
            threadLocal.set("线程_"+id);
            System.out.println(threadName+":"+threadLocal.get());
        }
    }

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

​​​​​在实际使用的时候,ThreadLocal一般是会定义成静态的。

3. 深入解析ThreadLocal类

3.1. 实现分析

怎么实现 ThreadLocal?
  既然说让每个线程都拥有自己变量的副本,最简单的方式就是用一个 Map 将线程的副本存放起来, Map 里 key 就是每个线程的唯 一性标识,比如线程 ID ,value 就是副本值, 实现起来也很简单:
/**
 * 类说明:自己实现的ThreadLocal
 */
public class MyThreadLocal<T> {
    /*存放变量副本的map容器,以Thread为键,变量副本为value*/
    private Map<Thread,T> threadTMap = new HashMap<>();

    public synchronized T get(){
        return  threadTMap.get(Thread.currentThread());
    }

    public synchronized void set(T t){
        threadTMap.put(Thread.currentThread(),t);
    }

}
上述方式,在功能实现方便没有太大问题。考虑到并发安全性, 对数据的存取用 synchronize 关键字加锁, 但是存在性能问题, DougLee 在《并发编程实战》中为我们做过性能测试 :

  可以看到 ThreadLocal 的性能远超类似 synchronize 的锁实现 ReentrantLock, 比我们后面要学的AtomicInteger 也要快很多,即使我们把 Map 的实现更换为Java 中专为并发设计的 ConcurrentHashMap 也不太可能达到这么高的性能。

怎么样设计可以让 ThreadLocal 达到这么高的性能呢?最好的办法则是让变量副本跟随着线程本身, 而不是将变量副本放在一个地方保存, 这样就可以在存取时避开线程之间的竞争。

同时,因为每个线程所拥有的变量的副本数是不定的, 有些线程可能有一个, 有些线程可能有 2 个甚至更多, 那么线程内部存放变量副本就需要一个容器, 而且容器要支持快速存取, 所以在每个线程内部都可以持有一个 Map 来支持多个变量副本,这个 Map 被称为 ThreadLocalMap。

3.2.  具体实现

  ThreadLocal使用ThreadLocalMap实现了线程中存放变量副本。ThreadLocalMap的定义是在ThreadLocal类中,是一个声明在ThreadLocal中的静态内部类。但是,使用是在Thread类中,Thread 类中有一个定义了一个ThreadLocalMap类型的成员变量。

ThreadLocalMap怎么实现?

  首先,我们回想下HashMap的实现方式,在JDK1.7,数据结构由数组+链表组成,在HashMap底层有个数组,这个数组中的每个元素是一个Entry,这个Entry是<key,value>的形式。当我们执行HashMap的put()操作的时候,通过put(key,value)中的key计算出当前要添加进去的这个元素(Entry)应该放到数组的哪个位置(通过键的hashCode值去定位当前元素应该放到数组的哪个下标位置)。但有可能出现多个元素放在同一个下标位置,这个时候就出现了hash冲突,于是就引入了链表,把发生hash冲突的元素链式的挂在同一个下标位置下面。当发生hash冲突时,把发生hash冲突的元素依次往链表后面追加。而在JDK1.8,当这个链表长度过长的时候,就会把它由链表变为红黑树,这个就是HashMap的实现。

  再看ThreadLocalMap,我们看ThreadLocal 的内部类 ThreadLocalMap 源码,这里其实是个标准的 Map 实现,ThreadLocalMap内部有一个类型为 Entry 的数组, 用以存放线程需要的多个副本变量。对于数组中的每一个Entry(元素)而言,key是ThreadLocal对象,value是每个线程所拥有的变量副本。

ThreadLocal的set()和get()方法

1. 先看set方法的实现

 

  对于ThreadLocal而言,当调用其set方法的时候,在set方法中会先获取到当前线程实例,并调用 getMap 方法获取当前线程中的ThreadLocalMap变量, 然后将ThreadLocal对象作为key,将当前线程所需的变量副本作为value,设置到ThreadLocalMap中。在往ThreadLocalMap中设置的时候,会根据ThreadLocal对象的hashCode值,到自己内部的Entry[]数组中去定位,应该放到数组的哪个位置,然后将ThreadLocal对象作为键,线程所需的变量副本作为值,以<key,value>键值作为一个Entry,添加到数组中。

2. 再看get方法的实现

  当调用get方法的时候,会先取得当前线程,并通过getMap(t)方法获取到一个map,map的类型为ThreadLocalMap。然后接着下面获取到<key,value>键值对,注意这里获取键值对传进去的是 this,而不是当前线程t。this是当前ThreadLocal对象,会根据ThreadLocal对象的hashCode,去ThreadLocalMap内部的Entry[]数组中查找,最终得到一个Entry,这个Entry的key就是当前ThreadLocal对象,value就是线程所需的变量副本。

 总结

  首先,在每个线程Thread内部有一个ThreadLocal.ThreadLocalMap类型的成员变量threadLocals,这个threadLocals就是用来存储实际的变量副本的,键值为ThreadLocal对象,value为变量副本(即T类型的变量)。初始时,在Thread里面,threadLocals为空,当通过ThreadLocal对象调用get()方法或者set()方法,就会对Thread类中的threadLocals进行初始化,并且以当前ThreadLocal对象为键值,以ThreadLocal要保存的副本变量为value,存到threadLocals。然后在当前线程里面,如果要使用副本变量,就可以通过get方法在threadLocals里面查找。

3.3. Hash 冲突的解决

哈希表的特点:关键字和它在表中存储位置之间存在一种函数关系。这个函数我们称为为哈希函数。

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

ThreadLocalMap和HashMap对于hash冲突的解决,也是不一样的。ThreadLocalMap中用的是开放定址法。

什么是hash冲突?

  在哈希表中,不同的关键字值对应到同一个存储位置的现象。即两个不同对象的HashCode相同,这种现象称为hash冲突。

我们已经知道 Hash 属于压缩映射,一定能会产生多个实际值映射为一个 Hash 值的情况, 这就产生了冲突,常见解决哈希冲突的办法:

1. 开放定址法

2. 再哈希

3. 链地址法

4. 创建公共溢出区

开放定址法:

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

ThreadLocal 里用的则是线性探测再散列

链地址法:

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

再哈希法:

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

建立公共溢出区:

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

4. 引发的内存泄漏分析

什么是内存泄露?

本来应该被回收的内存没有被回收就叫做内存泄露

3.1. 复习

引用

  Object obj = new Object();new Object()我们可以称之为在内存中产生了一个对象实例,而这个 obj ,指向着Object对象实例,我们可以称之为对象引用。

当写下 obj = null 时,只是表示 obj 不再指向堆中 Object 的对象实例, 不代表这个对象实例不存在了。

强引用就是指在程序代码之中普遍存在的, 类似“Object obj=new Object()” 这类的引用, 只
要强引用还存在, 垃圾收集器永远不会回收掉被引用的对象实例。也就是说当垃圾收集器工作时,堆上的任何一个对象实例有一个强引用指着它的时候,这个对象是不会被垃圾回收器回收的。

软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象, 在系统将要发生内存溢出异常之前, 将会把这些对象实例列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存, 才会抛出内存溢出异常。在 JDK 1.2 之后,提供了 Soft Reference 类来实现软引用。

弱引用也是用来描述非必需对象的, 但是它的强度比软引用更弱一些,被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在 JDK 1.2 之 后,提供了WeakReference 类来实现弱引用。

虚引用也称为幽灵引用或者幻影引用, 它是最弱的一种引用关系。一个对象实例是否有虚引用的存在, 完全不会对其生存时间构成影响, 也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象实例被收集器回收时收到一个系统通知。在 JDK 1.2 之后,提供了PhantomReference 类来实现虚引用。

3.2. 内存泄露的现象

  将堆内存大小设置为-Xmx256m,执行 ThreadLocalMemoryLeak中的程序并分析内存泄露产生的原因。在ThreadLocalMemoryLeak中会用到一个线程池,大小固定为 5 个线程。

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

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

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

    //成员变量,非静态的(这里如果改为static修饰,就不会发生内存泄露)
    ThreadLocal<LocalVariable> threadLocalLV;

    public static void main(String[] args) throws InterruptedException {
        SleepTools.ms(4000);
        //往线程池里提交500个任务
        for (int i = 0; i < TASK_LOOP_SIZE; ++i) {
            executor.execute(new Runnable() {
                public void run() {
                    SleepTools.ms(200);
                    LocalVariable localVariable = new LocalVariable();

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

                    oom.threadLocalLV.remove();

                    System.out.println("执行线程run方法......");

                }
            });

            SleepTools.ms(100);
        }
        System.out.println("主线程执行完毕......");
    }

}

场景 1

  线程任务中不执行任何有意义的代码, 当所有的任务提交执行完成后,可以看见,我们这个应用的内存占用基本上为 25M 左右。

通过 jvisualvm 来观察JVM内存

场景 2
 
  在场景1的基础上,在每个任务中 new 出一个LocalVariable对象,LocalVariable中有一个5M大小的数组 , 执行完成后我们可以看见,内存占用基本和场景 1 相同。

GC 成功回收

场景3

 在场景2的基础上启用 ThreadLocal,当启用了 ThreadLocal 以后,执行完成后我们可以看见,内存占用变为了100 M

GC没有成功回收
场景4
  在场景3的基础上,我们加入一行代码,再执行,看看内存情况

可以看见,内存占用基本和场景 1 相同。 这就充分说明,在场景 3 中,当我们启用了 ThreadLocal 以后确实发生了内存泄漏。

3.3. 分析

  根据前面对 ThreadLocal 的分析,我们可以知道每个 Thread 维护一个 ThreadLocalMap,这个映射表的 key 是 ThreadLocal 实例本身, value 是真正需要存储的 Object,也就是说 ThreadLocal 本身并不存储值, 它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。仔细观察ThreadLocalMap,这个 map 中的Entry是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。
因此使用了 ThreadLocal 后,引用链如图所示,图中的虚线表示弱引用。

补充知识:Java栈内存以帧的形式存放本地方法的调用状态(包括方法调用的参数、局部变量、中间结果等)。每调用一个方法就将对应该方法的方法帧压入 Java 栈,成为当前方法帧。当调用结束(返回)时,就弹出该帧。也就是说:在方法中定义的一些基本类型的变量和对象的引用变量都在方法的栈内存中分配。对于基本数据类型是保存在执行栈的栈帧中的,一个方法开始结束对应一次栈帧的入栈出栈,所以是立即回收的。但是如果是非基本数据类型,栈帧保存的只是指向这个对象的地址,方法结束只会回收这个地址指向,但是对象实例还是存在的,只不过指向它的引用少了一个。真正对象的回收依赖的就是JAVA的垃圾回收机制了,如果不再有引用指向它,那么,GC才会处理它。

对于上述场景 3,当run()执行完后,run()方法所组成的栈帧就要出栈了,run()方法中的局部变量也会被回收,所以threadLocalLV的对象引用会被回收。这个时候只有个弱引用指向着ThreadLocal对象实例,引用链如下图所示

  当threadLocalLV对象引用会被回收以后,没有任何强引用指向 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 、Map value 将全部被 GC 回收。最好的做法是不在需要使用ThreadLocal 变量后,调用它的 remove()方法,清除数据。回到我们前面分析的几个场景, 在场景 3 中,虽然线程池里面的任务执行完毕了,但是线程池里面的 5 个线程会一直存在直到 JVM 退出,我们通过 threadLocalLV 给每个线程设置了变量副本localVariable 后,没有调用 threadLocalLV.remove()方法,导致线程池里面的 5 个 线程的 threadLocals 变量里面的 LocalVariable 对象实例没有被释放。

  其实分析ThreadLocal 的实现,我们可以看见,无论是 get() 、set()在某些时候,调用了 expungeStaleEntry 方法用来清除 Entry 中 Key 为 null 的 Value ,但是,这是不及时的,也不是每次都会执行的,所以一些情况下还是会发生内存泄露。 只有 remove()方法中显式调用了 expungeStaleEntry 方法。

这样来看内存泄漏的根源在于使用了弱引用, 但是另一个问题也同样值得思考:为什么使用弱引用而不是强引用?下面我们分两种情况讨论:

key 使用强引用: 对 ThreadLocal 对象实例的引用被置为 null 了,但是ThreadLocalMap 还持有这个 ThreadLocal 对象实例的强引用, 如果没有手动删除, ThreadLocal 的对象实例不会被回收,导致 Entry 内存泄漏。如下图,当run()方法执行完毕后,threadLocalLV对象引用会被回收。但是在ThreadLocal 的get()方法中,需要通过threadLocalLV对象引用,获取Entry。由于threadLocalLV对象引用被回收了,所以,访问不到Entry,而Entry没有被回收,所以会导致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 可能造成累积的情况。

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

为什么每个线程都输出 115?难道他们没有独自保存自己的 Number 副本吗? 为什么其他线程还是能够修改这个值?仔细考察 ThreadLocal 和 Thead 的代码,我们发现 ThreadLocalMap 中保存的其实是对象的一个引用,这样的话,当有其他线程对这个引用指向的对象实例做修改时, 其实也同时影响了所有的线程持有的对象引用所指向的同一个对象实例。这也就是为什么上面的程序为什么会输出 一样的结果。

而上面的程序要正常的工作,应该的用法是让每个线程中的 ThreadLocal 都应该持有一个新的 Number 对象。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值