java中threadlocal_理解java中的ThreadLocal 专题

ThreadLocal每一印象:

public classIncrementWithStaticVariable{private static int seqNum = 0;public intgetNextNum(){

seqNum++;returnseqNum;

}public static voidmain(String[] args) {

IncrementWithStaticVariablesn= newIncrementWithStaticVariable();

TestClient t1= newTestClient(sn);

TestClient t2= newTestClient(sn);

TestClient t3= newTestClient(sn);

t3.start();

t2.start();

t1.start();

}private static class TestClient extendsThread{privateIncrementWithStaticVariable sn;publicTestClient(IncrementWithStaticVariable sn){this.sn =sn;

}public voidrun(){for(int i = 0;i<3;i++){

System.out.println("thread[" + Thread.currentThread().getName() +

"]sn[" +sn.getNextNum() + "]");

}

}

}

}

输出:

thread[Thread-2]sn[1]

thread[Thread-2]sn[3]

thread[Thread-2]sn[4]

thread[Thread-1]sn[2]

thread[Thread-1]sn[5]

thread[Thread-1]sn[6]

thread[Thread-0]sn[7]

thread[Thread-0]sn[8]

thread[Thread-0]sn[9]

上述线程t1,t2,t3争抢同一个静态变量对象seqNum,并且在run()方法中会连续三次获取seqNum值,每次获取后会对seqNum值+1。Thread-2在获取到seqNum=1后,seqNum+1成为2,立马存在Thread-1访问了seqNum值,并+1使得seqNum=3,以至于Thread-2再次访问seqNum的时候获取的seqNum=3.以上模拟了多线程下资源争抢的情景。

importjava.util.concurrent.ExecutorService;importjava.util.concurrent.Executors;public classThreadLocalDemo {static ThreadLocal seqNum = ThreadLocal.withInitial(() -> 0);public static voidmain(String[] args) {

ExecutorService executorService=Executors.newCachedThreadPool();for (int i = 0; i < 3; i++) {

executorService.execute(newTask(i));

}

executorService.shutdown();

}/*** 各个线程的输出结果之间是隔离的

* 输出结果:

* Thread[pool-1-thread-1,5,main]:0== >0

* Thread[pool-1-thread-1,5,main]:0== >1

* Thread[pool-1-thread-1,5,main]:0== >3

* Thread[pool-1-thread-2,5,main]:1== >0

* Thread[pool-1-thread-2,5,main]:1== >1

* Thread[pool-1-thread-2,5,main]:1== >3

* Thread[pool-1-thread-3,5,main]:2== >0

* Thread[pool-1-thread-3,5,main]:2== >1

* Thread[pool-1-thread-3,5,main]:2== >3*/

private static class Task implementsRunnable {private intflag;

Task(intflag) {this.flag =flag;

}

@Overridepublic voidrun() {for (int i = 0; i < 3; i++) {

seqNum.set(seqNum.get()+i);

System.out.println(Thread.currentThread()+ ":" + flag + "== >" +seqNum.get());

}

}

}

}

输出:

Thread[pool-1-thread-1,5,main]:0== >0

Thread[pool-1-thread-1,5,main]:0== >1

Thread[pool-1-thread-1,5,main]:0== >3

Thread[pool-1-thread-2,5,main]:1== >0

Thread[pool-1-thread-2,5,main]:1== >1

Thread[pool-1-thread-2,5,main]:1== >3

Thread[pool-1-thread-3,5,main]:2== >0

Thread[pool-1-thread-3,5,main]:2== >1

Thread[pool-1-thread-3,5,main]:2== >3

上述线程t1,t2,t3共享sn,使用同一个seqNum对象,但是线程却没有互相影响,均是各自打印自己的1,2,3.为什么呢?

因为ThreadLocal,多线程下会复制一份自己的seqNum,各自线程之间的数据是互不影响的。所以把不安全的变量放以泛型的方式放入ThreadLocal可以解决线程不安全的问题。

http://blog.csdn.net/weiweiai123456/article/details/40298485

一、对ThreadLocal概述

JDK API 写道:

该类提供了线程局部 (thread-local) 变量。这些变量不同于它们的普通对应物,因为访问某个变量(通过其 get 或 set 方法)的每个线程都有自己的局部变量,它独立于变量的初始化副本。ThreadLocal 实例通常是类中的 private static 字段,它们希望将状态与某一个线程(例如,用户 ID 或事务 ID)相关联。

二、结合源码理解

可以看到ThreadLocal类中的变量只有这3个int型:

