多线程原子性可见性和有序性的理解之一

多线程原子性可见性和有序性的理解之一

Released Monday, 26th September 2022
Good episode? Give it some love!
多线程原子性可见性和有序性的理解之一

多线程原子性可见性和有序性的理解之一

多线程原子性可见性和有序性的理解之一

多线程原子性可见性和有序性的理解之一

Monday, 26th September 2022
Good episode? Give it some love!
Rate Episode



说到多线程的原子性、可见性和有序性。

这是多线程确保线程安全的三个标准。

首先。咱说说。原子性。原子性其实很好理解。原子就是最小的单元,他就是可执行的最小的单元。在程序执行的时候,最小的一个可执行单元就是一个原子。

一段原子性的代码执行的时候。不会被打断。这一段代码的执行,要么不执行,要么全部执行完毕。

这段代码也许只有一行代码,也许是多行代码。

一行代码很多也不是原子性的,因为这个原子性并非是我们Java程序代码的原子性,而是CPU执行 阶段的原子性。并不是说代码少他就是原子性。也不是说代码多的就不是原子性。一行代码很多都不是原子性的,多行代码加上锁,也可以是原子性的。

比如。定义一个整型int i = 10。这一行代码它就是原子性的,这里是给整形变量i赋值,这个就是原子性的,它不可能被打断。

如果定义的一个长整型的 long j=10,这就不是原子性的,为什么呢?因为这个long类型啊,他是64位的,64位的长整型。在CPU中执行赋值操作的时候,它是分两步,分别64位的高位和低位进行赋值,这是分两步来完成的。类似的double也是这样子,double也是64位的,会分两步分别给高32位和低32位赋值,就是两步操作,不是原子性的。

那么咱们来看另一个语句。int i=10; i++ ; 这个i=10我们刚刚说过了,是原子性的,那这个 i++是不是原子型的呢?直接说答案:i++不是原子型的。

别看这么简单的一个计算,写程序就一行,但到了CPU级别就需要3条指令,1 获取i的值, 2 执行i+1 的操作,3 把i+1的结果赋值给i 。所以这个i++不是原子性的。

这么简单的语句都不是原子性的,那么是不是对于多行代码就肯定不是原子性了呢?其实也不是,Java可以利用锁来保证多行语句是原子性的。

从CPU的角度来看,就是插入一个Lock指令。当一段代码被Lock指令锁住后,一个线程执行这段代码的时候,其他线程就无法执行,只能等着这个线程执行完毕才能获得执行权。这就是保证这段代码的原子性。

具体在Java代码里面写的时候就用synchronized 和lock对象,来实现一段代码,一个方法,一个对象的锁。这是对原子性的解释。


那么可见性是什么呢?在Java的内存模型中,每一个子线程会拥有一个单独的工作内存,主线程有一个主内存。主内存中存放的是共享变量,虽说是共享变量,看起来是被主线程和子线程共享的,那么子线程就可以读写操作该共享变量了吧?其实不行的,子线程是不能直接读写操作主内存中的共享变量的。

子线程会在工作内存中创建该共享变量的副本,然后读写操作该副本。这里再强调一下,子线程不能直接读写主内存的共享变量,只能读写工作内存中的共享变量的副本,读写完毕后再将副本的值回写到主内存中。

这时候,我们就发现一个问题了,假设主线程中有共享变量 x = 10 , 有两个子线程 A和B,要操作共享变量x,就会分别在各自的工作内存中加载一份x的副本。这时候咱们看到了,原来只有一个x,现在变成了3份:主内存中的x和两个副本x。

如果子线程A对副本x做了+1 操作,线程A中的x就变成 11 了,但线程B看到的x还是10,这就出现了可见性的问题。

通过上面的描述,总结一下什么是线程的可见性问题:就是多线程情况下,一个线程修改了变量的值,另一个线程看不到这个变化。

那怎么解决这个问题呢?思路是这样的,对于线程A来说,只要修改了x副本的值,马上把这个值回写到主内存中;对于线程B来说,只要想读x的值,就再次从主线程加载x,这样副本的值永远都与主内存中的值是一样的。这就可以解决可见性问题了。

