并发编程

1.进程与线程

1.进程与线程

  1. 进程
  • 程序由指令和数据组成,指令需要运行,数据需要读写,就要将指令加载到cpu上,数据加载至内存。在指令运行过程中还需要使用磁盘,网络等设备。 进程就是用来加载指令、管理内存、管理I/O的。
  • 当一个程序被运行,指令代码被加载至内存,这时就开启了一个进程。
  • 进程可以视为程序的一个实例。有些可以多开(记事本),有些只能开一个(网易云)
  1. 线程
  • 一个进程之内可以有多个线程
  • 一个线程就是一个指令流,是 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. 方法一,直接创建
1
2
3
4
5
6
7
Thread t = new Thread (){
public void run(){
//要执行的代码
}
}
//启动线程
t.start();
  1. 方法二,使用runnable对象,使用lambda表达式简化,可以在方法二的基础上使用alt+enter让idea自己改
1
2
3
4
5
6
7
8
9
Runnable runnable  = new Runnable(){
public void(){
//要执行的代码
}
}
//创建线程对象
Thread t1 = new Thread(runnable);
//启动线程
t1.start();
1
2
3
4
Runnable runnable = () -> System.out.println("我是线程哈哈哈");
Thread t1 = new Thread(runnable);
t1.start();
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
2
3
4
5
6
7
8
9
10
11
Runnable r1 = new Runnable() {
public void run() {
System.out.println("线程");
}
};
Thread t1 = new Thread(r1);
System.out.println(t1.getState());
t1.start();
System.out.println(t1.getState());
t1.join();
System.out.println(t1.getState());

记住,start只能调用一次,会让state变为runnable。

sleep VS yield

  • sleep
    1. 调用sleep会让当前线程从Running进入Timed Waiting状态(阻塞)
    2. 其它线程可以使用interrupt方法打断正在睡眠的线程,这时sleep方法会抛出InterruptedException
    3. 睡眠结束后的线程未必会立刻得到执行(不能够马上获得cpu,看线程的状态)
    4. 建议用TimeUnit的sleep代替Thread的sleep来获得更好的可读性
  • yield(让出,谦让)
    1. 调用yield会让当前线程从Running变为runnable就绪态,然后调度执行其他线程
    2. 具体的实现依赖于OS
      应用:防止CPU占用100%
      在没有利用cpu进行计算的时候,不要让while(true)空转浪费cpu,可以使用yield或sleep让出cpu的使用权
1
2
3
4
5
6
7
while(true){
try{
Thread.sleep(50);
} catch(InterruptedException e){
e.printStackTrace();
}
}
  • 可以使用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
2
3
4
5
6
7
8
9
10
11
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
cnt++;
}
},"t1");
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
cnt--;
}
},"t2");
sout(cnt);

不一定是0。
多个线程对共享资源进行读写操作,上下文交换引起的指令交错

  • 问题出现在多个线程访问共享资源
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
  • 竞态条件(Race Condition)
    • 多个线程在临界区内执行,由于代码的执行顺序不同而导致结果无法预测,称之发生了静态条件

2.静态条件解决方案

  • 阻塞式
    • Synchronized、lock
  • 非阻塞式
    • 原子变量

synchronized(对象锁)

  1. 语法
1
2
3
synchronized (对象){
//临界区代码
}
类比说明
  • obj 想象成一个 房间,房间只有一个
  • 每个线程(如 t1t2)就像是 想进房间干活的人
  • 一次只能一个线程进房间,其他线程必须在门外 排队等待

执行过程
  1. t1 运行到 synchronized(obj)
    → 拿到钥匙,进入房间(获得锁)
    → 门被锁上,其他线程进不来。
  2. t2 也运行到 synchronized(obj)
    → 发现门被锁,阻塞在门外,等待钥匙。
  3. t1 时间片用完,被系统挂起
    → 虽然暂停了,但钥匙还在它手上(锁未释放)
    t2 仍无法进入房间。
  4. t1 被重新调度回来
    → 执行完同步代码块,出门还钥匙(释放锁)
    → 唤醒 t2t2 拿到钥匙进入房间继续执行。

关键类比总结
Java 概念 房间类比
锁对象(如 obj) 房间
synchronized(obj) 进入房间前先锁门
获得锁 拿到钥匙,进入房间
阻塞线程 在门外排队等待钥匙
释放锁 出门还钥匙,唤醒别人
面向对象加锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class Main {
public static void main(String[] args) throws InterruptedException {
Room r = new Room();
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
r.Increase();
}
},"t1");
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
r.Decrease();
}
},"t2");

t1.start();
t2.start();
t1.join();
t2.join();
r.getCnt();
}
}
class Room{
private int cnt;
public void Increase(){
synchronized (this){
cnt++;
}
}
public void Decrease(){
synchronized (this){
cnt--;
}
}
public void getCnt(){
System.out.println(cnt);
}
}
在方法上加锁
  1. 在实例上加锁
