锁的种类

1.锁的实现

锁的实现机制大概有三种:

  1. 软件实现:软件互斥锁是使用编程语言提供的原语、数据结构和算法来实现的。它可以基于原子操作、条件变量、标志位等实现互斥性。
  2. 硬件实现:通常是指在计算机体系结构层面上提供的支持并发控制的机制。这些机制通过硬件电路来实现锁操作,提供更高效的同步和并发访问控制。
  3. 软硬件实现(混合实现)::混合互斥锁是软件和硬件互斥锁的结合。它利用了软件的灵活性和硬件的效率。

锁的本身是一种机制,而这种机制的实现得以于编程语言和硬件的原子指令才得以让我们的锁成为一个具体可以实现的东西。

1.1通过软件实现锁

  • 在Java中,软件互斥锁可以通过使用内置的同步机制实现,例如使用关键字 synchronized 或者 Lock 接口的实现类 ReentrantLock

以下是拿synchronized实现的例子:

public class MutexExample {
    private static int counter = 0;
    private static final Object lock = new Object();

    public static void main(String[] args) {
        Runnable incrementTask = () -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (lock) {
                    counter++;
                }
            }
        };

        Thread thread1 = new Thread(incrementTask);
        Thread thread2 = new Thread(incrementTask);

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Counter: " + counter);
    }
}

count变量由于加了synchronized关键字的原因,导致每次对counter+1,是安全唯一的,并不会出现多线程竞争冒险的情况,导致数据覆盖发生纹路,因为这里的counter++ 并不是原子操作(counter++ --> 要先取值,在进行运算,在进行赋值,因此三个过程很有可能在多线程的情况下被干扰).

另外,java中也可以使用ReentrantLock锁去实现,这已经是种已经封装好的锁,例如上述案例:

public class MutexExample {
    private static int counter = 0;
    private static final Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        Runnable incrementTask = () -> {
            for (int i = 0; i < 10000; i++) {
                lock.lock();
                try {
                    counter++;
                } finally {
                    lock.unlock();
                }
            }
        };

        Thread thread1 = new Thread(incrementTask);
        Thread thread2 = new Thread(incrementTask);

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Counter: " + counter);
    }
}

这种实现更加灵活,ReentrantLock 提供了更多的灵活性,例如可中断锁、定时锁等.


1.2通过硬件实现锁

通过硬件去实现锁,无非就是利用和硬件相关的汇编指令,也就是这些原子指令(不可拆分),导致锁的机制得以实现.

例如常见的原子操作:

  1. Compare and Swap (CAS):比较并交换操作,通常用于实现乐观锁。它比较内存中的值与预期值,如果相等,则将新值写入内存。这个操作是原子的,用于保证多线程环境下的原子性和同步。
  2. Fetch and Add (FAA):获取并增加操作,用于原子地获取内存中的值并增加指定的增量。常用于实现计数器或者线程标识等场景。
  3. Fetch and Subtract (FAS):获取并减少操作,与 Fetch and Add 相反,用于原子地获取内存中的值并减少指定的减量。
  4. Fetch and Increment (FAI):获取并递增操作,用于原子地获取内存中的值并递增。
  5. Fetch and Decrement (FAD):获取并递减操作,用于原子地获取内存中的值并递减。

因此,在编程语言中也有相关的类去提供这些原子操作的实现,在java当中,这些类被统称为原子类(如 AtomicIntegerAtomicLong).

例如(CAS):

import java.util.concurrent.atomic.AtomicBoolean;

public class SpinLockExample {
    private static final AtomicBoolean locked = new AtomicBoolean(false);

    public static void main(String[] args) {
        Runnable task = () -> {
            while (!tryLock()) {
                // 自旋等待
            }
            // 执行需要保护的临界区代码
            System.out.println("Thread entered critical section");
            // 解锁
            unlock();
        };

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();
    }

    private static boolean tryLock() {
        return locked.compareAndSet(false, true);
    }

    private static void unlock() {
        locked.set(false);
    }
}

在这,通过原子类去调用CAS方法用来实现锁,通过预期值和实际值的比较返回一个布尔值;

public final boolean compareAndSet(boolean expect, boolean update)

意味着值相等,返回true,值不相等返回false.而这种实现方法其实常常应用到乐观锁,自旋锁里,因为执行的流程是原子操作,多线程情况下能够保证线程安全性和原子性.

那么硬件是如何提供支持来实现这些原子操作的?

