• 深入浅出Java的多线程编程——第二篇


    目录

    前情回顾

    1. 中断一个线程

    1.1 中断的API

    1.2 小结

    2. 等待一个线程

     2.1 等待的API

    3. 线程的状态

    3.1 贯彻线程的所有状态

    3.2 线程状态和状态转移的意义

    4. 多线程带来的的风险-线程安全 (重点)

    4.1 观察线程不安全

    4.2 线程安全的概念

    4.3 线程不安全的原因

    4.3.1 修改共享数据

    4.3.2 原子性

    4.3.3 可见性

    4.3.4 代码顺序性

    4.4 解决之前的线程不安全问题


    前情回顾

    操作系统、进程和线程_木子斤欠木同的博客-CSDN博客

    深入浅出Java的多线程编程——第一篇_木子斤欠木同的博客-CSDN博客

    让我们来回顾一下,第一篇多线程的内容:

    1. 多线程:

    (1)线程的概念

    (2)进程和线程的区别

    (3)Java代码如何创建线程

    ①继承Thread重写run

    ②实现Runnable接口重写run,将该实现类作为参数传给Thread的构造方法

    ③继承Thread,匿名内部类

    ④实现Runnable,匿名内部类

    ⑤lambda表达式

    2. Thread的常用属性

    start方法,真正从系统这里,创建一个线程,新的线程将会执行run方法。

    run方法:表示线程的入口方法是啥(线程启动起来,要执行哪些逻辑)(run方法不是让程序猿调用的,要交给系统去自动调用)【换个角度理解:我们可以把线程的run方法理解为main方法,都是系统去自动调用】

    1. 中断一个线程

    李四一旦进到工作状态,他就会按照行动指南上的步骤去进行工作,不完成是不会结束的。但有时我们需要增加一些机制,例如老板突然来电话了,说转账的对方是个骗子,需要赶紧停止转账,那张三该如何通知李四停止呢?这就涉及到我们的停止线程的方式了。

    本质上来说,让一个线程终止,办法就一种,让该线程的入口方法执行完毕!也就是让run跑完!

    目前常见的有以下两种方式:

    • 通过共享的标记来进行沟通
    • 调用 interrupt() 方法来通知【记住,只是通知而已】

    示例1:使用自定义的变量来作为标志位

    • 需要给标志位上加 volatile 关键字(这个关键字的功能后面介绍).
    1. public static volatile boolean isQuit = false;
    2. public static void main(String[] args) throws InterruptedException {
    3. Thread t = new Thread(()->{
    4. while (!isQuit){
    5. System.out.println(Thread.currentThread().getName() + " : 别管我,我忙着转正!");
    6. try {
    7. Thread.sleep(1000);
    8. } catch (InterruptedException e) {
    9. e.printStackTrace();
    10. }
    11. }
    12. System.out.println(Thread.currentThread().getName() + " : 啊!险些误了大事");
    13. });
    14. System.out.println(Thread.currentThread().getName() + " : 让李四开始转账");
    15. t.start();
    16. Thread.sleep(10*1000);
    17. System.out.println(Thread.currentThread().getName() + " : 老板来电话了,得赶紧通知李四对方是个骗子!");
    18. isQuit = true;
    19. }

    这里的   public static volatile boolean isQuit = false; 为什么需要加static,因为main函数被static修饰,所以在main内部用到的成员变量要加static

    示例2:使用 Thread.interrupted() 或者 Thread.currentThread().isInterrupted() 代替自定义标志位。

    1.1 中断的API

    • Thread 内部包含了一个 boolean 类型的变量作为线程是否被中断的标记。
    方法说明
    pubilc void interrupt()中断对象关联的线程,如果线程正在阻塞,会把线程唤醒,则以异常的方式通知,然后吧标志位设置为true
    public static boolean interrupted()判断当前线程的中断标志位是否设置,调用后清除标志位
    public boolean isInterrupted()判断对象关联的线程的标志位是否设置,调用后不清除标志位
    • 使用 thread 对象的 interrupted() 方法通知线程结束.
    1. public class Thread4 {
    2. public static void main(String[] args) {
    3. Thread t = new Thread(() -> {
    4. while(!Thread.currentThread().isInterrupted()){
    5. System.out.println("hello t");
    6. try {
    7. Thread.sleep(1000);
    8. } catch (InterruptedException e) {
    9. e.printStackTrace();
    10. }
    11. }
    12. });
    13. t.start();
    14. try {
    15. Thread.sleep(2000);
    16. } catch (InterruptedException e) {
    17. e.printStackTrace();
    18. }
    19. t.interrupt();
    20. }
    21. }

    我们可以发现,调用t.interrupt方法的时候,线程并没有真的结束,而是打印了个异常信息,又继续执行了

    1.2 小结

    interrupt方法的作用:

    (1)设置标志位为true

    (2)如果该线程正在阻塞中(比如执行了sleep)此时就会把阻塞状态唤醒,通过抛出异常的方式让sleep立即结束

    注意:一个非常重要的问题,当sleep被唤醒的时候(只有sleep被唤醒才会重置标志位),sleep自动地把isInterrupted标志位给清空了(true - > false),这导致下次循环,循环仍然可以继续执行了~~

    有的开关,是按下之后,就按下去了

    有的开关,是按下去之后,自动弹起(sleep就属于这种)

    一种极端的情况:

    如果设置interrupt的时候,恰好sleep刚醒,这个时候赶巧了,执行到下一轮循环条件就直接结束了。但是这种概率非常低,毕竟sleep的时间已经占据了整个循环体的99.999%的时间了

    如果需要结束循环,就得在catch中搞个break

    1. public class Thread4 {
    2. public static void main(String[] args) {
    3. Thread t = new Thread(() -> {
    4. while(!Thread.currentThread().isInterrupted()){
    5. System.out.println("hello t");
    6. try {
    7. Thread.sleep(1000);
    8. } catch (InterruptedException e) {
    9. e.printStackTrace();
    10. break;
    11. }
    12. }
    13. });
    14. t.start();
    15. try {
    16. Thread.sleep(2000);
    17. } catch (InterruptedException e) {
    18. e.printStackTrace();
    19. }
    20. t.interrupt();
    21. }
    22. }

    小结一下:

    如果sleep执行的时候看到这个标志位是false,sleep正常进行休眠操作

    如果当前的标志位为true

    sleep无论是刚刚执行还是已经执行了一般,都会触发两件事

    (1)立即抛出异常

    (2)清空标志位为false 

    再下次循环,到sleep

    由于当前标志位本身是false,就啥也不干~~

    总结到这里,就有小伙伴有疑问了,为什么sleep要清空标志位呢?

    目的就是为了让线程自身能够对于线程何时结束,有一个更明确的控制~~

    当前,interrupt方法,效果不是让线程立即结束,而是告诉他,你该结束了,至于他是否真的要立即结束还是等会结束,都是由它本线程的代码来控制~~,interrupt只是通知,而非“命令”!

    有的朋友就好奇了,我如果不加sleep,这些能令线程阻塞的代码,那是不是就能让该线程直接结束呢?对的,是可以,但是工作中没人会这么写代码,一个不可控的线程是多么可怕!

    这里就可以又引出一个问题,java为啥不强制制定“命令结束”的操作呢?

    只要调用interrupt就立即结束?

    答:主要是设定成这种,对线程来说非常不友好~,线程t何时结束,一定是t自己要最清楚,交给t自身来决定比较好!

    2. 等待一个线程

    有时,我们需要等待一个线程完成它的工作后,才能进行自己的下一步工作。例如,张三只有等李四转账成功,才决定是否存钱,这时我们需要一个方法明确等待线程的结束。

    1. public class Thread5 {
    2. public static void main(String[] args) throws InterruptedException {
    3. Runnable run = () -> {
    4. for(int i = 0;i < 3;i++){
    5. try {
    6. System.out.println(Thread.currentThread().getName() + " : 我正在工作!");
    7. Thread.sleep(1000);
    8. } catch (InterruptedException e) {
    9. e.printStackTrace();
    10. }
    11. }
    12. System.out.println(Thread.currentThread().getName() + " : 我的工作结束了!");
    13. };
    14. Thread t1 = new Thread(run,"李四");
    15. Thread t2 = new Thread(run,"张三");
    16. System.out.println("李四先工作");
    17. t1.start();
    18. t1.join();
    19. System.out.println("李四做完了,张三开始工作!");
    20. t2.start();
    21. t2.join();
    22. System.out.println("王五做完了,张三开始工作!");
    23. System.out.println("两人都工作结束了!");
    24. }
    25. }

    在main线程中调用t1.join()表示main线程要等t1线程跑完,main线程才可以继续执行。

    (1)main线程调用t1.join()的时候,如果t1还在运行,此时main线程阻塞,知道t执行完毕(t1的run执行完了),main线程才从阻塞中解除,才继续执行。

    (2)main线程调用t.join()的时候,如果t已经结束了,此时join就不会阻塞,会立即执行下去。

    如果把两个join方法注释掉,就会CPU的抢占式调用的典型例子:

    1. public class Thread5 {
    2. public static void main(String[] args) throws InterruptedException {
    3. Runnable run = () -> {
    4. for(int i = 0;i < 3;i++){
    5. try {
    6. System.out.println(Thread.currentThread().getName() + " : 我正在工作!");
    7. Thread.sleep(1000);
    8. } catch (InterruptedException e) {
    9. e.printStackTrace();
    10. }
    11. }
    12. System.out.println(Thread.currentThread().getName() + " : 我的工作结束了!");
    13. };
    14. Thread t1 = new Thread(run,"李四");
    15. Thread t2 = new Thread(run,"张三");
    16. System.out.println("李四先工作");
    17. t1.start();
    18. // t1.join();
    19. System.out.println("李四做完了,张三开始工作!");
    20. t2.start();
    21. // t2.join();
    22. System.out.println("王五做完了,张三开始工作!");
    23. System.out.println("两人都工作结束了!");
    24. }
    25. }

     2.1 等待的API

    方法说明
    public void join()等待线程结束
    public void join(long millis)等待线程结束,最多等 millis 毫秒
    public void join(long millis, int nanos)同理,但可以更高精度

    3. 线程的状态

    3.1 贯彻线程的所有状态

    线程的状态是一个枚举类型 Thread.State

    1. public class Thread6 {
    2. public static void main(String[] args) {
    3. for (Thread.State state:Thread.State.values()) {
    4. System.out.println(state);
    5. }
    6. }
    7. }

    • NEW: 安排了工作, 还未开始行动
    • RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作.
    • BLOCKED: 这几个都表示排队等着其他事情
    • WAITING: 这几个都表示排队等着其他事情
    • TIMED_WAITING: 这几个都表示排队等着其他事情
    • TERMINATED: 工作完成了.

    3.2 线程状态和状态转移的意义

    大家不要被这个状态转移图吓到,我们重点是要理解状态的意义以及各个状态的具体意思。

    举个栗子:
           刚把李四、王五找来,还是给他们在安排任务,没让他们行动起来,就是 NEW 状态;
    当李四、王五开始去窗口排队,等待服务,就进入到 RUNNABLE 状态。该状态并不表示已经被银行工。
           作人员开始接待,排在队伍中也是属于该状态,即可被服务的状态,是否开始服务,则看调度器的调度;
           当李四、王五因为一些事情需要去忙,例如需要填写信息、回家取证件、发呆一会等等时,进入
            BLOCKED 、 WATING 、 TIMED_WAITING 状态,至于这些状态的细分,我们以后再详解;
    如果李四、王五已经忙完,为 TERMINATED 状态。所以,之前我们学过的 isAlive() 方法,可以认为是处于不是 NEW 和 TERMINATED 的状态都是活着的。

    4. 多线程带来的的风险-线程安全 (重点)

    本质是因为线程之间的调度顺序的不确定性

    4.1 观察线程不安全

    1. public class Thread7 {
    2. static int count = 0;
    3. public static void main(String[] args) {
    4. Thread t1 = new Thread(() -> {
    5. for(int i = 0;i < 5000;i++){
    6. count++;
    7. }
    8. });
    9. Thread t2 = new Thread(() -> {
    10. for(int i = 0;i < 5000;i++){
    11. count++;
    12. }
    13. });
    14. t1.start();
    15. t2.start();
    16. try {
    17. t1.join();
    18. t2.join();
    19. } catch (InterruptedException e) {
    20. e.printStackTrace();
    21. }
    22. System.out.println(count);
    23. }
    24. }

     

    由于当前这两线程调度的顺序是无序的~~

    你也不知道这两线程自增的过程中,到底经历了什么

    有多少次是“顺序执行”,有多少次是“交错执行”不知道!

    得到的结果是啥也就是不确定的!

    这里就引出了一个问题,出现bug之后,得到的结果一定是 <= 10000,或者结果一定是 >= 5000?

    CPU调用是以原语为单位的!

    4.2 线程安全的概念

    想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
    如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

    4.3 线程不安全的原因

    4.3.1 修改共享数据

    上面的线程不安全的代码中, 涉及到多个线程针对 counter.count 变量进行修改.
    此时这个 count 是一个多个线程都能访问到的 "共享数据"!

    count 这个变量就是在堆上. 因此可以被多个线程共享访问. 

    4.3.2 原子性

    什么是原子性
    我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
    那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。有时也把这个现象叫做同步互斥,表示操作是互相排斥的。 

    一条 java 语句不一定是原子的,也不一定只是一条指令

    比如刚才我们看到的 n++,其实是由三步操作组成的:

    1. 从内存把数据读到 CPU
    2. 进行数据更新
    3. 把数据写回到 CPU

    不保证原子性会给多线程带来什么问题
    如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。

    这点也和线程的抢占式调度密切相关. 如果线程不是 "抢占" 的, 就算没有原子性, 也问题不大。

    4.3.3 可见性

    可见性指一个线程对共享变量值的修改,能够及时地被其他线程看到。

    Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
    目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

    1. 线程之间的共享变量存在 主内存 (Main Memory).
    2. 每一个线程都有自己的 "工作内存" (Working Memory) .
    3. 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
    4. 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存. 

    由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本". 此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.

    1) 初始情况下, 两个线程的工作内存内容一致.

    2) 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定能及时同步. 

    这个时候代码中就容易出现问题.

    此时引入了两个问题: 

    • 为啥要整这么多内存?
    • 为啥要这么麻烦的拷来拷去?

    1) 为啥整这么多内存?
    实际并没有这么多 "内存". 这只是 Java 规范中的一个术语, 是属于 "抽象" 的叫法.
    所谓的 "主内存" 才是真正硬件角度的 "内存". 而所谓的 "工作内存", 则是指 CPU 的寄存器和高速缓存.

    2) 为啥要这么麻烦的拷来拷去?
    因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍)

    比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了,效率就大大提高了

    那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥??
    答案就是一个字: 贵

    值的一提的是, 快和慢都是相对的. CPU 访问寄存器速度远远快于内存, 但是内存的访问速度又远远快于硬盘。
    对应的, CPU 的价格最贵, 内存次之, 硬盘最便宜。

    4.3.4 代码顺序性

    什么是代码重排序
    一段代码是这样的:

    1. 去前台取下 U 盘
    2. 去教室写 10 分钟作业
    3. 去前台取下快递

    如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台,这种叫做指令重排序。

    编译器对于指令重排序的前提是 "保持逻辑不发生变化". 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.

    重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论

    4.4 解决之前的线程不安全问题

    1. public class Thread7 {
    2. static int count = 0;
    3. public static void main(String[] args) {
    4. Object o = new Object();
    5. Thread t1 = new Thread(() -> {
    6. synchronized (o) {
    7. for(int i = 0;i < 5000;i++){
    8. count++;
    9. }
    10. }
    11. });
    12. Thread t2 = new Thread(() -> {
    13. synchronized (o) {
    14. for(int i = 0;i < 5000;i++){
    15. count++;
    16. }
    17. }
    18. });
    19. t1.start();
    20. t2.start();
    21. try {
    22. t1.join();
    23. t2.join();
    24. } catch (InterruptedException e) {
    25. e.printStackTrace();
    26. }
    27. System.out.println(count);
    28. }
    29. }

  • 相关阅读:
    C认证笔记 - 计算机通识 - HTTP
    Fluent中级工程进阶,从5种气体燃烧模型出发
    AtCoder Beginner Contest 278 F
    js的数据类型
    前端稳定性建设
    工程管理系统源码之全面+高效的工程项目管理软件
    【R语言入门】开启R的会话并大步向前!
    FT2000/4查看CPU温度以及频率
    【动态规划之完全背包问题】如何将完全背包运用到实际问题,强化完全背包以及一维优化的推导
    Zabbix“专家坐诊”第210期问答汇总
  • 原文地址:https://blog.csdn.net/ANNE_fly/article/details/133298242