java并发(7)锁

从前的日色变得慢,车,马,邮件都慢,一生只够爱一个人,
从前的锁也好看 钥匙精美有样子 你锁了 人家就懂了。
木心先生写的这首小诗很有情调,一般来说,你锁住了自己的家门,其他人就进不去了,本文的标题是锁,当然,这个“锁”说的不是锁住家门的锁,而是java中的锁,为了更好的理解java中的锁,先举个简单但不怎么优雅的栗子:

我们有个共享资源,这个资源是马桶,这个马桶可以被所有人使用,但是同一时刻只能被同一个人使用(毕竟要两个人同时使用一个马桶还是有些不雅),这个时候该怎么解决这个问题呢,很简单,将这个马桶围起来并加一道门和一把锁,这就是我们平常使用的卫生间,加了这个门之后有什么好处呢?我们来模拟一个场景,100个人同时想使用这个马桶,他们蜂拥而至到达卫生间门口,这时跑的最快的一个人将卫生间门打开,并且在里面将门反锁,开始使用马桶,接着剩下的99个人也来到了门口,他们要使用马桶必须将门打开,所以他们开始尝试打开门,但是门已经从里面反锁了,他们无奈打不开,只能等在门口,直到第一个人使用完这个共享马桶,将门打开,这时所有等待的人开始抢占卫生间,一个人抢到之后从里面将门反锁,其他人又只有等待,依次类推。

以上例子对应于java中的加锁解锁过程,而马桶就是共享资源,若等待的人排队等候就是公平锁,否者就是非公平锁,锁的概念非常大,java中有多种锁的实现,而锁又是并发编程中至关重要的一个概念,所以本文作为一个导读,从大体上描述一下锁的概念以及java中的锁,至于对锁的具体分析,将在后文慢慢补充。

锁的概念

先来说说锁的概念,锁从大类上分为乐观锁悲观锁,j.u.c.a包中的类都是乐观锁,其他的ReentrantLock、ReentrantReadWriteLock、synchronized是基于悲观锁实现的,而StampedLock的读锁既有悲观锁实现也有乐观锁实现。

从小类上来分,锁又分为可重入锁自旋锁独占锁(互斥锁)共享锁公平锁非公平锁 等,而我们常说的读写锁其实是两把锁,写锁是独占锁,读锁是共享锁,接下来阐述一下每种锁的含义

乐观锁

什么是乐观锁,顾名思义,乐观锁乐观的认为共享数据在大多数情况下不会发生冲突,只有少部分时候会发生冲突,在数据提交更新的时候会对冲突进行检查,当检查到冲突的时候,会更新失败,并且返回错误,由用户决定如何对该次失败进行补偿(通常会无限次失败重试),所以整个过程不会对共享数据上锁,称为无锁(lock-free)。

无锁的实现是依赖于底层硬件的,无锁就是指利用处理器的一些特殊的原子指令来避免传统的加锁,而java的乐观锁实现就是基于CAS的,CAS全称是CompareAndSwap,译为比较替换,CAS是无锁的一种实现,也是乐观锁的一种实现,java的Unsafe类有一系列CAS操作的方法,如compareAndSwapInt(Object var1, long var2, int var4, int var5)方法,该方法是一个native方法,该方法最终会通过JNI借助C语言来调用CPU底层指令cmpxchg,并且通过判断物理机是否是多核的来决定是否在cmpxchg指令前加上lock(lock cmpxchg),cmpxchg是cpu层面的一条cms指令,该指令保证比较和替换两个操作是原子性的,关于更多的CAS原理详解参考这篇文章JAVA CAS原理深度分析

在理解了CAS的比较和替换两个操作是原子性的之后(这一点至关重要)再来看java是如何通过CAS来实现乐观锁的(这里强调一下,乐观锁是一种思想,而CAS是乐观锁的一种实现),CAS操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B),只有当预期原值,与内存位置中的值相等时,才会把该内存位置的值设置为新值,否则不做任何处理。什么意思呢,举个栗子:内存中有一个值v = 0, 线程A、B同时取得v的值为0,这时线程A要将v的值设置为1,线程A带上v的预期原值0和新值1,通过CAS,先比较预期原值0等于此时内存中v的值0,所以CAS成功,v的值变成了1;此时线程B也想将v的值设置为1,由于线程B在之前读取到的v的值为0,线程B带上v的期望原值0和新值1,通过CAS,一比较发现期望原值0不等于此刻v在内存中的值1,所以设置新值失败,CAS失败,这就是怎个CAS过程。

