多线程之基础篇

Posted by ShiYu on 2019-05-29

本文参考了你真的了解volatile关键字吗

并发基础概念

什么是线程安全

线程安全指的是:一段代码,在多线程环境下,线程的调度顺序不影响任何结果。并发编程中,共享资源被多线程访问时,如果不施加人为的控制和调度,往往会导致结果与预期不同,想了解为什么会出现这种情况,就需要理解java的内存模型是什么样的。

java内存模型

java内存模型规定了所有的变量都存储在主内存中。每条线程拥有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的副本(从主内存中拷贝得到),线程对变量的所有操作,都是在自己的工作内存进行,操作完成后,会将工作内存中变量最新的值写回主内存(写回主内存的时间是不可控的),不同线程无法直接访问其他线程工作内存中的变量,线程间变量值的传递只能通过主内存完成。基于此种内存模型,大家应该很快就能想到共享数据的“安全”问题产生的缘由。

举个例子:

1
i++;

在上述代码中,执行线程首先将i的初始值从主内存中拷贝到自己的工作内存中执行,赋值完成后,再将得到的结果写入主内存,而不是将结果直接写入主内存,假如此时有两个线程并发执行这段代码,假设i的初始值为0,我们期待线程执行完毕后,i的值为2,但是事实会如此吗?

可能存在如下情况:初始时,两个线程分别读取i的值写入自己的内存中,然后线程1进行加1操作,然后把最新的值1写入主内存,此时线程2工作内存中的i依然是0,它也进行加1操作,得到结果1,然后写回内存,此时程序执行完毕后,i的值为1,而不是我们期待的2,这就是典型的由于共享变量导致的线程安全问题。

那么如何保证共享变量在多线程访问时,依然能够输出预期的结果?解决这个问题之前,需要先了解并发编程的3大基础概念。

原子性

原子性:即一个操作或者多个操作,要么全部执行并且执行过程不会被任何因素打断,要么就都不执行。
理解原子性最经典的例子就是银行转账问题:A向B转账,必须确保A扣款与B收款这两个操作要么全部成功,要么全部失败,否则会发生什么大家自行脑补。

在java中,对基本数据类型变量的读取和赋值操作是原子性操作,其它操作均无法保证原子性。

例:

1
2
3
4
x=10; //语句1
y=x; //语句2
x++; //语句3
x=x+1; //语句4

在上述4条语句中,仅有语句1位原子性操作,其它3个都不是:

  • 语句1位基本类型直接赋值,线程执行该语句时,直接将数值10写入到工作内存中。
  • 语句2实际包含两个操作,首先要去读X的值,再将X的值写入到工作内存,尽管读取与写入这两个操作都是原子性操作,但是合并起来就不是了。
  • 语句3与语句4包含3个操作:读取x的值、进行加1操作、写入新的值

可见性

可见性:多线程同时访问一个变量时,当一个线程修改了这个变量的值,其它线程能够立即看到修改后的值。前面的java内存模型已经分析了最典型的可见性导致的共享数据脏读问题。

有序性

有序性:即程序的执行顺序按照代码的先后顺序执行。

实际上,程序真正的执行只能保证单线程环境下,程序最终的执行结果与代码顺序执行时一样的,它并不能保证程序严格按照代码顺序执行,但是who care?我们只要保证结果正确。出现这种现象的原因是处理器可能会发生指令重排序导致的。

比如下面代码:

1
2
3
4
5
int i = 0; //语句1
boolean flag = false; //语句2
//分割处
i++; //语句3
flag = true; //语句4

编译器或处理器可能会对输出代码进行优化,它的执行顺序可能会是2、1、3、4,但是对程序的执行结果并不影响。

重排序不会影响单线程的执行结果,但是对于多线程呢?

1
2
3
4
5
6
7
8
9
//线程1
context=init();//语句1
inited=true;//语句2

//线程2
if(inited){
System.out.println("初始化成功")
context.method1();
}

上面代码中,对于线程1来说,语句1与语句2执行顺序的改变不会改变最终结果,可能会导致重排序,假如发生重排序,先执行了语句2,此时线程2会认为context已经初始化成功,从而导致空指针异常。

想要保证线程安全,必须保证原子性、可见性以及有序性,只要有一个没有被保证,就可能会导致程序运行不正确。后续文章将会介绍java有哪些途径来保证这3个概念的实现。