“水能载舟,亦能覆舟”,形容ThreadLocal最为贴切,因为它的初衷是在线程并发时,解决变量共享的问题,但是由于过度设计,比如弱引用、哈希碰撞,导致理解难度大,使用成本高,反而成为故障高发点,容易出现内存泄漏,脏数据、共享对象更新等问题。也许你认为它包治共享变量的问题,然而并不是。下面从内存模型,弱引用,哈希算法角度分析:
讨论ThreadLocal用在什么地方前,我们先明确下,如果仅仅就一个线程,那么都不用谈ThreadLocal的,ThreadLocal是用在多线程的场景的!!!
1. 理解 ThreadLocal
ThreadLocal的作用是提供线程内的局部变量,这种变量在多线程环境下访问时能够保证各个线程里变量的独立性。
一般使用ThreadLocal,官方建议我们定义为private static ,至于为什么要定义成静态的,这和内存泄露有关,后面再讲。
1.1 基本使用
public class DemoThreadLocal {
private static ThreadLocal<String> threadLocal = new ThreadLocal<String>() {
@Override
protected String initialValue() {
return "hello world";
}
};
private static ThreadLocal<User> threadLocalUser = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(new MyRunnable(1)).start();
User user = new User();
user.setName("Java半颗糖");
user.setAge(25);
threadLocalUser.set(user); // 保存值
System.out.println(threadLocalUser.get()); // 获取值
}
static class MyRunnable implements Runnable {
private int num;
public MyRunnable(int num) {
this.num = num;
}
@Override
public void run() {
threadLocal.set(String.valueOf(num));
System.out.println("threadLocalValue:" + threadLocal.get());
//手动移除
threadLocal.remove();
}
}
@Data
static class User {
String name;
Integer age;
}
}
输出结果
threadLocalValue:1
DemoThreadLocal.User(name=Java半颗糖, age=25)
1.2 引用类型
对象在堆上创建之后所持有的引用其实是一种变量类型,引用之间通过赋值可以构成引用链。从GC Root开始遍历,判断引用是否可达,引用的可达性是判断能否被垃圾回收的基本条件。根据引用类型语义的强弱可以决定垃圾回收的阶段。
- 强引用(Strong Reference):
Object object=new Object();这样的变量声明和定义就会产生对该对象的强引用。只要对象有强引用指向,并且GC Root可达,那么Java内存回收时,即使内存濒临耗尽,也不会回收该对象。 - 软引用(Soft Reference): 引用力弱与强引用,用在非必须对象的场景。在即将OOM之前,垃圾回收器会把这些引用指向的对象加入回收范围,以获得更多的内存空间,让程序能够继续健康运行。主要用来缓存服务器中间计算结果及不需要实时保存的用户行为等。
- 弱引用(Weak Reference): 引用强度较前两者更弱,也是用来描述非必须对象的。 如果弱引用指向的对象只存在弱引用这一条线路,则在下一次 Y GC (年轻代GC)时会被回收。由于YGC的时间不确定性,弱引用何时被回收也具有不确定性。弱引用主要用于指向某个易消失的对象,在强引用断开后,此引用不会劫持对象,调用weakReference.get() 会返回空。
- 虚引用(phantom Reference) : 定义完成后,就无法通过该引用获取指向的对象,为对象设置虚引用的唯一目的,就是希望能在这个对象被回收时,收到一个系统通知。
注意 虚引用与软引用和弱引用的一个区别在于虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中
1.3 内存泄漏问题
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
从上面源码可以看出,ThreadLocalMap使用ThreadLocal的弱引用作为Entry的key,如果一个ThreadLocal没有外部强引用来引用它,下一次系统GC时,这个ThreadLocal必然会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value。
ThreadLocal中 get、set、remove等方法,都会对key为null的Entry进行清除(expungeStaleEntry 方法,将Entry的value清空,等下一次垃圾回收时,这些Entry将会被彻底回收)。 但是如果当前线程一直在运行,并且一直不执行get、set、remove方法,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreadLocalMap -> Entry -> value,导致这些key为null的Entry的value永远无法回收,造成内存泄漏。
如何避免内存泄漏
- 为了避免这种情况,我们可以在使用完ThreadLocal后,手动调用remove方法,以避免出现内存泄漏。
- 官方建议把ThreadLocal定义为
private static,理由如下图,
2. ThreadLocal原理
Thread、ThreadLocalMap、ThreadLocal总览图:
每一个Thread类有属性变量threadLocals (类型是ThreadLocal.ThreadLocalMap)的this引用,也就是说每个线程有一个自己的ThreadLocalMap ,所以每个线程往这个ThreadLocal中读写隔离的,并且是互相不会影响的。该变量的类型是hashMap,其中key为我们定义的ThreadLocal 变量的this引用,value是我们使用set() 方法设置的值.
每个线程的本地变量存放在线程自己的内存变量threallocals 中,如果当前线程一直不消亡,那么这些本地变量会一直存在,从而可能导致内存溢出,因此使用完后,需要调用ThreadLocal的remove()方法删除对应线程的 threallocals中的本地变量。
2.1 ThreadLocalMap 定义
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
ThreadLocalMap是一个自定义哈希映射,仅用于维护线程本地变量值。ThreadLocalMap是ThreadLocal的内部类,主要有一个Entry对象数组,Entry对象的key为ThreadLocal,value为ThreadLocal对应的值。每个线程都有一个ThreadLocalMap类型的threadLocals变量。
2.2 set() 方法
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); // 获取当前线程的ThreadLocalMap
// 当前线程的ThreadLocalMap不为空则调用set方法, this为调用该方法的ThreadLocal对象
if (map != null)
map.set(this, value);
// map为空则调用createMap方法创建一个新的ThreadLocalMap, 并新建一个Entry放入该
// ThreadLocalMap, 调用set方法的ThreadLocal和传入的value作为该Entry的key和value
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals; // 返回线程t的threadLocals属性
}
- 先拿到当前线程,再使用getMap方法拿到当前线程的threadLocals变量
- 如果threadLocals不为空,则将当前ThreadLocal作为key,传入的值作为value,调用set方法(见下文代码块1详解)插入threadLocals。
- 如果threadLocals为空则调用创建一个ThreadLocalMap,并新建一个Entry放入该ThreadLocalMap, 调用set方法的ThreadLocal和传入的value作为该Entry的key和value 注意此处的threadLocals变量是一个ThreadLocalMap,是Thread的一个局部变量,因此它只与当前线程绑定。
2.3 get() 方法
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
// 调用getEntry方法, 通过this(调用get()方法的ThreadLocal)获取对应的Entry
ThreadLocalMap.Entry e = map.getEntry(this);
// Entry不为空则代表找到目标Entry, 返回该Entry的value值
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 该线程的ThreadLocalMap为空,或者没有找到目标Entry,则调用setInitialValue方法
return setInitialValue();
}
- 跟set方法差不多,先拿到当前的线程,再使用getMap方法拿到当前线程的threadLocals变量
- 如果threadLocals不为空,则将调用get方法的ThreadLocal作为key,调用getEntry方法(见下文代码块5详解)找到对应的Entry。
- 如果threadLocals为空或者找不到目标Entry,则调用setInitialValue方法(见下文代码块4详解)进行初始化。
2.4 remove() 方法
public void remove() {
// 获取当前线程的ThreadLocalMap
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
// 调用此方法的ThreadLocal作为入参,调用remove方法
m.remove(this);
}
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
// 根据hashCode计算出当前ThreadLocal的索引位置
int i = key.threadLocalHashCode & (len-1);
// 从位置i开始遍历,直到Entry为null
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) { // 如果找到key相同的
e.clear(); // 则调用clear方法, 该方法会把key的引用清空
expungeStaleEntry(i);//调用expungeStaleEntry方法清除key为null的Entry
return;
}
}
}
方法很简单,拿到当前线程的threadLocals属性,如果不为空,则将key为当前ThreadLocal的键值对移除,并且会调用expungeStaleEntry方法清除key为空的Entry。
3. ThreadLocal 使用不当导致内存泄漏
3.1 为何会出现内存泄漏
ThreadLocalMap 的Entry中key使用的是对ThreadLocal对象的弱引用,这在避免内存泄漏方面是一个进步,因为如果是强引用,即使其他地方没有对ThreadLocal对象的引用,ThreadLocalMap中的ThreadLocal对象还是不会被回收,而如果是弱引用则ThreadLocal引用会在GC的时候回收掉。
但是对应的Value不能被回收,这时候ThreadLocalMap里面就会存在key为null但是value不为null的entry项,虽然ThreadLocalMap提供了set、get、remove方法,可以在一些时机下对这些key进行清理,但是这是不及时的,也不是每次都会执行,所以一些情况下还是会发生内存泄漏。
因此,使用完毕后及时调用remove方法才是解决内存泄漏的好办法。
3.2 在线程池中使用ThreadLocal导致内存泄漏
public class ThreadPoolTest {
static class LocalVariable {
private Long[] a = new Long[1024*1024];
}
// (1)
final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES,
new LinkedBlockingQueue<>());
// (2)
final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<LocalVariable>();
public static void main(String[] args) throws InterruptedException {
// (3)
for (int i = 0; i < 50; ++i) {
poolExecutor.execute(new Runnable() {
public void run() {
// (4)
localVariable.set(new LocalVariable());
// (5)
System.out.println("use local varaible");
// 暂时不 remove 进行测试
//localVariable.remove();
}
});
Thread.sleep(1000);
}
// (6)
System.out.println("pool execute over");
}
- 代码(1)创建了一个核心线程数和最大线程数为5的线程池,这个保证了线程池里面随时都有5个线程在运行。
- 代码(2)创建了一个ThreadLocal的变量,泛型参数为LocalVariable,LocalVariable内部是一个Long数组。
- 代码(3)向线程池里面放入50个任务
- 代码(4)设置当前线程的localVariable变量,也就是把new的LocalVariable变量放入当前线程的threadLocals变量。
由于没有调用线程池的shutdown或者shutdownNow方法所以线程池里面的用户线程不会退出,进而JVM进程也不会退出。
运行代码中,在设置线程的localVariable变量后没有调用localVariable.remove() 方法,导致线程池里面的5个线程的threadLocals变量里面的new LocalVariable()实例没有被释放,虽然线程池里面的任务执行完毕了,但是线程池里面的5个线程会一直存在直到JVM退出。这里需要注意的是由于localVariable被声明了static,虽然线程的ThreadLocalMap里面是对localVariable的弱引用,localVariable也不会被回收。运行结果二的代码由于线程在设置localVariable变量后即使调用了localVariable.remove()方法进行了清理,所以不会存在内存泄露。
如果在线程池里面设置了ThreadLocal变量,则一定要及时清理,因为线程池里面的核心线程数一般是一直存在的,如果不及时清理,线程池的核心线程的threadlocals变量会一直持有ThrealLocal变量。
3.3 Tomcat的Servlet中使用ThreadLocal导致内存泄漏
public class HelloWorldExample extends HttpServlet {
private static final long serialVersionUID = 1L;
static class LocalVariable {
private Long[] a = new Long[1024 * 1024 * 100];
}
//(1)
final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<LocalVariable>();
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
//(2)
localVariable.set(new LocalVariable());
response.setContentType("text/html");
PrintWriter out = response.getWriter();
out.println("<html>");
out.println("<head>");
out.println("<title>" + "title" + "</title>");
out.println("</head>");
out.println("<body bgcolor=\"white\">");
//(3)
out.println(this.toString());
//(4)
out.println(Thread.currentThread().toString());
out.println("</body>");
out.println("</html>");
}
}
- 代码(1)创建一个localVariable对象,
- 代码(2)在servlet的doGet方法内设置localVariable值
- 代码(3)打印当前servlet的实例
- 代码(4)打印当前线程
修改tomcat的conf下sever.xml配置如下:
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-"
maxThreads="10" minSpareThreads="5"/>
<Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
Tomcat中Connector组件负责接受并处理请求,其中Socket acceptor thread 负责接受用户的访问请求,然后把接受到的请求交给Worker threads pool线程池进行具体处理,后者就是我们在server.xml里面配置的线程池。Worker threads pool里面的线程则负责把具体请求分发到具体的应用的servlet上进行处理。
有了上述知识,下面启动tomcat访问该servlet多次,会发现有可能输出下面结果
HelloWorldExample@2a10b2d2 Thread[catalina-exec-5,5,main]
HelloWorldExample@2a10b2d2 Thread[catalina-exec-1,5,main]
HelloWorldExample@2a10b2d2 Thread[catalina-exec-4,5,main]
其中前半部分是打印的servlet实例,这里都一样说明多次访问的都是一个servlet实例,后半部分中catalina-exec-5,catalina-exec-1,catalina-exec-4,说明使用了connector中线程池里面的线程5,线程1,线程4来执行serlvet的。
如果在访问该servlet的同时打开了jconsole观察堆内存会发现内存会飙升,究其原因是因为工作线程调用servlet的doGet方法时候,工作线程的threadLocals变量里面被添加了new LocalVariable()实例,但是没有被remove,另外多次访问该servlet可能用的不是工作线程池里面的同一个线程,这会导致工作线程池里面多个线程都会存在内存泄露。
4. ThreadLocal 拓展
4.1 ThreadLocal 不支持继承性
为了解决这个问题,InheritableThreadLocal 应用而生。InheritableThreadLocal 继承自ThreadLocal ,并提供了一个特性,就是让子线程可以访问在父线程中设置的本地变量。
5. 总结
- 每个线程都有一个ThreadLocalMap 类型的 threadLocals 属性。
- ThreadLocalMap 类相当于一个Map,key 是 ThreadLocal 本身,value 就是我们的Thread 值
- 当我们通过 threadLocal.set(new Integer(123)); ,我们就会在这个线程中的 threadLocals 属性中放入一个键值对
- key 是 这个 threadLocal.set(new Integer(123))的threadlocal
- value 就是值new Integer(123)。
- 当我们通过 threadlocal.get() 方法的时候,首先会根据这个线程得到这个线程的 threadLocals 属性,然后由于这个属性放的是键值对,我们就可以根据键 threadlocal 拿到值。 注意,这时候这个键 threadlocal 和 我们 set 方法的时候的那个键 threadlocal 是一样的,所以我们能够拿到相同的值。
- ThreadLocalMap 的get/set/remove方法跟HashMap的内部实现都基本一样,通过 "key.threadLocalHashCode & (table.length - 1)" 运算式计算得到我们想要找的索引位置,如果该索引位置的键值对不是我们要找的,则通过nextIndex方法计算下一个索引位置,直到找到目标键值对或者为空。
- hash冲突:在HashMap中相同索引位置的元素以链表形式保存在同一个索引位置;而在ThreadLocalMap中,没有使用链表的数据结构,而是将(当前的索引位置+1)对length取模的结果作为相同索引元素的位置:源码中的nextIndex方法,可以表达成如下公式:如果i为当前索引位置,则下一个索引位置 = (i + 1 < len) ? i + 1 : 0。
参考