public class ThreadLocal{/*** ThreadLocals rely on per-thread linear-probe hash maps attached

* to each thread (Thread.threadLocals and

* inheritableThreadLocals). The ThreadLocal objects act as keys,

* searched via threadLocalHashCode. This is a custom hash code

* (useful only within ThreadLocalMaps) that eliminates collisions

* in the common case where consecutively constructed ThreadLocals

* are used by the same threads, while remaining well-behaved in

* less common cases.*/

private final int threadLocalHashCode =nextHashCode();/*** The next hash code to be given out. Updated atomically. Starts at

* zero.*/

private static AtomicInteger nextHashCode =

newAtomicInteger();/*** The difference between successively generated hash codes - turns

* implicit sequential thread-local IDs into near-optimally spread

* multiplicative hash values for power-of-two-sized tables.*/

private static final int HASH_INCREMENT = 0x61c88647;

ThreadLocal实例的变量只有 threadLocalHashCode

ThreadLocal类的静态变量nextHashCode 和HASH_INCREMENT

实际上HASH_INCREMENT是一个常量,表示了连续分配的两个ThreadLocal实例的 threadLocalHashCode值的增量,而nextHashCode 的表示了即将分配的下一个ThreadLocal实例的threadLocalHashCode 的值。

而nextHashCode()方法就是将ThreadLocal类的下一个hashCode值即nextHashCode的值赋给实例的threadLocalHashCode,然后nextHashCode的值增加HASH_INCREMENT这个值。

/*** Returns the next hash code.*/

private static intnextHashCode() {returnnextHashCode.getAndAdd(HASH_INCREMENT);

}

ThreadLocal有一个ThreadLocalMap静态内部类,你可以简单理解为一个MAP,这个"Map"为每个线程复制一个变量的‘拷贝’存储其中。

看一下set()方法:  获取当前线程的引用,从map中获取该线程对应的map,如果map存在更新缓存值,否则创建并存储

/*** Sets the current thread's copy of this thread-local variable

* to the specified value. Most subclasses will have no need to

* override this method, relying solely on the {@link#initialValue}

* method to set the values of thread-locals.

*

*@paramvalue the value to be stored in the current thread's copy of

* this thread-local.*/

public voidset(T value) {

Thread t=Thread.currentThread();

ThreadLocalMap map=getMap(t);if (map != null)

map.set(this, value);elsecreateMap(t, value);

}

再来看一下get()方法:

首先获取当前线程引用,以此为key去获取响应的ThreadLocalMap,如果此"Map"不存在则初始化一个,否则返回其中的变量。

调用get方法如果此Map不存在首先初始化,创建此map,将线程为key,初始化的vlaue存入其中,注意此处的initialValue,我们可以覆盖此方法,在首次调用时初始化一个适当的值,默认是null

/*** Returns the value in the current thread's copy of this

* thread-local variable. If the variable has no value for the

* current thread, it is first initialized to the value returned

* by an invocation of the {@link#initialValue} method.

*

*@returnthe current thread's value of this thread-local*/

publicT get() {

Thread t=Thread.currentThread();

ThreadLocalMap map=getMap(t);if (map != null) {

ThreadLocalMap.Entry e= map.getEntry(this);if (e != null)return(T)e.value;

}returnsetInitialValue();

}

/*** Variant of set() to establish initialValue. Used instead

* of set() in case user has overridden the set() method.

*

*@returnthe initial value*/

privateT setInitialValue() {

T value=initialValue();

Thread t=Thread.currentThread();

ThreadLocalMap map=getMap(t);if (map != null)

map.set(this, value);elsecreateMap(t, value);returnvalue;

}

/*** Returns the current thread's "initial value" for this

* thread-local variable. This method will be invoked the first

* time a thread accesses the variable with the {@link#get}

* method, unless the thread previously invoked the {@link#set}

* method, in which case the initialValue method will not

* be invoked for the thread. Normally, this method is invoked at

* most once per thread, but it may be invoked again in case of

* subsequent invocations of {@link#remove} followed by {@link#get}.

*

*

This implementation simply returns null; if the

* programmer desires thread-local variables to have an initial

* value other than null, ThreadLocal must be

* subclassed, and this method overridden. Typically, an

* anonymous inner class will be used.

*

*@returnthe initial value for this thread-local*/

protectedT initialValue() {return null;

}

我们来看下ThreadLocalMap静态内部类,在ThreadLocalMap 内部的Entry是WeakReference

/*** ThreadLocalMap is a customized hash map suitable only for

* maintaining thread local values. No operations are exported

* outside of the ThreadLocal class. The class is package private to

* allow declaration of fields in class Thread. To help deal with

* very large and long-lived usages, the hash table entries use

* WeakReferences for keys. However, since reference queues are not

* used, stale entries are guaranteed to be removed only when

* the table starts running out of space.*/

static classThreadLocalMap {/*** The entries in this hash map extend WeakReference, using

* its main ref field as the key (which is always a

* ThreadLocal object). Note that null keys (i.e. entry.get()

* == null) mean that the key is no longer referenced, so the

* entry can be expunged from table. Such entries are referred to

* as "stale entries" in the code that follows.*/

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

Entry(ThreadLocal k, Object v) {super(k);

value=v;

}

}/*** The initial capacity -- MUST be a power of two.*/

private static final int INITIAL_CAPACITY = 16;/*** The table, resized as necessary.

* table.length MUST always be a power of two.*/

privateEntry[] table;/*** The number of entries in the table.*/

private int size = 0;/*** The next size value at which to resize.*/

private int threshold; //Default to 0

/*** Set the resize threshold to maintain at worst a 2/3 load factor.*/

private void setThreshold(intlen) {

threshold= len * 2 / 3;

}/*** Increment i modulo len.*/

private static int nextIndex(int i, intlen) {return ((i + 1 < len) ? i + 1 : 0);

}/*** Decrement i modulo len.*/

private static int prevIndex(int i, intlen) {return ((i - 1 >= 0) ? i - 1 : len - 1);

}/*** Construct a new map initially containing (firstKey, firstValue).

* ThreadLocalMaps are constructed lazily, so we only create

* one when we have at least one entry to put in it.*/ThreadLocalMap(ThreadLocal firstKey, Object firstValue) {

table= newEntry[INITIAL_CAPACITY];int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);

table[i]= newEntry(firstKey, firstValue);

size= 1;

setThreshold(INITIAL_CAPACITY);

}/*** Construct a new map including all Inheritable ThreadLocals

* from given parent map. Called only by createInheritedMap.

*

*@paramparentMap the map associated with parent thread.*/

