聊聊高并发(六)实现几种自旋锁

这一篇我们通过两种实现自旋锁的方式来看一下不同的编程方式带来的程序性能的变化。

先理解一下什么是自旋,所谓自旋就是线程在不满足某种条件的情况下,一直循环做某个动作。所以对于自旋锁来锁,当线程在没有获取锁的情况下,一直循环尝试获取锁,直到真正获取锁。

聊聊高并发(三)锁的一些基本概念 我们提到锁的本质就是等待,那么如何等待呢,有两种方式

1. 线程阻塞

2. 线程自旋

阻塞的缺点显而易见,线程一旦进入阻塞(Block),再被唤醒的代价比较高,性能较差。自旋的优点是线程还是Runnable的,只是在执行空代码。当然一直自旋也会白白消耗计算资源,所以常见的做法是先自旋一段时间,还没拿到锁就进入阻塞。JVM在处理synchrized实现时就是采用了这种折中的方案,并提供了调节自旋的参数。

这篇说一下两种最基本的自旋锁实现,并提供了一种优化的锁,后续会有更多的自旋锁的实现。

扫描二维码关注公众号,回复: 3417972 查看本文章

首先是TASLock (Test And Set Lock),测试-设置锁,它的特点是自旋时,每次尝试获取锁时,采用了CAS操作,不断的设置锁标志位,当锁标志位可用时,一个线程拿到锁,其他线程继续自旋。

缺点是CAS操作一直在修改共享变量的值,会引发缓存一致性流量风暴

 
  1. package com.test.lock;

  2.  
  3. // 锁接口

  4. public interface Lock {

  5.     public void lock();

  6.     

  7.     public void unlock();

  8. }

  9.  
  10.  
  11.  
  12.  
  13. package com.test.lock;

  14.  
  15. import java.util.concurrent.atomic.AtomicBoolean;

  16.  
  17. /**

  18. * 测试-设置自旋锁,使用AtomicBoolean原子变量保存状态

  19. * 每次都使用getAndSet原子操作来判断锁状态并尝试获取锁

  20. * 缺点是getAndSet底层使用CAS来实现,一直在修改共享变量的值,会引发缓存一致性流量风暴

  21. * **/

  22. public class TASLock implements Lock{

  23. private AtomicBoolean mutex = new AtomicBoolean(false);

  24.  
  25. @Override

  26. public void lock() {

  27. // getAndSet方法会设置mutex变量为true,并返回mutex之前的值

  28. // 当mutex之前是false时才返回,表示获取锁

  29. // getAndSet方法是原子操作,mutex原子变量的改动对所有线程可见

  30. while(mutex.getAndSet(true)){

  31.  
  32. }

  33. }

  34.  
  35. @Override

  36. public void unlock() {

  37. mutex.set(false);

  38. }

  39.  
  40. public String toString(){

  41. return "TASLock";

  42. }

  43. }


一种改进的算法是TTASLock(Test Test And Set Lock)测试-测试-设置锁,特点是在自旋尝试获取锁时,分为两步,第一步通过读操作来获取锁状态,当锁可获取时,第二步再通过CAS操作来尝试获取锁,减少了CAS的操作次数。并且第一步的读操作是处理器直接读取自身高速缓存,不会产生缓存一致性流量,不占用总线资源。

缺点是在锁高争用的情况下,线程很难一次就获取锁,CAS的操作会大大增加。

 
  1. package com.test.lock;

  2.  
  3. import java.util.concurrent.atomic.AtomicBoolean;

  4.  
  5. /**

  6. * 测试-测试-设置自旋锁,使用AtomicBoolean原子变量保存状态

  7. * 分为两步来获取锁

  8. * 1. 先采用读变量自旋的方式尝试获取锁

  9. * 2. 当有可能获取锁时,再使用getAndSet原子操作来尝试获取锁

  10. * 优点是第一步使用读变量的方式来获取锁,在处理器内部高速缓存操作,不会产生缓存一致性流量

  11. * 缺点是当锁争用激烈的时候,第一步一直获取不到锁,getAndSet底层使用CAS来实现,一直在修改共享变量的值,会引发缓存一致性流量风暴

  12. * **/

  13. public class TTASLock implements Lock{

  14.  
  15. private AtomicBoolean mutex = new AtomicBoolean(false);

  16.  
  17. @Override

  18. public void lock() {

  19. while(true){

  20. // 第一步使用读操作,尝试获取锁,当mutex为false时退出循环,表示可以获取锁

  21. while(mutex.get()){}

  22. // 第二部使用getAndSet方法来尝试获取锁

  23. if(!mutex.getAndSet(true)){

  24. return;

  25. }

  26.  
  27. }

  28. }

  29.  
  30. @Override

  31. public void unlock() {

  32. mutex.set(false);

  33. }

  34.  
  35. public String toString(){

  36. return "TTASLock";

  37. }

  38. }

