多线程理论——基于美团技术文章与Java的总结
更新: 1/11/2025 字数: 0 字 时长: 0 分钟
前排鸣谢
本文是基于该文章的总结
什么是多线程
什么是线程?
线程是一个执行上下文,线程是一个执行上下文,它包含诸多状态数据:每个线程有自己的执行流、调用栈、错误码、信号掩码、私有数据。Linux内核用任务(Task)表示一个执行流。
最经典的LocalThread类,就是在一个线程里面塞入一个键值对,便于我们在一个线程内使用
执行流
对于我们的一个线程,内部包含多条语句,这些语句有着自己的执行顺序(不一定和写的代码一致)。
一个任务里被依次执行的指令会形成一个指令序列(IP寄存器值的历史记录) 这个指令序列就是一个指令流。
逻辑线程
由代码描述的线程执行逻辑(何时开始,何时结束,何时执行某行指令...)就被我们称为逻辑线程。该线程与物理资源(CPU)无关,由JVM管理。
硬件线程
由操作系统决定一个软件线程由哪个CPU/核心来执行,何时执行,这就是硬件线程。
(一个软件线程实际上可能是逻辑线程在程序中实现的执行单位,负责实际的任务调度和执行,决定了哪个CPU核心/硬件线程来执行这些任务。)
线程、核心、函数的关系
函数: 我们编写的函数,没有什么好说的。 线程: 一个线程可能调用多个函数,除调用函数外还可能有单独的语句 线程: 线程从入口函数开始(如main函数...),一个指令接着一个指令执行,中间它可能会调用其他函数
软件线程不会一直处于执行中,原因是多方面的。比如我们的Thread.sleep()就会让当前的线程进入休眠状态
程序、进程、线程、协程
程序会产生进程(比如IDEA),当你多开程序时可能会产生多个进程(微信分身)
线程是一个进程中的多个执行流,这些线程在程序中以并发的形式独立执行, 在操作系统中,最小的可调度单元为线程, 一个进程中的多个线程共用地址空间和文件描述符。 共享地址空间意味着进程的代码(函数)区域、全局变量、堆、栈都被进程内的多线程共享
进程与线程的关系
原文章在此处引用并总结了linus的邮件,我在这里复制下来
- 把进程和线程区分为不同的实体是背着历史包袱的传统做法,没有必要做这样的区分,甚至这样的思考方式是一个主要错误。
- 进程和线程都是一回事:一个执行上下文(Context Of Execution),简称为COE,其状态包括:
- CPU状态(寄存器等)
- MMU状态(页映射)
- 权限状态(uid、gid等)
- 各种通信状态(打开的文件、信号处理器等)
- 传统观念认为:进程和线程的主要区别是线程有CPU状态(可能还包括其他最小必要状态),而其他上下文来自进程;然而,这种区分法并不正确,这是一种愚蠢的自我设限。
- Linux内核认为根本没有所谓的进程和线程的概念,只有COE(Linux称之为任务),不同的COE可以相互共享一些状态,通过此类共享向上构建起进程和线程的概念。
- 从实现来看,Linux下的线程目前是LWP实现,线程就是轻量级进程,所有的线程都当作进程来实现,因此线程和进程都是用task_struct来描述的。这一点通过/proc文件系统也能看出端倪,线程和进程拥有比较平等的地位。对于多线程来说,原本的进程称为主线程,它们在一起组成一个线程组。
- 简言之,内核不要基于进程/线程的概念做设计,而应该围绕COE的思考方式去做设计,然后,通过暴露有限的接口给用户去满足pthreads库的要求。
个人的理解是大佬认为线程和进程一样,就像是你的母亲生下了你,但你和你的母亲都是人类一样。
而大佬之所以这么想,是因为他认为进程和线程都是一个COE(任务),本身都是一群共享了一堆东西的执行任务,只不过大家共享的东西可能存在不同,至于其他的不同点,只能算是小的差别。
为什么要使用多线程
一个进程中并发执行多个线程就被称之为多线程,本身实际上是一种程序的设计思想/模型。
使用了多线程的情况下,我们可以实现。
- 并行计算:多个线程同时执行,提高一个业务的完成速度,充分利用CPU的资源
- 后台任务管理:由于在主线程中分离出来了子线程,因此我们可以对子线程进行管理(比如我们由于某些原因突然想中断某个线程(用户放弃操作,节约资源, 就可以用Thread类中的interrupt方法来结束这个线程)
多线程相关概念
时间分片
在多线程情况下,我们的线程并非真的是一同运行的,而是在CPU中进行分片运行, 例如我们现在有线程A和线程B,在CPU中他可能执行10ms的线程A,在执行10ms的线程B,再执行10ms的线程A,这就是时间分片
上下文切换
在时间分片的情况下,任务A的10ms执行完后,会先将任务A从CPU中迁走,然后再将任务B切到CPU上执行, 在这一操作中,会先保存任务A的当前状态,然后恢复任务B的之前状态,这一过程就称之为上下文切换。
线程安全
一个进程可以有多个线程在同时运行,这些线程可能同时执行一个函数, 如果多线程并发执行的结果和单线程依次执行的结果是一样的,那么就是线程安全的,反之就不是线程安全的。
不访问共享数据(全局变量,static local变量,类成员变量,只操作参数、无副作用的函数是线程安全函数)
线程安全函数可多线程重入。每个线程有独立的栈,而函数参数保存在寄存器或栈上, 局部变量在栈上,所以只操作参数和局部变量的函数被多线程并发调用不存在数据竞争。
比如在Java中HashMap就是线程不安全的(使用了作为成员变量的数组和链表),而CurrentHashMap就是线程安全的。(使用了 CAS(Compare-And-Swap)操作和 synchronized 关键字实现)
线程私有变量
因为全局变量(包括模块内的static变量)是进程内的所有线程共享的,但有时应用程序设计中需要提供线程私有的全局变量,这个变量仅在函数被执行的线程中有效,但却可以跨多个函数被访问。 (比如LocalThread)
阻塞和非阻塞
一个线程的执行逻辑在某一位置卡住进而导致后续逻辑无法正常执行,这一状态我们就称之为线程阻塞
当我们在使用Thread的sleep方法时就会导致线程休眠进而引发线程阻塞
有时线程的某个位置陷入死循环也算是线程阻塞
多线程同步
我们之前提到线程共享一些数据,而线程并行本身是依赖时间分片的, 那么线程在使用这些数据的时候就可能会出现由于执行顺序的不同,导致拿的数据并非我们实际想要的, 进而产生Rare Condition(竞争状态)
当多线程不同步时会导致我们得到的结果并非我们正在想要的结果,这时我们需要想办法实现多线程同步
什么时候需要多线程同步
一般情况下,在访问共享变量(如同一个类的全局变量、静态变量、对象成员变量等)时,我们就需要使用线程保护
除此之位,如果程序设计到分布式,我们还应该让各个节点间的数据一致性。
保护什么
多线程保护的是我们的数据,避免产生错误的读入读出。
如何保护
串行化
当一个线程正在访问某个资源时,那么在它结束访问之前,其他线程不能执行访问同一资源的代码(访问临界资源的代码叫临界代码),其他线程想要访问同一资源,则它必须等待,直到那个线程访问完成,它才能获得访问的机会 (有没有想到什么?是的,锁!)
原子操作和原子变量
在一些情况下,我们的操作可能在未完全完成时就被换到了另一个线程
如a++
在我们的电脑执行时实际上是分为三步进行的
- 读取a的值
- 运算a+1并得到结果
- 将a+1得到的值赋值给a
而这三步若不能在一个时间片中完成,则很有可能出现竞争问题,因此我们需要保证这些操作按顺序一次完成。
实现这样的操作就称之为原子化,而这样的变量称之为原子变量
在Java中有专门的原子类解决这个问题(AtomicXXX,如AtomicInteger,AtomicBoolean)
锁
我们可以通过使用锁的方式来给保证线程同步
互斥锁
互斥锁有且只有2种状态:
- 已加锁(locked)状态
- 未加锁(unlocked)状态
在已加锁的状态下,其他线程无法执行加锁的部分,需要等该线程执行完毕(阻塞)才可进入执行
在JDK中synchronized关键字和JUC中的Lock就是实现的互斥锁
读写锁
读写锁将锁细分为读锁和写锁
- 已加写锁状态
- 已加读锁状态,
- 未加锁状态
而跟据不同的状态存在不同的加锁解锁情况
- 加读锁:若读写锁处于只加写锁的状态则不能加读锁
- 加写锁:如读写锁处于加任意锁状态则阻塞。
- 解锁:把锁设置为未加锁状态后返回。
通过这样的方法,我们巧妙的将线程区分为了写线程和读线程
当写线程(加写锁)运行时,任何线程不可拿到该线程, 只有该线程成功运行完时才能被新的读取和写入,成功保证了数据的一致性。
当读线程(加读锁)运行时,写线程不可拿到该线程,而读线程可以继续使用, 由于没有写入,导致读的数据始终一致,也成功保证了数据的一致性。
JUC通过ReentrantReadWriteLock实现读写锁
import java.util.concurrent.locks.ReentrantReadWriteLock;
class ReadThread extends Thread {
private ReentrantReadWriteLock rrwLock;
public ReadThread(String name, ReentrantReadWriteLock rrwLock) {
super(name);
this.rrwLock = rrwLock; //传入读写锁
}
public void run() {
System.out.println(Thread.currentThread().getName() + " trying to lock");
try {
rrwLock.readLock().lock();
System.out.println(Thread.currentThread().getName() + " lock successfully");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rrwLock.readLock().unlock();
System.out.println(Thread.currentThread().getName() + " unlock successfully");
}
}
}
class WriteThread extends Thread {
private ReentrantReadWriteLock rrwLock;
public WriteThread(String name, ReentrantReadWriteLock rrwLock) {
super(name);
this.rrwLock = rrwLock; //传入读写锁
}
public void run() {
System.out.println(Thread.currentThread().getName() + " trying to lock");
try {
rrwLock.writeLock().lock();
System.out.println(Thread.currentThread().getName() + " lock successfully");
} finally {
rrwLock.writeLock().unlock();
System.out.println(Thread.currentThread().getName() + " unlock successfully");
}
}
}
public class ReentrantReadWriteLockDemo {
public static void main(String[] args) {
ReentrantReadWriteLock rrwLock = new ReentrantReadWriteLock(); //定义出使用的读写锁,通过传参的形式共用读写锁
ReadThread rt1 = new ReadThread("rt1", rrwLock);
ReadThread rt2 = new ReadThread("rt2", rrwLock);
WriteThread wt1 = new WriteThread("wt1", rrwLock);
rt1.start();
rt2.start();
wt1.start();
}
}
结果(略作打印内容的中文修改)
读线程2号已执行
读线程2号已上读锁
读线程1号已执行
读线程1号已上读锁
写线程1号已经执行
读线程2号已解读锁
写线程1号已上写锁
写线程1号已解锁
读线程1号已解读锁
我们不难发现其运行逻辑符合我们之前写的内容
自旋锁
在程序中阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这一过程十分消耗处理器时间,如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
我们是否可以像一种方法让该线程不阻塞的情况下仍然可以等待锁的释放呢?
这就是自旋锁想要解决的一个问题。
自旋锁通过让线程不休眠/挂起,而是处于执行自旋的方式来暂时等待锁的释放,若自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。
自选锁自身仍然存在缺点,那就是自旋会占用CPU的时间,若自旋次数过多那么自旋的线程只会白浪费处理器资源。 因此自旋一般有次数限制(一般为10次)
JDK中的Synchronized就实现了自选锁
在Java SE 1.6里Synchronized同步锁,一共有四种状态:无锁、偏向锁、轻量级锁、重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。
锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
而我们这里提到的轻量级锁就是自旋锁。
关于Synchronized关键字我会另写一篇文章重点学习
锁的范围
锁的范围要尽量小,最小化持有锁的时间。
死锁
程序出现死锁有两种典型原因
ABBA锁
- 线程A先执行函数A,然后对函数A加上锁,然后切换上下文到线程B。
- 线程B先执行函数B,然后对线程B加上锁,切换上下文到线程A。
- 线程A执行函数B,等待线程B释放锁,阻塞,切换上下文到线程B。
- 线程B执行函数A,等待线程A释放锁,阻塞,切换上下文到线程A。
- 烂完了。
这种情况我们就称之为ABBA锁
自死锁
对于不支持重复加锁的锁,如果线程持有某个锁,而后又再次申请锁,因为该锁已经被自己持有,再次申请锁必然得不到满足,从而导致死锁。 (多次请求不支持重复加锁的锁,并尝试上锁)