java多线程之线程安全(重点,难点)

由于操作系统中,线程的调度是抢占式执行的,或者说是随机的,这就造成线程调度执行时,线程的执行顺序是不确定的,虽然有一些代码在这种执行顺序不同的情况下也不会运行出错,但是还有一部分代码会因为执行顺序发生改变而受到影响,这就会造成程序出现Bug,对于多线程并发时会使程序出现bug的代码称作线程不安全的代码.

本质原因: 线程在系统中的调度是无序的/随机的(抢占式执行)

1. 线程不安全的原因:

序号 线程不安全的原因
1 抢占式执行(罪魁祸首)
2 多个线程同时修改同一个变量
3 修改操作不是原子的
4 内存可见性
5 指令重排序

多线程不安全的原因主要分为一下三种:

1.原子性

  • 多行指令,如果指令前后有依赖关系,不能插入其他影响自身线程执行结果的指令

2.可见性

  • 系统调用CPU执行线程内,一个线程对共享变量的修改,另一个线程能够立刻看到

3.有序性

  • 程序执行的顺序按照代码的先后顺序执行(处理器可能会对指令进行重排序)

1.1 抢占式执行

我们通过下面的代码来进行讲解:

class Demo{
    private static int count;
	//
    public static void countAdd(){
        count++;
    }
	//返回count
    public static int getCount() {
        return count;
    }
}

public class ThreadDemo11 {

	public static void main(String[] args) throws InterruptedException {
	        Thread t1 = new Thread(()->{
	            for (int i = 0; i < 50000; i++) {
	            //执行50000次count++
	                Demo.countAdd();
	            }
	        });
	        Thread t2 = new Thread(()->{
	            for (int i = 0; i < 50000; i++) {
	            //执行50000次count++
	                Demo.countAdd();
	            }
	        });
	        //执行t1线程
	        t1.start();
	
	        //执行t2线程
	        t2.start();
	
	        //等待t1,t2线程执行完
	        t1.join();
	        t2.join();
	
	        //打印此时的count值
	        System.out.println(Demo.getCount());
    }

此时我们可以看到,线程 t1 和线程 t2 分别对count进行50000次的自增,那么我们最后打印的值应该是100000吧~

此时我们运行看一下结果:

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

大家会发现,每次的运行结果都不一样,并且没有一次的值是正确的,到底是为什么呢?

小鱼给大家画图解释下:

在这里插入图片描述

  • load: 从内存中将值读取到cpu寄存器中

  • add: 将cpu寄存器的值进行+1

