JUC - 多线程
目录
文题
线程&进程 区别
进程是资源分配单位,线程是调度执行单位; 进程独立,线程共享进程资源;进程切换重,线程切换轻。
1. 定义不同
进程是资源分配的基本单位,线程是CPU 调度的基本单位。
2. 包含关系不同
一个进程里可以有多个线程,线程必须依附于进程存在。
3. 资源共享不同
进程之间内存空间独立,互不共享。
同一进程内的线程共享堆、方法区等资源,但每个线程也有自己的程序计数器、虚拟机栈、本地方法栈。
4. 开销不同
进程创建和切换开销更大。
线程创建和切换开销更小,所以并发执行效率通常更高。
5. 通信方式不同
进程通信比较复杂,需要管道、消息队列、socket 等 IPC 机制。
线程通信更简单,因为它们天然共享进程内存,但也更容易出现线程安全问题。
并发&并行 区别
并发是多个任务在同一时间段内交替执行,强调的是同时处理的能力。
并行是多个任务在同一时刻同时执行,强调的是同时执行的能力。
单核 CPU 更偏向并发,多核 CPU 才能更好实现并行。
notify & notifyAll区别
notify随机唤醒一个正在等待的线程 notifyAll唤醒所有正在等待的线程 (惊群效应)
sleep()&wait()区别
sleep 是 Thread 的静态方法,wait 是 Object 的成员方法。
sleep 不释放锁,wait 会释放锁。
sleep 可以在任何地方调用,wait 必须在 synchronized 中调用。
sleep 主要用于暂停线程,wait 主要用于线程间通信。
从状态上看,sleep 进入 TIMED_WAITING,wait() 进入 WAITING。
synchronized & lock
| 对比点 | synchronized | Lock / ReentrantLock |
|---|---|---|
| 层级 | JVM 关键字(cpp) | JDK 接口,Java 代码实现 |
| 加锁方式 | 隐式加锁 | 显式 lock() |
| 解锁方式 | 自动释放 | 必须手动 unlock() |
| 异常时释放锁 | 会自动释放 | 不手动释放就可能死锁 |
| 是否可中断 | 不支持正在等待锁时被中断 | 支持 lockInterruptibly() |
| 是否可超时 | 不支持 | 支持 tryLock(timeout) |
| 是否支持公平锁 | 不支持直接设置 | 支持公平 / 非公平 |
| 条件队列 | wait/notify,一个监视器一套等待队列 | Condition,一个锁可建多个条件队列 |
| 灵活性 | 较低 | 很高 |
| 适用场景 | 简单同步 | 复杂并发控制 |
- 功能层面二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能 Lock 提供了许多 synchronized 不具备的功能,例如公平锁、可打断、可超时、多条件变量 Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock(读写锁)
- 性能层面在没有竞争时, synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖 在竞争激烈时, Lock 的实现通常会提供更好的性能
死锁发生的条件
- jps: 输出JVM中运行的进程状态信息
- jstack: 查询JAVA进程内线程的堆栈信息
用jps(java process status)找到进程及其进程ID, jstack查看该进程内的线程的堆栈情况
可视化工具
- Jconsole用于 jvm 的内存,线程,类的监控,java 安装目录 bin 目录下直接启动 jconsole.exe 就行
- VisualVM 故障处理工具能够监控线程,内存情况,查看方法的 CPU 时间和内存中的对象、已被 GC 的对象,反向查看分配的堆栈打开方式: java 安装目录 bin 目录下直接启动 jvisualvm.exe 就行 (还能排查OOM问题)
LongAdder
为什么要LongAdder
并发场景下需要对一个数做高频的+1/-1操作。最直觉的做法是用AtomicLong,所有线程CAS同一个 变量。问题是并发越高,CAS失败越多,失败了就自旋重试,大量线程空转抢一个变量,吞吐量急剧 下降,这叫热点竞争。
LongAdder的思路:既然抢一个变量是瓶颈,那就把这个变量拆成多个格子,让线程分散去写,读的 时候再加起来。用空间换时间,牺牲精确性。
底层数据结构
内部维护两部分:
- base:一个long值,竞争不激烈时线程直接CAS改这里
- Cell[]:一个Cell数组,每个Cell内部包一个long值
写入流程:
- 线程先尝试CAS修改base
- 如果CAS成功,直接返回,结束
- 如果CAS失败(说明有竞争),根据线程的hash值映射到Cell[]中的某个Cell,对该Cell做CAS
- 如果Cell的CAS也失败,会rehash换一个Cell重试,或者对Cell[]扩容
读取流程:
- sum() = base + Cell[0].value + Cell[1].value + … + Cell[n].value
- 遍历求和,但求和过程中不加锁,所以别的线程可能同时在改某个Cell,结果是近似值,不是强 一致的
Cell的细节:
- 每个Cell用@Contended注解做了缓存行填充,防止多个Cell落在同一个缓存行导致伪共享(false sharing),否则分散竞争的效果会被伪共享抵消
在JDK中的应用
ConcurrentHashMap的size():
- 内部有 baseCount(对应base)+ CounterCell,机制和LongAdder一模一样
- put成功后计数+1,remove成功后计数-1,走的都是base → 失败 → CounterCell的流程
- size() = baseCount + 所有CounterCell求和
- Cell数组和Map的槽位没有任何映射关系,纯粹是为分散计数竞争而存在
- 所以ConcurrentHashMap的size()也是近似值
LongAccumulator:
- LongAdder的泛化版本,LongAdder只能做累加,LongAccumulator可以自定义运算(比如求最大值 、求乘积)
- 底层结构完全一样,base + Cell[]
ConcurrentHashMap
JDK 1.7 (segment+ReentrantLock+数组+链表)
JDK 1.7 的 ConcurrentHashMap 底层是 Segment 数组加桶数组的结构。 每个 Segment 相当于一把分段锁,并负责管理一部分桶。 并发写入时,锁定的是某个 Segment,而不是单个桶,所以只要两个线程操作的桶落在同一个 Segment 中,就会发生锁竞争。 这说明它的锁粒度是 Segment 级别,而不是 bucket 级别。
- 数据结构: Segment数组 + HashEntry数组 + 链表,头插法
- 锁机制: Segment继承ReentrantLock,锁的粒度是整个Segment,一个Segment下挂多个槽位
- 并发度: 默认16个Segment,最多16个线程同时写不同Segment
代码块收起展开
1.7 — Segment分段锁:
ConcurrentHashMap
├── Segment[0] ← 锁整个Segment (ReentrantLock)
│ ├── bucket[0] → Node → Node
│ ├── bucket[1] → Node
│ └── bucket[2] → Node → Node → Node
├── Segment[1] ← 锁整个Segment
│ ├── bucket[0] → Node
│ ├── bucket[1]
│ └── bucket[2] → Node → Node
└── ...JDK 1.8 (cas+synchronized+数组+链表+红黑树)
JDK 1.8 的 ConcurrentHashMap 在空桶插入时使用 CAS。 CAS 比较的是数组槽位当前保存的节点引用是否等于预期旧值。 对于空桶来说,预期旧值就是 null,因为底层是 Node 数组,槽位默认值为 null。 若比较成功,就原子地cas把新节点设为该桶的首节点;若失败,说明其他线程已经抢先插入,该线程会重试并转入链表或红黑树分支。 若桶中已有节点,对桶头加 synchronized 锁来完成链表或红黑树的修改。 JDK 1.8 也正是通过Node + CAS + synchronized,并且只锁当前桶头,来把锁粒度做细。 总结: null, 对首节点 —>cas 否则对首节点 synchronized
- 数据结构: Node数组 + 链表/红黑树,尾插法
- 锁机制: synchronized锁住槽位的头节点,粒度从段级别细化到槽级别
- 空槽位写入: 不加锁,用CAS(Unsafe.compareAndSwapObject),期望值为null,只有一个线程 能成功,失败的线程重新走put流程进入synchronized逻辑
- ABA问题: 此场景不存在。槽位从null→非null是单向的,扩容时旧槽位放的是ForwardingNode而 不是null,构不成ABA
代码块收起展开
1.8 — 槽位级别锁:
ConcurrentHashMap
├── bucket[0] → Node(头) → Node → Node ← synchronized(头Node)
├── bucket[1] → null ← ???
├── bucket[2] → Node(头) → Node ← synchronized(头Node)
├── bucket[3] → TreeBin → 红黑树 ← synchronized(TreeBin)
└── ...
看 bucket[1],是null,没有头节点。
链表转红黑树
- 树化条件: 链表长度 ≥ 8 且 数组长度 ≥ 64,两个条件同时满足才树化
- 退化条件: 红黑树节点数 ≤ 6 时退回链表
- 为什么不都是8: 防止在临界值附近频繁转换(抖动),6和8之间留7作为缓冲带
- 为什么数组要≥64: 数组小的时候碰撞多是因为槽位不够,正确做法是扩容rehash把碰撞分散, 而不是树化。树化是最后手段
size() 计数机制
- 内部维护 baseCount + CounterCell[],机制和LongAdder一致
- 写入时: put成功+1,remove成功-1。先CAS改baseCount,失败则根据线程hash找一个CounterCell去CAS
- 读取时: size() = baseCount + 所有CounterCell求和
- Cell数组和槽位没有映射关系,纯粹分散计数竞争
- size()是近似值,不是强一致的
- CounterCell用@Contended防伪共享
并发程序出现问题的原因
-
原子性: 一个线程在 c P U 中操作不可暂停,也不可中断,要不执行完成,要不不执行—>synchronized/lock
-
可见性: 让一个线程对共享变量的修改对另一个线程可见 —> volatile/synchronized/lock
-
有序性 —> volatile 指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致, 但是它会保证程序最终执行结果和代码顺序执行的结果是一致的
ArrayBlockingQueue & LinkedBlockingQueue
此处强制有界意思是必须给出初始化大小
![]()
确定核心线程数?
- IO密集型 -> 2*CPU+1
- 计算密集型-> CPU+1
线程池类别
#### newFixedThreadPool
>固定线程数量的线程池,
>**核心线程=最大线程---->没有救急线程**
>LinkedBlockingQueue--->无界队列, 因此理论上可以一直塞进去任务
>适合已知任务量, 相对耗时间的任务
```java
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue(),
threadFactory);
}
```
newSingleThreadExecutor
固定线程数量为1的线程池 适合按顺序执行的任务
代码块收起展开
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
return new AutoShutdownDelegatedExecutorService(new ThreadPoolExecutor( 1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue(), threadFactory));
}newCachedThreadPool
没有核心线程, 没有最大线程 60s没用直接回收 SynchronousQueue不存储元素的阻塞队列, 即插入队即刻派发 适合数量密集, 但是执行时间短的任务
代码块收起展开
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor( 0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue(), threadFactory);
}newScheduledThreadPool
执行延迟任务, 定时/周期性任务的线程池
代码块收起展开
public ScheduledThreadPoolExecutor(int corePoolSize, ThreadFactory threadFactory) {
super(corePoolSize, Integer.MAX_VALUE,
10L, TimeUnit.MILLISECONDS,
new DelayedWorkQueue(), threadFactory);
}不建议使用Executor?
因为newFixdThreadPool和newSingleThreadExecutor是无界阻塞队列LinkedBlockingQueue, 容易OOM
而newCachedThread和newScheduledThreadPoolExecutor是无最大线程数限制, 容易OOM
线程基础
线程与进程的区别 并行与并发的区别 线程创建的方式有哪些 runnable 和 callable 有什么区别 线程包括哪些状态,状态之间是如何变化的 java 中 wait 和 sleep 方法的不同 新建三个线程,如何保证它们按顺序执行 notify() 和 notifyAll有什么区别 线程的 ru n() 和 start() 有什么区别 如何停止一个正在运行的线程
线程
创建方式
1. 继承 Thread 类
重写 run() 方法,然后调用 start()
代码块收起展开
class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程执行了");
}
}
public class Test {
public static void main(String[] args) {
MyThread t = new MyThread();
t.start();
}
}特点
-
写法简单
-
线程类已经继承了
Thread,无法再继承别的类 -
任务和线程对象耦合在一起
2. Runnable子实现类
重写run()方法,再交给 Thread
代码块收起展开
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("线程执行了");
}
}
public class Test {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start();
}
}特点
-
更常用
-
任务和线程分离了,设计更合理
-
类还可以继续继承别的父类
-
run()没有返回值,也不能直接抛出检查异常
3. Callable子实现类
传给FutureTask在交给Thread
代码块收起展开
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
return 100;
}
}
public class Test {
public static void main(String[] args) throws Exception {
FutureTask<Integer> futureTask = new FutureTask<>(new MyCallable());
Thread t = new Thread(futureTask);
t.start();
Integer result = futureTask.get();
System.out.println(result);
}
}特点
-
有返回值
-
可以抛异常
-
需要借助
FutureTask或线程池 -
get()会阻塞等待结果
4. 线程池execute()
通过 ExecutorService 管理线程
代码块收起展开
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Test {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(3);
pool.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务");
});
pool.shutdown();
}
}特点
-
实际开发最推荐
-
线程可以复用,减少频繁创建和销毁开销
-
便于统一管理线程数量、任务队列、拒绝策略等
-
更适合高并发场景
区别与联系
联系:真正启动线程还得靠 Thread,它才是真正的执行载体。
Runnable 和 Callable
实现 Runnable
-
任务和线程解耦
-
无返回值
-
更符合面向对象设计
-
不可抛异常
实现 Callable
-
比
Runnable更强 -
有返回值,可抛异常
start() 和 run()
run()
只是一个普通方法调用,谁调用它,谁就执行。
不会启动新线程。
start()
才是真正用于启动新线程的方法。
调用 start() 后,线程进入可运行状态,由 JVM 调度,然后由新线程自动执行 run()。
线程状态
NEW 是新建,start 后进入 RUNNABLE。
抢不到 synchronized 锁进入 BLOCKED。
wait、join、park 进入 WAITING。
sleep、wait(long)、join(long) 进入 TIMED_WAITING。
执行结束进入 TERMINATED。
其中 Java 没有单独的 RUNNING 状态,运行中也属于 RUNNABLE。
code test
如何让t1、t2、t3有序地执行
代码块收起展开
Thread t1 = new Thread(sout("666"));
Thread t2 = new Thread(()->{
try{
t1.join(); //只有线程t1执行完才跑t2
} catch (InterruptedException e){
e.printStackTrace();
}
sout(t2)
})
Thread t3 = new Thread(()->{
try{
t2.join(); //只有线程t2执行完才跑t3
} catch (InterruptedException e){
e.printStackTrace();
}
sout(t3);
})
t1.start();
t2.start();
t3.start();如何停止一个正运行的线程
标记flag停止
stop强行终止
interrupt方法
//TODO 缺代码, ai不上后删除此句
线程安全
synchronized 关键字的底层原理 谈谈JMM (Java 内莫型) CAS AQS ReentrantLock 的实现原理 synchronized 和 Lock 有什么区别 死锁产生的条件是什么 如何进行死锁诊断 谈谈你对 volatile 的理解 聊一下 ConcurrentHashMap 导致并发程序出现问题的根本原因是什么
synchronize的原理
宏观上的Monitor
lock对象内部有一个Monitor, 其由三个部分组成: WaitSet、EntryList、Owner 其中
- Owner: 关联当前线程
- EntryList: 关联没抢到锁的线程(Blocked)
- WaitSet: 关联调用了wait的线程(Waitting)
注意这里的BLOCKED是非排队的, 谁抢到就是谁的, 所以是非公平锁
微观上的MarkWord
在 HotSpot JVM 里,一个普通对象在内存中通常由 3 部分组成:
- 对象头 Header
- Mark Word
- Klass Pointer
- 实例数据 Instance Data
- 对齐填充 Padding(HotSpot)
MarkWord(32bits)
-
hashcode: 25 位的对象标识 Hash 码
-
lock标识占两位
-
age: 对象分代年龄占 4 位
-
biased_lock: 偏向锁标识,占 1 位, 0 表示没有开始偏向锁, 1 表示开启了偏向锁
-
thread: 持有偏向锁的线程 ID ,占 23 位
-
epoch: 偏向时间戳,占 2 位
-
Ptr_to_lock_record :轻量级锁状态下, 指向栈中锁记录的指针,占 30 位
-
Ptr_to_heavyweight_monitor: 重量级锁状态下, 指向对象监视器 Monitor 的指针,占 30 位
重量级锁(多线程竞争)
一旦synchronized给对象加锁之后, 对象的MarkWord会变成重量级锁指针(30个ptr+2个lock表示) 并且指向就会指向Monitor (这就是对象如何关联到Monitor)
轻量级锁(线程交替执行)
同步代码块没有竞争, 不同线程交替执行 不管几个线程重入都要cas, 不过是设置为null, 不真正cas 解锁则cas回来
![]()
偏向锁(只有一个线程)
没有竞争的时候只需要cas一次, 后续同一个线程重入不用重新cas (java6后的优化) 并且直接把线程id可在MarkWord, 后续重入直接看对比线程id
锁升级
JMM
mindmap
root((JMM<br/>Java Memory Model))
本质
并发访问共享变量的规则
不是JVM物理内存分区
解决线程间可见性与有序性问题
核心目标
原子性
可见性
有序性
抽象模型
主内存
存共享变量
工作内存
每个线程私有副本
线程先读副本再写回主内存
问题来源
缓存不一致
指令重排
并发交错执行
关键机制
volatile
保证可见性
禁止部分指令重排
不能保证复合操作原子性
synchronized
保证原子性
保证可见性
保证有序性
final
构造完成后安全发布
对final字段有特殊可见性保障
Happens-Before
程序次序规则
锁规则
volatile规则
start规则
join规则
传递性
重排序
编译器优化
CPU乱序执行
单线程遵守as-if-serial
多线程可能出问题
经典场景
volatile标志位
DCL单例
i++线程不安全
JAVA内存模型: 将内存分为工作内存和主内存, 主内存多线程共享, 工作内存线程独有, 因此主内存变量会有线程安全问题
代码块收起展开
//下列代码线程1永远不会停, 因为两个线程一直读的是各自工作内存的flag
public class Test {
private static boolean flag = false;
public static void main(String[] args) {
new Thread(() -> {
while (!flag) {
}
System.out.println("结束");
}).start();
new Thread(() -> {
flag = true;
System.out.println("flag 已修改为 true");
}).start();
}
}cas
代码块收起展开
while (true) {
int oldValue = count;
int newValue = oldValue + 1;
if (CAS(count, oldValue, newValue)) {
break;
}
}
//cas
if (V == A) {
V = B;
} else {
更新失败
}
volatile
-
JIT不会优化volatile修饰的变量
-
禁止指令重排序
这里volatile是为了多线程下指令重排序导致获得到的变量不符合代码顺序执行的结果
写前读后有屏障 volatile 使用技巧 写变量让 volatile 修饰的变量的在代码最后位置 读变量让 volatile修饰的变量的在代码最开始位置
![]()
AQS
- 是多线程中的队列同步器。是一种锁机制,它是做为一个基础框架使用的像 ReentrantLock 、Semaphore 都是基于 AQS 实现的,
- AQS 内部维护了一个先进先出的双向队列,队列中存储的排队的线程在 AQS 内部还有一个属性 state ,这个 state 就相当于是一个资源,默认是 0(无锁状态),如果队列中的有一个线程修改成功了 state 为 1 ,则当前线程就相等于获取了资源
- 在对 state 修改的时候使用的 cas 操作,保证多个线程修改的情况下原子性
- 既是非公平也是公平锁, 新的线程与队列中的线程共同来抢资源,是非公平锁 新的线程到队列中等待,只让队列中的 head 线程获取锁,是公平锁
ReentrantLock(cas+AQS)
- Reentra ntLock 表示支持重新进入的锁,调用 lock 方法获取了锁之后,再次调用 lock, 是不会再阻塞
- 支持公平锁和非公平锁,在提供的构造器的中无参默认是非公平锁,也可以传参设置为公平锁
- 支持condition

