多线程基础篇(包教包会)

文章目录

    • 一、第一个多线程程序
      • 1.Jconsole观察线程
      • 2.线程休眠-sleep
    • 二、创建线程
    • 三、Thread类及常见方法
      • 1. Thread 的常见构造方法
      • 2. Thread 的几个常见属性
      • 3. 启动线程 - start
      • 4. 中断线程
      • 5. 等待一个线程
    • 四、线程状态
    • 五、线程安全问题(synchronized)(重点)
      • 1. 观察线程不安全问题
      • 2.线程安全问题分析
      • 3.线程安全问题的原因
      • 4.解决线程不安全问题
      • 5.synchronized 关键字
      • 6.总结
    • 六、内存可见性问题(volatile)
      • 1.观察内存不可见问题
      • 2.问题分析
      • 3.volatile关键字
      • 4.总结
    • 七、wait 和 notify
      • 1.wait 和 sleep 之间的区别

前言:平时我们敲的代码,当点击运行程序的时候,就会先创建出一个java进程。这个进程中就包含了至少一个线程。这个线程也叫做主线程。也就是负责执行main方法的线程.

一、第一个多线程程序

Java中为了实现多线程,提供了thread类。

创建一个类来继承thread,重写thread中的run方法,这里的run方法就相当于线程的入口,当程序运行后,此线程要做什么事情,都是通过run方法来实现的。

创建完后,我们要在main中来调用这个myThread线程,这里通过start方法来启动线程。(start会调用系统api,在系统内核中把线程对应的pcb给创建出来并管理好,由此新的线程就会参与调度了)

为什么不用 myThread.run() ? run只是上面的入口方法(普通的方法)。并没有调用系统 api,也没有创建出真正的线程来.不会执行并发操作,只是按顺序执行代码。

class myThread extends Thread{
    @Override
    public void run() {
        while (true) {
            System.out.println("hello Thread");
        }
    }
}
public class Main {
    public static void main(String[] args) {
        Thread myThread = new myThread();
        myThread.start();
        //myThread.run();
        while (true) {
            System.out.println("Hello world!");
        }
    }
}

主线程和新线程是并发执行的关系. 操作系统怎么调度?

每个线程,都是一个独立的执行流.每个线程都可以执行一段代码.多个线程之间是并发的关系~~

多线程基础篇(包教包会)_第1张图片

1.Jconsole观察线程

当创建出线程之后,也是可以通过一些方式,直观的观察到的~~

  1. idea 的调试器

    多线程基础篇(包教包会)_第2张图片

  2. jconsole

    此为官方在 jdk 中给程序猿提供的一个调试工具。

    我们可以按照之前 jdk 下载的路径在 bin 目录下找到 jconsole.exe

多线程基础篇(包教包会)_第3张图片

先运行java程序然后点击 jconsole.exe ,就会发现我们用 java 写的多线程正在运行。

多线程基础篇(包教包会)_第4张图片

这里就列出了当前进程中所有的线程不仅仅是主线程和自己创建的新线程. 剩下的线程,都是JVM里自带的,负责完成一些其他方面的任务。

多线程基础篇(包教包会)_第5张图片


2.线程休眠-sleep

这里先介绍Thread类中的一个sleep方法,顾名思义,就是让线程暂时睡一会、暂时停滞 不进行工作。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DUw7Zu3t-1692793532592)(C:\Users\28779\AppData\Roaming\Typora\typora-user-images\image-20230819173807151.png)]

在sleep()中我们可以设置休眠多长时间,其单位是ms。

重要知识点:sleep休眠的过程中不会释放锁 (后文中会讲到锁)

sleep 本身是存在一些误差.

设置 sleep(1000) ,不一定是精确的就休眠 1000ms,会存在误差!!原因是线程的调度,也是需要时间的。

冷知识:sleep(0) 是让当前线程放弃 CPU 重新去队列中排队,准备下一轮的调度。由于这个操作看起来比较抽象,因此java有封装了一个方法yield,和sleep(0)功能一样。

二、创建线程

创建线程的方式还有很多,包括:

1.创建一个类,继承自 Thread.重写run方法. (已介绍)

2.创建一个类,实现Runnable.重写run方法.

3.继承 Thread ,重写run,基于匿名内部类.

4.实现 Runnable ,重写run,基于匿名内部类.

5.使用 lambda表达式,表示run方法的内容.(推荐常用)

6.基于Callable(本篇未涉及到,下篇会讲解)

7.基于线程池(本篇未涉及到,下篇会)


上述第一种方法已介绍,接着介绍第二种方法。

创建一个类,实现 Runnable.重写 run 方法.

Runnable这里,则是分开了,把要完成的工作放到Runnable 中,再让Runnable和Thread 配合.

这里是把要完成的工作放到 Runnable 中,再让 Runnable 和 Thread 配合.

class myRunnable implements Runnable {
    @Override
    public void run() {

        while (true) {
            System.out.println("hello world");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        myRunnable myRunnable = new myRunnable();
        Thread i = new Thread(myRunnable);

        i.start();

        while (true) {
            System.out.println("hello main");
            Thread.sleep(1000);
        }
    }
}

继承 Thread ,重写run,基于匿名内部类

1.创建了一个子类,这个子类继承自Thread. 但是,这个子类,是没有名字的!!(匿名)另一方面,这个类的创建,是在Demo3这个类里面.

2.在子类中,重写了run方法.

3创建了该子类的实例.并且使用t这个引用来指向.

public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread() {
            @Override
            public void run() {
                while (true) {
                    System.out.println("hello world");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        };
        t.start();
        while (true) {
            System.out.println("hello main");
            Thread.sleep(1000);
        }
    }
}

实现 Runnable ,重写run,基于匿名内部类.

1.创建了一个Runnable的子类(类,实现 Runnable)

2.重写了run方法

3.把子类,创建出实例,把这个实例传给Thread的构造方法.

public class Demo4 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    System.out.println("hello world");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        });
        t.start();
        while (true) {
            System.out.println("hello main");
            Thread.sleep(1000);
        }
    }
}

使用 lambda表达式,表示run方法的内容.(推荐常用)

lambda表达式,本质上就是一个"匿名函数”。这样的匿名函数,主要就可以用来作为回调函数来使用.

经常会用到回调函数的场景:

  • 服务器开发:服务器收到一个请求,触发一个对应的回调函数.
  • 图形界面开发:用户的某个操作,触发一个对应的回调.

类似于lambda这样的写法,本质上并没有新增新的语言特性,而是把以往能实现的功能,换了一种更简洁的方式来编写.(新瓶装旧酒->语法糖)

public class Demo5 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (true) {
                System.out.println("hello world");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t.start();
        while (true) {
            System.out.println("hello main");
            Thread.sleep(1000);
        }

    }
}

三、Thread类及常见方法

1. Thread 的常见构造方法

多线程基础篇(包教包会)_第6张图片

我们可以给创建的线程进行命名

public class Demo5 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (true) {
                System.out.println("hello a");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        },"THREAD");
        t.start();
/*        while (true) {
            System.out.println("hello b");
            Thread.sleep(1000);
        }*/
    }
}

把主线程中的循环注释掉,当程序运行时,查看 jconsole,发现只剩HTREAD线程,main线程没有了。因为main已经执行完了!!

多线程基础篇(包教包会)_第7张图片


2. Thread 的几个常见属性

多线程基础篇(包教包会)_第8张图片

  • isDaemon是否后台线程

    JVM会在一个进程的所有非后台线程结束后,才会结束运行。

    创建的线程,默认是前台线程。可以通过setDaemon(true)显式的设置成后台。

  • islive是否存活

    Thread对象的生命周期,并不是和系统中的线程完全一致的!!

    一般,都是Thread对象,先创建好,然后手动调用start,内核才真正创建出线程。

    消亡的时候,可能是thread对象,先结束了生命周期(没有引用指向这个对象)。也可能是 thread对象还在,内核中的线程把run执行完了,就结束了。


3. 启动线程 - start

start 方法是系统中,真正创建出线程。此方法是调用系统中的 api 完成线程的创建

