JavaEE-多线程初阶3

✏️作者:银河罐头
系列专栏:JavaEE

“种一棵树最好的时间是十年前,其次是现在”

目录

  • volatile关键字
  • wait 和 notify
  • 多线程案例
    • 单例模式
      • 饿汉模式
      • 懒汉模式

volatile关键字

volatile : 易变的,易失的

volatile和内存可见性是密切相关的。

class MyCounter{
    public int flag = 0;
}
public class ThreadDemo{
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();
        Thread t1 = new Thread(()->{
            while(myCounter.flag == 0){
                //
            }
            System.out.println("t1 循环结束");
        });
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            myCounter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

JavaEE-多线程初阶3_第1张图片

JavaEE-多线程初阶3_第2张图片

这就和预期不符。

这个情况就是“内存可见性问题”,这也是一个线程不安全问题。

while(myCounter.flag == 0){
     //
}
//这里使用汇编来理解,大概就是2步操作,
//1. load , 把内存中 flag 的值,读取到寄存器里
//2.  cmp , 把寄存器的值和0进行比较,根据比较结果,决定下一步往哪个方向执行

这个循环执行速度极快,一秒钟执行百万次以上。

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

循环执行这么多次,在t2真正修改之前, load得到的结果都是一样的,load和cmp相比速度慢很多。

由于load速度比cmp慢太多+反复load得到的结果是一样的。JVM就不再真正的重复load了,判定好像没人改flag值,干脆就只读取一次(编译器优化的一种方式)

内存可见性:一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改。此时读到的值不一定是修改之后的值,这个读线程没有感知到变量的变化。(归根结底是编译器/JVM在多线程环境下优化时产生误判了)

此时就需要人为手动干预,可以给flag这个变量加上volatile关键字,意思是告诉编译器,这个变量是“易变的”,一定要每次都重新读取这个变量的内存内容,不确定什么时候就变了,不能再进行激进的优化。

volatile public int flag = 0;

JavaEE-多线程初阶3_第3张图片

volatile只能修饰变量。

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

方法内部的变量在“栈”这样的空间上。每个线程都有自己的栈空间。即使是同一个方法,在多个线程中被调用,这里的局部变量也会处在不同的栈空间中,本质上是不同变量。

栈就是记录了方法之间的调用关系。

上述说的内存可见性 编译器优化问题,也不是始终会出现的(编译器可能会误判,也不是100%误判)

class MyCounter{
    public int flag = 0;
}
public class ThreadDemo {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();
        Thread t1 = new Thread(()->{
            while(myCounter.flag == 0){
                //休眠
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 循环结束");
        });
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            myCounter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}
//去掉volatile,在循环体加休眠控制循环速度
//输出结果:
请输入一个整数:
1
t1 循环结束

编译器的优化,往往是无法预期的,所以稳妥点加上volatile

内存可见性问题,其他的一些资料,谈到 JMM,Java Memory Model , Java内存模型。

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

Java程序里,主内存,每个线程还有自己的工作内存( t1 的 t2 的工作内存不是一个东西)

t1 线程进行读取的时候,只是读取了工作内存的值

t2 线程进行修改的时候,先修改的工作内存的值,然后再把工作内存的内容同步到主内存中。

但是由于编译器优化,t1没有重新的从主内存同步数据到工作内存,读到的结果就是修改之前的结果。

主内存:main memory , 主存,也就是内存

工作内存: work memory => 工作存储区 (并非所说的内存,而是CPU上存储数据的单元,寄存器)

为啥Java这里不直接叫CPU寄存器,而搞了个“工作内存”这样的说法呢?

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

CPU读取寄存器,速度比读取内存快多了,因此就会在CPU内部引入缓存 cache

寄存器存储空间小,读写速度快,贵;

中间搞了个 cache ,存储空间居中,读写速度居中,成本居中;

内存存储空间大,读写速度慢,便宜(相对于寄存器来说)

当CPU要读取一个内存数据时,可能是直接读内存,也可能是读cache,还可能是读寄存器。

引入cache之后,硬件结构就更复杂了。

工作内存(工作存储区):CPU寄存器+CPU的cache

一方面是为了表述简单,一方面也是为了避免涉及到硬件的细节和差异。Java里就使用工作内存这个词给他涵盖了

有的CPU可能没有cache,有的有;

有的CPU可能有1个cache,还可能多个;

现代CPU普遍是3级cache,L1,L2,L3…

volatile不保证原子性,原子性是靠synchronized来保证的。synchronized和volatile都能保证线程安全。

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,无法精确控制好休眠时间。

使用wait和notify可以更好的解决上述问题。

可以认为 wait和notify比join功能更强,涵盖了join的用途,但是wait和notify使用起来要比join麻烦

wait, notify, notifyAll 都是 Object 类的方法。所有类都默认继承Object类。所以所有类都有这3个方法。

wait进行阻塞。某个线程调用wait方法,就会进入阻塞(无论是通过哪个对象wait),此时就处在WAITING

InterruptedException
//这个异常,很多带有阻塞功能的方法都带
//这些方法都是可以被 interrupt 方法通过这个异常给唤醒的
public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        object.wait();
        //wait 不带任何参数,就是一个死等,一直等待,等到有其他线程唤醒它
    }
}

