同步与锁

1 如何实现锁

定义变量a=0,如果有线程过来看到a=0则认为锁没被别人拿走,这时他将a设置为1,自己拿走锁。另一个线程过来发现a=1了则认为有人用锁,自己就等待。

但是这个过程存在并发问题,获取锁的操作是有两步的:

如果在1和2之间,有另一个线程进来拿锁。则另一个线程也认为锁还在,认为自己能拿走锁,就导致两个线程都拿到锁了。

这个问题在软件层面无解,所以CPU在硬件上添加了一些原子性操作指令集。CAS(CompareAndSwap)比较并交换。这样上面1和2两步就可以合成一步cas(state,0,1),cas函数的返回值是boolean,表示是否将锁赋值为0了(即自己是否拿到了锁)。

2 Lock

Lock接口就是使用上面cas原理实现的锁,它基于AQS框架。以ReentrantLock为例,这个类中的lock和unlock方法,完全是委托给内部成员变量sync执行的,所以核心内容都是sync实现的。

先从lock函数出发,看看加锁过程是如何实现的,先看下非公平的Sync:206行就是我们1中的cas,207行如果获取到锁了就把锁拥有者设置为当前线程。否则就acquire(1)

image

acquire函数其实非常重要,他代表了锁获取失败时候,线程的动作。aquire是AQS中定义的final方法,主要是调用tryAquire/addWaiter/acquireQueued这几个方法。

image
tryAquire函数:尝试获取锁,在图1中,他直接调用nonfaireTryAcquire方法,如下,这个函数也是尝试拿锁,并且加了一个如果已经拿到锁了,就state+1的操作。
image
addWaiter是把当前线程(设置为互斥模式),追加到队列尾部
image
acquireQueued是不断判断当前线程是不是位于队列头部了,如果是则尝试获取锁,获取失败也是不断重试。这里也是体现了AQS的自旋特性,死循环对每个节点不断判断和尝试加锁。其他人的说法:一旦加入同步队列,就需要使用该方法,自旋阻塞唤醒来不断的尝试获取锁,直到被中断或获取到锁
image

AQS中需要用队列,且只对队列头部的线程进行加锁尝试(x)?

这个队列叫做CLH队列,存储Node,字段有thread,waitStatus,prev,next,nextWaiter(Condition中使用)。队列节点除了给可重入的Lock用,更多的是AQS提供了一个框架,可以实现公平锁,非公平锁,以及对park、unpark的时候,queue是有用的。

并不是只对头部加锁,公平锁是对头部加锁,而非公平锁,在锁释放的瞬间,有新的线程过来,新的线程会竞争到这把锁。

公平锁与非公平锁的区别参考

unlock就比较简单了,因为只有加了锁的线程才能释放锁,所以就判断下owner是不是currentThread,是的话,就state-1。此外上面加锁的时候我们把2次尝试拿锁都失败的,节点的prev状态设置为了-1,并且使该节点的线程park了,所以这里需要将其唤醒,使其继续自旋拿锁。 image

3 Condition

先说下CLH队列中Node的waitStatus状态,主要有5种

Condition是个接口,在AQS中的实现类是ConditionObject,这个类内部维护了另一个队列,条件队列或者叫等待队列吧。这个队列也是用了前面提到的Node类作为元素,但是为了和同步队列区分没有使用next和prev,而是使用了nextWaiter。

await的过程通俗讲就是,把当前线程添加到等待队列尾部,释放当前线程的锁,确保已经不在同步队列后,进行park。注意addConditionWaiter中没有使用CAS保证并发,因为能对等待队列操作的线程只能是当前持有锁的线程。fullyRelease则是释放全部的state,isOnSyncQueue这里应该是必然返回false,线程进入park阻塞状态。下面的就是出现中断等状况运行到的代码,这里不展开了。 image

signal的过程也很简单,就是判断是不是当前线程有这把锁,并且至少有个线程在等待被唤醒,也是只有当前持有锁的线程在操作,也不需要CAS。 image
image

4 synchronized

jdk1.6之前是重型锁,利用linux内核的mutex互斥锁,保证只有一个线程能够进入,反编译代码中能看到monitorenter,monitorexit,而1.6之后引入了偏向锁和轻量级锁。之前讲对象头中就含有这3种锁的状态信息和锁记录的指针。 image

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。当条件不满足时,锁会按偏向锁->轻量级锁->重量级锁 的顺序升级。锁也是能降级的,只不过条件很苛刻。

锁记录主要有两部分第一部分就是对象头中对应的偏向锁这一行(Displaced Mark Word)这里简称Mark,第二部分Obj是指向锁对象。对于偏向锁的锁记录,第一部分直接是空,只有第二部分。

4.1 偏向锁

对象一开始使用的时候都是偏向锁,即锁标志位01,是否偏向锁1,线程id0

加锁时判断线程id是否为当前线程?

在释放的时候,只需要在线程栈找到最近的一条指向该对象的锁记录,将Obj改成null即可,注意这里不会去改线程id(这个线程id这辈子只可能是两个值0或者第一次来的线程)。

如果锁释放后(注意是释放后),又有另一个线程想使用该锁,cas(线程id,0,当前线程id)返回false,因为释放时不修改线程id,毕竟偏向锁,偏心这个线程。返回false后,偏向锁升级为轻量锁。

4.2 轻量锁

如果锁标志位是无锁01,是否偏向0,的时候加锁就会按照轻量锁来。

轻量锁再使用对象加锁的时候,先在栈帧创建锁记录,补全两部分内容。CAS操作将锁记录的地址存到对象头的锁记录指针的字段,cas(锁记录指针,0,刚创建的锁记录指针)

解锁:遍历栈帧,找到Obj指向当前对象的锁记录,若Mark=null,说明是之前重入导致的,此时只需要Obj设置为null即可。而如果Mark!=null,说明是第一次进入时的锁记录,则cas(锁记录指针,Mark,0)。如果成功,则这个线程的该对象锁全部释放完了。否则可能是膨胀为重量级锁导致的,需要配合进行膨胀。

4.3 重量级锁

重量级锁,使用的是mutex互斥量,最大的问题就是挂起和唤醒都要进入内核态完成,而大多数场景下同步代码都是简单如对某全局对象的写操作。这种场景下,重锁用户态内核态切换的开销就比较不划算。 image

重量级锁,在jdk1.4后存在自旋次数设定,在自旋n次后才进入内核态。1.6后参数取消,改为自适应。

mutex互斥量是操作系统提供的锁参考。对于一些资源的操作,通过先申请到mutex然后才能操作这些资源,实现锁的效果。对应的系统调用是futex.
image

5 小结

RenentrantLock通过AQS、cas、自旋配合park实现了锁,而synchronized偏向锁->轻量锁->重量锁 变形过程。

存在一些误解:

现代锁的设计理念,通过以上的各种代码和趋势分析我们发现:

早期的锁,完全依赖内核态,开销太大。而用户态的CAS自旋锁,又会在某些高竞争的情况下导致cpu空转浪费资源。

将两者融合的解决方案就出来了:CAS进行锁的获取,如果获取到了就不用进内核态。如果获取不到则自旋n次,如果n次之后还获取不到则futex系统调用让出cpu资源,等待被唤醒。

6 参考文献