ThreadLocal学习笔记

ThreadLocal简介

ThreadLocal是在多线程编程的环境下,为每个线程创建一个单独的线程变量,互不影响。避免了多个线程同时操作共享变量时产生冲突而带来的一些问题。

ThreadLocal vs Synchronized

synchronized关键字也可以实现防止多线程共享变量冲突的问题。但是与TheadLocal还是存在不同。

首先,ThreadLocal是采用了空间换时间的设计思路,通过为每个线程绑定一个变量,避免了线程争取锁的等待。而synchronized则是明显的时间换空间

另外,在使用方面。Synchronized的目的是为了防止某个代码块在多线程场景下执行会产生的冲突问题,主要针对的是代码块逻辑,最终目的是确保数据的正确性。使用ThreadLocal的目的是让各个线程有一个单独的变量副本,可以方便的在本线程内进行参数传递,从而执行正确的操作逻辑。

以具体的例子来说,Synchronized经常用于多线程对某个变量进行修改的时候,比如同时进行i++操作。这个时候,不同的线程的操作是有互相依赖关系的,a线程对i+1之后,b线程希望的是能在a线程加完后再执行+1。而使用ThreadLocal时,各个线程对其他线程的共享变量副本其实是不关心的。比如用的最多的数据库Connection的例子,每个线程只希望自己的connection实例不被别的线程影响。

使用例子

以下例子都通过ThreadLocal实现了各个线程之间的变量互相独立,不会被干扰。

Session管理

private static final ThreadLocal threadSession = new ThreadLocal();  
  
public static Session getSession() throws InfrastructureException {  
    Session s = (Session) threadSession.get();  
    try {  
        if (s == null) {  
            s = getSessionFactory().openSession();  
            threadSession.set(s);  
        }  
    } catch (HibernateException ex) {  
        throw new InfrastructureException(ex);  
    }  
    return s;  
}  

数据库连接管理

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class ConnectionManager {

    private static final ThreadLocal<Connection> dbConnectionLocal = new ThreadLocal<Connection>() {
        @Override
        protected Connection initialValue() {
            try {
                return DriverManager.getConnection("", "", "");
            } catch (SQLException e) {
                e.printStackTrace();
            }
            return null;
        }
    };

    public Connection getConnection() {
        return dbConnectionLocal.get();
    }
}
  

ThreadLocal的设计与实现

在早期的版本中,ThreadLocal的实现是每个ThreadLocal实例创建一个Map,然后以thread为key,存放对应的value。

而在JDK8中,ThreadLocal的设计是,**每个Thread维护一个 **ThreadLocalMap,这个Map的Key是ThreadLocal实例本身,value是要存储的值。ThreadLocalMap是由ThreadLocal来维护的,采用了懒加载的设计模式,只有在第一次使用get或者set时,才会初始化。

这种设计的好处在于:

  1. 随着线程销毁,ThreadLocalMap也随着销毁,减少内存空间的占用。

ThreadLocal的设计以及get、set方法流程图

get/set流程图

get源码

  /**
     * 返回当前线程中保存ThreadLocal的值
     * 如果当前线程没有此ThreadLocal变量,
     * 则它会通过调用{@link #initialValue} 方法进行初始化值
     *
     * @return 返回当前线程对应此ThreadLocal的值
     */
    public T get() {
        // 获取当前线程对象
        Thread t = Thread.currentThread();
        // 获取此线程对象中维护的ThreadLocalMap对象
        ThreadLocalMap map = getMap(t);
        // 如果此map存在
        if (map != null) {
            // 以当前的ThreadLocal 为 key,调用getEntry获取对应的存储实体e
            ThreadLocalMap.Entry e = map.getEntry(this);
            // 对e进行判空 
            if (e != null) {
                @SuppressWarnings("unchecked")
                // 获取存储实体 e 对应的 value值
                // 即为我们想要的当前线程对应此ThreadLocal的值
                T result = (T)e.value;
                return result;
            }
        }
        /*
        	初始化 : 有两种情况有执行当前代码
        	第一种情况: map不存在,表示此线程没有维护的ThreadLocalMap对象
        	第二种情况: map存在, 但是没有与当前ThreadLocal关联的entry
         */
        return setInitialValue();
    }

    /**
     * 初始化
     *
     * @return the initial value 初始化后的值
     */
    private T setInitialValue() {
        // 调用initialValue获取初始化的值
        // 此方法可以被子类重写, 如果不重写默认返回null
        T value = initialValue();
        // 获取当前线程对象
        Thread t = Thread.currentThread();
        // 获取此线程对象中维护的ThreadLocalMap对象
        ThreadLocalMap map = getMap(t);
        // 判断map是否存在
        if (map != null)
            // 存在则调用map.set设置此实体entry
            map.set(this, value);
        else
            // 1)当前线程Thread 不存在ThreadLocalMap对象
            // 2)则调用createMap进行ThreadLocalMap对象的初始化
            // 3)并将 t(当前线程)和value(t对应的值)作为第一个entry存放至ThreadLocalMap中
            createMap(t, value);
        // 返回设置的值value
        return value;
    }

