Java程序设计,触及到多线程并发方面,基本上会涉及到线程和锁的使用,本篇博文就常用的synchronized和ReentrantLock为主,谈谈Java中的锁机制,加强自己的理解,总结面试中的考点,帮助有需要的人。如果你对线程状态转换、内核态及用户态有了基础的认识,可以跳过预备知识,但还是建议花费几分钟快速的浏览一遍
本文图片大部分来自网络,如有侵权,必删。
进程和线程概念
进程是系统资源分配的基本单位
线程是独立调度的基本单位
如果把操作系统比喻成为一个公司,那么进程相当于是公司的一个职能部门,线程相当于是职能部门的一个员工。可以理解,公司资源(系统资源)是需要以部门(进程)的名义去申请,而申请好资源以后,需要指定员工(线程)去使用(调度)所需资源完成自己的工作任务,下文对一些基本概念的讲解也是通过这种比喻进行的。
进程的状态转换图如下:
进程的状态可以类比成一个职能部门的状态:
进程的创建(created)和终止(terminated)相当于一个职能部门的设立和撤销
进程的就绪(ready)相当于部门可以工作,但没有进行工作
进程的运行(running)相当于部门正在处理手头上的工作资料
进程的等待(waiting)相当于部门正在工作被喊停,资料不足,需要获取资料,才准备(ready)好进入工作(running)
线程的状态转换图如下:
线程的状态可以类比成一个部门员工的状态:
线程的创建(new)和终止(terminated)相当于一个员工的入职和离职
线程的运行(Runnable)相当于部门员工资料的在手,正在工作
进程的阻塞(Blocked)相当于部门员工小A和小B都需要使用公用账号登录服务器(安全可靠,一个时刻只允许一个ip登录),如果小A先登录(进入同步代码块),那么小B就进入阻塞状态,等待小A下线(退出同步代码块)后才能尝试登录
进程的等待(waiting)相当于部门员工小A和小B需要联合调试,小B需要等待(waiting)小A的通知后,才开展相关的工作,而限时等待(TimeWaiting)是小B的任务要进行,如果小A因为突发状况被离职(terminated)了但未能通知小B,那么小B自己会在一段指定时间后执行指定的任务。
在一个部门之前,员工的相互配合是进程中线程通信
部门之间的员工相互配合是进程间线程通信
进程之间的切换和线程之间的切换都需要保存对应的现场信息,一般的,同一个进程中的线程间切换系统开销最小。
用户运行自己写的程序时,一般在用户态运行,但是涉及到IO、时钟、中断等操作,会从用户态切换到内核态,其实可以理解为用户进程和内核进程的相互切换。
为什么用户态内核态切换系统开销大?
当程序中有系统调用语句,程序执行到系统调用时,首先使用类似int 80H的软中断指令,保存现场,使CPU从用户态切换到内核态,然后操作系统根据具体的参数值调用特定的服务程序,而这些服务程序则根据需要再调用底层的一些支持函数以完成特定的功能。在完成了应用程序要求的服务之后,操作系统又将CPU从核心态切换回用户态,从而返回到应用程序中继续执行后面的指令。
每个进程都会有两个栈,一个内核态栈和一个用户态栈。当执行int中断执行时就会由用户态栈转向内核栈。系统调用时需要进行栈的切换。而且内核代码对用户不信任,需要进行额外的检查。系统调用的返回过程有很多额外工作,比如检查是否需要调度等,所以用户态和内核态之间的切换开销较大。
CAS时CompareAndSwap(比较和交换)
CAS操作需要三个操作数,分别是内存位置V,旧的预期值A和新值B。CAS指令执行时,当且仅当V符合久预期值A时,处理器用新值B更新V值,否则他就不执行更新,但是无论是否更新V值,都会返回V的旧值,但是需要通过使用版本号来解决ABA问题。CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”
CAS操作需要硬件支持,在intel的CPU中,使用cmpxchg指令。了解即可。
如果使用CAS去尝试获取锁的话,可以在一定程度内避免线程进入Blocked和Waiting状态,减少线程状态切换的这一部分开销,但是在使用CAS时,一般是通过自旋(执行一段耗时代码)尝试某个操作,这个操作无疑会占用cpu时间。有利有弊,后面讲到的synchronized和ReentrantLock的一些有关CAS的操作其实就是经过实践,权衡后进行的设计,计算机科学也是一门实践性科学,扯远一点,我个人认为,很多经典算法其实就是对生活的抽象,是一种处理问题的方法论,如果读者有这个意识的话,可能很多问题就会突然想通。
HotSpot虚拟机中:
JVM中,每个线程都有自己的JVM Stack,每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
一个Object实例,即对象,对象在内存中存储分为三块区域:对象头、实例数据和对齐填充。对象一般分配在Heap上。
Object Header包括: Mark Word 和 Klass Pointer
“Mark Word”: 用于存储对象自身的运行时数据, 如哈希码(HashCode)25bit、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等.
“Klass Pointer”: 对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。(数组,对象头中还须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。 )
Mark Word结构体:
运行期间, Mark Word存储的数据会随着锁的标志变化而变化,32位系统中,Mark Word会有以下5种状态。
synchronized由JVM实现,ReentrantLock由JDK实现,在JDK5.0之前ReentrantLock的性能远远好于synchronized的性能,synchronized只支持非公平锁,ReentrantLock默认支持非公平锁,也支持公平锁,而且能够响应中断,且有超时等待的机制,可以解决死锁问题,相比于synchronized,ReentrantLock是显示的加锁过程,自主性更高。synchronized会使用会有内核态和用户态的切换,开销较大。
特性 | synchronized | ReentrantLock |
---|---|---|
实现主体 | JVM | JDK |
对锁的支持 | 只支持非公平锁 | 默认支持非公平锁,可支持公平锁 |
支持中断 | 否 | 是 |
可重入 | 是 | 是 |
灵活性 | 隐示 | 显示(使用Condition) |
应用场景:
synchronized:
在资源竞争不是很激烈的情况下,偶尔会有同步的情形下,synchronized是很合适的。原因在于,编译程序通常会尽可能的进行优化synchronized,另外可读性非常好,不管用没用过5.0多线程包的程序员都能理解。
ReentrantLock:
ReentrantLock提供了多样化的同步,比如有时间限制的同步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等。在资源竞争不激烈的情形下,性能稍微比synchronized差点点。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍。而ReentrantLock确还能维持常态。
Atomic:
和上面的类似,不激烈情况下,性能比synchronized略逊,而激烈的时候,也能维持常态。激烈的时候,Atomic的性能会优于ReentrantLock一倍左右。但是其有一个缺点,就是只能同步一个值,一段代码中只能出现一个Atomic的变量,多于一个同步无效。因为他不能在多个Atomic之间同步。
所以,我们写同步的时候,优先考虑synchronized,如果有特殊需要,再进一步优化。ReentrantLock和Atomic如果用的不好,不仅不能提高性能,还可能带来灾难。
补充:两者唤醒线程方式+线程切换
只要线程可以在30到50次自旋里拿到锁,那么Synchronized就不会升级为重量级锁,而等待的线程也就不用被挂起,我们也就少了挂起和唤醒这个上下文切换的过程开销.
但如果是ReentrantLock呢?不会自旋,而是直接被挂起,这样一来,我们就很容易会多出线程上下文开销的代价.当然,你也可以使用tryLock(),但是这样又出现了一个问题,你怎么知道tryLock的时间呢?在时间范围里还好,假如超过了呢?
所以,在锁被细化到如此程度上,使用Synchronized是最好的选择了.这里再补充一句,Synchronized和ReentrantLock他们的开销差距是在释放锁时唤醒线程的数量,Synchronized是唤醒锁池里所有的线程+刚好来访问的线程,而ReentrantLock则是当前线程后进来的第一个线程+刚好来访问的线程.
如果是线程并发量不大的情况下,那么Synchronized因为自旋锁,偏向锁,轻量级锁的原因,不用将等待线程挂起,偏向锁甚至不用自旋,所以在这种情况下要比ReentrantLock高效。
synchronized的优化:(锁状态变化:偏向锁->轻量级锁->重量级锁)
JDK5之后,synchronized的性能大大提升,基本上与ReentrantLock性能持平,主要是由于硬件支持了CAS操作,CAS操作需要三个操作数。
通过执行一个忙循环代替挂起线程和恢复线程,自选次数默认值是10,自适应自旋即由前一次自旋时间及锁的拥有者的状态决定。自旋是占用cpu的。
public String concat(String s1, String s2, String s3){
return s1 + s2 + s3;
}
// 实际上因为String是不可变的类,
// 对字符串的连接操作会转变为StringBuffer对象的append();
// 而StringBuffer是同步的,锁消除会消除这些锁
public String concat(String s1, String s2, String s3){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
就像上面的代码,假设9、10、11行每次都加锁解锁,不如整个过程加锁。
轻量级是相对于是用户操作系统互斥量来实现的传统锁而言的,因为传统的锁机制是重量级锁,轻量级锁是为了在没有多线程竞争的前提下,减少重量级锁使用操作系统互斥量产生的性能消耗。
下面的解释,需要读者知道一个前提,一般CAS操作在一个无限循环里,如果CAS操作返回的是目标值,会有一个跳出循环的语句,但是两个线程的循环CAS操作可能会出现的是,线程A刚准备进行CAS操作,时间片用完,线程B做了循环里的CAS操作,现代硬件是保证CAS操作可以原子性,但是并不是意味着CAS操作所在的循环是原子性的。
在代码进入同步块的时候,如果此时同步对象没有被锁定,虚拟机首先将当前线程的栈帧中建立一个名为 锁记录 (Lock Record)的空间,用于存储当前锁对象的Mark Word的 拷贝 Displaced Mark Word,有了栈帧和Object两个东西后,虚拟机会使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。
if(更新成功),线程就持有这个锁。就是Object的Object Header的Mark Word的引用指向了线程栈帧,就是锁对象的头的标记是某个线程的栈帧记录的信息。即,我作为一个线程拿到了这把锁。
else (即更新失败)虚拟机检查当前线程的栈帧有没有锁对象的头的Mark Word的指向,如果作为线程的我有这把锁,就可以直接进入同步代码块执行,如果我没有锁对象,说明其他线程拿到了。如果有两条以上的线程竞争(前面说了轻量级的锁的前提)一把锁,轻量级锁失效,膨胀为重量级锁。
以上是加锁,解锁过程如下:
若Object的Mark Word还是指向线程的锁记录,就CAS操作把当前的Mark Word和线程中复制的Displaced Mark Word替换回来。
if(CAS成功) 结束
else 有其他线程尝试获取该锁,释放锁的同时,要唤醒被挂起的线程。
如果存在线程竞争,其实轻量级锁还多了CAS开销,还比重量级锁慢。
5.偏向锁:减少轻量级锁的CAS操作
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。 注意:当锁有竞争关系的时候,需要解除偏向锁,进入轻量级锁。偏向锁的引入恰恰就体现了计算机是一门实践性科学。
声明:这个部分主要是笔者通过阅读实战JAVA高并发程序设计以及Javadoop的一系列博文等学习到的,在这一块我的理解可能会有些偏差,但是经过面试检验,我也对接下来的内容有一点信心,读者可以在读完这段内容后应付基本的面试,如果对AQS很感兴趣,可以参考前文提到的一些书籍和博文。
ReentrantLock等等一系列Lock是基于AQS实现的,AQS(AbstractSynchronizerQueue),有四个基本属性
volatile Node head
volatile Node tail
volatile int state
transient Thread exclusiveOwnerThread
AQS内部维护一个阻塞队列——CLH队列,阻塞队列是一个双向队列,且阻塞队列只包含tail,因为head是在用的,所以不阻塞,队列上的节点都是一个Node类型。
static final class Node{
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
}
Node.Thread指向了一个线程,线程就与Node形成了一一对应的映射关系。
线程通过CAS操作尝试获取锁,设置state为1成功,就抢到锁,表示AQS的head获得锁,从就绪状态转换为可执行状态(CLH中的第一个node),如果是非公平锁的话,各种不在CLH线程就想插队抢锁,但是CLH队列的的Node对应的线程需要Node排到CLH队头。这里补充一个:
synchronized和ReentrantLock为什么默认都是非公平锁
ReentrantLock公平和非公平锁的队列都基于锁内部维护的一个双向链表,表结点Node的值就是每一个请求当前锁的线程。
公平锁则在于每次都是依次从队首取值,严格按照线程启动的顺序来执行的,不允许插队。
非公平锁在等待锁的过程中, 如果有任意新的线程妄图获取锁,都是有很大的几率直接获取到锁的,允许插队。
默认情况下ReentrantLock是通过非公平锁来进行同步的,包括synchronized关键字都是如此,因为这样性能会更好。因为从线程进入了RUNNABLE状态,可以执行开始,到实际线程执行是要比较久的时间的。而且,在一个锁释放之后,其他的线程会需要重新来获取锁。其中经历了持有锁的线程释放锁,其他线程从挂起恢复到RUNNABLE状态,其他线程请求锁,获得锁,线程执行,这一系列步骤。如果这个时候,存在一个线程直接请求锁,可能就避开挂起到恢复RUNNABLE状态的这段消耗,所以性能更优化。
Condidtion经常用在生产者消费者场景,且前提是针对同一把锁,Condition的实现类ConditionObject如下:
public class ConditionObject implements Condition, java.io.Serializable{
private transient Node firstWaiter;
private transient Node lastWaiter;
...
}
维护一个条件队列:
每个Condtion都有一个关联的条件队列,firstWaiter和lastWaiter其实就是规定好了条件队列
condition.await()方法将当前线程包装到Node里面加入条件队列队尾,阻塞在这里,不执行,条件队列是一个单向链表。
condition.signal()方法触发一次唤醒,唤醒条件队列的队头,将firstWaiter移动到CLH队尾,等待获取锁,获取锁后await()方法才能返回,继续执行。
ReentrantLock中进行同步操作都是从lock方法开始。lock获取锁,进行一系列的业务操作,结束后使用unlock释放锁。
private final ReentrantLock lock = new ReentrantLock();
public void sync(){
lock.lock();
try {
// ... method body
}
finally {
lock.unlock()
}
}
lock
ReentrantLock中lock的调用AQS的acquire方法:
public final void acquire(int arg) {
//尝试获取锁
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
基于AQS的锁设计都是使用了模板模式。
对于锁的获取tryAcquire是在ReentrantLock中实现的。而非公平锁中的实际实现方法为nonfairTryAcquire。
// ReentrantLock#nonfairTryAcquire
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
在获取锁的逻辑中首先是尝试以CAS方式获取锁,如果获取失败则表示锁已经被线程持有。
再判断持有该锁的线程是否为当前线程,如果是当前线程就将state的值加1,在释放锁是也需要释放多次。这就是可重入锁的实现。
如果持有锁的线程并非当前线程则这次加锁失败,返回false。加锁失败后将调用AbstractQueuedSynchronizer#acquireQueued(addWaiter(Node.EXCLUSIVE), arg)。
首先会调用addWaiter方法将该线程入队。
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
mode是指以何种模式的节点入队,这里传入的是Node.EXCLUSIVE(独占锁)。首先将当前线程包装为node节点。然后判断等待队列的尾节点是否为空,如果不为空则通过cas的方式将当前节点接在队尾。如果tail为空则执行enq方法。
// AbstractQueuedSynchronizer#enq
// enq方法通过for(;;)无限循环的方式将node节点设置到等待队列的队尾(队列为空时head和tail都指向当前节点)。
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) {
// Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
综上可知addWaiter方法的作用是将竞争锁失败的节点放到等待队列的队尾。
等待队列中的节点也并不是什么都不做,这些节点也会不断的尝试获取锁,逻辑在acquireQueued中实现。
AbstractQueuedSynchronizer#acquireQueued
final Boolean acquireQueued(final Node node, int arg) {
Boolean failed = true;
try {
Boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
// help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
}
finally {
if (failed)
cancelAcquire(node);
}
}
可以看到该方法也是使用for(;;)无限循环的方式来尝试获取锁。首先判断当前节点是否为头结点的下一个节点,如果是则再次调用tryAcquire尝试获取锁。当然这个过程并不是一定不停进行的,这样的话多线程竞争下cpu切换也极耗费资源。
shouldParkAfterFailedAcquire会判断是否对当前节点进行阻塞,阻塞之后只有当unpark后节点才会继续假如争夺锁的行列。
private static Boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
}
while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
判断一个节点是否需要被阻塞是通过该节点的前继节点的状态判断的。
如果前继节点状态为singal,则表示前继节点还在等待,当前节点需要继续被阻塞。返回true。
如果前继节点大于0,则表示前继节点为取消状态。取消状态的节点不参与锁的竞争,直接跳过。返回false。
如果前继节点时其他状态(0,PROPAGATE),不进行阻塞,表示当前节点需要重试尝试获取锁。返回false。
shouldParkAfterFailedAcquire方法如果返回true,表示需要将当前节点阻塞,阻塞方法为parkAndCheckInterrupt。
// AbstractQueuedSynchronizer#parkAndCheckInterrupt
private final Boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
阻塞是通过LockSupport进行阻塞,被阻塞的节点不参与锁的竞争(不在进行循环获取锁),只能被unpark后才继续竞争锁。
而被阻塞的节点要被释放则依赖于unlock方法。
unlock
ReentrantLock中unlock的实现是通过调用AQS的AbstractQueuedSynchronizer#release方法实现。
public final Boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
release调用tryRelease方法,tryRelease是在ReentrantLock中实现。
// ReentrantLock#tryRelease
protected final Boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
Boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
tryRelease方法逻辑很简单,首先减去releases(一般为1)表示释放一个锁,如果释放后state=0表示释放锁成功,后续等待的节点可以获取该锁了。如果state!=0则表示该锁为重入锁,需要多次释放。
当释放锁成功后(state=0),会对头结点的后继节点进行unpark。
// AbstractQueuedSynchronizer#unparkSuccessor
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);
}
~~~
unparkSuccessor见名知意适用于接触后面节点的阻塞状态。整个方法的逻辑就是找到传入节点的后继节点,将其唤醒(排除掉状态为cancel即waitStatus > 0的节点)。
公平锁和非公平锁
ReentrantLock的构造方法接受一个可选的公平参数。当设置为 true 时,在多个线程的竞争时,倾向于将锁分配给等待时间最长的线程。
~~~ java
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
在多个锁竞争统一资源的环境下,AQS维护了一个等待队列,未能获取到锁的线程都会被挂到该队列中。如果使用公平锁则会从队列的头结点开始获取该资源。
而根据代码在公平锁和非公平锁的实现的差别仅仅在于公平锁多了一个检测的方法。
// 公平锁
protected final boolean tryAcquire(int acquires) {
//...
if (c == 0) {
if (!hasQueuedPredecessors() //!hasQueuedPredecessors()便是比非公平锁多出来的操作
&& compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//...
return false;
}
hasQueuedPredecessors()
public final boolean hasQueuedPredecessors() {
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
}
方法逻辑很简单,就是如果等待队列还有节点并且排在首位的不是当前线程所处的节点返回true表示还有等待更长时间的节点。需要等这部分节点获取资源后才能获取。