【JavaEE】多线程(五)- 基础知识完结篇

多线程(五)

文章目录

上文我们主要讲了 synchronized以及线程安全的一些话题

可重入锁 => 死锁

  1. 一个线程,一把锁,连续加锁两次
  2. 两个线程两把锁
  3. N个线程N把锁,哲学家就餐问题♂

产生死锁的四个必要条件

  1. 互斥使用
  2. 不可抢占/剥夺
  3. 请求和保持 获取多把锁 获取第二把锁的时候 第一把锁不要释放
  4. 循环等待/环路等待

续上文,本篇我们继续聊多线程~

volatile关键字

保证内存可见性

计算机运行的代码/程序,经常要访问数据,这些依赖的数据,往往就存储在内存中。(也就是定义一个变量,变量就是存储在内存中)

cpu使用这个变量的时候,就会把这个内存数据,先读出来,放到cpu寄存器里面,在参与运算load

这里我们要注意:

  • cpu的读取内存操作,其实是非常慢的
  • cpu进行大部分操作都是很快的,但是一旦操作读/写内存,此时速度就会慢下来
  • 读内存 相比于 读硬盘,快几千倍,上万倍
  • 读寄存器,相比于读内存,又快了几千倍,上万倍

因此,为了解决上述问题,提高效率,此时编译器就可能对代码做出优化,把一些本来要读内存的操作,优化成读寄存器,减少读内存的次数,也就可以提高整体程序的效率了

见以下代码:

java 复制代码
//多线程引起  bug
public class Demo19 {
    private static int isQuit = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
           while (isQuit ==0){
               //循环体里啥都没干
               //此时意味着这个循环,一秒钟会执行很多次
           }
            System.out.println("t1 退出");
        });
        t1.start();

        Thread t2 = new Thread(()->{
            System.out.println("请输入 isQuit :>");
            Scanner scanner = new Scanner(System.in);
            //一旦用户输入的值,不为0,此时就会使t1线程结束
            isQuit = scanner.nextInt();
        });
        t2.start();
    }
}

这段代码我们的预期是:用户输入非 0 值之后,t1线程要退出~

但是当我们输入非 0 值之后,此时的t1线程并没有退出

我们可以通过jconsole来看看它此时的运行状态

很明显,实际效果和预期效果不一样。

这是由于多线程引起的bug.也是线程安全问题!!

之前是两个线程,同时修改同一个变量,现在是一个线程读,一个线程修改,也可能会有问题。

此处问题,实际上就是内存可见性情况引起的~

编译器的优化,初心其实是好的,希望能够提高程序的效率,但是优化错咯。因为提高效率的前提是要保证逻辑不变,但是此时由于修改isQuit代码是另外一个线程的操作, 编译器没有正确的判定,所以编译器以为没人修改isQuit,就做出上述的优化,也就导致bug了~

此时解决方案就是:volatile

在多线程环境下,编译器对于是否要进行这样的优化,判定不一定准,就需要我们通过volatile关键字,告诉编译器,你不要优化!(优化,是算的快了,但是算的不准了)

java 复制代码
public class Demo20 {
    private volatile static int isQuit = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while (isQuit ==0){
                //循环体里啥都没干
                //此时意味着这个循环,一秒钟会执行很多次
            }
            System.out.println("t1 退出");
        });
        t1.start();

        Thread t2 = new Thread(()->{
            System.out.println("请输入 isQuit :>");
            Scanner scanner = new Scanner(System.in);
            //一旦用户输入的值,不为0,此时就会使t1线程结束
            isQuit = scanner.nextInt();
        });
        t2.start();
    }
}

不过

java 复制代码
public class Demo19 {
    private static int isQuit = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while (isQuit ==0){
                //循环体里啥都没干
                //此时意味着这个循环,一秒钟会执行很多次
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 退出");
        });
        t1.start();

        Thread t2 = new Thread(()->{
            System.out.println("请输入 isQuit :>");
            Scanner scanner = new Scanner(System.in);
            //一旦用户输入的值,不为0,此时就会使t1线程结束
            isQuit = scanner.nextInt();
        });
        t2.start();
    }
}

此时没加volatile,但是给循环里加了个sleep

此时,t1线程是可以顺利退出的!

加了sleep之后,while循环执行速度就慢了.

由于次数少了,load操作的开销,就不大了.

因此,优化也就没必要进行了.

