2.线程安全问题


目录
  • 2.1 什么是线程安全?
    • 2.1.1 模拟售票案例
  • 2.2 JMM内存模型
  • 2.3 并发编程三大特性
    • 2.3.1 原子性
    • 2.3.2 可见性
    • 2.3 有序性
      • 2.3.1 重排序
      • 2.3.2 指令重排序
      • 2.3.3 存储子系统重排序
      • 2.3.4 貌似串行语义
      • 2.3.5 保证内存访问的顺序性

2.1 什么是线程安全?

当多个线程访问更改共享变量时候,就会出现线程安全问题。

1. 什么是线程安全问题?
   多线程操作共享变量,导致访问数据出问题。
   
2. 出现线程安全问题的条件
   有多个线程
   有共享数据
   其中一个线程修改了共享数据

2.1.1 模拟售票案例

/**
 * 需求:我们来模拟电影院的售票窗口,实现多个窗口同时卖“速度与激情8”这场电影票(多个窗口一起卖这100张票)。
 * 分析: 多个窗口相当于多线程, 每个窗口做的事情是一样的,卖100张票(任务放到Runnable中)
 */
public class C1_线程安全_售票案例 {
    // 总票数
    static  int ticket = 100;
    public static void main(String[] args) {
        Runnable runnable = () ->{
            // 循环买票
            while (true){
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                    if (ticket > 0) {
                        ticket--;
                        System.out.println(Thread.currentThread().getName()+
                                "卖了一张票,剩余:" + ticket);
                    } else {
                        // 票没了
                        break;
                    }
            }
        };

        // 创建3个线程
        Thread t1 = new Thread(runnable,"窗口1");
        Thread t2 = new Thread(runnable,"窗口2");
        Thread t3 = new Thread(runnable,"窗口3");
        t1.start();
        t2.start();
        t3.start();
    }
 }

测试结果:

这也是超买的的现象,为什么会出现这种情况呢?要从jvm的内存模型设计开始。

2.2 JMM内存模型

Java内存模型(即Java Memory Model,简称JMM)。
	JMM本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据。而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问。
	线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:

需要注意的是,JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式。
	JMM是围绕原子性,有序性、可见性展开的。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。

2.3 并发编程三大特性

正因为有了JMM内存模型,以及java语言的设计,所以在并发编程当中我们可能会经常遇到下面几种问题。线程安全问题表现为三个方面:原子性、可见性和有序性

2.3.1 原子性

原子性,即一个操作或多个操作,要么全部执行并且在执行的过程中不被打断,要么全部不执行。(提供了互斥访问,在同一时刻只有一个线程进行访问)

互斥锁:这种线程一旦得到锁,其他需要锁的线程就会阻塞等待锁的释放  (悲观锁)
CAS操作的就是乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。

1、synchronized (互斥锁)    
2、Lock(互斥锁)


3、原子类(CAS 乐观锁) 提供了许多原子性的类:AtomicInteger、AtomicIntegerArray等保证了线程安全

举个??:

如现实生活中从 ATM 机取款,对于用户来说,要么操作成功用户拿到钱,余额减少了,增加了一条交易记录;要么没拿到钱,相当于取款操作没有发生。

Java 有两种方式实现原子性:一种是使用锁;另一种利用处理器的 CAS(Compare and Swap)指令。

锁具有排它性,保证共享变量在某一时刻只能被一个线程访问

CAS 指令直接在硬件(处理器和内存)层次上实现,看作是硬件锁

2.3.2 可见性

在多线程环境中,一个线程对某个共享变量进行更新之后,后续其他的线程可能无法立即读到这个更新的结果,这就是线程安全问题的另外一种形式:可见性(visibility)
如果一个线程对共享变量更新后,后续访问该变量的其他线程可以读到更新的结果,称这个线程对共享变量的更新对其他线程可见,否则称这个线程对共享变量的更新对其他线程不可见。
多线程程序因为可见性问题可能会导致其他线程读取到了旧数据(脏数据)a

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。

// 演示可见性的案例
public class C2_线程安全_可见性案例 {
    private static boolean flag = false;
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            System.out.println("1号线程启动 执行while循环");
            long num =0;
            while (!flag){
                num++;
            }
            System.out.println("num = " + num);
        }).start();

        Thread.sleep(1000);

        new Thread(()->{
            System.out.println("2号线程启动 更改变量 flag为true");
            setStop();
        }).start();
    }

    private static void setStop(){
        flag = true;
    }
}
已经将结果设置为fasle为什么?还一直在运行呢。
原因:线程之间是不可见的,读取的是副本,没有及时读取到主内存结果。

使用: volatile  关键字即可保证变量的可见性
private static volatile boolean flag = false;  // 专用修饰变量,变量发生改变,会立刻被其他线程知道

2.3 有序性

程序执行的顺序按照代码的先后顺序执行。
一般来说处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。如下:

int a = 10;    //语句1
a = a + 3;    //语句2
int r = 2;    //语句3
r = a * a;     //语句4  

