基础篇:独占锁、共享锁、公平锁、非公平锁,叫我如何分得清

引言

本文继续讲解 Java 并发编程实践的基础篇,今天来说说并发编程中锁的概念。不同领域,对锁的分类也不同,比如数据库的表锁、行锁等,它们因底层细节的差异,而有了各自的名字。扩展到整个 IT 技术领域,衍生出的那些名目繁多的锁,大抵也都是这样产生的。

Java 语言中,最顶层锁的实现方式,只有内置锁和显式锁两种。但是,这两种锁在实现过程中,遭遇到了各种处理方式的选择,不同的处理方式,也对应着一种锁,比如:

  1. 已经被某个线程持有的锁,是否允许其他线程线程同时持有呢?【独占/共享】
  2. 多个线程阻塞在同一个条件队列上时,先唤醒谁呢?已经有线程排队等待某个锁时,又有新的线程请求该锁,而恰好该锁被释放了,是否允许新线程插队获取锁呢?【公平/非公平】
  3. 已经持有锁的线程,还想继续请求同一把锁,是否允许呢?【可重入】
  4. 线程在请求锁而不得的等待期间,是否允许外部调用 inerterupt 中断该线程呢?【可中断锁】

对于开发人员而言,内置锁和显式锁的实现,是一个白盒,我们只需要知道哪种 API 可以触发某种锁的处理分支就可以了,没有必要去纠结它们之间的具体区别。况且,这些锁的概念,有些是分属不同维度的,貌似也没有可比性。

一起来跟它们过过招吧!

锁的独占与共享

锁的独占与共享,是排他性的两种表现。指已经被某个线程持有的锁,是否允许其他线程线程同时持有?内置锁和显式锁在解决这个问题时,具体是怎么做的呢?这就是独占锁和共享锁产生的背景了,它们的处理差异为:

  1. 独占锁,每次只能有一个线程能持有锁【霸道独享】
  2. 共享锁,则允许多个线程同时获取锁,并发访问共享资源【和谐共享】

内置锁和显式锁的排他性

先来看由 synchronized 代表的监视器层面的内置锁 ,它是以独占方式实现的,只允许一个线程持有某个锁对象,锁未释放,其他线程只能等待。

而以 Lock 为代表的显式锁,它提供了两种锁实现模式,独占和共享。比如,ReentrantLock 是独占锁,ReadWriteLock 的读锁是共享锁,写锁是独占锁。很显然,独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。

共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。 Java 的 ReadWriteLock,读-写锁,它允许一个资源同时被多个读线程访问,或只能被一个写线程访问,但两者不能同时进行,共享也仅限于读操作。如果是写线程获取了锁控制权,那么此时的锁就被降级成独占锁了,即由共享锁变成了独占锁。

事实上,在 “读多-写少” 的并发场景下,乐观锁它允许多个读线程同时访问资源,极大地提高了并发效率,但是在 “写多-读少” 的场景下,效果跟悲观锁就一样的。

AQS 的模板方法

AQS ,全称是 「 AbstractQueuedSynchronizer 」,它是显式锁的底层抽象类,定义了独占锁和共享锁必须实现的方法。而独占和共享,分别对应着 AQS 的内部类 Node 的两个常量 SHAREDEXCLUSIVE ,标识 AQS 队列中等待线程的锁获取模式。

独占锁的子类,必须实现 tryAcquiretryReleaseisHeldExclusively 等方法;共享锁的子类,必须实现 tryAcquireSharedtryReleaseShared 等方法,带有 Shared 后缀的方法是支持共享锁语义的。JUC 中,Semaphore 是一种共享锁,ReentrantLock 则是一种独占锁。

独占锁获取锁时,需要设置等待线程的节点模式为 Node.EXCLUSIVE,源码如下:

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

而共享锁,节点模式则为 Node.SHARED,是添加到等待队列的线程的锁模式:

  private void doAcquireShared(int arg) {
     final Node node = addWaiter(Node.SHARED);
     boolean failed = true;
     .....
 }

共享锁应用案例

来看一个读共享的例子,笔者曾在问答频道回答过 这样一个问题 ,一个使用 ReadWriteLock 的例子中,写线程锁释放锁之后,读线程获取锁的机会还是很少,读线程的并发数并不高,这是为什么呢?

首先,使用读写锁提供一个具有 plus 写操作和 get 读操作的类 ReadWriteFac

class ReadWriteFac {
    ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private volatile int i = 0;
    Lock r = lock.readLock();
    Lock w = lock.writeLock();

    public void plus() {
        w.lock();
        System.out.println(Thread.currentThread().getName() + "---获取了写锁");
        try {
            i++;
            System.out.println(Thread.currentThread().getName() + "---将i修改为" + i);
            r.lock();
        } finally {
            w.unlock();//释放写锁  因为上面读锁未被释放 其他写线程无法进入但读线程可以继续
            System.out.println(Thread.currentThread().getName() + "---释放了写锁\r\n\r\n");
            try{
                TimeUnit.SECONDS.sleep(3);
            }catch(Exception e){
            }
            r.unlock();//释放读锁
            System.out.println(Thread.currentThread().getName() + "---释放了读锁");
        }
    }

