较真儿学源码系列-ThreadLocal(逐行源码带你分析作者思路)

        Java版本:8u261。


1 简介

        ThreadLocal是线程本地变量(缓存),其往往用来实现在同一线程内部的变量之间进行交互的情景,不存在线程之间的交互。其对每一个线程内部都维护了一个数据,在a线程set的值,也只能在a线程里进行get。

        具体的使用场景:比如可以用ThreadLocal来封装数据库连接;也可以在复杂逻辑下,用ThreadLocal来作为方法之间的数据传递:如果一开始设置了一个数据,但因为调用逻辑复杂,跨越了很多的类和方法后,需要再次获取到这个数据,那么这个时候就可以用ThreadLocal来对这个数据进行缓存,访问就很方便了。但需要注意的是,这一切操作都必须保证是在同一个线程中进行的,也就是在同一个线程中进行set,也在同一个线程中进行get。在Spring框架中除了使用HashMap和ConcurrentHashMap来做各种缓存之外,也会用到ThreadLocal来缓存数据。

1.1 ThreadLocal与同步控制

        初学ThreadLocal可能会认为它跟synchronized和ReentrantLock是同样的同步机制(我之前也写过对ReentrantLock进行源码分析的文章《较真儿学源码系列-AQS(逐行源码带你分析作者思路)》),但实际上它们完全是两种东西。实现的方式不同,解决的场景也不同。synchronized和ReentrantLock使用的是时间换空间的思路,是用来在多线程场景中访问共享变量用的。获取不到资源的线程会进行排队,等待去获取;而ThreadLocal使用空间换时间的做法,是用来做数据隔离和单个线程内的数据共享用的。set方法只会在当前线程中缓存数据,而get方法也只会在当前线程中获取到这个数据,在别的线程中调用get方法是获取不到的(在别的线程中调用get方法只会获取到ThreadLocal在那个线程中缓存的值)。

1.2 ThreadLocalMap

        ThreadLocal内部维护了一个静态内部类ThreadLocalMap,ThreadLocal内部的所有操作都是在其中实现的(另外提一嘴:我看过很多讲ThreadLocal源码分析的文章,但他们都没有分析ThreadLocalMap的实现。没有分析到ThreadLocalMap这个层面,当然会觉得ThreadLocal的实现很简单。因为ThreadLocal只能说是个包装,核心的操作都在ThreadLocalMap里面):

static class ThreadLocalMap {

    static class Entry extends WeakReference<ThreadLocal<?>> {
        /**
         * The value associated with this ThreadLocal.
         */
        Object value;

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

    //...

    /**
     * The table, resized as necessary.
     * table.length MUST always be a power of two.
     */
    private Entry[] table;

    //...
}

        可以看到其中也维护了一个Entry数组table,而Entry是ThreadLocalMap中的静态内部类,并且Entry类本质上是一个包装ThreadLocal的弱引用,其内部维护着一个强引用的value属性,存放的便是ThreadLocal中需要存储的值。

        而ThreadLocalMap是放到了Thread类的内部属性中,由ThreadLocal来进行维护:

public class Thread implements Runnable {
    //...

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;

    //...
}

        也就是说每个线程中都会有一份缓存副本,每个线程可以访问自己内部的副本,而该副本对于其他线程来说是隔离的。总结来说,在同一个线程中的多个ThreadLocal,会通过hash算法放入到当前线程中的threadLocals属性中的table数组中,也就是哈希槽。只不过不同于HashMap遇到hash冲突就采用挂链表的方式,ThreadLocal采用的是线性探测(开放地址)的方式来放入的。

        ThreadLocal内部会维护着一条从Thread的引用->Thread->ThreadLocalMap->Entry->Entry的值的引用链,如下图所示:

        其上的虚线为弱引用,实线为强引用。当使用完Thread对象后需要被回收时,在下一次gc的时候,因为Entry连着的ThreadLocal引用是弱引用,所以Thread对象能够顺利被回收掉。而如果是强引用,并且ThreadLocal是用static修饰的话,可能就不会被回收,从而产生内存泄漏的问题。

1.3 ThreadLocal的内存泄露问题

