Java线程安全与等待通知

目录

    • 1. 线程不安全原因
      • 1.1 引入——线程不安全的例子(抢占式执行)
      • 1.2 线程不安全的原因(5点+其他)
    • 2. 抢占式执行引起的线程不安全——synchronized
    • 3. 内存可见性引起的线程不安全——volatile
      • 3.1 例子——编译器优化误判
      • 3.2 volatile——编译器暂停优化
    • 4. 指令重排序引起的线程不安全——volatile
    • 5. 等待通知——wait和notify关键字(锁中使用)
    • 6. wait和sleep的对比(面试题)

1. 线程不安全原因

1.1 引入——线程不安全的例子(抢占式执行)

由于线程调度顺序是无序的,则让两个线程对同一个变量各自自增5w次,看变量的运行结果。

为啥会出现线程安全问题?
本质原因:线程在系统中的调度是无序的/随机的(抢占式执行)。

public class TestDemo {
    public static int x;
    public static void main(String[] args) throws InterruptedException {
        //两个线程对同一个变量各自自增5w次,看变量的运行结果
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                x++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                x++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(x);
    }
}

//另一种写法
class Counter {
    public int count = 0;
    public void add() {
        count++;
    }
    public int getCount() {
        return count;
    }
}
public class TestDemo2 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.getCount());
        //实际结果和预期结果不相符,由多线程引起的bug【与线程的调度随机性密切相关】
    }
}


运行结果:
Java线程安全与等待通知_第1张图片

分析:有多少次是“顺序执行”,有多少次是“交错执行”是不知道的,得到的结果是啥也是变化的。

count++操作,本质上是3个cpu指令构成的(不是原子的)

  1. load把内存中的数据读取到cpu寄存器中
  2. add把寄存器中的值进行+1运算
  3. save把寄存器中的值写回到内存中
    归根结底,线程安全问题全是因为线程的无序调度导致了执行顺序不确定,结果就变化了。

1.2 线程不安全的原因(5点+其他)

①抢占式执行,随机调度(罪魁祸首,万恶之源)
线程中的代码执行到任意一行,都随时可能被切换出去
②多个线程同时修改同一个变量
注:一个线程修改同一个变量(安全)
多个线程读取同一个变量(安全)
多个线程修改不同变量(安全)
③修改操作不是原子的
注:如果某个操作对应多个cpu指令,大概率不是原子的,正是因为不是原子的,导致两个线程的指令排序存在更多的变数了。
例如++操作就不是原子的。
④内存可见性
编译器执行代码的时候对代码进行优化,有些操作下频繁读取内存,但是读取的结果不变,则优化成只从寄存器去读,不从内存中读。在这种情况下,如果另外一个线程修改了内存的值,原来读的那个线程无法感知到。【一个线程频繁读,一个线程修改】
⑤指令重排序
编译器优化产生的线程不安全。

2. 抢占式执行引起的线程不安全——synchronized

  1. 锁有2个核心操作:加锁和解锁。
    public void add() {
        synchronized (this) {
            count++;
        }
    }

①此处使用代码块的方式来表示,进入synchronized 修饰的代码块的时候,就会触发加锁,出synchronized 的代码块,就会触发解锁
②synchronized (),括号()中表示锁对象,在针对哪个对象加锁,如果2个线程针对不同对象加锁,此时不会存在锁竞争,各自获取各自的锁即可;如果2个线程针对同一个对象加锁,此时就会出现“锁竞争”,一个线程先拿到了锁,另一个线程阻塞等待。
③()里的锁对象可以是写任意一个Object对象,基本数据类型不可以,此处写了this相当于counter对象。
例如:手动指定一个锁对象,相当于吉祥物,仅仅是起到了一个标识的效果。

    private Object locker = new Object();
    public void add() {
        synchronized (locker) {
            count++;
        }
    }

注:如果多个线程尝试对同一个锁对象加锁,就会产生锁竞争;针对不同对象加锁,就不会有锁竞争。

  1. 给方法加锁
    synchronized public void add() {
            count++;
    }

如果直接给方法使用synchronized修饰,此时就相当于以this为锁对象。
下图2种加锁方法等价。
Java线程安全与等待通知_第2张图片
3. 给静态方法加锁

    public static void test() {
        synchronized (Counter.class) {
            
        }
    }
    synchronized public static void test1() {
        
    }

如果synchronized修饰静态方法(static),此时就不是给this加锁了,而是给类对象加锁。

类对象是什么?
Counter.class
类对象相当于“对象的图纸”,描述了类的方方面面的详细信息。
类对象可以用来表示.class文件的内容。

3. 内存可见性引起的线程不安全——volatile

3.1 例子——编译器优化误判

import java.util.Scanner;

public class ThreadDemo1 {
    public static int falg = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
           while (falg == 0) {

           }
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            falg = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

运行结果:
Java线程安全与等待通知_第3张图片
分析:理论上当输入2之后,线程1应该结束,实际上通过jconsole发现并没有结束。这是因为循环条件flag=0,编译器每次从内存中读数据发现都为真,因此编译器自动帮助我们优化了,编译器对于代码优化产生了误判。
Java线程安全与等待通知_第4张图片

内存可见性:就是多线程的环境下,编译器对于代码优化产生了误判,从而引起了bug,导致代码bug。
编译器优化:智能的调整代码执行逻辑,保证程序结果不变的前提下,通过加减语句、语句变换、一系列操作,让整个程序的执行效率大大提升。编译器对于“程序结果不变”单线程下判定是非常准确的,但是多线程就不一定了。

加了sleep循环执行速度就非常慢,当循环的次数下降了,此时load操作就不再是负担,编译器就不需要优化了。

