线程并发安全问题怎么解决(为什么线程能减少并发执行的开销)
导语:高并发必备篇(二)——线程为什么会不安全?
上期我们提到了的案例中,三个窗口线程卖票出现了有窗口卖的票是一样的问题,也就是得“线程不安全问题”,这篇文章我们就来聊聊“线程为什么会出现不安全”。
1. 什么是线程安全?
线程安全最早是由Brian Goetz 在其编写的“Java Concurrency In Practice”(Java并发编程实战)中定义的,它是这样来定义的:
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步,或者在调用方代码不做其他的协调操作,这个对象的行为获取的结果仍然是正确的,那个称这个对象是线程安全的。
在我们之前的三个线程卖票的案例,多个线程访问同一个对象的ticket 数据,但是我们发现操作这个对象获取的数据有的时候出现了重复性的错误结果,所以说我们之前写的卖票案例是“线程不安全”的。
虽然知道了三个线程访问ticket 出现重复数据的现象是线程不安全的,但是不知道为什么多线程并发访问数据不安全,所以接下来我们讲围绕“为什么线程并发访问不安全”来讲解。
2. 对象的有状态和无状态性
Java中按照状态可以把对象分为有状态和无状态两种。
无状态对象(Stateless Bean):无状态对象就是没有实例变量的对象,所以也无法保存数据,它不包含域也没有引用其他类地域。又因为无状态对象没有存储的数据那么这个对象也没有什么改变之说所以是不可变的,同样的多线程下对该对象的任意操作都不会改变对象的状态。所以“无状态的对象一定是线程安全的”。定义无状态案例如下:
有状态对象(Stateful Bean):就是有实例变量的对象 ,可以保存数据。
我们知道实例的数据是保存在堆中,而堆中的数据是可以被多个线程共享的(如上图)。
而在多线程同时访问相同堆中的数据进行读写操作时,就达到了竞态条件 导致多线程在竞争资源读写数据时最后的结果不会像我们预想的那样正确,出现线程不安全的情况。同时修改对象的数据对象的状态也被改变所以被称为有状态对象,定义案例如下:
3. 竞态条件
竞态条件是由于当一个对象或者一个不同步的共享状态,被多个线程修改时,会出现由于不恰当的执行时序而出现不正确的结果所引起。分析我们之前的卖票案例:
如果此时都在访问可共享的对象MyRunnable,如果此时“窗口1”和“窗口2”两个线程同时进入run方法并执行到了图中代码处。由于竞争下执行的代码时间线可能如下:
由图中可以看出,当“窗口1”线程获取到ticket数据的时候并判断ticket>0 的时候此时ticket为100,而“窗口2”线程此时正在输出ticket结果并且还是100,之后再“窗口1”线程输出100之后,才执行ticket--操作。
所以导致两个线程开始输出ticket的值的结果都为100;我们发现在竞态条件下多线程访问的数据是“脏数据”即错误的数据。
4. 指令重排和有序性
其实除了竞态的时候会出现不恰当的执行时序外,指令重排也会导致代码执行的顺序并不是按照你书写顺序的意愿执行的。
代码运行一般步骤是这样的:1、从主内存中获取指令解码2、在线程内存中计算值3、执行代码操作4、把结果写入主内存(主内存所有线程共享)而把结果写入主内存的操作比较耗时,CPU为了提高性能,可能不会等它完成,就进行对下一个指令解码计算,这就是指令重排了。定义如下:指令重排:计算机为了性能优化会对汇编指令进行重新排序,以便充分利用硬件的处理性能。
经典案例:
分析:虽然我们代码书写的顺序是“步骤1”、“步骤2”、“步骤3”,但是CPU在处理的时候会根据性能最优执行代码并保证最终结果不变。也就是说最后的步骤都是“步骤3”进行“a+b”运算,但是“步骤1”和“步骤2”执行的顺序可能为先三种:
(1)“步骤1”先于“步骤2”
(2)“步骤1”和“步骤2”同时间片内执行
(3)“步骤1”后于“步骤2”执行
指令重排会改变代码执行的顺序,但是因为最后执行的结果不变所以在单线程下是没有什么问题的。
而如果在多线程中,同时操作一个数据,如果一个读,一个写,当写的线程值已经改变了但是还没写入主内存时(也就是说值的改变其他线程还没有看到),另一个线程已经开始读取了,那么这个时候就会出现和预期不一致的结果。
其实现在对于多线程并发为什么会出现不安全的问题已经很清楚了,究其根本是因为多线程是不共享的,并且也无法准确的知道互相之间的状态,包括值的修改也无法可见才会导致修改数据出现问题,出现线程不安全的问题。
上面介绍了一个所有线程共享的主内存,那么主内存又是什么呢?线程运行的内存模型是怎么的呢?别急,下篇内容我们就介绍下线程模型和java线程的内存模型。
本文内容由小媛整理编辑!