针对锁高争用的问题,可以采取回退算法,即当线程没有拿到锁时,就等待一段时间再去尝试获取锁,这样可以减少锁的争用,提高程序的性能。

 
  1. package com.test.lock;

  2.  
  3. import java.util.Random;

  4.  
  5. /**

  6. * 回退算法,降低锁争用的几率

  7. * **/

  8. public class Backoff {

  9. private final int minDelay, maxDelay;

  10.  
  11. private int limit;

  12.  
  13. final Random random;

  14.  
  15. public Backoff(int min, int max){

  16. this.minDelay = min;

  17. this.maxDelay = max;

  18. limit = minDelay;

  19. random = new Random();

  20. }

  21.  
  22. // 回退,线程等待一段时间

  23. public void backoff() throws InterruptedException{

  24. int delay = random.nextInt(limit);

  25. limit = Math.min(maxDelay, 2 * limit);

  26. Thread.sleep(delay);

  27. }

  28. }

  29.  
  30. package com.test.lock;

  31.  
  32. import java.util.concurrent.atomic.AtomicBoolean;

  33.  
  34. /**

  35.  * 回退自旋锁,在测试-测试-设置自旋锁的基础上增加了线程回退,降低锁的争用

  36.  * 优点是在锁高争用的情况下减少了锁的争用,提高了执行的性能

  37.  * 缺点是回退的时间难以控制,需要不断测试才能找到合适的值,而且依赖底层硬件的性能,扩展性差

  38.  * **/

  39. public class BackoffLock implements Lock{

  40.  
  41.     private final int MIN_DELAY, MAX_DELAY;

  42.     

  43.     public BackoffLock(int min, int max){

  44.         MIN_DELAY = min;

  45.         MAX_DELAY = max;

  46.     }

  47.     

  48.     private AtomicBoolean mutex = new AtomicBoolean(false);

  49.     

  50.     @Override

  51.     public void lock() {

  52.         // 增加回退对象

  53.         Backoff backoff = new Backoff(MIN_DELAY, MAX_DELAY);

  54.         while(true){

  55.             // 第一步使用读操作,尝试获取锁,当mutex为false时退出循环,表示可以获取锁

  56.             while(mutex.get()){}

  57.             // 第二部使用getAndSet方法来尝试获取锁

  58.             if(!mutex.getAndSet(true)){

  59.                 return;

  60.             }else{

  61.                 //回退

  62.                 try {

  63.                     backoff.backoff();

  64.                 } catch (InterruptedException e) {

  65.                 }

  66.             }    

  67.             

  68.         }

  69.     }

  70.  
  71.     @Override

  72.     public void unlock() {

  73.         mutex.set(false);

  74.     }

  75.  
  76.     public String toString(){

  77.         return "TTASLock";

  78.     }

  79. }

  80.  
  81.  


回退自旋锁的问题是回退的时间难以控制,需要不断测试才能找到合适的值,而且依赖底层硬件的性能,扩展性差。后面会有更好的自旋锁实现算法。

下面我们测试一下TASLock和TTASLock的性能。

首先写一个计时的类

 
  1. package com.test.lock;

  2.  
  3. public class TimeCost implements Lock{

  4.  
  5. private final Lock lock;

  6.  
  7. public TimeCost(Lock lock){

  8. this.lock = lock;

  9. }

  10.  
  11. @Override

  12. public void lock() {

  13. long start = System.nanoTime();

  14. lock.lock();

  15. long duration = System.nanoTime() - start;

  16. System.out.println(lock.toString() + " time cost is " + duration + " ns");

  17. }

  18.  
  19. @Override

  20. public void unlock() {

  21. lock.unlock();

  22. }

  23.  
  24. }


然后采用多个线程来模拟对同一把锁的争用

 
  1. package com.test.lock;

  2.  
  3. public class Main {

  4. private static TimeCost timeCost = new TimeCost(new TASLock());

  5.  
  6. //private static TimeCost timeCost = new TimeCost(new TTASLock());

  7.  
  8. public static void method(){

  9. timeCost.lock();

  10. //int a = 10;

  11. timeCost.unlock();

  12. }

  13.  
  14. public static void main(String[] args) {

  15. for(int i = 0; i < 100; i ++){

  16. Thread t = new Thread(new Runnable(){

  17.  
  18. @Override

  19. public void run() {

  20. method();

  21. }

  22.  
  23. });

  24. t.start();

  25. }

  26. }

  27.  
  28. }


测试机器的性能如下:

CPU: 4  Intel(R) Core(TM) i3-2120 CPU @ 3.30GHz

内存: 8G

测试结果:

50个线程情况下:

TASLock平均获取锁的时间: 339715 ns

TTASLock平均获取锁的时间: 67106.2 ns

100个线程情况下:

TASLock平均获取锁的时间: 1198413 ns

TTASLock平均获取锁的时间: 1273588 ns

可以看到TTASLock的性能比TASLock的性能更好

对TTASLock的一种改进是BackoffLock,它会在锁高争用的情况下对线程进行回退,减少竞争,减少缓存一致性流量。但是BackoffLock有三个主要的问题:

1. 还是有大量的缓存一致性流量,因为所有线程在同一个共享变量上旋转,每一次成功的获取锁都会产生缓存一致性流量

2. 因为回退的存在,不能及时获取锁释放的信息,存在一个时间差,导致获取锁的时间变长

3. 不能保证无饥饿,有的线程可能一直无法获取锁

这篇会实现2种基于队列的锁,来解决上面提到的三个问题。主要的思路是将线程组织成一个队列,有4个优点:

1. 每个线程只需要检查它的前驱线程的状态,将自旋的变量从一个分散到多个,减少缓存一致性流量

2. 可以即使获取锁释放的通知

3. 队列提供了先来先服务的公平性

4. 无饥饿,队列中的每个线程都能保证被执行到

队列锁分为两类,一类是基于有界队列,一类是基于无界队列。

先看一下基于有界队列的队列锁。 ArrayLock有3个特点:

1. 基于一个volatile数组来组织线程

2. 通过一个原子变量tail来表示对尾线程

