juc
并发编程
1.进程与线程
1.进程与线程
- 进程
- 程序由指令和数据组成,指令需要运行,数据需要读写,就要将指令加载到cpu上,数据加载至内存。在指令运行过程中还需要使用磁盘,网络等设备。 进程就是用来加载指令、管理内存、管理I/O的。
- 当一个程序被运行,指令代码被加载至内存,这时就开启了一个进程。
- 进程可以视为程序的一个实例。有些可以多开(记事本),有些只能开一个(网易云)
- 线程
- 一个进程之内可以有多个线程
- 一个线程就是一个指令流,是 CPU 调度的基本单位,线程按照一定顺序执行代码,多个线程可以并发地被 CPU 核心调度执行。
- java中,线程作为最小调度单位,进程作为资源分配最小单位。
两者对比
- 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
- 进程拥有共享的资源,如内存空间等,供其内部的线程共享
- 进程间通信较为复杂
- 同一台计算机的进程通信称为IPC(Inter-process communication) 常见的 IPC 方式包括管道(pipe)、共享内存、消息队列、socket 等。
- 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如HTTP
- 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低
3.并行与并发
- 并发(concurrency):同一时间应对(dealing with)多件事情
- 并行(parallel):同一时间动手做(doing)多件事情的能力
4.应用
同步&&异步调用
从方法调用的角度来说,如果
类比:去奶茶店点单后站在原地等,做好了再走。 同步(调用一个方法,要等它执行完,拿到结果才能继续。)
类比:下单后坐下玩手机,等叫号取奶茶。 异步(调用一个方法后,不用等它完成,可以继续做别的事,结果准备好后再通知你。)
5.结论
- 单核cpu下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使
用cpu,不至于一个线程总占用cpu,别的线程没法干活 - 多核cpu可以并行跑多个线程,但能否提高程序运行效率还是要分情况的
- 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分(参考后文的【阿姆达尔定律】)
- 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
- IO操作不占用cpu,只是我们一般拷贝文件使用的是【阻塞IO】,这时相当于线程虽然不用cpu,但需要一直等待IO结束,没能充分利用线程。所以才有后面的【非阻塞IO】和【异步IO】优化
2.Java线程
1.创建和运行线程
创建线程有三个方法
- 方法一,直接创建
1 | Thread t = new Thread (){ |
- 方法二,使用runnable对象,使用lambda表达式简化,可以在方法二的基础上使用alt+enter让idea自己改
1 | Runnable runnable = new Runnable(){ |
1 | Runnable runnable = () -> System.out.println("我是线程哈哈哈"); |
使用任务和线程分开的写法好一些。
2.原理之线程运行
1.运行原理
每个线程启动后,虚拟机会为其分配一块栈内存
- 每个栈由多个栈帧组成,对应着每次方法调用使用的内存。
- 每个线程只能有一个活动栈帧。
2.线程上下文切换(Thread Context Switch)
- 原因:
- cpu时间用完
- 垃圾回收(gc)
- 有更高优先级的线程运行
- 线程自己调用了sleep,yield,wait,join,synchronized,lock等方法
- 当ContextSwitch发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java中对应的概念就是程序计数器(ProgramCounter Register),它的作用是记住下一条jvm指令的执行地址,是线程私有的
- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
- ContextSwitch频繁发生会影响性能
3.线程中的常见方法
run VS start
- run()只是普通方法调用,不会并发
- start才是启动线程
1 | Runnable r1 = new Runnable() { |
记住,start只能调用一次,会让state变为runnable。
sleep VS yield
- sleep
- 调用sleep会让当前线程从Running进入Timed Waiting状态(阻塞)
- 其它线程可以使用interrupt方法打断正在睡眠的线程,这时sleep方法会抛出InterruptedException
- 睡眠结束后的线程未必会立刻得到执行(不能够马上获得cpu,看线程的状态)
- 建议用TimeUnit的sleep代替Thread的sleep来获得更好的可读性
- yield(让出,谦让)
- 调用yield会让当前线程从Running变为runnable就绪态,然后调度执行其他线程
- 具体的实现依赖于OS
应用:防止CPU占用100%
在没有利用cpu进行计算的时候,不要让while(true)空转浪费cpu,可以使用yield或sleep让出cpu的使用权
1 | while(true){ |
- 可以使用wait / 条件变量达到类似效果
- 不同的是,后两种都需要加锁,并且需要相应的唤醒(notify / notifyall)操作,一般适用于要同步的场景
- wait() 必须放在 synchronized 中;
- 被 wait() 的线程会释放锁并阻塞;
- notify() 在同一个锁对象上唤醒一个线程,notifyAll() 唤醒所有等待线程。
- sleep适用于无需锁同步的场景
interrupt() && isInterrupted() && interrupted()
- thread.interrupt();
- 并不会立刻杀死线程!
- 它只是设置线程的“中断标志位”为 true
- 如果线程正在 sleep、wait、join 等阻塞操作,会抛出 InterruptedException 异常
- Thread.currentThread().isInterrupted();
- 返回当前线程的中断状态(true/false)
- 不会清除这个标志位
- Thread.interrupted(); // 检查当前线程是否中断,并清除标志位
Thread.interrupted():“我看一下我被打断没?顺便把这事忘了”
isInterrupted():“我看你有没有被打断,我不动你状态”
拓展:设计模式(两阶段终止)
有两个线程t1和t2,t2已经完成了,t1该如何帮t2料理后事?
- 不能使用暴力stop(),因为t2可能有一些上了锁的资源,当t2被杀死后就再也没有机会释放锁,导致其它线程无法获得锁。
守护线程
守护线程是为用户线程提供服务的线程。当所有用户线程都结束时,守护线程会被 JVM 自动终止,不会阻止程序退出。
GC 是守护线程的例子
- JVM 启动时会创建 GC 守护线程
- 它在后台自动运行,清理不再使用的对象
- 主线程(用户线程)结束后,GC 守护线程也会随 JVM 退出而结束
- 守护线程适合执行日志、监控、GC 等后台任务
3.共享模型之管程(Monitor)
1.共享带来的问题
1 | Thread t1 = new Thread(()->{ |
不一定是0。
多个线程对共享资源进行读写操作,上下文交换引起的指令交错
- 问题出现在多个线程访问共享资源
- 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
- 竞态条件(Race Condition)
- 多个线程在临界区内执行,由于代码的执行顺序不同而导致结果无法预测,称之发生了静态条件
2.静态条件解决方案
- 阻塞式
- Synchronized、lock
- 非阻塞式
- 原子变量
synchronized(对象锁)
- 语法
1 | synchronized (对象){ |
类比说明
- 把
obj
想象成一个 房间,房间只有一个 门。 - 每个线程(如
t1
、t2
)就像是 想进房间干活的人。 - 一次只能一个线程进房间,其他线程必须在门外 排队等待。
执行过程
t1
运行到synchronized(obj)
→ 拿到钥匙,进入房间(获得锁)
→ 门被锁上,其他线程进不来。t2
也运行到synchronized(obj)
→ 发现门被锁,阻塞在门外,等待钥匙。t1
时间片用完,被系统挂起
→ 虽然暂停了,但钥匙还在它手上(锁未释放)
→t2
仍无法进入房间。t1
被重新调度回来
→ 执行完同步代码块,出门还钥匙(释放锁)
→ 唤醒t2
,t2
拿到钥匙进入房间继续执行。
关键类比总结
Java 概念 | 房间类比 |
---|---|
锁对象(如 obj) | 房间 |
synchronized(obj) | 进入房间前先锁门 |
获得锁 | 拿到钥匙,进入房间 |
阻塞线程 | 在门外排队等待钥匙 |
释放锁 | 出门还钥匙,唤醒别人 |
面向对象加锁
1 | public class Main { |
在方法上加锁
- 在实例上加锁
1 | class test{ |
等价于
1 | class test{ |
- 在类上加锁
1 | class test{ |
等价于
1 | class Test{ |
- this 就像你家门 → 每家自己锁
- Test.class 就像整栋楼大门 → 所有人公用
区别是静态方法(类对象) 非静态方法(当前实例)
synchronized锁的是当前实例,Test锁的是整个类
搜线程八锁
进行练习,主要分析锁住的是不是同一个对象,如果不是同一个对象,那就是并行,不存在多线程;是同一个对象,就是并发。
实例方法加锁锁 this,每个对象一把锁;
静态方法加锁锁 class,全类共用一把锁;
不同对象加锁不冲突,相同对象才竞争;
类锁和实例锁互不影响。
线程安全:局部变量 vs 静态变量
局部变量是线程安全的
- 每次方法调用会创建新的栈帧
- 局部变量存放在线程独立的栈中,不共享,不冲突
静态变量是线程不安全的
- 静态变量属于类,全局共享
- 多线程访问会产生数据竞争,必须加锁保护
习题:银行账户转账
两个账户 a
和 b
,线程 T1 从 a 转账到 b,T2 从 b 转账到 a
如果两个账户是不同对象,用 synchronized(this)
是不安全的,因为锁的是两个不同的对象!
1 | public void transfer(Account target, int amount) { |
Monitor底层原理 (这里去看jvm的jmm)
Monitor被翻译为监视器或管程,可以理解为提到的锁
- 第一个线程获得对象的锁后,成为owner
- 后续来的线程发现owner被占用,只能进入等待队列并切换状态为阻塞
- 当临界区代码被执行完后,owner空出,通知Monitor唤醒阻塞队列的线程,(此时的阻塞队列竞争是非公平的,不一定是先进来的就先得到锁)
- 对象总是与一个monitor相关联的
notify()和wait()
在重量级锁的环境下,一个线程如果缺乏资源,就会调用wait方法主动放弃锁,进入等待池,自己的状态设置为waiting;等到别的线程将资源送来了,就会调用notify,告诉这个线程资源来了,这时候第一个线程就会离开等待池,状态变为blocking,进入锁池(EntryList)重新去竞争锁。
park()和unpark()
park()
和 unpark()
是 Java 中底层线程阻塞/唤醒的原语,属于 LockSupport
类提供的工具,用于实现更灵活的并发控制(比 wait/notify
、sleep/yield
更底层、更强大)。
锁的活跃性
- 死锁:各自持一把锁,又都想获得对方的锁,哲学家问题
产生死锁的条件- 互斥
- 占有且等待
- 不可抢占
- 循环等待 ✅ (最常通过避免循环等待解决)
解决策略 - 固定加锁顺序:如先锁 A 再锁 B,所有线程统一顺序,避免循环等待。
- 使用 tryLock():尝试加锁,失败就放弃,避免永久等待。
- 死锁检测与恢复:定期检测资源等待图,发现死锁强制中断线程(一般用于数据库/操作系统)。
- 饥饿:线程优先级不公平、锁分配不均
解决策略- 使用 公平锁:如
ReentrantLock(true)
,按请求顺序分配锁; - 避免线程优先级过高差距;
- 控制资源分配策略,避免让某些线程长期得不到调度。
- 使用 公平锁:如
- 活锁:
ReentrantLock(可重入锁,公平锁)
- 优点
- 可中断
- 可以设置超时时间
- 可以设置公平锁
- 支持多个条件变量(所有条件满足了才从waitSet出来去锁池)
与synchronized一样支持可重入
- 可重入
- 语法
1 | // 公平锁 |
- 条件变量
- 用于线程间通信,代替 wait() 和 notify();
- 支持多个等待队列(更细粒度的控制)。
1 | ReentrantLock lock = new ReentrantLock(); |
ReentrantLock + Condition:可以为不同条件建多个等待队列(每个condition内部有一个独立的等待队列)
- 锁超时
- 总结
- ReentrantLock 是显式锁,功能强于 synchronized;
- 支持更细粒度的锁策略;
- 搭配 Condition 可实现更复杂的线程通信模型;
- 需手动释放锁,推荐 try-finally 结构。
4.共享模型之不可变
- 什么是Java中的不可变类
- 不可变类是指无法修改对象的值(无法继承、线程安全),String不可变
- 追问:怎么实现不可变类?
- 通过看String的源码,String添加了final(类似于cpp的const)关键字,并且是private对象
- 替换方法replace()的实现是返回一个新的字符串。
- 追问:怎么实现不可变类?
5.并发工具
线程池
1.ThreadPoolExcutor
- 线程池状态
- 五状态:Running、Shutdown、Stop、Tidying、Terminated
Shutdown就像终结对方的连胜,但不代表这局游戏结束咯。
数字比较,是有符号数,最高位为-4,也可以去看ThreadPoolExecutor的源码
- 线程池构造方法
- corePoolSize核心线程数目:线程池中常驻的线程数。
- maximumPoolSize最大线程数目:线程池中允许创建的最大线程数。
- keepAliveTime线程存活时间:非核心线程空闲多久被回收。
- unit时间单位-针对救急线程
- workQueue阻塞队列:用于缓存待执行任务的阻塞队列,如 LinkedBlockingQueue。
- threadFactory线程工厂-可以为线程创建时起个好名字
- handler拒绝策略:线程池满时的拒绝策略
最大线程数 = 核心线程数 + 救急线程数
- 核心线程和救急线程的区别?
- 核心线程在任务执行完成后仍留在线程池中,救急线程执行完后会离开线程池。(外包)
- 如果系统面临高并发,你会怎样去配置线程池?
- 如果任务是CPU密集型(如计算型服务),线程数设为
CPU核心数+1
,使用无界队列,避免上下文切换;因为计算型服务几乎不进行IO操作,CPU使用频率很高,增加过多线程反而会导致频繁进行上下文切换,反而拖延性能,多加一个线程是为了应对线程调度或偶尔阻塞的情况。 - 如果任务是IO密集型(如网络请求,数据库读写),可设置更多线程,如
2 * CPU核心数
,配合适当长度的阻塞队列;因为IO密集型任务经常阻塞,线程会空闲,多设置一些线程来保证CPU有任务执行,线程看起来很多,但由于很多都处于阻塞,所以不会频繁进行上下文切换。所以本质上,线程池线程数的设置要根据任务的“阻塞程度”和“CPU占用率”做平衡。
- 拒绝策略方面,如果系统不能丢任务,可使用 CallerRunsPolicy 让主线程帮忙执行,降低系统负载;
- 队列类型上,对于高并发请求建议使用 LinkedBlockingQueue 或 ArrayBlockingQueue 来控制流量和内存使用。
- 如果任务是CPU密集型(如计算型服务),线程数设为
2.Fork/Join
JUC
1.AQS
全称是:AbstractQueuedSynchronized,是阻塞式锁和相关的同步器工具的框架
AQS 就是一套模板机制,你通过实现它的几个抽象方法,就可以“自定义一把锁”或“同步器”
- 特点:
- 用
state
属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获得锁和释放锁- getState:获取state状态
- setState:设置state状态
- compareAndSetState:乐观锁机制设置state状态
- 独占模式只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源
- 基于FIFO的等待队列,类似于monitor的Entrylist
- 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于Monitor 的 WaitSet
- 用
- 子类要实现下列方法
- tryAcquire
- tryRelease
- tryAcquireShared
- tryReleaseShared
- isHeldExclusively
- 并发工具类:是一个能让多个线程安全的去访问共享资源的一个工具,每个线程访问的时候要去判断当前共享资源是否被占用,是否正在被访问;如果没有线程访问,那当前线程就可以访问;如果当前资源被访问了,要么重试,要么阻塞,要么放弃。
- 如何表示共享资源当前正在被访问呢?
- 状态变量。为0时表示空闲,为1时表示被占有,同样是Reentrantlock的思想
- 公平策略:AQS是基于FIFO的队列设计的,只需要重写tryAcquire方法就可以自定义公平/非公平策略了
- Reentrantlock和Synchronized有什么区别?
- Reentrantlock基于FIFO实现了公平锁和非公平锁;而Synchronized都是非公平
2.Reentrantlock
- 类型:独占、可重入、支持公平/非公平
- 关键 AQS 方法:tryAcquire() / tryRelease()
- state 表示:当前线程持有锁的次数(支持递归加锁)
- 典型用法:
1 | lock.lock(); |
3.Semaphore
- 类型:共享锁
- 关键 AQS 方法:tryAcquireShared() / tryReleaseShared()
- state 表示:剩余可用许可数量
- 典型用法:限流、连接池并发控制
1 | semaphore.acquire(); // 获取许可 |
4.CountDownLatch (倒计时锁)
- 类型:共享同步器(不可重置)
- 关键 AQS 方法:tryAcquireShared()(为 0 才通过) / tryReleaseShared()
- state 表示:倒计时数量
- 典型用法:等待多个线程完成后再执行
1 | CountDownLatch latch = new CountDownLatch(3); |
5.ReentrantReadWriteLock
- 类型:读锁共享,写锁独占
- 关键点:
- 写锁使用 tryAcquire(),互斥;
- 读锁使用 tryAcquireShared(),可多个同时获取;
- state 表示:高 16 位写锁计数,低 16 位读锁计数(位运算拆解)
- 典型用法:
1 | readLock.lock(); // 多线程可以同时读 |
6.FutureTask
- 类型:任务状态控制器(封装线程执行结果)
- 关键 AQS 方法:用独占锁管理任务执行与等待线程;
- state 表示:任务状态(NEW、RUNNING、COMPLETED)
- 典型用法:配合线程池异步获取结果
1 | FutureTask<Integer> task = new FutureTask<>(() -> 1 + 2); |
面试准备
那请你说说:synchronized 和 ReentrantLock 有什么区别?你在什么场景下会选择用 ReentrantLock 而不是 synchronized?
从语法层面上说,synchronized是关键字,而Reentrantlock是concurrent包里提供的,reentrantlock可以中断,不会傻等,也支持trylock()尝试加锁,也可以设置公平/非公平锁,可以配合条件变量进行使用,需要手动lock和unlock。
- 如果只是简单的同步代码块或方法,用 synchronized 更方便;
- 如果需要
中断等待、限时尝试加锁、公平策略或多个条件等待队列
,就必须用 ReentrantLock,它更灵活、可控。 - 比如我做订单超卖防止时,需要加锁保护共享资源。我如果只是简单保证线程安全,会直接用 synchronized;但如果我要限制等待时间,或者希望后来的线程也有公平机会执行,就会选择 ReentrantLock 并配合 tryLock() 或 lockInterruptibly() 实现更复杂的并发控制逻辑。
- ReentrantLock.lockInterruptibly() 允许在等待锁的过程中被中断,是处理超时等待、线程池任务超时取消、避免死锁等高级并发场景非常实用的手段。而 synchronized 不支持中断,一旦等待只能卡住。
什么是ABA问题?如何解决?
- ABA 问题指的是:一个变量原本是 A,线程1 读取后准备操作;但此时线程2 把它改成了 B,然后又改回 A。当线程1 继续用 CAS 比较时,会误以为这个变量从未被修改,其实已经发生过变化,导致潜在的并发安全问题。
- 解决方法:加版本号;它在变量的基础上加一个版本号,每次修改时版本号递增,CAS 时比较的不只是值,还有版本;
Java线程池的工作原理是什么?当一个新任务提交给线程池时,线程池是如何处理的?整个流程是怎样的?
- Java中的线程池是通过ThreadPoolExcutor实现的,当调用.execute(task)的时候,线程池会按以下步骤执行
- 线程核心未满?->分配一个核心线程执行任务
- 核心线程满了,队列没满?->放进任务队列
workQueue
等待执行 - 队列也满了,但是最大线程数还没满?->创建一个救急线程来执行
- 连最大线程数也满了?->执行拒绝策略,抛出异常
I/O多路复用是什么?
- I/O 多路复用是指:通过一个线程同时监听多个 I/O 事件(比如 socket 连接、读写等),一旦有事件发生就去处理,避免为每个连接开一个线程,大大提高了并发性能和资源利用率。