        虽然上面使用了弱引用,以此来保证Thread对象能够成功被回收掉。但是正如上面ThreadLocalMap的源码所示,Entry其中的value仍为强引用。所以如果使用不当的话,仍然会造成内存泄露的问题出现。考虑这么一种情况:如果使用完ThreadLocal、同时其引用断开,并且没有其他的强引用,则在下一次gc的时候,这个ThreadLocal对象就会被回收,变为null。但是因为Entry中的value是强引用,所以此时在threadLocals的Entry数组中就会有一个ThreadLocal为null,但是其中的value仍然有值的Entry。此时就再也不能通过原来的ThreadLocal(此时为null)来访问到该value了。而该线程却一直存在(比如说是线程池),Thread又强引用着ThreadLocalMap,因此ThreadLocalMap也不会被回收。于是就产生了Entry中value的内存泄露。

        解决办法是再一次调用get/set/remove方法,这三个方法在其实现的内部逻辑中都会遍历删除Entry为null的值,以此来避免内存泄漏的发生(get和set方法只能删除部分垃圾数据,而且可能在还没有遍历到ThreadLocal为null的Entry时,这两个方法就已经提前成功返回了。所以最好是使用remove方法。在使用完ThreadLocal后显式调用一次remove方法,这将会把当前这个ThreadLocal对应的Entry删除掉,从根本上杜绝了内存泄漏的发生。在后面的源码分析中可以看到这点)。

        进一步思考:如果将value也置为弱引用行不行?如果这么做的话,value除了Entry这个弱引用之外,就再没有别的引用了。这样的话在下一次gc时value值就会被清除掉,而Thread对象却一直存活者,再次调用就会返回null。这是绝对不能容忍的,因为这个时候就不是内存泄不泄漏的问题了,此时就变成了程序的bug(丢失数据)。

1.4 SimpleDateFormat的线程安全问题

        这是《阿里巴巴编码规范》中的一条。SimpleDateFormat因为其内部的Calendar属性而存在线程安全问题,如果把其定义成static的成员变量,多个线程同时更改获取它,就可能会出现问题。解决方法是使用Java 8中新添加的时间API,或者使用第三方的时间类库(Joda-Time)。当然使用ThreadLocal来包装SimpleDateFormat的方式也不失为一种好的解决办法:

private static final ThreadLocal<DateFormat> SDF = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

public static Date parse(String str) throws ParseException {
    return SDF.get().parse(str);
}

public static String format(Date date) {
    return SDF.get().format(date);
}

2 构造器

/**
 * ThreadLocal:
 */
public ThreadLocal() {
    //空实现
}

        虽然ThreadLocal的构造器是空实现,但是同时会完成hashCode的计算,如下所示:

public class ThreadLocal<T> {

    private final int threadLocalHashCode = nextHashCode();

    private static AtomicInteger nextHashCode =
            new AtomicInteger();

    private static final int HASH_INCREMENT = 0x61c88647;

    private static int nextHashCode() {
        /*
        nextHashCode是AtomicInteger类型的,这里是在获取当前nextHashCode的值,然后会加上
        HASH_INCREMENT。注意这里的nextHashCode是static修饰的,也就是类变量。也就是说,
        每调用一次ThreadLocal的构造器,就会生成一个不一样的hashCode
         */
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

    //...
}