1
2
3
4
5
class test{
public synchronized void test(){

}
}

等价于

1
2
3
4
5
6
7
class test{
public void test(){
synchronized (this){

}
}
}
  1. 在类上加锁
1
2
3
4
5
class test{
public synchronized static void test(){

}
}

等价于

1
2
3
4
5
6
7
class Test{
public void test(){
synchronized (Test.class){

}
}
}
  • this 就像你家门 → 每家自己锁
  • Test.class 就像整栋楼大门 → 所有人公用
    区别是静态方法(类对象) 非静态方法(当前实例)
    synchronized锁的是当前实例,Test锁的是整个类
    线程八锁进行练习,主要分析锁住的是不是同一个对象,如果不是同一个对象,那就是并行,不存在多线程;是同一个对象,就是并发。
    实例方法加锁锁 this,每个对象一把锁;
    静态方法加锁锁 class,全类共用一把锁;
    不同对象加锁不冲突,相同对象才竞争;
    类锁和实例锁互不影响。

线程安全:局部变量 vs 静态变量

局部变量是线程安全的
  • 每次方法调用会创建新的栈帧
  • 局部变量存放在线程独立的栈中,不共享,不冲突
静态变量是线程不安全的
  • 静态变量属于类,全局共享
  • 多线程访问会产生数据竞争,必须加锁保护

习题:银行账户转账

两个账户 ab,线程 T1 从 a 转账到 b,T2 从 b 转账到 a
如果两个账户是不同对象,用 synchronized(this) 是不安全的,因为锁的是两个不同的对象

1
2
3
4
5
6
public void transfer(Account target, int amount) {
synchronized (this) {
this.balance -= amount;
target.balance += amount;
}
}

Monitor底层原理 (这里去看jvm的jmm)

Monitor被翻译为监视器或管程,可以理解为提到的

  • 第一个线程获得对象的锁后,成为owner
  • 后续来的线程发现owner被占用,只能进入等待队列并切换状态为阻塞
  • 当临界区代码被执行完后,owner空出,通知Monitor唤醒阻塞队列的线程,(此时的阻塞队列竞争是非公平的,不一定是先进来的就先得到锁)
  • 对象总是与一个monitor相关联的

notify()和wait()

在重量级锁的环境下,一个线程如果缺乏资源,就会调用wait方法主动放弃锁,进入等待池,自己的状态设置为waiting;等到别的线程将资源送来了,就会调用notify,告诉这个线程资源来了,这时候第一个线程就会离开等待池,状态变为blocking,进入锁池(EntryList)重新去竞争锁。

park()和unpark()

park()unpark() 是 Java 中底层线程阻塞/唤醒的原语,属于 LockSupport 类提供的工具,用于实现更灵活的并发控制(比 wait/notifysleep/yield 更底层、更强大)。

锁的活跃性

  • 死锁:各自持一把锁,又都想获得对方的锁,哲学家问题
    产生死锁的条件
    • 互斥
    • 占有且等待
    • 不可抢占
    • 循环等待 ✅ (最常通过避免循环等待解决)
      解决策略
    • 固定加锁顺序:如先锁 A 再锁 B,所有线程统一顺序,避免循环等待。
    • 使用 tryLock():尝试加锁,失败就放弃,避免永久等待。
    • 死锁检测与恢复:定期检测资源等待图,发现死锁强制中断线程(一般用于数据库/操作系统)。
  • 饥饿:线程优先级不公平锁分配不均
    解决策略
    • 使用 公平锁:如 ReentrantLock(true),按请求顺序分配锁;
    • 避免线程优先级过高差距;
    • 控制资源分配策略,避免让某些线程长期得不到调度。
  • 活锁:

ReentrantLock(可重入锁,公平锁)

  1. 优点
  • 可中断
  • 可以设置超时时间
  • 可以设置公平锁
  • 支持多个条件变量(所有条件满足了才从waitSet出来去锁池)

    与synchronized一样支持可重入

  1. 可重入
  2. 语法
1
2
3
4
// 公平锁
ReentrantLock fairLock = new ReentrantLock(true);
// 非公平锁(默认)
ReentrantLock unfairLock = new ReentrantLock();
  1. 条件变量
  • 用于线程间通信,代替 wait() 和 notify();
  • 支持多个等待队列(更细粒度的控制)。
1
2
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();

ReentrantLock + Condition:可以为不同条件建多个等待队列(每个condition内部有一个独立的等待队列)

  1. 锁超时
  1. 总结
  • ReentrantLock 是显式锁,功能强于 synchronized;
  • 支持更细粒度的锁策略;
  • 搭配 Condition 可实现更复杂的线程通信模型;
  • 需手动释放锁,推荐 try-finally 结构