    public int get() {
        r.lock();
        try {
                if(i!=10){
                    System.out.println(Thread.currentThread().getName() + "获取到了" + i);
                }
            return i;
        }finally {
            r.unlock();
        }
    }
}

接着,定义一个测试类,创建 10 个写线程, 5 个读线程,读线程循环读取数据,直到 30 秒后程序结束:

public class ReadWriteTest {
	private static boolean isRun = true;
    public static void main(String[] args) {
        ReadWriteFac fac = new ReadWriteFac();
        Thread thread = new Thread();
        for (int i = 0; i < 5; i++) {
           Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    while (isRun) {
                        fac.get();
                    }
                }
            }, "读线程" + i);
           t1.setPriority(10);
           t1.start();
        }
        for (int i = 0; i < 10; i++) {
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    fac.plus();
                }
            }, "写线程" + i);
            t1.setPriority(1);
            t1.start();
        }
        
        try{
            TimeUnit.SECONDS.sleep(30);
            isRun = false;
        }catch(Exception e){
        }
    }
}

示例中,读线程使用 while(true) 循环读取最新数据;plus() 方法中,写锁释放之后,线程休眠了 3 秒。理论上,这 3 秒休眠期间,读线程应该有机会获取读锁、继续执行读操作的,但是运行后发现跟预期效果不一致,为什么呢?

笔者推测,根源是写线程过多,当写锁释放的时候,写锁又立即被其他写线程获取了,导致一直看到是写线程占据写锁,而读线程在这休眠的三秒内,获得读锁的机会很少。

读写锁适用读多写少的场景,而这个测试案例恰好相反,写多读少,导致写锁一释放,就被其他的写线程给抢占了,所以读线程依旧没有机会获取读锁。调小写线程个数,打印时间,就能看到写线程释放写锁休眠期间读线程获取读锁的过程了。

锁的公平与非公平

来看第二个问题, 多个线程阻塞在同一个条件队列上时,先唤醒谁呢?已经有线程排队等待某个锁时,又有新的线程请求该锁,而此时恰好该锁被释放了,是否允许新线程插队获取锁呢?

这两个问题都属于锁的公平与非公平概念,它是指线程请求获取锁的过程中,是否允许插队。公平锁实现的方式是,JVM 将按线程发出请求的顺序来获得锁;而非公平锁则允许在线程发出请求后立即尝试获取锁,如果可用则可直接获取锁,尝试失败才进行排队等待。

插队的诱惑

插队请求锁,带来的实际效益是什么呢?我们来看看排队唤醒的过程:

  1. 第一步,将当前请求锁的线程加入队尾
  2. 第二步,从队头移除一个等待最久的线程
  3. 第三步,把锁分配给线程

插队比按规矩排队更高效,因为时机刚好,只需要执行第三步,当然更简单啦。

内置锁和显式锁的公平性

内置锁和显式锁在公平性方面的表现是,内置锁是非公平锁。多个线程阻塞在同一个条件队列上时,随机唤醒;已经有线程排队等待某个锁时,又有新的线程请求该锁,而此时恰好该锁被释放了,则直接给它。在公平性和排他性方面,内置锁是非公平、排他的,这是底层决定的,没有办法干预。

相比之下,显式锁灵活多了,它允许开发者选择。比如,ReentrantLock 类维护了一个成员变量
private final Sync sync;,它代表了锁获取方式,这个抽象类有两种实现 FairSynNofairSync,从源码中看,类的层级关系为:
在这里插入图片描述
ReentrantLock 默认是非公平的,它的有参构造函数可以传入一个标识改变锁的类型,源码相当简洁:

    /**
     * Creates an instance of {@code ReentrantLock}.
     * This is equivalent to using {@code ReentrantLock(false)}.
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * Creates an instance of {@code ReentrantLock} with the
     * given fairness policy.
     *
     * @param fair {@code true} if this lock should use a fair ordering policy
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

启示录

笔者最初看源码时并没有了解过这些锁的概念,所以看得很困惑。理解了用法之后,意识到,对于开发者来说,上层始终只有内置锁和显式锁这一个概念,其他都是为它的实现服务的。

弄清了内置锁和显式锁的各个行为表现,这些概念就不攻自破了:

锁实现 排他性 公平性 是否可重入 是否可中断
内置锁 独占锁 非公平 可重入 不可中断
ReentrantLock 独占锁 默认非公平,可更改为公平 可重入 可中断
ReadWriteLock 共享锁 默认非公平,可更改为公平 可重入 可中断
发布了234 篇原创文章 · 获赞 494 · 访问量 37万+

猜你喜欢

转载自blog.csdn.net/wojiushiwo945you/article/details/103612673