如何创建的呢? 在内核中完成创建pcb,并把pcb加入到对应的链表中。

start方法本身的执行是一瞬间就完成的.只是告诉系统,你要创建个小线程出来。调用start完毕之后,代码就会立即继续执行 start 后续的逻辑。


4. 中断线程

在线程执行 run 方法的时候,不完成是不会结束的。但有时候,因为特殊原因,需要终止一个正在执行的程序,该如何操作嘞??

常见的方式有以下两种:

  1. 程序猿手动设定标志位
  2. 调用 interrupt() 方法来通知

设定标志位

public class Demo8 {

    public static boolean isQuit = false;
    
    public static void main(String[] args) throws InterruptedException {
        //boolean isQuit = false;
        Thread t = new Thread(()->{
           while (!isQuit) {
               System.out.println("hello world");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
           }
        });
        t.start();

        //主线程执行一些其他逻辑后,要让 t 线程结束.
        Thread.sleep(3000);

        //这个代码就是在修改前面设置的标志位.
        isQuit = true;
        System.out.println("把 t 线程中断");
    }
}

以上代码就是通过设定标志位来终止线程的。

思考 如果我们现在把 isQuit 定义在 main 内,代码就会开始报错!!这是为什么呢?

是因为 lambda 所触发的“变量捕获”机制。变量捕获这里有个限制,要求捕获的变量得是final (至少是看起来是final),我们都知道被final修饰后面是不可以修改的。

如果这个变量想要进行修改,就不能进行变量捕获了~~因此上述代码就会进行报错。

什么是变量捕获:lambda内部看起来是在直接访问外部的变量,其实本质上是把外部的变量给复制了一份,到 lambda里面.

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QeLCyBvq-1692793532593)(C:\Users\28779\AppData\Roaming\Typora\typora-user-images\image-20230818171855839.png)]

为啥java这么设定??

java是通过复制的方式来实现"变量捕获",如果外面的代码要对这个变量进行修改,就会出现一个情况:外面的变量变了,里面的没变~~代码更容易出现歧义.


使用 interrupt()方法

使用 Thread.interrupted() 或者 Thread.currentThread().isInterrupted() 代替自定义标志位.

多线程基础篇(包教包会)_第9张图片

public class Demo9 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            //Thread.currentThread()其实就是 t
            //这里不能用t是因为,lambda表达式还没构造完t,因此看到不到。
           while (!Thread.currentThread().isInterrupted()) {
               System.out.println("hello Thread");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        t.start();
        Thread.sleep(3000);
        //把上述的标志位设置为true
        t.interrupt();
    }
}

执行程序后,并没有让我们的程序结束,而是出现了一个异常。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZidjOXRf-1692793532594)(C:\Users\28779\AppData\Roaming\Typora\typora-user-images\image-20230818175816376.png)]

我们可以理解成sleep被唤醒

一个程序可能处于正常运行状态,也可能处于Sleep状态,也可以称为阻塞状态,意思就是代码暂时不执行了。

重点:线程在阻塞过程中,如果其他线程调用interrupt方法,就会立即唤醒一个正在被阻塞的程序。但是sleep在被唤醒的同时,也会自动清除前面设置的标志位!! 唤醒后会给程序猿留下更多的操作空间.

此时,如果想添加其他的操作就可以在 catch 中编写新代码。如果想直接终止掉程序,只需要在 catch 中屏蔽掉异常,另加一个 break 即可。

多线程基础篇(包教包会)_第10张图片

这几种处理方式,都是比较温和的方式。另一个线程提出请求,本线程自己决定,是否要终止。更激进的做法是,这边提出请求,那边立即就结束,线程根本来不及反应。完全不考虑本线程的实际情况,就可能会造成一些负面的影响~

5. 等待一个线程

多线程基础篇(包教包会)_第11张图片

多个线程是并发执行的.具体的执行过程,都是由操作系统负责调度的!!!操作系统调度线程的过程,是"随机"的。无法确定线程执行的先后顺序。因此等待线程,就是一种规划 线程结束顺序 的手段。