在具体实现上Java内存模型是通过内存屏障来实现的。

那这里又引出一个内存屏障的概念,啥是内存屏障呢?内存屏障其实就是一个指令,将这个指令插入到其他指令之间,会执行某些特殊的操作。具体到解决可见性是两个内存屏障指令:load屏障和store屏障。 load和store这俩词儿之前讲内存模型的8个指令的时候见过,load指令是工作内存从主内存加载共享变量生成副本的指令,store指令是将工作内存的共享变量副本回写到主内存的指令。那么load屏障就呼之欲出了,这是在指令A前插入load屏障,那么指令A用到的变量副本将失效,必须从主内存加载对应的共享变量的值并替换当前副本;对应的store屏障插入到指令B之后,那么指令B修改的副本的值,马上会被回写到主内存。

这个效果应该可以想象了吧?通过load屏障和store屏障,可以实现修改了副本马上回写到主内存,读副本之前先从主内存加载一下,这样就确保了,只要副本值修改了马上就更新到主内存里面,然后另一个线程读副本的时候直接装载主内存中的新值,这样就解决了多线程下变量的可见性。


接下来讲解一下有序性。

什么是有序性呢?这里的序是指什么的顺序呢?

这里说的序是指CPU中指令执行的顺序,就是一条条的汇编指令的先后顺序。我们Java程序员写的是Java代码,写出来一行行的Java代码最后编译转为汇编在CPU中执行,这时候就是一条条的汇编指令了。一般程序员的理解是这样的,写了两行代码,行A在行B的前面,应该是先执行A再执行B,但其实CPU在指令执行的时候做了优化,如果指令A和指令B调换顺序后不影响执行结果,那么CPU会进行指令重排,重排后就是先执行指令B再执行指令A。这个原则叫做as if serial ,就是好像是连续的一样,也就是重排序优化后就好像没有重排序,而是正常连续执行的那样。

比如:

int i=10;

int j=20;

这样的两条指令,先执行哪条其实是无所谓的,这时候CPU就会执行指令重排。

对于单线程程序来说,CPU指令重排绝对不会有问题,但对于多线程指令重排就有可能产生数据安全问题。

打个比喻:

刘能和赵四去买冰糕,这个指令顺序是,推门进冷饮店,掏钱,拿冰糕。

如果就刘能自己去买冰糕的话,先掏钱再拿冰糕还是先拿冰糕再掏钱,没什么关系,冷饮店老板照顾的过来。

现在刘能和赵四俩人一起去,结果赵四掏钱了,还没拿冰糕,刘能先执行了拿冰糕,这下赵四不干了,我掏的钱,我掏的钱。老板说,没办法,人太多忙不过来,先后顺序调整一下,这样快,你别叫唤了。

那赵四能干嘛?赵四大叫:不行啊,出现多线程的有序性问题了。


那咋解决这个问题呢?还是下内存屏障。

有序内存屏障是在两条指令之间插入的屏障,插入后,前后两条指令就不能重排序了。

处理有序性的屏障有4个。

LoadLoad屏障:下在load和load之间,这个屏障指令之前的load指令和之后的load相关的指令不能重排序;

LoadStore屏障:下在load和store之间,这个屏障指令之前的load指令和之后的store指令不能重排序;

StoreLoad屏障:下在store和load之间,这个屏障指令之前的store指令和之后的load指令不能重排序;

StoreStore屏障:下载store和store之间,这个屏障指令之前的store指令和之后的store指令不能重排序。

说白了,这个有序性的内存屏障,就是禁止CPU对前后的指令进行重排序的,有了这个屏障,多线程下CPU也不能做重排序优化了,就解决了有序性的问题了。


关于内存屏障的细节,我们今儿就不聊了,以后我们再细说。

这个多线程的原子性、可见性和有序性是啥意思,你明白了吧?









Show More

Unlock more with Podchaser Pro

  • Audience Insights
  • Contact Information
  • Demographics
  • Charts
  • Sponsor History
  • and More!
Pro Features