3. 通过一个ThreadLocal变量给每个线程一个索引号,表示它位于队列的哪个位置。

 
  1. package com.test.lock;

  2.  
  3. import java.util.concurrent.atomic.AtomicInteger;

  4.  
  5. /**

  6. * 有界队列锁,使用一个volatile数组来组织线程

  7. * 缺点是得预先知道线程的规模n,所有线程获取同一个锁的次数不能超过n

  8. * 假设L把锁,那么锁的空间复杂度为O(Ln)

  9. * **/

  10. public class ArrayLock implements Lock{

  11. // 使用volatile数组来存放锁标志, flags[i] = true表示可以获得锁

  12. private volatile boolean[] flags;

  13.  
  14. // 指向新加入的节点的后一个位置

  15. private AtomicInteger tail;

  16.  
  17. // 总容量

  18. private final int capacity;

  19.  
  20. private ThreadLocal<Integer> mySlotIndex = new ThreadLocal<Integer>(){

  21. protected Integer initialValue() {

  22. return 0;

  23. }

  24. };

  25.  
  26. public ArrayLock(int capacity){

  27. this.capacity = capacity;

  28. flags = new boolean[capacity];

  29. tail = new AtomicInteger(0);

  30. // 默认第一个位置可获得锁

  31. flags[0] = true;

  32. }

  33.  
  34. @Override

  35. public void lock() {

  36. int slot = tail.getAndIncrement() % capacity;

  37. mySlotIndex.set(slot);

  38. // flags[slot] == true 表示获得了锁, volatile变量保证锁释放及时通知

  39. while(!flags[slot]){

  40.  
  41. }

  42. }

  43.  
  44. @Override

  45. public void unlock() {

  46. int slot = mySlotIndex.get();

  47. flags[slot] = false;

  48. flags[(slot + 1) % capacity] = true;

  49. }

  50. <pre name="code" class="java">

  51. public String toString(){

  52.          return "ArrayLock";

  53.      }


 }

 

我们可以看到有界队列锁的缺点是:

1. 它必须知道线程的规模数,对于同一把锁如果线程获取的次数超过了n会出现线程状态被覆盖的问题

2. 空间复杂度是O(Ln)

3. 对于共享的volatile数组来保存线程获取锁的状态,仍然可能存在缓存一致性。我们知道CPU读取一次内存时,会读满数据总线的位长,比如64位总线,一次读取64位长度的数据。那么对于boolean类型的数组,boolean长度是1个字节,那么一次读取能读到8个boolean变量,而高速缓存的一个缓存块的长度也是64位,也就是说一个缓存块上可以保存8个boolean变量,所以如果一次CAS操作修改了一个变量导致一个缓存块无效,它实际上可能导致8个变量失效。

解决办法是把变量以8个长度为单位分散,比如flag[0] = thread1  flag[8] = thread2。这样的问题是消耗的空间更大。

无界队列锁可以克服有界队列锁的几个问题。

1. 它使用链表来代替数组,实现无界队列

2. 使用两个ThreadLocal变量表示指针,一个指向自己的节点,一个指向前一个节点

3. 使用一个原子引用变量指向队尾

4. 空间复杂度降低,如果有L把锁,n个线程,每个线程只获取一把锁,那么空间复杂度为O(L + n)

5. 对同一个锁,一个线程可以多次获取而不增加空间复杂度

6. 当线程结束后,GC会自动回收内存

 
  1. package com.test.lock;

  2.  
  3. import java.util.concurrent.atomic.AtomicReference;

  4.  
  5. /**

  6. * 无界队列锁,使用一个链表来组织线程

  7. * 假设L把锁,n个线程,那么锁的空间复杂度为O(L+n)

  8. * **/

  9. public class CLHLock implements Lock{

  10. // 原子变量指向队尾

  11. private AtomicReference<QNode> tail;

  12. // 两个指针,一个指向自己的Node,一个指向前一个Node

  13. ThreadLocal<QNode> myNode;

  14. ThreadLocal<QNode> myPreNode;

  15.  
  16. public CLHLock(){

  17. tail = new AtomicReference<QNode>(new QNode());

  18. myNode = new ThreadLocal<QNode>(){

  19. protected QNode initialValue(){

  20. return new QNode();

  21. }

  22. };

  23. myPreNode = new ThreadLocal<QNode>(){

  24. protected QNode initialValue(){

  25. return null;

  26. }

  27. };

  28. }

  29.  
  30. @Override

  31. public void lock() {

  32. QNode node = myNode.get();

  33. node.lock = true;

  34. // CAS原子操作,保证原子性

  35. QNode preNode = tail.getAndSet(node);

  36. myPreNode.set(preNode);

  37. // volatile变量,能保证锁释放及时通知

  38. // 只对前一个节点的状态自旋,减少缓存一致性流量

  39. while(preNode.lock){

  40.  
  41. }

  42. }

  43.  
  44. @Override

  45. public void unlock() {

  46. QNode node = myNode.get();

  47. node.lock = false;

  48. // 把myNode指向preNode,目的是保证同一个线程下次还能使用这个锁,因为myNode原来指向的节点有它的后一个节点的preNode引用

  49. // 防止这个线程下次lock时myNode.get获得原来的节点

  50. myNode.set(myPreNode.get());

  51. }

  52.  
  53. public static class QNode {

  54. volatile boolean lock;

  55. }

  56.  
  57. public String toString(){

  58.          return "CLHLock";

  59.      }

  60.  }

下面我们从正确性和平均获取锁的时间上来测试这两种锁。

我们设计一个测试用例来验证正确性: 使用50个线程对一个volatile变量++操作,由于volatile变量++操作不是原子的,在不加锁的情况下,可能同时有多个线程同时对voaltile变量++, 最终的结果是无法预测的。然后使用这两种锁,先获取锁再volatile变量++,由于volatile变量会防止重排序,并能保证可见性,我们可以确定如果锁是正确获取的,也就是说同一时刻只有一个线程对volatile变量++,那么结果肯定是顺序的1到50。

