多线程的学习中篇下

请添加图片描述

volatile 关键字

volatile 能保证内存可见性
volatile 修饰的变量, 能够保证 “内存可见性”

示例代码:

image-20230927234356426

运行结果:
image-20230927234904570

当输入1(1是非O)的时候,但是t1这个线程并沿有结束循环,
同时可以看到,t2这个线程已经执行完了,而t1线程还在继续循环.

这个情况,就叫做内存可见性问题 ~~ 这也是一个线程不安全问题(一个线程读,一个线程改)

while (myCounter.flag == 0) { // 循环体空着,什么也不做 }

这里使用汇编来理解,大概就是两步操作:

  1. load, 把内存中 flag 的值,读取到寄存器里.
  2. cmp, 把寄存器的值,和0进行比较,根据比较结果,决定下一步往哪个地方执行(条件跳转指令).

上述是个循环,这个循环执行速度极快,一秒钟执行百万次以上…
循环执行这么多次,在线程 t2 真正修改之前, load得到的结果都是一样的;
另一方面, load操作和cmp操作相比,速度慢非常非常多!!!

注:
CPU针对寄存器的操作,要比内存操作快很多,快3-4数量级;
计算机对于内存的操作,比硬盘快3-4个数量级.

由于 load 执行速度太慢(相比于cmp来说),再加上反复 load 到的结果都一样, JVM 就做出了一个非常大胆的决定 ~~ 判定好像没人改 flag 值,不再真正的重复 load 了,干脆就只读取一次就好了 => 编译器优化的一种方式.
实际上是有人在修改的,但是 JVM/编译器 对于这种多线程的情况,判定可能存在误差.
此时,就需要我们手动干预了,可以给 flag 这个变量加上 volatile 关键字,意思就是告诉编译器,这个变量是"易变"的,要每次都重新读取这个变量的内存内容,不能再进行激进的优化了.
博主感慨: 快和准之间往往不可兼得

内存可见性问题

一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值,不一定是修改之后的值;这个读线程没有感知到变量的变化;
归根结底是 编译器/JVM 在多线程环境下优化时产生了误判了.
备注:
(1)上述说的内存可见性编译器优化的问题,也不是始终会出现的(编译器可能存在误判,也不是100%就误判!);
(2)编译器的优化,很多时候是“玄学问题”,应用程序这个角度是无法感知的.编译器的优化,很多时候是“玄学问题”,应用程序这个角度是无法感知的.

代码改正:

class MyCounter {
    
    
    volatile public int flag = 0;
}

运行结果:

image-20230928004924362

注意事项:
(1) volatile 只能修饰变量;
(2) volatile 不能修饰方法的局部变量,局部变量只能在你当前线程里面用,不能多线程之间同时读取/修改(天然就规避了线程安全问题);

(1)局部变量只能在当前方法里使用的,出了方法变量就没了,方法内部的变量在"栈”这样的内存空间上;
(2)每个线程都有自己的栈空间,即使是同一个方法,在多个线程中被调用,这里的局部变量也会处在不同的栈空间中,本质上是不同变量,也就涉及不到修改/读取同一个变量的操作;
(3)栈记录了方法之间的调用关系;
个人理解: 局部变量只对当前线程可见,其他线程看不了.

(3) 如果一个变量在两个线程中,一个读,一个写,就需要考虑volatile 了;
(4) volatile 不保证原子性,原子性是靠 synchronized 来保证的. synchronized 和 volatile 都能保证线程安全 => 不能使用 volatile 处理两个线程并发++这样的问题;
(5) 如果涉及到某个代码,既需要考虑原子性,有需要考虑内存可见性,就把 synchronized 和 volatile 都用上就行了.


从 JMM 的角度重新表述内存可见性问题

内存可见性问题,其他的一些资料,谈到了JMM(Java Memory Mode ~~ Java内存模型)
从 JMM 的角度重新表述内存可见性问题(Java的官方文档的大概表述):
Java 程序里,主内存,每个线程还有自己的工作内存(线程 t1 的和线程 t2 的工作内存不是同一个东西);
线程 t1 进行读取的时候,只是读取了工作内存的值;
线程 t2进行修改的时候,先修改的工作内存的值,然后再把工作内存的内容同步到主内存中,但是由于编译器优化,导致线程 t1没有重新的从主内存同步数据到工作内存,读到的结果就是“修改之前"的结果.

如果把"主内存”代替成"内存",把“工作内存"代替成"CPU寄存器",就容易理解.
注: 之所以上面这段话这么晦涩,是翻译不行,翻译官得背锅 ~~ 翻译的结果让人误会了!!!
主内存: main memory => 主存,也就是平时所说的内存
工作内存: work memory =>工作存储区,并非是所说的内存,而是CPU上存储数据的单元(寄存器)

为什么Java这里,不直接叫做“CPU寄存器",而是专门搞了"工作内存”说法呢?

这里的工作内存,不一定只是CPU的寄存器,还可能包括CPU的缓存cache.

image-20230928013620585

当CPU要读取一个内存数据的时候,可能是直接读内存也可能是读cache还能是读寄存器…
引入cache之后,硬件结构就更复杂了,工作内存(工作存储区): CPU寄存器 + CPU的cache;
一方面是为了表述简单,另一方面也是为了避免涉及到硬件的细节和差异,Java里就使用"工作内存"这个词来统称(泛指)了;毕竟,现实中有的 CPU 可能没有 cache, 有的 CPU 有;有的 CPU 可能有一个cache,还可能有多个;现代的 CPU 普遍是3级cache, L1, L2, L3,总之,情况多样.
注: 学校的"计算机系统结构”会讲解CPU内部的结构,尤其是寄存器, cache,指令等等,上这门课的时候要好好听讲.

wait 和 notify

线程最大的问题,是抢占式执行,随机调度~~
程序猿写代码,不喜欢随机,喜欢确定的东西,于是发明了一些办法,来控制线程之间的执行顺序,虽然线程在内核里的调度是随机的,但是可以通过一些 API 让线程主动塞,主动放弃CPU(给别的线程让路).
比如,t1,t2 两个线程,希望t1先干活,干的差不多了,再让t2来干.就可以让t2先wait(阻塞,主动放弃CPU)等t1干的差不多了,再通过notify 通知t2,把t2唤醒,让t2接着干.

那么上述场景,使用 join 或者 sleep行不行呢?

使用join,则必须要t1彻底执行完,t2才能运行.如果是希望t1先干50%的活,就让t2开始行动,join无能为力.
使用sleep,指定一个休眠时间的,但是t1执行的这些活,到底花了多少时间,不好估计.
使用wait和notify可以更好的解决上述的问题.

注: wait, notify, notifyAll 这几个类,都是Object类(Java里所有类的祖宗)的方法.Java里随便new个对象,都可以有这三个方法!!

wait

wait 进行阻塞.某个线程调用wait方法,就会进入阻塞(无论是通过哪个对象 wait的),此时就处在WAITING状态.
wait 不加任何参数,就是一个"死等"一直等待,直到有其它线程唤醒它.
示例代码:

public class ThreadDemo16 {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
            Object object = new Object();
            object.wait();
    }
}