回过头来再解释一下阻塞状态,顾名思义就是代码暂时不继续执行了(该线程暂时不去CPU上参与调度)

join 的阻塞,则是“死等” -> "不见不散"的那种。例如:t.join()表示t程序如果没执行完,则阻塞t.join所在的程序。

在计算机中,更推荐有时间限制的版本 join(long milis),留有余地。只要时间到了,不管来没来,都不等了。

join能否被interrupt唤醒?? 答案是可以的!!

sleep, join, wait…产生阻塞之后,都是可能被interrupt方法唤醒的,这几个方法都会在被唤醒之后自动清除标志位(和sleep类似的)

public class Demo10 {
    public static void main(String[] args) {
        
        //线程b
        Thread b = new Thread(()->{
            for (int i = 0; i < 5; i++) {
                System.out.println("hello b");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("b 结束了");
        });
        
        //线程a
        Thread a = new Thread(() ->{
            for (int i = 0; i < 3; i++) {
                System.out.println("hello a");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                //这里运用 b.join 来堵塞a程序
                //如果 b 此时还没执行完毕,b.join 就会产生阻塞的情况。
                //这里的join也会产生受查异常,需要try-catch
                b.join(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("a 结束了");
        });
        b.start();
        a.start();
    }
}

四、线程状态

之前谈到过线程的两个状态,一个是阻塞状态,另一个是就绪状态。这两个状态都是系统所设定的两个状态。在java中,把上述状态又进一步的细分出了6个状态。

  1. NEW: 安排了工作, 还未开始行动
  2. RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作.
    • 正在工作中:线程正在 CPU 上运行
    • 即将开始工作:线程正在排队,随时可以去 CPU 上执行
  3. BLOCKED: 这几个都表示排队等着其他事情(因锁产生的阻塞,后文后讲到)
  4. WAITING: 这几个都表示排队等着其他事情(因调用wait产生阻塞,后文会讲到)
  5. TIMED_WAITING: 这几个都表示排队等着其他事情(用 sleep(millis) 和 join(millis) 带时间参数的版本都会触发)
  6. TERMINATED: 工作完成了.

多线程基础篇(包教包会)_第12张图片

public class Demo1 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //System.out.println("执行完毕!!");
        });
        //安排了线程,但还未工作
        System.out.println("状态1;" + t.getState());

        t.start();
        //开始工作,正在执行中
        System.out.println("状态2:" + t.getState());

        Thread.sleep(1000);
        //排队等待中 
        System.out.println("状态3:" + t.getState());

        t.join();
        //线程结束,工作完成了
        System.out.println("状态4:" + t.getState());
    }
}
/*输出
    状态1;NEW
    状态2:RUNNABLE
    状态3:TIMED_WAITING
    状态4:TERMINATED
*/

五、线程安全问题(synchronized)(重点)

1. 观察线程不安全问题

观察下列代码

static class Counter {
    public int count = 0;
    public void increase() {
        count++;
   }
}
public static void main(String[] args) throws InterruptedException {
    final Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    
    t1.start();
    t2.start();
    
    t1.join();  //等待线程t1结束
    t2.join();	//等待线程t2结束
    
    System.out.println(counter.count);
}
/*输出
	64821       //输出任意数小于10W
*/

我们发现,如果按照正常逻辑来,两个线程针对同一个变量,进行循环自增,各自增 5w 次,预期最终结果应该是 10w,但实际上并不是!! 说明我们的代码有 bug!!

这里的 bug 是一个非常广义的概念,只要是实际运行效果和预期效果(需求效果)不一致,就可以称之是一个 bug.

在多线程下,发现由于多线程执行,导致的 bug,统称为“线程安全问题”如果某个代码,在单线程下执行没有问题,多个线程下执行也没问题,则称为“线程安全”,反之也可以称为“线程不安全”。


2.线程安全问题分析

那为啥会出现上述的 bug 呢??

问题出现在这里,count++ 看上去是一行代码,实际上在CPU角度上来说是执行了三步操作。

  1. 把内存中的数据,加载到CPU的寄存器中(load)
  2. 把寄存器中的数据进行+1 (add)
  3. 把寄存器中的数据写回到内存中(save)

