ThreadLocal 的原理讲述 + 基于ThreadLocal实现MVC中的M层的事务控制

ThreadLocal 的原理讲述 + 基于ThreadLocal实现MVC中的M层的事务控制

[toc]

每博一文案

 

tex

复制代码

生活不是努力了就可以变好的,喜欢做的事情也不是轻易就可以做的。以前总听别人说, 坚持就好了,努力就好了,都会好的,可是真的做起来压根就不是这样。这种时候要怎么办? 这种时候还能轻易地相信时间吗? 我总是一时间不知道怎么回答:直到今天我决定记录这些日子的生活时,直到我写完以上的文字时,我 脑海里才出现了一个清晰的答案。四个字:尽力而为。 我想这样的。世事无常,分道扬镳,生老病死,我们常常没法得偿所愿。 然而我们都必须尽力而为。 我觉得挺好的:把眼前的事情做好就行了,路都是走着走着才知道能走到哪里的。 越是焦虑,就越是要回到生活里去。因为身处迷雾中本就很难找到方向,能看见的也就 眼前的五米,那就五米五米地一步步走下去。 至于路能走成什么样,又能走去哪里...... 走着走着,就都知道了。 但或许其实终点到底是哪里也不是那么重要。 重要的是,我们走了很远的路,最终找到的人,是我们自己。 是哪个可以很好地应对挫折,应对痛苦,应对生活的变故的自己。 是那个依然前行,依然努力,依然能够为了小事而欣喜,为了善良而感动的自己。 是那个终于学会了珍惜的自己,是那个不再害怕平方的自己。 生活如河,自己就是自己的船。 ——————卢思浩《你也走了,很远的路吧》

1. ThreadLocal 给概述

ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程 ,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。

ThreadLoal 变量,线程局部变量,同一个 ThreadLocal 所包含的对象,在不同的 Thread 中有不同的副本。这里有几点需要注意:

  • 因为每个 Thread 内有自己的实例副本,且该副本只能由当前 Thread 使用。这是也是 ThreadLocal 命名的由来。
  • 既然每个 Thread 有自己的实例副本,且其它 Thread 不可访问,那就不存在多线程间共享的问题

ThreadLocal 提供了线程本地的实例。它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。ThreadLocal 变量通常被private static修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。

  • 这种变量在多线程环境下访问(通过get和set方法访问)时能保证各个线程的变量相对独立于其他线程内的变量
  • 在线程的生命周期内起作用,可以减少同一个线程内多个函数或组件之间一些公共变量传递的复杂度

总的来说,ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景

下图可以增强理解:

2. 抛砖引玉——>ThreadLocal

从上述一篇文章中:我们运用 **MVC的架构模式——> 实现了用户转账的功能:**🔜🔜🔜  MVC 三层架构案例详细讲解_ChinaRainbowSea的博客-CSDN博客 但是其中存在,一个事务处理的问题。

如下事务控制的处理是在:M(Model 模型层/业务逻辑处理层) 的源码(注意: 对应事务上的控制一定是在 M层当中的),我们可以看到其中并没有进行一个事务上从处理。

 

java

复制代码