        不同于HashMap中计算hashCode是采用key的hashCode方法高低16位异或的方式,在ThreadLocal中计算hashCode是使用了一个固定的值0x61c88647,那么为什么会是这个数呢?其实0x61c88647换算成十进制就是1640531527。而Java中的int是32位,也就是2654435769 = -1640531527。这里的2654435769 \approx 2^{32}*\phi , \phi=\frac{\sqrt{5}-1}{2},其中的φ也就是所谓的黄金分割率,也就是近似于0.618的那个数。而黄金比例又与斐波那契数(F_n=\frac{1}{\sqrt{5}}[(\frac{1+\sqrt{5}}{2})^n-(\frac{1-\sqrt{5}}{2})^n])之间有密切关系,使用这个数时会使得散列的结果很均匀:


3 set方法

/**
 * ThreadLocal:
 */
public void set(T value) {
    //获取当前的线程
    Thread t = Thread.currentThread();
    //获取线程中的threadLocals
    ThreadLocalMap map = getMap(t);
    if (map != null)
        //如果threadLocals存在,就往其中存放数据(this代表当前的ThreadLocal)
        map.set(this, value);
    else
        //否则就完成ThreadLocalMap的初始化并放入数据
        createMap(t, value);
}

/**
 * 第8行代码处:
 */
ThreadLocalMap getMap(Thread t) {
    //返回上面说过的Thread中的threadLocals,也就是当前线程的缓存副本
    return t.threadLocals;
}

/**
 * 第14行代码处:
 * 首先来看一下初始化的过程
 */
void createMap(Thread t, T firstValue) {
    /*
    前面看到在ThreadLocal的构造器中是空实现,而在set方法中的此处完成ThreadLocalMap的延迟初始化,
    初始化完成后赋值给当前线程的threadLocals属性
     */
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

/**
 * ThreadLocalMap构造器
 */
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    //对Entry数组table进行初始化,初始容量INITIAL_CAPACITY为16
    table = new Entry[INITIAL_CAPACITY];
    //获取哈希槽的位置。这里的按位与也就是在做threadLocalHashCode对数组容量取余的结果
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    //对哈希槽做初始化(只初始化当前槽的位置,也体现了延迟初始化的思想)
    table[i] = new Entry(firstKey, firstValue);
    //计数为1
    size = 1;
    //设置threshold
    setThreshold(INITIAL_CAPACITY);
}

/**
 * 第46行代码处:
 */
Entry(ThreadLocal<?> k, Object v) {
    //这里会将当前ThreadLocal放入到弱引用中
    super(k);
    //这里会将要存入ThreadLocal的值存入到Entry的value中
    value = v;
}

/**
 * 第50行代码处:
 * 设置threshold值(threshold相当于HashMap中负载因子的概念),可以看到这里的策略是数组容量的2/3
 */
private void setThreshold(int len) {
    threshold = len * 2 / 3;
}

/**
 * 第11行代码处:
 * 再来看一下set方法中、如果当前线程中的ThreadLocalMap已经初始化了,就会执行本方法完成set操作
 */
private void set(ThreadLocal<?> key, Object value) {

    Entry[] tab = table;
    int len = tab.length;
    //和上面ThreadLocalMap构造器中的一样,这里是在获取哈希槽的位置
    int i = key.threadLocalHashCode & (len - 1);

    for (Entry e = tab[i];
        e != null;
        e = tab[i = nextIndex(i, len)]) {
        //获取当前槽中的Entry中保存的ThreadLocal
        ThreadLocal<?> k = e.get();

        /*
        如果这个ThreadLocal就是当前线程的ThreadLocal的话,就更新一下value值,也就是做值的覆盖。然后返回
        如果不等,就说明发生了hash冲突,此时继续寻找下一个哈希槽,也就是用线性探测的方式(nextIndex(i, len))
         */
        if (k == key) {
            e.value = value;
            return;
        }

        /*
        如果当前槽不为null,但是其中保存的ThreadLocal为null,说明这个弱引用被回收了(上面说过Entry继承弱引用)
        此时会删除这个位置的垃圾数据(以及其他无效的位置),防止内存泄漏
         */
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    /*
    走到这里说明上面的for循环全走完了也没有找到key相等的节点或key是null的节点,此时的i就是下一个要插入的槽位
    那么现在就把新的value数据插入到这个位置中就行了
     */
    tab[i] = new Entry(key, value);
    //计数+1
    int sz = ++size;
    /*
    当存放完数据后,此时会查看是否需要清理垃圾数据,如果没有垃圾数据的话,
    并且当前数组的容量大于等于threshold阈值,就进行“扩容”
     */
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

/**
 * 第102行代码处:
 */
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    /*
    从staleSlot位置起往前寻找第一个ThreadLocal为null的哈希槽位置(也就是垃圾数据)
    这里没有直接用staleSlot而是用了一次遍历确定下来的slotToExpunge是因为:这里不光会
    删除staleSlot位置的垃圾数据,还会把所有的垃圾数据都删除
     */
    int slotToExpunge = staleSlot;
    //向前环形搜索垃圾数据
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;

    //向后环形搜索
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        //获取当前槽中的Entry中保存的ThreadLocal
        ThreadLocal<?> k = e.get();

        //如果key(ThreadLocal)相同的话
        if (k == key) {
            //就做一下值的覆盖
            e.value = value;

            /*
            同时会将staleSlot(垃圾数据位置处)和i位置的数据做一下交换
            (因为tab[i]的数据已经在上面缓存了,所以不需要暂存变量)
            交换之后,垃圾数据就到了i处(这样就可以保证垃圾数据最后会放在
            相同key的最后一个哈希槽位置处,保证了hash的顺序)
             */
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            /*
            如果在之前的向前环形搜索的过程中没有找到垃圾数据的话,就把i赋值给slotToExpunge,
            意思是以当前i位置处作为清理的起点
             */
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            //找到垃圾数据并进行清理
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        /*
        如果在之前的向前环形搜索的过程中没有找到垃圾数据,但是在此时的向后环形搜索的过程中找到了,
        就一样把i赋值为slotToExpunge,以当前i位置处作为清理的起点
         */
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    //如果在上面的循环中没有找到相同的key的话,此时将垃圾数据位置处的value置为null,去掉强引用
    tab[staleSlot].value = null;
    //同时将一个新的Entry赋值进去(也就是当前需要set进去的数据)
    tab[staleSlot] = new Entry(key, value);

    //如果在上面向后环形搜索的过程中找到了垃圾数据的话,就一样需要进行清理
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

/**
 * 第138行和第140行代码处:
 * 根据指定的哈希槽位置查找上一个位置处,如果已经到第一个位置了,就重新返回最后一个位置处
 */
private static int prevIndex(int i, int len) {
    return ((i - 1 >= 0) ? i - 1 : len - 1);
}

/**
 * 第84行、第145行、第147行、第226行、第228行、第253行、第275行和第353行代码处:
 * 根据指定的哈希槽位置查找下一个位置处,如果已经到最后一个位置了,就重新返回第一个位置处
 */
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

/**
 * 第172行、第191行、第285行和第320行代码处:
 */
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    //删除垃圾数据位置处的强引用value和Entry
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    //计数-1
    size--;

    Entry e;
    int i;
    //从staleSlot位置处向后环形搜索
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        //获取当前槽中的Entry中保存的ThreadLocal
        ThreadLocal<?> k = e.get();
        if (k == null) {
            //如果再次遇到垃圾数据,就将其清理掉,并且计数-1
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            /*
            在线性探测中删除一个节点的话,是不能简单地将一个节点置为null就完事了的。因为在线性探测查找的时候,
            如果遍历时遇到一个为null的位置,就可以停止遍历、认定为找不到这个数据了。而如果删除的时候只将这个数据
            置为null的话,那么后面的节点就有可能会访问不到。本来存在的数据,但却访问不到,从而出现了问题
            所以在这里需要对staleSlot后面的节点做一些处理,这里选择的是rehash的方式

            获取哈希槽的位置
             */
            int h = k.threadLocalHashCode & (len - 1);
            //如果这次获取哈希槽的位置和i不同的话(如果相同就不转移)
            if (h != i) {
                //就将tab[i]位置的数据清空
                tab[i] = null;

                //并且重新线性探测一个新的空位置处
                while (tab[h] != null)
                    h = nextIndex(h, len);
                //同时把数据转移进去
                tab[h] = e;
            }
        }
    }
    /*
    返回staleSlot位置后第一个为null的哈希槽位置,从本方法的实现中可以看出:本方法只是清理了从staleSlot
    到其后不为null的这一段哈希槽的垃圾数据,并不是清理全部哈希槽。清理全部的话需要借助下面的cleanSomeSlots方法
     */
    return i;
}

/**
 * 第118行、第172行和第191行代码处:
 */
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        //获取下一个哈希槽的位置
        i = nextIndex(i, len);
        //获取其中的Entry
        Entry e = tab[i];
        //如果有垃圾数据的话(Entry不为null但是其中的ThreadLocal为null)
        if (e != null && e.get() == null) {
            //就将n重新置为当前数组的长度,再重新进行do-while循环
            n = len;
            //删除标志位置为true
            removed = true;
            //调用expungeStaleEntry方法来删除垃圾数据,删除后会继续循环,直到n等于0为止
            i = expungeStaleEntry(i);
        }
    //n每次循环都会除以2
    } while ((n >>>= 1) != 0);
    //返回是否删除过垃圾数据的标志位
    return removed;
}

/**
 * 第119行代码处:
 * 是否需要扩容首先还会查看当前全表是否含有垃圾数据,如果有垃圾数据并且删除后还是比数组容量的一半多的话,才进行扩容
 */
private void rehash() {
    //全表扫描是否含有垃圾数据,如果有的话就进行删除
    expungeStaleEntries();

    /*
    如果调用上面expungeStaleEntries方法完毕后,size还是大于等于threshold*0.75(也就是数组容量的一半),
    就会进行扩容操作
     */
    if (size >= threshold - threshold / 4)
        resize();
}

/**
 * 第299行代码处:
 * 从table数组的第一个位置处向后遍历,如果发现有垃圾数据的话(Entry不为null但是其中的ThreadLocal为null),
 * 就调用expungeStaleEntry方法进行删除
 */
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);
    }
}

