图森未来面试官:Java并发中,自旋锁如何实现同步?

时间:2021-08-09
本文章向大家介绍图森未来面试官:Java并发中,自旋锁如何实现同步?,主要包括图森未来面试官:Java并发中,自旋锁如何实现同步?使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

关于自旋锁

我们知道自旋锁是实现同步的一种方案,它是一种非阻塞锁。它与常规锁的主要区别就在于获取锁失败后的处理方式不同,常规锁会将线程阻塞并在适当时唤醒它。而自旋锁的核心机制就在自旋两个字,即用自旋操作来替代阻塞操作。某一线程尝试获取某个锁时,如果该锁已经被另一个线程占用的话,则此线程将不断循环检查该锁是否被释放,而不是让此线程挂起或睡眠。一旦另外一个线程释放该锁后,此线程便能获得该锁。自旋是一种忙等待状态,过程中会一直消耗CPU的时间片。

实现方案

实际上自旋锁有多种实现方案,每种方案都是为了解决存在的缺点或为了适用其它场景。这里将介绍4中常见的实现方案,包括最原始的自旋锁、排队自旋锁、CLH锁以及MCS锁。每种实现方式都有自己的优缺点,下面我们详细看每种实现方案。

UMA架构

因为在分析CLH锁与MCS锁的缺点时会涉及处理器架构问题,所以在介绍每种自旋锁之前我们需要先了解两种处理器架构:UMA架构和NUMA架构。在多处理器系统中,根据内存的共享方式可以分为UMA(Uniform Memory Access)和NUMA(Non-uniform Memory Access),即统一内存访问和非统一内存访问。

UMA架构的性质就是每个CPU核访问主存储的时间都是一样的。下面看基于总线的UMA架构,一共有4个CPU处理器,它们都直接与总线连接,通过总线进行通信。从这个结构中可以看到,每个CPU都没有区别,它们平等地访问主存储。访问主存储所需的时间都是一样的,即为统一内存访问。

当某个CPU想要进行读写操作时,它首先会检查总线是否空闲,只有在空闲状态下才能允许其与主存储进行通信,否则它将等待直到总线空闲。为了优化这个问题,在每个CPU的内部引入缓存。这样一来,CPU的读操作就能够在本地的缓存中进行。但这时我们需要考虑CPU中缓存与主存的数据一致性问题,否则可能会引起脏数据问题。

NUMA架构

与UMA架构相反,NUMA架构中并非每个CPU对主存储的访问时间都相同的,NUMA架构中CPU能访问所有主存储。通过下图可以看到CPU如果通过本地总线来访问相应的本地主存储的话,则访问时间较短,但如果访问的是非本地主存储(远程主存)则时间将很长,也就是CPU访问本地主存和访问远程主存的速度不相同。NUMA架构的优点就在于,它具有优秀的可扩展性,能够实现超过百个CPU的组合。

自旋锁

最原始的自旋锁就是多个线程不断自旋,大家都不断尝试获取执行锁。看下面例子,主要看lock和unlock两个方法,Unsafe仅仅是为操作提供了硬件级别的原子CAS操作。对于lock方法,假如有若干线程竞争,能成功通过CAS操作修改value值为newV的线程即是成功获取锁的线程。它将顺利通过,而其它线程则不断在循环检测value值是否改回0,将value改为0的操作就是获取锁的线程执行完后对该锁进行释放。对于unlock方法,用于释放锁,释放后若干线程又继续对该锁竞争。如此一来,没获得锁的线程也不会被挂起或阻塞,而是不断循环检查状态。

原始自旋锁例子

我们通过下图来加深对自旋锁的理解,现有五条线程,它们都轮询value变量。t1率先成功修改value的值,即获取锁成功。将value设置为1后,其它线程都无法得到锁,只能继续不断循环检测。而当t1使用完锁后将value置为0,此时剩下的线程继续竞争锁。以此类推,这样就能保证某个区域的线程安全性。

自旋锁执行过程

原始自旋锁的缺点如下:

  • 它不具有公平性,不能保证先到的线程先获取锁,不管先来还是后到都得一起竞争锁。
  • 它需要保证各个CPU的缓存与主存之间的数据一致性,因此通讯开销很大,特别是在多处理器系统上更严重。

排队自旋锁

鉴于原始自旋锁存在公平性问题,于是引入一种排队机制来解决它,这就是排队自旋锁。所有线程在尝试获取锁之前得先拿到一个排队号,然后再不断轮询当前是不是已经轮到自己了,判断的依据就是当前处理号是否等于自己的排队号。如果两者相等,则表示已经轮到自己了,于是得到锁并往下执行。

看下面例子,主要看lock和unlock两个方法,Unsafe仅仅是为操作提供了硬件级别的原子CAS操作。对于lock方法,首先通过不断循环去尝试拿到一个排队号,一旦成功拿到排队号后就开始通过 while(processingNum != nowNum) 轮询看自己是否已经轮到了。而unlock方法则是直接修改当前处理号,直接加1,表示自己已经不需要锁了,可以让给下一位了。

如下图,每个线程一到达都会先去拿一个排队号,然后观察当前处理号是否等于自己所持有的排队号。如果排队号等于当前处理号,则获取锁成功,往下执行。

虽然说排队自旋锁解决了公平性问题,但是CPU的缓存与主存之间的数据一致性的问题还没有解决。每线程都对同一个变量操作将导致大量的同步操作,影响整体性能。

CLH锁

为了优化同步带来的花销,Craig、Landin、Hagersten三个人发明了CLH锁。其核心思想是:通过一定手段将所有线程对某一共享变量的轮询竞争转化为一个线程队列,且队列中的线程各自轮询自己的本地变量。

这个转化过程有两个要点:一是应该构建怎样的队列以及如何构建队列?为了保证公平性,我们构建的将是一个FIFO队列。构建的时候主要通过移动尾部节点tail来实现队列的排队,每个想获取锁的线程创建一个新节点并通过CAS原子操作将新节点赋给tail,然后让当前线程轮询前一节点的某个状态位。如图可以清晰看到队列结构及自旋操作,这样就成功构建了线程排队队列。二是如何释放队列?执行完线程后只需将当前线程对应的节点状态位置为解锁状态即可,由于下一节点一直在轮询,所以可获取到锁。

CLH锁

所以,CLH锁的核心思想是将众多线程长时间对某资源的竞争,通过有序化这些线程将其转化为只需对本地变量检测。而唯一存在竞争的地方就是在入队列之前对尾节点tail的竞争,但此时竞争的线程数量已经少了很多了。比起所有线程直接对某资源竞争的轮询次数也减少了很多,这也大大节省了CPU缓存同步的消耗,从而大大提升系统性能。

下面我们提供一个简单的CLH锁实现代码,以便更好理解CLH锁的原理。其中lock与unlock两方法提供加锁和解锁操作,每次加锁解锁必须将一个CLHNode对象作为参数传入。lock方法的for循环是通过CAS操作将新节点插入队列,而while循环则是检测前驱节点的锁状态位。一旦前驱节点锁状态位允许则结束检测,让线程往下执行。解锁操作先判断当前节点是否为尾节点,如是则直接将尾节点设置为空,此时说明仅仅只有一条线程在执行,否则将当前节点的锁状态位设置为解锁状态。

原文链接:https://learning.snssdk.com/feoffline/toutiao_wallet_bundles/toutiao_learning_wap/online/article.html?item_id=6738542775907648011&app_name=news_article

原文地址:https://www.cnblogs.com/QLCZ/p/15119122.html