执行流程:

  1. 通过当前线程,获取当前线程维护的ThreadLocalMap。
  2. 以ThreadLocal自己为实例(传入this),从Map获取Entry并返回。
  3. 如果1中map不存在或者map中没有ThreadLocal对应的key,那么会执行初始化setInitialValue()流程。

set源码

  /**
     * 设置当前线程对应的ThreadLocal的值
     *
     * @param value 将要保存在当前线程对应的ThreadLocal的值
     */
    public void set(T value) {
        // 获取当前线程对象
        Thread t = Thread.currentThread();
        // 获取此线程对象中维护的ThreadLocalMap对象
        ThreadLocalMap map = getMap(t);
        // 判断map是否存在
        if (map != null)
            // 存在则调用map.set设置此实体entry
            map.set(this, value);
        else
            // 1)当前线程Thread 不存在ThreadLocalMap对象
            // 2)则调用createMap进行ThreadLocalMap对象的初始化
            // 3)并将 t(当前线程)和value(t对应的值)作为第一个entry存放至ThreadLocalMap中
            createMap(t, value);
    }

 /**
     * 获取当前线程Thread对应维护的ThreadLocalMap 
     * 
     * @param  t the current thread 当前线程
     * @return the map 对应维护的ThreadLocalMap 
     */
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
	/**
     *创建当前线程Thread对应维护的ThreadLocalMap 
     *
     * @param t 当前线程
     * @param firstValue 存放到map中第一个entry的值
     */
	void createMap(Thread t, T firstValue) {
        //这里的this是调用此方法的threadLocal
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

执行流程:

  1. 通过当前线程,获取当前线程维护的ThreadLocalMap。
  2. 以当前ThreadLocal的实例为key,设置一个value。
  3. 如果获取Map时,发现map还为空。那么会调用createMap()方法先创建map。

remove方法

/**
     * 删除当前线程中保存的ThreadLocal对应的实体entry
     */
     public void remove() {
        // 获取当前线程对象中维护的ThreadLocalMap对象
         ThreadLocalMap m = getMap(Thread.currentThread());
        // 如果此map存在
         if (m != null)
            // 存在则调用map.remove
            // 以当前ThreadLocal为key删除对应的实体entry
             m.remove(this);
     }

remove方法的逻辑很简单,调用map的remove方法删除以本ThreadLocal实例为key的Entry

ThreadLocalMap的设计与实现

ThreadLocalMap实际上并没有继承Map接口,而是自己实现了Map功能。其内部的Entry也是独立实现的。

![image.png](https://img-blog.csdnimg.cn/img_convert/6e33cdfab2a12fd503b4c537fe9583e3.png#clientId=ubfd504fe-78ec-4&from=paste&height=384&id=u0c91481d&margin=[object Object]&name=image.png&originHeight=384&originWidth=650&originalType=binary&ratio=1&size=53895&status=done&style=none&taskId=u1d8e8bc6-0e04-463f-ad9d-274a0f5262a&width=650)

Map的成员变量如下,可以发现,设计理念和HashMap其实非常相似。

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

    /**
     * 存放数据的table,Entry类的定义在下面分析
     * 同样,数组长度必须是2的整次幂。
     */
    private Entry[] table;

    /**
     * 数组里面entrys的个数,可以用于判断table当前使用量是否超过阈值。
     */
    private int size = 0;

    /**
     * 进行扩容的阈值,表使用量大于它的时候进行扩容。
     */
    private int threshold; // Default to 0
    

Entry的核心源码:

/*
 * Entry继承WeakReference,并且用ThreadLocal作为key.
 * 如果key为null(entry.get() == null),意味着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;
    }
}

set源码解读



private void set(ThreadLocal<?> key, Object value) {

    // We don't use a fast path as with get() because it is at
    // least as common to use set() to create new entries as
    // it is to replace existing ones, in which case, a fast
    // path would fail more often than not.

    Entry[] tab = table;
    int len = tab.length;
    
    //获取当前threadLocal对象在数组中索引的位置
    int i = key.threadLocalHashCode & (len-1);
	
    //获取当前i位置上的key
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
		//对比key是否相等,如果相等,直接替换为新值
        if (k == key) {
            e.value = value;
            return;
        }
		//如果为空,说明此ThreadLocal已经被回收了,那么调用replaceStaleEntry方法对该key以及其前后
        //做一次探测式清扫,清理回收已经过期的Entry,源码见下方
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    //如果清理完无用Entry,并且数组中的数据大小 > 阈值的时候对当前的Table进行重新哈希 
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

replaceStaleEntry源码

 private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                       int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;
            Entry e;

            //这里采用的是从当前的staleSlot 位置向前面遍历,i--
            //这样的话是为了把前面所有的的已经被垃圾回收的也一起释放空间出来
            //(注意这里只是key 被回收,value还没被回收,entry更加没回收,所以需要让他们回收),
            //同时也避免这样存在很多过期的对象的占用,导致这个时候刚好来了一个新的元素达到阀值而触发一次新的rehash
            int slotToExpunge = staleSlot;
            for (int i = prevIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = prevIndex(i, len))
                 //slotToExpunge 记录staleSlot左手边第一个空的entry 到staleSlot 之间key过期最小的index
                if (e.get() == null)
                    slotToExpunge = i;

            // 这个时候是从数组下标小的往下标大的方向遍历,i++,刚好跟上面相反。
            //这两个遍历就是为了在左边遇到的第一个空的entry到右边遇到的第一空的 entry之间查询所有过期的对象。
            //注意:在右边如果找到需要设置值的key(这个例子是key=15)相同的时候就开始清理,然后返回,不再继续遍历下去了
            for (int i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                //说明之前已经存在相同的key,所以需要替换旧的值并且和前面那个过期的对象的进行交换位置,
                //交换的目的下面会解释
                if (k == key) {
                    e.value = value;

                    tab[i] = tab[staleSlot];
                    tab[staleSlot] = e;

                    //这里的意思就是前面的第一个for 循环(i--)往前查找的时候没有找到过期的,只有staleSlot
                    // 这个过期,由于前面过期的对象已经通过交换位置的方式放到index=i上了,
                    // 所以需要清理的位置是i,而不是传过来的staleSlot
                    if (slotToExpunge == staleSlot)
                        slotToExpunge = i;
                        //进行清理过期数据
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                    return;
                }

                // 如果我们在第一个for 循环(i--) 向前遍历的时候没有找到任何过期的对象
                // 那么我们需要把slotToExpunge 设置为向后遍历(i++) 的第一个过期对象的位置
                // 因为如果整个数组都没有找到要设置的key 的时候,该key 会设置在该staleSlot的位置上
                //如果数组中存在要设置的key,那么上面也会通过交换位置的时候把有效值移到staleSlot位置上
                //综上所述,staleSlot位置上不管怎么样,存放的都是有效的值,所以不需要清理的
                if (k == null && slotToExpunge == staleSlot)
                    slotToExpunge = i;
            }

            // 如果key 在数组中没有存在,那么直接新建一个新的放进去就可以
            tab[staleSlot].value = null;
            tab[staleSlot] = new Entry(key, value);

            // 如果有其他已经过期的对象,那么需要清理他
            if (slotToExpunge != staleSlot)
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        }

getEntry源码

private Entry getEntry(ThreadLocal<?> key) {
    //先获取索引地址
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    //如果索引地址上的Entry不为空且就是要找的key,直接返回
    if (e != null && e.get() == key)
        return e;
    else
        //否则向后查找,并且在此方法中发现了为null的key会对其进行清理
        return getEntryAfterMiss(key, i, e);
}

ThreadLocal的内存泄露问题

内存溢出与内存泄露

内存溢出指的是已经没有足够的内存分配给申请者。

内存泄露是指之前的创建的对象无法被正确回收,导致系统内存的浪费。长时间的内存泄露必然会导致内存溢出。

Java对象的引用类型

强引用:我们平时使用的最多的引用类型,通过直接new一个对象创建的就是强引用。在GC时,只要通过GC Roots可达,那么就不会被回收

软引用:使用SoftReference<T> ref包装的类型,就是软引用。在一次GC后内存仍然不够,那么就会回收软引用类型。

弱引用:使用WeakReference<T> 包装的类型。每次GC都会对弱引用回收。

虚引用:虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。这和弱引用类似,但是虚引用一定要配合引用队列使用,这里不细说。

内存泄露问题分析

从上面ThreadLocalMap的源码可以看到,Entry是继承了被弱引用包装的ThreadLocal的,Entry extends WeakReference<ThreadLocal<?>>,并且以ThreadLocal作为key,所以Entry中的key和ThreadLocal对象之间的引用关系是弱引用。

我们先假设如果这层引用关系采用的是强引用,会发生什么。

如果ThreadLocal使用的是强引用

首先看看,如果ThreadLocal使用的是强引用会发生什么

![image.png](https://img-blog.csdnimg.cn/img_convert/61406353186012f78a1b367d14ba0784.png#clientId=ubfd504fe-78ec-4&from=paste&height=585&id=u320d4bff&margin=[object Object]&name=image.png&originHeight=585&originWidth=1057&originalType=binary&ratio=1&size=340124&status=done&style=none&taskId=u2b575368-d481-480c-b4bc-591c5640de8&width=1057)

假设我们在线程中不使用当前ThreadLocal了,那么ThreadLocal Ref -> 堆中 ThreadLocal对象这一条引用链会断开。但是由于Entry的key到ThreadLocal对象是强引用,所以Entry的Key不会被回收。

并且当前线程(Current Thread)一直在运行,thread会维护着自己的ThreadLocalMap,因此底下这条强引用链threadRef->currentThread->threadLocalMap->entry也一直存在。

所以,这就会导致即使我们即使用完了当前的ThreadLocal对象,Entry(包括了Key和Value)仍然不会被回收,导致内存泄露。


弱引用情况分析

我们知道实际上,Entry中ThreadLocal作为key使用的是弱引用,那么是否采用弱引用的方式就能避免内存泄露呢?答案是不能,见如下分析:

![image.png](https://img-blog.csdnimg.cn/img_convert/f425554db3340c419a6521cbc9d99369.png#clientId=ubfd504fe-78ec-4&from=paste&height=418&id=u3f60e28f&margin=[object Object]&name=image.png&originHeight=418&originWidth=819&originalType=binary&ratio=1&size=202409&status=done&style=none&taskId=uf6ca6918-caca-4d5a-affc-435aca9df0b&width=819)

当ThreadLocal不再使用后,ThreadLocal Ref -> 堆中 ThreadLocal对象这一条引用链消失,并且由于key到ThreadLocal对象是弱引用。因此,ThreadLocal对象可以被正确回收,此时Entry中的key对象会变为null

但是,底下的强引用链threadRef->currentThread->threadLocalMap->entry是依然存在的,这和上面强引用的情况是一致的。所以此时Entry仍然无法被回收,依然会导致内存泄露。但是和强引用相比,此时Entry中只包含了value。


总结:ThreadLocal出现内存泄露的原因—— 由于ThreadLocalMap是由Thread维护的,其生命周期和Thread一样长。而Entry对象又没有被 整个 remove掉,最终导致了内存泄漏。这也是为什么线程池使用ThreadLocal容易出现内存泄漏的原因,线程被重复使用而不会被销毁,如果不及时remove掉Entry就发生泄露了。

为什么要用弱引用

既然弱引用也无法防止内存泄露的情况,那为什么还要专门用弱引用呢?通过上面ThreadLocalMap的源码可以发现,在使用get或者set方法时,都会对key做检查,如果发现为null,那么对其进行清理,把value也设为null。

这样的话,即使程序员忘记用remove手动移除掉Entry,在下一次get或者set操作时,ThreadLocalMap内部也会帮我们做清理工作。比起使用强引用,就多了一层检查机制。


但是这并不意味着这种检查机制能保证不会发生内存泄漏。

  1. 当Map中存放了大量Key为Null的Entry,且不再调用remove,set,getEntry方法时,依旧会发生内存泄漏。
  2. 如果使用了static ThreadLocal,那么因为静态变量的在类被创建时就被加载,并且在类被销毁时才被释放,他的生命周期比普通实例对象更长,因此也更容易产生内存泄露的问题。(上图中上面那条引用链存活时间变久)

Hash冲突解决

ThreadLocalMap采用了和HashMap不一样的方式解决Hash冲突问题。HashMap采用的是拉链法+红黑树,而ThreadLocalMap采用的则是**开放地址法中的线性探测法。**实现方式是,如果发现存在Hash冲突,那么就继续往后找一个为空的地址,只要数组够大,总能找到放下的地方。

具体实现看看ThreadLocalMap中的涉及到解决Hash冲突的源码

ThreadLocalMap的构造函数

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    //根据INITIAL_CAPACITY,初始化table
    table = new ThreadLocal.ThreadLocalMap.Entry[INITIAL_CAPACITY];
    //计算索引(关键)
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    //设置值
    table[i] = new ThreadLocal.ThreadLocalMap.Entry(firstKey, firstValue);
    //因为是初始化,所以当前Map的大小直接设为1
    size = 1;
    //设置扩容阈值,为INITIAL_CAPACITY的2/3
    setThreshold(INITIAL_CAPACITY);
}

这一段代码的关键在于索引的计算:int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); 。首先看threadLocalHashCode是如何计算出来的

 	private final int threadLocalHashCode = nextHashCode();
    
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
	//AtomicInteger是一个提供原子操作的Integer类,通过线程安全的方式操作加减,适合高并发情况下的使用
    private static AtomicInteger nextHashCode =  new AtomicInteger();
     //特殊的hash值,使用它是为了让哈希码能均匀的分布在2的N次方的数组里
    private static final int HASH_INCREMENT = 0x61c88647;

源码中定义了一个AtomicInteger类型,每次获取hash码的当前值然后用线程安全的方式加上0x61c88647,0x61c88647是一个魔数,可以让元素更均匀的分布在2的N次方的数组里,这里不展开讨论这个数字,感兴趣可以自己搜索了解一下。

& (INITIAL_CAPACITY - 1) 就等于对INITIAL_CAPACITY取模,但是要求INITIAL_CAPACITY必须是2的整数次幂,原因参考这篇文章:位运算取余。和直接取模运算相比,位运算的计算速度更快。

看到这里我们也明白了为什么要控制Map的长度为2的n次方了。一是配合魔数0x61c88647让元素分布更均,二是配合位运算让计算更快。

ThreadLocalMap中的set方法

源码:

private void set(ThreadLocal<?> key, Object value) {
        ThreadLocal.ThreadLocalMap.Entry[] tab = table;
        int len = tab.length;
        //计算索引(重点代码,刚才分析过了)
        int i = key.threadLocalHashCode & (len-1);
        /**
         * 使用线性探测法查找元素(重点代码)
         */
        for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
            ThreadLocal<?> k = e.get();
            //ThreadLocal 对应的 key 存在,直接覆盖之前的值
            if (k == key) {
                e.value = value;
                return;
            }
            // key为 null,但是值不为 null,说明之前的 ThreadLocal 对象已经被回收了,
           // 当前数组中的 Entry 是一个陈旧(stale)的元素
            if (k == null) {
                //用新元素替换陈旧的元素,这个方法进行了不少的垃圾清理动作,防止内存泄漏
                replaceStaleEntry(key, value, i);
                return;
            }
        }
    
    	//ThreadLocal对应的key不存在并且没有找到陈旧的元素,则在空元素的位置创建一个新的Entry。
            tab[i] = new Entry(key, value);
            int sz = ++size;
            /**
             * cleanSomeSlots采用的是对数级扫描,而非全部扫描,如果扫描中发现过期Entry会对其进行回收,
             * 如果全部扫描完成后没有发现过期Entry,方法会返回false。
             * 
             * 当cleanSomeSlots返回的是false,并且当前使用量达到了负载因子所定义(长度的2/3),那么进行				 * rehash(执行一次全表的扫描清理工作)
             * rehash,对全表进行过期Entry回收,如果全表回收完后使用量仍然大于长度的2/3,那么对表进行resize
             * 扩容流程。 具体流程可以查看源码,这里不仔细讨论。
             **/
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
}

 /**
     * 获取环形数组的下一个索引
     */
    private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
    }


