深入浅出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 关键字(这个关键字的功能后面介绍).
    public static volatile boolean isQuit = false;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            while (!isQuit){
                System.out.println(Thread.currentThread().getName() + " : 别管我,我忙着转正!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() + " : 啊!险些误了大事");
        });
        System.out.println(Thread.currentThread().getName() + " : 让李四开始转账");
        t.start();
        Thread.sleep(10*1000);
        System.out.println(Thread.currentThread().getName() + " : 老板来电话了,得赶紧通知李四对方是个骗子!");
        isQuit = true;
    }

深入浅出Java的多线程编程——第二篇_第1张图片

这里的   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() 方法通知线程结束.
public class Thread4 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
           while(!Thread.currentThread().isInterrupted()){
               System.out.println("hello t");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        t.start();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        t.interrupt();
    }
}

深入浅出Java的多线程编程——第二篇_第2张图片

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

1.2 小结

interrupt方法的作用:

(1)设置标志位为true

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

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

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

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

一种极端的情况:

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

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

public class Thread4 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
           while(!Thread.currentThread().isInterrupted()){
               System.out.println("hello t");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
                   break;
               }
           }
        });
        t.start();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        t.interrupt();
    }
}

深入浅出Java的多线程编程——第二篇_第3张图片

小结一下:

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

如果当前的标志位为true

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

(1)立即抛出异常

(2)清空标志位为false 

再下次循环,到sleep

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

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

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

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

深入浅出Java的多线程编程——第二篇_第4张图片

深入浅出Java的多线程编程——第二篇_第5张图片

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

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

只要调用interrupt就立即结束?

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

2. 等待一个线程

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

public class Thread5 {
    public static void main(String[] args) throws InterruptedException {
        Runnable run = () -> {
          for(int i = 0;i < 3;i++){
              try {
                  System.out.println(Thread.currentThread().getName() + " : 我正在工作!");
                  Thread.sleep(1000);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
          }
            System.out.println(Thread.currentThread().getName() + " : 我的工作结束了!");
        };

        Thread t1 = new Thread(run,"李四");
        Thread t2 = new Thread(run,"张三");
        System.out.println("李四先工作");
        t1.start();
        t1.join();
        System.out.println("李四做完了,张三开始工作!");
        t2.start();
        t2.join();
        System.out.println("王五做完了,张三开始工作!");
        System.out.println("两人都工作结束了!");
    }
}

深入浅出Java的多线程编程——第二篇_第6张图片

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

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

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

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

public class Thread5 {
    public static void main(String[] args) throws InterruptedException {
        Runnable run = () -> {
          for(int i = 0;i < 3;i++){
              try {
                  System.out.println(Thread.currentThread().getName() + " : 我正在工作!");
                  Thread.sleep(1000);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
          }
            System.out.println(Thread.currentThread().getName() + " : 我的工作结束了!");
        };

        Thread t1 = new Thread(run,"李四");
        Thread t2 = new Thread(run,"张三");
        System.out.println("李四先工作");
        t1.start();
//        t1.join();
        System.out.println("李四做完了,张三开始工作!");
        t2.start();
//        t2.join();
        System.out.println("王五做完了,张三开始工作!");
        System.out.println("两人都工作结束了!");
    }
}

深入浅出Java的多线程编程——第二篇_第7张图片

 2.1 等待的API

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

3. 线程的状态

3.1 贯彻线程的所有状态

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

public class Thread6 {
    public static void main(String[] args) {
        for (Thread.State state:Thread.State.values()) {
            System.out.println(state);
        }
    }
}

深入浅出Java的多线程编程——第二篇_第8张图片

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

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

深入浅出Java的多线程编程——第二篇_第9张图片

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

深入浅出Java的多线程编程——第二篇_第10张图片

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

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

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

4.1 观察线程不安全

public class Thread7 {
    static int count = 0;


    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for(int i = 0;i < 5000;i++){
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0;i < 5000;i++){
                count++;
            }
        });
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count);
    }

}

深入浅出Java的多线程编程——第二篇_第11张图片

 

深入浅出Java的多线程编程——第二篇_第12张图片

深入浅出Java的多线程编程——第二篇_第13张图片

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

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

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

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

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

深入浅出Java的多线程编程——第二篇_第14张图片

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

4.2 线程安全的概念

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

4.3 线程不安全的原因

4.3.1 修改共享数据

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

深入浅出Java的多线程编程——第二篇_第15张图片

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

4.3.2 原子性

深入浅出Java的多线程编程——第二篇_第16张图片

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

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

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

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

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

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

4.3.3 可见性

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

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

深入浅出Java的多线程编程——第二篇_第17张图片

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

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

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

深入浅出Java的多线程编程——第二篇_第18张图片

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

深入浅出Java的多线程编程——第二篇_第19张图片

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

此时引入了两个问题: 

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

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

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

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

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

深入浅出Java的多线程编程——第二篇_第20张图片

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

4.3.4 代码顺序性

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

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

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

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

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

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

public class Thread7 {
    static int count = 0;


    public static void main(String[] args) {
        Object o = new Object();
        Thread t1 = new Thread(() -> {

            synchronized (o) {
                for(int i = 0;i < 5000;i++){
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (o) {
                for(int i = 0;i < 5000;i++){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count);
    }

}

你可能感兴趣的:(Java的多线程编程,java,开发语言)