  • save: 将寄存器的值读取到内存中

此时这里的结果错误,就是因为count++不是原子的而造成.

为什么这么说呢? 为了方便大家理解,我们用两个cpu内核来举例.内两个圆圆的东西就是 t1 的工作内存和 t2 的工作内存.

工作内存包含:cpu寄存器和缓存…

在这里插入图片描述

此时我们可以观看到,我们是在执行完 t1 线程的count++之后再去执行 t2 的count++的.此时我们的count是2,是正确的~~

但是呢,由于线程的随即调度,我们可能会存在这种情况:

在这里插入图片描述

由于线程是抢占式执行的,所以可能会存在,当 t1 线程刚进行 load 之后, t2 就也进行了 load,就是上图中左侧线程执行的顺序.当然,类似于这种插队式的组合方法多的数不清,上图运行的结果是count=1,但是我们的count已经自增两次了啊,应该是2的,此时出现的错误,就是线程安全的问题.

下面是一部分可能出现的随机排列情况:

在这里插入图片描述

1.2 多个线程修改同一个变量

上述的多线程安全问题就是因为多个线程对同一变量进行修改造成的.

大家看下面的表格:

原因 安全性
一个线程修改一个变量 安全
多个线程修改一个变量 不安全
多个线程读取多个变量 安全
多个线程修改多个不同变量 安全

1.3 修改操作不是原子的

原子性: 不可分割的最小单位.

在这里插入图片描述

通过上述过程我们知道,正因为有些操作不是原子的,导致两个或多个线程的指令排序存在更多的变数,自然就引发线程不安全的问题.

关于内存可见性,和指令重排序在后面讲到…

那我们如何解决上述的线程安全问题呢?

我们通过count++ 举例,如何让count++ 变成原子的呢?

我们可以通过加锁的方式将count++ 变成原子的.

锁(synchronized)

锁的核心操作分为两个:

(1) 加锁 : 当我们进入这个房间之后,别人就无法进入这个房间.

(2) 解锁 : 只有我们打开门,释放锁之后,别人才可以进入这个房间.

如果我进入这个房间之后,还有别人想要进入,就需要等我释放锁,走出这个房间才可以进入,别人在门口等待我的过程称为"阻塞".

当然,由于线程是抢占式执行的,所以如果我想进入这个房间就需要和那些都想进入这个房间的人进行争抢,当我进入这个房间之后,此时一群人只能在外面等着我,不能干别的事情,当我出了这个房间之后,他们又会开始新一轮的争抢,如果我还有进入房间的需要,我也会再次和他们一起争抢.

锁(synchronized) 这个关键字还有一个参数需要传进去,这个参数的类型需要是object或者它的子类

1.一个锁对应一个锁对象.

关于这个锁对象的用途,这里通过生活中的例子讲解…

我们假设有两个老师,A,B他们都是教英语的,学校规定,这个学期英语课只有50节,工资按照你们每个人上课的次数决定,由于上课的教室只有一个,所以A,B老师先到先得.因为老师上课的时候不能被打扰,所以规定,这个教室一次只能进入一个老师,假设A老师在讲课,那么B老师如果想讲课的话只能等着,且不能做别的事情,只能等A老师讲完课并且出门之后,再次和A老师竞争上课的资格.

上面涉及到的知识点有:

(1) A,B老师先到先得(抢占式执行)(也可以称为锁竞争)

(2) 学校规定,一次只能进去一个老师(加锁)

(3)如果A老师讲课,B老师如果想讲课的话只能等着(阻塞)

(4) 只能等A老师讲完课并且出门之后(执行完锁内的代码块,并释放锁)

也可以用代码举例:

我们这里假设两个老师讲的课程一模一样,这50节课的内容从来没变.

class Teacher{
    static Object object = new Object();
    public static void AttendClass(){
        //此时两个进程都调用这个方法,为了防止老师讲课被打扰
        //对这个讲课进行加锁
        //此时这个锁的参数,也可以称为锁对象
        //我们可以将锁对象理解为教室,
        synchronized (object){
        //课程
            System.out.println("ABCDEFG....");
            ThreadDemo14.size--;

        }
    }
}
public class ThreadDemo14 {
   static int size = 50;
    public static void main(String[] args) {
        Thread A = new Thread(()->{
            while (size >= 0)
                    Teacher.AttendClass();
        }) ;
        Thread B = new Thread(()->{
            while (size >= 0)
                    Teacher.AttendClass();
        }) ;

        A.start();
        B.start();
    }
}

上述讲的例子是只在教室里面讲英语(只有一个锁,且只有一个锁对象).下面这个例子是不仅仅是讲英语…

2.多个锁对应一个锁对象.

依旧是先语言表达一下:

A,B两个老师,A英语老师,B语文老师,但是呢,只有一个教室可以用来上课,此时A,B老师就会为了这个教室的使用权而争抢,没有抢到的老师只能等着,依旧是50节课,谁上的课多谁工资高!

代码举例:

class Teacher{
    static Object object = new Object();
    public static void EnglishClass(){
    //注意这里
        synchronized (object){
            System.out.println("ABCDEFG...."+ThreadDemo14.size);
            ThreadDemo14.size--;

        }
    }