因为代码本身其实并不具备原子性,就拿CAS举例说明:如果两个线程同时对共享变量value进行修改,同样会出现更新遗失的情况,因此硬件辅助是不可缺少的,而通过硬件内联的汇编语言去实现CAS原子操作的代码就显得很有必要.

这里拿x86架构下的汇编语言为例:

lock_cmpxchg:
    mov eax, [mem]        ; 将内存地址 mem 处的值加载到寄存器 eax
    cmp eax, [old_val]    ; 比较寄存器 eax 中的值与 old_val 的值
    jne end               ; 如果不相等,则跳转到 end 标签
    mov ebx, [new_val]    ; 将 new_val 的值加载到寄存器 ebx
    lock cmpxchg [mem], ebx ; 如果 eax 中的值等于内存地址 mem 处的值,则将 ebx 的值写入内存地址 mem 处
end:
    ; 继续执行其他指令...

mem 是要进行 CAS 操作的内存地址,old_val 是预期的旧值,new_val 是要更新为的新值。CAS 操作的目标是将 new_val 的值写入内存地址 mem 处,前提是当前内存地址 mem 处的值等于 old_val

通过使用 cmpxchg 指令,可以比较 eax 中的值与内存地址 mem 处的值。如果相等,则将 ebx 的值写入内存地址 mem 处,否则不执行写入操作


1.3混合实现锁

例如:使用Lock和原子类共同实现锁机制:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.atomic.AtomicInteger;

public class MixedLockExample {
    private final Lock lock = new ReentrantLock();
    private final AtomicInteger counter = new AtomicInteger(0);

    public void performMixedLocking() {
        // 使用软件锁进行初步同步
        lock.lock();
        try {
            // 进入临界区
            int value = counter.getAndIncrement();

            // 在临界区内部使用硬件锁进行细粒度同步
            // 执行共享资源的访问和修改操作
            // ...

            // 离开临界区
        } finally {
            lock.unlock();
        }
    }
}

通过使用 Lock 接口和原子类,我们实现了软件锁和硬件锁的混合使用。Lock 接口提供了更多的灵活性,例如可中断锁、定时锁等。而原子类则提供了硬件层面的原子操作,保证了共享资源的原子性和一致性。这样可以在不同层面上提供线程同步和原子性保证,提高并发性能和可靠性。


2.互斥锁

互斥锁本质上其实就是一种排他锁,他不允许在未持有锁的情况下去访问线程资源,因此同一时刻会导致只有一个线程拥有锁。

  1. 可重入性:同一个线程可以多次获取互斥锁而不会产生死锁。线程在获取锁时,记录锁的拥有者和计数器,每次成功获取锁时,计数器加一,每次释放锁时,计数器减一,只有计数器归零时,锁才完全释放。

在JAVA中,synchronized 关键字和 ReentrantLock 类都是互斥锁的实现。