多线程基础篇(包教包会)_第13张图片

如果上述的操作,在两个线程,或者更多个线程并发执行的情况下,就可能会出现问题!!

接下来我们可以通过时间轴,具体看一下问题出现在哪。

预期情况下,t1、t2 线程串行执行,t1完事后 t2 才开始。执行结果为正确。

多线程基础篇(包教包会)_第14张图片

多线程基础篇(包教包会)_第15张图片

若通常情况下,CPU针对这些线程的调度,是按照抢占式的方式进行调度的,因此这些命令的执行顺序可能会存在很多中方式。 因此这两组执行操作的相对顺序会存在很大差异!!

多线程基础篇(包教包会)_第16张图片

取其中的一个执行方法为例,虽然是自增两次,但是由于两个线程并发执行,就可能在一定的执行顺序下,导致运算的中间结果就被覆盖了。

在这5w次的循环过程中,有多少次,这俩线程执行++是"串行的”,有多少次会出现覆盖结果的??不确定!!线程的调度是随机的,抢占式执行的过程。

此处这里的结果就会出现问题,而且得到的这个错误值,一定是小于10w。

因此很多代码都会涉及到线程安全问题,不仅仅只是 count++.

3.线程安全问题的原因

1.[根本原因]多个线程之间的调度顺序是“随机的”,操作系统使用"抢占式"执行的策略来调度线程。

和单线程不同的是,多线程下,代码的执行顺序,产生了更多的变化。

以往只需要考虑代码在一个固定的顺序下执行,执行正确即可。现在则要考虑多线程下,N种执行顺序下,代码执行结果都得正确。

2.多个线程同时修改同一个变量.容易产生线程安全问题. 代码的结构

3.进行的修改,不是“原子的”。 此为切入线程安全问题,最主要的手段。

如果修改操作,能够按照原子的方式来完成,此时也不会有线程安全问题。

例如上述例子中,count++ 操作不是原子的。需要考虑到CPU 中的三步操作。

4.内存可见性,引起的线程安全问题。(后文讲解)

5.指令重排序,引起的线程安全问题。(后文讲解)

4.解决线程不安全问题

为了解决线程不安全问题,我们引入加锁这不操作。

其原理就相当于,把一组操作打包成一个“原子的操作”。但是与事务的那个原子不同。事务原子性,主要体现在“回滚”的操作。而这里的原子,则是通过锁,进行“互斥”,相当于我这个线程工作的时候,其他线程无法工作

通过这个锁,就限制了,同一时刻,只有一个线程能使用当前资源。

多线程基础篇(包教包会)_第17张图片

此时当t1线程进行访问时,就会对increase方法加锁。若果在t1加完锁后,t2又来试图访问加锁,t2就会阻塞等待!!这个阻塞一直会持续到t1把锁解放后,t2才能够加锁成功。

按照上述加锁方法,就相当于把 increase 方法中的 count++ 操作“打包成一个原子”。

因此就实现了把“穿插执行”变成了串行执行。

多线程基础篇(包教包会)_第18张图片

这里提出个问题:通过加锁使并发执行变为串行化执行,此时多线程还有存在的意义吗??

必然是有的,我们要知道串行化执行针对的是 count++ 操作,也就是线程中的 counter.increase() 方法,但是线程中不仅仅包含了这一句代码,还有 for 循环,因此线程之间还是存在并发执行的操作,也就是说多线程还是有意义的。

5.synchronized 关键字

java 给我们提供的加锁的方式(关键字)是搭配 代码块 来完成的~(进入代码块就加锁,出了代码块就解锁)。

多线程基础篇(包教包会)_第19张图片

synchronized 进行加锁 解锁,其实是以"对象"为维度进行展开的!!!

以下是 synchronized 锁的两种用法,的一种是第二种的简化,直接修饰方法,就相当于对 this 加锁。

多线程基础篇(包教包会)_第20张图片

