Java进阶(3)、并发编程

一、 线程基础概念


  线程的概念

线程是进程中的一个最小执行单位,是一个对立的任务,是cpu执行的最小单元

把一些独立的热舞放在线程中执行,多个线程可以同时并发的执行,提高了程序处理速度

  创建线程

1. 类 继承 Thread类

步骤:
1.创建一个继承于Thread类的子类
2.重写Thread类的run() --> 将此线程执行的操作声明在run()中
3.创建Thread类的子类的对象
4.通过此对象调用start()执行线程

2. 类 实现Runnable接口 重写public void run()方法 new Thread(任务)

步骤:
1.创建一个实现了Runnable接口的类
2.实现类去实现Runnable中的抽象方法:run()
3.创建实现类的对象
4.将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
5.通过Thread类的对象调用start()
① 启动线程
②调用当前线程的run()–>调用了Runnable类型的target的run()

方式一和方式二的比较:

  • 开发中优先选择实现Runnable接口的方式
  • 原因:
    (1)实现的方式没有类的单继承性的局限性
    (2)实现的方式更适合来处理多个线程有共享数据的情况
  • 相同点:两种方式都需要重写run(),将线程要执行的逻辑声明在run()中

3. 类 实现Callable接口 重写public T call() throws Exception 方法

步骤:
1.创建一个实现Callable的实现类
2.实现call方法,将此线程需要执行的操作声明在call()中
3.创建Callable接口实现类的对象
4.将此Callable接口实现类的对象作为传递到FutureTask构造器中,创建FutureTask的对象
5.将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start()
6.获取Callable中call方法的返回值

4. 使用线程池

  线程方法

run()方法:要执行的任务

start()方法:启动线程

sleep(时间):让线程休眠

join():将线程加入,使得体坛线程休眠等待当前线程执行完后再执行

currentThread():获取当前正在执行的线程

  线程的状态

创建状态: new Thread 此时还不能执行

就绪状态: start() 把该线程注册到操作系统进行排队

运行状态: 线程获得了cpu的执行

阻塞状态: sleep() / join()加入的线程 / wait等待的线程 会堵塞,期间操作系统就不再调用了

                等阻塞动作完成之后,再回到就绪状态

死亡状态:任务运行结束 或 出现异常没有处理

  多线程访问共享数据

存在资源竞用问题

解决方法:加锁

  加锁

使用 synchronized 关键字来修饰代码块和方法

同步锁对象,任意类的对象头可以,但是只能是唯一的一个对象,记录有没有线程进入到同步代码块

synchronized(同步锁对象){

}

    /*
        synchronized修饰方法时,同步锁对象不需要我们指定,是自动提供的
        同步锁对象会默认提供:
           1.非静态的方法--锁对象默认是this
           2.静态方法--锁对象是当前类的Class对象(类的对象,一个类的对象只有一个)
     */

ReentrantLock类实现

lock()

unlock()

synchronized 和 ReentrantLock区别:

synchronized是一个关键字,控制依靠底层编译后的执令去实现
synchronized可以修饰一个方法,还可以修饰一个代码块
synchronized是隐式的加锁和释放锁,一旦方法或代码块中运行结束或出现异常,会自动释放锁


ReentrantLock是一个类,是依靠java代码去控制(底层有一个同步队列)
ReentrantLock只能修饰代码块
ReentrantLock需要手动的加锁,手动的释放锁,所以释放锁最好写在finally中,一旦出现异常,保证锁能释放

  线程通信(生产者——消费者模型)

wait()方法、notify()方法、notifyAll()方法 都只能在同步代码块中使用,他们是Object类中定义的方法,调用的对象只能是锁对象

sleep() 和 wait() 的区别

sleep(时间) 休眠指定的时间,时间到了后,会自动进入就绪状态,休眠期间不会释放锁(进程保留)

wait() 线程等待,不会自己醒来,需要其他线程唤醒,wait() 是会释放锁的(此时其他线程可以运行),是Object类中的方法

二、 多线程


●  多线程优点

提高程序的相应处理速度,提高cpu的利用率,压榨硬件的剩余价值

问题:多线程访问同一资源

现在cpu是多内核的,在理论上是可以同时执行多个线程的

并行执行:在同一个时间节点上,多个线程同时执行

并发执行:在同一个时间段内,多个线程交替执行 微观上是一个一个的执行,宏观上感觉是同步执行

  并发编程核心问题

即就是多个线程访问共享数据时,出现问题的根本原因

Java内存模型 Java Memory Model(JMM)

Java内存模型是变量数据(票数)都存在主内存中,每个线程还有自己的工作内存(本地内存)

