多线程的线程安全

举报
游离 发表于 2022/11/30 18:59:40 2022/11/30
【摘要】 多线程的线程安全@[toc]线程安全是多线程的重点和难点,一定要好好理解线程安全 : 在多线程各种随机的调度顺序下,代码都没有bug,都能符合 预期的方式执行什么是bug : 不符合需求就算是bug举一个实例来验证线程安全class Counter{ public int count = 0; public void increase(){ count++; ...

多线程的线程安全

@[toc]
线程安全是多线程的重点和难点,一定要好好理解

线程安全 : 在多线程各种随机的调度顺序下,代码都没有bug,都能符合 预期的方式执行

什么是bug : 不符合需求就算是bug

举一个实例来验证线程安全

class  Counter{
    public int count = 0;
    public  void increase(){
        count++;
    }
}
public class demo14 {
    public static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        //阻塞main,先执行 t1 t2线程,等他们执行完了,再执行main
        t1.join();
        t2.join();
        System.out.println("counter="+ counter.count);
    }
}

预期的是,有两个线程各累加5000次,静态的count应该会变成10000,可是结果却不到10000次,并且count还是随机的

在进行count++的时候,底层会在CPU上执行3条指令(不是原子性)

  1. 把内存的数据读取到CPU寄存器上 load

  2. 把CPU的寄存器上的值+1 add

  3. 把寄存器中的值,写到内存中 save

串行: 机器执行完一条指命后,才取出下一条指令来执行的一种工作方式。

极端的两种情况:

如果两个线程之间的调度全是串行执行,结果就是10000

如果两个线程全是其他的情况,没有一次串行执行,结果就是5000

所以最终情况就是5000 - 10000

所以以上的随机值就是一种线程不安全

线程不安全的原因:

  1. 多线程之间抢占式执行(多线程不安全的根本原因)

任何一种调度都是有可能 的

  1. 多个线程修改同一个变量

  2. 执行修改的操作不是原子的(上述的count++就涉及到了3个CPU指令LOAD ADD SAVE)

  3. 内存可见性

  4. 指令重排序 (4 5 两点主要是JVM优化代码的时候出现的bug)

…(具体还得看代码实现)

要想解决线程安全问题,最常见的方法就是将多个操作通过特殊手段变成一个原子操作

在上面的例子中,可以在count++ 之前进行加锁,在count++之后进行解锁,在加锁与解锁之间进行修改count,此时别的进程修改不了count,别的线程出于阻塞状态(BLOCKED状态)

在java中,进行加锁,要使用synchronized 关键字

image-20220907141030597

加上锁之后就使别的线程变成了阻塞状态,由"并发"变成了 串行,运行效率确实降低,但是保证了多线程的安全

一定要知道: 加上锁不一定就能保证线程安全,==正确的加锁是通过加锁,让并发修改同一个变量–>串行修改同一个变量==

要是只给一个线程加锁,另一个不加锁,其实是没用的,只给一个线程加锁不会涉及到"锁竞争",也就不会有阻塞等待,也就不会并发执行–>串行执行(追根究底就是还是会抢占式执行)

synchronized锁对象的理解

关键字synchronized 不仅能修饰方法,还能修饰代码块

image-20220907144712315

synchronized 后面括号填的是锁对象, 也就是针对该对象进行加锁,谁要是调用increase方法,谁就是this–锁对象

锁对象不止可以是this,还可以是任何的对象

上面的synchronized锁方法其实默认的锁对象就是this

image-20220907145158918

写多线程代码的时候,最关心的是,两个线程锁的是否是同一个对象,只要锁同一个对象就会存在锁竞争

注意: 此处打印的是counter的count,所以要想达成10000,就要在counter上形成锁竞争,counter与counter2 里面的counter是不一样的

class  Counter{
    public int count = 0;
    public void increase(){
        synchronized (this){
            count++;
        }
    }
}
public class demo14 {
    public static Counter counter = new Counter();
    public static Counter counter2 = new Counter();//再次设立一个新的对象

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                counter2.increase();
            }
        });
        t1.start();
        t2.start();
        //阻塞main,先执行 t1 t2线程,等他们执行完了,再执行main
        t1.join();
        t2.join();
        System.out.println("counter="+ counter.count);
    }

}

其实这种写法与之前的synchronized后直接加this,counter调用的写法在线程安全角度是一样的

image-20220909095836820

注意: 此处打印的是counter的count,所以要想达成10000,就要在counter上形成锁竞争,counter与counter2 里面的counter是不一样的

总结就是一句话: 写多线程代码的时候,最关心的是,两个线程锁的是否是同一个对象,只要锁同一个对象就会存在锁竞争

死锁问题

对于一个线程,连续加锁两次,就会形成死锁

第二次加锁会阻塞等待,直到第一把锁解开,才能加第二把锁

第一把锁要想解开,要求第二把锁加锁成功

所以就这样子相互纠缠住了,就形成了死锁

image-20220909103051065

但是,死锁问题有时候是很难避免的,要是加锁函数1嵌套函数2 ,函数2嵌套函数3,函数3嵌套加锁函数4,这样子就会很难发现死锁问题

