Java 并发包的基石 显式锁

16.2 显式锁

      15.2节介绍了利用synchronized实现锁,我们提到了synchronized的 一些局限性,本节探讨Java并发包中的显式锁,它可以解决synchronized 的限制。

      Java并发包中的显式锁接口和类位于包java.util.concurrent.locks下, 主要接口和类有:

      ·锁接口Lock,主要实现类是ReentrantLock

      ·读写锁接口ReadWriteLock,主要实现类是 ReentrantReadWriteLock

      本节主要介绍接口Lock和实现类ReentrantLock,关于读写锁,我们 后续章节介绍。

16.2.1 接口Lock

      显式锁接口Lock的定义为:

在这里插入图片描述

      下面解释一下。

      1)lock()/unlock():就是普通的获取锁和释放锁方法, lock()会阻塞直到成功。

      2)lockInterruptibly():与lock()的不同是,它可以响应中断, 如果被其他线程中断了,则抛出InterruptedException。

      3)tryLock():只是尝试获取锁,立即返回,不阻塞,如果获取成功,返回true,否则返回false。

      4)tryLock(long time,TimeUnit unit):先尝试获取锁,如果能成功则立即返回true,否则阻塞等待,但等待的最长时间由指定的参数设置,在等待的同时响应中断,如果发生了中断,抛出 InterruptedException,如果在等待的时间内获得了锁,返回true,否则返 回false。

      5)newCondition:新建一个条件,一个Lock可以关联多个条件, 关于条件,我们留待16.3节介绍。

      可以看出,相比synchronized,显式锁支持以非阻塞方式获取锁、 可以响应中断、可以限时, 这使得它灵活得多。

16.2.2 可重入锁ReentrantLock

      下面,先介绍ReentrantLock的基本用法,然后重点介绍如何使用 tryLock避免死锁。

1.基本用法

      Lock接口的主要实现类是ReentrantLock,它的基本用法lock/unlock 实现了与syn-chronized一样的语义,包括:

      ·可重入,一个线程在持有一个锁的前提下,可以继续获得该锁;

      ·可以解决竞态条件问题;

      ·可以保证内存可见性。

      ReentrantLock有两个构造方法:

在这里插入图片描述

      参数fair表示是否保证公平,不指定的情况下,默认为false,表示不保证公平所谓公平是指,等待时间最长的线程优先获得锁。保证公平会影响性能,一般也不需要,所以默认不保证, synchronized锁也是不保证公平的,16.2.3节还会再分析实现细节。

      使用显式锁,一定要记得调用unlock一般而言,应该将lock之后 的代码包装到try语句内,在finally语句内释放锁。比如,使用 ReentrantLock实现Counter,代码可以为:

在这里插入图片描述

2.使用tryLock避免死锁

      使用tryLock(),可以避免死锁 。在持有一个锁获取另一个锁而 获取不到的时候,可以释放已持有的锁,给其他线程获取锁的机会,然 后重试获取所有锁。

      我们来看个例子,银行账户之间转账,用类Account表示账户,如 代码清单16-3所示。

在这里插入图片描述
      Account里的money表示当前余额,add/reduce用于修改余额。在账户之间转账,需要两个账户都锁定,如果不使用tryLock,而直接使用 lock,则代码如代码清单16-4所示。

在这里插入图片描述

      但这么写是有问题的,如果两个账户都同时给对方转账,都先获取了第一个锁,则会发生死锁。我们写段代码来模拟这个过程,如代码清单16-5所示。

在这里插入图片描述
      以上代码创建了10个账户,100个线程,每个线程执行100次循环, 在每次循环中,随机挑选两个账户进行转账。在笔者的计算机中,每次执行该段代码都会发生死锁。读者可以更改这些数值进行试验。

      我们使用tryLock来进行修改,先定义一个tryTransfer方法,如代码 清单16-6所示。