由于本系列不打算对乐观锁做过多的探讨,所以关于java中使用乐观锁实现的Atomic的类,选一个AtomicInteger类在这里做个简单分析:
AtomicInteger的用法如下,我们知道普通int类型的i++是一个非原子性的操作,但是a.incrementAndGet方法是一个原子性的自增操作,其依赖于CAS。

1
2
AtomicInteger a = new AtomicInteger(0);
a.incrementAndGet();

AtomicInteger 持有一个volatile修饰的int类型的value字段,该字段就是用来保存int值的,除此之外还有一个valueOffset字段,该字段记录实例变量value在对象内存中的偏移量,简单来说,通过对象和valueOffset就能找到该对象中value字段的位置进而取得value的值,这主要是为了能在c++中获取到AtomicInteger对象的value值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;

// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;
...
}

AtomicInteger.incrementAndGet()源码:

1
2
3
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

可以看到虽然方法名是incrementAndGet,但实际上返回的是unsafe.getAndAddInt + 1,其实这里取了个巧,因为Unsafe类没有addAndGetInt这样的方法。接下来看看Unsafe.getAndAddInt()方法:

1
2
3
4
5
6
7
8
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;
}

可以看到该方法有3个参数,第一个var1是object,我们传的this,第二个var2是valueOffset,正是传的上文计算出来的valueOffset,第三个参数是要增加多少,前面提到通过对象和valueOffset就可以拿到该对象的value值,所以var5就是通过getIntVloatile方法拿到了内存中的value值,并且保证拿到的是最新值,然后在while中通过compareAndSwapInt函数进行CAS操作,var1、var2可以确定内存中的value值,var5是期望的value旧值,var5+var4是要替换的value新值,通过上文讲到的CAS操作,如果失败,将会继续循环重试,直到CAS成功,此时内存中value值已经+1,跳出循环,返回旧值var5,然后上层函数返回旧值+1,从而完成了这一个自增操作。

CAS还有一个ABA问题,就是线程A取得变量a的值为0,此时线程B将a设置为1,然后又设置为0,虽然线程B对变量a进行了多次修改,但是在线程A执行CAS操作的时候发现变量a的值还是0没有改变(实际上a由0变成了1,然后又变成了0),就会CAS成功,为了解决这个问题,通常是变量前面加上版本号,版本号每次操作都会增加1,由于版本号只会增加不会减少,所以不会出现ABA问题,A-B-A就变成了1A-2B-3A。

关于乐观锁的介绍就写到这里,后面不再打算继续探讨乐观锁,主要是对几种悲观锁进行详细剖析。

悲观锁

与乐观锁相反,悲观锁悲观的认为共享数据在大部分时间下都会发生冲突,所以只能在线程访问共享数据的时候将其锁住,不让其他线程同时访问,所有线程对共享数据的访问都变成了线性访问,所以不会产生任何并发问题,在java中 synchronized、ReentrantLock、ReentrantReadWriteLock都是悲观锁的实现,关于这几个类会在后文详细分析。

公平锁/非公平锁

公平锁和非公平锁是指,线程获取锁阻塞的时候是否需要排队等候,若阻塞的线程按照请求锁的顺序获得锁,那么这把锁是一把公平锁,若阻塞的线程不按照请求锁的顺序获得锁,而是采用抢占式随机获得锁,那么这把锁就是一把非公平锁。

举个栗子,线程A获得了锁,此时线程BCD依次来请求这把锁,但是无奈锁被A持有了,所以BCD只能等待,这时候又两种策略,一种是BCD排队等候,因为B比C先来,C比D先来,所以按照BCD的顺序排好序,当A释放了锁的时候,排在最前面的B获得锁,B释放之后,C获得,依次类推,这种方式就是公平锁;另一种策略是BCD不排队,等A释放锁的时候,BCD去抢这把锁,谁先抢到谁就持有,这种锁就是非公平锁。

优缺点:
公平锁由于维护了线程请求顺序,保证了时间上的绝对顺序,但是在吞吐量上是远不如非公平锁的,非公平锁容易造成饥饿现象,因为非公平锁是抢占式的,所以某个线程可能长时间无法抢占到锁,而处于长时间阻塞状态

