关于i++是不是原子操作的问题

i++是不是原子操作?这个看似简单的问题,实则背后有很深的坑,今天就来踩踩这个坑。
之所以要讨论某个操作是不是原子操作,是因为一旦确认某个操作是原子操作的话,就不用为了去保护这个操作而加上昂贵又要耗费性能的锁。而在单核CPU中,中断只会发生在指令间,可以把能够在一个指令间完成的操作,看成是原子操作。
处理器虽然会自动保证基本的内存操作的原子性,但对于多核CPU的复杂的内存操作,处理器是不能保证其原子性的。这里举个i++的例子,因为i++是个经典的读改写操作。
首先运行一下代码:
这里写图片描述
运行这段代码的结果大部分情况下都是1000,这样i++的操作好像是原子操作的,而这也是我们所期望的值。但是当把循环次数增加10倍,100倍甚至更大的时候,每次所得的结果却都是不一样的。当我把上述代码稍作修改之后:
这里写图片描述
所得的结果:
这里写图片描述
同样的代码,只是加了一点其他的操作,结果就大不一样了,而且把循环次数或线程数增大,看到的结果更加是五花八门。为什么会出现这样的结果呢?其实从这里可以看出i++确实不是原子操作。
I++做了三次指令操作,两次内存访问,第一次,从内存中读取i变量的值到CPU的寄存器,第二次在寄存器中的i自增1,第三次将寄存器中的值写入内存。这三次指令操作中任意两次如果同时执行的话,都会造成结果的差异性。而对于++i,在多核机器上,CPU在读取内存时也可能同时读到同一个值,这样就会同一个值自增两次,而实际上只自增了一次,所以++i也不是原子操作。对于上述代码中第一次出现的结果,实际上是个原子操作的假象,因为线程数量少,循环次数也少,加上在执行时,虚拟机可能会对代码做部分优化,所以看起来结果是对的,而当我在代码中做一些其他的耗时的操作时,这种假象就不攻自破了。
那么,如何实现i++和++i的原子性呢?
在多核CPU的复杂内存操作中,处理器提供了总线锁和缓存锁两个机制来保证原子性,可以通过处理器提供的很多LOCK前缀的指令来实现。
总线锁:多个处理器可能会同时从各自的缓存中读取变量,并分别进行操作,在分别写入内存中,想要保证读改写共享变量的操作是原子的,就要使用总线锁来解决,即使用处理器提供的一个LOCK#信号,当有一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞,此时该处理器可以独占共享内存。
缓存锁:频繁使用的内存将会缓存在处理器的高速缓存里,内存区域如果被缓存到处理器的缓存行里,并且在Lock操作期间被锁定,那么当他执行锁操作回写到内存时,其他处理器会检查各自缓存行内的内存地址,如果发现自己的缓存行对应的地址被修改了,就会将缓存行置于无效状态,下次访问时,重新从内存中读取数据到缓存行,并允许它的缓存一致性机制(通常采用嗅探技术来实现,即缓存不仅仅是在内存传输的时候才和总线打交道,而是时刻不停的在窥探总线上发生的数据交换,并跟踪其他缓存在做什么,所以当一个缓存代表它所属的处理器去读写内存时,其他处理器都会得到通知,从而保证各个缓存保持同步)来保证操作的原子性。此时,处理器不会在总线上输出LOCK#信号。
总线锁会把CPU和内存之间的通信锁住,导致其他处理器不能操作其他内存地址的数据,所以总线锁的开销比较大,在某些场合下,会使用缓存锁来代替总线锁进行优化。
但是,当操作的数据不能被缓存在处理器内部或要操作的数据会跨多个缓存行时,处理器会调用总线锁。对于有些处理器不支持缓存锁,就算锁定的内存区域在处理器的缓存行,这时也会调用总线锁。

上一篇:Java中是如何实现原子操作的?

猜你喜欢

转载自blog.csdn.net/lx_Frolf/article/details/82254722