在并发编程中有各种各样的锁,有的锁对象一个就身兼多种锁身份,所以初学者常常对这些锁造成混淆,所以这里来总结一下这些锁的特点和实现。
乐观锁、悲观锁
悲观锁
悲观锁是最常见的锁,我们常说的加锁指的也就是悲观锁。顾名思义,每次修改都抱着一种 "悲观" 的态度,每次修改前都会认为有人会和他一样执行同一段代码,所以每次修改时都会加锁,而这个锁就是悲观锁,加上悲观锁,其他线程不能执行这段代码,除非当前线程主动释放锁资源或者执行完成正常释放锁资源。常见的悲观锁有 synchronized、ReentrantLock(常见对象时不加参数或者是false)。
适用场景:悲观锁适用于单个线程执行时间长或者并发量高的场景,执行时间长意味着上下文切换消耗的时间相当于线程执行的时间就不算长,而并发量高则说明上下文切换的时间相当于多线程在队列中等待消耗的时间也微不足道。
乐观锁
乐观锁,顾名思义,就是以一种乐观的心态看待多线程对共享数据修改问题。认为当前线程在修改更新到主内存中过程中(jmm,如果不熟悉可以移步多线程基础总结)没有其他线程进行修改。所以在修改时并不会加锁来限制其他线程,这样的好处就是在其他线程没有修改时效率更高,因为添加悲观锁就涉及到上下文切换,这样在高并发场景就降低了程序的执行效率。那么乐观锁又是如何实现的呢? 就是通过三个值来实现的,分别是内存地址存储的实际值、预期值和更新值,当实际值与预期值相等时,就判定该值在修改过程中没有发生改变,此时将值修改为更新值,而如果预期值和实际值不相等,则表示在更新期间有线程进行过修改,那么就修改失败。CAS 就是乐观锁的一种实现,因为用得比较多所以一般就用 CAS 来代指乐观锁,CAS 是 compareAndSet 方法名的缩写,它是位于 JUC下 atomic 包下的类中的实现方法。在不同的类中实现方法不同,首先是普通的包装类(AtomicInteger、AtomicBoolean等)和引用类(AtomicReference),他们的实现比较相似,都是两个参数。下面就以 AtomicInteger 源码为例,关于 atomic 相关介绍可以查看 atomic .
public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); }
这里是调用 unsafe 对象的 compareAndSwapInt 方法,这里要知道 unSafe 类,unsafe 是 CAS 实现的核心类,在其内部定义了很多 CAS 的实现方法。
可以看到内部有许多 native 方法,native 方法是本地方法,其实现是使用 C、C++语言实现的,由于 C、C++语言与系统的兼容性更好,所以一些需要偏操作系统层面的操作还是使用 C与C++实现。而 unsafe 就是 CAS 实现的关键类。回到源码,这里的 compareAndSet 方法是两个参数,预期值和更新值,但是内部实现逻辑还是乐观锁的原理。
ABA 问题:上面这种实现是可能存在问题的,因为在比较时只会比较预期值和实际值,而可能这个值开始是A,这时开始进行 CAS 修改,所以预期值就是A,先修改进工作内存,但是在这之间 实际值由 A 变成了 B,在 CAS 线程执行 CAS 前又由 B 变回了 A,此时执行 CAS 就会以为这个 A 是没有变化的,所以正常更新为更新值,但其实它是改变过的。这就是乐观锁的 ABA 问题。这种问题在只需要比较开始和结束的业务中不需要管理,但是在需要数据一直保持不变的业务场景中就会变得很致命。
为了解决 ABA 问题,又提出版本号的概念,原理就是在修改前后比较的值从要修改的值变成版本号,每次数据变化时都会改变版本号,最终如果预期版本号和实际版本号相等就正常修改,如果不同就放弃修改。在 atomic包下这种思想的实现类就是 AtomicStampedReference,其 compareAndSet 源码如下
public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair<V> current = pair; return expectedReference == current.reference && expectedStamp == current.stamp && ((newReference == current.reference && newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); }
四个参数分别为期望值,更新值,期望版本号,更新版本号。这里的实现是比较期望值和版本号两个值。只有全部符合才会修改成功。
适用场景:乐观锁适用于执行时间短且并发量小的场景,对于一段代码只有几个甚至只有一个线程同时执行,那么乐观锁就可以起到大作用,因为它没有加锁解锁的操作,线程不需要进行阻塞和唤醒,没有上下文切换的时间损耗,同时如果并发量高的话乐观锁的效率也会远低于悲观锁,因为乐观锁往往是与自旋锁搭配使用的,而自旋锁意味着会一直在执行,一致占用 CPU,所以乐观锁只适用于执行时间短且并发量小的场景。
自旋锁
自旋锁是不断尝试的锁,一般与乐观锁搭配使用,因为悲观锁需要加锁解锁操作,这样导致线程阻塞,经过一次上下文切换后才能继续执行,这样在并发量小且执行时间短的场景中所消耗的时间就相对来说较长,所以在这种场景可以使用乐观锁+自旋锁来实现。典型的这种实现就是 Atomic 包下的一些包装类的方法实现。下面就以 AtomicInteger 的 getAndIncrement 方法源码来解读
public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); }
发现底层使用的还是 unsafe 类调用的方法。再点进这个方法
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; }
可以看到这里是使用了 while 循环,在不断尝试调用 native 的 compareAndSwapInt 方法。这个 compareAndSwapInt 就是一个乐观锁方法,当执行成功后就会跳出循环,否则会一直尝试执行。
适用场景:因为一般是和乐观锁搭配使用的,所以和乐观锁适用场景一致。也就是线程执行时间短且并发量低的场景。
共享锁、独占锁
独占锁
独占锁又称 “排它锁”、"写锁"、“互斥锁”,意为当前线程获取到锁之后其他线程就不能再获取到锁,我们常用的锁如 synchronized、ReentrantLock 锁都是独占锁。
共享锁
共享锁又称 “读锁”,是指当前线程获取到锁之后其他线程也能获取到当前锁并执行锁中的代码。可能有人会觉得如果这样的话,那么共享锁和无锁有什么区别?事实上共享锁一般是与独占锁搭配使用的,共享锁与独占锁是互斥的,常用的共享锁与独占锁实现类是 ReentrantReadWriteLock,关于这个锁的使用可以查看 ReentrantReadWriteLock使用 。
适用场景:一般与独占锁搭配使用,用于某段代码可以多个线程同时执行但是与另外一段代码互斥,比如A代码同一时间可以有多个线程执行,但是B代码与A 代码同一时间只能有一个线程执行。
公平锁、非公平锁
非公平锁
非公平锁是指一段代码块被多个线程尝试执行,那么会有一个线程获取到锁资源,其他线程就会进入等待队列等待,而新来的线程并不会直接进入阻塞队列尾部,而是先尝试获取锁资源,如果这时候之前占用锁资源的线程刚好释放锁那么这个新来的线程就会直接获取到锁,而如果占用资源的线程没有释放锁,那么新来的线程就获取失败,乖乖进入等待队列尾部等待。synchronized 是非公平锁,而 Lock 实现类的锁会有两种形式,公平锁和非公平锁,在创建对象时没有指定参数默认就是非公平锁。
适用场景:非公平锁的优势是效率高,如果位于头部的线程出现问题阻塞住了,也没有获取锁资源,那么后面的线程就需要一直等待,直到其执行完成,这样就非常耗时,而非公平锁则可以让新来的线程直接获取到锁跳过其阻塞时间。缺点是这样就会导致位于等待队列尾部的线程获取到锁资源的机会很小,可能一直都没有办法获取到锁,造成 “饥饿”。适应场景是要求执行效率高,响应时间短,同时每个线程的执行时间较短的场景。
公平锁
公平锁是非公平锁的对立面,也就是新来的线程直接进入阻塞队列的尾部,而不会先尝试获取锁资源。synchronized 只能是非公平锁,而 Lock 系列的锁在创建锁对象时指定参数为 false 时就是公平锁。
适用场景:公平锁的优势是每个线程能按顺序有序执行,不会发生 “饥饿” 的情况,缺点是整个系统的执行效率会低一些。适用于线程执行时间较长,线程数较少,同时对线程执行顺序有要求的场景。
可重入锁
可重入锁指的是当前已经获取资源的线程在执行内部代码时又遇到一个相同的锁,比如下面这种场景。
public synchronized void test(){ System.out.println("11"); synchronized (this){ System.out.println("2"); } }
这里方法上的 synchronized 对应的对象和方法内的 synchronized 对应的对象是同一个对象,当某个对象进入方法后又遇到一个相同对象的锁,那么如果这个锁是可重入锁,当前线程就会将当前锁层次加1,相当于 AQS 机制中的 state,如果对 AQS 不熟悉可以看一下 AQS全解析 。每加一把锁就会加1,释放锁就会减1。平时常用的锁都是可重入锁。
分段锁
分段锁是 ConcurrentHashMap 在1.7中的概念,因为在1.7中 ConcurrentHashMap 中使用的是分段锁,也就是对数组的每一个元素进行加锁,这样就可以同时有16个线程(默认数组容量)一起操作。具体实现是在1.7中有一个内部类 Segment,这个类内部存储的数据属性就是 ConcurrentHashMap 数组某个下标对应的链表所存储的所有数据,在需要同步的地方直接通过下标数获取对应的 Segment 对象然后进行加锁,这样将整个数组和其链表存储的数据分段来加锁就是分段锁。关于 ConcurrentHashMap 和 HashMap 解析可以查看 HashMap 、ConcurrentHashMap知识点全解析 。