✏️作者:银河罐头
📋系列专栏:JavaEE
🌲“种一棵树最好的时间是十年前,其次是现在”
目录
- Thread类及常见方法
- 获取当前线程引用
- 休眠当前线程
- 线程的状态
- 线程的所有状态
- 线程状态
- 多线程的意义
- 多线程带来的的风险-线程安全
- 代码示例
- 线程不安全的原因
- synchronized 关键字
- synchronized 的特性
- 1)互斥
- 2)可重入
- Java 标准库中的线程安全类
- 死锁
- 死锁的几种情况
- 死锁的4个必要条件
- 如何避免死锁
Thread类及常见方法
获取当前线程引用
public static Thread currentThread();
返回当前线程对象的引用
休眠当前线程
public static void sleep(long millis) throws InterruptedException
本质上就是这个线程不参与调度了,不去CPU上执行了
PCB是使用链表来组织的,实际情况并不是一个简单的链表,是一系列以链表为核心的数据结构。
一旦线程进入阻塞状态,对应PCB就进入阻塞队列了,就暂时无法参与调度了。
- 比如调用sleep(1000),那么线程就要在阻塞队列待1000ms,当这个PCB回到了就绪队列,会被立即调度吗?
其实不是,实际上考虑到调度的开销,对应的线程是无法在唤醒之后立即被调度的,实际上的时间间隔大概率要大于1000ms.
挂起(hung up)就是阻塞(block)
线程的状态
线程的所有状态
-
1.NEW 创建了Thread对象,但是还没调用start(内核里还没创建对应PCB)
-
2.TERMINATED 表示内核里的PCB已经执行完毕了,但是Thread对象还在
-
3.RUNNABLE 可运行的
a)正在CPU上执行的
b)在就绪队列里,随时可以去CPU上执行的
-
4.WAITING
-
5.TIMED_WAITING
-
6.BLOCKED
4,5,6都是阻塞,都表示线程PCB在阻塞队列中,这几个状态是不同原因的阻塞
线程的状态是一个枚举类型 Thread.State
public class ThreadState {
public static void main(String[] args) {
for (Thread.State state : Thread.State.values()) {
System.out.println(state);
}
}
}
线程状态
线程的状态转化
WAITING
Scanner这里的阻塞是因为等待IO的, 等待IO也会进行一些线程操作,内部可能会涉及到锁操作或者wait之类的操作。
读写文件,读写控制台,读写网络…都可能会造成阻塞
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
for(int i = 0;i < 1000000;i++){
}
});
// 在启动之前,获取 t 的状态,就是 NEW 状态
System.out.println("start 之前:" + t.getState());
t.start();
System.out.println("t 执行中的状态:"+ t.getState());
t.join();
//线程执行完毕之后,就是 TERMINATED 状态
System.out.println("t 结束之后:" + t.getState());
}
一旦内核里的PCB消亡了,此时代码中的t对象就没啥用了。之所以存在,是迫不得已。Java中对象的生命周期自有其规则,这个生命周期和内核中线程并非完全一致。内核的线程释放的时候,无法保证Java代码中t对象也立即释放。因此,势必会存在,内核中PCB没了,但是代码中t还存在这样的情况。因此就需要通过特定的状态,来把t对象标识为"无效"。
一个线程只能start一次。
//输出结果:
start 之前:NEW
t 执行中的状态:RUNNABLE
t 结束之后:TERMINATED
之所以此处能看到RUNNABLE,主要是因为线程run里没有写sleep之类的方法
通过这里的循环获取,就可以看到这里的交替状态,当前获取到的状态完全取决于系统的调度操作,获取状态的这一瞬间 t 线程是正在执行还是正在 sleep
多线程的意义
多线程最核心的地方:抢占式执行,随机调度
多线程的意义是?
单个线程和多个线程之间,执行速度的差别
程序分成
CPU密集,包含了大量加减乘除等算术运算
IO密集,涉及到读写文件,读写控制台,读写网络
这种衡量执行时间的代码,运行的久一点,误差越小,
线程调度自身也是有时间开销的,运算的任务量越大,线程调度的开销相比之下就非常不明显了,从而就可以忽略不计
public static void main(String[] args) {
//serial();
concurrency();
}
//串行执行,一个线程完成
public static void serial(){
//为了衡量代码的执行速度,加上个计时的操作
//currentTimeMillis获取到当前系统 ms 级的时间戳
long beg = System.currentTimeMillis();
long a = 0;
for(long i = 0;i < 100_0000_0000L;i++){
a++;
}
long b = 0;
for(long i = 0;i < 100_0000_0000L;i++){
b++;
}
long end = System.currentTimeMillis();
System.out.println("执行时间:" + (end - beg) + " ms");
}
public static void concurrency(){
long beg = System.currentTimeMillis();
Thread t1 = new Thread(()->{
long a = 0;
for(long i = 0;i < 100_0000_0000L;i++){
a++;
}
});
Thread t2 = new Thread(()->{
long b = 0;
for(long i = 0;i < 100_0000_0000L;i++){
b++;
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("并发执行时间:" + (end - beg) + " ms");
}
多线程更快,多线程可以更充分利用多核心CPU的资源
这个例子中单线程到多线程时间为什么不是正好缩短一半?
不确定t1和t2是在两个CPU上并行执行的还是并发执行的。
实际上,t1和t2在执行过程中会经历很多次调度,这些调度有些是并行执行(在2个核心上),有些是并发执行的(1个核心上)。
到底是多少次并行,多少次并发,不好预估,取决于系统的配置,也取决于当前程序的运行环境。(系统同一时刻跑了很多程序,并发的概率更大,很多程序来抢CPU)
另一方面,线程调度自身也是有时间消耗的。
梳理一遍上述代码的执行逻辑:main线程先调用t1.start,启动t1开始计算t1的同时main再启动t2.start。启动t2的同时t1仍然在继续计算。同时main线程进入t1.join,此时main线程阻塞等待,t1,t2还是在继续执行。等t1执行完了,main线程从t1.join返回再执行t2.join,main线程等待t2,t2执行完了,main线程从t2.join返回,继续执行计时操作。
- 关于join的理解,比如这里是main线程里调用t1.join(),就是main线程阻塞等待t1结束
不是说多线程一定能提高效率
1.是否是多核(现在CPU基本上都是多核了)
2.当前核心是否空闲(如果CPU这些核心已经都满载了,这个时候启动多线程也没用)
多线程带来的的风险-线程安全
造成线程安全这种问题的原因是多线程的抢占式执行,带来的随机性。从单线程到多线程,代码执行顺序的可能性从一种情况变成了无数种情况。所以就需要保证在这无数种线程调度顺序的情况下,代码的执行结果都是正确的。只要有一种情况代码结果不正确,就被视为线程不安全。
代码示例
class Counter{
public int count = 0;
public void add(){
count++;
}
}
public class ThreadDemo {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for(int i = 0;i < 50000;i++){
counter.add();
}
});
Thread t2 = new Thread(()->{
for(int i = 0;i < 50000;i++){
counter.add();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count = " + counter.count);
}
}
//三次运行结果:
count = 53782
count = 55883
count = 68803
预期结果是10w,而实际结果不是10w,并且每次运行的结果都不一样,程序出现bug了!
++ 操作本质上分为三步:
1.先把内存的值读取到CPU的寄存器中 load
2.把CPU寄存器的值进行 + 1 运算 add
3.把得到的结果写回到内存中 save
//这3个操作就是CPU上执行的3个指令。指令是机器语言
如果是两个线程并发执行count ++,此时就相当于两组 load add save 进行执行,此时不同的线程调度顺序就可能会产生一些结果上的差异。
线程一些可能的调度顺序:
其中情况1,2结果是正确的,其他情况都是有问题的
比如情况3
经历2次自增之后count结果还是1
其实这里就和 事务 读未提交 read uncomitted 是一样的,相当于t1读到的是一个t2未提交的脏数据,于是出现了脏读问题
这里的多线程和并发事务本质上都是"并发编程"问题,并发处理事务,底层是基于多线程这样的方式来实现的
这是一个线程,这个线程要具体执行,就需要先编译成很多CPU指令,写的任何一个代码都是需要编译成很多CPU指令的。
由于线程的抢占式执行,导致当前执行到任意一个指令的时候线程都可能被调度走,CPU让别的线程来执行。
CPU里有个重要的组成部分,寄存器。寄存器也能存数据,空间更小,访问速度更快,CPU进行的运算都是针对寄存器的数据进行的。
CPU里的寄存器有很多种,
有的是通用寄存器(用来参与运算的) EAX , EBX , ECX…
有的是专用寄存器(有特定功能的) EBP ESP EIP…
保存上下文的,用PCB里的内存把当前所有的寄存器都给保存起来。
机器指令就是汇编,机器指令就是直接在CPU上运行的,势必要经常操作寄存器
当前这个代码是否有可能结果正好是10w?有可能,概率很小
假设2个线程每次调度的顺序都是情况1,2
当前结果一定大于5w?
实际运行基本都是大于5w的,但是也不一定
还可能出现t1自增1次,t2自增2次,(情况5)最终还是增1这种
线程不安全的原因
- 1.(根本原因)抢占式执行,随机调度
- 2.代码结构:多个线程修改同一个变量
一个线程修改一个变量,没事;
多个线程读取同一个变量,没事;
多个线程修改多个不同的变量,没事。
因此可以通过修改代码的结构来规避这个问题,这种调整不一定都是能使用的,代码结构也是源于需求的。调整代码结构是个方案,但是不是一个普适性特别高的方案
String是不可变对象
不可变对象,天然就是线程安全的。
像有些编程语言,比如erlang,语法里没有"变量"这个概念,所有的"变量"都是不可变的,这样的语言更适合并发编程,出现线程安全的概率大大降低了
- 3.原子性
如果修改对象是原子的,那就罢了
如果是非原子的,出现问题的概率就非常高了。
原子:不可拆分的最小单位。
count ++ 这里可以拆分成 load add save 三个操作,这三个操作中的每个操作都是原子的,单个指令是无法再进一步拆分了。
如果++操作是原子的,那么线程安全问题就迎刃而解了(出问题的本质是"脏读",t1修改的结果还没提交,t2就读了)
针对线程安全问题,如何解决?最主要的手段就是从这个原子性入手,把这个非原子的操作变成原子的,加锁。
- 4.内存可见性问题
如果是一个线程读,一个线程改呢?也可能出问题,可能出现此处读的结果不太符合预期。
- 5.指令重排序
本质上是编译器优化出bug了,编译器在保持逻辑不变的情况下,调整代码的执行顺序,从而加快代码的执行效率
上述分析出的是5个典型的原因,不是全部。一个代码究竟是线程安全还是不安全,都得具体情况具体分析,难以一概而论。
如果一个代码踩中了上面的原因,也可能线程安全;如果一个代码没踩中上面的原因,也可能线程不安全。
原则是多线程运行代码不出bug就是安全的。
在阅读代码的时候,脑子里分析出所有可能执行的情况,并一一判定里面是否有问题
重排序指的是单个线程里,顺序发生调整。
synchronized 关键字
synchronized 的特性
1)互斥
如何从原子性入手来解决线程安全问题,通过加锁。
synchronized public void add(){
count++;
}
//synchronized 这是一个关键字,表示加锁
加了synchronized之后,进入方法会加锁,出了方法会解锁。
如果两个线程同时尝试加锁,此时一个能获取锁成功,另一个只能阻塞等待(BLOCKED),一直阻塞到刚才的线程释放锁(解锁),当前线程才能加锁成功。
t2加锁没加成,一直阻塞等待到 t1 unlock了之后才继续执行。
加锁,说是保证原子性,其实不是说让这里的3次操作一次完成,也不是这3步操作过程中不调度,而是让其他也想操作的线程阻塞等待了。
加锁本质上是把并发变成了串行。
操作系统中的基本设定,系统里的锁"不可剥夺"特性,一旦一个线程获取到锁,除非他主动释放,否则无法强占。
synchronized 的行为就是阻塞等待,一直等下去。 Java中还有一种锁, ReentrantLock 这个锁,获取不到就放弃
一旦加锁之后,代码的速度是大打折扣的。算的快前提是算得准。
虽然加锁之后算的慢了,但是还是比单线程快,加锁只是针对count++ 加锁了,除了 count++ 之外还有for循环的代码,for循环代码是可以并发执行的,只是count++ 串行执行了。一个任务中,一部分并发,一部分串行,仍然是要比所有代码都串行要更快的。
package Thread;
import java.util.concurrent.CountDownLatch;
class Counter{
public int count = 0;
synchronized public void add(){
count++;
}
}
public class ThreadDemo13 {
public static void main(String[] args) {
Counter counter = new Counter();
//搞两个线程,两个线程分别针对 counter 来调用 5w 次的 add 方法
Thread t1 = new Thread(()->{
for(int i = 0;i < 50000;i++){
counter.add();
}
});
Thread t2 = new Thread(()->{
for(int i = 0;i < 50000;i++){
counter.add();
}
});
//启动线程
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count = " + counter.count);
}
}
//结果:
count = 100000
- synchronized使用方法
1.修饰方法
1)修饰普通方法。锁对象就是this
2)修饰静态方法。锁对象就是类对象(Counter.class)
2.修饰代码块。显式/手动指定锁对象
加锁要明确是针对哪个对象加锁。
如果两个线程针对同一个对象加锁,会产生阻塞等待(锁竞争/锁冲突)。
如果两个线程针对不同对象加锁,不会阻塞等待(不会锁竞争/锁冲突)。
两个线程,一个线程加锁,一个线程不加锁,这种情况没有锁竞争。
线程 对 对象加锁
举个例子,张三的npy是李四,相当于张三对李四加锁了,如果王五也想和李四处对象(王五也想对李四加锁)。就会出现锁竞争/锁冲突。因为张三已经对李四加锁了,所以王五就只能阻塞等待。不过赵六刚好单身,王五可以对赵六加锁。张三和王五对不同对象加锁,不会发生阻塞等待。
(两男争一女会出现锁竞争)
没有锁竞争=>抢占式执行=>很有可能出现线程安全问题
问:构造方法可以使用synchronized关键字修饰吗?
不能❌
synchronized关键字作用于方法上,是给当前对象实例/类加锁,而在构造方法上加 synchronized,此时对象实例还没产生;另外构造方法每次都是构造出新的对象,不存在多个线程同时读写同一对象中的属性的问题,所以不需要同步。
monitor lock 监视器锁
JVM给 synchronized 起的名字,代码报异常可能会见到 monitor lock
2)可重入
因为在Java里这种代码是很容易出现的,为了避免不小心就死锁,Java就把 synchronized 设定为可重入的了。但是 C++ , Python , 操作系统原生的锁,都是不可重入的。
就是在锁对象里记录一下,当前锁是哪个线程持有的。如果加锁线程和持有线程是同一个,就直接放过,否则就阻塞。
上面只是死锁的一种情况,还有别的情况。
Java 标准库中的线程安全类
如果多个线程操作同一个集合类,就需要考虑线程安全的问题。
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder
上述这些都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.
Vector (不推荐使用)
HashTable (不推荐使用)
ConcurrentHashMap
StringBuffer
上述这些已经内置了 synchronized 加锁,相对来说更安全一点。
StringBuffer 的核心方法都带有 synchronized .
还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的,比如String
死锁
死锁的几种情况
1.一个线程,一把锁,连续加锁两次,如果锁是不可重入锁,就会出现死锁。Java中synchronized和ReentrantLock都是可重入锁。
2.两个线程,两把锁,t1和t2各自先针对锁A和锁B加锁,再尝试获取对方的锁。
举个栗子:家钥匙锁车里了,车钥匙锁家里了。
public class ThreadDemo {
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
//sleep是为了确保两个线程先把第一把锁拿到(线程是抢占式执行的)
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2){
System.out.println("t1 把 locker1 和 locker2 都拿到了");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker1){
System.out.println("t2 把 locker2 和 locker1 都拿到了");
}
}
});
t1.start();
t2.start();
}
}
程序结果没有输出,死锁了。
针对这样的死锁问题,可以借助 jconsole 这样的工具来进行定位,看线程的状态和调用栈就可以分析出程序在哪里死锁了。
3.多个线程 多把锁(相当于2的一般情况)
经典案例:哲学家就餐问题
5个哲学家围着桌子吃意大利面,只有5只筷子。每个哲学家有两种状态,1.思考人生(相当于线程阻塞的状态) 2.拿起筷子吃面条(相当于线程获取到锁然后进行一些计算)。由于操作系统随机调度,这5个哲学家随时有可能想吃面也随时有可能在思考人生,要想吃面就得拿起左手和右手的两根筷子。
假如出现了极端情况,同一时刻哲学家同时拿起左手的筷子,这时哲学家拿不起右手的筷子,都要等待右边的哲学家把筷子放下,出现了死锁。
死锁的4个必要条件
1.互斥使用。线程1拿到了锁,线程2就得等着,(锁的基本特性)
2.不可抢占。线程1拿到了锁之后,必须是线程1主动释放,不能说是线程2把锁强行获取到。
3.请求和保持。线程1拿到锁A之后,再尝试获取锁B,A这把锁还是保持的。(不会因为获取锁B就把锁A释放了)
4.循环等待。线程1尝试获取锁A和锁B,线程2尝试获取锁B和锁A。线程1在获取B时等待线程2释放B;同时线程2在获取A时要等待线程1释放A。
前3个条件都是锁的基本特性(对于synchronized这把锁来说无法改变)
条件4循环等待是这4个条件里唯一一个和代码结构相关的,也是程序员可以控制的。
世界上的锁不是只有synchronized,还会存在一些其他情况的锁,可能和上述1,2,3条件还有变数。
如何避免死锁
打破必要条件即可,突破口是循环等待。
解决办法:给锁编号,然后指定一个固定的顺序(比如从小到大)来加锁,任意线程加多把锁的时候都让线程遵守上述规则,此时循环等待自然破除。
以哲学家用餐为例如何破除死锁:
//上面例子
//约定两个线程先拿编号小的后拿编号大的。(先拿locker1,后拿locker2)
public class ThreadDemo {
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2){
System.out.println("t1 把 locker1 和 locker2 都拿到了");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2){
System.out.println("t2 把 locker2 和 locker1 都拿到了");
}
}
});
t1.start();
t2.start();
}
}
//输出结果:
t1 把 locker1 和 locker2 都拿到了
t2 把 locker2 和 locker1 都拿到了