我们来聊一下ReentrantLock这个类,他的英文名就是可重入锁的意思,与synchronized一个很明显地区别就是他可以主动地去使用,通过上锁和解锁的形式,主动地去操控线程锁的机制,

  • 现在我们来了解一下ReentrantLock锁的实现原理:

    public void lock() {
         sync.acquire(1);
    }
    

    SyncReentrantLock 的内部抽象类,它定义了具体的锁操作和状态管理。Sync里并没有实现accquire的方法,于是查询他的父类AbstractQueuedSynchronizer(AQS);

     public final void acquire(int arg) {
            if (!tryAcquire(arg) &&
                acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
                selfInterrupt();
     }
    

    这个方法可以说是整体实现的一个核心,我们先来了解其他方法,先从tryAcquire开始,由于AQS没有具体去实现,而是由Sync的两个的子类分别实现的,因此先拿公平锁举例:

    protected final boolean tryAcquire(int acquires) {
                final Thread current = Thread.currentThread();
                int c = getState();
                if (c == 0) {
                    if (!hasQueuedPredecessors() &&
                        compareAndSetState(0, acquires)) {
                        setExclusiveOwnerThread(current);
                        return true;
                    }
                }
                else if (current == getExclusiveOwnerThread()) {
                    int nextc = c + acquires;
                    if (nextc < 0)
                        throw new Error("Maximum lock count exceeded");
                    setState(nextc);
                    return true;
                }
                return false;
    }
    

    这里的大致意思是首先获取当前的进程,然后获取锁的状态,如果锁的状态等于0,那么去走hasQueuedPredecessors,意思就是判断当前线程在队列中是否是第一位,然后进程CAS操作,将锁的状态变成期望的值,也就是1,然后再通过setExclusiveOwner....方法将当前线程置为锁的持有者.以上就是锁的状态等于0的情况.

    ,如果锁的状态不等于0,他会去判断当前线程是不是锁的持有者(其实根据英文名大概也知道意思了),然后他会将获得的值与锁的状态+1,因此这个地方其实就能解释为什么可重入性了,即同一个线程可以不断地获取锁,因此会使锁的计数不断+1,而下面的异常则是锁的状态<0这种预料之外的错误,之后将锁的状态置换回去就完成了锁的实现.

  • 以下是某些方法其中的调用

    protected final boolean compareAndSetState(int expect, int update) {
            return STATE.compareAndSet(this, expect, update);
    }
    

    此方法是AbstractQueuedSynchronizer抽象类下的

    protected final void setExclusiveOwnerThread(Thread thread) {
            exclusiveOwnerThread = thread;
    }
    

    此方法则是AbstractQueuedSynchronizer的父类AbstractOwnableSynchronizer实现的,通理get.....也是一样的.

  • 而非公平锁的acquire其实与公平锁类似,唯一不同在于他们想要获得锁的策略是不一样的,也就是非公平锁他不会去调用hasQueuedPredecessors方法,意味着队列中如果有排队等待的线程,他不会礼让,这种机制其实可以提高并发量,不过也有可能引发其他线程的饥饿.具体情况具体分析吧.

接下来会调用acquireQueued方法(这是没获得锁的情况,获得锁的前提下会因为短路运算符直接退出).而了解此方法前会调用addWaiter将此线程创建一个结点添加至队尾,因为没有获取到锁,因此将他放入等待队列.

private Node addWaiter(Node mode) {
        Node node = new Node(mode);

        for (;;) {
            Node oldTail = tail;
            if (oldTail != null) {
                node.setPrevRelaxed(oldTail);
                if (compareAndSetTail(oldTail, node)) {
                    oldTail.next = node;
                    return node;
                }
            } else {
                initializeSyncQueue();
            }
        }
}

接下来我们看acquireQueued源码的具体实现:

final boolean acquireQueued(final Node node, int arg) {
        boolean interrupted = false;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node))
                    interrupted |= parkAndCheckInterrupt();
            }
        } catch (Throwable t) {
            cancelAcquire(node);
            if (interrupted)
                selfInterrupt();
            throw t;
        }
}

先来看一下这个for循环,很明显是一个自旋锁其实,无限循环,因此他里面的操作其实很简单就是想尽办法获取锁.

  • final Node p = node.predecessor(); 这行代码用于获取当前节点 node 的前驱节点 p
   final Node predecessor() {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
   }
  • if (p == head && tryAcquire(arg)) { ... } 这个条件判断语句表示如果当前节点的前驱节点是头节点,并且能够成功获取到锁(tryAcquire 返回 true),则表示获取锁成功,执行相关操作后返回。

  • if (shouldParkAfterFailedAcquire(p, node)) ... 这个条件判断语句用于判断是否应该在获取锁失败后进行阻塞。因此,自旋锁的实现就得以体现,在未获得锁之前,会被阻塞(这里我查阅了相关资料发现其实不会立即阻塞,而是继续循环,之所以引入这个方法就是为了避免线程的切换和上下文的开销,同时也让线程具备自身阻塞的能力),直到获得锁为止,因此也保证了Reentrant的互斥性.

至此,整个互斥锁的获得流程就大致结束了,因此互斥锁的实现其内部包含了自旋,可重入性等操作来共同实现互斥锁.

至于解锁的过程,只需要反方向理解即可.


3.自旋锁

其实自旋锁同样是一种机制,通过忙忙不断的自旋也就是循环去获得锁,而通过原子类一般可以实现简单的自旋锁:

import java.util.concurrent.atomic.AtomicBoolean;

public class SpinLock {
    private AtomicBoolean locked = new AtomicBoolean(false);

    public void lock() {
        while (!locked.compareAndSet(false, true)) {
            // 自旋等待,直到成功获取锁
        }
    }

    public void unlock() {
        locked.set(false);
    }
}