在这里插入图片描述

      如果两个锁都能够获得,且转账成功,则返回true,否则返回 false。不管怎样,结束都会释放所有锁。transfer方法可以循环调用该方法以避免死锁,代码可以为:
在这里插入图片描述
      除了实现Lock接口中的方法,ReentrantLock还有一些其他方法,通过它们,可以获取关于锁的一些信息,这些信息可以用于监控和调试目的,具体可参看API文档,就不介绍了。

16.2.3 ReentrantLock的实现原理

      ReentrantLock的用法是比较简单的,它是怎么实现的呢?在最底层,它依赖于16.1节介绍的CAS方法,另外,它依赖于类LockSupport中的一些方法。我们先介绍Lock-Support。

1.LockSupport

      类LockSupport也位于包java.util.concurrent.locks下,它的基本方法有:

在这里插入图片描述

      park使得当前线程放弃CPU,进入等待状态(WAITING),操作系统不再对它进行调度,什么时候再调度呢?有其他线程对它调用了 unparkunpark使参数指定的线程恢复可运行状态。我们看个例子:

在这里插入图片描述

      上述例子中,主线程启动子线程t,线程t启动后调用park,放弃 CPU,主线程睡眠1秒以确保子线程已执行LockSupport.park(),调用 unpark,线程t恢复运行,输出exit。

      park不同于Thread.yield()yield只是告诉操作系统可以先让其他线程运行,但自己依然是可运行状态,而park会放弃调度资格,使线程 进入WAITING状态。

      需要说明的是,park是响应中断的 ,当有中断发生时,park会返回,线程的中断状态会被设置。另外还需要说明,park可能会无缘无故地返回,程序应该重新检查park等待的条件是否满足

      park有两个变体:

      ·parkNanos:可以指定等待的最长时间,参数是相对于当前时间的 纳秒数;

      ·parkUntil:可以指定最长等到什么时候,参数是绝对时间,是相对于纪元时的毫秒数。

      当等待超时的时候,它们也会返回。

      这些park方法还有一些变体,可以指定一个对象,表示是由于该对象而进行等待的,以便于调试,通常传递的值是this,比如:

在这里插入图片描述
      LockSupport有一个方法,可以返回一个线程的blocker对象

在这里插入图片描述

      这些park/unpark方法是怎么实现的呢?与CAS方法一样,它们也调 用了Unsafe类中的对应方法。Unsafe类最终调用了操作系统的API,从 程序员的角度,我们可以认为Lock-Support中的这些方法就是基本操作。

2.AQS

      利用CASLockSupport提供的基本方法,就可以用来实现 ReentrantLock。但Java中还有很多其他并发工具,如 ReentrantReadWriteLockSemaphoreCountDownLatch,它们的实现有很多类似的地方,为了复用代码,Java提供了一个抽象类 AbstractQueued-Synchronizer,简称AQS,它简化了并发工具的实现。 AQS的整体实现比较复杂,我们主要以ReentrantLock的使用为例进行简要介绍。

      AQS封装了一个状态,给子类提供了查询和设置状态的方法

在这里插入图片描述

      用于实现锁时,AQS可以保存锁的当前持有线程,提供了方法进行查询和设置:

在这里插入图片描述

      AQS内部维护了一个等待队列,借助CAS方法实现了无阻塞算法进行更新。

      下面,我们以ReentrantLock的使用为例简要介绍AQS的原理。

3.ReentrantLock

      ReentrantLock内部使用AQS,有三个内部类

在这里插入图片描述

      Sync是抽象类,NonfairSync是fair为false时使用的类,FairSync是 fire为true时使用的类。ReentrantLock内部有一个Sync成员:

在这里插入图片描述

      在构造方法中sync被赋值,比如:

在这里插入图片描述
      我们来看ReentrantLock中的基本方法lock/unlock的实现。先看lock 方法,代码为:

在这里插入图片描述
      sync默认类型是NonfairSync,NonfairSync的lock代码为:

