并发编程
1 概览
2 线程的基础知识
2.1 线程与进程的区别
-
进程是资源分配的最小单元,进程是正在执行的程序实例,每个进程都有自己独立的内存,全局变量和文件描述符;
-
线程是 CPU 调度的基本单元,一个进程中可以包含多个线程,线程共享进程的资源和地址空间。
-
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
2.2 并行和并发的区别
现在都是多核 CPU,在多核 CPU 下:
-
并行是指同一时刻,多个任务 同时执行,4 核 CPU 同时执行 4 个线程,解决多任务加速完成,依赖硬件资源(必须多个硬件执行单元)
-
并发是指同一时间段,多个任务 交替执行,多个线程轮流使用一个或多个 CPU,解决多任务不阻塞,依赖调度算法
并发和并行都是为了提升系统处理多任务的能力
2.3 创建线程的方式
创建线程有 4 种方式:1.继承 Thread 类,重写 run()方法;2.实现 Runnable 接口,并实现该接口的 run()方法;3.实现 Callable 接口,重写 call()方法;4.线程池创建线程(项目中使用方式)。前两种方式线程执行完后都没有返回值,最后一种带返回值。
2.3.1 runnable 和 callable 的区别
- Runnable 接口 run()方法没有返回值
- Callable 接口 call()方法有返回值,是个泛型,和 Future、FutureTask 配合可以用来异步执行的结果
- Callable 接口 call()方法运行抛异常;而 Runnable 接口的 run()方法的异常只能在内部消化,不能继续上抛
2.3.2 run() 和 start() 的区别
- start():用来启动线程,通过该线程调用 run()方法执行 run()方法中所定义的逻辑代码。start()方法只能被调用一次。
- run():封装了要被线程执行的代码,可以被调用多次。
2.4 线程的状态
2.4.1 示例问题
- 线程包括哪些状态?
新建(NEW)、可运行(RUNNABLE)、阻塞(BLOCKED)、等待(WAITING)、时间等待(TIMED_WALTING)、终止(TERMINATED - 线程状态之间是如何变化的?
- 创建线程对象是 新建状态
- 调用了 start()方法转变为 可执行状态
- 线程获取到了 CPU 的执行权,执行结束是 终止状态
- 在可执行状态的过程中,如果没有获取 CPU 的执行权,可能会切换其他状态
- 如果没有获取锁(synchronized 或 lock)进入 阻塞状态,获得锁再切换为可执行状态
- 如果线程调用了 wait()方法进入 等待状态,其他线程调用 notify()唤醒后可切换为可执行状态
- 如果线程调用了 sleep(50)方法,进入 计时等待状态,到时间后可切换为可执行状态
2.5 顺序执行线程
join() 方法是 Thread 类的一个实例方法。当在一个线程中调用另一个线程的 join() 方法时,当前线程会被阻塞,直到被调用 join() 方法的线程执行完毕。也就是说,A 线程等待 B 线程执行完毕,就需要在 A 线程中令 B 线程调用 join 方法。
Thread t1 = new Thread(() -> {
System.out.println("Thread 1");
});
Thread t2 = new Thread(() -> {
try {
t1.join(); // 加入t1线程,等待t1线程执行完毕后再执行t2线程
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("Thread 2");
});
// 顺序可以打乱
t1.start();
t2.start();
2.6 notify
当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了 notify 后只要一个线程会由等待池进入锁池,而 notifyAll 会将该对象等待池内的所有线程移动到锁池中,等待锁竞争。
2.7 wait 和 sleep
共同点
wait(),wait(long)和 sleep(long)的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
不同点
- 方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long)都是 Object 的成员方法,每个对象都有
- 醒来时机不同
- 执行 sleep(long)和 wait(long)的线程都会在等待相应毫秒后醒来
- wait(long)和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
- 锁特性不同 (重点)
- wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
2.8 打断线程
有三种方式可以停止线程
- 使用退出标志,使线程正常退出,也就是当 run 方法完成后线程终止
- 使用 stop 方法强行终止(不推荐,方法已作废)
- 使用 interrupt 方法中断线程
- 打断阻塞的线程(sleep,wait,join)的线程,线程会抛出 InterruptedException 异常
- 打断正常的线程,可以根据打断状态来标记是否退出线程
2.9 乐观锁和悲观锁
乐观锁和悲观锁其实是个概念并非是具体的锁,悲观锁是指悲观的认为并发情况下,线程获取资源会有线程安全问题,所以在一开始获取线程资源时加锁,保证线程安全,synchronize,lock 都是悲观锁的实现适合读写频繁,锁竞争激烈的情况。乐观锁是一开始认为并发冲突不会发生,只在进行执行操作去提交时检验数据是否有被修改过。通常通过 cas 或版本号去实现乐观锁。适合读操作频繁,锁竞争不激烈的情况。
3 线程中并发安全
3.1 synchronized 关键字
3.1.1 基本原理
synchronized 是 Java 中用于解决并发问题的核心关键字,它通过确保多个线程对共享资源的 互斥访问,来避免线程安全问题(如竞态条件、数据不一致等)。
synchronized 是基于一对 monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现单元,由 JVM 提供,C++语言实现,线程获得锁需要使用对象(锁)关联 Monitor。
Monitor 的结构如下:
- Owner:存储当前获取锁的线程,只能有一个线程可以获取
- EntryList:关联没有抢到锁的线程,处于 Blocked 状态的线程
- WaitSet:关联调用了 wait()方法的线程,处于 Waiting 状态的线程
3.1.2 进阶-锁升级
首先提出问题:Monitor 结构怎么和 lock 对象关联的?
对象的内存结构
在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头(Header)、实例数据(Istance Data)和对齐填充。
最重要的便是 MarkWord:
重量级锁: 每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。
轻量级锁: 在很多的情况下,在 Java 程序运行时,同步块中的代码都是 不存在竞争 的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此 JVM 引入了轻量级锁的概念。
加锁流程
- 在线程栈中创建一个 LockRecord,将其 obj 字段指向锁对象。
- 通过 CAS 指令将 LockRecord 的地址存储在对象头的 markword 中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
- 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置 LockRecord 第一部分为 null,起到了一个重入计数器的作用。
- 如果 CAS 修改失败,说明发生了竞争,需要膨胀为重量级锁。
解锁流程
- 遍历线程栈,找到所有 obj 字段等于当前锁对象的 LockRecord。
- 如果 LockRecord 的 MarkWord 为 null,代表这是一次重入,将 obj 设置为 null 后 continue。
- 如果 LockRecord 的 MarkWord 不为 null,则利用 CAS 指令将对象头的 markword 恢复成为无锁状态。如果失败则膨胀为重量级锁
偏向锁: 轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 MarkWord 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。
3.1.3 示例问题
-
Monitor 实现的锁属于重量级锁,你了解过锁升级吗?
Java 中的 synchronized 有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。描述 重量级锁 底层使用的 Monitor 实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 轻量级锁 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是 CAS 操作,保证原子性。 偏向锁 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个 CAs 操作,之后该线程再获取锁,只需要判断 markword 中是否是自己的线程 id 即可,而不是开销相对较大的 CAS 命令。
3.2 Java 内存模型(JMM)
JMM(JavaMemoryModel)Java 内存模型,定义了共享内存中 多线程程序读写操作 的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性。
JMM 把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域 (主内存)
线程跟线程之间相互隔离,线程跟线程交互需要通过主内存
3.3 比较再交换-CAS
CAS 体现的一种乐观锁的思想,在 无锁情况 下保证线程操作共享数据的原子性。
一个当前内存值 V、旧的预期值 A、即将更新的值 B,当且仅当旧的预期值 A 和内存值 V 相同时,将内存值修改为 B 并返回 true,否则什么都不做,并返回 false。如果 CAS 操作失败,通过自旋的方式等待并再次尝试,直到成功。
- CAS 使用到的地方很多:AQS 框架、Atomic 开头的类
- 在操作共享变量的时候使用的自旋锁,效率上更高一些
- CAS 的底层是调用的 Unsafe 类中的方法,都是操作系统提供的,由 C/C++实现
乐观锁和悲观锁的区别
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算修改了也没关系,继续重试。
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
3.4 volatile 关键字
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
对于 1 立即可见,假设有以下代码
boolean stop = false;
// 线程1
while(!stop){
doSomething();
}
// 线程2
stop = true
当线程 2 更改了 stop 变量的值之后,但是还没来得及写入主存当中,线程 2 转去做其他事情了,那么线程 1 由于不知道线程 2 对 stop 变量的更改,因此还会一直循环下去。
- 使用 volatile 关键字会强制将修改的值 立即写入主存;
- 使用 volatile 关键字的话,当线程 2 进行修改时,会导致线程 1 的工作内存中缓存变量 stop 的缓存行无效(反映到硬件层的话,就是 CPU 的 L1 或者 L2 缓存中对应的缓存行无效);
- 由于线程 1 的工作内存中缓存变量 stop 的缓存行无效,所以线程 1 再次读取变量 stop 的值时会去主存读取。
对于 2 禁止进行指令重排序,用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。举个例子:
使用技巧:
- 写变量让 volatile 修饰的变量的在代码最后位置
- 读变量让 volatile 修饰的变量的在代码最开始位置
3.5 抽象队列同步器-AQS
全称 AbstractQueuedSynchronizer ,即抽象队列同步器,是构建锁或者其他同步组件的基础框架,例如 ReentrantLock,SynchronousQueue 等等都是基于 AQS 实现的。
AQS 内部维护了一个先进先出的双向队列,队列中存储的排队的线程。
在 AQS 内部还有一个属性 state,这个 state 就相当于是一个资源,默认是 0(无锁状态),如果队列中的有一个线程修改成功了 state 为 1,则当前线程就相等于获取了资源。
在对 state 修改的时候使用的 CAS 操作,保证多个线程修改的情况下原子性。
AQS 和 Synchronized 的区别
| Synchronized | AQS |
|---|---|
| 关键字,C++实现 | Java 实现 |
| 悲观锁,自动释放锁 | 悲观锁,手动开启和关闭 |
| 锁竞争激烈都是重量级锁,性能差 | 锁竞争激烈的情况下,提供多种解决方案 |
3.6 ReentrantLock 实现原理
可重入锁,调用 lock()方法获取锁知乎再次调用 lock()不会阻塞,底层使用了 CAS + AQS 队列实现,支持公平锁和非公平锁,两者的实现类似。
构造方法如下,接受一个可选的公平参数(默认非公平锁),当设置为 true 时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。
public ReentrantLock() {
sync = new NonfairSync(); //默认,非公平
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync(); //根据参数创建
}
- 线程来抢锁后使用 cas 的方式修改 state 状态,修改状态成功为 1,则让 exclusiveOwnerThread 属性指向当前线程,获取锁成功
- 假如修改状态失败,则会进入双向队列中等待,head 指向双向队列头部,tail 指向双向队列尾部
- 当 exclusiveOwnerThread 为 null 的时候,则会唤醒在双向队列中等待的线程
- 公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁
3.7 synchronized 和 lock 的区别
-
语法层面
- synchronized 是关键字,源码在 JVM 中,C++实现。
- Lock 是接口,源码由 JDK 提供,由 Java 实现。
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁。
-
功能层面
-
二者均属于悲观锁,都具备基本的互斥、同步、锁冲入功能。
-
Lock 提供了许多如公平锁、可打断、可超时、多条件遍历等 synchronized 不具备的功能。。
在对应的代码包中有相关代码。
lock\01.ReentrantLockTest.java -
Lock 有适合不同场景的实现,如 ReentrantLock、RentrantReadWriteLock(读写锁)。
-
-
性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖。
- 在竞争激烈时,Lock 的实现通常会提供更好的性能。
3.8 死锁产生的条件
此时程序不会结束,这种现象就是死锁现象;线程 t1 持有 A 的锁等待获取 B 锁,线程 t2 持有 B 的锁等待获取 A 的锁。
当程序出现了死锁现象,我们可以使用 jdk 自带的工具:jps 和 jstack
- jps:输出 JVM 中运行的 进程状态 信息
- jstack:查看 java 进程内 线程的堆栈 信息
3.9 ConcurrentHashMap ⭐
ConcurrentHashMap 是一种线程安全的高效 Map 集合底层数据结构:
-
JDK1.7 底层采用分段数组 + 链表实现;采用的是 Segment 分段锁,底层使用的是 ReentrantLock,当多个 key 在一个 Segment [x] 时,只能有一个线程操作这个数据。
-
JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树;采用 CAS+Synchronized 来保证并发安全进行实现。CAS 控制数组节点的添加,synchronized 只锁定当前链表或红黑二叉树的首节点,只要 hash 不冲突,就不会产生并发的问题,效率得到提升。
3.10 保证多线程的执行安全
Java 并发编程的三大特性:原子性、可见性、有序性。
-
原子性:一个线程在 CPU 中操作不可暂停,也不可中断,要不执行完成,要不不执行。
上述代码中可以给方法加 synchronized 关键字,也可以使用 LOCK 锁。
-
可见性:让一个线程对共享变量的修改对另一个线程可见。
使用 volatile 关键字让共享变量 flag 对另一个线程可见。
-
有序性
指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
可以使用 vlotile 解决指令重排序的问题。
4 线程池
4.1 线程池的参数和原理 ⭐
4.1.1 核心参数
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- corePoolSize 核心线程数目
- maximumPoolSize 最大线程数目 =(核心线程 + 非核心线程的最大数目)
- keepAliveTime 生存时间 - 非核心线程的生存时间,生存时间内没有新任务,此线程资源会释放
- unit 时间单位 - 非核心线程的生存时间单位,如秒、毫秒等
- workQueue- 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建非核心线程 执行任务
- threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
- handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
4.1.2 执行原理
详情查看对应代码 threadpool\01.TestThreadPoolExcutor.java。
4.2 常见阻塞队列
workQueue- 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
- LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
- DelayedWorkQueue:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
- SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
| LinkedBlockingQueue | ArrayBlockingQueue |
|---|---|
| 默认无界,支持有界 | 强制有界 |
| 底层是链表 | 底层是数组 |
| 懒惰的,创建节点时添加数据 | 提前初始化 Node 数组 |
| 入队会生成新 Node | Node 需要是提前创建好的 |
| 两把锁 (头尾) | 一把锁(效率较低) |
4.3 确定核心线程数
-
高并发、任务执行时间短 →(CPU 核数+1),减少线程上下文的切换,增加 CPU 执行效率
-
并发不高、任务执行时间长
IO 密集型的任务 → (CPU 核数 * 2 + 1) 多个线程执行计算密集型任务 →(CPU 核数 + 1) 增加 CPU 执行效率
-
并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)
4.4 线程池的种类
4.4.1 固定线程数的线程池
public static ExecutorService newFixedThreadPool(int nThreads){
return new ThreadPoolExecutor(nThreads,nThreads,
OL,TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>();
}
- 核心线程数与最大线程数一样,没有救急线程
- 阻塞队列是 LinkedBlockingQueue,最大容量为 Integer.MAX_VALUE
适用于任务量已知,相对耗时的任务
4.4.2 单线程化的线程池,
只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行
public static ExecutorService newSingleThreadExecutor() {
returnnew FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,)
0L,TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
- 核心线程数和最大线程数都是 1
- 阻塞队列是 LinkedBlockingQueue,最大容量为 Integer.MAX_VALUE
适用于按照顺序执行的任务
4.4.3 可缓存线程池
public static ExecutorService newCachedThreadPool){
return new ThreadPoolExecutor(O,Integer.MAX_VALUE,
60L,TimeUnit.SECONDS,
new SynchronousQueue<Runnable>();
}
- 核心线程数为 0
- 最大线程数是 Integer.MAX_VALUE
- 阻塞队列为 SynchronousQueue: 不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。即所有任务都交予创建的非核心线程执行。
适合任务数比较密集,但每个任务执行时间较短的情况。
4.4.4 计划执行线程池
提供了 “延迟” 和 “周期执行” 功能的 ThreadPoolExecutor。
ScheduledExecutorService executorService = Executors.newScheduledThreadPool(2);
executorService.schedule(new Demo(),0, TimeUnit.SECONDS); // 立即执行
executorService.schedule(new Demo(), 2, TimeUnit.SECONDS); // 2秒后执行
executorService.schedule(new Demo(), 4, TimeUnit.SECONDS); // 4秒后执行
4.5 为什么不建议用 Executors 创建线程池
参考阿里开发手册《Java 开发手册-嵩山版》
【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下:
-
FixedThreadPool 和 SingleThreadPool :
允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, // 创建阻塞队列其长度为 Integer.MAX_VALUE new LinkedBlockingQueue<Runnable>()); } -
CachedThreadPool :
允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); } -
通过 ThreadPoolExecutor 方式,指定七个参数
ThreadPoolExecutor threadpool = new ThreadPoolExecutor( 2, 3, 0, TimeUnit.MILLISECONDS, queue, r -> new Thread(r, "myThread-" + c.getAndIncrement()), // 线程工厂 new ThreadPoolExecutor.AbortPolicy()); // 拒绝策略
5 使用场景
5.1 线程池使用场景
5.1.1 CountDownLatch
CountDownLatch(闭锁/倒计时锁)基于 AQS 实现,用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)
- 其中构造参数用来初始化等待计数值
- await()用来等待计数归零
- countDown()用来让计数减一
具体使用场景:在我们项目上线之前,我们需要把数据库中的数据一次性的同步到 es 索引库中,但是当时的数据好像是 1000 万左右,一次性读取数据肯定不行(oom 异常),当时我就想到可以使用线程池的方式导入,利用 CountDownLatch 来控制,就能避免一次性加载过多,防止内存溢出。
5.1.2 Future
在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?
在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的 没有依赖关系,就可以使用线程池 + future 来提升性能
5.1.3 异步线程
异步线程(线程池):为了避免下一级方法影响上一级方法(性能考虑),可使用异步线程调用下一个方法(不需要下一级方法返回值),可以提升方法响应时间。
5.2 控制某个方法充许并发访问线程的数量
SemaphoreCase 使用流程
- 创建 Semaphore 对象,可以给一个容量仓
- semaphore.acquire():请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)
- semaphore.release():释放一个信号量,此时信号量个数 +1
5.3 对 ThreadLocal 的理解
ThreadLocal 是多线程中对于解决线程安全的一个操作类,它会 为每个线程都分配一个独立的线程副本 从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享
案例:使用 JDBC 操作数据库时,会将每一个线程的 Connection 放入各自的 ThreadLocal 中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免 A 线程关闭了 B 线程的连接。
5.3.1 基本使用
5.3.2 实现原理
- set()方法:
-
get()/remove()方法:
remove()和 get()类似。
5.3.3 内存泄漏问题
Java 对象中的四种引用类型:强引用、软引用、弱引用、虚引用
-
强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则 GC 并不会回收它。即便堆中内存不足了,宁可出现 OOM,也不会对其进行回收。
User u = new User() -
弱引用:表示一个对象处于可能有用且非必须的状态。在 GC 线程扫描内存区域时,一旦发现弱引 I 用,就会回收到弱引 I 用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。
User user = new User(); WeakReference weakReference =new WeakReference(user);
每一个 Thread 维护一个 ThreadLocalMap,在 ThreadLocalMap 中的 Entry 对象继承了 WeakReference。其中 key 为使用弱引用的 ThreadLocal 实例,value 为线程变量的副本。
- 当
ThreadLocal对象不再被外部引用(例如:static ThreadLocal tl = null;),由于 key 是弱引用,GC 会回收 key(此时Entry的 key 变为null)。 - 但 value 是强引用,且被
ThreadLocalMap持有 → value 无法被 GC 回收。 - 如果 线程长期存活(例如线程池中的线程),
ThreadLocalMap会一直存在 → value 持续占用内存 → 内存泄漏。所以建议主动 remove 释放 key、value。例如实际应用上的用户登录。

浙公网安备 33010602011771号