ThreadLocal源码分析-java8

1.特性分析

  • 类功能
    • 提供线程本地变量。
    • 减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度
    • 为线程提供一个私有的变量副本,这样每一个线程都可以随意修改自己的变量副本,而不会对其他线程产生影响。
  • 与类中其它普通变量的区别
    • 普通的变量可以通过线程自身的get和set方法进行访问。
    • 本地变量是独立初始化的变量de副本。
  • ThreadLocal实例声明规则
    • 定义为private static类型变量
    • 和线程相关的状态(比如用户ID或者事务ID).
  • 一个线程都会对线程本地变量持有一个明确引用de时间
    • 线程处于alive状态
    • ThreadLocal实例可以访问
      note:线程消失后,它的本地变量实例的副本就成为GC的对象(除非有其它引用指向这些副本)
  • 线程和ThreadLocal的关系
    • 一个线程可以有多个ThreadLocal对象,线程使用ThreadLocal类对象的内部类ThreadLocalMap对其所有的ThreadLocal变量进行存储。
    • 线程查找自己的某一个ThreadLocal对象时,使用Hash映射实现。
  • 如何保证两个同时实例化的ThreadLocal对象有不同的threadLocalHashCode属性
    • 三个static变量保证
      • private static AtomicInteger nextHashCode =
        new AtomicInteger();
      • private static final int HASH_INCREMENT = 0x61c88647;
      • private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
        }
      • nextHashCode是类变量,所以每一次调用ThreadLocal类都可以保证nextHashCode被更新到新的值,并且下一次调用ThreadLocal类这个被更新的值仍然可用
      • AtomicInteger保证了nextHashCode自增的原子性。
  • ThreadLocal内存泄漏问题
    • ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用引用它,那么系统gc的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链(如图红色箭头部分),永远无法回收,造成内存泄露。
    • 解决措施
      • 调用ThreadLocalMap的getEntry函数或者set函数
      • 使用者手动调用ThreadLocal的remove函数,手动删除不再需要的ThreadLocal,防止内存泄露
      • 将ThreadLocal变量定义成private static的,这样的话ThreadLocal的生命周期就更长,由于一直存在ThreadLocal的强引用,所以ThreadLocal也就不会被回收,也就能保证任何时候都能根据ThreadLocal的弱引用访问到Entry的value值,然后remove它,防止内存泄露
        这里写图片描述
    • ThreadLocal在一个线程中的初始化时间
      • 初始化值为null的时间
        • 第一次调用get()方法时
      • 可以初始化非null的时间
        • 方法public static ThreadLocal withInitial(Supplier supplier)
        • 调用set()方法

2.方法、变量分析

  • threadLocalHashCode 说明
    • 一种自定义hash值(只在ThreadLocalMaps内有效)
    • 消除了常见情况下由相同线程使用连续构造的ThreadLocals的冲突,同时在不太常见的情况下也能保持良好的性能。
  • 初始化值为null
    如果编程人员期望本地变量有一个初始值而非null,则ThreadLocal必须被子类化,并且initialValue()这一方法被覆写 。
  • public T get()
    • 功能:返回当前线程对此本地变量的副本的值
    • 如果当前线程没有此变量的值,它会首先被初始化为由initialValue方法调用的值,默认为null。
    • 方法执行过程
      这里写图片描述
  • public void set(T value)
    • 功能:将当前线程本地变量的副本设定为指定的值
    • 大多数子类都没有必要覆写此方法,只是依赖于initialValue()方法对线程本地变量赋值即可。
    • 方法执行过程
      这里写图片描述
  • ThreadLocalMap类
    • static class 类
    • ThreadLocalMap是一个定制的哈希映射,只适用于维护线程本地值。
    • 没有操作被导出ThreadLocal类.此类是包级私有的,允许在Thread类中声明的变量
    • entry定义
      • key:ThreadLocal
      • value:ThreadLocal对应的value引用
    • hash表的entry使用了弱引用类型的key
      目的:为了辅助处理大且生命长的使用。
    • 由于并未使用引用队列,只有当entry耗尽table的内存空间时才删除旧的条目.
    • null值的key含义
      此key不再被引用,也就意味着此entry可以从table中删除啦.这样的条目被称为“过时条目”。
    • 初始化容量16
    • 可以扩容的table,2倍扩容
    • 负载因子:2/3
    • 删除旧entry,解决了弱引用导致的内存溢出问题涉及的方法
      • private Entry getEntry(ThreadLocal key)
        删除多个旧entry
      • private void set(ThreadLocal key, Object value)
        删除一个及以上旧entry
      • private boolean cleanSomeSlots(int i, int n)
      • private void rehash()