JavaEE-多线程初阶3_第4张图片

锁的状态无非就是被加锁和被解锁

wait操作:

1.先释放锁

2.进行阻塞等待

3.收到通知之后,重新尝试获取锁,并且在获取锁之后继续向下执行

对于上述代码而言,这还没加锁怎么释放锁?!

所以,wait需要搭配synchronized使用

有关wait操作这里,举个形象的例子。

有一天张三去银行ATM机取钱,发现ATM机没钱了,只能等运钞机运钱过来。ATM也有个锁,只能等进去的人解锁,下一个人才能进去用。张三后面还排着很多人也要用ATM机。此时张三只能解锁(先释放锁)出来等(进行阻塞等待)运钞机。运钞机来了,操作之后ATM能取钱了(这里张三相当于收到通知了),然后张三再次进去ATM机取钱(重新尝试获取锁)。

JavaEE-多线程初阶3_第5张图片

public class ThreadDemo {
    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) {
                e.printStackTrace();
            }
            System.out.println("t1: wait 之后");
        });
        Thread t2 = new Thread(()->{
            System.out.println("t2: notify 之前");

            synchronized (object) {
                //notify要先获取到锁才能进行通知
                object.notify();
            }
            System.out.println("t2: notify 之后");
        });
        t1.start();
        Thread.sleep(500);
        t2.start();
    }
}

JavaEE-多线程初阶3_第6张图片

JavaEE-多线程初阶3_第7张图片

wait无参数,死等

wait带参数,指定了等待的最大时间

wait带参数版本和sleep有点类似,

wait是使用notify唤醒,sleep是用interrupt唤醒。

notify唤醒wait不会出现异常,而interrup唤醒sleep是出异常了

如果当前有多个线程在等待object对象,此时有一个线程object.notify(),此时是随机唤醒一个等待的线程

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

