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

2023-11-10


前言:平时我们敲的代码,当点击运行程序的时候,就会先创建出一个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!");
        }
    }
}

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

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

image-20230818120859577

1.Jconsole观察线程

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

  1. idea 的调试器

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

  2. jconsole

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

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

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

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

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

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

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


2.线程休眠-sleep

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

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

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

但是 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 的常见构造方法

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

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

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已经执行完了!!

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


2. Thread 的几个常见属性

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

  • 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() 代替自定义标志位.

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

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 即可。

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

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

5. 等待一个线程

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

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

回过头来再解释一下阻塞状态,顾名思义就是代码暂时不继续执行了(该线程暂时不去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: 工作完成了.

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

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)

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

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

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

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

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

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

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

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

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

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

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

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

3.线程安全问题的原因

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

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

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

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

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

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

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

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

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

4.解决线程不安全问题

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

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

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

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

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

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

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

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

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

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

5.synchronized 关键字

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

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

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

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

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

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

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

线程安全案例

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

线程不安全案例

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

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

6.总结

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

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

六、内存可见性问题(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,都能保证逻辑不变但是,如果遇到多线程了,此时的优化可能就会出现差错!!! 会使用程序中原有的逻辑发生改变

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

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

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

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

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

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

另一个线程中,并没有重复读取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本质上是保证变量的内存可见性.(禁止该变量的读操作被优化到读寄存器中).

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

4.总结

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

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

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

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

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

代码稍微改动一下,可能就不会触发上述优化~~ 比如说在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!!

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

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

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

这里的 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 相当于"空打一炮",不会有任何副作用

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

多线程基础篇(包教包会) 的相关文章

  • 数据结构与算法笔记2(线性表)

    1 线性表 1 1线性表是一种逻辑关系 见绪论 1 2定义 是具有相同类型的n个元素的有限序列 其中n为表长 n 0时为空表 关键词 相同类型 一般处理的数据元素都是相同类型 比如一个人那么都是人 而不会把人与车放在一起 关键词 有限序列

随机推荐

  • Java泛型知识点整理

    Java泛型知识点整理 Java泛型 泛型提供了编译时类型安全检测机制 该机制允许程序员在编译时检测到非法的类型 泛型的本质是参数化类型 也就是说所操作的数据类型被指定为一个参数 比如我们要写一个排序方法 能够对整型数组 字符串数组甚至其他
  • ConcurrentHashMap为什么是线程安全的?

    1 ConcurrentHashMap的原理和结构 我们都知道Hash表的结构是数组加链表 就是一个数组中 每一个元素都是一个链表 有时候也把会形象的把数组中的每个元素称为一个 桶 在插入元素的时候 首先通过对传入的键 key 进行一个哈希
  • MapReduce的Job提交流程

    编写一个简单的WordCount程序 Mapper import org apache hadoop io LongWritable import org apache hadoop io Text import org apache ha
  • Tip of the Week #49: Argument-Dependent Lookup

    Tip of the Week 49 Argument Dependent Lookup Originally posted as totw 49 on 2013 07 14 whatever disappearing trail of i
  • 深度学习技术在自动驾驶中的应用与挑战

    导读 深度学习技术经过近几年井喷式的发展 在很多领域都得到了广泛的应用 在自动驾驶系统中 深度学习技术也起到了至关重要的作用 同时也面临着非常多的挑战 我们一直在探索 在一个安全 稳定的自动驾驶产品中 深度学习技术应该有着怎样的作用边界 又
  • Unable to load configuration的解决方法

    最近在学Struts2 5 5 因为喜欢用最新的 并且之前没有学习过的经验 就按照一个网上的博客跟着做一个小实例 里面说直接用 Struts2 5 5中自带例子的struts xml文件 结果我就用了 然后写了一个小程序就一直报 Unabl
  • Java中的对象是什么?

    Java是一种面向对象的编程语言 它将世界视为具有属性和行为的对象的集合 Java的面向对象版本非常简单 它是该语言几乎所有内容的基础 因为它对Java非常重要 所以我将对幕后内容进行一些解释 以帮助任何不熟悉Java的人 遗产 通常 所有
  • 奇安信笔试编程题完整解析附代码

    昨天晚上奇安信笔试 两道编程题做的都不好 有紧张的元素 也有自己实力不够硬的问题 总之把两道编程题又做了一遍 思路屡清楚 下次继续努力 其实两道题非常非常简单 如果放在高中数学 基本就是送分题了 但是最近疫情期间 在家都躺退化了 算了 开搞
  • Android性能优化大法——内存优化

    作者 layz4android 内存 是Android应用的生命线 一旦在内存上出现问题 轻者内存泄漏 重者直接crash 因此一个应用保持健壮 内存这块的工作是持久战 而且从写代码这块就需要注意合理性 所以想要了解内存优化如何去做 要先从
  • jqury ajax 提交from conflict,JQuery中使用ajax提交表单遇到的问题

    今天在做维护时 遇到一段JQuery旧代码 看得很纠结 大致结构如下 html代码 javascript代码 mySubmit click function doSubmit 提交 function doSubmit myForm subm
  • 探讨STOS指令

    转载在http hi baidu com darks00n blog item 4c019ec42ad0cdcad00060b1 html 下面是一段win32 console程序 Debug版 的反汇编代码 很程式化的东西 本文不讨论这段
  • Chromium Win10 开发环境搭建

    记录chromium 开发搭建过程 系统 软件环境不同 所遇问题可能不同 但主体关键相似 仅供参考 VS 安装 安装vs2019 the version 10 0 19041 or higher Windows 10 SDK install
  • idea maven项目运行不了,好多包导不了

    其实是idea默认给你选择了自带的maven和仓库 你可以改成你自己的 使用国内镜像就可以了 先简单记录一下 到时再详细写
  • 使用php 实现生成Excel文件并导出

    在现在的项目里 不管是电商项目还是别的项目 在管理端都会有导出的功能 比方说订单表导出 用户表导出 业绩表导出 这些都需要提前生成excel表 然后在导出 实际上是在代码里生成一张excel表 然后通过下载api进行导出的 好了 先给大家讲
  • promise跟ajax区别,Promise和AJAX有什么区别?

    你感到困惑的承诺和Ajax调用 它们有点像苹果和刀子 你可以用刀切苹果 刀是可以应用于苹果的工具 但这两者是非常不同的东西 承诺是管理异步操作的工具 他们会跟踪异步操作何时完成以及结果如何 并让您与其他代码或其他异步操作协调完成以及这些结果
  • 嵌入式学习--vi的基本命令二

    嵌入式学习 vi的基本命令二 vi查找命令 vi替换命令 vi复制和剪切命令 vi查找命令 string 查找字符串string n继续向下查找 N向上查找 按回车后 光标的位置直接跳转到字符char的前面 vi替换命令 范围 s 旧str
  • 2021 字节跳动面试总监首发 1121 道 LeetCode 算法刷题笔记(含答案)

    关于算法刷题的困惑和疑问也经常听朋友们提及 这份笔记里面共包含作者刷 LeetCode 算法题后整理的数百道题 每道题均附有详细题解过程 很多人表示刷数据结构和算法题效率不高 甚是痛苦 有了这个笔记的总结 对校招和社招的算法刷题帮助之大不言
  • Windows与网络基础-1-2-虚拟机安装Windows10/ server2016

    目录 一 下载虚拟机软件 1 1新建虚拟机 1 2选择操作系统类型和windows版本 1 3自定义虚拟机名称 1 4设置最大磁盘大小 1 5配置内存和处理器 1 6挂载镜像文件 1 7进入window配置界面 二 点击进去按步骤安装即可
  • 常用邮箱工具类

    废话不多说 直接上代码 自个耍 package com example demo util import javax mail import javax mail internet InternetAddress import javax
  • 多线程基础篇(包教包会)

    文章目录 一 第一个多线程程序 1 Jconsole观察线程 2 线程休眠 sleep 二 创建线程 三 Thread类及常见方法 1 Thread 的常见构造方法 2 Thread 的几个常见属性 3 启动线程 start 4 中断线程