而这些原子类的操作得以于本身的方法底层是通过原子操作去实现的.而这种机制其实不像是去实现进程的并发现,而是为了去实现其他锁的一种机制.而由于互斥锁ReentrantLock其实本质上只有一个线程作为锁的持有者,因此其他线程会因为FIFO先入先出而陷入这种阻塞的状态,通过排队自旋的方式去实现锁的获得.


4.读写锁

读写锁其实也是一种并发机制,将对数据的行为进行划分,也就是读操作和写操作,而根据行为的性质判断出具体的操作.读写锁有两种状态:读模式和写模式。在读模式下,多个线程可以同时获得读锁并进行读操作,互不干扰。而在写模式下,只允许一个线程获得写锁进行写操作,其他线程无法同时进行读或写操作。

在JAVA中,读写锁的实现:ReadWriteLock,其中最常用的实现是ReentrantReadWriteLock

ReentrantReadWriteLock内部包含了一个互斥锁(写锁)和一个共享锁(读锁),根据需要动态地控制读写的并发性。

读写锁

  • 而写锁由于互斥性,底层的上锁和释放锁和ReentrantLock是一样的,因此这种锁在实现机理上于互斥锁基本相同,

  • 而面对读锁时,则有些不同,因为读锁他更加宽阔,更加愿意分享,因此:

    他的上锁机制是这样的:

     public void lock() {
                sync.acquireShared(1);
     }
    

    同样是通过AQS类去实现的:

      public final void acquireShared(int arg) {
            if (tryAcquireShared(arg) < 0)
                doAcquireShared(arg);
      }
    

    tryAcquireShared的源码实现是根据Sync类实现的:

     protected final int tryAcquireShared(int unused) {
                /*
                 * Walkthrough:
                 * 1. If write lock held by another thread, fail.
                 * 2. Otherwise, this thread is eligible for
                 *    lock wrt state, so ask if it should block
                 *    because of queue policy. If not, try
                 *    to grant by CASing state and updating count.
                 *    Note that step does not check for reentrant
                 *    acquires, which is postponed to full version
                 *    to avoid having to check hold count in
                 *    the more typical non-reentrant case.
                 * 3. If step 2 fails either because thread
                 *    apparently not eligible or CAS fails or count
                 *    saturated, chain to version with full retry loop.
                 */
                Thread current = Thread.currentThread();
                int c = getState();
                if (exclusiveCount(c) != 0 &&
                    getExclusiveOwnerThread() != current)
                    return -1;
                int r = sharedCount(c);
                if (!readerShouldBlock() &&
                    r < MAX_COUNT &&
                    compareAndSetState(c, c + SHARED_UNIT)) {
                    if (r == 0) {
                        firstReader = current;
                        firstReaderHoldCount = 1;
                    } else if (firstReader == current) {
                        firstReaderHoldCount++;
                    } else {
                        HoldCounter rh = cachedHoldCounter;
                        if (rh == null ||
                            rh.tid != LockSupport.getThreadId(current))
                            cachedHoldCounter = rh = readHolds.get();
                        else if (rh.count == 0)
                            readHolds.set(rh);
                        rh.count++;
                    }
                    return 1;
                }
                return fullTryAcquireShared(current);
    }
    
  • 这个代码不得不说我看的真头疼,因为if真的多,所以打算一步一步去看

    1. 首先,获取当前线程对象并获取当前锁的状态(使用getState()方法)。

      Thread current = Thread.currentThread();
      int c = getState();
      
    2. 接下来,检查是否有写锁被其他线程持有,如果是,则返回-1,表示当前线程无法获取读锁。这是为了保证读锁和写锁的互斥性。exclusiveCount(c)是用于提取状态中的写锁计数,如果计数不为0,则表示有写锁被持有。getExclusiveOwnerThread()方法用于获取持有写锁的线程对象。

      if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
          return -1;
      

      接下来我们深入刨析一下exclusiveCount(c)方法,因为他将揭示锁的状态:

      static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
      

      锁的状态

      • 因此上面的代码实际是:

        static int exclusiveCount(int c) {
            return c & 65535;
        }
        

        其实这个16太灵性了,我理解了很久,看着位运算,又想了写和读的两种实现,我大概理解了因为c这个参数其实就是锁的状态state他是一个int,也就是32位,而执行此方法时65535是后16位,16个1,只要后16位中其中有1个1就能立刻知道这个锁的状态有写锁,也就是互斥锁,而读锁则是高16位,也很明显,下一个方法sharedConter内部执行过程是这样的:

        static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
        

        无符号移动16位来分享读锁的数量,只有高16位保存着读锁的信息,他才敢这么移位,因此高16位表示读锁,低16位表示写锁,理解了这一个状态就很好地理解下面的机制了

    3. 接下来readerShouldBlock()方法用于确定当前线程是否应该被阻塞。接着,检查当前读锁的计数是否小于MAX_COUNT,并且通过使用compareAndSetState()方法将状态原子地更新为新值(c + SHARED_UNIT)。如果成功更新状态,则表示当前线程成功获取读锁。

      int r = sharedCount(c);
      if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) {
          //...
          return 1;
      }
      

      readerShouldBlock其实得以于Sync的两个子类的实现,公平锁和非公平锁,HoldCounter:辅助类,记录该线程持有读锁的次数.而公平锁的实现机制其实就是调用了hasQueuedPredecessors,具体如下:

      final boolean readerShouldBlock() {
                  return hasQueuedPredecessors();
              }
      

      这个方法在互斥锁的时候其实有接触过,也就是遵循着公平锁的原则实现公平等待,看看前面还有没有等待的线程,如果有排队的线程,则返回true,让当前线程阻塞.而后面另一个条件的判断r,判断就是读锁的个数还够不够了,如果都超了就没有必要了,因此读锁的限制是16位.而后面的CAS操作我们也接触过.就是期望状态与当前状态判断一下,如果相同,则增加一个共享单位,也就是赋值.(思考一下这里加多少?)

      static final int SHARED_SHIFT   = 16;
              static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
      

      意思这个数值是65536,为什么加这么多?,因为低16位代表的是写锁,而高16位代表的是读锁,这意味着其实对于读锁也就加了1而已,因此实际上是让读锁的数量多了1而已.

      因此,这句if的意思就是如果没有线程排队的情况下,且读锁的数量足够,那么此时读锁+1

    4. 如果读锁的计数为0,表示当前线程是第一个获取读锁的线程。在这种情况下,将当前线程设置为firstReader,并将firstReaderHoldCount设置为1。这样可以跟踪第一个获取读锁的线程以及它持有读锁的数量。

      如果读锁的计数不为0,表示当前线程不是第一个获取读锁的线程。如果当前线程是firstReader,则增加firstReaderHoldCount的计数。否则,从缓存的HoldCounter对象中获取线程的持有计数,如果不存在或者线程ID不匹配,则通过readHolds.get()方法获取一个新的HoldCounter对象。如果HoldCounter的计数为0,则将其设置回readHolds列表中。最后,将HoldCounter的计数增加。

      if (r == 0) {
          firstReader = current;
          firstReaderHoldCount = 1;
      } else if (firstReader == current) {
          firstReaderHoldCount++;
      } else {
          HoldCounter rh = cachedHoldCounter;
          if (rh == null ||
              rh.tid != LockSupport.getThreadId(current))
              cachedHoldCounter = rh = readHolds.get();
          else if (rh.count == 0)
              readHolds.set(rh);
          rh.count++;
      }
      

      至于这个缓存计数器,其实就是这样的:

      static final class HoldCounter {
          int count = 0; // 读锁持有的次数
          final long tid = getThreadId(Thread.currentThread()); // 线程 ID
      }
      

      缓存技术器

      也就意味着其实是线程私有的成员属性,他属于每个对象的实例成员变量,用来缓存线程持有读锁的数量.

    而当这一切都做完了就意味着获得了读锁,就会返回1,如果没有,则走另一个方法,而那个方法则是fullTryAcquireShared,通过自旋的方式去尝试获得读锁.其中的流程其实和获得锁的实现大差不多,只不过后者相比前者更为复杂.至于哪里复杂在于

    1. 其实读锁的获取他并没有考虑到当前线程是持有写锁的线程,而后者的方法考虑到了.

    2. 同样,内部存在一些Error的情况

      if (sharedCount(c) == MAX_COUNT)
                          throw new Error("Maximum lock count exceeded");
      
  • 因此回到前面的tryAcquireShared方法上,在未尝试获取到锁后,则会去调用doAcquireShared(arg),而这个操作在互斥锁中其实也得以实现

而释放锁的逻辑机理其实也是类似的,虽然锁的实现的确过于复杂,但只要耐着心一定能够有所收获.

posted @ 2023-06-10 09:53  不会上猪的树  阅读(106)  评论(0)    收藏  举报