/**
 * 第306行代码处:
 */
private void resize() {
    Entry[] oldTab = table;
    int oldLen = oldTab.length;
    //新数组的容量为旧数组的两倍(这里没有使用<<1的方式感觉是因为历史遗留代码的原因)
    int newLen = oldLen * 2;
    //创建新数组
    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) {
                /*
                如果Entry不为null但是其中的ThreadLocal为null,就说明当前这个是垃圾数据
                此时将它的value也置为null,便于GC回收(这里的删除就不需要考虑后续节点的
                rehash了,因为所有的节点最后都是要转移到新数组的)
                 */
                e.value = null;
            } else {
                //根据新数组容量来进行hash
                int h = k.threadLocalHashCode & (newLen - 1);
                //通过线性探测的方式来找到要插入的位置
                while (newTab[h] != null)
                    h = nextIndex(h, newLen);
                //并且把数据转移进去
                newTab[h] = e;
                //新数组计数+1
                count++;
            }
        }
    }

    //走到这里说明完成了数据迁移的过程,此时重新计算一下threshold值
    setThreshold(newLen);
    //更新一下size
    size = count;
    //将扩容后新的数组赋值给table
    table = newTab;
}

4 get方法

/**
 * ThreadLocal:
 */
public T get() {
    //获取当前的线程
    Thread t = Thread.currentThread();
    //获取线程中的threadLocals
    ThreadLocalMap map = getMap(t);
    //如果ThreadLocalMap已经初始化了的话
    if (map != null) {
        //从threadLocals中寻找对应的Entry
        Entry e = map.getEntry(this);
        //如果找到的话
        if (e != null) {
            //直接返回Entry中的value就行了
            @SuppressWarnings("unchecked")
            T result = (T) e.value;
            return result;
        }
    }
    //如果ThreadLocalMap没有初始化,或者没找到Entry的话,就在setInitialValue方法中进行处理
    return setInitialValue();
}

