【多线程】 synchronized关键字 | 可重入锁 | 死锁 | volatile关键字 | 内存可见性问题 |wait&notify方法|

synchronized和volatile关键字

synchronized 监视器锁 monitor lock

一、加锁互斥

​ 前文提到了线程安全问题,为了解决线程安全问题,最常用的方法就是使用synchronized关键字进行加锁处理。

synchronized在使用时,要搭配代码块{ },进入{ 就会加锁,出了 } 就会解锁。

  • 在已经加锁的状态中,另一个线程尝试同样加这个锁。就会产生“锁冲突/锁竞争”。此时,后一个线程就会阻塞,一直等到前一个线程解锁为止。本质上把“并行”变成了“串行”。

  • synchronized ()中需要表示一个用来加锁的对象,这个对象是谁不重要。重要的是根据这个对象来区分两个进程是否在竞争同一个锁。

  • 如果两个线程在针对同一个对象加锁,就会有锁竞争。如果不是针对同一个对象加锁,就没有锁竞争,仍然是并发执行。

    public static int count = 0;


    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            //对变量自增50000次
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    //()中需要表示一个用来加锁的对象,这个对象是谁不重要
                    //主要是根据这个对象来区分两个进程是否在竞争同一个锁
                    count++;
                }

            }

        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });

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

        t1.join();
        t2.join();//没有join的话,线程还没自增完,就会打印count

        System.out.println("count: " + count);
    }

count: 100000
  • 引入锁之后,保证了线程安全

synchronized用的锁是存在Java对象头里面的。在对象头中,就有属性来表示当前这个对象是否加锁,来进行区分。

Java的一个对象对应的空间中,除了自己定义的一些属性外,还会有一些自带的属性,这些自带的属性就叫对象头。

二、synchronized的使用

​ synchronized除了修饰代码块外,还可以修饰一个实例方法,或者修饰一个类方法。

class Counter {
    public int count;
    public static int num;

    synchronized public void increase() {
        count++;
    }

    public void increase2() {
        synchronized (this) {
            count++;
        }
    }

    synchronized public static void increase3() {
        num++;
    }

    public static void increase4() {
        synchronized (Counter.class) {
            num++;
        }
    }
}

public class demo1 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase2();
                Counter.increase3();

            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase2();
                Counter.increase4();
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println("count: " + counter.count);
        System.out.println("num: " + Counter.num);

    }
1.修饰实例方法
  • 用synchronized来修饰一个方法,此时就是使用this作为锁对象
    synchronized public void increase() {
        count++;
    }
    public void increase2(){
        synchronized (this){
            count++;
        }
    }

两种写法等价

2.修饰类方法
  • 如果用synchronized来修饰类方法,就是针对类对象加锁。

​ 类对象:.java代码会编译成.class文件。.class文件会被JVM加载到内存中。加载到内存中的数据就是类对象。

类对象中包含类的属性、方法、继承、接口(名字、类型、权限)等信息。同时,类对象在一个Java进程中,是唯一的。

    public static int num;
   synchronized public static void increase3() {
        num++;
    }

    public static void increase4() {
        synchronized (Counter.class) {
            num++;
        }
    }

两种写法等价

锁对象是谁不重要,重要的是两个线程中锁对象是否是同一个对象。同一个对象才能进行锁冲突。

三、可重入锁

可重入锁,指的是一个线程。连续针对一把锁加锁两次,不会出现死锁。

1.死锁
                synchronized (locker){
                    synchronized (locker){
                     //   
                    }
                }

​ 同一个对象加两次锁。第一次加锁,假设加锁成功,locker就属于“被锁定”状态。在进行第二次加锁的时候,原则上来说是要进行阻塞,等待到锁被释放之后才能进行第二次的加锁操作。此时由于第二次加锁被阻塞了,就出现了死锁。(线程卡死)第二次无法加锁导致第一个加锁无法执行完毕解锁。第一次无法解锁,第二次就不能加锁。

  • 把synchronized设计成“可重入锁”就可以解决死锁的问题。

让锁记录一下,是哪一个线程给它锁住的。后续再次加锁的时候,如果加锁线程是之前持有锁的线程,那么直接加锁成功。

​ 无论可重入锁有多少层,都需要在最外层才能释放锁。保证中间代码的线程安全。在锁对象中,不光要记录谁拿到了锁,还要记录这个锁被加了几次。每加锁1次,计数器+1,每解锁一次,计数器减一。出了最后一个大括号后减为0,此时进行锁的释放。

关于死锁

1.一个线程针对一把锁,连续加锁两次。如果是不可重入锁,就死锁了。

synchronized不会出现死锁,因为它是可重入锁。C++的 std::mutex是不可重入锁,就会出现死锁。

2.两个线程,两把锁(无论是不是可重入锁,都会死锁)

1.t1获取锁A,t2获取锁B

2.t1尝试获取B,t2尝试获取A

