互斥锁、自旋锁、读写锁、乐观锁、悲观锁

462 阅读6分钟

互斥锁

互斥锁是一种「独占锁」,当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,线程 B 加锁的代码就会被阻塞。

对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态, 等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。

互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。如果被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁。

image.png

自旋锁

  • 互斥锁加锁失败后,线程会释放 CPU,进入阻塞状态 ,给其他线程
  • 自旋锁加锁失败后,线程会忙等待,CAS,直到它拿到锁

CAS

一个写操作是分3步执行的:1.读取,2.修改,3.写回。这并不是一个原子操作,有可能2个线程交错执行这3步,出现脏读等并发问题。CAS用于执行第3步写回时,将第1步读取到的值作为预期值,再重新读取一个当前实际值,比较预期值和实际值,如果相等,说明在执行第2步修改的过程中,没有其他线程对该共享变量修改,则执行第3步写回,否则放弃写回,重新执行写操作,即再次1.读取,2.修改,3.CAS写回,重复这个过程,直至成功。

3个操作数:内存当前实际值,预期原始值,修改后的新值

  • 如果内存中的值和预期原始值相等, 就将修改后的新值保存到内存中。
  • 如果内存中的值和预期原始值不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作,直到重试成功。

以上操作是操作系统底层汇编的一个原子指令实现的,保证了原子性。即3步CAS写回可以用一个原子操作完成,但整个写操作的3步如果要保证原子性,那就要对这3步加锁,就是悲观锁了。

CAS的忙等待状态,不会释放cpu,不叫阻塞。CAS乐观锁认为冲突的可能不大,多数情况不用循环忙等待或不用等待太久,避免了加锁失败时状态切换的开销。

AtomicInteger等原子类没有使用synchronized锁,而是通过volatile和CAS(Compare And Swap)解决资源的线程安全问题。

读写锁

读写锁适用于能明确区分读操作和写操作的场景。

读锁是「共享锁」,多个读线程能够并发地持有读锁,同时读取共享资源,只是和写线程互斥。

写锁是「独占锁」,和其他所有线程互斥,包括读线程和其他写线程。

读写锁可以分为「读优先锁」和「写优先锁」。

读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁。

如果一直有读线程获取读锁,那么写线程将永远获取不到写锁,这就造成了写线程「饥饿」的现象。

image.png

写优先锁:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,(虽然A,C都是读线程,但A的读锁外面又被B加了一个写锁,C会先面对外面优先级更高的互斥的写锁),这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁。

如果一直有写线程获取写锁,读线程也会被「饿死」。

乐观锁与悲观锁

互斥锁、读写锁,属于悲观锁。自旋锁是乐观锁。

悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。

乐观锁,总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。

乐观锁全程并没有加锁,所以它也叫无锁编程。

在线文档,SVN 和 Git 也是用了乐观锁的思想。

java中的锁

常用的synchronized关键字和并发包中的锁类,都属于互斥锁

synchronized

class A {
    // 对象锁:普通实例方法默认同步监视器就是this,即调用该方法的对象
    public synchronized void methodA() {}

    public void methodB() {
        // 对象锁:this表示是对象锁
        synchronized (this) {
        }
    }

    // 类锁:修饰静态方法
    public static synchronized void methodC() {
    }

    public void methodD() {
        // 类锁:A.class说明是类锁
        synchronized (A.class) {
        }
    }

    // 普通方法:任何情况下调用时,都不会发生竞争
    public void common() {
    }
}

methodA,和methodB都是对当前对象加锁,即如果有两个线程同时访问同一个对象的methoA或methodB会发生竞争。如果两个线程访问的是不同对象的methodA和methodB则不会发生竞争。

methodC和methodD是对类加锁,即如果两个线程同时访问同一个对象的methodC和methodD会发生竞争,且两个线程同时访问不同对象的methodC和methodD是也会发生竞争。

如果一个线程访问methodA或methodB,另一个线程访问methodC或methodD,则这两个线程不会发生竞争。因为一个是类锁另一个是对象锁。类锁和对象锁是两个不一样的锁,控制着不同的区域,它们互不干扰。

ReentrantLock

重入锁,一个线程在拥有了当前资源的锁之后,可以再次拿到该锁而不被阻塞

class A {
    private ReentrantLock lock = new ReentrantLock();
    public void testLock() {
        // 获取锁
        lock.lock();
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("test ReentrantLock ");
        // 释放锁
        lock.unlock();
    }
}