package com.RainbowSea.bank.mvc; /** * service 翻译为:业务。 * AccountService 专门处理Account业务的一个类 * 在该类中应该编写纯业务代码。(只专注域业务处理,不写别的,不和其他代码混合在一块) * 只希望专注业务,能够将业务完美实现,少量bug. * <p> * 业务类一般起名:XXXService,XXXBiz... */ public class AccountService { // 这里的方法起名,一定要体现出,你要处理的是什么业务: // 我们要提供一个能够实现转账的业务的方法(一个业务对应一个方法) // 比如:UserService StudentService OrderService // 处理Account 转账业务的增删改查的Dao private AccountDao accountDao = new AccountDao(); /** * 完成转账的业务逻辑 * * @param fromActno 转出账号 * @param toActno 转入账号 * @param money 转账金额 */ public void transfer(String fromActno, String toActno, double money) throws MoneyNotEnoughException, AppException { // 查询余额是否充足 Account fromAct = accountDao.selectByActno(fromActno); if (fromAct.getBalance() < money) { throw new MoneyNotEnoughException("对不起,余额不足"); } // 程序到这里说明余额充足 Account toAct = accountDao.selectByActno(toActno); // 修改金额,先从内存上修改,再从硬盘上修改 fromAct.setBalance(fromAct.getBalance() - money); toAct.setBalance(toAct.getBalance() + money); // 从硬盘数据库上修改 int count = accountDao.update(fromAct); count += accountDao.update(toAct); if(count != 2) { throw new AppException("账户转账异常,请联系管理员"); } } }

如下:如果我们没有进行事务处理控制存在一个什么样的问题:

假如:

用户 act001 ——> 转账给用户 act002 ,10000元

转账的过程中,突然用户 act001 网络出现了问题,转账失败了。

注意:这里转账失败了,用户act001的钱是不应该减少的,因为我们没有转账成功嘛

可是这里,并没有进行一个事务上的控制,导致的结果就是,我们转账失败的,但是用户 act001 的钱少 10000,用户act002 的钱却没有增加。其中 用户 act001 的 10000 元丢失在了,网络中,这是不可以的。用户会发飙的,钱转账失败了,钱还少了。这不是坑钱嘛。

如下测试:

我们执行转账操作:act002 转账给用户 act001 ,10000元,中途发生网络中断:

下面我们进行“事务的控制”:

在Java中要进行事务的控制就需要使用到 Connectino对象了。

 

java

复制代码

// 开启事务,不会自动提交数据给数据库 connection.setAutoCommit(false); connection.commit(); // 提交数据 connection.rollback(); // 事务的回滚

 

java

复制代码

package com.RainbowSea.bank.mvc; import com.RainbowSea.bank.utils.DBUtil; import java.sql.Connection; import java.sql.SQLException; /** * service 翻译为:业务。 * AccountService 专门处理Account业务的一个类 * 在该类中应该编写纯业务代码。(只专注域业务处理,不写别的,不和其他代码混合在一块) * 只希望专注业务,能够将业务完美实现,少量bug. * <p> * 业务类一般起名:XXXService,XXXBiz... */ public class AccountService { // 这里的方法起名,一定要体现出,你要处理的是什么业务: // 我们要提供一个能够实现转账的业务的方法(一个业务对应一个方法) // 比如:UserService StudentService OrderService // 处理Account 转账业务的增删改查的Dao private AccountDao accountDao = new AccountDao(); /** * 完成转账的业务逻辑 * * @param fromActno 转出账号 * @param toActno 转入账号 * @param money 转账金额 */ public void transfer(String fromActno, String toActno, double money) throws MoneyNotEnoughException, AppException { // 查询余额是否充足 Account fromAct = accountDao.selectByActno(fromActno); Connection connection = DBUtil.getConnection(); try { if (fromAct.getBalance() < money) { throw new MoneyNotEnoughException("对不起,余额不足"); } // 程序到这里说明余额充足 Account toAct = accountDao.selectByActno(toActno); // 修改金额,先从内存上修改,再从硬盘上修改 fromAct.setBalance(fromAct.getBalance() - money); toAct.setBalance(toAct.getBalance() + money); // 开启事务,不会自动提交数据给数据库 connection.setAutoCommit(false); // 从硬盘数据库上修改 int count = accountDao.update(fromAct); // null 引用异常,模拟转账过程中发生网络异常,转账失败 String s = null; s.toString(); count += accountDao.update(toAct); if(count != 2) { throw new AppException("账户转账异常,请联系管理员"); } // 程序走到这说明,没有问题,提交数据给数据库 connection.commit(); // 提交数据 } catch (SQLException e) { try { connection.rollback(); // 事务的回滚 } catch (SQLException ex) { throw new RuntimeException(ex); } throw new RuntimeException(e); } finally { DBUtil.close(connection,null,null); } } }

我们再次进行一个,转账看看是否,真的做到的事务的处理:也就是,转账失败的,act001用户的钱不会减少。

如果结果:为什么我们使用了Connection 对象进行了一个事务的控制,但是,还是会少钱???事务上并没有控制成功。

为什么会出现如上:情况,明明我们使用了 Connection 进行了一个事务上的控制,但是,却还是会少钱,就没有把事务真正控制上了。

解释:

这里你却是是将事务开启了,但是,你开启的事务是对于当前:AccountService类当中的transfer( )方法当中的Connetion 局部变量进行了一个事务上的控制。我们对数据库的修改是存在两个位置的:

其中这两个位置上的 accountDao.update() 方法中同样是存在了一个 Connection 对象的

简单的说就是:我们对数据的更新,需要通过:两个位置上的更新

  1. AccountService类当中的transfer( )方法当中的Connetion 局部变量进行了一个事务上的控制。
  2. AccountDao 类当中的update() 方法到当中的Connection 局部变量进行一个事务上的控制。
 
 

复制代码

存在一个问题就是:我们这里的service 层虽然进行了事务的控制,但是这里的使用的 Connection事务控制 的对象是不一致的,也就是说:我们Connection的事务控制对应不上,我们对数据库修改的操作上,就导致无法对 数据库进行事务控制。

操作同一个事务,但是存在两个Connection ,而且这两者之间的Connection 对象是不一致的,就会导致事务的控制失败。

因为你控制了一个Connection,但是还存在一个Connection ,没有对事务进行控制。

如何解决上述问题:

解决方法:

既然一个操作同一个事务,存在两个不同的Connection。

那我们就控制成:同一个事务,虽然存在两个Connection,但是它们的值是一样的,也就是同一个事务上的处理,一个Connection就够了。

怎么做到,共用一个Connection,我们可以通过传引用类型参数的方式:

如下修改:

测试:

优化:

虽然我们上述:通过传引用类型的参数,对Connetion 对象进行了共用的操作。

但是存在一个问题就是:我们每次对数据库操作,进行一个事务的处理,在 M(Model)层都要先创建一个Connection对象,并将该对象作为参数传送给 XXxDao,这样的操作,大大提高代码的耦合度。背离了 "高内聚,低耦合" 的思想。

有没有别的方法,将Connetion 存储起来,做到同一个线程当中获取到的Connection 都是同一个,不同的线程获取到的Connection是不同的。

有的,我们的ThreadLocal 就实现了这种方式。

同一个线程,我们知道在同一个线程当的 Thread 线程对象是一样的如下测试:

,既然同一个线程的Thread 是一样,那么我们可不可以,创建一个大Map ,将 Thread 作为 key ,其中Connection 作为value,所有需要同一个线程共用Connection对象的,都从这个 大Map当中获取。因为同一个线程的 Thread 都是一样的,而通过Thread 作为key ,获取到的Value(也就是 Connection )也就是一样的了。

3. ThreadLocal 的模拟编写

根据上述的讲述,我们这里来模拟编写一个 大Map,将 Thread 作为 key ,其中Connection 作为value。其中这种在Java中就叫做:ThreadLocal

首先,这里我们先演示没有使用:大Map的结果:

自定义的Connection 类

 

java

复制代码

package com.rainbowSea.testThreadLocal; public class MyConnection { }

 

java

复制代码

package com.rainbowSea.testThreadLocal; public class UserDao { public void insert(){ MyConnection myConnection = new MyConnection(); System.out.println("UserDao Connection : " + myConnection); Thread thread = Thread.currentThread(); System.out.println("UserDao Thread : " + thread); } }

 

java

复制代码

package com.rainbowSea.testThreadLocal; public class UserService { public UserDao userDao = new UserDao(); public void save() { MyConnection myConnection = new MyConnection(); System.out.println("UserService Connection :" + myConnection); Thread thread = Thread.currentThread(); System.out.println("UserService Thread : " + thread); userDao.insert(); } }

 

java

复制代码

package com.rainbowSea.testThreadLocal; public class Test { public static void main(String[] args) { MyConnection myConnection = new MyConnection(); System.out.println("Test Connection : " + myConnection); Thread thread = Thread.currentThread(); System.out.println("Test Thread : " + thread); UserService userService = new UserService(); userService.save(); } }

使用 "大Map处理:"

 

java

复制代码

package com.rainbowSea.testThreadLocal; import java.util.HashMap; import java.util.Map; public class MyThreadLocal<T> { /** * 所有需要和当前线程绑定的数据要放到整个容器当中 */ private Map<Thread,T> map = new HashMap<Thread,T>(); /** * 向ThreadLocal 中绑定数据 * 注意是:Thread.currentThread() 当前线程作为 key 存在 */ public void set(T t) { map.put(Thread.currentThread(),t); } /** * 向ThreadLocal 当中获取数据 * 注意:获取到的是当前线程的Connection绑定的数据, */ public T get() { // 通过 Thread.currentThread()当中线程作为key ,获取到对应的 value值 return map.get(Thread.currentThread()); } /** * 移除ThreadLocal当中数据 * 注意:移除的是Thread.currentThread()当前线程作为key 存储的数据信息。 */ public void remove() { map.remove(Thread.currentThread()); } }

 

java

复制代码

package com.rainbowSea.testThreadLocal; public class DBUtil { // 静态变量特点:类加载时执行,并且只执行一次 // 全局的大Map集合 public static MyThreadLocal<MyConnection> local = new MyThreadLocal<MyConnection>(); /** * 每一次都调用这个方法来获取Connection 对象 */ public static MyConnection getConnection() { // 从这个大的Map当中获取 Connection 对象 MyConnection connection = local.get(); // 如果是第一次:获取到的话这个 大MyThreadLocal 是没有存储到 Connection 对象的 // 所有我们需要向 MyThreadLocal 添加上 if (connection == null) { connection = new MyConnection(); // 添加到 这个大Map当中 local.set(connection); } // 返回从这个大Map当中获取到的Connection对象 return connection; } }

 

java

复制代码

package com.rainbowSea.testThreadLocal; public class Test { public static void main(String[] args) { // 从大Map MyThreadLocal中获取Connection对象 MyConnection myConnection = DBUtil.getConnection(); System.out.println("Test Connection : " + myConnection); UserService userService = new UserService(); userService.save(); } }

 

java

复制代码

package com.rainbowSea.testThreadLocal; public class UserService { public UserDao userDao = new UserDao(); public void save() { // 从大Map MyThreadLocal中获取Connection对象 MyConnection myConnection = DBUtil.getConnection(); System.out.println("UserService Connection :" + myConnection); userDao.insert(); } }

 

java

复制代码

package com.rainbowSea.testThreadLocal; public class UserDao { public void insert(){ // 从大Map MyThreadLocal中获取Connection对象 MyConnection myConnection = DBUtil.getConnection(); System.out.println("UserDao Connection : " + myConnection); } }

测试:

4. ThreadLocal 源码原理分析

下面我们来看看,Java为我们提供的 ThreadLocal 类吧

ThreadLocal的主要用途是实现线程间变量的隔离,表面上他们使用的是同一个ThreadLocal, 但是实际上使用的值value却是自己独有的一份。 用一图直接表示threadlocal 的使用方式

从图中我们可以当线程使用threadlocal 时,是将threadlocal当做当前线程thread的属性ThreadLocalMap 中的一个Entry的key值,实际上存放的变量是Entry的value值,我们实际要使用的值是value值。 value值为什么不存在并发问题呢,因为它只有一个线程能访问。threadlocal我们可以当做一个索引看待,可以有多个threadlocal 变量,不同的threadlocal对应于不同的value值,他们之间互不影响。ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。简单的说就是:实现一个线程当中的信息对象共用,共享。代替传引用类型参数的方式。

这里我们使用ThreadLocal是基于一个用户转账的案例来讲解的,为了解决事务上控制问题,一个线程共用一个Connection 。其中的我们的ThreadLocal 就作为了一个容器,其中的key 存储的就是当前线程,而value值则是对应Connection

5. ThreadLocal 常用方法

方法名描述
ThreadLocal()创建ThreadLocal对象
public void set( T value)设置当前线程绑定的局部变量
public T get()获取当前线程绑定的局部变量
public T remove()移除当前线程绑定的局部变量,该方法可以帮助JVM进行GC
protected T initialValue()返回当前线程局部变量的初始值

5.1 ThreadLocal的set()方法

 

java

复制代码

public void set(T value) { //1、获取当前线程 Thread t = Thread.currentThread(); //2、获取线程中的属性 threadLocalMap ,如果threadLocalMap 不为空, //则直接更新要保存的变量值,否则创建threadLocalMap,并赋值 ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else // 初始化thradLocalMap 并赋值 createMap(t, value); }

 

java

复制代码

/** * 设置当前线程对应的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,this这里指调用此方法的ThreadLocal对象 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); }

从上面的代码可以看出,ThreadLocal set赋值的时候首先会获取当前线程thread,并获取thread线程中的ThreadLocalMap属性。如果map属性不为空,则直接更新value值,如果map为空,则实例化threadLocalMap,并将value值初始化。

那么ThreadLocalMap又是什么呢,还有createMap又是怎么做的,我们继续往下看。大家最后自己再idea上跟下源码,会有更深的认识。

 

java

复制代码

static class ThreadLocalMap { /** * 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<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } } }

可看出ThreadLocalMap是ThreadLocal的内部静态类,而它的构成主要是用Entry来保存数据 ,而且还是继承的弱引用。在Entry内部使用ThreadLocal作为key,使用我们设置的value作为value。详细内容要大家自己去跟。

 

java

复制代码

//这个是threadlocal 的内部方法 void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); } //ThreadLocalMap 构造方法 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); }

  1. 获取当前线程,并根据当前线程获取一个Map
  2. 如果获取的Map不为空,则将参数设置到Map中(当前ThreadLocal的引用作为key)
  3. 如果Map为空,则给该线程创建 Map,并设置初始值

5.2 ThreadLocal的get( )方法

 

java

复制代码

/** * 返回当前线程中保存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. 获取当前线程, 根据当前线程获取一个Map
  2. 如果获取的Map不为空,则在Map中以ThreadLocal的引用作为key来在Map中获取对应的Entrye,否则转到4
  3. 如果e不为null,则返回e.value,否则转到4
  4. Map为空或者e为空,则通过initialValue函数获取初始值value,然后用ThreadLocal的引用和value作为firstKey和firstValue创建一个新的Map

5.3 ThreadLocal的remove( )方法

 

java

复制代码

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

执行流程:

  1. 首先获取当前线程,并根据当前线程获取一个Map
  2. 如果获取的Map不为空,则移除当前ThreadLocal对象对应的entry

remove()方法,直接将ThrealLocal 对应的值从当前相差Thread中的ThreadLocalMap中删除。

为什么要删除,这涉及到内存泄露的问题?。

实际上 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,弱引用的特点是,如果这个对象只存在弱引用,那么在下一次垃圾回收的时候必然会被清理掉。

所以如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候会被清理掉的,这样一来 ThreadLocalMap中使用这个 ThreadLocal 的 key 也会被清理掉。但是,value 是强引用,不会被清理,这样一来就会出现 key 为 null 的 value。

ThreadLocal其实是与线程绑定的一个变量,如此就会出现一个问题:如果没有将ThreadLocal内的变量删除(remove)或替换,它的生命周期将会与线程共存。通常线程池中对线程管理都是采用线程复用的方法,在线程池中线程很难结束甚至于永远不会结束,这将意味着线程持续的时间将不可预测,甚至与JVM的生命周期一致。举个例字,如果ThreadLocal中直接或间接包装了集合类或复杂对象,每次在同一个ThreadLocal中取出对象后,再对内容做操作,那么内部的集合类和复杂对象所占用的空间可能会开始持续膨胀。

5.4 ThreadLocal 的 initialValue( )方法

  • 此方法的作用是返回该线程局部变量的初始值
  • 这个方法是一个延迟调用方法,从上面的代码我们得知,在set方法还未调用而先调用了get方法时才执行,并且仅执行1次
  • 这个方法缺省实现直接返回一个null
  • 如果想要一个除null之外的初始值,可以重写此方法。(备注: 该方法是一个protected的方法,显然是为了让子类覆盖而设计的)
 

java

复制代码

/** * 返回当前线程对应的ThreadLocal的初始值 * 此方法的第一次调用发生在,当线程通过get方法访问此线程的ThreadLocal值时 * 除非线程先调用了set方法,在这种情况下,initialValue 才不会被这个线程调用。 * 通常情况下,每个线程最多调用一次这个方法。 * * <p>这个方法仅仅简单的返回null {@code null}; * 如果想ThreadLocal线程局部变量有一个除null以外的初始值, * 必须通过子类继承{@code ThreadLocal} 的方式去重写此方法 * 通常, 可以通过匿名内部类的方式实现 * * @return 当前ThreadLocal的初始值 */ protected T initialValue() { return null; }

6. ThreadLocal 注意移除数据

当我们对于 ThreadLocal 中的value值资源对象,使用完毕的时候,一定要执行ThreadLocal.remove()对象方法的移除绑定在ThreadLocal中的资源信息。因为一般ThreadLocal的使用场景都是在 多线程的,而多线程一般都是使用线程池管理线程的。就会存在一个问题?

我们通过上述账户转账案例来讲解这个问题:

我们的运行环境是在Tomcat10,Tomcat10本身就是一个多线程的。

如下当我们对应一个数据库操作完以后,我们需要将对应的资源释放。最后使用的最先关闭,分开 try,防止关闭资源的时候出现异常导致其他资源没有关闭。

如下:我们的ThreadLocal对应的 value 是 Connection 对象

 

java

复制代码

// 创建 ThreadLocal 容器存储绑定线程相关的 信息 private static ThreadLocal<Connection> threadLocal = new ThreadLocal<Connection>(); /** * 这里没有使用数据库连接池,直接创建连接对象 */ public static Connection getConnection() { Connection connection = threadLocal.get(); // 从ThreadLocal容器中获取 try { // 第一次ThreadLocal 是为空的 if (connection == null) { connection = DriverManager.getConnection(url, user, password); threadLocal.set(connection); } } catch (SQLException e) { throw new RuntimeException(e); } return connection; } if (connection != null) { try { connection.close(); threadLocal.remove(); // 注意关闭资源的时候需要将绑定在threadLocal移除 } catch (SQLException e) { throw new RuntimeException(e); } }

当我们将Connection 资源关闭了,但是:其中该Connection 存储在ThreadLocal当中value值,却是还存储着是为 当前Connection 关闭了的对象的。由于Tomcat是多线程,其中Tomcat服务器是内置了一个线程池的。线程池中的多线程对象是有限的, 这样线程对象 t1,t2,t3 都是提前创建好的,也就是说,t1,t2,t3,是在重复使用的,如果你没有将其 ThreadLocal.remove( 移除掉), 当新的用户,一个新的线程,出现的时候,可能会获取到上一个Connection 已经关闭了的对象,t1线程对象。从而导致的结果就是 这个新用户使用的是 t1 这个对应上的Connection 对象已经关闭了,出现错误。

所以对于: ThreadLocal 中的value值资源对象,使用完毕的时候,一定要执行ThreadLocal.remove()对象方法的移除绑定在ThreadLocal中的资源信息

7. ThreadLocal 内存泄漏

1 . 没有手动删除这个 Entry 2 . CurrentThread 当前线程依然运行

​ 第一点很好理解,只要在使用完下 ThreadLocal ,调用其 remove 方法删除对应的 Entry ,就能避免内存泄漏。 ​ 第二点稍微复杂一点,由于ThreadLocalMap 是 Thread 的一个属性,被当前线程所引用,所以ThreadLocalMap的生命周期跟 Thread 一样长。如果threadlocal变量被回收,那么当前线程的threadlocal 变量副本指向的就是key=null, 也即entry(null,value),那这个entry对应的value永远无法访问到。实际私用ThreadLocal场景都是采用线程池,而线程池中的线程都是复用的,这样就可能导致非常多的entry(null,value)出现,从而导致内存泄露。 综上, ThreadLocal 内存泄漏的根源是: 由于ThreadLocalMap 的生命周期跟 Thread 一样长,对于重复利用的线程来说,如果没有手动删除(remove()方法)对应 key 就会导致entry(null,value)的对象越来越多,从而导致内存泄漏.

8. 正确的使用ThreadLocal

  1. 将ThreadLocal变量定义成private static的,这样的话ThreadLocal的生命周期就更长,由于一直存在ThreadLocal的强引用,所以ThreadLocal也就不会被回收,也就能保证任何时候都能根据ThreadLocal的弱引用访问到Entry的value值,然后remove它,防止内存泄露
  2. 每次使用完ThreadLocal,都调用它的remove()方法,清除数据。

9. ThreadLocal 常见使用场景

如上文所述,ThreadLocal 适用于如下两种场景

  1. 每个线程需要有自己单独的实例
  2. 实例需要在多个方法中共享,但不希望被多线程共享

对于第一点,每个线程拥有自己实例,实现它的方式很多。例如可以在线程内部构建一个单独的实例。ThreadLoca 可以以非常方便的形式满足该需求。

对于第二点,可以在满足第一点(每个线程有自己的实例)的条件下,通过方法间引用传递的形式实现。ThreadLocal 使得代码耦合度更低,且实现更优雅。

10. 案例:MVC三层架构 + 面向接口编程 + ThreadLocal 事务处理实:现用户转账功能的优化

如下是我们对于上篇MVC三层架构🔜🔜🔜  MVC 三层架构案例详细讲解_ChinaRainbowSea的博客-CSDN博客 存在事务安全问题的优化:

对应的包架构:

  • resources: 表示一些资源:比如这里是一些连接数据库的一些配置信息:
 

java

复制代码

driver=com.mysql.jdbc.Driver url=jdbc:mysql://localhost:3306/mvc user=root password=MySQL

  • lib : 该项目所需要的依赖:注意:该目录名一定要为:lib,不然Tomcat 无法识别到,该目录一定要在web/WEB-INF/下,不然Tomcat 无法识别到的。

  • M(Model 模型层/业务逻辑处理层)
    • utils : 工具包,这里使用一个数据库连接的工具包。
    • dao : 表示对应XXx数据表的业务逻辑上的处理(增删改查)。面向接口编程:AccountDao,接口定义规范,这里的AccoutDao 定义了对于这张account数据表的业务逻辑上的操作规范。
      • Impl: 表示实现的接口的类:AccountDaoImplImpl命名为后缀,表示接口的实现类。这是大家共识的一种规范。
    • exceptions : 表示对应业务上自定义的异常。
    • javaBean: 表示对应的封装数据的实体类。
    • service: 表示对应的业务逻辑的处理。面向接口编程:AccountService,接口定义规范,这里的AccountService定义了对于这张account数据表的业务逻辑处理上的操作规范,可能需要多个 Dao同时配合。获取多个Service 之间相互配合。
      • Impl: 表示实现的接口的类:AccountServiceImplImpl命名为后缀,表示接口的实现类。这是大家共识的一种规范。
  • C(Controller 控制层):对应 M层,V的之间的桥梁,进行一个调度处理,本身仅仅只做一个调度,不进行业务的处理。比如一个事情:需要调度M层进行处理,同时需要将该M层处理的结果,通过调度V层显示给用户。

10.1 M(Model 模型层/业务逻辑处理层)

 

java

复制代码

package com.RainbowSea.bank.utils; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ResourceBundle; public class DBUtil { // resourceBundle 只能读取到 properties 后缀的文件,注意不要加文件后缀名 private static ResourceBundle resourceBundle = ResourceBundle.getBundle("resources/jdbc"); private static String driver = resourceBundle.getString("driver"); private static String url = resourceBundle.getString("url"); private static String user = resourceBundle.getString("user"); private static String password = resourceBundle.getString("password"); // DBUtil 类加载注册驱动 static { try { Class.forName(driver); } catch (ClassNotFoundException e) { e.printStackTrace(); } } // 将构造器私有化,不让创建对象,因为工具类中的方法都是静态的,不需要创建对象 // 为了防止创建对象,故将构造方法私有化 private DBUtil() { } // 创建 ThreadLocal 容器存储绑定线程相关的 信息 private static ThreadLocal<Connection> threadLocal = new ThreadLocal<Connection>(); /** * 这里没有使用数据库连接池,直接创建连接对象 */ public static Connection getConnection() { Connection connection = threadLocal.get(); // 从ThreadLocal容器中获取 try { // 第一次ThreadLocal 是为空的 if (connection == null) { connection = DriverManager.getConnection(url, user, password); threadLocal.set(connection); } } catch (SQLException e) { throw new RuntimeException(e); } return connection; } /** * 资源的关闭 * 最后使用的最先关闭,逐个关闭,防止存在没有关闭的 */ public static void close(Connection connection, PreparedStatement preparedStatement, ResultSet resultSet) { if (resultSet != null) { try { resultSet.close(); } catch (SQLException e) { throw new RuntimeException(e); } } if (preparedStatement != null) { try { preparedStatement.close(); } catch (SQLException e) { throw new RuntimeException(e); } } if (connection != null) { try { connection.close(); threadLocal.remove(); // 注意关闭资源的时候需要将绑定在threadLocal移除 } catch (SQLException e) { throw new RuntimeException(e); } } } }

 

java

复制代码

package com.RainbowSea.bank.javabeen; import java.io.Serializable; import java.util.Objects; /** * 账户实体类,封装账户信息的 * 一般是一张表一个。 * pojo 对象 * 有的人也会把这种专门封装数据的对象,称为:"bean对象" (javabean对象,咖啡豆) * 有的人也会把这种专门封装数据的对象,称为领域模型对象,domain对象 * 不同的程序员不同的习惯。 */ public class Account implements Serializable { // 这种普通的简单的对象被成为pojo对象 // 注意我们这里定义的数据类型,使用引用数据类型 // 因为我们数据库中可能存在 null 值,而基本数据类型是不可以存储 null值的 private Long id = null; // id private String actno; // 账号 private Double balance; // 余额 // 反序列化 private static final long serialVersionUID = 1L; public Account() { } public Account(Long id, String actno, Double balance) { this.id = id; this.actno = actno; this.balance = balance; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getActno() { return actno; } public void setActno(String actno) { this.actno = actno; } public Double getBalance() { return balance; } public void setBalance(Double balance) { this.balance = balance; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Account)) return false; Account account = (Account) o; return Objects.equals(getId(), account.getId()) && Objects.equals(getActno(), account.getActno()) && Objects.equals(getBalance(), account.getBalance()); } @Override public int hashCode() { return Objects.hash(getId(), getActno(), getBalance()); } @Override public String toString() { return "Account{" + "id=" + id + ", actno='" + actno + '\'' + ", balance=" + balance + '}'; } }

 

java

复制代码

package com.RainbowSea.bank.dao; import com.RainbowSea.bank.javabeen.Account; import java.util.List; public interface AccountDao { /** * 插入数据 * * @param account * @return */ public int insert(Account account); /** * 通过Id删除数据 * * @param id * @return */ public int deleteById(String id); /** * 更新数据 * * @param account * @return */ public int update(Account account); /** * 通过 actno 查找账户信息 * * @param actno * @return */ public Account selectByActno(String actno); /** * 查询所有的账户信息 * * @return */ public List<Account> selectAll(); }

 

java

复制代码

package com.RainbowSea.bank.dao.impl; import com.RainbowSea.bank.dao.AccountDao; import com.RainbowSea.bank.javabeen.Account; import com.RainbowSea.bank.utils.DBUtil; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.List; /** * AccountDao 是负责Account 数据的增上改查 * <p> * 1. 什么是DAO ? * Data Access Object (数据访问对象) * 2. DAO实际上是一种设计模式,属于 JavaEE的设计模式之一,不是 23种设计模式 * 3.DAO只负责数据库表的CRUD ,没有任何业务逻辑在里面 * 4.没有任何业务逻辑,只负责表中数据增上改查的对象,有一个特俗的称谓:DAO对象 * 5. 为什么叫做 AccountDao 呢? * 这是因为DAO是专门处理t_act 这张表的 * 如果处理t_act 表的话,可以叫做:UserDao * 如果处理t-student表的话,可以叫做 StudentDao * <p> * int insert() ; * int deleteByActno(); * int update() ; * Account selectByActno(); * List<Account> selectAll(); */ public class AccountDaoImpl implements AccountDao { /** * 插入数据 * * @param account * @return */ public int insert(Account account) { Connection connection = DBUtil.getConnection(); PreparedStatement preparedStatement = null; int count = 0; try { String sql = "insert into t_act(actno,balance) values(?,?)"; preparedStatement = connection.prepareStatement(sql); preparedStatement.setString(1, account.getActno()); preparedStatement.setDouble(2, account.getBalance()); count = preparedStatement.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(e); } finally { DBUtil.close(connection, preparedStatement, null); } return count; } /** * 通过Id删除数据 * * @param id * @return */ public int deleteById(String id) { Connection connection = DBUtil.getConnection(); int count = 0; PreparedStatement preparedStatement = null; try { String sql = "delete from t_act where id = ?"; preparedStatement = connection.prepareStatement(sql); preparedStatement.setString(1, id); count = preparedStatement.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(e); } finally { DBUtil.close(connection, preparedStatement, null); } return count; } /** * 更新数据 * * @param account * @return */ public int update(Account account) { PreparedStatement preparedStatement = null; Connection connection = DBUtil.getConnection(); // 从 ThreadLocal中获取到的 int count = 0; System.out.println("update: "+connection); try { String sql = "update t_act set balance = ?, actno = ? where id = ?"; preparedStatement = connection.prepareStatement(sql); //注意设置的 set类型要保持一致。 preparedStatement.setDouble(1, account.getBalance()); preparedStatement.setString(2, account.getActno()); preparedStatement.setLong(3, account.getId()); count = preparedStatement.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(e); } finally { DBUtil.close(null, preparedStatement, null); } return count; } /** * 通过 actno 查找账户信息 * * @param actno * @return */ public Account selectByActno(String actno) { PreparedStatement preparedStatement = null; ResultSet resultSet = null; Account account = new Account(); Connection connection = DBUtil.getConnection(); // 从 ThreadLocal中获取到的 System.out.println("selectByActno :" + connection); try { String sql = "select id,actno,balance from t_act where actno = ?"; preparedStatement = connection.prepareStatement(sql); //注意设置的 set类型要保持一致。 preparedStatement.setString(1, actno); resultSet = preparedStatement.executeQuery(); if (resultSet.next()) { Long id = resultSet.getLong("id"); Double balance = resultSet.getDouble("balance"); // 将结果集封装到java 对象中 account.setActno(actno); account.setId(id); account.setBalance(balance); } } catch (SQLException e) { throw new RuntimeException(e); } finally { DBUtil.close(null, preparedStatement, resultSet); } return account; } /** * 查询所有的账户信息 * * @return */ public List<Account> selectAll() { Connection connection = DBUtil.getConnection(); PreparedStatement preparedStatement = null; ResultSet resultSet = null; List<Account> list = null; try { String sql = "select id,actno,balance from t_act"; preparedStatement = connection.prepareStatement(sql); resultSet = preparedStatement.executeQuery(); while (resultSet.next()) { String actno = resultSet.getString("actno"); Long id = resultSet.getLong("id"); Double balance = resultSet.getDouble("balance"); // 将结果集封装到java 对象中 Account account = new Account(id,actno,balance); // 添加到List集合当中 list.add(account); } } catch (SQLException e) { throw new RuntimeException(e); } finally { DBUtil.close(connection, preparedStatement, resultSet); } return list; } }

 

java

复制代码

package com.RainbowSea.bank.exceptions; /** * 余额不足异常 */ public class AppException extends Exception{ public AppException() { } public AppException(String msg) { super(msg); } }

 

java

复制代码

package com.RainbowSea.bank.exceptions; /** * 余额不足异常 */ public class MoneyNotEnoughException extends Exception{ public MoneyNotEnoughException() { } public MoneyNotEnoughException(String msg) { super(msg); } }

 

java

复制代码

package com.RainbowSea.bank.service; import com.RainbowSea.bank.exceptions.AppException; import com.RainbowSea.bank.exceptions.MoneyNotEnoughException; public interface AccountService { public void transfer(String fromActno, String toActno, double money) throws MoneyNotEnoughException, AppException; }

 

java

复制代码

package com.RainbowSea.bank.service.impl; import com.RainbowSea.bank.dao.AccountDao; import com.RainbowSea.bank.dao.impl.AccountDaoImpl; import com.RainbowSea.bank.exceptions.AppException; import com.RainbowSea.bank.exceptions.MoneyNotEnoughException; import com.RainbowSea.bank.javabeen.Account; import com.RainbowSea.bank.service.AccountService; import com.RainbowSea.bank.utils.DBUtil; import java.sql.Connection; import java.sql.SQLException; /** * service 翻译为:业务。 * AccountService 专门处理Account业务的一个类 * 在该类中应该编写纯业务代码。(只专注域业务处理,不写别的,不和其他代码混合在一块) * 只希望专注业务,能够将业务完美实现,少量bug. * <p> * 业务类一般起名:XXXService,XXXBiz... */ public class AccountServiceImpl implements AccountService { // 这里的方法起名,一定要体现出,你要处理的是什么业务: // 我们要提供一个能够实现转账的业务的方法(一个业务对应一个方法) // 比如:UserService StudentService OrderService // 处理Account 转账业务的增删改查的Dao private AccountDao accountDao = new AccountDaoImpl(); // 多态:父类的引用指向子类 /** * 完成转账的业务逻辑 * * @param fromActno 转出账号 * @param toActno 转入账号 * @param money 转账金额 */ public void transfer(String fromActno, String toActno, double money) throws MoneyNotEnoughException, AppException { Thread thread = Thread.currentThread(); // 获取当前线程 System.out.println("seervice tranfer: " + thread); Connection connection = DBUtil.getConnection(); // 从ThreadLocal获取到的 // service 层控制事务: // 事务的控制需要 Connection 对象 try { // 自动管理,会自动关闭资源 // 开启事务 connection.setAutoCommit(false); System.out.println("service transfer: " + connection); // 查询余额是否充足 Account fromAct = accountDao.selectByActno(fromActno); if (fromAct.getBalance() < money) { throw new MoneyNotEnoughException("对不起,余额不足"); } // 程序到这里说明余额充足 Account toAct = accountDao.selectByActno(toActno); // 修改金额,先从内存上修改,再从硬盘上修改 fromAct.setBalance(fromAct.getBalance() - money); toAct.setBalance(toAct.getBalance() + money); // 从硬盘数据库上修改 int count = accountDao.update(fromAct); // 模拟异常 /* String s = null; s.toString();*/ count += accountDao.update(toAct); if (count != 2) { throw new AppException("账户转账异常,请联系管理员"); } // 提交事务 connection.commit(); } catch (SQLException e) { // 事务的回滚 // 因为我们这里是失败了,是不会提交数据的,数据库也就不会发生改变了。 throw new AppException("账户异常,请联系管理员"); } finally { // 关闭资源,移除ThreadLocal当中绑定的 Connection 对象 DBUtil.close(connection, null, null); } } }

10.2 C(Controller 控制层)

 

java

复制代码

package com.RainbowSea.bank.web; import com.RainbowSea.bank.exceptions.AppException; import com.RainbowSea.bank.exceptions.MoneyNotEnoughException; import com.RainbowSea.bank.service.AccountService; import com.RainbowSea.bank.service.impl.AccountServiceImpl; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; /** * 账户小程序 * AccountServlet 是一个司令官,他负责调度其他组件来完成任务。 * */ @WebServlet("/transfer") public class AccountServlet extends HttpServlet { // AccountServlet 作为一个 Controller 司令官 @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 获取数据 String fromActno = request.getParameter("fromActno"); String toActno = request.getParameter("toActno"); double money = Double.parseDouble(request.getParameter("money")); // 调用业务方法处理业务(调度Model处理业务,其中是对应数据表的 CRUD操作) AccountService accountService = new AccountServiceImpl(); // 多态 父类的引用指向子类 try { accountService.transfer(fromActno,toActno,money); // 执行到这里说明,成功了, // 展示处理结束(调度 View 做页面展示) response.sendRedirect(request.getContextPath()+"/success.jsp"); } catch (MoneyNotEnoughException e) { // 执行到种类,说明失败了,(余额不足 // 展示处理结束(调度 View 做页面展示) response.sendRedirect(request.getContextPath()+"/error.jsp"); } catch (AppException e) { // 执行到种类,说明失败了,转账异常 // 展示处理结束(调度 View 做页面展示) response.sendRedirect(request.getContextPath()+"/error.jsp"); } // 页面的展示 (调度View做页面展示) } }

10.3 V(View 显示层)

 

java

复制代码

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>银行账号转账</title> </head> <body> <form action="<%=request.getContextPath()%>/transfer" method="post"> 转出账户: <input type="text" name="fromActno" /> <br> 转入账户: <input type="text" name="toActno" /> <br> 转账金额: <input type="text" name="money" /><br> <input type="submit" value="转账" /> </form> </body> </html>

 

java

复制代码

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>转账成功</title> </head> <body> <h3>转账成功</h3> </body> </html>

 

java

复制代码

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>转账失败</title> </head> <body> <h3>转账失败</h3> </body> </html>

10.4 测试

11. ThreadLocal与Synchronized的区别

ThreadLocal其实是与线程绑定的一个变量。 ThreadLocal和Synchonized都用于解决多线程并发访问。

但是ThreadLocal与synchronized有本质的区别:

  1. Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离。

  2. Synchronized是利用锁的机制,使变量或代码块在某一时该只能被一个线程访问。而ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。

  3. 而Synchronized却正好相反,它用于在多个线程间通信时能够获得数据共享。

一句话理解ThreadLocal,threadlocl是作为当前线程中属性ThreadLocalMap集合中的某一个Entry的key值Entry(threadlocl,value),虽然不同的线程之间threadlocal这个key值是一样,但是不同的线程所拥有的ThreadLocalMap是独一无二的,也就是不同的线程间同一个ThreadLocal(key)对应存储的值(value)不一样,从而到达了线程间变量隔离的目的,但是在同一个线程中这个value变量地址是一样的。

12. ThreadLocal与Thread,ThreadLocalMap之间的关系

Thread、THreadLocal、ThreadLocalMap之间啊的数据关系图

从这个图中我们可以非常直观的看出,ThreadLocalMap其实是Thread线程的一个属性值,而ThreadLocal是维护ThreadLocalMap这个属性指的一个工具类。Thread线程可以拥有多个ThreadLocal维护的自己线程独享的共享变量(这个共享变量只是针对自己线程里面共享)

13. 总结:

  1. ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程 ,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。

  2. ThreadLocal的主要用途是实现线程间变量的隔离,表面上他们使用的是同一个ThreadLocal, 但是实际上使用的值value却是自己独有的一份。 简单的说就是:实现一个线程当中的信息对象共用,共享。代替传引用类型参数的方式。

  3. ThreadLocal 常用的方法。

  4. 当我们对于 ThreadLocal 中的value值资源对象,使用完毕的时候,一定要执行ThreadLocal.remove()对象方法的移除绑定在ThreadLocal中的资源信息

作者:RainbowSea
链接:https://juejin.cn/post/7234061982579802169

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值