出现死锁,家钥匙锁车里,车钥匙锁家里

    private static Object locker1 = new Object();
    private static Object locker2 = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);//确保两个线程都先获取到一把锁
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2){
                    System.out.println("t1加锁成功");
                }
            }


        });
        Thread t2 = new Thread(()->{
            synchronized (locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker1){
                    System.out.println("t2加锁成功");
                }
            }

        });
        t1.start();
        t2.start();
    }
  • 两个线程都没有获取到第二把锁
  • 两个synchronizid是嵌套关系,在占有一把锁的前提下,再获取另一把锁(可能出现死锁)
  • 而并列关系是先释放当前的锁,再获取下一把锁(不会死锁)

在这里插入图片描述

两个线程都是BLOCKED状态,因为等待锁而出现了阻塞。

3.N个线程,M把锁

更容易出现死锁的情况

哲学家就餐问题

哲学家就餐问题就是经典的N个线程M把锁的模型。

1.在一个圆桌上,每个哲学家左右两侧放了一根筷子,桌子的中间是一碗面。

2.每个哲学家要么进行思考,放下筷子,什么都不干。要么拿起左右两根筷子,开始吃面

3.哲学家吃面和思考是随机的。

4.哲学家什么时候吃完也是随机的

5.哲学家吃面时,会拿起左右两侧的筷子,相邻的哲学家就会阻塞等待。

哲学家=线程 ,哲学家行为的随机=线程的随机调度 ,筷子=锁

通常条件下,可以正常运转,但是在极端情况下。所有哲学家都想吃面条,同时拿起了左手边的筷子。所有人都只有一根筷子,都只能阻塞等待。

要解决问题,需要给筷子进行编号。同时规定每个哲学家先要拿起编号小的,再拿起编号大的筷子。

破除了循环等待,就不会出现死锁了。

如何避免死锁
死锁成因的四个必要条件

1.互斥使用(锁的基本特性)。当一个线程持有一把锁之后,另一个线程也想获取到锁,就要阻塞等待。

2.不可抢占(锁的基本特性)。当锁已经被线程1拿到后,线程2只能等线程1主动释放,不能强行抢过来。

3.请求保持(代码结构)。一个线程尝试获取多把锁(先拿到锁1后,再尝试获取锁2。获取时锁1不会释放)

4.循环等待/环路等待:等待的依赖关系形成环了(车、房锁、哲学家)

解决死锁的核心,就是破坏上面的这些必要条件:

对于3来说,需要调整代码结构,避免编写“嵌套”关系。

对于4来说,可以约定加锁的顺序,就可以避免循环等待。

针对锁进行编号,先加编号大/小的,再加编号小/大的(所有线程都要遵守规则)

四、volatile关键字

作用:1.保证内存可见性 2.禁止指令重排序

1.保证内存可见性

什么是内存可见性问题

​ 计算机运行的程序/代码,经常要访问数据。这些依赖的数据,往往会存储在内存中(例如,当定义一个变量时,这个变量就存储在内存中)。当CPU使用这个变量的时候,就会把这个内存中的数据,先读出来,放到cpu寄存器中,再参与运算(load)。但是CPU读取内存的操作,相对于读取寄存器来说要非常慢(CPU其他操作都很快,但是涉及到读写内存,速度就降下来了)。为了解决上述问题,提高效率。此时的编译器就会对代码做出优化,把原本要读内存的操作,优化成读取寄存器,减少读内存的次数,达到提高效率的目的。

    private static int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while (isQuit==0){
                //循环体内没有代码,意味着循环一秒就会执行很多次
            }
            System.out.println("t1线程退出");

        });
        t1.start();
        Thread t2 =new Thread(()->{
            System.out.println("请输入isQuit : ");
            Scanner sc = new Scanner(System.in);
            isQuit = sc.nextInt();
            //一旦用户输入的值不为0,就会使t1线程执行结束
        });
        t2.start();
    }
  • 预期结束: 只要用户输入一个非0的值,线程t1就会结束循环并退出。

  • 但是实际结果是:不管输入的是什么,线程1一直在循环,并没有结束。还是RUNNABLE状态。

这就是由于多线程引发的bug,同样是线程安全问题。

一个线程读、一个线程修改变量,也可能会出现问题。就是由于“内存可见性”情况引起的。

            while (isQuit==0){
                //循环体内没有代码,意味着循环一秒就会执行很多次
            }

在代码的这个while循环内主要做两件事:

1.要通过load读取isQuit的值到寄存器中

2.通过cmp指令比较寄存器的值是否是0,决定是否进行循环

​ 由于这个循环的速度非常快,短时间内会进行大量的循环。进行大量的load、cmp操作。此时,编译器/JVM发现:进行的这么多次load的结果是一样的,并且load非常耗费时间,一次load的时间可以做上万次cmp。因此,编译器自动做出来优化:在第一次循环的时候,才读了内存。后续的循环都不再读取内存了,直接从寄存器中读取isQuit的值。

​ 由于在线程1的循环中,编译器不会读取isQuit的值了,读的都是寄存器中的值。在线程2当中修改isQuit,线程1感知不到内存发生了变化,就无法对线程1产生影响了。

编译器本意是好的,可惜执行坏了。在优化之后,修改了多线程情况下的执行逻辑。

