导致 JVM 内存泄露的 ThreadLocal 详解

为什么要有 ThreadLocal

我们首先来看看一段最纯粹的原生 JDBC 代码
在这里插入图片描述

package cn.wang.tl.jdbc;

import java.sql.*;

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

    /*Insert数据,Update,Delete用法与之类似*/
    private static int insert(String student) {
        Connection conn = getConn();
        int i = 0;
        String sql = "insert into students (Name,Sex,Age) 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 语句时临时去建立连接,而是会借
助数据库连接池,同时因为实际业务的复杂性,为了保证数据的一致性,我们还
会引入事务操作,于是上面的代码就会变成:
在这里插入图片描述

package cn.wang.tl.jdbc;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * 类说明:使用连接池和事务的JDBC
 */
public class UsePoolTran {

    /*数据库连接池*/
    private static DBPool dbPool = new DBPool(10);

    /*使用事务*/
    public void business(){
        /*获取连接*/
        Connection conn = null;
        try {
            conn = dbPool.fetchConnection(1000);
            conn.setAutoCommit(false);/*开启事务*/

            insert("13号");
            System.out.println("其他业务工作");
            insert("14号");

            conn.commit();/*提交*/
        } catch (Exception e) {
            try {
                /*发生异常时,回滚*/
                if(null!=conn) conn.rollback();
            } catch (SQLException e1) {
                e1.printStackTrace();
            }
            e.printStackTrace();
        }finally {
            try {
                conn.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }

    /*Insert数据,Update,Delete用法与之类似*/
    private static int insert(String student) {
        int i = 0;
        try {
            Connection conn = dbPool.fetchConnection(1000);
            String sql = "insert into students (Name,Sex,Age) values(?,?,?)";
            PreparedStatement pstmt;
            pstmt = (PreparedStatement) conn.prepareStatement(sql);
            pstmt.setString(1, student);
            i = pstmt.executeUpdate();
            pstmt.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return i;
    }

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

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

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

package cn.wang.tl.jdbc;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * 类说明:正确使用事务的JDBC
 */
public class UsePoolTranOK {

    private static DBPool dbPool = new DBPool(10);

    /*Insert数据,Update,Delete用法与之类似*/
    private static int insert(String student,Connection conn) {
        int i = 0;
        try {
            String sql = "insert into students (Name,Sex,Age) values(?,?,?)";
            PreparedStatement pstmt;
            pstmt = (PreparedStatement) conn.prepareStatement(sql);
            pstmt.setString(1, student);
            i = pstmt.executeUpdate();
            pstmt.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return i;
    }

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

    /*使用事务*/
    public void business(){
        /*获取连接*/
        Connection conn = null;
        try {
            conn = dbPool.fetchConnection(1000);
            conn.setAutoCommit(false);/*开启事务*/

            insert("13号",conn);
            System.out.println("其他业务工作");
            insert("14号",conn);

            conn.commit();/*提交*/
        } catch (Exception e) {
            try {
                if(null!=conn) conn.rollback();/*发生异常时,回滚*/
            } catch (SQLException e1) {
                e1.printStackTrace();
            }
            e.printStackTrace();
        }finally {
            try {
                conn.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }
}

但是我们分析平时我们使用 SSM 的代码会发现,我们在编写数据访问相关
代码的时候从来没有把数据库连接作为方法参数进行传递。这意味着,对 Spring
来说,在帮我们进行事务托管的时候,会遇到同样的问题,那么 Spring 是如何解
决这个问题的?

其实稍微分析下 Spring 的事务管理器的代码就能发现端倪,在
org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin 中,
我们会看到如下代码
在这里插入图片描述
上面的注释已经很清楚了说明“绑定连接到这个线程”,如何绑定的?继续
深入看看
在这里插入图片描述
看来,Spring 是使用一个 ThreadLocal 来实现“绑定连接到线程”的。现在我们可以对 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。

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。

实现解析

实现分析

怎么实现 ThreadLocal,既然说让每个线程都拥有自己变量的副本,最容易
的方式就是用一个 Map 将线程的副本存放起来,Map 里 key 就是每个线程的唯
一性标识,比如线程 ID,value 就是副本值,实现起来也很简单:

package cn.wang.tl;

import java.util.HashMap;
import java.util.Map;

/**
 * 类说明:自己实现的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。

ThreadLocalMap在Threadlocal里面定义的,但是是在Thread里面调用的
在这里插入图片描述
在这里插入图片描述## 具体实现
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
上面先取到当前线程,然后调用 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 的创建,初
始化等工作。

引发的内存泄漏分析

内存泄漏的现象

我们启用一个线程池,大小固定为 5 个线程

package cn.wang.tl;

import cn.wang.tools.SleepTools;

import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * @author james
 * 类说明:
 */
//vm: -Xmx256m
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");
    }

}

场景 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 后,引用链如图所示
在这里插入图片描述
图中的虚线表示弱引用。
这样,当把 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、Map value 将全部被 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导致线程不安全

package cn.wang.tl;

import cn.wang.tools.SleepTools;

import java.util.Random;

/**
 * @author 小王爷
 * 类说明:
 */
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 + "]";
        }
    }

}

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

package cn.wang.tl;

import cn.wang.tools.SleepTools;

import java.util.Random;

/**
 * @author Mark老师   享学课堂 https://enjoy.ke.qq.com
 * 类说明:
 */
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 + "]";
        }
    }

}

在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值