谈谈对getAndSet()方法的理解

1,004 阅读6分钟

本篇内容主要涉及到的知识点

  • cpu cache知识点
  • getAndSet的底层原理
  • 为自定义锁-队列锁做铺垫

写本篇的用意

本着分享和自我学习的心态抒写本篇内容,作者本人也是知识的搬运工所以仅此作为学习笔记而已。

知识来源

书籍:《多处理器编程的艺术》

直入主题-代码篇1-TAS(测试-设置-锁)

public class TASLock implements Lock {
    AtomicBoolean state = new AtomicBoolean(false);
    
    @Override
    public void lock() {
        while (state.getAndSet(true)) {
        }
    }
    @Override
    public void unlock() {
        state.set(false);
    }
}

直入主题-代码篇2-TTAS(测试-测试-设置-锁)

public class TTASLock implements Lock {
    AtomicBoolean state = new AtomicBoolean(false);

    @Override
    public void lock() {
        while (true) {
            while (state.get()) {
            }
            if (!state.getAndSet(true)) {
                return;
            }
        }
    }
    @Override
    public void unlock() {
        state.set(false);
    }
    
}

直入主题-问题篇

问题:

  1. TASLock和TTASLock的区别
  2. TASLock和TTASLock区别带来的变化
  3. 它们两者之间仅一行代码的差别为什么会有差异?

直入主题-问题分析篇

问题1:TASLock和TTASLock的区别

代码解译:   
从代码中可以直观看到这是一个自定义的自旋锁-沿用了AtomicBoolean里面的CAS原理
当获取lock()方法时会调用getAndSet()`原子性的设置新值并返回旧值`
值为true则表示锁被占用调用线程进行自旋,否则执行业务。
unLock()方法则将锁标识设置为fasle表示锁可用(未被占用)
区别:
TTASLock类中的lock()中自旋条件为:state.get(),而TASLock类中的自旋条件为:state.getAndSet()

问题2:TASLock和TTASLock的区别

先上图: 2-1.png

这个是并发测数据结果这里用来测试不同代码性能上所展示的差异(这个不是重点这里就不做讲解了),
左图是TTASLock,右图是TASLock。
它们分别预热了3次(目的:使用jvm充分对代码进行优化、使cpu忙碌起来。等到真实的性能数据)
预热完后它们分别执行了5次迭代,可以明显的看出来TTASLock耗时比TASLock耗时少(这个采用的是纳秒为的单位)
从Result的结果可以得到 
TTASLock执行5次迭代平均耗时:18.679纳秒。
TASLock执行5次迭代平均耗时: 20.120纳秒。

问题3: 它们两者之间仅一行代码的差别为什么会有差异

在进行讲解它们的差异之前我们先聊一聊cpu cache
关于cpu cache的知识本人在‘队列锁-基于数组的锁’这一篇文章中有简单的做过介绍这里就不重复介绍了。在这里会进行更加深入的讲解这方面的知识但仅限于和本章有关的知识面,后面会写一篇cpu cache知识的文章。

2-2.png 简单理解:

cpu缓存分为三级,一级和二级为cpu自身缓存,三级缓存为cpu共享缓存,缓存下面再是内存。 cpu缓存的容量没有内存容量大,但是访问数据速度却是它的几个钟周期,所以在访问数据的时候会优先访问cpu缓存,cpu缓存没有命中再去访问内存。

以一种最经典的多处理器系统结构为例: 处理器之间是通过一种称为总线(类似一个微型以太网)的共享广播媒介进行通信的。处理器和存储控制器都可以在总线上广播,但在一个时刻只能有一个处理器(或存储控制器)在总线上广播。所有的处理器(存储控制器)都可以监听。 当处理器从内存地址中读数据时,首先检查该地址及其所储存的数据是否已在它的cpu cache中,如果在cpu cache中,那么处理器产生一个cache命中,并可以立即加载这个值。 如果不存在,则产生一个cache缺失,且必须在内存或另一个处理器的cache中去查找这个数据。接着处理器在总线上广播这个地址。其他的处理器监听总线。如果某个处理器在自己的cache中发现这个地址,则广播该地址及其值来做出响应。如果所有处理器中都没有发现此地址,则以内存中该地址所对应的值来进行响应。

ok本章对cpu cache的相关知识讲解到这,下面切回正题。

getAndSet的底层原理

首先分析在共享总线系统结构中TASLock算法是怎样执行的。每个getAndSet()调用实质上是总线上的一个广播。由于所有线程都必须通过总线和内存进行通信,所以getAndSet()调用将会延迟所有的线程,包括那些没有等待锁的线程。更为糟糕的是,getAndSet()调用能够迫使其他的处理器丢弃它们自己cache中的锁副本,这样每一个正在自旋的线程几乎每次都会遇到一个cache缺失,并且必须通过总线来获取新的没有被修改的值。而比这更为糟糕的是,当持有锁的线程试图释放锁时,由于总线被正在自旋的线程所独占,该线程有可能会被延迟。现在可以理解为什么TASLock的性能如此之差。

下面分析当锁被线程A持有时TTASLock算法的执行行为。线程B第一次读锁时发生cache缺失,从而阻塞等待值被载入它的cache中。只要A持有锁,B就不断地重读该值,且每次都命中cache。这样,B不产生总线流量,而且也不会降低其他线程的内存访问速度。此外,释放锁的线程也不会被正在该锁上旋转的线程所延迟。

然而,当锁被释放时情况却并不理想。锁的持有者将false值写入锁变量来释放锁,该操作将会使自旋线程的cache副本立刻失效。每个线程都将发生一次cache缺失并重读新值,它们都(几乎是同时)调用getAndSet()以获取锁。第一个成功的线程将使其他线程失效,这些失效线程接下来又重读那个值,从而引起一场总线流量风暴。最终,所有线程再次平静,进入本地旋转。

本地旋转指线程反复地重读被缓存的值而不是反复地使用总线,这个概念是一个重要的原则,对设计高效的自旋锁非常关键。

通过上面一系列知识的讲解就不难理解TASLock和TTASLock之间存在性能差异的问题点所在了。

结语

抒写不易,可能作者写的不是很完美理解的不是很彻底~但是还是想各位有缘看到本篇的同行萌留下您们的`赞`哈哈。