线程安全

  • 1. 线程不安全的原因:
    • 1.1 抢占式执行
    • 1.2 多个线程修改同一个变量
    • 1.3 修改操作不是原子的
    • 锁(synchronized)
      • 1.一个锁对应一个锁对象.
      • 2.多个锁对应一个锁对象.
      • 2.多个锁对应多个锁对象.
      • 4. 找出代码错误
      • 5. 锁的另一种用法
    • 1.4 内存可见性
      • 解决内存可见性引发的线程安全问题(volatile)
    • 1.5 指令重排序

由于操作系统中,线程的调度是抢占式执行的,或者说是随机的,这就造成线程调度执行时,线程的执行顺序是不确定的,虽然有一些代码在这种执行顺序不同的情况下也不会运行出错,但是还有一部分代码会因为执行顺序发生改变而受到影响,这就会造成程序出现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 修改操作不是原子的

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

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

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

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

咦,怎么打印出来了-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 内存可见性

什么是内存可见性呢” />

正确答案是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是一个类型修饰符,作用是作为指令关键字,一般都是和const对应,确保本条指令不会被编译器的优化而忽略。

1.5 指令重排序

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

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

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

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

妈妈今天让小鱼去菜市场买菜,把买菜的清单列给了小鱼.

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

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

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

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

怎么只有西红柿,别的菜呢” />
此时呢,为了避免指令重排序产生的线程安全问题,我们需要将

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