不可不说的Java“锁”事

前言

Java提供了种类丰富的锁,每种锁因其特性的不一样,在适当的场景下可以展示出很是高的效率。本文旨在对锁相关源码(本文中的源码来自JDK 8)、使用场景进行举例,为读者介绍主流锁的知识点,以及不一样的锁的适用场景。html

Java中每每是按照是否含有某一特性来定义锁,咱们经过特性将锁进行分组归类,再使用对比的方式进行介绍,帮助你们更快捷的理解相关知识。下面给出本文内容的整体分类目录: java

图片1

1. 乐观锁 VS 悲观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不一样角度。在Java和数据库中都有此概念对应的实际应用。算法

先说概念。对于同一个数据的并发操做,悲观锁认为本身在使用数据的时候必定有别的线程来修改数据,所以在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。数据库

而乐观锁认为本身在使用数据时不会有别的线程修改数据,因此不会添加锁,只是在更新数据的时候去判断以前有没有别的线程更新了这个数据。若是这个数据没有被更新,当前线程将本身修改的数据成功写入。若是数据已经被其余线程更新,则根据不一样的实现方式执行不一样的操做(例如报错或者自动重试)。编程

乐观锁在Java中是经过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操做就经过CAS自旋实现的。 后端

图片1

根据从上面的概念描述咱们能够发现:安全

  • 悲观锁适合写操做多的场景,先加锁能够保证写操做时数据正确。
  • 乐观锁适合读操做多的场景,不加锁的特色可以使其读操做的性能大幅提高。

光说概念有些抽象,咱们来看下乐观锁和悲观锁的调用方式示例:数据结构