     Thread t1 = new Thread(() -> {
           while (falg == 0) {
               try {
                   Thread.sleep(3000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });

3.2 volatile——编译器暂停优化

volatile关键字:加上volatile关键字之后,编译器就能够保证每次都是重新从内存读取flag变量的值。

    volatile public static int falg = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            falg = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }

运行结果:
Java线程安全与等待通知_第5张图片
分析:此时t2修改falg,t1就可以立即感知到了,t1就可以正确退出循环。

volatile适用的场景:一个线程读,一个线程写
sychronized适用的场景:多个线程写
volatile的效果,称为“保证内存可见性”。

4. 指令重排序引起的线程不安全——volatile

指令重排序也是编译器优化的策略,调整了代码执行的顺序,让程序更高效。
谈到优化,都得保证优化之后的结果和之前是不变的,在单线程的情况下容易保证,但是在多线程的情况下就不好说了。

class Student {

}
public class ThreadDemo2 {
    public static Student s;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            s = new Student();
        });
        Thread t2 = new Thread(() -> {
            if (s != null) {
                System.out.println("111");
            }
        });
        t1.start();
        t2.start();
    }
}

分析:s = new Student()大体可以分为3步:①申请内存空间②调用构造方法,初始化内存的数据③把对象的引用赋值给s。②和③编译器优化可对其进行调换顺序,这样上述代码就可能会因为指令重排序出现问题。

解决办法:使用volatile关键字,volatile关键字的作用主要有如下2个:

  1. 保证内存可见性:当一个线程修改一个共享变量的时候,另一个线程能读到这个修改的值。
  2. 保证有序性:禁止指令重排序,编译时jvm编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。
    注意:volatile不能保证原子性。

5. 等待通知——wait和notify关键字(锁中使用)

由于线程的调度是无序的、随机的,但是在一定的需求场景下,希望线程有序执行。

  • join,算是一种控制顺序的方式,但是功效有限
  • wait,就是让某个线程先暂停下来等一等;发现条件不满足/时机不成熟,就先阻塞等待。
  • notify,就是把该线程唤醒,能够继续执行;其他线程构造了一个成熟的条件,就可以唤醒wait的线程。
  1. 分析代码的报错信息
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();
        System.out.println("wait之前");
        o.wait();
        System.out.println("wait之后");

    }

运行结果:
Java线程安全与等待通知_第6张图片
分析:IllegalMonitorStateException 非法锁状态异常,锁还没获取到,就尝试解锁,就会产生上述异常。【此时wait需要解锁,但是都没加上锁】

wait主要做3件事:①解锁 ②阻塞等待 ③当收到通知的时候唤醒,同时尝试重新获取锁。
因为wait必须写到synchronized代码块里面,这样才能尝试①解锁。
同理notify也是要放到synchronized中使用的。
先有wait再有notify,否则就相当于一炮打空了。
使用wait,阻塞等待会让线程进入WAITING状态。

        synchronized (o) {
            o.wait();
        }

注意:加锁的对象必须和wait的对象是同一个,这样wait操作就是在针对当前对象进行解锁。

  1. wait和notify的例子
 public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        //wait
        Thread t1 = new Thread(() -> {
            System.out.println("wait之前");
            synchronized (object) {
                try {
                    object.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("wait之后");
        });
        t1.start();
        Thread.sleep(1000);
        //notify
        Thread t2 = new Thread(() -> {
            System.out.println("notify之前");
            synchronized (object) {
                object.notify();
            }
            System.out.println("notify之后");
        });
        t2.start();
    }

运行结果:
Java线程安全与等待通知_第7张图片
分析:
t1先执行,执行到了wait,就阻塞了
1秒之后,t2开始执行,执行到了notify,就会通知t1线程唤醒。
注意:notify是再synchronized内部,就需要t2释放锁之后t1才能继续往下走,因为t1要重新获取锁,所有要t2释放锁。

join和wait、notify的区别
join只能让t2线程先执行完,再继续执行t1,一定是串行的。
wait notify可以让t2执行完一部分,再让t1执行,t1执行一部分,再让t2执行,非常灵活。

  1. notifyAll
    可以有多个线程,等待同一个对象。比如t1 t2 t3中都调用了object.wait,
    此时在main中调用object.notify,会随机唤醒上述的1个线程,另外2个仍然是waiting状态。
    如果调用object.notifyAll,此时就会把上述3个线程都唤醒,此时这3个线程就会重新竞争锁,然后依次执行。

6. wait和sleep的对比(面试题)

wait有一个带参数的版本,用来体现超时时间,这个时候感觉和sleep差不多。wait和sleep都能提前唤醒。
最大的区别:初心不同,设计这个东西解决的问题不同。
wait解决线程之间的顺序控制。
sleep解决让当前线程休眠一会。

使用上也有明显的区别:wait要搭配锁使用,sleep不需要。
再进一步,只是java这里的sleep和wait用法看起来比较像,其他语言的sleep和wait差别很大。

你可能感兴趣的:(JavaEE,java,jvm,开发语言)