ThreadLcoal为什么会内存泄漏

 2023-01-18
原文作者:克里斯朵夫李维 原文地址:https://juejin.cn/post/6921347272353513486

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();          
            }
        }

202301011642529861.png

可以看到,即使引用已经为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();
            }
        }

202301011642538602.png

移除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。

参考:

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