【JavaEE】多线程之线程安全(下)

发布于:2025-08-16 ⋅ 阅读:(25) ⋅ 点赞:(0)

本节是线程安全的最后一部分,当然了,线程安全这个概念本身会伴随着我们整个多线程学习部分,so,let’s begin~

三、内存可见性问题

存在这样一种场景:对于同一个变量,一个线程对其读取,另一个线程对其修改,但修改后的值并没有被前一个线程读到

在前面我们提到过,这可能是因为修改操作不是原子的,由于线程随机调度的原因导致前一个线程读取到的仍是修改前的结果。但是在这里,我们假设这个修改操作是原子的,理论上来说另一个线程完全可以读到这个修改后的值,那么此时为什么会出现上述情况呢?

这就需要引入另一个概念:编译器优化

要知道,程序员的水平是参差不齐的,不同程序员写的代码运行效率可能天差地别,研究JDK的大佬们为了我们可谓是费劲了心思,他们希望能够通过编译器和JVM对程序员写的代码自动地进行优化,在保证代码原有逻辑不变的基础上对代码进行调整,使程序的运行效率提高。站在程序员的角度,因为并不能直观感受到代码的优化是在JVM部分进行的还是在编译器中进行的,因此我们将其统称为编译器优化

大佬们本意是好的,但是在一些特殊情况下编译器优化也会带来一些问题,在多线程情况下,编译器优化就可能会出现逻辑上的修改(也就是编译器出现了失误)

又到了熟悉的环节 举个例子:

private volatile static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            while(count == 0){
            System.out.println("t1线程结束");
        });
        Thread t2 = new Thread(()->{
            Scanner in = new Scanner(System.in);
            System.out.print("count的值:");
            count = in.nextInt();
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }

当我们执行上述代码的时候,很容易发现,诶,为什么我已经在t2线程中修改了count的值使其不为0了,为什么一直没有执行System.out.println(“t1线程结束”);这行语句呢?

这就是因为程序由于编译器优化而出现了内存可见性问题

我们知道,上述代码的逻辑是,t1线程不断判断flag的值是否为0,如果为0则进入循环,直到t2线程中在控制台输入任意数修改count的值为止,此时t1线程中读取的flag值不再为0,跳出循环执行打印操作

这个while操作在汇编层面可以理解为cmp指令,也就是比较和跳转,程序进入while语句块中会执行load和cmp操作,即先加载load的值,再将其与0进行比较,由于while循环体是空的,所以在短时间内这个循环会执行很多很多很多次,也就是不断地进行load和cmp操作

在程序执行过程中,JVM感知到load操作反复执行的时候好像加载到的值都是一样的(这里load操作是CPU从内存中读取数据并加载到寄存器中,cmp操作是读取寄存器的值并进行比较),而读取内存的时间开销会比读取寄存器的开销大几千倍,那此时JVM就会把这里读取内存的操作优化为读取寄存器的操作,也就是除了第一次load是从内存中读数据并加载到寄存器中,其余的load都是直接读取这个寄存器中的数据

当在用户真正输入并修改count的值的时候t1线程就感知不到了,因为这个操作是在t2线程中的,修改的是内存中count的值,因此t1线程一直在while循环中不死不休~

当然了,存在问题就会有对应的解决方案。我们引入volatile关键字来解决上述问题

通过使用volatile关键字来修饰变量,使得编译器对这个变量的读取操作不被优化为读取寄存器(也就是告诉编译器,这块我寻思好了,不用你帮着优化,你该读内存读内存,别瞎读寄存器)

tips:volatile可以解决内存可见性问题,但是不能解决原子性问题(一个猴有一个猴的栓法~~原子性问题你该加锁加锁去,volatile帮不了啥忙)

四、JMM——Java内存模型

JMM——Java memory model
官方一点的说法是Java内存模型,是Java虚拟机定义的一种规范

JMM对于内存可见性问题的描述是:每个线程都有一个自己的工作内存(work memory),同时这些线程共享同一个主内存(main memory)。当一个线程循环进行上述读取变量操作时,就会把主内存中的数据拷贝到该线程的工作内存中,后续另一个线程修改也是先修改自己的工作内存,将其拷贝到主内存中。由于第一个线程仍然在读取自己的工作内存,因此感知不到主内存的变化(也就是别人干了啥活你还没来得及知道,你就要做与之相关的其他操作的了)

  • 这里的工作内存指得就是CPU的寄存器,主内存指的就是真正的内存
  • 也就是说这种说法和上述说法本质上是一样的
  • 至于为什么Java文档中不直接说“寄存器”“register”这个名词,而是用更抽象的“工作内存”“work memory”表示,是为了能够兼容不同的设备(不同CPU用来缓存上述内存数据的区域可能是不同的,Java程序员不需要关心硬件的区别)这也很好地体现了Java语言的跨平台特性

Write Once, Run Anywhere

五、wait和notify

这两个方法都是Thread类中提供的,之所以放在这里而不是Thread类介绍里,还是因为我觉得这两个方法在线程安全中的作用更加明显~当然,如果你觉得这样安排让你看着不太舒服,那么对不起对不起对不起对不起对不起……

wait(等待),notify(通知),这两个方法配合使用,达到协调线程之间执行顺序的效果

比如这样:

Object locker = new Object();
        Thread t1 = new Thread(()->{
            try {
                System.out.println("wait之前");
                synchronized (locker){
                    locker.wait();
                }
                System.out.println("wait之后");
            }catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            //next是一个带阻塞的效果,等待IO进入的阻塞
            System.out.println("输入任意内容,通知唤醒t1:");
            scanner.next();
            synchronized (locker){
                //先wait,再notify
                locker.notify();
            }
        });
        t1.start();
        t2.start();

这个程序的执行顺序就是,t1线程打印后等待t2线程输入内容后将其唤醒,t1停止等待,打印后程序结束

5.1 wait方法

wait,顾名思义,就是让该线程等待

需要注意的是,wait的等待要先释放锁,给其他线程获取锁的机会,再等待

synchronized(locker){ // 加锁操作
	locker.wait();    // 代码进入wait,先释放锁并且阻塞等待
	// 如果其他线程做完了要做的工作,调用notify唤醒了这个wait线程,wait就会解除阻塞,重新获得锁对象并继续执行
} // 释放锁

几点说明:

  1. 这里synchronized的锁对象和调用wait的对象必须是同一个
  2. wait必须搭配锁才能使用,需要先加锁,再wait
  3. wait()有两个重载的方法,有参数和无参数的,参数表示最大的等待时间(也就是一直等和超时就不等了)

5.2 wait和join的区别

  • join等待另一个线程彻底执行完才继续执行当前的线程
  • wait等到另一个线程执行notify后继续执行当前线程(不需要等另一个线程完全执行完)

5.3 wait和sleep的区别

最主要的区别在于针对锁的操作

  • wait必须搭配锁,需要先加锁才能使用wait,sleep不需要
  • 如果都是在synchronized内部调用,wait会释放锁,sleep不会(也就是,wait等待的时候放开锁,sleep直接抱着锁睡)

5.4 notify方法

和wait相同的,需要拿到锁之后才能进行唤醒,也就是wait和notify都强制需要搭配synchronized使用(尽管notify并不涉及加锁解锁操作)

如果想使用notify唤醒wait,那么这两处需要使用同一个对象才能成功唤醒

synchronized(locker){
	locker.wait();
}
synchronized(locker){
	locker.notify();
}

使用这两个方法时务必要确保先执行wait再使用notify唤醒。如果是先notify后wait,此时notify不起作用,wait无法被唤醒(虽然notify此时也不会报错,对这个线程也没有影响,但还是不建议这样的问题出现)

如果有多个线程对同一个对象wait,进行一次notify的时候是随机唤醒其中一个线程

如果想要唤醒全部的wait,可以多次调用notify,或者调用notigyAll()方法,一次性唤醒所有线程(这种情况需要考虑好是否所有线程都需要被唤醒)


okk,我们线程安全部分也算是告一段落了,但是暂离不是完结哦,在后续内容中线程安全是我们一直需要考虑和注意的事情,大家还是要多加练习多加感受的牙~


网站公告

今日签到

点亮在社区的每一天
去签到