从零开端本人入手写自旋锁

1年前 (2023) 程序员胖胖胖虎阿
113 0 0

从零开端本人入手写自旋锁

我们在写并发程序的时分,一个十分常见的需求就是保证在某一个时辰只要一个线程执行某段代码,像这种代码叫做临界区,而通常保证一个时辰只要一个线程执行临界区的代码的办法就是锁🔒。在本篇文章当中我们将会认真剖析和学习自旋锁,所谓自旋锁就是经过while循环完成的,让拿到锁的线程进入临界区执行代码,让没有拿到锁的线程不断停止while死循环,这其实就是线程本人“旋”在while循环了,因此这种锁就叫做自旋锁。

原子性

在谈自旋锁之前就不得不谈原子性了。所谓原子性简单说来就是一个一个操作要么不做要么全做,全做的意义就是在操作的过程当中不可以被中缀,比方说对变量data停止加一操作,有以下三个步骤:

将data从内存加载到存放器。
将data这个值加一。
将得到的结果写回内存。

原子性就表示一个线程在停止加一操作的时分,不可以被其他线程中缀,只要这个线程执行完这三个过程的时分其他线程才干够操作数据data。
我们如今用代码体验一下,在Java当中我们能够运用AtomicInteger停止对整型数据的原子操作:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicDemo {

  public static void main(String[] args) throws InterruptedException {
    AtomicInteger data = new AtomicInteger();
    data.set(0); // 将数据初始化位0
    Thread t1 = new Thread(() -> {
      for (int i = 0; i < 100000; i++) {
        data.addAndGet(1); // 对数据 data 停止原子加1操作
      }
    });
    Thread t2 = new Thread(() -> {
      for (int i = 0; i < 100000; i++) {
        data.addAndGet(1);// 对数据 data 停止原子加1操作
      }
    });
    // 启动两个线程
    t1.start();
    t2.start();
    // 等候两个线程执行完成
    t1.join();
    t2.join();
    // 打印最终的结果
    System.out.println(data); // 200000
  }
}

复制代码
从上面的代码剖析能够晓得,假如是普通的整型变量假如两个线程同时停止操作的时分,最终的结果是会小于200000。

本人入手写自旋锁
AtomicInteger类
如今我们曾经理解了原子性的作用了,我们如今来理解AtomicInteger类的另外一个原子性的操作——compareAndSet,这个操作叫做比拟并交流(CAS),他具有原子性。

public static void main(String[] args) {
  AtomicInteger atomicInteger = new AtomicInteger();
  atomicInteger.set(0);
  atomicInteger.compareAndSet(0, 1);
}

复制代码
compareAndSet函数的意义:首先会比拟第一个参数(对应上面的代码就是0)和atomicInteger的值,假如相等则停止交流,也就是将atomicInteger的值设置为第二个参数(对应上面的代码就是1),假如这些操作胜利,那么compareAndSet函数就返回true,假如操作失败则返回false,操作失败可能是由于第一个参数的值(希冀值)和atomicInteger不相等,假如相等也可能由于在更改atomicInteger的值的时分失败(由于可能有多个线程在操作,由于原子性的存在,只能有一个线程操作胜利)。

自旋锁完成原理

我们能够运用AtomicInteger类完成自旋锁,我们能够用0这个值表示未上锁,1这个值表示曾经上锁了。

AtomicInteger类的初始值为0。
在上锁时,我们能够运用代码atomicInteger.compareAndSet(0, 1)停止完成,我们在前面曾经提到了只可以有一个线程完成这个操作,也就是说只能有一个线程调用这行代码然后返回true其他线程都返回false,这些返回false的线程不可以进入临界区,因而我们需求这些线程停在atomicInteger.compareAndSet(0, 1)这行代码不可以往下执行,我们能够运用while循环让这些线程不断停在这里while (!value.compareAndSet(0, 1));,只要返回true的线程才干够跳出循环,其他线程都会不断在这里循环,我们称这种行为叫做自旋,这种锁因此也被叫做自旋锁。
线程在出临界区的时分需求重新将锁的状态调整为未上锁的上状态,我们运用代码value.compareAndSet(1, 0);就能够完成,将锁的状态复原为未上锁的状态,这样其他的自旋的线程就能够拿到锁,然后进入临界区了。

自旋锁代码完成