规定线程不能直接对主内存中的数据操作,只能把主内存数据加载到自己的工作内存中操作,操作完成后,再写回主内存

这样设计就会引发不可见性问题:一个线程在自己的工作内存中操作了数据后,在没有写回到主内存之前,另一个线程无法预知数据改变

  乱序性

        为了优化指令执行,在执行一些等待时间长的执行时,可以把其他的一些指令提前执行,提高速度,但是在多线程场景下,就可能会出现问题

  非原子性

一个或多个操作在 CPU 执行的过程中不被中断的特性,我们称为原子性

线程的切换执行带来非原子性问题

CPU保证的原子性执行时CPU指令级别的,但是对于高级语言的一条代码,有时是要拆分成多条指令的,现成在执行到某条指令时,操作系统回切换到其他线程去执行,这样这条高级语言指令执行就是非原子性的

例如: ++操作可以分为3条指令:1、加载主内存数据到工作内存;2、这工作内存操作数据;3、写回主内存

总结:工作内存的缓存导致了不可见性,指令的优化导致了乱序(无序)性,线程的切换执行导致非原子性

三、 volatile关键字(解决乱序性)


一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后:

1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

2. 禁止进行指令重排序。(不会优化排序,解决了乱序性)

3. volatile 不能保证对变量操作的原子性。

解决非原子性问题可以通过加锁的方式实现

四、 如何保证原子性(加锁)


        “ 同一时刻只有一个线程执行 ” 我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的那么就都能保证原子性

  锁

锁是一种通用的技术方案,通过 synchronized 关键字可以使用

synchronized 一定能保证原子性,因为被 synchronized 修饰某段代码后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行该代码,所以一定能保证原子操作

  原子变量

一个是 locks 包,一个是 atomic 包,它们可以解决原子性问题

加锁是一种阻塞式方式实现

原子变量是非阻塞式方式实现

  原子类

原子类原理(AtomicInteger 为例)

原子类的原子性是通过 volatile + CAS 实现原子操作的。

AtomicInteger 类中的 value 是有 volatile 关键字修饰的,这就保证了 value 的内存可见性,这为后续的 CAS 实现提供了基础。

低并发情况下:使用 AtomicInteger。

  CAS(Compare-And-Swap)

比较并交换,该算法是硬件对于并发操作的支持(是乐观锁的一种实现,即未加锁的)

        采用的是自旋的思想,是一种轻量级的锁机制。即每次判断我的预期值和内存中的值是不是相同,如果不相同则说明该内存值已经被其他线程更新过了,因此需要拿到该最新的主内存的值作为预期值,重新操作、判断,直到预期值与主内存中的值相同(即主内存中的值没有因为其他线程操作而改变)

CAS 包含了三个操作数:

①内存值 V

②预估值 A (比较时,从内存中再次读到的值)

③更新值 B (更新后的值)

当且仅当预期值 A==V,将内存值 V=B,否则什么都不做。

CAS 的缺点

CAS 使用自旋锁的方式,由于该锁会不断循环判断,因此不会类似 synchronize 线程阻塞导致线程切换。但是不断的自旋,会导致 CPU 的消耗,在并发量大的时候容易导致 CPU 跑满。

A B A问题

线程A从中内存中回去值后,这期间有其他线程已经多次修改内存数据,最终有修改的和线程A拿到得知相同。

解决方法:可以通过带版本号的原子类,每次操作时改变版本号即可

五、 Java中锁的类型


        Java 中很多锁的名词,这些并不是全指锁,有的指锁的特性,有的指锁的设计,有的指锁的状态,下面总结的内容是对每个锁的名词进行一定的解释。

  乐观锁悲观锁

乐观锁:是一种不加锁的实现,例如原子类。认为不加锁,采用自旋方式尝试修改共享数据是不会有问题的

悲观锁:是一种加锁实现,例如synchronized 和 ReentrantLock,认为不加锁修改共享数据会有问题

  可重入锁

可重入锁又名递归锁,当同一个线程,获取锁进入到外层方法后,可以在内存进入到另一个方法(内层方法和外蹭饭使用的是同一把锁),synchronized 和 ReentrantLock 都是可重入锁

如图,方法A在运行中会调用方法B,但是方法B加锁,此时方法A没有被释放,如果不是可重入锁的话就无法进入方法B,就会造成死锁

  读写锁