4.共享模型之不可变

  1. 什么是Java中的不可变类
  • 不可变类是指无法修改对象的值(无法继承、线程安全),String不可变
    • 追问:怎么实现不可变类?
      • 通过看String的源码,String添加了final(类似于cpp的const)关键字,并且是private对象
      • 替换方法replace()的实现是返回一个新的字符串。

5.并发工具

线程池

1.ThreadPoolExcutor

  1. 线程池状态
    线程池状态
  • 五状态:Running、Shutdown、Stop、Tidying、Terminated
    Shutdown就像终结对方的连胜,但不代表这局游戏结束咯。

    数字比较,是有符号数,最高位为-4,也可以去看ThreadPoolExecutor的源码
  1. 线程池构造方法
    线程池构造方法
  • corePoolSize核心线程数目:线程池中常驻的线程数。
  • maximumPoolSize最大线程数目:线程池中允许创建的最大线程数。
  • keepAliveTime线程存活时间:非核心线程空闲多久被回收。
  • unit时间单位-针对救急线程
  • workQueue阻塞队列:用于缓存待执行任务的阻塞队列,如 LinkedBlockingQueue。
  • threadFactory线程工厂-可以为线程创建时起个好名字
  • handler拒绝策略:线程池满时的拒绝策略

    最大线程数 = 核心线程数 + 救急线程数

  • 核心线程和救急线程的区别?
    • 核心线程在任务执行完成后仍留在线程池中,救急线程执行完后会离开线程池。(外包)
  • 如果系统面临高并发,你会怎样去配置线程池?
    • 如果任务是CPU密集型(如计算型服务),线程数设为CPU核心数+1,使用无界队列,避免上下文切换;因为计算型服务几乎不进行IO操作,CPU使用频率很高,增加过多线程反而会导致频繁进行上下文切换,反而拖延性能,多加一个线程是为了应对线程调度或偶尔阻塞的情况。
    • 如果任务是IO密集型(如网络请求,数据库读写),可设置更多线程,如 2 * CPU核心数,配合适当长度的阻塞队列;因为IO密集型任务经常阻塞,线程会空闲,多设置一些线程来保证CPU有任务执行,线程看起来很多,但由于很多都处于阻塞,所以不会频繁进行上下文切换。

      所以本质上,线程池线程数的设置要根据任务的“阻塞程度”和“CPU占用率”做平衡。

    • 拒绝策略方面,如果系统不能丢任务,可使用 CallerRunsPolicy 让主线程帮忙执行,降低系统负载;
    • 队列类型上,对于高并发请求建议使用 LinkedBlockingQueue 或 ArrayBlockingQueue 来控制流量和内存使用。

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
2
3
4
5
6
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}

3.Semaphore

  • 类型:共享锁
  • 关键 AQS 方法:tryAcquireShared() / tryReleaseShared()
  • state 表示:剩余可用许可数量
  • 典型用法:限流、连接池并发控制
1
2
semaphore.acquire();   // 获取许可
semaphore.release(); // 释放许可

4.CountDownLatch (倒计时锁)

  • 类型:共享同步器(不可重置)
  • 关键 AQS 方法:tryAcquireShared()(为 0 才通过) / tryReleaseShared()
  • state 表示:倒计时数量
  • 典型用法:等待多个线程完成后再执行
1
2
3
CountDownLatch latch = new CountDownLatch(3);
latch.countDown(); // 调用 3 次后 latch.await() 才能继续,countDown计数--
latch.await(); //等待计数归零

5.ReentrantReadWriteLock

  • 类型:读锁共享,写锁独占
  • 关键点:
    • 写锁使用 tryAcquire(),互斥;
    • 读锁使用 tryAcquireShared(),可多个同时获取;
  • state 表示:高 16 位写锁计数,低 16 位读锁计数(位运算拆解)
  • 典型用法:
1
2
readLock.lock();   // 多线程可以同时读
writeLock.lock(); // 写操作互斥,读写互斥

6.FutureTask

  • 类型:任务状态控制器(封装线程执行结果)
  • 关键 AQS 方法:用独占锁管理任务执行与等待线程;
  • state 表示:任务状态(NEW、RUNNING、COMPLETED)
  • 典型用法:配合线程池异步获取结果
1
2
3
FutureTask<Integer> task = new FutureTask<>(() -> 1 + 2);
new Thread(task).start();
task.get(); // 等待并获取结果

面试准备

那请你说说: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)的时候,线程池会按以下步骤执行
    1. 线程核心未满?->分配一个核心线程执行任务
    2. 核心线程满了,队列没满?->放进任务队列workQueue等待执行
    3. 队列也满了,但是最大线程数还没满?->创建一个救急线程来执行
    4. 连最大线程数也满了?->执行拒绝策略,抛出异常

I/O多路复用是什么?

  • I/O 多路复用是指:通过一个线程同时监听多个 I/O 事件(比如 socket 连接、读写等),一旦有事件发生就去处理,避免为每个连接开一个线程,大大提高了并发性能和资源利用率。