JavaEE初阶第五期:解锁多线程,从 “单车道” 到 “高速公路” 的编程升级(三)
专栏:JavaEE初阶起飞计划
个人主页:手握风云
目录
一、线程的状态
1.1. 观察所有线程的状态
1.2. 观察线程的状态和转移
1.3. 线程状态和状态转移的意义
二、线程安全
2.1. 观察线程不安全
2.2. 线程安全的概念
2.3. 线程不安全的原因
一、线程的状态
1.1. 观察所有线程的状态
public class Demo1 {public static void main(String[] args) throws InterruptedException {for (Thread.State state : Thread.State.values()) {System.out.println(state);}}
}
- NEW:当使用new关键字创建一个线程对象时,它就处于新建状态。此时,线程还没有开始执行,它只是一个Thread类的实例。
- RUNNABLE:当线程对象调用start()方法后,线程就进入可运行状态。这意味着线程已经准备好运行,正在等待 CPU 调度器分配执行时间。在可运行状态下,线程可能正在运行,也可能正在等待运行。
- BLOCKED:当一个线程试图获取一个被其他线程持有的锁时,它会进入阻塞状态。一旦锁被释放,线程将重新进入可运行状态,等待调度。
- WAITING:当线程执行了以下方法时,它会进入等待状态,并且需要其他线程的特定动作才能唤醒。
- TIMED_WAITING:与等待状态类似,但有时间限制。当线程执行了某些方法时,会进入有时限的等待状态。
- TERMINATED:当线程的run()方法执行完毕,或者因异常而退出时,线程就进入终止状态。一旦线程进入终止状态,它就不能再被重新启动。
1.2. 观察线程的状态和转移
public class Demo2 {public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {});// 创建了Thread对象,还没启动System.out.println(t.getState() + " isAlive: " + t.isAlive());t.start();t.join();// 线程执行完了,但Thread对象还在System.out.println(t.getState() + " isAlive: " + t.isAlive());}
}
通过上面可以看出,操作系统中的线程的生命周期和Thread对象不完全一致。
public class Demo2 {public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {while (true) {}});// 创建了Thread对象,还没启动System.out.println(t.getState() + " isAlive: " + t.isAlive());t.start();System.out.println(t.getState() + " isAlive: " + t.isAlive());t.join();// 线程执行完了,但Thread对象还在System.out.println(t.getState() + " isAlive: " + t.isAlive());}
}
因为上面的while循环没有执行完毕,所以t线程一直阻塞,就不会打印第三条语句。只要代码不触发阻塞类操作,就一直是RUNNABLE状态。
1.3. 线程状态和状态转移的意义
线程状态转移是指线程在执行过程中,从一种状态变为另一种状态。理解线程状态转移对于编写高效、稳定的并发程序至关重要。操作系统需要根据线程状态来合理分配CPU时间、内存等资源。了解这些状态有助于开发者更好地与操作系统协作,避免资源浪费。
二、线程安全
某一段代码,在单线程环境下是正确的,但放在多线程环境下执行会产生bug,这就是线程安全的问题。
2.1. 观察线程不安全
public class Demo3 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();// 预期结果是10wSystem.out.println(count);}
}
但实际运行结果与预期不同,这就是产生了bug,并且每运行一次会产生不同的结果。t1和t2两个线程在同时修改count两个变量,并且修改操作不是“原子的(事务要解决的根本问题)”,就会产生上述问题。count++这样的操作,站在CPU的角度来说,其实是三个指令。对于CPU来说,每个指令是执行的最基本单位,由于操作系统调度线程是随机的,某个线程执行到任意一个指令的时候,都有可能触发CPU调度。
count++本质上对应三个指令:1.load:把内存中的数值,加载到CPU寄存器里;2.add:把寄存器中的数据进行加1操作,结果还是放到寄存器中;3.save:把寄存器中的值写回到内存中。
如上图所示,上图的CPU调度就是正确的,可以正确打印出10w,但这毕竟是少数情况,还会出现如下图的情况:
t1从CPU上切换走的时候,会保存上下文,当再次切换到t1的时候,就会恢复上下文,也就是把CPU寄存器的值全都返回回去,而count虽然执行了两次加1的操作,但打印结果还是1。
2.2. 线程安全的概念
在并发编程中,线程安全是指当多个线程同时访问或操作同一个数据或资源时,程序能够正确地执行,并且不会产生不正确的结果或不可预测的行为。简单来说,就是你的代码在多线程环境下也能正常工作,不会因为多个线程的并发执行而“出错”。
2.3. 线程不安全的原因
- 执行式抢占
线程的随机调度这是造成线程安全问题的罪魁祸首。写代码的时候,需要确保无数种调度顺序下,总体的结果都是正确的。
- 多个线程同时修改一个变量
public class Demo3 {// private static int count = 0;public static void main(String[] args) throws InterruptedException {int count = 0;Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();// 预期结果是10wSystem.out.println(count);}
}
如果我们把上面的代码中的静态成员变量修改为局部变量,会出现报错,原因如下图所示:
在Lambda表达式中对局部变量进行捕获,这个变量要么是被final修饰的,如果不是被final修饰的,要确保在使用之前没有被修改。
注意,如果是单个线程修改一个变量,多个线程访问同一个变量,多个线程修改不用变量是没事的。
- 修改操作不是“原子性”的
原子性指一个操作或一组操作在执行过程中不可被中断,要么全部完成,要么完全不执行,不存在中间状态。在并发编程中,原子性是保证数据一致性的基础,避免多线程竞争共享资源时出现 “部分执行” 的错误。
例如,count++看似单一操作,实则分为三步(读取值 → 加 1 → 写入值)。若线程 A 读取count=0后被中断,线程 B 读取count=0并完成写入count=1,此时线程 A 恢复执行并写入count=1,最终结果应为2却得到1,这就是原子性缺失导致的错误。
- 指令重排序
为了提高执行效率,处理器和编译器可能会对指令进行重排序。在单线程环境下,这种重排序不会影响程序的最终结果。但在多线程环境下,指令重排序可能会破坏程序的逻辑顺序,导致意想不到的结果。
- 内存可见性
在多核处理器系统中,每个处理器都有自己的高速缓存。为了提高性能,线程可能会将共享变量的值缓存到自己的本地缓存中。如果一个线程修改了共享变量的值,但这个修改还没有及时地刷新到主内存,那么其他线程从主内存或者自己的本地缓存中读取到的仍然是旧的值。