[并发]synchronized&ReentrantLock底层原理

62 阅读12分钟

问题

  1. synchronized原理&锁升级过程
  2. reentrantlock实现原理&aqs

目前对这些问题的大致印象:

synchronized是jvm层面实现的,通过在编译期给代码前后加上os的monitor机制实现并发控制。synchronized的效率较api层面实现的lock来说相对低下,所以后来引入了锁升级。api层面实现的lock大多基于aqs这个东西。

参考文章:

tech.youzan.com/javasuo-yu-…

blog.csdn.net/qicha3705/a…

blog.csdn.net/wekajava/ar…

synchronized

前言

观察sync反编译后的字节码可知,sync会在代码块上加一个monitor_enter和两个monitor_exit,第二个exit是为异常服务。monitorenter时,会将当前对象关联到一个monitor对象,设置monitor的owner为当前线程。

monitorenter和monitorexit这两个都属于jvm指令,这两个指令主要基于java对象的mark word和object monitor来做的。

markword是对象头的一部分,64bit长度,引用youzan技术的图

image.png

jdk1.6以前,sync就是直接去关联monitor对象。但是效率很低,后面引入了锁升级,涉及到偏向锁和轻量级锁两个优化锁,这两个锁就是基于mark word实现的。

偏向锁

偏向锁适用的场景是,加了sync的代码块但没有发生线程竞争的场景。jvm通过一次cas来尝试获取线程所有权,如果成功,则是偏向锁状态。多线程发生竞争时,需要主动撤去偏向锁。


偏向锁的锁标识一定是101,但会根据mark word中thread id和epoch细分成多种小状态

  1. 匿名偏向,threadid为0
  2. 可重偏向,当前对象mark word中epoch与kclass的_proptotype_header的epoch不一样,可被cas thread id
  3. 已偏向,thread id 非空,epoch有效

jvm启动默认4s后会将所有对象的类的prototypeheader设置为匿名偏向样式。前四秒所有的对象会直接进入轻量级锁的状态。

偏向锁加锁过程

  1. 判断对象mark word的标识和对象所属klass的原型头的锁标识位是否为可偏向,若不是则走轻量级锁逻辑
  2. 比较对象mark word的epoch和klass原型头的epoch是否匹配,验证偏向状态有没有过期
  3. 比较thread id,0直接cas,非0先判断是否是重入锁,再结合epoch状态进行重偏向

偏向锁被一个线程cas其锁对象mark word的thread id成功后,会向线程栈里添加一条displaced mark word为空的lock record,锁释放将栈中最近一条lock record的obj字段设置为null。注意,解锁并不会修改mark word的thread id。

当其他线程尝试获取锁时,遍历偏向线程的lock record来判断是否还在执行同步代码块。

如果当前锁已经偏向其他线程/epoch值过期/class偏向模式关闭/获取偏向锁时发生并发冲突,会进行偏向锁的撤销。

jvm需要通过safe point暂停原持有偏向锁的线程,safe point不一定是同步代码块之外,所以也延伸出两种情况。

1.原线程仍在sync块内,升级为轻量级锁,在栈帧中创建lock record(也有提前生成的),将mark word复制到lock record的replace mark word中,再将mark word替换为一个指向lock record的指针。修改锁标记位00,继续执行。

2.原线程退出sync块,将mark word设置为001无锁状态,其他线程走轻量级锁逻辑争抢线程。

批量重偏向/撤销

一个类会有多个对象,当这些对象都作为锁对象被使用时:

如果一个对象a,被当前线程执行完,又有另一个线程进入,此时对象a会进入锁撤销逻辑,撤销锁的开销是比较大的。

解决方案就是,每个class维护一个偏向锁撤销计数器,当计数器到达阈值(默认20)时,jvm认为此锁的偏向逻辑有问题,进行批量重偏向。批量重偏向会将klass的prototypeheader的epoch字段++,这样下次再发生锁竞争时,线程会发现klass的epoch与当前的不一致,直接进行cas替换threadid即可,不需要去做锁撤销的逻辑。


轻量级锁

偏向锁服务于,完全没有第二个线程执行sync代码块的场景。而轻量级锁服务于,虽然有多个线程执行sync代码块,但多线程之间天然串行,没有发生争抢的场景。

轻量级锁的进入方式

  1. 锁对象处于无锁状态001
  2. 锁对象已经偏向某线程,且epoch没过期,这里与偏向锁撤销的未退出sync块关联
  3. 锁对象已经处于轻量级锁状态