先看不加锁的情况

 
  1. package com.test.lock;

  2.  
  3. public class Main {

  4. //private static Lock lock = new ArrayLock(150);

  5.  
  6. private static Lock lock = new CLHLock();

  7.  
  8. //private static TimeCost timeCost = new TimeCost(new TTASLock());

  9.  
  10. private static volatile int value = 0;

  11. public static void method(){

  12. //lock.lock();

  13. System.out.println("Value: " + ++value);

  14. //lock.unlock();

  15. }

  16.  
  17. public static void main(String[] args) {

  18. for(int i = 0; i < 50; i ++){

  19. Thread t = new Thread(new Runnable(){

  20.  
  21. @Override

  22. public void run() {

  23. method();

  24. }

  25.  
  26. });

  27. t.start();

  28. }

  29. }

  30.  
  31. }


运行结果: 我们可以看到确实是发生的线程同时对volatile变量++的操作,结果是无法预料的

 
  1. Value: 1

  2. Value: 1

  3. Value: 2

  4. Value: 3

  5. Value: 4

  6. Value: 5

  7. Value: 6

  8. Value: 7

  9. Value: 8

  10. Value: 9

  11. Value: 10

  12. Value: 11

  13. Value: 13

  14. Value: 12

  15. Value: 14

  16. Value: 15

  17. Value: 16

  18. Value: 17

  19. Value: 18

  20. Value: 19

  21. Value: 20

  22. Value: 21

  23. Value: 22

  24. Value: 23

  25. Value: 24

  26. Value: 25

  27. Value: 26

  28. Value: 27

  29. Value: 28

  30. Value: 29

  31. Value: 30

  32. Value: 31

  33. Value: 32

  34. Value: 33

  35. Value: 34

  36. Value: 35

  37. Value: 36

  38. Value: 37

  39. Value: 38

  40. Value: 37

  41. Value: 39

  42. Value: 40

  43. Value: 41

  44. Value: 42

  45. Value: 43

  46. Value: 44

  47. Value: 45

  48. Value: 46

  49. Value: 47

  50. Value: 48

  51. Value: 50


使用有界队列锁:

 
  1. package com.test.lock;

  2.  
  3. public class Main {

  4. private static Lock lock = new ArrayLock(100);

  5.  
  6. //private static Lock lock = new CLHLock();

  7.  
  8. //private static TimeCost timeCost = new TimeCost(new TTASLock());

  9.  
  10. private static volatile int value = 0;

  11. public static void method(){

  12. lock.lock();

  13. System.out.println("Value: " + ++value);

  14. lock.unlock();

  15. }

  16.  
  17. public static void main(String[] args) {

  18. for(int i = 0; i < 50; i ++){

  19. Thread t = new Thread(new Runnable(){

  20.  
  21. @Override

  22. public void run() {

  23. method();

  24. }

  25.  
  26. });

  27. t.start();

  28. }

  29. }

  30.  
  31. }


运行结果是1到50的顺序自增,说明锁保证了同一时刻只有一个线程在对volatile变量++,是正确的

 
  1. Value: 1

  2. Value: 2

  3. Value: 3

  4. Value: 4

  5. Value: 5

  6. Value: 6

  7. Value: 7

  8. Value: 8

  9. Value: 9

  10. Value: 10

  11. Value: 11

  12. Value: 12

  13. Value: 13

  14. Value: 14

  15. Value: 15

  16. Value: 16

  17. Value: 17

  18. Value: 18

  19. Value: 19

  20. Value: 20

  21. Value: 21

  22. Value: 22

  23. Value: 23

  24. Value: 24

  25. Value: 25

  26. Value: 26

  27. Value: 27

  28. Value: 28

  29. Value: 29

  30. Value: 30

  31. Value: 31

  32. Value: 32

  33. Value: 33

  34. Value: 34

  35. Value: 35

  36. Value: 36

  37. Value: 37

  38. Value: 38

  39. Value: 39

  40. Value: 40

  41. Value: 41

  42. Value: 42

  43. Value: 43

  44. Value: 44

  45. Value: 45

  46. Value: 46

  47. Value: 47

  48. Value: 48

  49. Value: 49

  50. Value: 50


使用无界队列锁的情况也是正确的,由于篇幅原因这里就不帖代码了。

再看平均获取锁的时间。

 
  1. package com.test.lock;

  2.  
  3. public class Main {

  4. private static Lock lock = new TimeCost(new CLHLock());

  5.  
  6. //private static Lock lock = new CLHLock();

  7.  
  8. //private static TimeCost timeCost = new TimeCost(new TTASLock());

  9.  
  10. private static volatile int value = 0;

  11. public static void method(){

  12. lock.lock();

  13. //System.out.println("Value: " + ++value);

  14. lock.unlock();

  15. }

  16.  
  17. public static void main(String[] args) {

  18. for(int i = 0; i < 100; i ++){

  19. Thread t = new Thread(new Runnable(){

  20.  
  21. @Override

  22. public void run() {

  23. method();

  24. }

  25.  
  26. });

  27. t.start();

  28. }

  29. }

  30.  
  31. }


在100个线程并发的情况下,

ArrayLock获取锁的平均时间是: 719550 ns

CLHLock获取锁的平均时间是:  488577 ns

可以看到,队列锁在使用多个共享变量自旋的情况下,减少了一致性流量,比TASLock和TTASLock 提高了程序的性能。而CLHLock比ArrayLock有更好的扩展性和性能,是一种很好的自旋锁实现。

CLHLock是无饥饿的,保证先来先服务公平性,只有少量的缓存一致性流量,在SMP系统结构中,是一种比较完善的锁。但是在没有cache的NUMA系统架构中,由于在前一个节点的lock状态上自旋,NUMA架构中处理器访问本地内存的速度高于通过网络访问其他节点的内存,所以CLHLock在NUMA架构上不是最优的自旋锁。