这里非常关键:只要两个线程针对同一个对象进行加锁,就会出现 锁竞争/锁冲突,一个线程加锁成功,另一个线程阻塞等待。这里的锁对象,是任意对象都可以。锁对象和要访问的对象没有必然关联

反之两个线程针对不同对象进行加锁,就不会出现锁竞争。会出现“穿插执行”的线程不安全问题

线程安全案例

多线程基础篇(包教包会)_第21张图片

线程不安全案例

这里面锁对象是不同的,此时,就不会出现有阻塞等待,也不会有两个线程按照串行的方式执行。

多线程基础篇(包教包会)_第22张图片

6.总结

利用synchronized 锁的时候,代码执行流程如下。

多线程基础篇(包教包会)_第23张图片

六、内存可见性问题(volatile)

1.观察内存不可见问题

观察下面代码

public class Demo1 {
    public static int isQuite = 0;
    
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
           while (isQuite == 0) {
               ;
           }
            System.out.println("程序t1执行结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            isQuite = scanner.nextInt();
        });

        t1.start();
        t2.start();
    }
}

我们所期望的代码逻辑:t1始终在进行while循环,t2则是要让用户通过控制台输入一个整数,作为isQuit的值。当用户输入的仍然是0的时候,,t1线程继续执行。如果用户输入的非0,则t1线程就应该循环结束。

而实际上:即使t2线程修改了isQuite值,代码也不会结束,而是陷入无限循环状态。

问题出现了,当输入非0值的时候,已经修改了isQuit的值了。但是t1线程仍然在继续执行。这就是不符合预期的,也是bug

2.问题分析

为什么会出现上述 bug 呢??? 其根本原因就是 java 编译器的优化机制。

当我们写出来的代码程序运行时,java编译器和 jvm可能会对代码做出一些“优化”。

编译器优化,本质上是靠代码,智能的对你写的代码进行分析判断,进行调整。这个调整过程大部分情况下都是ok,都能保证逻辑不变但是,如果遇到多线程了,此时的优化可能就会出现差错!!! 会使用程序中原有的逻辑发生改变

多线程基础篇(包教包会)_第24张图片

对于上述代码中的 isQuite == 0 本质上其实是两步指令

  1. 第一步加载(load),读取到内存中的数据。 ->读内存操作,速度非常慢
  2. 第二步放在寄存器中操作(与0进行比较是否相等) ->寄存器操作,速度极快

此时,编译器/JVM就发现,这个逻辑中,代码要反复的,快速的读取同一个内存的值。并且,这个内存的值,每次读出来还是一样的~~
因此,编译器就做出一个大胆的决策,直接把 load 操作优化掉了,只是第一次执行load 。后续都不再执行load,直接拿寄存器中的数据进行比较了。

但是,万万没想到,程序猿有点不讲武德,搞偷袭,在另一个线程 t2 中,把内存中的 isQuite 给改了!

多线程基础篇(包教包会)_第25张图片

另一个线程中,并没有重复读取isQuit的值,而是只读寄存器中的值。因此 t1线程就无法感知到 t2 的修改。因此也就出现了上述内存不可见问题。

3.volatile关键字

编译器优化在上述代码中好心办坏事,算是编译器的 bug 吧。为了弥补这样的 bug ,volatile就由此诞生喽。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QJr1iGgv-1692793532600)(C:\Users\28779\AppData\Roaming\Typora\typora-user-images\image-20230822155210428.png)]

把volatile用来修饰一个变量之后,编译器就明白,这个变量是"易变"的,就不能按照上述方式,把读操作优化到读寄存器中.(编译器就会禁止上述优化)于是就能保证t1在循环过程中,始终都能读取内存中的数据!!

volatile本质上是保证变量的内存可见性.(禁止该变量的读操作被优化到读寄存器中).

多线程基础篇(包教包会)_第26张图片

4.总结

编译器优化后的 java 线程只能从寄存器中读数据

多线程基础篇(包教包会)_第27张图片

加上 volatile 后,就可以保证内存的可见性(非原子性)。从而线程就可以正常从内存中读数据。

多线程基础篇(包教包会)_第28张图片