没有触发load的优化,也就没有触发内存可见性问题了.

到底啥时候代码有优化,啥时候没有?也说不清~~

使用volatile是更靠谱的选择


这里稍微总结一下:

内存可见性也是属于一种线程安全的情况。

这都是编译器进行代码优化搞出来的bug,代码优化是非常普遍的情况,编译器为了进一步提高代码的执行效率,会在保持逻辑不变的情况下,调整生成代码的内容。

但是如果是多线程的代码,代码优化就有可能会出现误判,优化之后的代码逻辑和之前的就不一样了~


其次,关于内存可见性,还涉及到一个关键概念

JMM(Java Memory Model)

Java内存模型 -> Java规范文档的叫法

JMM主要关注以下几个方面:

  1. 可见性(Visibility):保证一个线程对共享变量的修改对其他线程是可见的。当一个线程修改了一个共享变量的值后,其它线程能够看到这个修改。
  2. 原子性(Atomicity):保证对于一个共享变量的读写操作是原子性的,不会出现中间状态。
  3. 有序性(Ordering):保证程序执行的结果与源代码的顺序一致。对于一段代码的执行,可能会进行指令重排序优化,但是不能改变执行结果的顺序。

JMM使用了一些机制来实现这些特性,如内存屏障(Memory Barrier)、volatile关键字、锁、synchronized等。这些机制帮助Java编译器和运行时环境协同工作,以保证多线程程序的正确性。

理解JMM对于编写正确且高效的多线程程序非常重要。遵循JMM的规则可以避免在多线程程序中出现各种内存可见性、原子性和有序性的问题。

总结来说,JMM定义了Java程序在多线程环境下共享变量的访问规则,保证了多线程程序的正确性和可预测性。

volatilesynchronized都能对线程安全起到一定的积极作用,但是他们也是各司其职的,volatitl是不能保障原子性的~

volatilesynchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.

不保证原子性

看下面例子:

java 复制代码
public class VolatileExample {
    private static volatile int counter = 0;

    public static void main(String[] args) {
        new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter++;
            }
        }).start();

        new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter++;
            }
        }).start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Counter: " + counter);
    }
}

在上面的例子中,我们有两个线程对 counter 变量进行递增操作。counter 被声明为 volatile,所以每个线程都能够立即看到对 counter 的修改。

但是,由于 counter++ 不是一个原子操作,而是由读取变量、加1、写回变量三个步骤组成。在多线程环境下运行时,一个线程对 counter 的修改可能被另一个线程打断,导致数据不一致的问题。

比如,一个线程读取了 counter 变量的值为10,准备将其加1变为11,但这时被另一个线程打断,修改为11的 counter 写回变为10,然后再将其加1变为11。

由于 volatile 不能保证多个线程同时对同一个变量进行原子操作,所以在上面的代码中,最终打印的结果可能会小于预期的2000。

如果需要保证变量的原子性,可以使用原子类(比如 AtomicInteger)或加锁机制(比如 synchronizedLock)。这些机制能够确保对变量的修改是原子性的,从而避免了竞态条件和数据不一致性的问题。

总结来说,虽然 volatile 关键字可以保证变量的可见性和禁止指令重排序,但它并不能提供变量操作的原子性。如果需要保证原子性,应该使用原子类或加锁机制。


wait 和 notify

多线程中比较重要的机制~是用来协调多个线程的执行顺序

因为本身多个线程的执行顺序是随机的(系统随机调度,抢占式执行的)

所以很多时候,我们希望能够通过一定的手段,协调的执行顺序。

比如说join,它是影响到线程结束的先后顺序,但是相比之下,此处是希望线程不结束,也能够有先后顺序的控制。

wait:等待,让指定线程进入阻塞状态

notify:通知,唤醒对应的阻塞状态的线程


join等待的过程和"主线程"没有直接的联系,哪个线程调用join哪个线程就阻塞。

java 复制代码
public class Demo18 {
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5; i++) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 结束!");
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5; i++) {
                try {
                    t1.join();
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t2 结束!");
        });
        t1.start();
        t2.start();
        System.out.println("主线程结束!");
    }
}

waitnotify都是Object的方法

随便定义一个对象都可以wait notify

wait()

我们先给一个示例代码:

java 复制代码
public class Demo19 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
            System.out.println("wait 之前");
            object.wait();
            System.out.println("wait 之后");
    }
}