throws InterruptedException : 这个异常,很多带有阻塞功能的方法都带.这些方法都是可以 interrupt 方法通过这个异常给唤醒的.

运行结果:

image-20230927204141554

IllegalMonitorStateException
~~ 非法的锁状态异常
~~ 锁的状态,无非就是被加锁的状态和和被解锁的状态.

为什么有这个异常,要先理解 wait 的操作是干什么了.
1.先释放锁
2.进行阻塞等待
3.收到通知之后,重新尝试获取锁,并且在获取锁后,继续往下执行.

这里锁状态异常,就是没加锁呢,就想着释放锁.就好比单身着呢,就想着分手.

public static void main(String[] args) throws InterruptedException {
    
    
      Object object = new Object();
      synchronized (object) {
    
    
            System.out.println("wait 之前");
            object.wait();
            System.out.println("wait 之后");
      }
 }

image-20230927205029017

虽然这里wait是阻塞了,阻塞在 synchronized 代码块里,实际上,这里的阻塞是释放了锁的,此时其他线程是可以获取到object这个对象的锁的,此时这里的阻塞,就处在WAITING状态.

image-20230928094946950

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

如果代码这里写作 t1.start 和 t2.start 由于线程调度的不确定性,此时不能保证一定是先执行 wait ,后执行notify. 如果调用notify,此时没有线程wait,此处的wait是无法被唤醒的!!!(这种通知就是无效通知).
因此此处的代码还是要尽量保证先执行wait后执行notify才是有意义的.

改正的代码:

   public static void main(String[] args) throws InterruptedException {
    
    
        Object object = new Object();
        Thread t1 = new Thread(() -> {
    
    
            // 这个线程负责进行等待
            System.out.println("t1: wait 之前");
            try {
    
    
                synchronized (object) {
    
    
                    object.wait();
                }
            } catch (InterruptedException e) {
    
    
                throw new RuntimeException(e);
            }
            System.out.println("t2: wait 之后");
        });
        Thread t2 = new Thread(() -> {
    
    
            System.out.println("t2: notify 之前");
            synchronized (object) {
    
    
                // notify 务必要获取到锁,才能进行通知
                object.notify();
            }
            System.out.println("t2: notify 之后");
        });

        t1.start();
        // 此处写的 sleep 500 是大概率会让 t1 先执行 wait 的
        // 极端情况下,电脑特别卡的时候, 可能线程的调度时间就超过了 500 ms
        // 还是可能 t2 先执行 notify
        Thread.sleep(500);
        t2.start();
    }

运行结果:

image-20230927214510375

此处,先执行了wait,很明显wait操作阻塞了,没有看到wait之后的打印;
接下来执行到了t2, t2进行了notify的时候,才会把t1的wait唤醒.t1才能继续执行.
只要t2不进行notify,此时t1就会始终wait下去(死等).

wait无参数版本,就是死等的.
wait带参数版本,指定了等待的最大时间.

wait的带有等待时间的版本,看起来就和sleep有点像.其实还是有本质差别的:
虽然都是能指定等待时间,也都能被提前唤醒(wait是使用notify 唤醒, sleep使用interrupt唤醒)但是这里表示的含义截然不同.
notify唤醒wait,这是不会有任何异常的.(正常的业务逻辑),interrupt唤醒sleep 则是出异常了(表示一个出问题了的逻辑).

如果当前有多个线程在等待object对象,此时有一个线程 object.notify(),此时是随机唤醒一个等待的线程.(不知道具体是哪个),但是,可以用多组不同的对象来控制线程的执行顺序.
比如,有三个线程,希望先执行线程1,再执行线程2,再执行线程3,
创建obj1,供线程1,2使用创建obj2,供线程2,3使用线程3, obj2.wait
线程2.obj1.wait(),唤醒之后执行obj2.notify()
线程1执行自己的任务,执行完了之后,obj1.notify即可.

notifyAll和notify非常相似.
多个线程 wait 的时候, notify随机唤醒一个, notifyAll 所有线程都唤醒,这些线程再一起竞争锁…

猜你喜欢

转载自blog.csdn.net/m0_73740682/article/details/133382084