// ------------------------- 悲观锁的调用方式 -------------------------
// synchronized
public synchronized void testMethod() {
	// 操做同步资源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 须要保证多个线程使用的是同一个锁
public void modifyPublicResources() {
	lock.lock();
	// 操做同步资源
	lock.unlock();
}

// ------------------------- 乐观锁的调用方式 -------------------------
private AtomicInteger atomicInteger = new AtomicInteger();  // 须要保证多个线程使用的是同一个AtomicInteger
atomicInteger.incrementAndGet(); //执行自增1
复制代码

经过调用方式示例,咱们能够发现悲观锁基本都是在显式的锁定以后再操做同步资源,而乐观锁则直接去操做同步资源。那么,为什么乐观锁可以作到不锁定同步资源也能够正确的实现线程同步呢?咱们经过介绍乐观锁的主要实现方式 “CAS” 的技术原理来为你们解惑。多线程

CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的状况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是经过CAS来实现了乐观锁。并发

CAS算法涉及到三个操做数:

  • 须要读写的内存值 V。
  • 进行比较的值 A。
  • 要写入的新值 B。

当且仅当 V 的值等于 A 时,CAS经过原子方式用新值B来更新V的值(“比较+更新”总体是一个原子操做),不然不会执行任何操做。通常状况下,“更新”是一个不断重试的操做。

以前提到java.util.concurrent包中的原子类,就是经过CAS来实现了乐观锁,那么咱们进入原子类AtomicInteger的源码,看一下AtomicInteger的定义:

图片2

根据定义咱们能够看出各属性的做用:

  • unsafe: 获取并操做内存的数据。
  • valueOffset: 存储value在AtomicInteger中的偏移量。
  • value: 存储AtomicInteger的int值,该属性须要借助volatile关键字保证其在线程间是可见的。

接下来,咱们查看AtomicInteger的自增函数incrementAndGet()的源码时,发现自增函数底层调用的是unsafe.getAndAddInt()。可是因为JDK自己只有Unsafe.class,只经过class文件中的参数名,并不能很好的了解方法的做用,因此咱们经过OpenJDK 8 来查看Unsafe的源码:

// ------------------------- JDK 8 -------------------------
// AtomicInteger 自增方法
public final int incrementAndGet() {
  return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

// Unsafe.class
public final int getAndAddInt(Object var1, long var2, int var4) {
  int var5;
  do {
      var5 = this.getIntVolatile(var1, var2);
  } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
  return var5;
}

// ------------------------- OpenJDK 8 -------------------------
// Unsafe.java
public final int getAndAddInt(Object o, long offset, int delta) {
   int v;
   do {
       v = getIntVolatile(o, offset);
   } while (!compareAndSwapInt(o, offset, v, v + delta));
   return v;
}
复制代码

根据OpenJDK 8的源码咱们能够看出,getAndAddInt()循环获取给定对象o中的偏移量处的值v,而后判断内存值是否等于v。若是相等则将内存值设置为 v + delta,不然返回false,继续循环进行重试,直到设置成功才能退出循环,而且将旧值返回。整个“比较+更新”操做封装在compareAndSwapInt()中,在JNI里是借助于一个CPU指令完成的,属于原子操做,能够保证多个线程都可以看到同一个变量的修改值。

后续JDK经过CPU的cmpxchg指令,去比较寄存器中的 A 和 内存中的值 V。若是相等,就把要写入的新值 B 存入内存中。若是不相等,就将内存值 V 赋值给寄存器中的值 A。而后经过Java代码中的while循环再次调用cmpxchg指令进行重试,直到设置成功为止。

CAS虽然很高效,可是它也存在三大问题,这里也简单说一下:

1.ABA问题。CAS须要在操做值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。可是若是内存值原来是A,后来变成了B,而后又变成了A,那么CAS进行检查时会发现值没有发生变化,可是其实是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。

JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操做封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,若是都相等,则以原子方式将引用值和标志的值设置为给定的更新值。

2.循环时间长开销大。CAS操做若是长时间不成功,会致使其一直自旋,给CPU带来很是大的开销。

3.只能保证一个共享变量的原子操做。对一个共享变量执行操做时,CAS可以保证原子操做,可是对多个共享变量操做时,CAS是没法保证操做的原子性的。

Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,能够把多个变量放在一个对象里来进行CAS操做。

2. 自旋锁 VS 适应性自旋锁

在介绍自旋锁前,咱们须要介绍一些前提知识来帮助你们明白自旋锁的概念。

阻塞或唤醒一个Java线程须要操做系统切换CPU状态来完成,这种状态转换须要耗费处理器时间。若是同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。若是物理机器有多个处理器,可以让两个或以上的线程同时并行执行,咱们就可让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

而为了让当前线程“稍等一下”,咱们需让当前线程进行自旋,若是在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就能够没必要阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

图片3
自旋锁自己是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。若是锁被占用的时间很短,自旋等待的效果就会很是好。反之,若是锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。因此,自旋等待的时间必需要有必定的限度,若是自旋超过了限定次数(默认是10次,可使用-XX:PreBlockSpin来更改)没有成功得到锁,就应当挂起线程。

自旋锁的实现原理一样也是CAS,AtomicInteger中调用unsafe进行自增操做的源码中的do-while循环就是一个自旋操做,若是修改数值失败则经过循环来执行自旋,直至修改为功。

图片4
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,而且引入了自适应的自旋锁(适应性自旋锁)。

自适应意味着自旋的时间(次数)再也不固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。若是在同一个锁对象上,自旋等待刚刚成功得到过锁,而且持有锁的线程正在运行中,那么虚拟机就会认为此次自旋也是颇有可能再次成功,进而它将容许自旋等待持续相对更长的时间。若是对于某个锁,自旋不多成功得到过,那在之后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

在自旋锁中 另有三种常见的锁形式:TicketLock、CLHlock和MCSlock,本文中仅作名词介绍,不作深刻讲解,感兴趣的同窗能够自行查阅相关资料。

3. 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

这四种锁是指锁的状态,专门针对synchronized的。在介绍这四种锁状态以前还须要介绍一些额外的知识。

首先为何Synchronized能实现线程同步?

在回答这个问题以前咱们须要了解两个重要的概念:“Java对象头”、“Monitor”。

Java对象头

synchronized是悲观锁,在操做同步资源以前须要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?

咱们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,因此Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽可能多的数据。它会根据对象的状态复用本身的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

Klass Point:对象指向它的类元数据的指针,虚拟机经过这个指针来肯定这个对象是哪一个类的实例。

Monitor

Monitor能够理解为一个同步工具或一种同步机制,一般被描述为一个对象。每个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。

Monitor是线程私有的数据结构,每个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的惟一标识,表示该锁被这个线程占用。

如今话题回到synchronized,synchronized经过Monitor来实现线程同步,Monitor是依赖于底层的操做系统的Mutex Lock(互斥锁)来实现的线程同步。

如同咱们在自旋锁中提到的“阻塞或唤醒一个Java线程须要操做系统切换CPU状态来完成,这种状态转换须要耗费处理器时间。若是同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6以前synchronized效率低的缘由。这种依赖于操做系统Mutex Lock所实现的锁咱们称之为“重量级锁”,JDK 6中为了减小得到锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

因此目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。

经过上面的介绍,咱们对synchronized的加锁机制以及相关知识有了一个了解,那么下面咱们给出四种锁状态对应的的Mark Word内容,而后再分别讲解四种锁状态的思路以及特色:

锁状态 存储内容 存储内容
无锁 对象的hashCode、对象分代年龄、是不是偏向锁(0) 01
偏向锁 偏向线程ID、偏向时间戳、对象分代年龄、是不是偏向锁(1) 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针 10

无锁

无锁没有对资源进行锁定,全部的线程都能访问并修改同一个资源,但同时只有一个线程能修改为功。

无锁的特色就是修改操做在循环内进行,线程会不断的尝试修改共享资源。若是没有冲突就修改为功并退出,不然就会继续循环尝试。若是有多个线程修改同一个值,一定会有一个线程能修改为功,而其余修改失败的线程会不断重试直到修改为功。上面咱们介绍的CAS原理及应用便是无锁的实现。无锁没法全面代替有锁,但无锁在某些场合下的性能是很是高的。

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,下降获取锁的代价。

在大多数状况下,锁老是由同一线程屡次得到,不存在多线程竞争,因此出现了偏向锁。其目标就是在只有一个线程执行同步代码块时可以提升性能。

当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时再也不经过CAS操做来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的状况下尽可能减小没必要要的轻量级锁执行路径,由于轻量级锁的获取及释放依赖屡次CAS原子指令,而偏向锁只须要在置换ThreadID的时候依赖一次CAS原子指令便可。

偏向锁只有遇到其余线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,须要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁在JDK 6及之后的JVM里是默认启用的。能够经过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭以后程序默认会进入轻量级锁状态。

轻量级锁

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其余线程会经过自旋的形式尝试获取锁,不会阻塞,从而提升性能。

在代码进入同步块的时候,若是同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,而后拷贝对象头中的Mark Word复制到锁记录中。

拷贝成功后,虚拟机将使用CAS操做尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

若是这个更新动做成功了,那么这个线程就拥有了该对象的锁,而且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

若是轻量级锁的更新操做失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,若是是就说明当前线程已经拥有了这个对象的锁,那就能够直接进入同步块继续执行,不然说明多个线程竞争锁。

若当前只有一个等待线程,则该线程经过自旋进行等待。可是当自旋超过必定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

总体的锁状态升级流程以下:

图片5

综上,偏向锁经过对比Mark Word解决加锁问题,避免执行CAS操做。而轻量级锁是经过用CAS操做和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程之外的线程都阻塞。

4. 公平锁 VS 非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能得到锁。公平锁的优势是等待锁的线程不会饿死。缺点是总体吞吐效率相对非公平锁要低,等待队列中除第一个线程之外的全部线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但若是此时锁恰好可用,那么这个线程能够无需阻塞直接获取到锁,因此非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优势是能够减小唤起线程的开销,总体的吞吐效率高,由于线程有概率不阻塞直接得到锁,CPU没必要唤醒全部线程。缺点是处于等待队列中的线程可能会饿死,或者等好久才会得到锁。

直接用语言描述可能有点抽象,这里做者用从别处看到的一个例子来说述一下公平锁和非公平锁。

图片6
如上图所示,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才可以打水,打完水要把锁还给管理员。每一个过来打水的人都要管理员的容许并拿到锁以后才能去打水,若是前面有人正在打水,那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是否是队伍里排最前面的人,若是是的话,才会给你锁让你去打水;若是你不是排第一的人,就必须去队尾排队,这就是公平锁。

可是对于非公平锁,管理员对打水的人没有要求。即便等待队伍里有排队等待的人,但若是在上一我的刚打完水把锁还给管理员并且管理员尚未容许等待队伍里下一我的去打水时,恰好来了一个插队的人,这个插队的人是能够直接从管理员那里拿到锁去打水,不须要排队,本来排队等待的人只能继续等待。以下图所示:

图片7

接下来咱们经过ReentrantLock的源码来说解公平锁和非公平锁。

图片8
根据代码可知,ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操做实际上都是在Sync中实现的。它有公平锁FairSync和非公平锁NonfairSync两个子类。ReentrantLock默认使用非公平锁,也能够经过构造器来显示的指定使用公平锁。

下面咱们来看一下公平锁与非公平锁的加锁方法的源码:

图片9
经过上图中的源代码对比,咱们能够明显的看出公平锁与非公平锁的lock()方法惟一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。
图片10
再进入hasQueuedPredecessors(),能够看到该方法主要作一件事情:主要是判断当前线程是否位于同步队列中的第一个。若是是则返回true,不然返回false。

综上,公平锁就是经过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,因此存在后申请却先得到锁的状况。

5. 可重入锁 VS 非可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会由于以前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优势是可必定程度避免死锁。下面用示例代码来进行分析:

public class Widget {
    public synchronized void doSomething() {
        System.out.println("方法1执行...");
        doOthers();
    }

    public synchronized void doOthers() {
        System.out.println("方法2执行...");
    }
}
复制代码

在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。由于内置锁是可重入的,因此同一个线程在调用doOthers()时能够直接得到当前对象的锁,进入doOthers()进行操做。

若是是一个不可重入锁,那么当前线程在调用doOthers()以前须要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且没法释放。因此此时会出现死锁。

而为何可重入锁就能够在嵌套调用时能够自动得到锁呢?咱们经过图示和源码来分别解析一下。

仍是打水的例子,有多我的在排队打水,此时管理员容许锁和同一我的的多个水桶绑定。这我的用多个水桶打水时,第一个水桶和锁绑定并打完水以后,第二个水桶也能够直接和锁绑定并开始打水,全部的水桶都打完水以后打水人才会将锁还给管理员。这我的的全部打水流程都可以成功执行,后续等待的人也可以打到水。这就是可重入锁。

图片11
但若是是非可重入锁的话,此时管理员只容许锁和同一我的的一个水桶绑定。第一个水桶和锁绑定打完水以后并不会释放锁,致使第二个水桶不能和锁绑定也没法打水。当前线程出现死锁,整个等待队列中的全部线程都没法被唤醒。
图片12
以前咱们说过ReentrantLock和synchronized都是重入锁,那么咱们经过重入锁ReentrantLock以及非可重入锁NonReentrantLock的源码来对比分析一下为何非可重入锁在重复调用同步资源时会出现死锁。

首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。

当线程尝试获取锁时,可重入锁先尝试获取并更新status值,若是status == 0表示没有其余线程在执行同步代码,则把status置为1,当前线程开始执行。若是status != 0,则判断当前线程是不是获取到这个锁的线程,若是是的话执行status+1,且当前线程能够再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,若是status != 0的话会致使其获取锁失败,当前线程阻塞。

释放锁时,可重入锁一样先获取当前status的值,在当前线程是持有锁的线程的前提下。若是status-1 == 0,则表示当前线程全部重复获取锁的操做都已经执行完毕,而后该线程才会真正释放锁。而非可重入锁则是在肯定当前线程是持有锁的线程以后,直接将status置为0,将锁释放。

图片13

6. 独享锁 VS 共享锁

独享锁和共享锁一样是一种概念。咱们先介绍一下具体的概念,而后经过ReentrantLock和ReentrantReadWriteLock的源码来介绍独享锁和共享锁。

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。若是线程T对数据A加上排它锁后,则其余线程不能再对A加任何类型的锁。得到排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁是指该锁可被多个线程所持有。若是线程T对数据A加上共享锁后,则其余线程只能对A再加共享锁,不能加排它锁。得到共享锁的线程只能读数据,不能修改数据。

独享锁与共享锁也是经过AQS来实现的,经过实现不一样的方法,来实现独享或者共享。

下图为ReentrantReadWriteLock的部分源码:

图片14
咱们看到ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察能够发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。

在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不同。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读很是高效,而读写、写读、写写的过程互斥,由于读锁和写锁是分离的。因此ReentrantReadWriteLock的并发性相比通常的互斥锁有了很大提高。

那读锁和写锁的具体加锁方式有什么区别呢?在了解源码以前咱们须要回顾一下其余知识。 在最开始说起AQS的时候咱们也提到了state字段(int类型,32位),该字段用来描述有多少线程获持有锁。

在独享锁中这个值一般是0或者1(若是是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。可是在ReentrantReadWriteLock中有读、写两把锁,因此须要在一个整型变量state上分别描述读锁和写锁的数量(或者也能够叫状态)。因而将state变量“按位切割”切分红了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。以下图所示:

图片15

了解了概念以后咱们再来看代码,先看写锁的加锁源码:

protected final boolean tryAcquire(int acquires) {
	Thread current = Thread.currentThread();
	int c = getState(); // 取到当前锁的个数
	int w = exclusiveCount(c); // 取写锁的个数w
	if (c != 0) { // 若是已经有线程持有了锁(c!=0)
    // (Note: if c != 0 and w == 0 then shared count != 0)
		if (w == 0 || current != getExclusiveOwnerThread()) // 若是写线程数(w)为0(换言之存在读锁) 或者持有锁的线程不是当前线程就返回失败
			return false;
		if (w + exclusiveCount(acquires) > MAX_COUNT)    // 若是写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
      throw new Error("Maximum lock count exceeded");
		// Reentrant acquire
    setState(c + acquires);
    return true;
  }
  if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) // 若是当且写线程数为0,而且当前线程须要阻塞那么就返回失败;或者若是经过CAS增长写线程数失败也返回失败。
		return false;
	setExclusiveOwnerThread(current); // 若是c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者
	return true;
}
复制代码
  • 这段代码首先取到当前锁的个数c,而后再经过c来获取写锁的个数w。由于写锁是低16位,因此取低16位的最大值与当前的c作与运算( int w = exclusiveCount(c); ),高16位和0与运算后是0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。
  • 在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。若是已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,若是写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
  • 若是写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
  • 若是当且写线程数为0(那么读线程也应该为0,由于上面已经处理c!=0的状况),而且当前线程须要阻塞那么就返回失败;若是经过CAS增长写线程数失败也返回失败。
  • 若是c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!

tryAcquire()除了重入条件(当前线程为获取了写锁的线程)以外,增长了一个读锁是否存在的判断。若是存在读锁,则写锁不能被获取,缘由在于:必须确保写锁的操做对读锁可见,若是容许读锁在已被获取的状况下对写锁的获取,那么正在运行的其余读线程就没法感知到当前写线程的操做。

所以,只有等待其余读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其余读写线程的后续访问均被阻塞。写锁的释放与ReentrantLock的释放过程基本相似,每次释放均减小写状态,当写状态为0时表示写锁已被释放,而后等待的读写线程才可以继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。

接着是读锁的代码:

protected final int tryAcquireShared(int unused) {
    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 != getThreadId(current))
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    return fullTryAcquireShared(current);
}
复制代码

能够看到在tryAcquireShared(int unused)方法中,若是其余线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。若是当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增长读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减小读状态,减小的值是“1<<16”。因此读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。

此时,咱们再回头看一下互斥锁ReentrantLock中公平锁和非公平锁的加锁源码:

图片16
咱们发如今ReentrantLock虽然有公平锁和非公平锁两种,可是它们添加的都是独享锁。根据源码所示,当某一个线程调用lock方法获取锁时,若是同步资源没有被其余线程锁住,那么当前线程在使用CAS更新state成功后就会成功抢占该资源。而若是公共资源被占用且不是被当前线程占用,那么就会加锁失败。因此能够肯定ReentrantLock不管读操做仍是写操做,添加的锁都是都是独享锁。

结语

本文Java中经常使用的锁以及常见的锁的概念进行了基本介绍,并从源码以及实际应用的角度进行了对比分析。限于篇幅以及我的水平,没有在本篇文章中对全部内容进行深层次的讲解。

其实Java自己已经对锁自己进行了良好的封装,下降了研发同窗在平时工做中的使用难度。可是研发同窗也须要熟悉锁的底层原理,不一样场景下选择最适合的锁。并且源码中的思路都是很是好的思路,也是值得你们去学习和借鉴的。

参考资料

1.《Java并发编程艺术》 2.Java中的锁 3.Java CAS 原理剖析 4.Java并发——关键字synchronized解析 5.Java synchronized原理总结 6.聊聊并发(二)——Java SE1.6中的Synchronized 7.深刻理解读写锁—ReadWriteLock源码分析 8.【JUC】JDK1.8源码分析之ReentrantReadWriteLock 9.Java多线程(十)之ReentrantReadWriteLock深刻分析 10.Java--读写锁的实现原理

做者简介

家琪,美团点评后端工程师。2017 年加入美团点评,负责美团点评境内度假的业务开发。

相关文章
相关标签/搜索