import java.util.concurrent.atomic.AtomicInteger;

public class SpinLock {
    
  // 0 表示未上锁状态
  // 1 表示上锁状态
  protected AtomicInteger value;

  public SpinLock() {
    this.value = new AtomicInteger();
    // 设置 value 的初始值为0 表示未上锁的状态
    this.value.set(0);
  }

  public void lock() {
    // 停止自旋操作
    while (!value.compareAndSet(0, 1));
  }

  public void unlock() {
    // 将锁的状态设置为未上锁状态
    value.compareAndSet(1, 0);
  }

}

复制代码
上面就是我们本人完成的自旋锁的代码,这看起来真实太简单了,但是它的确协助我们完成了一个锁,而且可以在真实场景停止运用的,我们如今用代码对上面我们写的锁停止测试。
测试程序:

public class SpinLockTest {

  public static int data;
  public static SpinLock lock = new SpinLock();

  public static void add() {
    for (int i = 0; i < 100000; i++) {
      // 上锁 只能有一个线程执行 data++ 操作 其他线程都只能停止while循环
      lock.lock();
      data++;
      lock.unlock();
    }
  }

  public static void main(String[] args) throws InterruptedException {
    Thread[] threads = new Thread[100];
    // 设置100个线程
    for (int i = 0; i < 100; i ++) {
      threads[i] = new Thread(SpinLockTest::add);
    }
    // 启动一百个线程
    for (int i = 0; i < 100; i++) {
      threads[i].start();
    }
    // 等候这100个线程执行完成
    for (int i = 0; i < 100; i++) {
      threads[i].join();
    }
    System.out.println(data); // 10000000
  }
}

复制代码
在上面的代码单中,我们运用100个线程,然后每个线程循环执行100000data++操作,上面的代码最后输出的结果是10000000,和我们等待的结果是相等的,这就阐明我们完成的自旋锁是正确的。
本人入手写可重入自旋锁
可重入自旋锁
在上面完成的自旋锁当中曾经能够满足一些我们的根本需求了,就是一个时辰只可以有一个线程执行临界区的代码。但是上面的的代码并不可以满足重入的需求,也就是说上面写的自旋锁并不是一个可重入的自旋锁,事实上在上面完成的自旋锁当中重入的话就会产生死锁。
我们经过一份代码来模仿上面重入产生死锁的状况:

public static void add(int state) throws InterruptedException {
  TimeUnit.SECONDS.sleep(1);
  if (state <= 3) {
    lock.lock();
    System.out.println(Thread.currentThread().getName() + "\t进入临界区 state = " + state);
    for (int i = 0; i < 10; i++)
      data++;
    add(state + 1); // 停止递归重入 重入之前锁状态曾经是1了 由于这个线程进入了临界区
    lock.unlock();
  }
}

复制代码

在上面的代码当中参加我们传入的参数state的值为1,那么在线程执行for循环之后再次递归调用add函数的话,那么state的值就变成了2。
if条件依然满足,这个线程也需求重新取得锁,但是此时锁的状态是1,这个线程曾经取得过一次锁了,但是自旋锁等待的锁的状态是0,由于只要这样他才干够再次取得锁,进入临界区,但是如今锁的状态是1,也就是说固然这个线程取得过一次锁,但是它也会不断停止while循环而且永远都出不来了,这样就构成了死锁了。

可重入自旋锁思想

针对上面这种状况我们需求完成一个可重入的自旋锁,我们的思想大致如下:

在我们完成的自旋锁当中,我们能够增加两个变量,owner一个用于存当前具有锁的线程,count一个记载当前线程进入锁的次数。
假如线程取得锁,owner = Thread.currentThread()并且count = 1。
当线程下次再想获取锁的时分,首先先看owner是不是指向本人,则不断停止循环操作,假如是则直接停止count++操作,然后就能够进入临界区了。
我们在出临界区的时分,假如count大于一的话,阐明这个线程重入了这把锁,因而不可以直接将锁设置为0也就是未上锁的状态,这种状况直接停止count--操作,假如count等于1的话,阐明线程当前的状态不是重入状态(可能是重入之后递归返回了),因而在出临界区之前需求将锁的状态设置为0,也就是没上锁的状态,好让其他线程可以获取锁。

可重入锁代码完成:
完成的可重入锁代码如下:

public class ReentrantSpinLock extends SpinLock {