synchronized是一般非公平锁,ReentrantLock可通过构造函数ReentrantLock(boolean fair)来指定是否是公平锁,默认是非公平锁,ReentrantReadWriteLock同理

可重入锁

可重入锁是指,当线程A获得锁以后,如果线程A在此请求该锁,它能重新获得该锁,synchronized和ReentrantLock都是可重入锁,仔细想想如果synchronized和ReentrantLock是不可重入锁,那么当两个方法A、B都被synchronized修饰,A方法调用B方法的时候就会发生死锁,因为执行A方法的时候线程已经获取了this对象的锁,然后调用B方法的时候又会去请求一次this对象锁,如果该锁不可重入,则会等待这把锁释放,但是释放这把锁需要A方法执行完成,所以就形成了死锁

表现在代码上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class SynchronizedTest {

public static void main(String[] args){
new SynchronizedTest().methodA();
}

public synchronized void methodA() {
System.out.println("A is execute ... ");
B();
}

public synchronized void methodB() {
System.out.println("B is execute ... ");
}
}

//从结果上看,A()B()方法都得到了执行,没有发生死锁
== 输出 ==
A is execute ...
B is execute ...

自旋锁

自旋锁是指在获取锁失败之后不进入阻塞,而是通过在循环体里面进行不断的重试,前面乐观锁中提到,Unsafe类的CAS操作,在CAS失败的时候会在循环体里面不断重试,直到CAS成功才退出循环,这其实也是一种自旋的表现。

自旋锁由于不会让线程真正的挂起,而是不停的执行循环,所以少了线程状态的切换,清空cpu缓存及重新加载cpu缓存的操作,所以响应速度更快,但是由于自旋锁会占用cpu时间片,所以当线程数量多了之后性能会明显下降

在jdk8中对synchronized关键字进行了一系列优化,引入了轻量级锁、重量级锁、偏向锁、自适应自旋锁等手段,其中自适应自旋锁就是在获取锁失败之后开始自旋,自旋次数不固定,而是根据以前线程自旋期间成功获取到错的次数,也就是说,如果上一个线程通过自旋获取到了锁,那么认为这一个线程通过自旋获取锁的成功率会很高,所以当前线程的自旋次数会增加,相反,如果上一个线程达到最大自旋次数任然没有获取到锁,那么认为自旋获取锁的失败率会很高,所以当前线程的自旋次数会减少,甚至可能出现若是多个线程连续自旋获取锁失败,那么当前线程不再自旋,而是直接挂起。加入自适应自旋锁很好的解决了:如果自旋次数固定,由于任务的差异,导致每次的最佳自旋次数有差异,不好拿捏自旋次数的问题,而是通过“智能学习”的方式动态改变自旋次数

关于synchronized的优化手段还有很多,由于篇幅限制,将在后文单独写一篇来阐述。

独占锁/共享锁

独占锁和共享锁也是一个比较大的概念,如果一把锁同一时刻只能被一个线程持有,则这把锁是独占锁,如果一把锁同一时刻能被多个线程同时持有,则这把锁叫做共享锁。

java中的synchronized和ReentrantLock都是独占锁,而ReentrantReadWriteLock的写锁是一把独占锁,读锁是一把共享锁,关于读写锁ReentrantReadWriteLock和可重入锁ReentrantLock将在后文详细介绍。

后记

本文简单的阐述了几种锁的概念,以及java类中用到的几种锁,简单总结一下:

  1. atomic: 乐观锁、共享锁、无锁、非阻塞同步
  2. synchronized:悲观锁、独占锁、可重入锁、非公平锁、自旋锁
  3. ReentrantLock: 悲观锁、可重入锁、(公平锁|非公平锁)、独占锁
  4. ReentrantReadWriteLock.ReadLock: 悲观锁、可重入锁、(公平锁|非公平锁)、共享锁
  5. ReentrantReadWriteLock.WriteLock: 悲观锁、可重入锁、(公平锁|非公平锁)、独占锁

关于几种锁的基本概念暂时先了解到这里,现在有个显而易见的问题,我们说了这么久的锁,这把锁到底存放在什么地方,这把锁到低锁住的是什么,我们经常使用的同步代码块语法:synchronized(this){} 又是什么意思?这一系列问题以及上文提到的synchronized锁优化内容都将会在下一篇文章中详细介绍。


可以卑微如尘土,不可扭曲如蛆虫