加锁过程

  1. 判断是否为无锁状态,是则进入2,否则进入3
  2. 栈中创建lock record,将mark word拷贝到lock record的displace mark word重,将lock record的obj指向当前锁对象,尝试通过cas将锁对象的mark word指向lock record,成功则获取轻量级锁,修改锁标识,失败则进入3
  3. 判断是否是重入锁,是则记录重入,栈帧中加一个displace mark word为null的lock record。
  4. 不是则升级为重量级锁逻辑

解锁过程

  1. 判断是否是重入锁,看栈帧最上面的lock record的displace mark word值是否为null
  2. 当到最后一个锁时,将lock record的displac mark word cas到锁对象的mark word,成功则解锁,失败则表示发生了锁膨胀,cas回去后,锁标识就变成最初的01无锁状态
  3. 执行重量级锁解锁过程

重量级锁

重量级锁调用os的mutex lock,一般由轻量级锁膨胀而来,轻量级锁不需要考虑多线程竞争问题,只需要一个标识标识是否有线程占用锁对象,因此使用lock record这种线程私有的对象也能实现。

而重量级锁是实实在在的并发控制,需要一个功能更强大的Object monitor对象,其不仅能存储锁对象的mark word,还能存储抢锁失败的阻塞线程队列_cxq,以及调用wait等待的线程队列_waitSet。

重量级锁进入逻辑

  1. 已经是重量级锁状态,返回
  2. 膨胀中,continue重试,通过spin yield park完成自旋
  3. 还是轻量级锁,执行膨胀逻辑
  4. 无锁状态,执行膨胀逻辑

膨胀过程

  1. 调用omalloc方法分配一个obj monitor对象,并初始化其值。omalloc从线程私有的omFreeList中分配对象,如果list中没有monitor对象了,则从jvm全局的gFreeList分配一批monitor到omFreeList中
  2. 将锁对象mark word设置为膨胀状态inflating,如果设置失败则表示其他线程正在执行锁膨胀,尝试忙等待
  3. 设置monitor对象,重点是设置其owner指向lock record而不是线程
  4. 设置锁对象mark word的锁标识,并将指向monitor的指针放入mark word

锁膨胀inflate完后,不代表执行膨胀的线程就能获得这个锁,真正的锁竞争发生在其他方法

monitor竞争过程

  1. 如果当前是无锁,cas其owner为当前线程,成功则代表获取了锁
  2. 如果owner是自己,则执行重入逻辑,monitor对象中的重入次数++
  3. 如果owner是lock record,也就是上面锁膨胀完的状态,说明当前是之前轻量级锁持有者拥有,初始化,重入次数=1,owner=当前线程
  4. 如果没有获取到锁,执行enterl函数

enterl函数逻辑

  1. trylock一下,执行了一次cas操作置换owner
  2. tryspin一下,自旋n次cas操作,中间有pause,默认10次,源码里说20-100次效果最佳
  3. 将当前线程包装成object waiter对象,尝试插入cxq队列,每次插入失败还会继续尝试获取锁,直到某一个操作成功。
  4. 如果进入cxq队列后,继续尝试获取一次锁,如果失败则进入挂起逻辑,等待被唤醒
  5. 唤醒动作触发后,尝试获取一次锁,成功则退出并从cxq队列移除,失败自旋然后继续挂起

锁释放exit逻辑

  1. 若轻量级锁在膨胀后释放了该锁,将其owner指向轻量级锁线程
  2. 取出parkevent,将owner置为空,unpark等待的线程

聊完了synchronized原理,再来看一下api层面的锁的实现原理,最需要关注的就是AbstractQueuedSynchronizer这个类,简称AQS,他是实现java并发包下reentrantlock,countdownlatch,futuretask等类的基础。

参考文章:

javadoop.com/post/Abstra…


AQS

aqs的核心全局变量

private transient volatile Node head;  // 头节点
private transient volatile Node tail; //尾节点
private volatile int state; //锁状态标识,基于cas操作
private transient Thread exclusiveOwnerThread; //代表当前持有独占锁的线程

aqs模型:

head ↔ node ↔ node ↔ node这样的模型

head不属于阻塞队列,是当前持有锁的线程的包装。


node模型:

// 标识节点当前在共享模式下
static final Node SHARED = new Node();
// 标识节点当前在独占模式下
static final Node EXCLUSIVE = null;

// node的几种状态 final
int canceled = 1;
int singnal = -1;
int condition = -2;
int propagate = -3;

// node实际状态
int waitStatus; // 上面的几种
Node prev;
Node next;
Thread th; // 这个node包装的thread