/**
 * 第12行代码处:
 */
private Entry getEntry(ThreadLocal<?> key) {
    //获取哈希槽的位置
    int i = key.threadLocalHashCode & (table.length - 1);
    //获取Entry
    Entry e = table[i];
    if (e != null && e.get() == key)
        //如果Entry不为null,并且其中的ThreadLocal也相等的话,就说明找到了,返回这个Entry
        return e;
    else
        //否则未命中的话,就在getEntryAfterMiss方法中进行处理
        return getEntryAfterMiss(key, i, e);
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {
        ThreadLocal<?> k = e.get();
        if (k == key)
            //如果ThreadLocal相等的话,就返回这个Entry
            return e;
        if (k == null)
            /*
            如果Entry不为null但是其中的ThreadLocal为null,就说明当前这个是垃圾数据
            调用expungeStaleEntry方法来进行删除
             */
            expungeStaleEntry(i);
        else
            //否则通过线性探测的方式来找到下一个哈希槽的位置
            i = nextIndex(i, len);
        //重新更新一下Entry的指向
        e = tab[i];
    }
    //如果循环走完还找不到的话,就返回null
    return null;
}

/**
 * 第22行代码处:
 */
private T setInitialValue() {
    //获取初始值
    T value = initialValue();
    //获取当前的线程
    Thread t = Thread.currentThread();
    //获取线程中的threadLocals
    ThreadLocalMap map = getMap(t);
    if (map != null)
        //如果threadLocals存在,就往其中存放初始数据
        map.set(this, value);
    else
        //如果ThreadLocalMap没有初始化,就完成相关初始化工作并放入初始数据
        createMap(t, value);
    //最后返回这个初始值就行了
    return value;
}

/**
 * 第71行代码处:
 * 本方法默认返回null,可以由调用者覆写
 */
protected T initialValue() {
    return null;
}

5 remove方法

/**
 * ThreadLocal:
 */
public void remove() {
    //获取当前线程中的threadLocals
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        //如果不为null就进行删除(删除的是当前的ThreadLocal)
        m.remove(this);
}

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)]) {
        //遍历table数组,如果ThreadLocal相等的话(如果不等说明发生了hash冲突,此时用线性探测的方式找寻下一个哈希槽)
        if (e.get() == key) {
            //就清除ThreadLocal
            e.clear();
            //同时尝试删除垃圾数据
            expungeStaleEntry(i);
            return;
        }
    }
}

原创不易,未得准许,请勿转载,翻版必究

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值