ThreadLcoal为什么会内存泄漏

902 阅读3分钟

ThreadLocal会造成内存泄漏的是一个很常见的面试题的,但是究竟为什么ThreadLocal会造成内存泄漏,网上的文章基本上就是说使用了弱引用导致的内存泄漏,但是为什么使用弱引用会导致内存泄漏那?

弱引用

弱引用是对一个对象(称为referent)的引用的持有者。使用弱引用后,可以维持对referent的引用,而不会阻止它被垃圾收集。当垃圾收集器跟踪堆的时候,如果对一个对象的引用只有弱引用,那么这个referent就会成为垃圾收集的候选对象,就像没有任何剩余的引用一样,而且所有剩余的弱引用都被清除。(只有弱引用的对象称为弱可及(weakly reachable)。

来看一个例子:

public class Car {

    private String name;

    public Car(String name) {
        this.name = name;
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("car gc");
    }

    public static void main(String[] args) {
        Car car = new Car("baoma");
        WeakReference<Car> carWeakReference = new WeakReference<Car>(car);
        System.out.println(car);
        System.out.println(carWeakReference.get());
        car = null;
        System.gc();
        System.out.println(carWeakReference.get());
    }
}

在上述例子中carCar对象(referent)的强引用,并且是GC Root。而carWeakReference是持有referent的引用car的弱引用。当car=null后,弱引用并不会阻止Car对象被GC回收。

com.xiaowei.Car@5cad8086
com.xiaowei.Car@5cad8086
null
car gc // Car对象已经被回收

全局Map导致内存泄漏

public class SocketManager {

    private Map<Socket, User> m = new HashMap<Socket, User>();

    public void setUser(Socket s, User u) {
        m.put(s, u);
    }
    public User getUser(Socket s) {
        return m.get(s);
    }
    public void removeUser(Socket s) {
        m.remove(s);
    }
    public boolean isEmpty() {
        return m.isEmpty();
    }
}

一个常见场景就是客户端的Socket需要和User进行绑定,在这种场景下我们期望的是Socket关闭后,相应的映射也从Map中删除,但是除非我们手动删除这个映射,否则会导致Socket和User对象永远无法被GC回收。

 public static void main(String[] args) {
        SocketManager socketManager = new SocketManager();
        int i = 0;
        while (i < 5000) {
            Socket s = new Socket();
            User u = new User();
            socketManager.setUser(s, u);
            s = null;
            u = null;
            System.gc();          
        }
    }

可以看到,即使引用已经为null,但是由于Map的Node的引用关系,导致Socket和User依旧无法被GC回收。

public static void main(String[] args) throws InterruptedException {
        SocketManager socketManager = new SocketManager();
        int i = 0;
        while (i < 5000) {
            Socket s = new Socket();
            User u = new User();
            socketManager.setUser(s, u);
            // 移除映射
            socketManager.removeUser(s);
            s = null;
            u = null;
            System.gc();
        }
    }

移除Map的映射后,正常进行GC。

通常情况下我们应该是HashMap,但是有没有一种方式能在Socket关闭之后自动删除Map中的映射那?答案是使用弱引用。

private static ReferenceQueue<Socket> rq = new ReferenceQueue<Socket>();

    public static void main(String[] args) throws InterruptedException {
        SocketManager socketManager = new SocketManager();
        Thread thread = new Thread(() -> {
            try {
                int cnt = 0;
                WeakReference<Socket> k;
                while ((k = (WeakReference) rq.remove()) != null) {
                    System.out.println((cnt++) + " 回收了:" + k);
                    // 反向操作
                    socketManager.removeUser(k);
                }
            } catch (InterruptedException e) {
                //结束循环
            }
        });

        thread.setDaemon(true);
        thread.start();

        Socket s = new Socket();
        User u = new User();
        WeakReference<Socket> weakReference = new WeakReference<Socket>(s, rq);
        socketManager.setUser(weakReference, u);
        // help gc
        s = null;
        System.gc();

        TimeUnit.SECONDS.sleep(1);

        System.out.println("map.size->" + socketManager.size());
    }

输出:

0 回收了:java.lang.ref.WeakReference@5fd0d5ae
map.size->0

使用WeakHashMap

WeakHashMap用弱引用承载映射键,这使得应用程序不再使用键对象时它们可以被垃圾收集,get()实现可以根据 WeakReference.get()是否返回null来区分死的映射和活的映射。但是这只是防止Map内存消耗在应用程序的生命周期中不断增加所需要做的工作的一半,还需要做一些工作以便在键对象被收集后从Map中删除死项。否则,Map会充满对应于死键的项。虽然这对于应用程序是不可见的,但是它仍然会造成应用程序耗尽内存,因为即使键被收集了,Map.Entry和值对象也不会被收集。

WeakHashMap有一个名为expungeStaleEntries()的私有方法,大多数Map操作中会调用它,它去掉引用队列中所有失效的引用,并删除关联的映射Map.Entry。

private Map<Socket, User> m = new WeakHashMap<Socket, User>();

public static void main(String[] args) throws InterruptedException {
        SocketManager socketManager = new SocketManager();
        Socket s = new Socket();
        User u = new User();
        socketManager.setUser(s, u);
        // help gc
        s = null;
        System.gc();
        TimeUnit.SECONDS.sleep(2);
        // 会调用expungeStaleEntries 删除Map.Entry
        System.out.println(socketManager.isEmpty());
    }

输出:

Socket GC
true

为什么ThreadLocal会内存泄漏

TheadLocal的原理是每个Thread内维护一个ThreadLocalMap,ThreadLocal对象是ThreadLocalMap的key。

static class ThreadLocalMap {

        /**
         *这个哈希映射中的Entry扩展了WeakReference,使用它的主ref字段作为键(它总是一个*ThreadLocal对象)。请注意,空键(即entry.get() == null)意味着键不再被引用,
         *所以条目可以从表中删除。在下面的代码中,这样的条目被称为“陈旧条目(stale entries)”。
         */
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

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

所以当ThreadLocal对象的强引用为null时,ThreadLocalMap.EntryThreadLocal对象会被GC,而value无法被GC。

参考:

[^]:用弱引用堵住内存泄漏