  private Thread owner;
  private int count;

  @Override
  public void lock() {
    if (owner == null || owner != Thread.currentThread()) {
      while (!value.compareAndSet(0, 1));
      owner = Thread.currentThread();
      count = 1;
    }else {
      count++;
    }

  }

  @Override
  public void unlock() {
    if (count == 1) {
      count = 0;
      value.compareAndSet(1, 0);
    }else
      count--;
  }
}

复制代码
下面我们经过一个递归程序去考证我们写的可重入的自旋锁能否可以胜利工作。
测试程序:

import java.util.concurrent.TimeUnit;

public class ReentrantSpinLockTest {

  public static int data;
  public static ReentrantSpinLock lock = new ReentrantSpinLock();

  public static void add(int state) throws InterruptedException {
    TimeUnit.SECONDS.sleep(1);
    if (state <= 3) {
      lock.lock();
      System.out.println(Thread.currentThread().getName() + "\t进入临界区 state = " + state);
      for (int i = 0; i < 10; i++)
        data++;
      add(state + 1);
      lock.unlock();
    }
  }

  public static void main(String[] args) throws InterruptedException {
    Thread[] threads = new Thread[10];
    for (int i = 0; i < 10; i++) {
      threads[i] = new Thread(new Thread(() -> {
        try {
          ReentrantSpinLockTest.add(1);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }, String.valueOf(i)));
    }
    for (int i = 0; i < 10; i++) {
      threads[i].start();
    }
    for (int i = 0; i < 10; i++) {
      threads[i].join();
    }
    System.out.println(data);
  }
}

复制代码
上面程序的输出:

Thread-3    进入临界区 state = 1
Thread-3    进入临界区 state = 2
Thread-3    进入临界区 state = 3
Thread-0    进入临界区 state = 1
Thread-0    进入临界区 state = 2
Thread-0    进入临界区 state = 3
Thread-9    进入临界区 state = 1
Thread-9    进入临界区 state = 2
Thread-9    进入临界区 state = 3
Thread-4    进入临界区 state = 1
Thread-4    进入临界区 state = 2
Thread-4    进入临界区 state = 3
Thread-7    进入临界区 state = 1
Thread-7    进入临界区 state = 2
Thread-7    进入临界区 state = 3
Thread-8    进入临界区 state = 1
Thread-8    进入临界区 state = 2
Thread-8    进入临界区 state = 3
Thread-5    进入临界区 state = 1
Thread-5    进入临界区 state = 2
Thread-5    进入临界区 state = 3
Thread-2    进入临界区 state = 1
Thread-2    进入临界区 state = 2
Thread-2    进入临界区 state = 3
Thread-6    进入临界区 state = 1
Thread-6    进入临界区 state = 2
Thread-6    进入临界区 state = 3
Thread-1    进入临界区 state = 1
Thread-1    进入临界区 state = 2
Thread-1    进入临界区 state = 3
300

复制代码
从上面的输出结果我们就能够晓得,当一个线程可以获取锁的时分他可以停止重入,而且最终输出的结果也是正确的,因而考证了我们写了可重入自旋锁是有效的!

总结

在本篇文章当中主要给大家引见了自旋锁和可重入自旋锁的原理,并且完成了一遍,其实代码还是比拟简单关键需求大家将这其中的逻辑理分明:

所谓自旋锁就是经过while循环完成的,让拿到锁的线程进入临界区执行代码,让没有拿到锁的线程不断停止while死循环。
可重入的含义就是一个线程曾经竞争到了一个锁,在竞争到这个锁之后又一次有重入临界区代码的需求,假如可以保证这个线程可以重新进入临界区,这就叫可重入。
我们在完成自旋锁的时分运用的是AtomicInteger类,并且我们运用0和1这两个数值用于表示无锁和锁被占用两个状态,在获取锁的时分运用while循环不时停止CAS操作,直到操作胜利返回true,在释放锁的时分运用CAS将锁的状态从1变成0。
完成可重入锁最重要的一点就是需求记载是那个线程取得了锁,同时还需求记载获取了几次锁,由于我们在解锁的时分需求停止判别,之后count = 1的状况才干将锁的状态从1设置成0。

版权声明:程序员胖胖胖虎阿 发表于 2023年9月1日 下午2:32。
转载请注明:从零开端本人入手写自旋锁 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...