public class ThreadDemo {
    //有 3 个线程,分别只打印 A , B , C ,控制 3 个线程固定按照 ABC 的顺序来打印
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(()->{
            System.out.println("A");
            synchronized (locker1){
                locker1.notify();
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (locker1){
                try {
                    locker1.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("B");
            synchronized (locker2){
                locker2.notify();
            }
        });
        Thread t3 = new Thread(()->{
            synchronized (locker2){
                try {
                    locker2.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("C");
        });

        t2.start();
        t3.start();
        Thread.sleep(100);
        t1.start();
        //保证t2和t3的wait都执行完了,再执行t1的notify
    }
}

多线程案例

单例模式

单例模式是设计模式的一种。

单例模式,单个实例(对象)。

在有些场景中,有的特定的类,只能创建出一个实例,不应该创建多个实例。使用了单例模式后,此时想创建多个实例都难。

单例模式,就是针对上述的需求场景进行了个更强制的保证,通过巧用Java的现有语法,达成了某个类只能被创建出一个实例这样的效果(如果不小心创建出多个实例就会编译报错)

JDBC,DataSource 这样的类,其实就非常适合于用单例模式

在 Java 里实现单例模式的方式有很多种。

下面介绍最常见的 2 种

1)饿汉模式

2)懒汉模式

饿汉模式

// 饿汉模式 的 单例模式 实现
//此处保证 Singleton 这个类只能创建出一个实例
class Singleton{
    //在此处,先把这个实例给创建出来了
    private static Singleton instance = new Singleton();
    //如果需要使用这个唯一实例,只能通过 Singleton.getInstance()方式进行获取
    public static Singleton getInstance(){
        return instance;
    }
    private Singleton(){}
    //为了避免 Singleton 这个类不小心被复制出多个来
    //把构造方法设置为 private , 这样在类外就无法通过 new 的方式创建出 Singleton 的实例
}
public class ThreadDemo{
    public static void main(String[] args) {
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();
        //Singleton s3 = new Singleton();
        System.out.println(s1 == s2);
    }
}

image-20221214162827496

Java代码里的每个类,都会在编译完成后得到.class文件

JVM运行时就会加载这个.class文件读取其中的二进制指令,并且在内存中构造出对应的类对象(形如Singleton.class)

由于类对象在一个Java 进程里,只是有唯一一份的,因此类对象内部的类属性也是唯一一份了

饿汉模式:一个饿了很久的人看到吃的就会很急切

JavaEE-多线程初阶3_第8张图片

JavaEE-多线程初阶3_第9张图片

private static Singleton instance = new Singleton();
//static 保证这个实例是唯一的,
//static 保证这个实例是在一定时机被创建出来
//(static属于这个实现方式中的灵魂角色)
//static 这个操作是让当前 instance 属性是类属性了,类属性是长在类对象上的,类对象又是唯一实例的(只是在类加载阶段被创建出一个实例)

运行一个 Java 程序,就需要让 Java 进程能够找到并读取对应的 .class 文件,就会读取文件内容并解析,构造成类对象…这一系列的过程操作,称为类加载。

要执行 Java 程序前提是要把类加载起来才行。

懒汉模式

class SingletonLazy{
    private static SingletonLazy instance = null;
    public static SingletonLazy getInstance(){
        if(instance == null){
            instance = new SingletonLazy();
            //这个实例并非是类加载的时候创建了,而是真正第一次使用的时候去创建(如果不用就不创建)
        }
        return instance;
    }
    private SingletonLazy(){}
}
public class ThreadDemo{
    public static void main(String[] args) {
        SingletonLazy s1 = SingletonLazy.getInstance();
        SingletonLazy s2 = SingletonLazy.getInstance();
        System.out.println(s1 == s2);
    }
}

上述写的饿汉模式和懒汉模式,如果在多线程环境调用getInstance,是否是线程安全的?

JavaEE-多线程初阶3_第10张图片JavaEE-多线程初阶3_第11张图片

如何能让上述懒汉模式成为线程安全?

加锁

刚才线程不安全的原因是读,比较和写这 3 个操作不是原子的,这就导致 t2 读到的数据是 t1 还没来得及修改的(脏读)
JavaEE-多线程初阶3_第12张图片
这样写就会出现新的问题,每次getInstance都需要加锁,加锁操作是有开销的

仔细分析,其实只用在new对象之前加锁就可以,new完对象之后调用getInstance就只有比较和返回这 2 个读操作了。
JavaEE-多线程初阶3_第13张图片
此时还存在一个问题:内存可见性

如果很多线程都去getInstance , 这个时候是否有会被编译器优化的风险?(只有第一次读是读了内存,后续都是读寄存器/cache)

另外还有指令重排序的问题

 instance = new SingletonLazy();
 //这里拆分成 3 步
 //1.申请内存空间
 //2.调用构造方法,把这个内存空间初始化成一个合理的对象
 //3.把内存空间的地址赋值给 instance 引用

正常情况下是按照1,2,3的顺序执行的。

但是编译器可能会进行指令重排序(为了提高程序效率,调整代码执行顺序)

1,2,3这个顺序可能会变化1 ,3,2 (如果是单线程,这里调整顺序没有本质区别)

但在多线程环境下会出问题

假设 t1 是按照 1,3,2 的顺序执行的,当 t1 执行到 1,3 之后执行 t2 之前被切出 CPU,t2来执行。当 t1 执行完 3 之后,instance 就非空了, t2 就直接返回了 instance 引用,并且还可能会尝试使用引用中的属性。但是 t1 的 2 还没完成,t2 拿到的是一个非法的对象。

这种情况轮到 volatile 上场了

volatile 有 2 个功能:解决内存可见性 + 禁止指令重排序。

最终调整之后的代码如下:

JavaEE-多线程初阶3_第14张图片

你可能感兴趣的:(JavaEE初阶,java-ee,jvm,java)