privateThreadLocalMap(ThreadLocalMap parentMap) {

Entry[] parentTable=parentMap.table;int len =parentTable.length;

setThreshold(len);

table= newEntry[len];for (int j = 0; j < len; j++) {

Entry e=parentTable[j];if (e != null) {

ThreadLocal key=e.get();if (key != null) {

Object value=key.childValue(e.value);

Entry c= newEntry(key, value);int h = key.threadLocalHashCode & (len - 1);while (table[h] != null)

h=nextIndex(h, len);

table[h]=c;

size++;

}

}

}

}/*** Get the entry associated with key. This method

* itself handles only the fast path: a direct hit of existing

* key. It otherwise relays to getEntryAfterMiss. This is

* designed to maximize performance for direct hits, in part

* by making this method readily inlinable.

*

*@paramkey the thread local object

*@returnthe entry associated with key, or null if no such*/

privateEntry getEntry(ThreadLocal key) {int i = key.threadLocalHashCode & (table.length - 1);

Entry e=table[i];if (e != null && e.get() ==key)returne;else

returngetEntryAfterMiss(key, i, e);

}/*** Version of getEntry method for use when key is not found in

* its direct hash slot.

*

*@paramkey the thread local object

*@parami the table index for key's hash code

*@parame the entry at table[i]

*@returnthe entry associated with key, or null if no such*/

private Entry getEntryAfterMiss(ThreadLocal key, inti, Entry e) {

Entry[] tab=table;int len =tab.length;while (e != null) {

ThreadLocal k=e.get();if (k ==key)returne;if (k == null)

expungeStaleEntry(i);elsei=nextIndex(i, len);

e=tab[i];

}return null;

}/*** Set the value associated with key.

*

*@paramkey the thread local object

*@paramvalue the value to be set*/

private voidset(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;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;

}if (k == null) {

replaceStaleEntry(key, value, i);return;

}

}

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

rehash();

}/*** Remove the entry for key.*/

private voidremove(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;

}

}

}/*** Replace a stale entry encountered during a set operation

* with an entry for the specified key. The value passed in

* the value parameter is stored in the entry, whether or not

* an entry already exists for the specified key.

*

* As a side effect, this method expunges all stale entries in the

* "run" containing the stale entry. (A run is a sequence of entries

* between two null slots.)

*

*@paramkey the key

*@paramvalue the value to be associated with key

*@paramstaleSlot index of the first stale entry encountered while

* searching for key.*/

private voidreplaceStaleEntry(ThreadLocal key, Object value,intstaleSlot) {

Entry[] tab=table;int len =tab.length;

Entry e;//Back up to check for prior stale entry in current run.//We clean out whole runs at a time to avoid continual//incremental rehashing due to garbage collector freeing//up refs in bunches (i.e., whenever the collector runs).

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();//If we find key, then we need to swap it//with the stale entry to maintain hash table order.//The newly stale slot, or any other stale slot//encountered above it, can then be sent to expungeStaleEntry//to remove or rehash all of the other entries in run.

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;

}//If we didn't find stale entry on backward scan, the//first stale entry seen while scanning for key is the//first still present in the run.

if (k == null && slotToExpunge ==staleSlot)

slotToExpunge=i;

}//If key not found, put new entry in stale slot

tab[staleSlot].value = null;