这篇介绍一种适合在无cache的NUMA系统架构中比较完善的队列锁MCSLock。它的特点是:

1. 使用1个ThreadLocal指针来做链表,由QNode自身维护下一个节点的指针

2. 线程在自身节点自旋,而不是CLHLock那样在前一个节点自旋

3. 在释放锁时需要判断是否是唯一节点,需要做一次CAS操作,如果不是唯一节点,要稍微等待链表关系的建立

 
  1. package com.zc.lock;

  2.  
  3. import java.util.concurrent.atomic.AtomicReference;

  4.  
  5. /**

  6. * 无界队列锁,使用一个链表来组织线程

  7. * 假设L把锁,n个线程,那么锁的空间复杂度为O(L+n)

  8. * **/

  9. public class MCSLock implements Lock{

  10. // 原子变量指向队尾

  11. private AtomicReference<QNode> tail;

  12. // 两个指针,一个指向自己的Node,一个指向前一个Node

  13. ThreadLocal<QNode> myNode;

  14.  
  15. public MCSLock(){

  16. tail = new AtomicReference<QNode>(null);

  17. myNode = new ThreadLocal<QNode>(){

  18. protected QNode initialValue(){

  19. return new QNode();

  20. }

  21. };

  22. }

  23.  
  24. @Override

  25. public void lock() {

  26. QNode node = myNode.get();

  27. // CAS原子操作,保证原子性

  28. QNode preNode = tail.getAndSet(node);

  29. // 如果preNode等于空,证明是第一个获取锁的

  30. if(preNode != null){

  31. node.lock = true;

  32. preNode.next = node;

  33. // 对线程自己的node进行自旋,对无cache的NUMA系统架构来说,访问本地内存速度优于其他节点的内存

  34. while(node.lock){

  35.  
  36. }

  37. }

  38. }

  39.  
  40. @Override

  41. public void unlock() {

  42. QNode node = myNode.get();

  43. if(node.next == null){

  44. // CAS操作,判断是否没有新加入的节点

  45. if(tail.compareAndSet(node, null)){

  46. // 没有新加入的节点,直接返回

  47. return;

  48. }

  49. // 有新加入的节点,等待设置链关系

  50. while(node.next == null){

  51.  
  52. }

  53. }

  54. // 通知下一个节点获取锁

  55. node.next.lock = false;

  56. // 设置next节点为空,为下次获取锁清理状态

  57. node.next = null;

  58. }

  59.  
  60. public static class QNode {

  61. volatile boolean lock;

  62. volatile QNode next;

  63. }

  64.  
  65. public String toString(){

  66. return "MCSLock";

  67. }

  68. }

  69.  


下面采用和上一篇同样的测试用例来测试MCSLock的正确性

 
  1. package com.zc.lock;

  2.  
  3. public class Main {

  4. //private static Lock lock = new TimeCost(new ArrayLock(150));

  5.  
  6. private static Lock lock = new MCSLock();

  7.  
  8. //private static TimeCost timeCost = new TimeCost(new TTASLock());

  9.  
  10. private static volatile int value = 0;

  11. public static void method(){

  12. lock.lock();

  13. System.out.println("Value: " + ++value);

  14. lock.unlock();

  15. }

  16.  
  17. public static void main(String[] args) {

  18. for(int i = 0; i < 50; i ++){

  19. Thread t = new Thread(new Runnable(){

  20.  
  21. @Override

  22. public void run() {

  23. method();

  24. }

  25.  
  26. });

  27. t.start();

  28. }

  29. }

  30.  
  31. }


测试结果:顺序地打印出volatile变量++的结果,证明同一时刻只有一个线程在做volatile++操作,证明加锁成功。

 
  1. Value: 1

  2. Value: 2

  3. Value: 3

  4. Value: 4

  5. Value: 5

  6. Value: 6

  7. Value: 7

  8. Value: 8

  9. Value: 9

  10. Value: 10

  11. Value: 11

  12. Value: 12

  13. Value: 13

  14. Value: 14

  15. Value: 15

  16. Value: 16

  17. Value: 17

  18. Value: 18

  19. Value: 19

  20. Value: 20

  21. Value: 21

  22. Value: 22

  23. Value: 23

  24. Value: 24

  25. Value: 25

  26. Value: 26

  27. Value: 27

  28. Value: 28

  29. Value: 29

  30. Value: 30

  31. Value: 31

  32. Value: 32

  33. Value: 33

  34. Value: 34

  35. Value: 35

  36. Value: 36

  37. Value: 37

  38. Value: 38

  39. Value: 39

  40. Value: 40

  41. Value: 41

  42. Value: 42

  43. Value: 43

  44. Value: 44

  45. Value: 45

  46. Value: 46

  47. Value: 47

  48. Value: 48

  49. Value: 49

  50. Value: 50

java并发包中的Lock定义包含了时限锁的接口:

 
  1. public interface Lock {

  2.  
  3. void lock();

  4.  
  5. void lockInterruptibly() throws InterruptedException;

  6.  
  7.  boolean tryLock();

  8.  
  9. boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

  10.  
  11. void unlock();

  12.  
  13. Condition newCondition();

  14. }


tryLock就是实现锁的接口,它支持限时操作,支持中断操作。这两个特性很重要,可以防止死锁,也可以在死锁的情况下取消锁。

因为这两个特性的需要,队列锁的节点需要支持“退出队列”的机制,也就是说当发生超时或者线程中断的情况下,线程能从队列中出队列,不影响其他节点继续等待。之前实现的几种队列锁都不支持退出机制,一旦发生队列中的线程长时间阻塞,那么后续所有的线程都会被动阻塞。

我们看一种限时队列锁的实现,它有几个要点:

1. 定义一个共享的AVAILABLE节点,当一个节点的preNode指向AVAILABLE时,表示这个节点获得锁

