小陈:上一篇说了JAVA内存模型,但是后面说了在多线程并发操作的时候有可见性问题,我现在迫不及待想知道线程安全的可见性、原子性、有序性是啥了
老王:哈哈,可以。我先说说我自己对可见性、有序性、原子性的理解:
可见性
上一篇讲了,多个线程同时对某一个共享变量进行操作的时候,存在线程A的操作对线程B不可见的问题。简单来说就是线程A执行了某些操作对数据进行了变更;但是线程B并不知道,所以还是使用旧数据干它自己的活。
小陈:这么讲,按照概念来理解我还是很模糊啊,能不能搞个例子来讲解一下?
老王,没问题,我就结合上次你提的那个JAVA内存模型可能导致数据不一致的这个例子给你讲解一下
比如线程A和线程B都执行x++操作(x的初始值是0),线程A执行完了之后将主内存的值更新为1,但是线程B由于已经将 x = 0 读取进入自己的工作内存了,不知道线程A将x更新为1了,所以还是使用x=0去进行++操作。
像这种,就是典型的可见性问题,就是线程A操作了数据,但是线程B不可见,感知不到。
小陈:嘿嘿,看来上次我的猜测没错啊,无论是CPU缓存架构下还是JAVA内存模型都是有可见性的问题。
老王:没错,你说的这个问题是存在的,但是还是有些手段可以避免的,后面我们再来讨论。下面我们再来说一下有序性的问题
有序性
有序性是指由于JIT动态编译器、操作系统为了给提高程序的执行效率,可能会对按顺序书写好的指令进行重排,线程或者CPU执行的时候不一定按照程序书写的顺序来执行:
比如程序的书写顺序是 指令1 -> 指令2 -> 指令3;但是由于指令重排序,某个线程执行这几个指令的时候,比如说线程A执行的时候,可能先执行指令3,然后再执行指令2、指令1。导致别的线程,比如说线程B看到线程A的指令执行是乱序的。
我搞个代码给你讲解一下:
线程A在执行数据库、http客户端的初始化工作,初始化完毕之后将initOk初始化表示置为true表示初始化完毕。
// 步骤1 dataSource = initDataSource(); // 步骤2 httpClient = initHttpClient(); // 步骤3 initOK = true;
线程B在这里一直监听线程A是否初始化资源完毕,看到initOK标识为true表示初始化结束。开始执行业务操作,获取数据,根据数据发起网络调用。
// 步骤4 while(!initOK) { } // 步骤5 Object data = dataSource.getData(); // 步骤6 httpClient.request(data);
上面这段代码,正常来说线程A的执行顺序应该是 步骤1 -> 步骤2 -> 步骤3。但是由于JIT动态编译器或者操作系统可能对指令进行重排序,所以可能执行顺序是 步骤3 -> 步骤1 -> 步骤2。
这样就会导致线程B先看到了initOk = true,这样就会导致线程B直接跳出while循环,跳出等待,执行dataSource.getData方法,执行httpClient.request()方法;但是线程A的步骤1、步骤2还没执行dataSource、httpClient是null,会抛出空指针异常。
小陈:等等,我来理解一下;线程A先执行了initOK = true;导致线程B跳出了while循环,然后调用dataSource.getData方法,由于线程A还没执行dataSource = initDataSource()方法,所以dataSource对象可能是null值,这样线程B调用的时候可能抛出空指针异常,是这样吧?
老王:没错,理解得非常好,小陈你果然聪明啊;你这个理解力,我对后面讲解的文章越来越有信心了。
小陈:嘿嘿......
老王:上面这种有序性问题,在多线程并发执行的时候,由于指令的重排序存在,很可能是会发生的。
这就是有序性带来的线程安全问题,也就是线程B看到线程A的执行时乱序的,也就是不是按照步骤1、2、3这样顺序的来执行。
简单点来讲就是线程A还没初始化好,就将标识initOk设置为true。导致线程B误以为线程A搞定了,然后去获取数据,发起http请求,然后...,然后线程B就挂了...(线程B:线程A这坑爹的,还没初始化好就告诉我搞定了,这不是坑我嘛...)
老王:说了可见性、有序性的问题,下面我们再来说说原子性问题。
原子性
老王:原子性是说某个操作是不可分割的、不可中断的。
小陈:这个不可分割、不可中断是啥意思?
老王:
比如之前说的JAVA内存模型定义的8中操作;read、load、use、assign、store、write、lock、unlock等八种指令都是原子的。
老王:比如说read指令,不可分割:说的是这条指令是读取数据最小的指令了,不能再拆分成更多的指令
小陈:不可分割是不是说它就是最小的执行单元了,不能被拆分的意思?
老王:没错,就是这个意思.....
小陈:哦哦,这个不可分割我懂了,那不可中断又是啥玩意?
老王:简单来讲就是不能执行到一半就不干了,比如这个read指令,你不能读取一个变量的数据,只读取到一半的时候就撂挑子不干了;要执行就一起全部执行,不能干了一半就不干了,同时也不能被其它外部的因素打断了。
小陈:那意思是说cpu执行read指令,执行到一半的时候,就把这个线程挂起来,这个是不被允许的咯。
老王:哈哈,就是这样的。要干就全部都干了,不能中途搞了一半你跟我说退出了....
小陈:老王你真牛逼,这么晦涩的东西都被你三言两句简单的话就给说清楚了。
老王:那是,毕竟我可是单身十多年...;不,是工作十多年的老兵了,“技巧”早就磨练的杠杠的......
小陈:......
小陈:道理我是听明白了,实际编码里面那些操作是原子的,那些不是原子的呢?
老王:给你讲讲下面的例子就知道了:
比如下面的操作:
(1)y = 1;
(2)x++;
(3)z = y;
(1)其中y = 1操作是原子的,因为只是执行了load操作,将1直接load给y,只有一条指令的执行。
(2) x++操作就不是原子性的,之前画图讲解过,i++ 操作经过,read、load、use、assign、store、write等六个操作;虽然每个指令都是原子的,但是合并起来并不是原子的。
比如说线程A执行read和load操作将工作内存的变量x的值载入自己工作内存的变量副本中。但是还没来得及执行后续的use、assign、store、write指令,这个时候线程A就被挂起了。
线程A被挂起期间,线程B就也执行了read、load指令将变量x放入线程B的工作内存里了。这就相当于线程A的这6条指令没有连续执行完,被中断了,中途CPU又去执行别的指令了,并不是不可分割、不可中断的。
(3)z = y 也不是原子的,它先要执行read指令读取y的值,然后执行load执行赋值给z。并不是单一的原子指令
小陈:哇塞,老王你太牛逼了,你这么说我全懂了。
小陈:既然多线程并发操作的时候会有这些问题,那操作系统或者说JAVA底层是怎么解决这些问题达到并发安全的效果的呢?
老王:操作系统设计者肯定是会想到这些问题的,这就是我们下面要慢慢讲解的话题了,操作系统或者JAVA底层是怎么解决这些并发安全的问题的。
老王:小陈,给你个任务,你去看看MESI一致性协议的内容,下面我们讲解一下MESI一致性协议,以及MESI一致性协议是如何解决可见性问题的。