Java并发(一)-- 线程安全(从“线程不安全”说起)

版权声明:本文为博主原创文章,转载请注明原文链接。 https://blog.csdn.net/hao2244/article/details/51212608

    提到“Java并发”,很多人直接会想到“线程安全”,咱现在就坐下来聊聊这个话题。不过,我的切入点是啥叫“线程不安全”。


    先简单说一说 "Java内存模型"

    我们在写并发程序的时候,对于线程间可以共享的变量,我们在线程中拿过来就用了,就像在同一个池子里捞同一条鱼一样。然而,在实际内存模型中可不是这么直接。为了提升程序性能,Java引入了缓存机制。线程间共享的变量都被储存在“主内存”中,主内存对所有有关线程均可见。每个线程都有自己的缓存(工作内存),且对其它线程不可见。当线程需要用到共享的变量时,会从主内存中copy一份,放到自己的缓存(工作内存)中,然后再对变量进行操作。来,上张图:


    注意:线程中的指令对共享变量的所有操作,本质上都是对工作内存的操作,而非直接和主内存打交道

    虚拟机对主内存和工作内存的操作有如下8个(每个操作都具备原子性):

    lock:锁定主内存中某变量的内存区域;

    unlock:和lock对应的解锁;

    read:从主内存中度变量的值;

    load:将read操作读来的值加载到工作内存;

    use:将工作内存中变量的值传入执行引擎(注意:只是指这个传输动作,不包括计算,这和代码层面的“使用”不同);

    assign:将执行引擎计算的结果写到工作内存;

    store:将变量的值从工作内存中拿出;

    write:将store拿出的值写到主内存.


    现在开始正式聊聊 "线程不安全"

    先上一段代码:
package com.test.thread;

public class Test
{
	private static int v=0;

	public static void main(String[] args){
		TestThread thread0 = new TestThread();
		TestThread thread1 = new TestThread();
		thread0.start();
		thread1.start();
	}
    
	private static class TestThread extends Thread
	{
		public void run(){
			v++;
		}
	}
} 

    最后Test类中的v属性的值是多少?是2?未必。

    这里v++在Java代码上来看,只有一个动作,但是当v++被翻译成成机器指令之后,它会被分解:
    1.将主内存的v值copy到工作内存;
    2.将工作内存中的v值传到执行引擎;
    3.执行引擎计算v+1的值;
    4.将执行引擎的计算结果写到工作内存;
    5.将工作内存中的v写到主内存.
    (注:以上5条描述就足以说明问题了,所以没有正经八板地用具体指令说话)
    我们知道,所谓并发,并不是真正的同时,而是各线程的快速切换,每个线程的指令流可能不会被连续执行,那总体指令流中就有可能出现如下排列(对照上面的Java代码, 红色代表 thread0蓝色代表 thread1,操作序号参照上面的5条描述):
    操作1, 操作1, 操作2,操作3,操作4,操作5 ,操作2,操作3,操作4,操作5。
    这条指令流导致的结果是 v的值为1,而不是我们所期望的2。我们发现,在 操作1操作5中间, thread0完成了对主内存的赋值, 操作5完成后, thread1的工作内存中的数据就已经过期了,然而 thread1并不知道,仍然以为自己工作内存中的数据是正确的是最新的,所以把0值传入到执行引擎,完成+1后传到主内存,这样主内存中v的最终结果是1。导致非期望结果的关键原因是在thread0更新了主内存后,thread1没有得知这个更新,自以为自己的工作内存中的数据是最新的。这个工作内存数据过期引起的工作内存和主内存不相等的问题叫做“数据一致性问题”。
    那这个工作内存和主内存之间数据一致性问题该咋解决呢?答案是用volatile关键字修饰Test类中的v属性:
package com.test.thread;

public class Test
{
	private static volatile int v=0;

	public static void main(String[] args){
		TestThread thread0 = new TestThread();
		TestThread thread1 = new TestThread();
		thread0.start();
		thread1.start();
	}
    
	private static class TestThread extends Thread
	{
		public void run(){
			v++;
		}
	}
} 
    volatile关键字通过使其修饰的变量的读与写具有原子性,保证了当需要用到某共享变量时,工作内存中的数据是最新的(普通变量的读会被分成read,load,use三个操作,写会被分成assign,store,write三个操作)。用前文提到的5个描述来说话就是在指令流中, 操作1操作2必须连续排列, 操作4操作5必须连续排列, thread1的操作也是同理。
    更改后的程序已经不存在工作内存和主内存之间的数据一致性问题了,绝大多数时候可以得到v=2这个结果。注意,是绝大多数时候。我们发现,有时候同样还会出现v=1这个“非期望”结果。那是为啥呢?因为虽然工作内存中的数据和主内存一致了,但是正在执行引擎中“翻滚”的数据却可能和主内存中不一致。如在添加了volatile之后,指令流可能是这样的排列:
     操作1, 操作2, 操作1, 操作2,操作3,操作4,操作5 操作3,操作4,操作5。
    在 操作2操作3之间, thread0完成了对主内存的赋值。虽然 thread1的工作内存会在下次读的时候得到及时刷新,可是在下次读工作内存之前, thread1还有v=0这个数据正在执行引擎中翻滚呀,它不会得到更新啊,最终执行引擎计算的结果是v=1,那最后 thread1赋给主内存的结果还是v=1。这个“执行引擎数据”和“主内存数据”之间的不一致问题咋解决呢?答案是用synchronized:
package com.test.thread;

public class Test
{
	private static int v=0;

	public static void main(String[] args){
		TestThread thread0 = new TestThread();
		TestThread thread1 = new TestThread();
		thread0.start();
		thread1.start();
	}
    
	private synchronized void increment(){
		v++;
	}

	private static class TestThread extends Thread
	{
		public void run(){
			increment();
		}
	}
} 
    在加了synchronized之后,volatile就可以省略了,因为synchronized语句块在进入时会强制从主内存中刷新数据,退出时会强制将工作内存中的数据写到主内存。
    
    总结:“ 线程不安全”的 本质就是“ 数据过期”,“数据过期”分为 两个级别的过期,即“ 工作内存数据过期”和“ 执行引擎数据过期”。保证线程安全主要是从两个角度考虑,即 可见性原子性(本文为了通俗易懂,暂时先不考虑 指令重排),保证 可见性是为了保证“ 工作内存数据”不过期,保证 原子性是为了进一步保证“ 执行引擎数据”不过期。
    
    最后来张图,



猜你喜欢

转载自blog.csdn.net/hao2244/article/details/51212608
今日推荐