2. QNode节点维护一个preNode引用,这个引用只有当获得锁时,会指向AVAILABLE,或者超时了会指向它的前一个节点,其他等待锁的时候都是Null,因为一旦一个节点超时了,需要让它的后续节点指向它的前驱节点,所以只有超时的时候会给preNode设置值(指向AVAILABLE节点除外)。

3. 使用一个AtomicReference原子变量tail来形成一个虚拟的单向链表结构。tail的getAndSet操作会返回之前的节点的引用,相当于获得了前驱节点。当获得锁后,前驱节点引用就释放了,前驱节点就可以被GC回收

4. 支持中断操作,Thread.isInterrupted()可以获得线程中断的信息,一旦获取中断信息,就抛出中断异常。需要注意的时,线程中断信息发出时,并不是要求线程马上中断,而是告知了线程要中断的信息,程序自己控制中断的地点。

5. 由于线程只有一个ThreadLocal的myNode变量指向自己的节点,所以获取锁时,使用了每次new一个新的Node,并设置给线程的方式,避免unlock时对node的操作影响后续节点的状态,也可以使线程多次获得锁。这里可以考虑像CLHLock那样,维护两个ThreadLocal的引用,释放锁时把myNode的引用指向已经不使用的前驱节点,这样避免无谓的new操作。

 
  1. package com.zc.lock;

  2.  
  3. import java.util.concurrent.TimeUnit;

  4. import java.util.concurrent.atomic.AtomicReference;

  5.  
  6. /**

  7. * 时限队列锁,支持tryLock超时操作

  8. * QNode维护一个指针preNode指向前一个节点。当preNode == AVAILABLE表示已经释放锁。当preNode == null表示等待锁

  9. * tail维护一个虚拟链表,通过tail.getAndSet方法获得前一个节点,并在前一个节点自旋,当释放锁时前一个节点的preNode == AVAIABLE,自动通知后一个节点获取锁

  10. * 当一个节点超时或者被中断,那么它的前驱节点不为空。后续节点看到它的前驱节点不为空,并且不是AVAILABLE时,知道这个节点退出了,就会跳过它

  11. * 当节点获得锁,进入临界区后,它的前驱节点可以被回收

  12. * **/

  13. public class TimeoutLock implements TryLock{

  14. // 声明为静态变量,防止被临时回收

  15. private static final QNode AVAILABLE = new QNode();

  16.  
  17. // 原子变量指向队尾

  18. private AtomicReference<QNode> tail;

  19.  
  20. ThreadLocal<QNode> myNode;

  21.  
  22. public TimeoutLock(){

  23. tail = new AtomicReference<QNode>(null);

  24. myNode = new ThreadLocal<QNode>(){

  25. protected QNode initialValue(){

  26. return new QNode();

  27. }

  28. };

  29. }

  30.  
  31. @Override

  32. public void lock() {

  33. // 和CLHLock不同,每次新建一个Node,并设置给线程,目的是支持同一个线程可以多次获得锁,而不影响链中其他节点的状态

  34. // CLHLock不需要每次新建Node是因为它使用了两个指针,一个指向前驱节点。而前驱节点释放后就可以回收了。

  35. // CLHLock每次释放锁时设置myNode为失效的前驱节点,也是为了支持同一个线程可以多次获取锁而不影响其他节点

  36. QNode node = new QNode();

  37. myNode.set(node);

  38. QNode pre = tail.getAndSet(node);

  39. if(pre != null){

  40. // 在前一个节点自旋,当前一个节点是AVAILABLE时,表示它获得锁

  41. while(pre.preNode != AVAILABLE){

  42.  
  43. }

  44. }

  45. }

  46.  
  47. @Override

  48. public void unlock() {

  49. QNode node = myNode.get();

  50. // CAS操作,如果为true,表示是唯一节点,直接释放就行;否则把preNode指向AVAILABLE

  51. if(!tail.compareAndSet(node, null)){

  52. node.preNode = AVAILABLE;

  53. }

  54. }

  55.  
  56. @Override

  57. //TimeUnit只支持毫秒

  58. public boolean trylock(long time, TimeUnit unit) throws InterruptedException {

  59. if(Thread.interrupted()){

  60. throw new InterruptedException();

  61. }

  62. boolean isInterrupted = false;

  63. long startTime = System.currentTimeMillis();

  64. long duration = TimeUnit.MILLISECONDS.convert(time, unit);

  65. // 注意:每次tryLock都要new新的Node,为了同一个线程可以多次获得锁。如果每个线程都使用同一个节点,会影响链中其他的节点

  66. QNode node = new QNode();

  67. myNode.set(node);

  68. // 尝试一次获取锁

  69. QNode pre = tail.getAndSet(node);

  70. // 第一个节点或者之前的节点都是已经释放了锁的节点, pre==AVAILABLE表示获得了锁

  71. if(pre == null || pre == AVAILABLE){

  72. return true;

  73. }

  74. // 在给定时间内对preNode自旋

  75. while((System.currentTimeMillis() - startTime < duration) && !isInterrupted){

  76. QNode predPreNode = pre.preNode;

  77. // 表示前一个节点已经释放了锁,设置了preNode域,否则preNode域为空

  78. if(predPreNode == AVAILABLE){

  79. return true;

  80. }

  81. // 当prePreNode != null时,只有两种情况,就是它超时了,或者被中断了。

  82. // 跳过prePreNode不为空的节点,继续自旋它的下一个节点

  83. else if(predPreNode != null){

  84. pre = predPreNode;

  85. }

  86. if(Thread.interrupted()){

  87. isInterrupted = true;

  88. }

  89. }

  90.  
  91. // 超时或者interrupted,都要设置node的前驱节点不为空

  92. node.preNode = pre;

  93.  
  94. if(isInterrupted){

  95. throw new InterruptedException();

  96. }

  97.  
  98. return false;

  99. }

  100.  
  101. public static class QNode {

  102. volatile QNode preNode;

  103. }

  104.  
  105. public String toString(){

  106. return "TimeoutLock";

  107. }

  108.  
  109. }

  110.  