因为指令重排序(happen-before),他还可能执行顺序为 2-1-3-4,1-3-2-4,但绝不可能 2-1-4-3,因为这打破了依赖关系。
显然重排序对单线程运行是不会有任何问题,而多线程就不一定了,所以我们在多线程编程时就得考虑这个问题了。

2.3.1 重排序

在多核处理器的环境下,编写的顺序结构,这种操作执行的顺序可能是没有保障的:
编译器可能会改变两个操作的先后顺序;
处理器也可能不会按照目标代码的顺序执行;
这种一个处理器上执行的多个操作,在其他处理器来看它的顺序与目标代码指定的顺序可能不一样,这种现象称为重排序。
重排序是对内存访问有序操作的一种优化,可以在不影响单线程程序正确的情况下提升程序的性能。但是,可能对多线程程序的正确性产生影响,即可能导致线程安全问题。
重排序与可见性问题类似,不是必然出现的。

与内存操作顺序有关的几个概念:

源代码顺序,就是源码中指定的内存访问顺序。
程序顺序,处理器上运行的目标代码所指定的内存访问顺序。
执行顺序,内存访问操作在处理器上的实际执行顺序。
感知顺序,给定处理器所感知到的该处理器及其他处理器的内存访问操作的顺序

可以把重排序分为指令重排序与存储子系统重排序两种。
指令重排序主要是由 JIT 编译器引起的,指程序顺序与执行顺序不一样。
存储子系统重排序是由高速缓存,写缓冲器引起的,感知顺序与执行顺序不一致。

2.3.2 指令重排序

在源码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,我们就说发生了指令重排序。
指令重排是一种动作,确实对指令的顺序做了调整,javac 编译器一般不会执行指令重排序,而 JIT 编译器可能执行指
令重排序。
处理器也可能执行指令重排序,使得执行顺序与程序顺序不一致。

2.3.3 存储子系统重排序

存储子系统是指写缓冲器与高速缓存.
高速缓存(Cache)是 CPU 中为了匹配与主内存处理速度不匹配而设计的一个高速缓存,写缓冲器(Store buffer, Write buffer)用来提高写高速缓存操作的效率。即使处理器严格按照程序顺序执行两个内存访问操作,在存储子系统的作用下,其他处理器对这两个操作的感知顺序与程序顺序不一致,即这两个操作的顺序顺序看起来像是发生了变化,这种现象称为存储子系统重排序。
存储子系统重排序并没有真正的对指令执行顺序进行调整,而是造成一种指令执行顺序被调整的现象。存储子系统重排序对象是内存操作的结果,从处理器角度来看,读内存就是从指定的 RAM 地址中加载数据到寄存器,称为 Load 操作;写内存就是把数据存储到指定的地址表示的 RAM 存储单元中,称为 Store 操作,内存重排序有以下四种可能:
LoadLoad 重排序,一个处理器先后执行两个读操作 L1 和 L2,其他处理器对两个内存操作的感知顺序可能是 L2->L1
StoreStore重排序,一个处理器先后执行两个写操作 W1和 W2,其他处理器对两个内存操作的感知顺序可能是 W2->W1
LoadStore 重排序,一个处理器先执行读内存操作 L1 再执行写内存操作 W1,其他处理器对两个内存操作的感知顺序可能是 W1->L1
StoreLoad重排序,一个处理器先执行写内存操作W1再执行读内存操作 L1,其他处理器对两个内存操作的感知顺序可能是 L1->W1
内存重排序与具体的处理器微架构有关,不同架构的处理器所允许的内存重排序不同。内存重排序可能会导致线程安全问题。

2.3.4 貌似串行语义

JIT 编译器,存储子系统是按照一定的规则对指令内存操作的结果进行重排序,给单线程程序造成一种假象----指令是按照源码的顺序执行的,这种假象称为貌似串行语义。

并不能保证多线程环境程序的正确性

为了保证貌似串行语义,有数据依赖关系的语句不会被重排序,只有不存在数据依赖关系的语句才会被重排序。如果两个操作(指令)访问同一个变量,且其中一个操作(指令)为写操作,那么这两个操作之间就存在数据依赖关系。
如:

x = 1;
y = x + 1;

后一条语句的操作数包含前一条语句的执行结果;

y = x;
x = 1;

先读取 x 变量,再更新 x 变量的值;

x = 1;
x = 2; 

两条语句同时对一个变量进行写操作,如果不存在数据依赖关系则可能重排序,如:

double price = 45.8;
int quantity = 10;
double sum = price * quantity;

存在控制依赖关系的语句允许重排,一条语句(指令)的执行结果会决定另一条语句(指令)能否被执行,这两条语句(指令)存在控制依赖关系(Control Dependency)。如在 if 语句中允许重排,可能存在处理器先执行 if 代码块,再判断 if 条件是否成立。

2.3.5 保证内存访问的顺序性

可以使用 volatile 关键字、synchronized 关键字实现有序性