问题
- synchronized原理&锁升级过程
- reentrantlock实现原理&aqs
目前对这些问题的大致印象:
synchronized是jvm层面实现的,通过在编译期给代码前后加上os的monitor机制实现并发控制。synchronized的效率较api层面实现的lock来说相对低下,所以后来引入了锁升级。api层面实现的lock大多基于aqs这个东西。
参考文章:
synchronized
前言
观察sync反编译后的字节码可知,sync会在代码块上加一个monitor_enter和两个monitor_exit,第二个exit是为异常服务。monitorenter时,会将当前对象关联到一个monitor对象,设置monitor的owner为当前线程。
monitorenter和monitorexit这两个都属于jvm指令,这两个指令主要基于java对象的mark word和object monitor来做的。
markword是对象头的一部分,64bit长度,引用youzan技术的图
jdk1.6以前,sync就是直接去关联monitor对象。但是效率很低,后面引入了锁升级,涉及到偏向锁和轻量级锁两个优化锁,这两个锁就是基于mark word实现的。
偏向锁
偏向锁适用的场景是,加了sync的代码块但没有发生线程竞争的场景。jvm通过一次cas来尝试获取线程所有权,如果成功,则是偏向锁状态。多线程发生竞争时,需要主动撤去偏向锁。
偏向锁的锁标识一定是101,但会根据mark word中thread id和epoch细分成多种小状态
- 匿名偏向,threadid为0
- 可重偏向,当前对象mark word中epoch与kclass的_proptotype_header的epoch不一样,可被cas thread id
- 已偏向,thread id 非空,epoch有效
jvm启动默认4s后会将所有对象的类的prototypeheader设置为匿名偏向样式。前四秒所有的对象会直接进入轻量级锁的状态。
偏向锁加锁过程
- 判断对象mark word的标识和对象所属klass的原型头的锁标识位是否为可偏向,若不是则走轻量级锁逻辑
- 比较对象mark word的epoch和klass原型头的epoch是否匹配,验证偏向状态有没有过期
- 比较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代码块,但多线程之间天然串行,没有发生争抢的场景。
轻量级锁的进入方式
- 锁对象处于无锁状态001
- 锁对象已经偏向某线程,且epoch没过期,这里与偏向锁撤销的未退出sync块关联
- 锁对象已经处于轻量级锁状态
加锁过程
- 判断是否为无锁状态,是则进入2,否则进入3
- 栈中创建lock record,将mark word拷贝到lock record的displace mark word重,将lock record的obj指向当前锁对象,尝试通过cas将锁对象的mark word指向lock record,成功则获取轻量级锁,修改锁标识,失败则进入3
- 判断是否是重入锁,是则记录重入,栈帧中加一个displace mark word为null的lock record。
- 不是则升级为重量级锁逻辑
解锁过程
- 判断是否是重入锁,看栈帧最上面的lock record的displace mark word值是否为null
- 当到最后一个锁时,将lock record的displac mark word cas到锁对象的mark word,成功则解锁,失败则表示发生了锁膨胀,cas回去后,锁标识就变成最初的01无锁状态
- 执行重量级锁解锁过程
重量级锁
重量级锁调用os的mutex lock,一般由轻量级锁膨胀而来,轻量级锁不需要考虑多线程竞争问题,只需要一个标识标识是否有线程占用锁对象,因此使用lock record这种线程私有的对象也能实现。
而重量级锁是实实在在的并发控制,需要一个功能更强大的Object monitor对象,其不仅能存储锁对象的mark word,还能存储抢锁失败的阻塞线程队列_cxq,以及调用wait等待的线程队列_waitSet。
重量级锁进入逻辑
- 已经是重量级锁状态,返回
- 膨胀中,continue重试,通过spin yield park完成自旋
- 还是轻量级锁,执行膨胀逻辑
- 无锁状态,执行膨胀逻辑
膨胀过程
- 调用omalloc方法分配一个obj monitor对象,并初始化其值。omalloc从线程私有的omFreeList中分配对象,如果list中没有monitor对象了,则从jvm全局的gFreeList分配一批monitor到omFreeList中
- 将锁对象mark word设置为膨胀状态inflating,如果设置失败则表示其他线程正在执行锁膨胀,尝试忙等待
- 设置monitor对象,重点是设置其owner指向lock record而不是线程
- 设置锁对象mark word的锁标识,并将指向monitor的指针放入mark word
锁膨胀inflate完后,不代表执行膨胀的线程就能获得这个锁,真正的锁竞争发生在其他方法
monitor竞争过程
- 如果当前是无锁,cas其owner为当前线程,成功则代表获取了锁
- 如果owner是自己,则执行重入逻辑,monitor对象中的重入次数++
- 如果owner是lock record,也就是上面锁膨胀完的状态,说明当前是之前轻量级锁持有者拥有,初始化,重入次数=1,owner=当前线程
- 如果没有获取到锁,执行enterl函数
enterl函数逻辑
- trylock一下,执行了一次cas操作置换owner
- tryspin一下,自旋n次cas操作,中间有pause,默认10次,源码里说20-100次效果最佳
- 将当前线程包装成object waiter对象,尝试插入cxq队列,每次插入失败还会继续尝试获取锁,直到某一个操作成功。
- 如果进入cxq队列后,继续尝试获取一次锁,如果失败则进入挂起逻辑,等待被唤醒
- 唤醒动作触发后,尝试获取一次锁,成功则退出并从cxq队列移除,失败自旋然后继续挂起
锁释放exit逻辑
- 若轻量级锁在膨胀后释放了该锁,将其owner指向轻量级锁线程
- 取出parkevent,将owner置为空,unpark等待的线程
聊完了synchronized原理,再来看一下api层面的锁的实现原理,最需要关注的就是AbstractQueuedSynchronizer这个类,简称AQS,他是实现java并发包下reentrantlock,countdownlatch,futuretask等类的基础。
参考文章:
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,步骤如下
- tryAcquire (1),尝试获取锁,注意这里是公平锁实现
- addWaiter(Node.exclusive)
- acquireQueued(node, arg)
- 如果步骤一失败并且步骤三成功,挂起当前线程
tryAcquire逻辑
- 判断state,0代表当前无线程持有锁,进入2,如果有进入4
- 判断queue中有无节点,注意这是公平锁实现,如果有节点则return false,否则继续
- cas尝试设置state状态为acquire的arg,即1,成功则设置exclusiveOwner的线程为自己
- 衔接上面state不为0的情况,先判断下是否是自己的重入,也就是判断exclusiveOwnerthread和自己对比,如果是重入操作state++
- 如果不是,tryacquire失败,return false
addWaiter逻辑,将当前线程包装成node同时加入队列
- 判断tail是否为空,不为空通过cas操作将自己设置为tail,入队成功则返回
- cas失败代表有其他节点在竞争入queue,执行enq(node)操作,return node
enq逻辑,通过自旋手段入队,注意,enq入口可能是有多线程竞争入队,也有可能是等待队列为空
enq是一个for;;循环
- 队列为空,先处理,初始化head节点和tail节点,此时两者相同。进入下一次循环
- 设置当前node为tail
注意:enq仅仅将head初始化了,但并没有让enq的node成为head,所以这时的node虽然在阻塞队列,但他能够成为head抢占锁
addWaiter得到的是一个已经加入queue的node节点,衔接上面的操作
acquireQueue(addWaiter得到的node, arg)逻辑,这个逻辑也是一个for;;循环
- 获得node的前置节点,尝试cas state,成功则return
- 进入判断逻辑 是否Park线程AfterAcquireFail(prev, node),如果需要park,继续进入 parkAndCheckInterrupt逻辑,最后设置interrupted = true
- 如果上面逻辑都执行失败,继续循环
- 如果tryAcquire发生异常,进入finally块,执行cancelAcquire逻辑
shouldParkAfterFailedAcquire逻辑,在addWaiter的2中执行,判断是否需要将当前线程挂起
- 获取prev的waitStatus,若=-1,将当前节点挂起,return
- 若prev的waitStatus>0,表示prev取消了排队,需要给自己换一个正常的(waitStatus<0)的prev
- 若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中
- 用state-arg也就是1得到当前状态
- 如果不为0代表是重入锁,state - -后return
- 如果为0,设置exclusiveThread为null,state=0,return true
tryRelease成功后,执行unparkSuccessor head逻辑
- 将自己node的waitStatus设置为0
- 从后往前找到最前面的waitStatus<0的节点
- 调用LockSupport.unpark唤醒
被唤醒的线程从刚刚的parkAndCheckInterrupt继续执行。还记得他在一个for;;中吗?在下一次for循环中他会找到自己的prev节点,然后发现为0,将自己设为head,tryAcquire锁,成功return,执行同步代码块,unlock,唤醒自己的下一个线程,继续上述操作…
此时一个完整的 最基本的reentrantlock流程就出来了。