tab[staleSlot]= newEntry(key, value);//If there are any other stale entries in run, expunge them

if (slotToExpunge !=staleSlot)

cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);

}/*** Expunge a stale entry by rehashing any possibly colliding entries

* lying between staleSlot and the next null slot. This also expunges

* any other stale entries encountered before the trailing null. See

* Knuth, Section 6.4

*

*@paramstaleSlot index of slot known to have null key

*@returnthe index of the next null slot after staleSlot

* (all between staleSlot and this slot will have been checked

* for expunging).*/

private int expungeStaleEntry(intstaleSlot) {

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;inti;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;

}

}

}returni;

}/*** Heuristically scan some cells looking for stale entries.

* This is invoked when either a new element is added, or

* another stale one has been expunged. It performs a

* logarithmic number of scans, as a balance between no

* scanning (fast but retains garbage) and a number of scans

* proportional to number of elements, that would find all

* garbage but would cause some insertions to take O(n) time.

*

*@parami a position known NOT to hold a stale entry. The

* scan starts at the element after i.

*

*@paramn scan control: log2(n) cells are scanned,

* unless a stale entry is found, in which case

* log2(table.length)-1 additional cells are scanned.

* When called from insertions, this parameter is the number

* of elements, but when from replaceStaleEntry, it is the

* table length. (Note: all this could be changed to be either

* more or less aggressive by weighting n instead of just

* using straight log n. But this version is simple, fast, and

* seems to work well.)

*

*@returntrue if any stale entries have been removed.*/

private boolean cleanSomeSlots(int i, intn) {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);returnremoved;

}/*** Re-pack and/or re-size the table. First scan the entire

* table removing stale entries. If this doesn't sufficiently

* shrink the size of the table, double the table size.*/

private voidrehash() {

expungeStaleEntries();//Use lower threshold for doubling to avoid hysteresis

if (size >= threshold - threshold / 4)

resize();

}/*** Double the capacity of the table.*/

private voidresize() {

Entry[] oldTab=table;int oldLen =oldTab.length;int newLen = oldLen * 2;

Entry[] newTab= newEntry[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;

table=newTab;

}/*** Expunge all stale entries in the table.*/

private voidexpungeStaleEntries() {

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

}

}

}

ThreadLocal和多线程并发没有什么关系。

ThreadLocal模式是为了解决单线程内的跨类跨方法调用的

ThreadLocal不是用来解决对象共享访问问题的,而主要是提供了保持对象的方法和避免参数传递的方便的对象访问方式。

一般情况下,通过ThreadLocal.set() 到线程中的对象是该线程自己使用的对象,其他线程是不需要访问的,也访问不到的。各个线程中访问的是不同的对象。

三、例子

引用Tim Cull的博文“SimpleDateFormat: Performance Pig”介绍下ThreadLocal的简单使用,同时也对SimpleDateFormat的使用有个深入的了解。

Tim Cull 写道:

Tim Cull碰到一个SimpleDateFormat带来的严重的性能问题,该问题主要有SimpleDateFormat引发,创建一个 SimpleDateFormat实例的开销比较昂贵,解析字符串时间时频繁创建生命周期短暂的实例导致性能低下。即使将 SimpleDateFormat定义为静态类变量,貌似能解决这个问题,但是SimpleDateFormat是非线程安全的,同样存在问题,如果用 ‘synchronized’线程同步同样面临问题,同步导致性能下降(线程之间序列化的获取SimpleDateFormat实例)。

Tim Cull使用Threadlocal解决了此问题,对于每个线程SimpleDateFormat不存在影响他们之间协作的状态,为每个线程创建一个SimpleDateFormat变量的拷贝或者叫做副本

private static final String DATE_FORMAT = "yyyyMMddHH24mmssS";private static ThreadLocal threadLocal = newThreadLocal() {protected synchronizedDateFormat

initialValue() {return newSimpleDateFormat(DATE_FORMAT);

}

};public staticString getTimeStamp() {return threadLocal.get().format(newDate());

}public static Date parse(String textDate) throwsParseException {returnthreadLocal.get().parse(textDate);

}

创建一个ThreadLocal类变量,这里创建时用了一个匿名类,覆盖了initialValue方法,主要作用是创建时初始化实例。也可以采用下面方式创建

private static final String DATE_FORMAT = "yyyyMMddHH24mmssS";//第一次调用get将返回null

private static ThreadLocal threadLocal = new ThreadLocal();//获取线程的变量副本,如果不覆盖initialValue,第一次get返回null,//故需要初始化一个SimpleDateFormat,并set到threadLocal中

public staticDateFormat getDateFormat() {

DateFormat df=threadLocal.get();if (df == null) {

df= newSimpleDateFormat(DATE_FORMAT);

threadLocal.set(df);

}returndf;

}

1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值