TimeoutLock具备所有CLHLock的特性,比如无饥饿,先来先服务的公平性,在多个共享变量上自旋,从而控制合理的缓存一致性流量等等,并且支持了限时操作和中断操作。

使用限时锁时有固定的模板,防止锁被错误使用。

 
  1. Lock lock = ...;

  2. if (lock.tryLock()) {

  3. try {

  4. // manipulate protected state

  5. } finally {

  6. lock.unlock();

  7. }

  8. } else {

  9. // perform alternative actions

  10. }

这篇说说限时有界队列锁,它采用了有界队列,并且和ArrayLock不同,它不限制线程的个数。它的特点主要有

1. 采用有界队列,减小了空间复杂度,L把锁的空间复杂度在最坏的情况下(有界队列长度为1)是O(L)

2. 非公平,不保证先来先服务,这也是一个很常见的需求

3. 因为是有界队列,所以在高并发下存在高争用,需要结合回退锁来降低争用

它的实现思路是:

1. 采用了一个有界的等待队列,等待队列的每个节点都有多种状态,每个节点是可复用的

2. 采用了一个工作队列,Tail指针指向工作队列的队尾节点。获取和是否锁的操作是在工作队列中的节点之间进行

3. 由于是限时队列,并支持中断,所以队列中的节点都是可以退出队列的

4. 算法分为三步,第一步是线程从有界的等待队列中获得一个节点,并设置为WAITING,如果没有获得,就自旋

    第二步是把这个节点加入工作队列,并获得前一个节点的指针

    第三步是在前一个节点的状态上自旋,直到获得锁,并把前一个节点RELEASED状态改为FREE

节点有4种状态:

1. FREE:  表示节点可以被获得。当前一个节点释放锁,并设置状态为RELEASED的时候,后一个节点需要把前一个节点设置为FREE。当节点在没有进入工作队列时超时,也被设置为FREE.

2. RELEASED:节点释放锁时设置为RELEASED,需要后续节点把它设置为FREE。如果是工作队列的最后一个节点,那么RELEASED状态的节点在第一步时可被获得

3. WAITING:表示获得了锁或在工作队列中等待锁。是在第一步中被设置的,第一步的结果就是获得一个状态为WAITING的节点

4. ABORTED:工作队列中的节点超时或者中断的节点被设置为ABORTED。 队尾的ABORTED节点可以被第一步获得,队中的ABORTED节点不能被第一步获取,只能把它的preNode指针指向它的前一个节点,表示它自己不能被获取了

理解节点这4种状态的转变是理解这个设计的关键。这个设计比较复杂,从篇幅考虑,这篇只介绍Lock和UnLock操作,下一篇说tryLock限时操作

1. 创建枚举类型State来表示状态

2. 创建QNode表示节点,使用一个AtomicReference原子变量指向它的State,以便于支持CAS操作。节点维护一个PreNode引用,只有节点被Aborted的时候才设置这个引用的值,表示跳过这个节点

3. 一个有界的QNode队列,使用数组表示

4. MIN_BACKOFF和MAX_BACKOFF支持回退操作,单位是毫秒。这两个值依赖于硬件性能,需要通过不断测试来获取最优值

5. 一个Random随机数,来产生随即的数组下标,非公平性需要

6. 一个AtomicStampedReference类型的原子变量作为队尾指针tail。AtomicStampedReference采用了版本号来避免CAS操作的ABA问题。这很重要,因为有界等待队列的节点会多次进出工作队列,所以可能发生同一个节点被前一个线程准备CAS操作时,已经被后几个线程进出了工作队列,导致第一个线程拿到的QNode的状态不正确。

7. lock实现分为三步,上文已经说过了

