ホームページ  >  記事  >  Java  >  Javaを使用して手動スピンロックを実装する方法

Javaを使用して手動スピンロックを実装する方法

WBOY
WBOY転載
2023-05-09 18:31:191599ブラウズ

    まえがき

    並行プログラムを作成する場合、非常に一般的な要件は、特定の時間に 1 つのスレッドだけが特定のコード部分を実行するようにすることです。この種のコードはクリティカル セクションと呼ばれ、一度に 1 つのスレッドだけがクリティカル セクションのコードを実行するようにする通常の方法はロックすることです。この記事では、スピン ロックについて詳しく分析して学習します。いわゆるスピン ロックは while ループを通じて実装され、ロックを取得したスレッドがコードを実行するためのクリティカル セクションに入ることができ、スレッドが実行できるようになります。 while ループを終了し続けるためのロックを取得していない場合、これは実際には while ループ内でスレッド自体が「スピン」しているため、この種のロックはスピン ロックと呼ばれます。

    スピン ロック

    原子性

    スピン ロックについて話す前に、原子性について話さなければなりません。いわゆるアトミック性とは、単に各操作が完了しないか、すべて完了することを意味します。すべてを実行するとは、操作中に中断できないことを意味します。たとえば、変数データに 1 を追加するには、次の 3 つのステップがあります:

    • データをメモリからレジスタにロードします。

    • データの値に 1 を加えます。

    • 結果をメモリに書き込みます。

    アトミック性とは、スレッドがインクリメント操作を実行するときに、他のスレッドによって中断できないことを意味します。このスレッドがこれら 3 つのプロセスを完了した場合にのみ、他のスレッドがデータを操作できるようになります。

    それでは、コードで体験してみましょう。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
      }
    }

    上記のコード分析から、それが一般的なものであるかどうかを知ることができます。整数 2 つのスレッドが同時に変数を操作する場合、最終結果は 200,000 未満になります。

    ここで、一般的な整数変数の問題のプロセスをシミュレートしてみましょう:

    メイン メモリ データの初期値は 0 に等しく、両方のスレッドによって取得されたデータの初期値は 0 に等しくなります。 。

    Javaを使用して手動スピンロックを実装する方法

    ここで、スレッド 1 がデータに 1 を追加し、次にスレッド 1 がデータの値をメイン メモリに同期させます。メモリ全体のデータは次のように変更されます:

    Javaを使用して手動スピンロックを実装する方法

    #スレッド 2 のデータが 1 つ増加し、データの値がメイン メモリに同期されて戻されます (メイン メモリの元の値が上書きされます)。

    Javaを使用して手動スピンロックを実装する方法

    当初、上記の変更後にデータの値が 2 になることを期待していましたが、スレッド 2 によって値が上書きされたため、マルチスレッドの場合、最終結果は次のようになります。より小さい。

    しかし、上記のプログラムでは、最終的な出力結果は 20000 に等しくなります。これは、データに対する 1 の操作がアトミックであり、割り切れないためです。操作プロセス中、他のスレッドはデータを操作できません。これがアトミック性の利点です。

    独自のスピン ロックを作成する

    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 の値が 2 番目のパラメータに設定されます (上記の対応するコードは 1)。これらの操作が成功すると、compareAndSet 関数は true を返します。操作が失敗した場合は、false を返します。操作の失敗は、次のパラメータの値が原因である可能性があります。最初のパラメータ (期待値) が atomicInteger と等しくありません。等しい場合は、atomicInteger の値を変更するときに失敗した可能性があります (複数のスレッドが動作している可能性があり、アトミック性の存在により 1 つのスレッドのみが動作するため)正常に動作できます)。

    スピン ロックの実装原理

    AtomicInteger クラスを使用してスピン ロックを実装できます。値 0 を使用してロックされていないことを示し、ロックされていることを示す値 1。

    AtomicInteger クラスの初期値は 0 です。

    ロックするときは、コード atomicInteger.compareAndSet(0, 1) を使用して実装できます。この操作を完了できるのは 1 つのスレッドだけであることは前に述べました。つまり、この操作を呼び出せるのは 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);
      }
    }

    上面程序的输出:

    スレッド-3 クリティカル セクション状態への移行 = 1
    スレッド-3 クリティカル セクション状態への移行 = 2
    スレッド-3 クリティカル セクション状態への移行 = 3
    スレッド-0クリティカル セクションの状態 = 1
    Thread-0 クリティカル セクションの状態に入る = 2
    Thread-0 クリティカル セクションの状態に入る = 3
    Thread-9 クリティカル セクションの状態に入る = 1
    Thread-9クリティカル セクション状態への移行 = 2
    Thread-9 クリティカル セクション状態への移行 = 3
    Thread-4 クリティカル セクション状態への移行 = 1
    Thread-4 クリティカル セクション状態への移行 = 2
    Thread -4 クリティカル セクション状態への移行 = 3
    Thread-7 クリティカル セクション状態への移行 = 1
    Thread-7 クリティカル セクション状態への移行 = 2
    Thread-7 クリティカル セクション状態への移行 = 3
    Thread-8 クリティカル セクション状態への移行 = 1
    Thread-8 クリティカル セクション状態への移行 = 2
    Thread-8 クリティカル セクション状態への移行 = 3
    Thread-5 クリティカル セクション状態への移行 = 1
    スレッド-5 クリティカル セクション状態に入る = 2
    スレッド-5 クリティカル セクション状態に入る = 3
    スレッド-2 クリティカル セクション状態に入る = 1
    スレッド-2 クリティカル セクション状態に入る = 2
    Thread-2 クリティカル セクションの状態に入る = 3
    Thread-6 クリティカル セクションの状態に入る = 1
    Thread-6 クリティカル セクションの状態に入る = 2
    Thread-6 クリティカル セクションに入るstate = 3
    Thread-1 クリティカル セクションに入る state = 1
    Thread-1 クリティカル セクションに入る state = 2
    Thread-1 クリティカル セクションに入る state = 3
    300

    上記の出力結果から、スレッドがロックを取得できれば再入可能であることがわかり、最終的な出力結果も正しいので、作成したリエントラントなスピンロックが有効であることが確認できます。効果的!

    以上がJavaを使用して手動スピンロックを実装する方法の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

    声明:
    この記事はyisu.comで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。