Java线程不安全的原因

  • Post author:
  • Post category:java


线程不安全的原因

这个问题,一般在学Java SE时,我们老师会让我们背诵一段长长的话。”当不同线程同时能访问同一个变量时,可能会导致线程不安全”。实际上,这句话重点想突出的只有原子性。

而我们往往考虑线程不安全的原因,会从三方面进行考虑:就是原子性,可见性,有序性。

原子性

即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

原子性主要针对的是对任务粒度的描述。因为在CPU的层面上来说。它不会因为某一个线程正在执行某部分代码块儿,就不给这个代码块儿再分配其他线程进行执行了。

那么同一个代码块儿被不同线程同时访问,为啥就线程不安全了呢?

假设现在妈妈在厨房做席(吃饭的人较多),小明调皮捣蛋,老去搞破坏。

共有三份菜。鱼香肉丝,宫保鸡丁,番茄炒蛋做好了,此时要分给家里的大叔,二叔,三叔一起吃。此时妈妈指挥爸爸给各位叔叔端菜。并指挥你给菜里撒盐。爸爸给大叔端菜时,你给一个菜里多加了一勺盐。爸爸给二叔端菜时,你给两个菜里各加了一勺盐。爸爸给三叔端菜时,你给三个菜里各加了一勺盐。

等妈妈回头问各位叔叔,菜做的咋样。不用想,大叔说,有一盘菜做咸了。二叔马上接话到,不是两盘菜么?三叔马上又说道:不是三盘菜都很咸么???

这个故事就是原子性的问题。我们来挖掘一下小明和妈妈的真实目的。妈妈是想让所有叔叔吃到咸味刚好的菜,而小明是想让叔叔们都吃上特咸的菜。然而给三个菜加盐需要分成三次来进行。我们的爸爸的堪比CPU速度的爸爸,在小明还没捣蛋完毕前,就开始一直端菜,导致破坏了原子性,从而引发了线程安全问题。

从上面的故事来上,我们叔叔们(程序调用层)能接受的情况只有两种:

  1. 全香:妈妈做的饭
  2. 全咸:小明倒的蛋

但中间出现了只有一个菜咸,两个菜咸的情况,都不是出自小明和妈妈的本愿。

对应到我们的线程来讲。我们调用层不关心菜是香了还是咸了。我们只想完完整整的获取每个修改线程进行完整修改后的一个一致性快照!也就是全香,或是全咸。半香半咸的情况对于程序来说,是错误的。

对应到程序里的非原子性操作有很多。常见的比如我们常说的i++,实际上是分为了三步;

  1. 获取i值
  2. 执行i+1
  3. 给i重新赋值

由于多线程环境下,这三个操作不是原子性的。假设第三个线程同时获取i=0,分别执行i+1。那么在给i赋值阶段,就会把计算结果1覆盖2次。导致表面执行了三次i++,结果依旧是1。

我们常见的HashMap在发生线程安全问题时,也会在Hash冲突,给链表添加节点的过程中,发生节点覆盖,也是原子性问题。


要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。volatile是无法保证复合操作的原子性。

可见性

可见性怎么理解呢?举个例子:

//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;

首先,想要理解这个问题,得要稍微介绍一下我们的Java内存模型。实际在底层可以理解为,我们每一个共享变量都有其主内存,我们的私有线程在获取这个变量时,总会从主内存拷贝一个副本进入我们的工作内存。

假设线程1是在工作内存1中执行,线程2是在工作内存2中执行。当线程1在执行i=10时,会在工作内存1中将i的初始值修改为10,但并不会写入主内存中;那么此时执行线程2的时候,它会先去读取i的初始值,但工作内存2中i的值仍是0,那么j的值就是0而不是10。

这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

那么对于这样的问题,我们加锁能解决他么?

首先有一点可以肯定,在多线程里,没有锁搞不定的东西。但是锁消耗的成本与代价实在是太大了。除此之外,还有其他什么方法可以用较小的代价解决这个问题呢?

**对于可见性,Java提供了volatile关键字来保证可见性。**当一个共享变量被volatile修饰时,可以解决可见性问题。相当于屏蔽掉了我们每个线程的缓存区域,都直接操作主内存

在学习JMM时我们会更详细的进行讲解。

有序性

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,因为可能会发生指令重排序。

什么是指令重排序?

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

比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?

再举一个例子:

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

这段代码有4个语句,那么可能的一个执行顺序是:

语句2 -> 语句1 -> 语句3 -> 语句4

那么可不可能是这个执行顺序:

语句2 -> 语句1 -> 语句4 -> 语句3。

不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

**从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。**也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

在Java里面,可以通过volatile关键字来保证一定的“有序性”,volatile可以禁止指令重排序。也可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。



版权声明:本文为weixin_47184173原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。