在这里插入图片描述

      ReentrantLock使用state表示是否被锁和持有数量,如果当前未被锁定,则立即获得锁,否则调用acquire(1)获得锁。acquireAQS中的方法,代码为:

在这里插入图片描述

      它调用tryAcquire获取锁,tryAcquire必须被子类重写。NonfairSync 的实现为:

在这里插入图片描述

      nonfairTryAcquire是sync中实现的,代码为:

在这里插入图片描述

      这段代码容易理解,如果未被锁定,则使用CAS进行锁定;如果已被当前线程锁定,则增加锁定次数。如果tryAcquire返回false,则AQS 会调用:

在这里插入图片描述
      其中,addWaiter会新建一个节点Node,代表当前线程然后加入 内部的等待队列中,限于篇幅,具体代码就不列出来了。放入等待队列 后,调用acquireQueued尝试获得锁,代码为:

在这里插入图片描述

      主体是一个死循环,在每次循环中,首先检查当前节点是不是第一 个等待的节点,如果是且能获得到锁,则将当前节点从等待队列中移除并返回,否则最终调用LockSupport.park放弃CPU,进入等待,被唤醒后,检查是否发生了中断,记录中断标志,在最终方法返回时返回中断标志。如果发生过中断,acquire方法最终会调用selfInterrupt方法设置中断标志位,其代码为:

在这里插入图片描述
      以上就是lock方法的基本过程,能获得锁就立即获得,否则加入等待队列,被唤醒后检查自己是否是第一个等待的线程,如果是且能获得锁,则返回,否则继续等待。这个过程中如果发生了中断,lock会记录 中断标志位,但不会提前返回或抛出异常。

      ReentrantLock的unlock方法的代码为:

在这里插入图片描述
      releaseAQS中定义的方法,代码为:

在这里插入图片描述
      tryRelease方法会修改状态释放锁,unparkSuccessor会调用 LockSupport.unpark将第一个等待的线程唤醒,具体代码就不列举了。

      FairSync和NonfairSync的主要区别是:在获取锁时,即在tryAcquire 方法中,如果当前未被锁定,即c==0,FairSync多了一个检查,如下:

在这里插入图片描述

      这个检查是指,只有不存在其他等待时间更长的线程,它才会尝试获取锁。

      这样保证公平不是很好吗?为什么默认不保证公平呢?保证公平整体性能比较低,低的原因不是这个检查慢,而是会让活跃线程得不到锁,进入等待状态,引起频繁上下文切换,降低了整体的效率, 通常情况下,谁先运行关系不大,而且长时间运行,从统计角度而言,虽然不保证公平,也基本是公平的。需要说明是,即使fair参数为true, ReentrantLock中不带参数的tryLock方法也是不保证公平的, 它不会检查是否有其他等待时间更长的线程。

16.2.4 对比ReentrantLock和synchronized

      相比synchronized,ReentrantLock可以实现与synchronized相同的语 义,而且支持以非阻塞方式获取锁,可以响应中断,可以限时,更为灵活。不过,synchronized的使用更为简单,写的代码更少,也更不容易出错。

      synchronized代表一种声明式编程思维, 程序员更多的是表达一种同步声明,由Java系统负责具体实现,程序员不知道其实现细节;显式锁代表一种命令式编程思维, 程序员实现所有细节。

      声明式编程的好处除了简单,还在于性能,在较新版本的JVM上, ReentrantLock和synchronized的性能是接近的,但Java编译器和虚拟机可以不断优化synchronized的实现, 比如自动分析synchronized的使用,对于没有锁竞争的场景,自动省略对锁获取/释放的调用。

      简单总结下,能用synchronized就用synchronized, 不满足要求时再 考虑Reentrant-Lock。

参考目录

绝大多数内容来自于:Java编程的逻辑 作者: 马俊昌(第16章 并发包的基石 -16.2 显式锁)

Java官方文档
https://docs.oracle.com/javase/specs/index.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值