3.源码分析

    package sourcecode.analysis;

    /**
     * @Author: cxh
     * @CreateTime: 18/5/14 17:26
     * @ProjectName: JavaBaseTest
     */
    import java.lang.*;
    import java.lang.ref.*;
    import java.util.Objects;
    import java.util.concurrent.atomic.AtomicInteger;
    import java.util.function.Supplier;

    /**
     * 该类提供线程本地变量.这些变量和线程中其它普通的变量不同,因为那些普通的变量可以通过线程自身的get和set方法进行访问.
     * 而这些变量是独立初始化的变量副本.
     * ThreadLocal实例通常是类中的private static类型变量,且和线程相关的状态(比如用户ID或者事务ID).
     *
     * 比如,下面的类会为每一个线程生成一个唯一标识符.
     * 在首次调用ThreadId.get()方法时会生成线程ID,且在后续的调用中会保持不变.
     *
     * import java.util.concurrent.atomic.AtomicInteger;
     *
     * public class ThreadId {
     *     // Atomic integer containing the next thread ID to be assigned
     *     private static final AtomicInteger nextId = new AtomicInteger(0);
     *
     *     // Thread local variable containing each thread's ID
     *     private static final ThreadLocal<Integer> threadId =
     *         new ThreadLocal<Integer>() {
     *             @Override protected Integer initialValue() {
     *                 return nextId.getAndIncrement();
     *         }
     *     };
     *
     *     // Returns the current thread's unique ID, assigning it if necessary
     *     public static int get() {
     *         return threadId.get();
     *     }
     * }
     *
     * 只要线程还活着且ThreadLocal实例可以访问,则每一个线程都会对线程本地变量的副本持有一个明确引用.
     * 线程消失后,它的本地变量实例的副本就成为GC的对象(除非有其它引用指向这些副本).
     *
     * @author  Josh Bloch and Doug Lea
     * @since   1.2
     */
    public class ThreadLocal<T> {
        /**
         * ThreadLocal依赖于每个线程的线性探测hash映射.ThreadLocal和key一样,需要通过threadLocalHashCode进行查找.
         * 这是一种自定义hash值(只在ThreadLocalMaps内有效),它消除了常见情况下由相同线程使用连续构造的ThreadLocals的冲突,
         * 同时在不太常见的情况下也能保持良好的性能。
         */
        private final int threadLocalHashCode = nextHashCode();

        //给出的下一个hash值;自动更新;开始值为0.
        private static AtomicInteger nextHashCode =
                new AtomicInteger();

        //连续生成的哈希码之间的差异-将隐式顺序本地线程ID转化为在大小为2的整数次幂table中几近完美的hash映射的值.
        private static final int HASH_INCREMENT = 0x61c88647;

        //返回下一个hash值
        private static int nextHashCode() {
            return nextHashCode.getAndAdd(HASH_INCREMENT);
        }

        /**
         * 返回当前线程本地变量的"初始值".
         * 这一方法会在线程第一次通过get()方法访问变量时触发,除非线程之前已经调用过set()方法,这种情况下本方法不会被触发.
         * 通常,此方法每个线程最多触发一次,但是如果get(),remove()两个方法相继被调用,则此方法可能会在remove()后继续被触发一次.
         *
         * 这种实现简单地返回null;如果编程人员期望本地变量有一个初始值而非null,则ThreadLocal必须被子类化,并且这一方法被覆写.
         * 通常,可以使用匿名内部类(感觉这句话应该是jdk8之前写的,从jdk8开始可以使用lambda表达式替代匿名内部类了)
         */
        protected T initialValue() {
            return null;
        }

        /**
         * 使用本地变量创建一个线程.变量初始化值由Supplier函数接口在调用get方法时决定.
         * @since 1.8
         */
        public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
            return new SuppliedThreadLocal<>(supplier);
        }

        //创建一个本地变量,无参实例构造器函数
        public ThreadLocal() {
        }

        //返回当前线程对此本地变量的副本的值.如果当前线程没有此变量的值,它会首先被初始化为由initialValue方法调用的值.
        public T get() {
            java.lang.Thread t = java.lang.Thread.currentThread();
            ThreadLocalMap map = getMap(t);
            if (map != null) {
                ThreadLocalMap.Entry e = map.getEntry(this);
                if (e != null) {
                    @SuppressWarnings("unchecked")
                    T result = (T)e.value;
                    return result;
                }
            }
            return setInitialValue();
        }

        //变量的set()方法用于创建初始值.并未使用set()名字的方法是为了防止用户覆写set()方法.
        private T setInitialValue() {
            T value = initialValue();
            java.lang.Thread t = java.lang.Thread.currentThread();
            ThreadLocalMap map = getMap(t);
            if (map != null)
                map.set(this, value);
            else
                createMap(t, value);
            return value;
        }

       //将当前线程本地变量的副本设定为指定的值.大多数子类都没有必要覆写此方法,只是依赖于initialValue()方法对线程本地变量赋值即可.
        public void set(T value) {
            java.lang.Thread t = java.lang.Thread.currentThread();
            ThreadLocalMap map = getMap(t);
            if (map != null)
                map.set(this, value);
            else
                createMap(t, value);
        }

        /**
         * 删除当前线程本地变量的值.如果本地变量随后会被当前线程读取,则它的值会通过initialValue重新初始化.这会带来在当前线程中多次调用
         * initialValue()方法
         * @since 1.5
         */
        public void remove() {
            ThreadLocalMap m = getMap(java.lang.Thread.currentThread());
            if (m != null)
                m.remove(this);
        }

        //获取和本地变量有关的map.在InheritableThreadLocal已经覆写了此方法
        ThreadLocalMap getMap(java.lang.Thread t) {
            return t.threadLocals;//threadLocals类型:ThreadLocal.ThreadLocalMap
        }

        //创建一个和ThreadLocal相关的map.在InheritableThreadLocal里面进行了覆写
        void createMap(java.lang.Thread t, T firstValue) {
            t.threadLocals = new ThreadLocalMap(this, firstValue);
        }

        //工厂方法:用于创建继承类型的线程变量的map.
        //本方法被设计目的:只是由Thread实例构造器函数进行调用.
        static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
            return new ThreadLocalMap(parentMap);
        }

        /**
         * childValue()方法在子类InheritableThreadLocal中是定义可见的.
         * 此处被定义为内部方法的原因是:提供了createInheritedMap的工厂方法但却没必要对InheritableThreadLocal中的map类进行子类化.
         * 这种方法比在方法中嵌入测试实例更为可取。
         */
        T childValue(T parentValue) {
            throw new UnsupportedOperationException();
        }

        //ThreadLocal的扩展类,它通过指定的函数接口supplier获取其初始化值
        static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {

            private final Supplier<? extends T> supplier;

            SuppliedThreadLocal(Supplier<? extends T> supplier) {
                this.supplier = Objects.requireNonNull(supplier);
            }

            @Override
            protected T initialValue() {
                return supplier.get();
            }
        }

        /**
         * ThreadLocalMap是一个定制的哈希映射,只适用于维护线程本地值。没有操作被导出ThreadLocal类.此类是包级私有的,允许在Thread类中声明
         * 变量.为了辅助处理大且生命长的使用,hash表的entry使用了弱引用类型的key.然而,由于并未使用引用队列,只有当entry耗尽table的内存空间时才
         * 删除旧的条目.
         */
        static class ThreadLocalMap {

            /**
             * 此hashmap中的entry通过使用主引用字段作为key扩展了弱引用.
             * 注意:null值的key表示此key不再被引用,也就意味着此entry可以从table中删除啦.
             * 这样的条目在下面的代码中被称为“过时条目”。
             */
            static class Entry extends WeakReference<ThreadLocal<?>> {
                /** The value associated with this ThreadLocal. */
                Object value;

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

            //初始化容量16--必须是2的整数次幂
            private static final int INITIAL_CAPACITY = 16;

            //可以扩容的table.其长度必须为2的整数次幂
            private Entry[] table;

            //table中的条目数
            private int size = 0;

            //扩容时下一次的size大小,默认为0
            private int threshold; // Default to 0

            //设定扩容时的阈值以维持最差2/3的负载因子
            private void setThreshold(int len) {
                threshold = len * 2 / 3;
            }

            //返回当前下标i的下一个下标值,如果i==len-1,则从头开始,返回0
            private static int nextIndex(int i, int len) {
                return ((i + 1 < len) ? i + 1 : 0);
            }

            //返回当前下标的前一个下标值,如果i==0,则从尾部继续开始,返回len-1.
            private static int prevIndex(int i, int len) {
                return ((i - 1 >= 0) ? i - 1 : len - 1);
            }

            /**
             * 构建一个新的map,初始化包含(firstkey,firstvalue).
             * ThreadLocalMaps是延迟构造的,因此当我们需要将entry放入map时才创建此map.
             */
            ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
                table = new Entry[INITIAL_CAPACITY];
                int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
                table[i] = new Entry(firstKey, firstValue);
                size = 1;
                setThreshold(INITIAL_CAPACITY);
            }

            //创建一个新的map,它包括了从给定父map继承的线程本地变量.此方法只被createInheritedMap()调用.
            private ThreadLocalMap(ThreadLocalMap parentMap) {
                Entry[] parentTable = parentMap.table;
                int len = parentTable.length;
                setThreshold(len);
                table = new Entry[len];

                for (int j = 0; j < len; j++) {
                    Entry e = parentTable[j];
                    if (e != null) {
                        @SuppressWarnings("unchecked")
                        ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
                        if (key != null) {
                            Object value = key.childValue(e.value);
                            Entry c = new Entry(key, value);
                            int h = key.threadLocalHashCode & (len - 1);
                            while (table[h] != null)
                                h = nextIndex(h, len);
                            table[h] = c;
                            size++;
                        }
                    }
                }
            }

            /**
             * 获取和key相关的entry.此方法本身只处理fast path型映射:现有key的直接映射.否则会将处理逻辑转到getEntryAfterMiss()方法.
             * 这一设计被用于最大限度提升直接命中的性能.
             */
            private Entry getEntry(ThreadLocal<?> key) {
                int i = key.threadLocalHashCode & (table.length - 1);
                Entry e = table[i];
                //直接映射可以找到,则返回
                if (e != null && e.get() == key)
                    return e;
                //直接映射找不到,则调用其它方法的逻辑.
                else
                    return getEntryAfterMiss(key, i, e);
            }

            /**
             * getEntry()方法的另一版本,用于在key直接映射找不到entry时
             *
             * @param  key 线程本地对象
             * @param  i key的哈希值在table中的索引.
             * @param  e 在table[i]中的条目
             * @return 和key相关的entry,如果没有则返回null
             */
            private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
                //获取当前table
                Entry[] tab = table;
                //获取table长度
                int len = tab.length;

                //如果查找entry不为null
                while (e != null) {
                    ThreadLocal<?> k = e.get();
                    //如果key相等,则返回结果
                    if (k == key)
                        return e;
                    //如果key为null,则删除旧entry,解决了弱引用导致的内存溢出问题
                    if (k == null)
                        expungeStaleEntry(i);
                    //获取下一个索引i
                    else
                        i = nextIndex(i, len);
                    e = tab[i];
                }
                return null;
            }

            //设置和key相关的值
            private void set(ThreadLocal<?> key, Object value) {

                //此处,我们并未使用和get()方法一样的fast path方式,因为创建一个新的entry和替换一个已存在的entry基本相同,
                //在这种情况下,fail path的失败次数要比不失败的次数更多.
                //获取table
                Entry[] tab = table;
                //获取表长
                int len = tab.length;
                //获取key对应的索引位置
                int i = key.threadLocalHashCode & (len-1);

                //从当前索引开始查找
                for (Entry e = tab[i];e != null;e = tab[i = nextIndex(i, len)]) {
                    //获取当前本地变量
                    ThreadLocal<?> k = e.get();
                    //如果本地变量被找到,则更新值后返回.
                    if (k == key) {
                        e.value = value;
                        return;
                    }
                    //如果当前本地变量为null,则将此key和 value保存到table[i]中.
                    if (k == null) {
                        replaceStaleEntry(key, value, i);
                        return;
                    }
                }

                tab[i] = new Entry(key, value);
                int sz = ++size;
                if (!cleanSomeSlots(i, sz) && sz >= threshold)
                    rehash();
            }

            //手动删除指定key的entry
            private void remove(ThreadLocal<?> key) {
                Entry[] tab = table;
                int len = tab.length;
                int i = key.threadLocalHashCode & (len-1);
                for (Entry e = tab[i];
                     e != null;
                     e = tab[i = nextIndex(i, len)]) {
                    if (e.get() == key) {
                        e.clear();
                        expungeStaleEntry(i);
                        return;
                    }
                }
            }

            /**
             * 在使用指定的key进行set操作期间,如果key已经存在则用此方法进行替换操作.
             * 通过参数传入的value会保存到entry中,无论指定key的entry是否已经存在.
             *
             * 本方法的副作用就是:此方法会删除包含旧条目的"run"中所有的条目(run的定义:table中两个空槽位之间的所有entry序列)
             *
             * @param  staleSlot 查找key时遇到的第一个旧entry索引
             */
            private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                           int staleSlot) {
                Entry[] tab = table;
                int len = tab.length;
                Entry e;

                // 备份以检查当前运行中的原始过期条目。
                // 一次清理范围包括整个允许阶段,这样做的目的是为了避免由于GC释放大量的引用而出现持续增长的rehash操作.
                int slotToExpunge = staleSlot;
                for (int i = prevIndex(staleSlot, len);
                     (e = tab[i]) != null;
                     i = prevIndex(i, len))
                    if (e.get() == null)
                        slotToExpunge = i;

                // Find either the key or trailing null slot of run, whichever
                // occurs first
                for (int i = nextIndex(staleSlot, len);
                     (e = tab[i]) != null;
                     i = nextIndex(i, len)) {
                    ThreadLocal<?> k = e.get();
                    //如果我们找到key,则我们需要将它和旧条目进行替换以维持hash表的顺序.
                    //新的旧槽位,或者其它任何旧的槽位如果遇到上述情况,则将被发送到expungeStaleEntry()方法而移除or
                    //重新rehash本次运行中所有的其它entry.
                    if (k == key) {
                        e.value = value;

                        tab[i] = tab[staleSlot];
                        tab[staleSlot] = e;
                        // Start expunge at preceding stale entry if it exists
                        if (slotToExpunge == staleSlot)
                            slotToExpunge = i;
                        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                        return;
                    }
                    //如果我们在向后扫描中没有找到过时的条目,在扫描key时看到的第一个旧条目是运行中第一个仍然存在的条目。
                    if (k == null && slotToExpunge == staleSlot)
                        slotToExpunge = i;
                }
                //如果找不到此key对应的entry,那就在旧的槽位中放入一个新的entry
                tab[staleSlot].value = null;
                tab[staleSlot] = new Entry(key, value);

                //如果运行中有其他过时的条目,则删除它们。(这就是注释中说到的副作用)
                if (slotToExpunge != staleSlot)
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            }

            /**
             * 通过重置在旧槽位和下一个null槽位之间且可能发生冲突的entry来删除一个旧的槽位.
             * 且同时删除了在null之前的所有旧entry.
             */
            private int expungeStaleEntry(int staleSlot) {
                Entry[] tab = table;
                int len = tab.length;

                // expunge entry at staleSlot
                tab[staleSlot].value = null;
                tab[staleSlot] = null;
                size--;

                // Rehash until we encounter null
                Entry e;
                int i;
                for (i = nextIndex(staleSlot, len);
                     (e = tab[i]) != null;
                     i = nextIndex(i, len)) {
                    ThreadLocal<?> k = e.get();
                    if (k == null) {
                        e.value = null;
                        tab[i] = null;
                        size--;
                    } else {
                        int h = k.threadLocalHashCode & (len - 1);
                        if (h != i) {
                            tab[i] = null;

                            // Unlike Knuth 6.4 Algorithm R, we must scan until
                            // null because multiple entries could have been stale.
                            while (tab[h] != null)
                                h = nextIndex(h, len);
                            tab[h] = e;
                        }
                    }
                }
                return i;
            }

            /**
             * 启发式扫描一些槽位寻找旧的条目。当添加新元素或已删除另一个元素时此方法被调用.
             * 本方法执行扫描的次数的数量级是对数级的,这是在无扫描(快速但保留垃圾)和与元素数量成正比的扫描次数之间的平衡.
             * 这将发现所有的垃圾,但会导致一些插入操作占用O(n)的时间。
             *
             * @param i 索引为i的位置一定不包含旧条目.所以扫描操作从索引为i+1开始.
             *
             * @param n 扫描控制: log2(n)数量级的槽位会被扫描, 除非找到一个旧条目,在这种情况下,log2(table.length)-1个数的槽位
             *          被扫描.当插入操作调用此方法时,此参数是元素的个数;但如果从replaceStaleEntry()方法调用时,此参数为表长.
             *          (注意:所有这些操作都会通过加权n而非logn进行或多或少的贪心方向的改变,但此版本的方法依旧简单,迅速,性能良好.)
             *
             * @return 如果删除了旧的entry则返回true
             */
            private boolean cleanSomeSlots(int i, int n) {
                boolean removed = false;
                Entry[] tab = table;
                int len = tab.length;
                do {
                    i = nextIndex(i, len);
                    Entry e = tab[i];
                    if (e != null && e.get() == null) {
                        n = len;
                        removed = true;
                        i = expungeStaleEntry(i);
                    }
                } while ( (n >>>= 1) != 0);
                return removed;
            }

            //重新包装和/或重新调整表的大小。首先扫描整个表,删除过时的条目。如果这不能充分缩小表的大小,那么就要2被扩容表。
            private void rehash() {
                expungeStaleEntries();

                //使用较低阈值进行2倍扩容以避免滞后现象
                if (size >= threshold - threshold / 4)
                    resize();
            }

            //2倍扩容table的容量
            private void resize() {
                Entry[] oldTab = table;
                int oldLen = oldTab.length;//获取旧table长度
                int newLen = oldLen * 2;//2倍长度(新table长度)
                Entry[] newTab = new Entry[newLen];//声明新数组空间
                int count = 0;

                for (int j = 0; j < oldLen; ++j) {
                    Entry e = oldTab[j];
                    if (e != null) {
                        ThreadLocal<?> k = e.get();
                        if (k == null) {
                            e.value = null; // Help the GC
                        } else {
                            int h = k.threadLocalHashCode & (newLen - 1);
                            while (newTab[h] != null) //索引冲突时,重新生成一个新索引
                                h = nextIndex(h, newLen);
                            newTab[h] = e;
                            count++;
                        }
                    }
                }
                //根据表的新长度,设定新阈值
                setThreshold(newLen);
                size = count;//size为table中元素个数
                table = newTab;//原table引用指向新table
            }

            //删除table中所有的旧entry
            private void expungeStaleEntries() {
                Entry[] tab = table;
                int len = tab.length;
                for (int j = 0; j < len; j++) {
                    Entry e = tab[j];
                    if (e != null && e.get() == null)
                        expungeStaleEntry(j);
                }
            }
        }
    }

参考:
https://www.cnblogs.com/xzwblog/p/7227509.html
http://www.cnblogs.com/dolphin0520/p/3920407.html
https://www.cnblogs.com/coshaho/p/5127135.html

猜你喜欢

转载自blog.csdn.net/caoxiaohong1005/article/details/80349346