关于java:从零开始自己动手写自旋锁

12次阅读

共计 6710 个字符,预计需要花费 17 分钟才能阅读完成。

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

前言

咱们在写并发程序的时候,一个十分常见的需要就是保障在某一个时刻只有一个线程执行某段代码,像这种代码叫做临界区,而通常保障一个时刻只有一个线程执行临界区的代码的办法就是锁🔒。在本篇文章当中咱们将会仔细分析和学习自旋锁,所谓自旋锁就是通过 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。

咱们当初来模仿一下个别的整型变量呈现问题的过程:

  • 主内存 data 的初始值等于 0,两个线程失去的 data 初始值都等于 0。
  • 当初线程一将 data 加一,而后线程一将 data 的值同步回主内存,整个内存的数据变动如下:
  • 当初线程二 data 加一,而后将 data 的值同步回主内存(将原来主内存的值笼罩掉了):

咱们原本心愿 data 的值在通过下面的变动之后变成2,然而线程二笼罩了咱们的值,因而在多线程状况下,会使得咱们最终的后果变小。

然而在下面的程序当中咱们最终的输入后果是等于 20000 的,这是因为给 data 进行 +1 的操作是原子的不可分的,在操作的过程当中其余线程是不能对 data 进行操作的。这就是 原子性 带来的劣势。

本人入手写自旋锁

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。

以上就是本篇文章的所有内容了,我是LeHung,咱们下期再见!!!更多精彩内容合集可拜访我的项目:https://github.com/Chang-LeHu…

关注公众号:一无是处的钻研僧,理解更多计算机(Java、Python、计算机系统根底、算法与数据结构)常识。

正文完
 0