JUC查漏补缺
1. 线程与进程
线程和进程?
进程
程序的一次执行过程,是系统运行程序的基本单位,
进程是动态的,系统执行一个程序即是一个进程从创建,运行到消亡的过程。
比如Windows里任务管理器就能看到运行的进程(.exe文件)线程
线程是比进程更小的运行单位
一个进程在运行的时候可以产生多个线程
与进程不同,同类的多个线程共享进程的堆和方法区,但每个线程都有自己的程序计数器,虚拟机栈和本地方法栈。
所以系统在产生一个线程,或是线程切换时,负担要比进程小得多。
Java线程和操作系统线程的区别?
JDK1.2之前,Java线程基于绿色线程(Green Threads),是一种用户线程
由JVM自己调度,不依赖操作系统,因此无法利用多核(CPU多个核心)资源,也无法使用操作系统的线程能力。JDK1.2之后,Java线程改为基于原生线程(Native Threads)实现
也就是每个Java线程都对应一个操作系统的内核线程,由操作系统负责调度,因此可以利用多核CPU,实现真正的并行执行。用户线程和内核线程的区别
- 用户线程:由用户程序/JVM自己管理,操作系统不知道它的存在,切换速度快(不需要内核参与)
但是无法利用多核,一个线程阻塞会导致多个线程卡住 - 内核线程:由操作系统管理,调度由操作系统完成,每个线程对应一个内核线程(Java现在是1:1模型)
- 用户线程:由用户程序/JVM自己管理,操作系统不知道它的存在,切换速度快(不需要内核参与)
现在很多语言也会用用户线程 + 内核线程混合模型,比如Go的
Goroutine就是M:N模型,而Java目前仍然是1:1模型
2. 如何创建线程
继承
Thread- 最简单的创建线程方法
- 写法:
class MyThread extends Thread - 使用方法:
- 必须调用
start(),不能直接run() run()只是普通方法start()才会创建新线程(底层调用OS)
- 必须调用
- 缺点:
- Java是单继承,不灵活
实现
Runnable接口- 最常用的方法
- 写法:
class MyRunnable implements Runnable - 一般使用lambda表法式
new Thread(() -> {System.out.println("lambda线程");}).start(); - 优点:
- 可以继承其他的类
- 多线程共享资源更方便
实现
Callable接口- 配合FutureTask使用,可以获取线程执行结果并支持异常处理。
- 特点:
- 有返回值
- 可以抛异常
- get()方法会阻塞
线程池
- 通过线程池(
ExecutorService)来创建线程,最推荐的方式。 - 实际开发中一般不会直接创建线程,而是使用线程池
ThreadPoolExecutor。
1
2
3
4
5
6
7
8
9
10
11public class Test {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(3);
pool.execute(() -> {
System.out.println("线程池执行任务");
});
pool.shutdown();
}
}- 优点:
- 可以复用线程
- 避免频繁创建/销毁线程(开销大)
- 可以指定并发数量
- 通过线程池(
3. 线程的生命周期
- 共六个状态,如下
1 | NEW(新建) |
NEW(创建)
- 线程刚创建,还没启动
Thread t = new Thread- 还没调用start()方法
RUNNABLE(就绪/运行)
- 调用了start()方法
- 注:Java中没有单独的Running状态
- RUNNABLE = 就绪 + 运行中
- 真正运行与否由操作系统调度决定(CPU时间片)
BLOCKED(锁阻塞)
- 抢锁失败(synchronized)
synchronized(obj){//线程执行} - 如果锁被别人占用,就会进入BLOCKED
- 抢锁失败(synchronized)
WATING(无限等待)
- 在别人唤醒之前需一直等待
- 常见方法
- obj.wait()
- thread.join()
- 不会主动恢复,必须被唤醒
TIMED_WAITING(超时等待)
- 带时间的WAITTING
- 常见方法
- obj.wait(100)
- thread.join(100)
- 时间到了会自动醒
TERMINATED(终止)
- 线程执行完毕 -> run()执行结束
- 注意,BLOCKED和WAITING不同
- 前者需要等拿到锁才能运行,而后者需要被唤醒。
4. Thread.sleep()方法和Object.wait()方法对比
共同点:两者都可以暂停线程
区别:
Thread.sleep()方法没有释放锁,而Object.wait()会自动释放锁wait用于线程间通信(配合notify),而sleep只是让当前线程暂停执行sleep()是 Thread 类的静态本地方法,wait()则是 Object 类的本地方法
为什么wait()方法不定义在Thread类?
wait()方法是让线程释放占用的锁然后等待
既然要释放锁,肯定要操作对应的对象Object,而不是当前的线程Threadsleep()方法是让当前线程暂停,不涉及对象类,所以定义在Thread类中
可以不调用start()而直接区调用Thread.run()吗
- 调用
start()方法才会由 JVM 创建新的线程,并交由操作系统调度执行 run 方法。 - 如果直接执行run()方法,只是普通方法调用,并不会创建新线程,仍然在当前线程中执行。
5. 多线程
并行与并发的区别
- 并发:多个任务“交替执行”,只有一个CPU核心去执行多线程任务
- 并行:多个任务“同时执行”,多个cpu核心,每个核心去执行一个任务
同步和异步的区别
- 同步:做完一件事,才能做下一件(阻塞)
- 异步:发出任务后,不等结果,继续做别的(非阻塞)
使用多线程可能带来什么问题?
- 并发编程的目的就是为了能提高程序的执行效率进而提高程序的运行速度
但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
如何理解线程安全和不安全?
- 线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。
线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失
6. Synchronized关键字
Synchronized是JVM提供的内置锁(也叫监视器锁/ Monitor Lock),
他锁的是对象,具体是对象的Monitor,一般对象定义在方法外
用于保证
线程安全(同一时间只能被一个线程执行)
内存可见性(修改对其他线程可见)
有序性(可以防止指令重排)
Synchronized底层原理
Synchronized基于Monitor(监视器)
每个对象都可以关联一个Monitor
进入Synchronized的本质就是:
- 获取对象的 Monitor(加锁)
- 执行Synchronized内的代码
- 释放 Monitor(解锁)
在JVM字节码层面
获取Monitor就是执行monitor enter指令
释放Monitor就是执行monitor exit指令
Synchronized下线程执行流程
Synchronized (Lock) { // 临界区 }
Lock是方法外的对象,用于锁住该对象的Monitor(每个对象都自带一个Monitor)
临界区放执行代码
线程进入
1. 尝试获取对象Monitor
2. 如果成功——>进入临界区执行
3. 如果失败——>进入阻塞队列(等待Monitor)
4. 执行完,释放Monitor,唤醒其他阻塞线程
Synchronized锁升级/锁膨胀
- Synchronized 会根据竞争情况逐步升级锁,避免一开始就用开销大的重量级锁
锁的升级路线:无锁 ——> 偏向锁 ——> 轻量级锁 ——> 重量级锁
锁只能升级,不能降级
一旦升级为重量级锁,不会降回轻量级锁或偏向锁
JDK18之后,应用多线程为主流,将偏向锁删去
无锁
Synchronized的初始阶段- 对象刚创建
Object lock = new Object() - 没有线程竞争
此时没有加锁行为
线程进入时升级为偏向锁
- 对象刚创建
偏向锁
只有一个线程反复进入临界区- 锁直接**”偏向”**某个线程
- 记录线程ID,下次这个线程再来
当第二个线程来争时升级为轻量锁
轻量级锁
多个线程,但是竞争不激烈
使用**CAS(自旋)**来重复抢锁
线程不会阻塞,也就是不进入内核,整个程序只在内核态就能完成
流程:- 线程尝试CAS
- 成功 ——> 获取锁
- 失败 ——> 自旋等待
优点: - 不阻塞线程(线程资源)
- 避免线程切换
缺点:
自旋不需要切换内核态,避免线程切换,但是如果自旋次数过多,会造成长时间CPU空转,性能还不如阻塞
自旋一定次数(默认20次)还是失败(竞争激烈)升级为重量级锁
重量级锁
使用操作系统底层的互斥锁(MuteX),这样每次切换线程会造成用户态内核态切换
线程没抢到锁会进入阻塞状态
Synchronized特性
可重入锁(Reentrant)
同一个线程可以重复获取对象锁,可以避免死锁自动释放锁(与ReentrantLock的区别)
不需要手动unlock
正常执行或者异常都会释放锁不可中断(与ReentrantLock的区别)
获取锁时,等待的线程不能被中断不公平性(与ReentrantLock的区别)
Synchronized是不公平锁,因为新来的线程可以插队,直接竞争锁,而不是按照等待顺序获取锁- 公平锁:谁先排队,谁先拿锁(FIFO)
- 非公平锁:新线程可以插队抢锁
Monitor的竞争队列不会保持顺序唤醒
唤醒的线程和新进入的线程都可以去抢锁
这样可以避免线程切换,提高吞吐量
7. 悲观锁,乐观锁
悲观锁
顾名思义,就是总会假设最坏的情况
认为共享资源每次被访问都会出现问题,所以每次获取资源都会上锁
其他线程想拿资源就会被阻塞,直到释放锁
也就是说,共享资源每次只能给一个线程使用,其他线程阻塞,用完再转给其他线程
- 像Java中的
Synchronized和ReentrantLock等都是悲观锁的实现
高并发的场所下,激烈的锁竞争会造成线程阻塞,还会导致频繁的上下文切换,增加性能开销
同时,多线程下,悲观锁可能会出现死锁问题 - 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。
乐观锁
总会假设最好的情况
认为共享资源每次访问都不会出现问题,线程可以不停的执行,所以不需要加锁
但是在提交时会去检验对应的资源是否被其他线程修改,如果失败则自旋重试,直到成功(具体实现使用CAS或者版本号)
- 高并发下,乐观锁不会产生锁竞争,也不会造成线程阻塞,也不会有死锁问题,在性能上比悲观锁更胜一筹
但是冲突频繁发生时,自旋会频繁失败,同样会影响性能 - 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。
- 乐观锁通常使用版本号机制或CAS算法实现
版本号机制
在数据表中加上一个版本号(version)字段,表示数据被修改次数
当数据被修改时,version+1
线程A更新数据时,同时读取version,提交前对比读取到的version和当前DB中的version
如果相等则更新,否则重试
CAS算法
全称Compare And Swap(比较和交换),用于实现乐观锁
思想就是用一个预期值和要更新的变量值进行比较,两值相等则更新
CAS是一个原子操作,底层依赖于CPU的原子指令,操作一旦开始不能被打断。
CAS涉及到三个操作数
V:要更新的变量值(Var)
E:预期值(Expected)
N:写入的新值(New)
- 当V = E 时,CAS通过原子方式用新值 N 来更新 V 的值
如果不等于,说明有其他线程更新了 V ,自旋重试
举个例子
我们将i = 1 更新为 6 (V = 1,N = 6),假如更新前读到数据库中E = 1,则将i赋值成6
Java中CAS如何实现
- 通过魔法类
Unsafe实现的
Unsafe提供了compareAndSwapObject,compareAndSwapInt,compareAndSwapLong三个方法实现了对Object,int,long类型的CAS操作
Unsafe类中的CAS方法是native方法,由C++编写,通过JNI调用
CAS存在的问题
ABA问题
- 如果一个变量 V 初次读取的是 A ,在更新前还是读到是A
我们能说他没被修改过吗?
显然是不能的 因为他可能被修改成其他值 然后重新修改为A
这样CAS就会以为 V 没被修改,这就是ABA问题 - ABA的解决思路
在 V 前加上版本号或时间戳,先检查版本号是否相同,在检查 V = E ,如果都相等,则更新
- 如果一个变量 V 初次读取的是 A ,在更新前还是读到是A
自旋循环时间长,开销大
- CAS经常使用自旋进行重试,如果不成功则一直循环,长时间会对CPU造成开销
8. ReentrantLock
JUC包中最经典的显式锁,比Synchronized更优秀,功能如下:
- 可中断(Synchronized不能中断线程)
- 可超时获取锁
- 可选择公平/非公平(Synchronized是不公平锁)
ReentrantLock四个特点
- Java中,最基础的锁是Synchronized
ReentrantLock相比于Synchronized有四个增强点
可手动加锁/解锁
- Synchronized在进入临界区自动加锁,离开临界区自动释放锁
而ReentrantLock是显式的
1
2
3
4
5
6lock.lock();
try(){
//临界区
} finally {
lock.unlock();
}可以更灵活的控制加锁解锁时机
- Synchronized在进入临界区自动加锁,离开临界区自动释放锁
可中断获取锁过程
Synchronized在等待锁时,不能被中断
而ReentrantLock可以使用lock.lockInterruptibly在等待期间被中断,停止等待在取消任务,避免长期阻塞的场景下很有用
支持超时获取锁
- 可以设置TTL,在TTL结束之前尝试获取锁,超时返回
false
1
2
3
4
5
6
7
8
9if (lock.trylock(3,TimeUnit.SECONDS)){
try{
//获取锁成功
} finally {
lock.unlock();
} else {
//获取锁失败
}
}可以避免线程一直阻塞,也可以避免死锁
- 可以设置TTL,在TTL结束之前尝试获取锁,超时返回
支持公平锁
Synchronized本质上不公平
而ReentrantLock可以指定锁是否公平
Lock lock = new ReentrantLock(true);//公平锁可以使线程按顺序获取锁,避免插队
ReentrantLock用法
- 标准写法
1
2
3
4
5
6
7ReentrantLock lock = new ReentrantLock();
lock.lock();
try{
//临界区
} finally {
lock.unlock();
}- 一定要在finally里解锁
如果解锁不放在finally,一旦临界区抛异常
后面的unlock()执行不到,会产生死锁
- 一定要在finally里解锁
lock.lock()- 最普通的加锁方式
lock.unlock()- 释放锁
只有持有锁的线程才能去执行
没有锁的线程去调用,会抛IllegalMonitorStateException
- 释放锁
lock.tryLock()- 尝试获取锁,拿到锁执行,没拿到返回
false,不阻塞
- 尝试获取锁,拿到锁执行,没拿到返回
lock.tryLock(time , TimeUnit)- 在指定时间内获取锁
时间内获取锁返回true
超时返回false
- 在指定时间内获取锁
lock.lockInterruptibly()- 锁空闲,获取锁
- 锁被占用,线程阻塞
- 等待过程中,如果线程中断,停止等待并抛
InterruptException
lock.newCondition()
可重入怎么实现?
ReentrantLock可以实现可重入
比如一个线程去methodA()方法拿到锁,然后可以再去methodB()方法拿到同一把锁
这就是可重入ReentrantLock可重入底层原理
内部会记录两件事- 当前持有锁的使哪个线程
- 这个线程重入了多少次
第一次加锁state:0 -> 1
第二次重入state:1 -> 2
第一次unlockstate:2 -> 1
第二次unlockstate:1 -> 0
加锁几次,必须解锁几次
ReentrantLock 和 Synchronized 的异同
相同点:
- 都是可重入锁
- 都能保证临界区线程安全
- 都是独占锁
不同点:
- 使用方式不同
Synchronized是关键字,JVM层面的锁
ReentrantLock是类,API层面的锁 - ReentrantLock更灵活,他支持
tryLock
可中断获取锁
超时获取锁
公平锁
多个Condition
这些都是Synchronized不具备的 - 底层实现不同
Synchronized基于JVM的monitor机制
ReentrantLcok依赖AQS实现
- 使用方式不同
ReentrantLock 底层原理
ReentratLock本质上就是基于AQS实现的独占锁,AQS是JUC里很多同步器的底层框架
AQS里有个核心变量statevolatile int state
这个state对于ReentrantLock来说state = 0代表无锁state > 0代表被占用 一个线程重入几次,state就加几次大致流程:
- 锁没人占,通过
CAS把state从0改为1
被占用,看是否重入或者是被别人占用
若锁被别人占用,则获取失败,进入AQS的等待队列,必要时阻塞 - 释放锁时,线程调用
unlock()
若当前线程不是锁持有者,抛异常
否则state - 1
直到减到0 , 说明彻底释放锁,唤醒AQS等待队列的下一个线程
- 锁没人占,通过
AQS等待队列是什么
- 当一个线程抢锁失败,不会空转,而是进入AQS维护的等待队列
他时一个双向链表队列
线程抢不到锁时
伪装成Node节点,加入队尾,等待挂起
Condition是什么
- Condition可以理解为
与锁绑定的等待/通知机制
作用类似于Object.wait/notify()
传统Synchronized的线程通信
需要在临界区去调用对象的等待(wait)和唤醒(notify)
只能与一个对象的Monitor绑定ReentrantLock+Condition
使用方法:
1 | ReentrantLock lock = new ReentrantLock(); |
- 三个方法
await()
使持有锁的当前线程 释放锁,并进入等待队列signal()
唤醒该等待线程,重新竞争锁signalAll()
唤醒所有等待线程
Condition 比 wait/notify 强在哪
- 最大的优势:
一个锁可以创建多个Condition 对应多个等待队列- 比如:
生产者消费者的模型
有两个队列notFull队列没满才能产生notEmpty队列不空才能消费
- 比如:
9. JMM(Java Memory Model)
- JMM是Java的内存模型规范
用于定义多线程之间变量如何共享/可见/有序
CPU缓存模型
要搞清楚多线程变量之间的关系,我们先从CPU缓存讲起
磁盘运行速度太慢,所以引入了内存,运行程序时,先将磁盘数据读到内存,然后再运行
相比于磁盘,内存相当于缓存
相比于CPU,内存还是太慢了,所以用了CPU缓存所以为什么要有CPU缓存:
解决CPU处理速度与内存不匹配的问题CPU缓存分为三级L1 L2 L3
L1和L2是CPU核心私有的,而L3是共享的
CPU缓存怎么工作的?
- CPU运行程序时,先将内存中的数据复制到CPU缓存,CPU就可以直接从缓存中读取数据
运算完成后,再将运算得到的数据写回内存中,这样内存缓存不一致问题就解决了
指令重排序
- 为了提升执行速度,计算机会在执行代码时,对指令进行重排序
什么是指令重排序
- 简单来说就是系统执行代码时不一定按照写的代码顺序依次执行
- Java 源代码会经历
编译器优化重排 —> 指令并行重排 —> 内存系统重排的过程
最终才变成操作系统可执行的指令序列。指令重排序可以保证串行语义一致,
但是没有保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
为什么需要JMM
- 为了解决以下问题:
可见性问题
当一个CPU核心执行线程任务,修改了CPU缓存中的数据
但是缓存中的数据还没有写回到内存中
这时其他CPU核心去执行另外一个线程任务,从内存中读取的数据就和缓存中不一致有序性问题
多线程执行时,为了提升性能会对指令重排序,会造成语义不一致原子性问题
每个线程执行的时候都会分给一个时间片,规定线程占用CPU的时间
如果一个线程读取到数据,还没修改,时间片耗尽了
这时第二个线程修改了
而第一个线程恢复之后又进行了修改,导致失去了原子性
OS通过内存模型定义了规范去解决了这些问题,但是不同OS的规范不同
会导致Java代码在不同OS下无法执行所以为了达到不同OS下都能执行,Java自己去提供了内存模型规范,也就是JMM
- 标题: JUC查漏补缺
- 作者: yin_bo_
- 创建于 : 2026-03-23 19:53:43
- 更新于 : 2026-04-06 20:01:01
- 链接: https://www.blog.yinbo.xyz/2026/03/23/面试题/JUC查漏补缺/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。