读写锁特点: 读读不互斥,读写互斥,写写互斥(只要有写就互斥

ReentrantReadWriteLock.WriteLock

ReentrantReadWriteLock.ReadLock

加读锁是防止在另外的线程在此时写入数据,防止读取脏数据(数据修改后还没存入),最大的保证读的效率

  分段锁

        分段锁并非一种实际的锁,而是一种思想,用于将数据分段,并在每个分段上都会单独加锁,把锁进一步细粒度化,以提高并发效率。

  自旋锁

        所谓自旋其实指的就是自己重试,当线程抢锁失败后,重试几次,要是抢到锁了就继续,要是抢不到就阻塞线程。

说白了还是为了尽量不要阻塞线程。

由此可见,自旋锁是是比较消耗 CPU 的,因为要不断的循环重试,不会释放 CPU资源。

加锁时间普遍较短的场景非常适合自旋锁,可以极大提高锁的效率。

  共享锁/独占锁

共享锁是指该锁可被多个线程所持有,并发访问共享资源。

独占锁也叫互斥锁,是指该锁一次只能被一个线程所持有。

对于 Java ReentrantLock、Synchronized 而言,都是独享锁。

但是对于 Lock 的另一个实现类 ReadWriteLock,其读锁是共享锁,其写锁是独享锁。

读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。

  公平锁/非公平锁

公平锁(Fair Lock)是指按照请求锁的顺序分配,拥有稳定获得锁的机会

非公平锁(Nonfair Lock)是指不按照请求锁的顺序分配,不一定拥有获得锁的机会

synchronized 是一种非公平锁。

ReentrantLock 默认是非公平锁,但是底层可以通过 AQS 的来实现线程调度,所以可以使其变成公平锁

public ReentrantLock(boolean fair){
    sync = fair ? new FairSync() : new NonfairSync();
}

  锁的状态(使用synchronized时)

Java中为了synchronized进行优化,提供了4种锁状态

无锁

偏向锁(一个线程):一段同步代码块一直由一个线程执行,那么会在锁对象中记录下了线程信息,可以直接获得锁;

轻量级锁(少量线程):当锁状态为偏向锁时,此时又有其他线程访问,锁状态升级为轻量级锁,线程不阻塞,采用自旋方式获取锁;

重量级锁(大量线程):当锁状态为轻量级锁时,如果有大量的线程到来,大量的线程自旋,锁状态升级为重量级锁,自旋的线程会进入到阻塞状态,由操作系统去调度管理

六、 对象结构


在 Hotspot 虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充;

Java 对象头是实现 synchronized 的锁对象的基础,一般而言,synchronized 使用的锁对象是存储在 Java 对象头里。它是轻量级锁和偏向锁的关键

对象头中有一块区域称为 Mark Word,用于存储对象自身的运行时数据,

如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等等。

32 位操作系统 Mark Word 为 32bit 为,64 位操作系统 Mark Word 为 64bit.

下面就是对象头的一些信息:

七、 synchronized 锁实现


synchronized是一个关键字,实现同步还需要我笨提供一个同步锁对象,记录锁状态,记录线程信息

synchronized控制同步是通过底层的指令实现的

如果是同步方法,在指令中会为方法添加ACC_SYNCHRONIZED标志

如果是同步代码块,在进入到同步代码块中,会执行monitorenter,离开同步代码块时或者出异常时,执行monitorexit

八、 AQS


AQS(AbstractQueuedSynchronizer这 个 类 在 java.util.concurrent.locks 包下面。

AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出同步器,是 JUC 中核 心的组件,比如我们提到的 ReentrantLock,CountDownLatch 等等都是基于 AQS 来实现。

只要搞懂了 AQS,那么 JUC 中绝大部分的 api 都能掌握

  AQS 实现原理

在内部有一个 state 变量表示锁是否使用, 初始化 0,在多线程条件下,线程要执行临界区的代码,必须首先获取 state,某个线程获取成功之后, state 加 1;

其他线程再获取的话由于共享资源已被占用,所以会到 FIFO 队列去等待,等占有 state 的线程执行完临界区的代码释放资源( state 减 1)后,会唤醒 FIFO 中的下一个等待线程(head 中的下一个结点)去获取 state。

        state 由于是多线程共享变量,所以必须定义成 volatile,以保证 state 的可见性。同时虽然 volatile 能保证可见性,但不能保证原子性,所以 AQS 提供了对 state 的原子操作方法,保证了线程安全。

        另外 AQS 中实现的 FIFO 队列其实是双向链表实现的,head 结点代表当前占用的线程,其他节点由于暂时获取不到锁所以依次排队等待锁释放。队列由 Node 对象组成,Node 是 AQS 中的内部类

AbstractQueuedSynchronizer 成员

private transient volatile Node head;

private transient volatile Node tail;

/*

        使用变量 state 表示锁状态,0-锁未被使用,大于 0 锁已被使用

        共享变量 state,使用 volatile 修饰保证线程可见性

*/

private volatile int state;

状态信息通过 getState , setState , compareAndSetState 进行操作

protected final int getState() { //获得锁状态

        return state;

}

//使用 CAS 机制设置状态

protected final boolean compareAndSetState(int expect, int update) {

        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);

}

  ReentrantLock 锁实现

ReentrantLock 基于 AQS,在并发编程中它可以实现公平锁和非公平锁来对共享资源进行同步

class ReentrantLock{
        abstract static class Sync extends Abstractqueuedsynchronizer {
                abstract voId lock();
        }
        //非公平锁
        static final class NonfairSync extends Sync {
                void lock(){
                }

        }
        //公平锁
        static final class FairSync extends Sync {
                void lock(){

                }

        }

}

九、 JUC常用类


在集合类中,像Vector,Hashtable 这些类加锁时都是直接把锁加载在方法上了,性能就会降低

例如:set() 和 get() 方法,如果说不同的是为了保证数据正常,那么俩个get() 就没必要了,加锁还会导致性能降低

  ConcurrentHashMap

一般的HashMap是只支持单线程操作的,不允许多线程访问(会报ConcurrentModificationException异常)

而HashTable直接给方法加锁,效率低

ConcurrentHashMap 是线程安全的,没有直接给方法加锁,而是用哈希表中每一个位置上的第一个元素(存在)作为锁对象。哈希表长度为16,那么就有16把锁,锁住自己的位置即可。这样如果多个线程如果操作不同位置时,互不影响;只有多个线程同时访问同一个位置时才会等待。如果位置上没有任何元素,那么采用CAS机制插入数据到对应位置

注:ConcurrentHashMap 不支持存储 null 键和 null 值,为了消除歧义

无法分辨是 key 没找到的 null 还是有 key 值为 null,这在多线程里面是模糊不清的,所以就不让 put null

  CopyOnWriteArrayList

ArrayList 是单线程场景下使用的,在多线程下会报ConcurrentModificationException异常
Vector 是线程安全的,但是由于在读写方法上都加锁,效率低
CopyOnWriteArrayList 对写方法上加了锁(通过ReentrantLock实现),在写操作时,会创建一个副本,然后对副本进行操作,操作完成后再把副本替换到原数组中。这样读操作不会被影响

CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的

  CopyOnWriteArraySet

CopyOnWriteArraySet 的实现基于 CopyOnWriteArrayList,区别是不能存储重复数据

  辅助类 CountDownLatch

CountDownLatch 允许一个线程等待其他线程各自执行完毕后再执行。底层实现实现是通 AQS 来完成的

创建 CountDownLatch 对象时指定一个初始值是线程的数量。每当一个线程执行完毕后,AQS 内部的 state 就 -1,当 state 的值为 0 时,表示所有线程都执行完毕,然后在闭锁上等待的线程就可以恢复工作了。

十、 线程池


  池的概念

1、字符串常量池(String s1 = "abc;String s2 = "abc;s1==s2 // true)

2、Integer自动装箱(Integer a=100;Integer b=100; a==b // true),缓存了 -128~127之间的对象

3、数据库连接池

阿里巴巴Druid数据库连接池

帮我们缓存一定数量的链接对象,放在池子里,用完还回到池子中,

减少了对象的频繁创建和销毁的时间开销

  线程池

为减少频繁的创建和销毁线程,JDK5引入了线程池,建议使用ThreadPoolExecutor类来创建线程池

优点:

重复利用线程,降低线程创建和销毁带来的资源消耗

统一管理线程,线程的创建和销毁都由线程池进行管理

提高响应速度,线程创建已经完成,任务来到可直接处理,省去了创建时间

项目优化

  ThreadPoolExecutor 类

一共7个参数

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)

corePoolSoze:核心线程池中的数量(初始化的数量)5

maximumPoolSize:线程池中最大的数量10 (+5个队列)

keepAliveTime:空闲线程存活时间,当核心线程池中的线程足以应付任务是,非核心线程池中的线程在制定存活时间到后销毁

unit:时间单位

workQueue:等待队列,当核心线程池汇总的线程都在使用时,回先将等待的线程放在队列中,如果队列也满了,才会创建新的线程(非核心线程池中的线程)

threadFactory:线程工厂,用于创建线程

handler:拒绝策略,在满载的情况下(线程满了且队列也满了),有 4 种

  线程池工作流程

1. 如果线程池中存活的核心线程数小于线程数 corePoolSize 时,线程池会创建一个核心线程去处理提交的任务。

2. 如果线程池核心线程数已满,即线程数已经等于 corePoolSize,一个新提交的任务,会被放进任务队列 workQueue 排队等待执行。

3. 当线程池里面存活的线程数已经等于 corePoolSize 了,并且任务队列 workQueue 也满,判断线程数是否达到 maximumPoolSize,即最大线程数是否已满,如果没到达,创建一个非核心线程执行提交的任务。

4. 如果当前的线程数达到了 maximumPoolSize,还有新的任务过来的话,就使用拒绝策略处理。

  线程池中的队列

线程池有以下工作队列:

ArrayBlockingQueue:是一个用数组实现的有界阻塞队列,创建时必须设置长度,,按 FIFO 排序量。

LinkedBlockingQueue:基于链表结构的阻塞队列,按 FIFO 排序任务,容量可以选择进行设置,不设置是一个最大长度为 Integer.MAX_VALUE;

  线程池的拒绝策略

默认有四种类型:

AbortPolicy 策略:该策略会直接抛出异常,阻止系统正常工作。

CallerRunsPolicy 策略:只要线程池未关闭,该策略在调用者线程中运行当前的任务(如果任务被拒绝了,则由提交任务的线程(例如:main)直接执行此任务)。

DiscardOldestPolicy 策略:该策略将丢弃等待时间最长的任务,也就是即将被执行的任务,并尝试再次提交当前任务。

DiscardPolicy 策略:该策略丢弃最后无法处理的任务,不予任何处理。

  execute 与 submit 的区别

执行任务除了可以使用 execute 方法还可以使用 submit 方法。它们的主要区别

是:execute 适用于不需要关注返回值的场景,submit 方法适用于需要关注返

回值的场景。

  关闭线程池

关闭线程池可以调用 shutdownNow 和 shutdown 两个方法来实现。

shutdownNow:对正在执行的任务全部发出 interrupt(),停止执行,对还未开

始执行的任务全部取消,并且返回还没开始的任务列表。

shutdown:当我们调用 shutdown 后,线程池将不再接受新的任务,但也不会

去强制终止已经提交或者正在执行中的任务

十一、 ThreadLocal


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

static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>(){

        @Override

        protected Integer initialValue() {

                return 1;

        }

};

  ThreadLocal 原理

ThreadLocal 是一个泛型类,保证可以接受任何类型的对象。ThreadLocal 内部维护了一个 Map,ThreadLocal 实现了一个叫做 ThreadLocalMap 的静态内部类。而我们使用的 get()、set() 方法其实都是由这个 ThreadLocalMap 类对应的 get()、set() 方法实现的。

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

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

 ThreadLocal的get方法

    public T get() {
        //1、获取当前线程
        Thread t = Thread.currentThread();
        //2、获取当前线程的ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        //3、如果map数据不为空,
        if (map != null) {
            //3.1、获取threalLocalMap中存储的值
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        //如果是数据为null,则初始化,初始化的结果,TheralLocalMap中存放key值为threadLocal,值为null
        return setInitialValue();
    }

最终的变量是放在了当前线程的ThreadLocalMap 中,并不是存在ThreadLocal 上,ThreadLocal 作为 key。

  内存泄漏问题

对象已经不用了,但是垃圾回收不能回收该对象.(例如数据库连接对象,流对象,socket...)
对象引用分为四种:

强引用

Object obj = new Object):         强引用

        obj.hashCode();

        obj=null;        没有引用指向对象

对象如果有强引用关联,那么肯定是不能被回收的

软引用

被SoftReference类包裹的对象,当内存充足时,不会被回收;当内存不足时,即使有引用指向,

也会被回收

Object o1 = new Object();

SoftReference<object> softReference = new SoftReference<object>(o1);

弱引用

被WeakReference类包过的对象,只要发生垃圾回收,该类对象都会被回收掉,不管内存是否充足

Object o1 = new Object();

WeakReference<Object> weakReference= new WeakReference<Object>(o1);

ThreadLocal 被弱引用管理 static class Entry extends WeakReference<ThreadLocal<?>> {}

当发生垃圾回收时,被回收掉,但是value还与外界保持引用关系,不能被回收,造成内存泄漏

虚引用

被PhantomReference类包裹的对象,随时都可以被回收

通过虚引用对象跟踪对象回收的状态

  ThreadLocal 内存泄漏

        TreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 不存在外部强引用时,Key(ThreadLocal)势必会被 GC 回收,这样就会导致 ThreadLocalMap 中 key 为 null, 而 value 还存在着强引用,只有 thead 线程退出以后,value 的强引用链条才会断掉。

但如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:

Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏

ThreadLocal 正确的使用方法

每次使用完 ThreadLocal 都调用它的 remove()方法清除数据

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

三木几

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值