首先会用key.threadLocalHashCode & (len-1)得到索引地址,刚刚已经分析过。 接着通过这个索引地址在for循环中向后查找,分以下三种情况:

  1. 索引上存放的Entry的key和传入的key一致,那么直接将原本的value值修改为新的value值。
  2. 索引上的key为null,说明这是一个已过期的Entry,调用replaceStaleEntry()对数组进行清理并把key和value存入数组中
  3. ThreadLocal对应的key不存在,并且没有过期的Entry。那么会在空位置新建一个Entry:tab[i] = new Entry(key, value);。在创建完后,还会执行对应的过期Entry回收或者表的resize操作,见上面源码注释。

另外值得一提的是,在Map中向后探测数组时,使用的是nextIndex()方法。这个方法的逻辑是,每次都往后探测一位,当要超过数组长度时,将探测位设置到0。可以理解成在探测时,整个Entry[] 数组被当成了一个环处理。

参考文章:

https://www.cnblogs.com/aspirant/p/8991010.html

https://blog.csdn.net/weixin_44050144/article/details/113061884

https://www.pdai.tech/md/java/thread/java-thread-x-threadlocal.html#threadlocal%E9%80%A0%E6%88%90%E5%86%85%E5%AD%98%E6%B3%84%E9%9C%B2%E7%9A%84%E9%97%AE%E9%A2%98

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值