线程池
核心参数
handler: Abort / CallerRun / DiscardOldest / discard workQueue: 阻塞队列
执行原理
使用场景
1. CountDownLatch(都做完叫我)
理解为countdown launch, 火箭发射有许许多多前置工作, 一切都好时才发射, 所以是countdown launch
场景
es数据的批量导入
**一个线程等待多个任务完成**
关键词
- 一次性
- 主线程等待
- 初始化完成
- 并行任务汇总
典型例子
- 系统启动等多个模块 ready
- 主线程等待多个 RPC 查询完成
- 压测时等待所有线程结束
2. CyclicBarrier(凑齐了一起走)
场景
多个线程彼此等待,凑齐了再一起往下走
它和 CountDownLatch 最大区别在于:
- CountDownLatch 更像 老板等员工干完
- CyclicBarrier 更像 大家集合,等人齐了一起出发
典型例子
- 多线程分阶段计算
- 多人游戏房间凑满开始
- 多个 worker 每轮都要同步
为什么叫 cyclic(循环的)
因为它可以复用,一轮结束后下一轮还能继续。
3. Semaphore
场景
控制并发数量
你可以把它理解成“许可证”。
比如只允许:
- 10 个线程同时访问某接口
- 20 个任务同时处理图片
- 5 个线程同时拿数据库连接
典型例子
- 限流
- 连接池资源控制
- 热点资源访问保护
它解决的不是等待完成
它解决的是:同时允许多少人进来—>可以控制最多多少个线程同时运行
new Semaphore(5) --->允许的信号量
.acquire() ---> 信号量-1
.release() ---> 信号量+1
4.数据汇总
线程池+FutureTast+get
5.异步调用
异步线程(线程池):为了避免下一级方法影响上一级方法(性能考虑), 可使用异步线程调用下一个方法(不需要下一级方法返回值),可以提升方法响应时间
其他
引用的分类
强不回收,软看内存,弱见必收,虚只通知
强引用
- 直接new出来的对象
- 只要强引用还在,GC 绝不会回收这个对象
- 就算内存不够了,JVM 也宁可抛
OutOfMemoryError,也不会随便动强引用对象
代码块收起展开
User usr=new User("6");弱引用
- 只要 GC 扫到了,只剩弱引用的对象就会被回收
- 不管内存够不够,都会回收它
代码块收起展开
User usr=new User("6");
WeakReference<User> wr=new WeakReference<>(usr);软引用
- 内存充足:一般不回收
- 内存紧张:优先回收软引用对象
- 所以它是“有点重要,但不是非活不可”的对象
代码块收起展开
String str = new String("abc");
SoftReference<String> softRef = new SoftReference<>(str);
str = null;虚引用
- 虚引用不会影响对象生命周期
- 只有虚引用的对象,和没有引用几乎一样,随时可被回收
get()基本拿不到对象本体- 必须配合
ReferenceQueue使用
代码块收起展开
String str = new String("abc");
ReferenceQueue<String> queue = new ReferenceQueue<>();
PhantomReference<String> phantomRef = new PhantomReference<>(str, queue);ThreadLocal
同一个
ThreadLocal实例,在不同线程里各自持有一份属于自己的资源对象。 线程 A 的conn是线程 A 的,线程 B 的conn是线程 B 的,两者互不干扰,彻底规避并发竞争。
内存泄漏
ThreadLocal 的 key 是弱引用, val是强引用, key被回收后val永久悬挂导致堆积, 所以最好remove掉