然而这里会报错:

IllegalMonitorStateException非法的 监视器 异常

而什么是监视器呢?

synchronized:也叫做监视器锁

wait 在执行要做的三件事情:

~公平,公平,还是他妈的公平!(buhsi)~

  • 释放当前的锁

  • 让线程进入阻塞

  • 当线程被唤醒, 重新尝试获取这个锁.

修改代码:

java 复制代码
public class Demo19 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object) {
            System.out.println("wait 之前");
            //把 wait 放入 synchronized 里面来调用,保证确实是拿到锁
            object.wait();
            // wait 会持续地阻塞等待下去,直到其他线程调用 notify 唤醒

            System.out.println("wait 之后");
        }
    }
}

所以这串的代码的wait,就会持续等待,直到其他线程调用notify唤醒


wait除了默认的无参数版本之外,还有一个带参数的版本.

带参数的版本就是指定超时时间,

避免wait无休止的等待下去

notify()

先看示例代码:

java 复制代码
// notify 唤醒
public class Demo20 {
    public static void main(String[] args) {
        Object object = new Object();
        Thread t1 = new Thread(()->{
            synchronized (object){
                System.out.println(" wait 之前");
                try {
                    object.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(" wait 之后");
            }
        });

        Thread t2 = new Thread(()->{
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (object){
                System.out.println(" 进行通知 ");
                object.notify();
            }
        });
        t1.start();
        t2.start();
    }
}
  • 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。

  • 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到")

  • notify()方法后,当前线程不会马上释放该对象锁,要等到执行~方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。


线程饿死

使用wait notify可以避免线程饿死~

针对上述情况,同样也可以使用wait notify来解决

可以让1号loopy,在发现没钱的时候,就进行waitwait内部本身就会释放锁,并且进入阻塞)

那么1号loopy就不会参与后续的竞争了,也把锁释放出来让别人取,就给其他的loopy提供了机会~

wait的过程是等,等待运钞车将钱送过来,运钞车的线程就相当于调用notify唤醒的线程,这个等的状态时阻塞的,什么都不做,也就不会占据cpu


当线程调用了一个对象的 wait 方法时,它进入了该对象的等待集(wait set),并释放了持有的锁。

在这里,我们假设有多个线程都在等待这个对象上。

  • 当另一个线程调用了相同对象的 notify 方法时,它会随机选择一个线程,从等待集中唤醒一个线程,使其从等待状态转移到可运行状态。被唤醒的线程会重新尝试获取锁,并从 wait 方法返回继续执行。

  • notifyAll 方法则会唤醒所有在等待集中的线程,使它们从等待状态转移到可运行状态。每个被唤醒的线程都会尝试重新获取锁,并从 wait 方法返回继续执行。

    在唤醒的时候,wait要涉及一个重新获取锁的过程,也是需要串行执行的。

这种等待和唤醒的机制通常用于线程间的协作和同步。例如,当一个线程需要等待某个条件满足时,它可以调用对象的 wait 方法,而其他线程则可以在某个条件满足时调用 notifynotifyAll 方法来唤醒等待的线程。

需要注意的是,waitnotifynotifyAll 都必须在同步代码块(synchronized)或同步方法中使用,以确保线程的安全性和正确性。

因此,综上,虽然提供了notifyAll,但是相比之下notify更可控,使用的频率高一些。


至此,多线程的基础知识就介绍到这里,接下来会详细聊聊多进程的进阶,敬请期待~

相关推荐
Abladol-aj22 分钟前
并发和并行的基础知识
java·linux·windows
清水白石00822 分钟前
从一个“支付状态不一致“的bug,看大型分布式系统的“隐藏杀机“
java·数据库·bug
吾日三省吾码6 小时前
JVM 性能调优
java
弗拉唐7 小时前
springBoot,mp,ssm整合案例
java·spring boot·mybatis
oi777 小时前
使用itextpdf进行pdf模版填充中文文本时部分字不显示问题
java·服务器
少说多做3438 小时前
Android 不同情况下使用 runOnUiThread
android·java
知兀8 小时前
Java的方法、基本和引用数据类型
java·笔记·黑马程序员
蓝黑20208 小时前
IntelliJ IDEA常用快捷键
java·ide·intellij-idea
Ysjt | 深8 小时前
C++多线程编程入门教程(优质版)
java·开发语言·jvm·c++