那么aqs的运作原理就很简单了,head是能够执行的thread节点,后面的都是阻塞的thread节点,每个节点自己都有标识,aqs维护一个state变量控制锁的流转。

以reentrantlock为实际案例来看看aqs具体怎么实现锁的。


ReentrantLock

reentrantlock中使用抽象类Sync继承了aqs的能力,并有FairSync和UnFairSync两个具体实现。

以FairSync举例说明


lock

lock函数中调用acquire(1)方法

acquire来自aqs,步骤如下

  1. tryAcquire (1),尝试获取锁,注意这里是公平锁实现
  2. addWaiter(Node.exclusive)
  3. acquireQueued(node, arg)
  4. 如果步骤一失败并且步骤三成功,挂起当前线程

tryAcquire逻辑

  1. 判断state,0代表当前无线程持有锁,进入2,如果有进入4
  2. 判断queue中有无节点,注意这是公平锁实现,如果有节点则return false,否则继续
  3. cas尝试设置state状态为acquire的arg,即1,成功则设置exclusiveOwner的线程为自己
  4. 衔接上面state不为0的情况,先判断下是否是自己的重入,也就是判断exclusiveOwnerthread和自己对比,如果是重入操作state++
  5. 如果不是,tryacquire失败,return false

addWaiter逻辑,将当前线程包装成node同时加入队列

  1. 判断tail是否为空,不为空通过cas操作将自己设置为tail,入队成功则返回
  2. cas失败代表有其他节点在竞争入queue,执行enq(node)操作,return node

enq逻辑,通过自旋手段入队,注意,enq入口可能是有多线程竞争入队,也有可能是等待队列为空

enq是一个for;;循环

  1. 队列为空,先处理,初始化head节点和tail节点,此时两者相同。进入下一次循环
  2. 设置当前node为tail

注意:enq仅仅将head初始化了,但并没有让enq的node成为head,所以这时的node虽然在阻塞队列,但他能够成为head抢占锁

addWaiter得到的是一个已经加入queue的node节点,衔接上面的操作

acquireQueue(addWaiter得到的node, arg)逻辑,这个逻辑也是一个for;;循环

  1. 获得node的前置节点,尝试cas state,成功则return
  2. 进入判断逻辑 是否Park线程AfterAcquireFail(prev, node),如果需要park,继续进入 parkAndCheckInterrupt逻辑,最后设置interrupted = true
  3. 如果上面逻辑都执行失败,继续循环
  4. 如果tryAcquire发生异常,进入finally块,执行cancelAcquire逻辑

shouldParkAfterFailedAcquire逻辑,在addWaiter的2中执行,判断是否需要将当前线程挂起

  1. 获取prev的waitStatus,若=-1,将当前节点挂起,return
  2. 若prev的waitStatus>0,表示prev取消了排队,需要给自己换一个正常的(waitStatus<0)的prev
  3. 若prev的waitStatus=0,-2,-3,cas其prev的waitStatus=-1,注意,newNode塞入queue时,状态都为0,也就是说,当其后面有节点加入,就会被set为-1,同时其后节点会进入acquireQueue的下一次循环,然后park自己

视角转回当前node,shouldParAfterFailedAcquire成功后,执行真正的park逻辑

parkAndCheckInterrupt,调用LockSupport.park(this),return Thread.interrupt();

这就是lock的全流程,回顾一下,首先acquire 1,进入加锁逻辑,tryAcquire,如果失败,将自己包装成node,入阻塞队列,进入后尝试park自己的线程,但会先试一下能不能获取到锁,如果还是不行,则真正的调用LockSupport.park


unlock

调用sync的release 1, 进入tryRelease方法

这里的1就是reentrantlock的特殊参数

tryRelease中

  1. 用state-arg也就是1得到当前状态
  2. 如果不为0代表是重入锁,state - -后return
  3. 如果为0,设置exclusiveThread为null,state=0,return true

tryRelease成功后,执行unparkSuccessor head逻辑

  1. 将自己node的waitStatus设置为0
  2. 从后往前找到最前面的waitStatus<0的节点
  3. 调用LockSupport.unpark唤醒

被唤醒的线程从刚刚的parkAndCheckInterrupt继续执行。还记得他在一个for;;中吗?在下一次for循环中他会找到自己的prev节点,然后发现为0,将自己设为head,tryAcquire锁,成功return,执行同步代码块,unlock,唤醒自己的下一个线程,继续上述操作…

此时一个完整的 最基本的reentrantlock流程就出来了。