    public static void ChineseClass(){
    //注意这里
        synchronized (object){
            System.out.println("鹅鹅鹅,曲项向天歌...."+ThreadDemo14.size);
            ThreadDemo14.size--;

        }
    }
}
public class ThreadDemo14 {
   static int size = 50;
    public static void main(String[] args) {
        Thread A = new Thread(()->{
            while (size >= 0)
                    Teacher.EnglishClass();
        }) ;
        Thread B = new Thread(()->{
            while (size >= 0)
                    Teacher.ChineseClass();
        }) ;

        A.start();
        B.start();
    }
}

上述代码和之前的代码的区别就是,我上了两个锁,但是锁对象一样(多个锁,但是只有一个锁对象),这是什么意思呢? 意思就是,虽然我们讲的是不同的课程(锁的代码块不一样),但是这个锁对象一样,意味着我们需要在同一个教室讲课,一次只能有一个老师讲课.

更通俗点理解就是:不管你多少个老师,不管你教什么,只要是同一个教室(锁对象一样)那么,你就只能等这个教室空出来才能用…在此期间

2.多个锁对应多个锁对象.

但是呀,如果多个老师争抢一个教室,那么一定会引发不满的,所以个,学校就多建造了几个教室,那么这些老师就可以在不同的教室上课了…

class Teacher{
//教室1
    static Object classroom1 = new Object();
  //教室2
    static Object classroom2 = new Object();
    public static void EnglishClass(){
    //此时锁对象不同,意味着在不同的教室
        synchronized (classroom1){
            System.out.println("ABCDEFG...."+ThreadDemo14.size);
            ThreadDemo14.size--;

        }
    }

    public static void ChineseClass(){
    //此时锁对象不同,意味着在不同的教室
        synchronized (class2room){
            System.out.println("鹅鹅鹅,曲项向天歌...."+ThreadDemo14.size);
            ThreadDemo14.size--;

        }
    }
}

此时我们大概了解锁对象的意思了,科学一点的解释如下:

总结:
(1) 多个代码块使用了同一个同步监视器 ( 锁 ) , 锁住一个代码块的同时,也锁住所有使用该锁的所有代码块,其他线程无法访问其中任何一个代码块.

(2) 多个代码块使用了同一个同步监视器 ( 锁 ) , 锁住一个代码块的同时,也锁住了所有该锁的所有代码块,但是没有锁柱使用其他同步监视器的代码块,其他线程有机会访问其他同步监视器的代码块.

4. 找出代码错误

你也为锁这块讲完了吗?

天真! 上面还有一处错误! 不知道同学发现了没有…

我们来运行下两个英语老师的那个例子:
在这里插入图片描述

咦,怎么打印出来了-1,这是为什么呢?

有一种情况我们没有考虑到,就是…

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

那么怎么解决呢?

在这里插入图片描述
此时的运行结果:

在这里插入图片描述
可能这个方法不是最优的,因为每次进入锁又需要进行一次if(),这些都是时间上的开销!!!

5. 锁的另一种用法

我们可以用synchronized来修饰方法,如果是用锁来修饰方法的话,我们不需要给这个锁设置参数,如果这个方法是静态的,那么这个锁的对象就是类名(该方法所处的类).class,如果是非静态的方法,那么谁调用这个方法,谁就是锁对象,也就是this.

代码如下:

非静态方法:

   public  void func1(){
        synchronized (this){
            
        }
    }
    //两者等价
    synchronized public void func2(){
        
    }

静态方法:

class A1{
    public static void func1(){
        synchronized (A.class){

        }
    }
    //两者等价
    synchronized public void func2(){

    }

}

1.4 内存可见性

什么是内存可见性呢? 大家看下面的代码,猜猜运行结果!

public class ThreadDemo15 {
    static boolean flog = false;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while (!flog){
                //什么都不打印
            }
            //循环执行结束
            System.out.println("循环执行结束");
        });

        t1.start();

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flog = true;
        System.out.println("flog改为true");
    }
}

两个选项:

A. 打印完flog之后就程序结束
B. 打印完flog之后程序没有结束

现在请看运行结果…

在这里插入图片描述

正确答案是B,为什么呢?

小鱼为大家解答.

我们通过一个例子来解释:

一天呢,玉帝派给孙悟空一个任务,要求他一直看着唐僧,看他会不会怀孕…
这孙悟空一听,男的会怀孕? 那不可能啊…