编译器优化,其实是一个"玄学问题"。啥时候进行优化,啥时候不优化,咱们作为外行,有些摸不到规律~~

代码稍微改动一下,可能就不会触发上述优化~~ 比如说在while内加上个sleep就不会触发优化机制。(这里不给演示了)

七、wait 和 notify

wait 和 notify 也是多线程编程中的重要工具。多线程调度是随机的,有时候希望多个线程能够按照咱们规定的顺序来执行,完成线程间的配合工作。由此,wait 和 notify就闪亮登场了。wait 和 notify 通常都是搭配成对使用。

wait:等待. notify:通知. 我们可以按照字面意思来理解。

wait 和 notify ,都是由Object所提供的方法。因此随便找个对象,都可以使用 wait 和 notify.

在尝试使用 wait 的时候编译器出现提示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1giwhauV-1692793532602)(C:\Users\28779\AppData\Roaming\Typora\typora-user-images\image-20230822204100728.png)]

大概意思就是,在 wait 运行阻塞时,可能被 interrupted 给唤醒,需要捕获异常。

当我们添加完 try-catch 运行后,编译器报错:非法监视器状态(这里的监视器是指 synchronized 可以称为监视器锁)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2eDMtFRC-1692793532603)(C:\Users\28779\AppData\Roaming\Typora\typora-user-images\image-20230822204355344.png)]

这里为啥会报错呢??

wait 在执行的时候,会做三件事:

  • 解锁。 object.wait 会尝试针对object 对象解锁。
  • 阻塞等待。
  • 当被其他线程唤醒之后,就会尝试重新加锁。加锁成功, wait 执行完毕,继续往下执行其他逻辑。

在锁中无非就两种状态,要么加锁,要么解锁。

这里 wait 操作要解锁的前提,那就是把 wait 加锁。

核心思路:先加锁,然后在synchronized里头再进行wait!!

多线程基础篇(包教包会)_第29张图片

在运行过程中,我们通过 t1.getState() 观察线程状态发现,此线程正在 WAITING,阻塞等待中。

多线程基础篇(包教包会)_第30张图片

这里的 wait 就是一直阻塞到其他线程进行 notify 了。

notify 使用方法和 wait 差不多。直接上代码。

public class Demo2 {
    //public static Object locker;
    public static void main(String[] args) throws InterruptedException {

        Object locker = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (locker) {
                System.out.println("t1 wait 开始");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("t1 wait 结束");
            }
        });
        t1.start();
        Thread.sleep(1000);
        System.out.println(t1.getState());

        Thread t2 = new Thread(() -> {

            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            synchronized (locker) {
                System.out.println("t2 notify 开始");
                locker.notify();
                System.out.println("t2 notify 结束");
            }

        });
        t2.start();
    }
}

几个注意事项:

  1. 要想让 notify 能够顺利唤醒 wait,就需要确保 wait 和 notify 都是使用同一个对象调用的.

  2. wait 和 notify 都需要放到synchronized之内的。虽然 notify 不涉及"解锁操作"但是 java 也强制要求 notify 要放到 synchronized 中(系统的原生api中,没有这个要求)

  3. 如果进行 notify 的时候,另一个线程并没有处于wait状态。此时, notify 相当于"空打一炮",不会有任何副作用

小技巧:

如果就想唤醒某个指定的线程。就可以让不同的线程,使用不同的对象来进行wait 。想唤醒谁,就可以使用对应的对象来 notify。

1.wait 和 sleep 之间的区别

sleep是有一个明确的时间的。到达时间,自然就会被唤醒。也能提前唤醒,使用interrupt就可以。

wait 默认是一个死等,一直等到有其他线程notify。wait 也能够被 interrupt 提前唤醒。

notify 的唤醒是顺理成章的唤醒。唤醒之后该线程还需要继续工作后续还会进入到 wait 状态。

interrupt 的唤醒就相当于,告知线程要结束了。接下来线程就要进入到收尾工作了。

因此,协调多个线程之间的执行顺序,当然还是优先考虑使用 wait notify 而不是 sleep 。

你可能感兴趣的:(JavaEE,多线程)