【JavaEE】了解volatile和wait、notify(三)

volatile

volatile关键字的重要功能:保证内存可见性

在多线程中常常会出现的线程安全问题:

1.内存可见性

计算机运行的程序代码中,经常要访问数据,这些依赖的数据往往会存储在内存中(也就是会定义一个变量,变量中存放的数据就是经常要访问的数据,而这个变量就在内存中)。CPU使用这个变量时,就会把这个内存中的数据先读出来,放在CPU寄存器中,再参与运算。

CPU读取内存这个操作时非常慢的(慢只是相对而言),对CPU而言,它的大部分操作都是很快的,一旦操作涉及读/写内存,就导致速度降下来。CPU内部操作 > 读/写寄存器 > 读取高速缓存Cache > 读/写内存 > 读硬盘。

为了解决读取内存速度慢这个问题,编译器会对代码做出优化,把一些本来要读内存的操作优化程读取寄存器,这样一来就可以减少读内存的次数,提高效率,这个就是内存的可见性。

我们用一个代码详细的解释:

复制代码
public class Demo01 {
    private static int Q = 0;//全局变量
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            //该线程不断的判断Q的值是否有变化
           while (Q == 0){

           }
            System.out.println("t1结束");
        });
        Thread t2 = new Thread(()->{
            System.out.println("请输入Q的值:");
            Scanner s = new Scanner(System.in);
            Q = s.nextInt();
        });
        t1.start();
        t2.start();
    }
}

解释以上代码:代码想表达的意思是线程一在不断的判断此时此刻的Q是否为0,而线程二在修改Q的值。正常情况下如果我们将Q的值修改成非零那么线程一就不再循环判断,并且结束线程一。如下图我们运行的结果并非是我们所预期的效果,当预期于结果不同时就是一个线程安全的问题。

也就是说一个线程读,一个线程写也会出现线程安全问题,这就是内存可见性引起的。那么我们深入分析一下每一步过程:

在此之前我们也得知道这个线程一是分两步的,第一步:load读取内存中的Q值到寄存器中;第二步:通过cmp指令比较寄存器的值是否为0,决定是否继续循环;

由于这个循环的速度非常快,短时间内就会进行大量循环,也就是进行大量的load和cmp。此时,编译器就发现进行这么多次的load,结果都是一样的,读取内存速度慢费时间(完成一次load等价于上万次cmp),于是,编译器就做了一个决定,只读一次内存中的数据,后面不再重复读,直接从寄存器中取Q的值(这样就感知不到Q在变化)。以至于线程二修改了Q的值,线程一也没有结束的原因,于是就出现了以上的bug。

解决内存可见性问题

在多线程环境下,编译器对于是否进行这样的优化判断不一定准确,就需要我们通过volatile关键字告诉编译器不要优化,不管是否优化都可以通过volatile预防这种优化(也叫保证内存可见性),并且volatile不能保证原子性。也就是开头所说的保证内存可见性。同样的,我们可以也在循环中添加sheep休眠为了减缓速度也可以达到volatile的效果,只是原因不同了减缓速度使开销变小了,编译器就没有必要进行优化(这种方案不可靠,在编写代码时,我们无法确定编译器是否对我们的程序进行了优化),最好的方案就是是由volatile关键字。

wait 和 notify

wait 和 notify 执行时所做的事:

1.释放当前的锁

2.让线程进入阻塞

3.当线程被唤醒时,重新获得锁

如果在一个线程中没有锁就直接调用 wait 会抛出异常(非法监视),所以在调用 wait/notify 前该线程需要加锁

wait 和 notify 是相互使用,wait 是阻塞状态,而 notify 则是唤醒 其中一个被阻塞的线程。

复制代码
public class Demo01 {
    public static void main(String[] args) throws InterruptedException {
        Object lock = new Object();
        Thread t1 = new Thread(()->{
            synchronized (lock){
                System.out.println("开始");
                try {
                    lock.wait();//此时的锁被释放,目前为阻塞状态,等待notify唤醒
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("结束");
            }
        });
        Thread t2 = new Thread(()->{
            try {
                Thread.sleep(3000);//先让t1线程获得锁
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            synchronized (lock){
                lock.notify();
            }
        });
        t1.start();
        t2.start();
    }
}

notigy 是一次唤醒一个,而 notifyAll 则是唤醒当前所有被阻塞状态的线程。但是就算线程被唤醒,需要重新获得锁,如果是同一把锁还是存在锁竞争。

并且wait 和 notify 可以避免线程饿死,正是它的功能 "释放当前锁"。(线程饿死:一个或多个线程因为无法获取到必要的资源(通常是 CPU 时间片 ,或者是锁资源),而长时间无法执行,处于饥饿等待状态。)

wait 也可以添加超时限制,也就是当阻塞时间超过该限制时,线程不再阻塞而是继续往后执行。

join的用法:哪个线程调用join,就等那个线程结束后再往后执行代码。