8. unlock操作就是两步,第一修改状态通知其他线程获取锁。第二是设置自己的节点引用,以便下次可再次获得锁而不影响其他线程的状态。这里是把线程指向的节点状态设置为RELEASED,同时设置线程的节点引用为空,这样其他线程可以继续使用这个节点。

 
  1. package com.zc.lock;

  2.  
  3. import java.util.Random;

  4. import java.util.concurrent.TimeUnit;

  5. import java.util.concurrent.atomic.AtomicReference;

  6. import java.util.concurrent.atomic.AtomicStampedReference;

  7.  
  8. /**

  9. * 限时有界队列锁,并且直接不限数量的线程

  10. * 由于是有界的队列,所以争用激烈,可以复合回退锁的概念,减少高争用

  11. * 分为三步:

  12. * 第一步是取得一个State为FREE的节点,设置为WAITING

  13. * 第二步是把这个节点加入队列,获取前一个节点

  14. * 第三步是在前一个节点上自旋

  15. *

  16. * 优点是L个锁的空间复杂度是O(L),而限时无界队列锁的空间复杂度为O(Ln)

  17. * **/

  18. public class CompositeLock implements TryLock{

  19.  
  20. enum State {FREE, WAITING, RELEASED, ABORTED}

  21.  
  22. class QNode{

  23. AtomicReference<State> state = new AtomicReference<CompositeLock.State>(State.FREE);

  24. volatile QNode preNode;

  25. }

  26.  
  27. private final int SIZE = 10;

  28.  
  29. private final int MIN_BACKOFF = 1;

  30.  
  31. private final int MAX_BACKOFF = 10;

  32.  
  33. private Random random = new Random();

  34.  
  35. // 有界的QNode数组,表示队列总共可以使用的节点数

  36. private QNode[] waitings = new QNode[10];

  37.  
  38. // 指向队尾节点,使用AtomicStampedReference带版本号的原子引用变量,可以防止ABA问题,因为这个算法实现需要对同一个Node多次进出队列

  39. private AtomicStampedReference<QNode> tail = new AtomicStampedReference<CompositeLock.QNode>(null, 0);

  40.  
  41. // 每个线程维护一个QNode引用

  42. private ThreadLocal<QNode> myNode = new ThreadLocal<CompositeLock.QNode>(){

  43. public QNode initialValue(){

  44. return null;

  45. }

  46. };

  47.  
  48. public CompositeLock(){

  49. for(int i = 0; i < SIZE; i ++){

  50. waitings[i] = new QNode();

  51. }

  52. }

  53.  
  54.  
  55. @Override

  56. public void lock() {

  57. Backoff backoff = new Backoff(MIN_BACKOFF, MAX_BACKOFF);

  58. QNode node = waitings[random.nextInt(SIZE)];

  59.  
  60. // 第一步: 先获得数组里的一个Node,并把它的状态设置为WAITING,否则就自旋

  61. GETNODE:

  62. while(true){

  63. while(node.state.get() != State.FREE){

  64. // 因为释放锁时只是设置了State为RELEASED,由后继的线程来设置RELEASED为FREE

  65. // 如果该节点已经是队尾节点了并且是RELEASED,那么可以直接可以被使用

  66. // 获取当前原子引用变量的版本号

  67. int[] currentStamp = new int[1];

  68. QNode tailNode = tail.get(currentStamp);

  69. if(tailNode == node && tailNode.state.get() == State.RELEASED){

  70. if(tail.compareAndSet(tailNode, null, currentStamp[0], currentStamp[0] + 1)){

  71. node.state.set(State.WAITING);

  72. break GETNODE;

  73. }

  74. }

  75. }

  76. if(node.state.compareAndSet(State.FREE, State.WAITING)){

  77. break;

  78. }

  79. try {

  80. backoff.backoff();

  81. } catch (InterruptedException e) {

  82. throw new RuntimeException("Thread interrupted, stop to get the lock");

  83. }

  84. }

  85. // 第二步加入队列

  86. int[] currentStamp = new int[1];

  87. QNode preTailNode = null;

  88. do{

  89. preTailNode = tail.get(currentStamp);

  90. }

  91. // 如果没加入队列,就一直自旋

  92. while(!tail.compareAndSet(preTailNode, node, currentStamp[0], currentStamp[0] + 1));

  93.  
  94.  
  95. // 第三步在前一个节点自旋,如果前一个节点为null,证明是第一个加入队列的节点

  96. if(preTailNode != null){

  97. // 在前一个节点的状态自旋

  98. while(preTailNode.state.get() != State.RELEASED){}

  99. // 设置前一个节点的状态为FREE,可以被其他线程使用

  100. preTailNode.state.set(State.FREE);

  101. }

  102.  
  103. // 将线程的myNode指向获得锁的node

  104. myNode.set(node);

  105. return;

  106. }

  107.  
  108. @Override

  109. public void unlock() {

  110. QNode node = myNode.get();

  111. node.state.set(State.RELEASED);

  112. myNode.set(null);

  113. }

  114.  
  115. @Override

  116. public boolean trylock(long time, TimeUnit unit)

  117. throws InterruptedException {

  118. // TODO Auto-generated method stub

  119. return false;

  120. }

  121.  
  122. }


采用我们之前的验证锁正确性的测试用例来测试lock, unlock操作。

 
  1. package com.zc.lock;

  2.  
  3. public class Main {

  4. //private static Lock lock = new TimeCost(new ArrayLock(150));

  5.  
  6. private static Lock lock = new CompositeLock();

  7.  
  8. //private static TimeCost timeCost = new TimeCost(new TTASLock());

  9.  
  10. private static volatile int value = 0;

  11. public static void method(){

  12. lock.lock();

  13. System.out.println("Value: " + ++value);

  14. lock.unlock();

  15. }

  16.  
  17. public static void main(String[] args) {

  18. for(int i = 0; i < 50; i ++){

  19. Thread t = new Thread(new Runnable(){

  20.  
  21. @Override

  22. public void run() {

  23. method();

  24. }

  25.  
  26. });

  27. t.start();

  28. }

  29. }

  30.  
  31. }

结果是顺序打印的,证明锁是正确的,每次只有一个线程获得了锁

 
  1. Value: 1

  2. Value: 2

  3. Value: 3

  4. Value: 4

  5. Value: 5

  6. Value: 6

  7. Value: 7

  8. Value: 8

  9. Value: 9

  10. Value: 10

  11. Value: 11

  12. Value: 12

  13. Value: 13

  14. Value: 14

  15. Value: 15

  16. Value: 16

  17. Value: 17

  18. Value: 18

  19. Value: 19

  20. Value: 20

  21. Value: 21

  22. Value: 22

  23. Value: 23

  24. Value: 24

  25. Value: 25

  26. Value: 26

  27. Value: 27

  28. Value: 28

  29. Value: 29

  30. Value: 30

  31. Value: 31

  32. Value: 32

  33. Value: 33

  34. Value: 34

  35. Value: 35

  36. Value: 36

  37. Value: 37

  38. Value: 38

  39. Value: 39

  40. Value: 40

  41. Value: 41

  42. Value: 42

  43. Value: 43

  44. Value: 44

  45. Value: 45

  46. Value: 46

  47. Value: 47

  48. Value: 48

  49. Value: 49

猜你喜欢

转载自blog.csdn.net/hellozhxy/article/details/82759681