可重入锁

要是不会产生死锁的话,这样的所就叫做"可重入锁"

synchronized就是可重入的

可重入锁的底层实现是很简单的

只要让锁记录好时哪个线程持有的这把锁

加锁: t 线程尝试对this来加锁,锁就会记录是 t 线程持有了它

第二次锁就会发现,还是t线程,此时就会直接通过,不会再次加锁

解锁: 在锁里增加一个计数器,每次加锁就++,每次解锁就–,如果计数器为0,此时才真加锁,当计数器为0,此时才真解锁

总结:

可重入锁的实现要点:

  1. 让锁里持有线程对象,记录哪个线程加了锁
  2. 维护一个计数器,用来衡量什么时候真加锁,什么时候真解锁,什么时候直接通过

就算加锁代码出现异常,也还是会解锁,还是不会死锁—不得不说synchronized关键字是一个十分优秀的设计

所以上面的代码是不会引起死锁的

复习一下final :

final修饰一个变量: 禁止修改

final 修饰类: 禁止进程

final 修饰方法: 禁止重写

内存可见性

线程不安全的其中一个原因就是内存可见性

image-20220909170128127

要是程序需要频繁地读取数据,比较数据速度远快于LOAD的速度,此时编译器就会开始优化, 要是频繁地执行LOAD 并且 LOAD的结果还是一样的,编译器就会只执行一次LOAD,之后就不会重新读取内存了

import java.util.Scanner;

public class Demo16 {
    public  static  class  Counter{
        public int count = 0;
    }
    public static void main(String[] args) {
        Counter  counter = new Counter();
        Thread t1 = new Thread(()->{
           while (counter.count == 0){
			//具体操作
           }
            System.out.println("t1进程运行结束");
        });
        t1.start();
        Thread t2 =  new Thread(()->{
            System.out.println("请输入一个整数:");
            Scanner scanner = new Scanner(System.in);
            counter.count = scanner.nextInt();//修改count
        });
        t2.start();
    }
}

image-20220909171036729

运行以上的代码就会发现,while循环会一直执行,永远不会停止循环

原因: 内存中的count已经修改成了输入的1 ,但是刚才的修改并不会影响t1 的读内存操作,因为t1 的读内存已经被编译器优化成了不再循环读内存,只是读一次就好了,t1 还以为count还是0

也就是说, t2 把内存改了,但是t1没有没看见,这就是内存可见性问题

内存可见性是编译器优化惹的祸, 编译器在单线程情况下,对于代码的优化,逻辑是不会变的,但是编译器在多线程的情况下,很有可能会发生误判

要想解决内存可见性问题,就不要让编译器进行优化,由我们自己进行操作,此时就 要使用关键字volatile[ˈvɒlətaɪl]

volatile关键字

使用volatile"可变的"来修饰一个变量,这样子编译器就不会进行优化,也就是说,每次编译器都会去读取变量的值,

import java.util.Scanner;

public class Demo16 {
    public  static  class  Counter{
       volatile public int count = 0;//加上volatile
    }
    public static void main(String[] args) {
        Counter  counter = new Counter();
        Thread t1 = new Thread(()->{
           while (counter.count == 0){

           }
            System.out.println("t1进程运行结束");
        });
        t1.start();
        Thread t2 =  new Thread(()->{
            System.out.println("请输入一个整数:");
            Scanner scanner = new Scanner(System.in);
            counter.count = scanner.nextInt();
        });
        t2.start();
    }
}

image-20220914200643504

此时编译器每次都会去读取内存中的数据

volatile能解决内存可见性问题,也就是一个线程读,一个线程修改的情况,它并不能保证原子性的问题

要是两个线程修改同一个变量还得是synchronized来保证原子性

public class Demo17 {
    static  class Counter{
        volatile int count = 0;//只能解决内存可见性
         public  void increase(){
             count ++;
         }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(()->{
           for(int i = 0; i<5000; i++){
               counter.increase();
           }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = "+ counter.count);
    }
}
//两个线程修改同一个变量,还是要 synchronized来保证原子性

要是谈到volatile就一定要知道JMM(Java Memory Model) Java内存模型

  1. 通俗地讲:

volatile禁止了编译器优化,避免了直接读取从CPU寄存器缓存的数据,而是每次都会重新读内存

Java语言为了更加通用,尽可能 避免硬件的差异,就起了一些术语

CPU寄存器–>工作内存(work memory) 内存—> 主内存(main m emory )

  1. 站在JMM角度看volatile:

正常程序执行的过程中,会把主内存的数据先加载到工作内存中,再进行计算处理,要是编译器进行优化,就不会每次都去主内存中读取,而是直接去工作内存读取, 这样就会导致内存可见性问题

volatile 起到的效果就是 ,保证每次读取内存都是真的才能够主内存中读取

注意: 工作内存不是真的内存,它只是CPU的寄存器,这是术语而已

wait 和 notify

多线程中总是会出现抢占式执行,可以使用wait 和 notify 来控制线程的执行顺序\

线程1调用了wait,线程1 就会阻塞,直到别的线程调用notify之后,线程1 才会继续执行

package Threading;

public class Demo18 {
    public static void main(String[] args) {
        Object object = new Object();
        System.out.println("wait之前");
        try {
            object.wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("wait之后");
    }
}

这样写的结果,会报错

image-20220914223718232

不合法的锁状态异常

wait内部会进行一下三个操作:

  1. 释放当前的锁
  2. 进行等待
  3. 当有别的线程调用 notify 时,就会被唤醒,然后重新获取锁

所以要想要释放当前的锁的前提就是要先加上锁

package Threading;

public class Demo18 {
    public static void main(String[] args) {
        Object object = new Object();
        synchronized(object){  //先加上锁,wait之后object就会解锁,其他的线程会获取到锁
            System.out.println("wait之前");
            try {
                object.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("wait之后");
        }

    }
}

要想使用wait 和 notify 就一定要先加上锁

举一个例子来理解wait与notify执行的具体顺序

package Threading;

public class Demo19 {
    public static void main(String[] args) {
        Object object = new Object();//创建 一个对象
            Thread t1 = new Thread(()->{
                 while (true) {
                	synchronized (object) {
                        System.out.println("wait之前");
                        try {
                            object.wait();//前面加上锁
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println("wait之后");
                    }
                }
            });
            t1.start(); 
            Thread t2 = new Thread(()->{
                while (true){
                    synchronized (object){
                        System.out.println("notify之前");
                        object.notify();//前面已经加上锁
                        System.out.println("notify之后");
                    }
                    try {
                        Thread.sleep(10000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            t2.start();
    }
}

image-20220915144645447

就像是上面写的,t1先调用了wait, t2后调用notify, 此时notify就会唤醒wait

但是,要是t2先执行了 notify , t1 后执行wait , 或者干脆就不调用wait , 其实也没有什么事,只是不符合上面的规定罢了,什么都不会发生

notifyAll : 唤醒所有被wait的线程,

wait 与 notify 总结

wait notify是 用来控制多线程直接的执行先后顺序的

  1. wait 和 notify 都要先进行上锁(synchronized)
  2. 必须是同一个对象调用wait 和 notify
  3. 锁对象也要和 调用wait / notify 的对象一致
  4. 就算没有wait , 直接notify 也是没有副作用的

wait 与 sleep 的区别

首先要知道,wait 和 sleep 都是 让线程进入阻塞等待的状态

  1. 两个方法所属类不一样, sleep是thread 类的方法, wait是Object类的方法

  2. sleep是 通过时间来控制何时唤醒线程, wait 是 其他的线程通过notify 唤醒线程的(但是wait还有一个重载版本 , 参数可以传入时间, 表示等待的最大时间)

  3. 有无释放锁: 在调用wait之前, 必须要保证已经请求到锁, 调用之后会释放掉已经获得的锁,唤醒之会重新请求锁, sleep就不涉及到锁

练习

有三个线程,线程名称分别为:a,b,c。

每个线程打印自己的名称。

需要让他们同时启动,并按 c,b,a的顺序打印

public static void main(String[] args) {
    Thread tc = new Thread(()->{
        System.out.println("c");
    });
    Thread tb = new Thread(()->{
        try {
            tc.join();//等待tc线程结束
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("b");
    });
    Thread ta = new Thread(()->{
        try {
            tb.join();//等待tb线程结束
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("a");
    });
    ta.start();
    tb.start();
    tc.start();
}

总结来说,就是线程c先开始, b等c结束再开始, a等b结束再开始

进阶版

有三个线程,分别只能打印A,B和C

要求按顺序打印ABC,打印10次

输出示例:

ABC

ABC

ABC

ABC

ABC

ABC

ABC

ABC

ABC

ABC

public  static  boolean isThreadA = true;
public  static  boolean isThreadB = false;
public  static  boolean isThreadC = false;

public static void main(String[] args) {
     final Test test = new Test();//其实创建一个Object对象也是一样的
    Thread t1 = new Thread(()->{
        for (int i = 0; i < 10; i++) {
            synchronized (test){
                while (!isThreadA){
                    try {
                        test.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.print("A");
                isThreadA = false;
                isThreadB = true;//交给线程2
                isThreadC = false;
                test.notifyAll();//唤醒
            }
            //以上的代码必须要synchronized里面,保证原子性
        }
    });

    Thread t2 = new Thread(()->{
        for (int i = 0; i < 10; i++) {
            synchronized (test){
                while (!isThreadB){
                    try {
                        test.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.print("B");
                isThreadA = false;
                isThreadB = false;
                isThreadC = true;
                test.notifyAll();
            }
        }
    });

    Thread t3 = new Thread(()->{
        for (int i = 0; i < 10; i++) {
            synchronized (test){
                while (!isThreadC){
                    try {
                        test.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.print("C");
                isThreadA = true;
                isThreadB = false;
                isThreadC = false;
                test.notifyAll();
                System.out.println();
            }
        }
    });
    t1.start();
    t2.start();
    t3.start();
}

这道题就不好向上面一样使用join了,使用的是标识位 + 加锁 + wait notify 控制顺序

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。