于是玉帝每次问孙悟空,孙悟空看都不看唐僧一眼就说没怀孕,不知过了多久,唐僧因为喝了女儿国的水,怀孕了!!! 就在唐僧怀孕之后,玉帝问孙悟空,你师傅怀孕了嘛?孙悟空依旧说不屑的回答:“没有”,殊不知唐僧孩子都快生出来了…

我们刚才的程序为什么会在我修改flog之后也一直在继续执行呢?

是因为,while(flog != false) 需要两步,从内存中读取flog的值到自己的寄存器,再将寄存器的值和false比较,由于呢~读内存(load)的操作很是麻烦,所以编译器就自作主张,想要优化这个代码,于是就不再去内存中读取了,直接将自己寄存器的值和false进行比较,但是这一不读取就出现了意外,代码内心: 你小子看我一眼啊,我都变了,我都变成true了,你丫还在循环!!!

上面出现线程安全的主要原因就是编译器优化,因为读内存的操作比读寄存器要慢几千倍,所以编译器为了运行效率,擅自做了决定.

所谓内存可见性就是在多线程的情况下,编译器对于代码优化,产生了误判,从而引起的一系列Bug,进而导致咱们的代码bug了.

解决内存可见性引发的线程安全问题(volatile)

我们对比这个代码:

public class ThreadDemo15 {
    static boolean flog = false;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while (!flog){
                //什么都不打印
                //加入了一个时间限制
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            //循环执行结束
            System.out.println("循环执行结束");
        });

        t1.start();

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flog = true;
        System.out.println("flog改为true");
    }
}

运行结果:

在这里插入图片描述

这个代码有sleep,加上sleep循环执行速度就变得很慢,当循环次数下降了,此时load不再是负担,编译器就没必要优化了.

但是我如果我就想让循环空转,并且还不能出错,该怎么处理?

咱么可以让编译器针对这个场景暂停优化.

如何做到呢?

有请接下来的主角: volatile

我们通过使用volatile关键字修饰变量,此时该变量就会禁止编译器优化,能够保证每次都是从内存中重新读取数据.

在这里插入图片描述

volatile是一个类型修饰符,作用是作为指令关键字,一般都是和const对应,确保本条指令不会被编译器的优化而忽略。

1.5 指令重排序

volatile还有一个用处就是禁止指令重排序.

指令重排序也是编译器优化的策略,调整了代码的执行顺序,让程序更高效.

前提: 保证代码逻辑不变,并且调整之后的结果要和之前是一样的.

关于指令重排序,小鱼给大家举个例子吧…

妈妈今天让小鱼去菜市场买菜,把买菜的清单列给了小鱼.
在这里插入图片描述

小鱼发现,如果按照清单上的顺序购买,比较浪费时间.

在这里插入图片描述

小鱼于是呢,就想换个路线…

在这里插入图片描述

当小鱼买完西红柿之后呢,妈妈给小鱼打电话,问小鱼买完西红柿了嘛,小鱼说买完了,妈妈说,那就抓紧回家吧~~

此时小鱼就到了家里,妈妈看到小鱼手里的西红柿陷入了沉思…

怎么只有西红柿,别的菜呢? 然后小鱼就挨打了,因为妈妈以为他是按照清单上的顺序去买的,当看到小鱼买完西红柿之后以他都买好了,就让他回来了,结果今天只能吃凉拌西红柿了…

我们也可以用代码来解释:

class Student {
	//成员变量
    static Student s;
    //成员方法
    public static Student getS() {
        return s;
    }
	//
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            s = new Student();
        });
        Thread t2 = new Thread(()->{
            if(s != null) {
                s.getClass();
            }
        });
		t1.start();
		t2.start();
    }
}
s = new Studnet();

这和new的过程可以大体分为三部分.

1.申请内存空间
2.调用构造方法
3.把对象的引用赋值给 s

如果是在单线程的环境下,1,2,3的指令可以发生重排序,1先执行,2和3谁先谁后都可以.

在单线程情况下,这种优化并不会出现什么问题,但是在多线程情况下就不好说了…

在这里插入图片描述
此时呢,为了避免指令重排序产生的线程安全问题,我们需要将

   volatile static Student s;//进行volatile修饰

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>