上述的这个问题就是内存可见性问题。内存已经修改了,但是无法进行感知。

如何解决

使用volatile关键字

在多线程环境下,编译器对于是否优化的判断并不准确。此时需要程序员通过volatile关键字来告诉编译器不用优化。

这时,给isQuit变量前加上volatile关键字,编译器就会禁止上述优化。

    private volatile static  int  isQuit = 0;

请输入isQuit : 
1
t1线程退出

当我们在循环内部加上sleep

    private  static  int  isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while (isQuit==0){
                //循环体内没有代码,意味着循环一秒就会执行很多次
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

            }
            System.out.println("t1线程退出");

        });
        t1.start();
        Thread t2 =new Thread(()->{
            System.out.println("请输入isQuit : ");
            Scanner sc = new Scanner(System.in);
            isQuit = sc.nextInt();
            //一旦用户输入的值不为0,就会使t1线程执行结束
        });
        t2.start();
    }
请输入isQuit : 
1
t1线程退出

​ 此时,没有加上volatile,t1线程却可以顺利退出。因为加了sleep后,while循环的速度变慢了,load操作的开销就不大了,就没有触发load的优化,也就没有触发内存可见性问题。

Java内存模型JMM

JMM(Java Memory Model) Java内存模型

是Java规范文档上的叫法

JMM把存储空间划分为了主内存和工作内存。t1线程对应的isQuit变量,本身是在主内存中的。由于此处的优化,就会把isQuit变量放到工作内存中,t2再修改主内存中的isQuit时,就不会影响到t1的工作内存。

主内存 main memory: 平常说的内存

工作内存 work memory:cpu寄存器 和缓存

Java是跨平台的,要兼容各种硬件,所以采用统一的术语

volatile不能保证原子性,保證的是内存可见性

五、wait 和 notify

  • 用来协调多个线程的执行顺序

    线程是系统随机调度,抢占式执行的。很多时候,会通过一些手段来调整执行的顺序。

​ join影响的是线程结束的先后顺序,从而影响执行的顺序。而希望线程在不结束的情况下,进行执行顺序的控制,就可以用wait和notify

  • wait(等待):让指定的线程进入阻塞状态

  • notify(通知):唤醒对应的阻塞状态的线程

1.wait

wait和notify都是Object的方法。随便定义一个对象,都可以使用wait 和 notify.

    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("wait之前");
        object.wait();
        System.out.println("wait之后");
    }
  • 此时如果进行执行,就会抛出异常

在这里插入图片描述

这个监视器就是synchronized (监视器锁),此时的含义就是非法的锁状态。

因为wait在执行的时候要做三件事。

1.释放当前的锁。

2.让线程进入阻塞。

3,当线程被唤醒的时候,重新获取到锁。

​ synchronized加锁就是把对象头的标记进行操作,而wait的第一步就是释放锁,如果之前就没加锁,就无法进行释放。所以会出现异常。所以要解决这个异常,就需要把wait放进 synchronized里面,确保已经拿到锁了,才能释放。

    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object){
            System.out.println("wait之前");
            object.wait();
            System.out.println("wait之后");
        }
    }
}
  • 代码走到 object.wait()后线程就会一直阻塞等待下去,直到其他线程调用了对应的notify方法。

在这里插入图片描述

​ 此时主线程中的状态就是WAITING状态。

wait还有一个带参数的方法:可以指定一个超时时间。从而避免wait无休止的等待下去。

      object.wait(3000);
//最多等待三千毫秒

2.notify

    public static void main(String[] args) {
        Object object = new Object();

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

        });
        Thread t2 = new Thread(()->{
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            synchronized (object){
                System.out.println("进行通知");
                object.notify();
            }
        });
        t1.start();
        t2.start();
    }

wait之前
进行通知
wait之后

先进行阻塞,直到三秒后线程2进行了唤醒

这样就可以实现多线程执行顺序的调整。需要注意的是,这套操作,只能让线程执行的时间后移,不能向前。

”线程饿死“

使用wait notify 也可以避免”线程饿死“

​ 当1号线程加锁后,没有产生实际的操作,于是就进行了释放解锁。此时,后续的进程要想加锁,还需要一个系统的调度过程。但是由于1号进程已经在cpu上面执行了,所以1号线程更容易再次重新拿到锁,以此往复。这种情况叫做线程饿死

解决方法:让1号线程进行wait(wait本身就会对锁进行释放,并且对1号线程进行堵塞),1号线程就不会参与后续的锁竞争了,为后续的线程提供了机会。

notify:一次唤醒一个线程。相比之下notify更可控,用的更多。

notifyAll:一次唤醒全部线程

​ 当有多个线程调用wait,这些线程就都会进入阻塞,此时唤醒要么一次唤醒一个线程,要么一次唤醒全部线程。(实际上唤醒的时候,wait要涉及到一个重新获取锁的一个过程,就会产生锁竞争,此时就是一个挨一个串行执行的,没轮到执行的是BLOCKED状态)

点击移步博客主页,欢迎光临~

偷cyk的图

  • 62
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值