JVM 和 JDK 中的锁
锁……就是关住厕所门,能独占茅坑的那玩意儿。占坑的花样多了,就出现了各种不同的花样手法的名字。每个名字还都用锁结尾,吓得不查攻略都不敢靠近城市里的公共卫生间。
把最后一个锁字拿掉。把它们读作 乐观的、悲观的、可重入的、不可重入的、公平的,非公平的、自旋的、偏向、读写分离的 就会舒服很多。 这些只是形容词,这些不是锁,我们不要从形容词开始学。爆多的、没见过的、甚至是不贴切的形容词是老手之间交流的内行话、装逼话。
新手应该先了解锁的各个型号。型号才能精确对应实物。把实物观察明白的话,甚至可以自己再编几个新的形容词出来。
希望这篇笔记,能帮助我们,消除对锁的恐惧,无视形容词。
自己实现一把锁
如果要造轮子,自己实现一把锁,应该怎么做呢?
–
首先需要一个共享的标志位,用来给各个线程抢占,抢占的意思,就是谁先给标志位设置了值,差不多就算谁获得了锁。
如果标志位是空的,说明没人拿到锁,这时候是可以去抢的。
标志位有值,标记的是其他线程,那就该做 阻塞,重试,等待重试,放弃执行 之类的操作了。
标志位有值,而且是自己线程标记的,那就可以做 继续运行 之类的操作了。
释放锁的操作,就是把这个标志位设置回空。如果其他线程没抢到锁后的操作是阻塞,还得记得把他们叫起来,通知他们可以再来试着抢锁了。
–
上面的逻辑,严重依赖标志位,抢着给标志位设置值的时候,至少要 get()
一次,判断下当前值是可抢的,才能去 set()
值来抢锁。三步操作才能完成。这三步操作首先要是原子的,不能被打断。其次是要可见的,原子的修改操作结束后,其他线程看到的一定是修改完后的新数据。
这就出现了 要有鸡,就得先有蛋 的情况。。不过至少,我们把锁的执行流程给剥离出来了,问题只剩下要一步操作实现 compareAndSet()
了。
–
总结一下,做个名词解析。我们把实现一把锁,拆成了两个部分。“锁的执行流程” 和 “蛋的问题”。
“锁的执行流程” 的不同,也对应的是文章第一段占茅坑中提到的 “占坑花样” 的不同。
“蛋的问题” 如何解决,可以寻求他人的帮助。比如 操作系统内核就提供了一些系统调用。比如 cpu 支持 cmpxchg
指令来比较并交换数据,多核的时候这个指令前面还可以加个 lock
。再比如 redis 的 setNx() 命令。
思维再放开点,比如 mysql 的:
update table_name set version = version + 1 where version = 1
–
记住 lock cmpxchg
,记住 compareAndSet()
, 记住 CAS
: CompareAndSet/CompareAndSwap。
旧型号的锁
旧型号的锁,这里专指旧版JDK中的 synchronized 关键字,调用操作系统内核里互斥量的实现来解决蛋的问题,锁的执行流程则在 JVM 里,用 C++ 写的。
以 synchronized 对一个普通Java对象加锁为例。
–
先要了解对象在堆中的结构,它由几个部分组成,开头是8个字节的 markWord。之后是一个指针指向方法区里的Class对象,然后是一个数组对象才有的4字节记录数组长度。再往后就是成员变量的值或是指针。最后是一些占位符用来占满8字节的整数倍以方便整存整取。
展开点说,
指向类信息的指针 和 指向成员变量的指针 两类指针,上面都没有提到占几个字节,要看指针压缩有没有生效,生效就是4字节,不生效就是8字节。由两个jvm参数分别配置这两类指针的压缩与否(默认开启),但系统可用内存大小比jvm参数的优先级更高,大内存(32G以上)就不会启用指针压缩。由程序员在编码阶段就确定对象的大小,能做到一件很好玩的事情:对象从主存加载到cpu缓存中,也是整存整取的,单位叫缓存行(64字节)。假设有一个多线程共享的数组实现的列表,每个线程使用列表里的不同元素,列表里每个元素又很小,在元素对象的有效的成员变量前后加上适量的成员变量来占位,就能保证一个cpu缓存行只能存在一个元素,线程对各个元素的并发操作就没有缓存一致性协议的开销。
方法区里的Class对象是一个c++对象,而且不在堆内,所以c++对象里还有一个指针指向堆中的Class对象,堆中的Class对象是java对象,可以用来反射用。
markWord 是多种数据混用的一个区域,有2个比特的标志位,标识当前的数据含义。比如某个含义下,GC年龄就存在这个区域。
–
synchronized 对 java Object 的加锁,就操作了 markWord 区域,把数据设置成一个指针,指向一个 ObjectMonitor c++对象。这个c++对象里就保存了 当前锁的拥有者线程(Owner)、抢锁的线程集合、锁的重入次数 等等。markWord 区域原本的数据转存到 Owner 指向的线程里的 LockRecord 区域。
这已经到 jvm 的 c++ 代码了,没法看得太深。不过已经可以联想到一些推论了。比如 wait()、 notify() 为什么都是 java Object 提供的方法? 因为它们操作的就是 java Object 关联的 c++ ObjectMonitor 里保存的线程集合。比如 wait()、 notify() 都只在 synchronized 代码块里面调用。
–
做个特点总结,给 synchronized 加几个形容词:
重量级的:因为系统调用开销大
互斥的:只有一个线程能加锁成功
悲观的:先获取锁再执行,心里就是默认了肯定有人在抢了嘛
可重入的:同一个线程可以加锁多次,有个计数器记着次数
非公平的:这个特点是使用的那些内核调用带的
对象的:
新型号的锁
新型号的锁,这里指的是 java.util.concurrent 包下的那些类。放眼望去,这个包下的锁几乎都使用了 AQS。
AQS
AQS
:AbstractQueuedSynchronizer,是 java.util.concurrent 包下的一个较为底层的类, 它规定并提供了一套数据结构 和 一套模板方法。
–
数据结构,包含一个 volatile int state
和 一个 自定义的 Node
类型组成的链表。
state 的含义是不固定的,上层的锁实现自己去规定。ReentrantLock 里含义是上锁与否 和 锁的重入次数。CountDownLatch 里是 Count 的值。ReentrantReadWriteLock 里把 int 按二进制拆成了两瓣 分别标识读锁和写锁。
链表 是线程的等待队列。可以用不同的模式来使用队列。比如 Condition 模式下,队列不一定只有一条,可以通过 ConditionObject 新起一个队列,这样在唤醒线程的时候就可以只唤醒特定队列里的线程。标准的生产者消费者模型,就可以用 Condition 分开保存生产者和消费者线程。
–
模板方法,已经把 申请锁失败的线程加进等待队列、用 LockSupport 完成阻塞和唤醒 等等逻辑实现好了。主要留下未完成的 tryAcquire()、tryRelease()、tryAcquireShared()、tryReleaseShared()等方法。
看两个模板例子:
public final void acquire(int arg) {// 申请锁, 子类可以把这个方法包装成互斥的 lock() 实现。
if (!tryAcquire(arg) && // tryAcquire() 需要子类实现,一般用CAS操作去设置 state 的值。
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // addWaiter 用CAS操作,设置队列的tail节点,把当前线程加入等待队列。
// acquireQueued 里把线程 park() 阻塞。
selfInterrupt();
}
public final boolean release(int arg) {// 释放锁, 子类可以把这个方法包装成互斥的 unLock() 实现。
if (tryRelease(arg)) {// tryRelease() 需要子类实现,一般用CAS操作去设置 state 的值。
Node h = head;// 从等待队列里取一个线程。
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);// unpark() 唤醒线程
return true;
}
return false;
}
ReentrantLock
ReentrantLock 里面,实现了两个版本的 AQS 实现类:FairSync 和 NonfairSync。 构造方法不指定参数的时候,默认是 NonfairSync。
NonfairSync 加锁的源码截取如下:
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;
}
看得出来它是可重入的,因为判断当前拥有锁的线程是自己的时候,nextc = c + acquires
然后返回了 true。
FairSync 加锁的源码截取如下,只有第五行跟 NonfairSync 有区别,
即使 c == 0,是可以抢锁的状态,也要看下队列里是不是已经有等待线程。有其他人已经在排队等待了,就只返回 false,让父类的模板代码把自己加到队列末尾排队。
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() && // 公平的关键在这里
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
ReentrantLock 还把 AQS 的 ConditionObject 暴露了出来,可以创建多个等待队列。
public Condition newCondition() {
return sync.newCondition();
}
AQS.ConditionObject 类实现了 Condition 接口。每个 ConditionObject 里存着一条 Node 组成的等待队列。 ConditionObject 队列的 await()、signal() 方法,也已经由 AQS 模板实现好了。
CountDownLatch
CountDownLatch 对 AQS 的使用方式是这样的:
首先,在构造方法中,就把 state 设置成了 count。
然后,使用者调用 countDown() 实质是走到了 AQS.releaseShared() 去释放锁。CountDownLatch 通过 CAS 的操作对 state 的值减1。如果成功被减到了0,返回值才会是true,true 代表释放成功的含义,以触发 AQS.releaseShared() 去执行唤醒等待线程的操作。
最后,使用者调用 await() 实质是对应 AQS.acquireSharedInterruptibly() 去获取锁。CountDownLatch 判断如果 state == 0,就反馈给 AQS 获取锁成功,那么线程将直接继续运行。如果 state != 0,反馈给 AQS 获取锁失败,AQS 就会把线程阻塞,加入到等待队列。等待被上一小节中描述的 countDown() 操作唤醒。
展开讨论下CAS
AtomicInteger
AtomicInteger 就强依赖 cpu 指令提供的 CAS 操作。核心代码长这样:
// AtomicInteger 类的 incrementAndGet()
public final int incrementAndGet() {
// this, valueOffset 这俩参数:标明了 atomicInteger 对象里面的 volatile int value 成员变量在内存里的地址。
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// unsafe.getAndAddInt()
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));// CAS 操作
return v;
}
–
自旋的,这个形容词用在这里就很典型。自己不停的在循环,而不是去阻塞线程释放cpu。
乐观的,
轻量级的,
看到有人把它叫做 “无锁的”。。。明明 CAS 就是锁啊。
还想再造一个形容词, 如果在循环里每次都自己阻塞一小段时间,到时间自己醒来 ,是不是可以叫 “自旋+阻塞”的。。。。简单的分布式锁里可以用。
synchronized 的优化
偏向 这个词,最经典的来源就是对 synchronized 的优化了。通过这个例子,还可以借鉴一下 乐观还是悲观、轻量还是重量、自旋还是阻塞 应该怎么选型。
偏向的功能,也是有 jvm 参数配置的,目前是默认开启,jvm 启动4秒后生效。(记得回来感受一下,前几秒为啥不生效。)
–
第一个线程来申请锁的时候,查看 markWord 状态是空白的。使用一次 CAS 操作,把自己的线程标识填入 markWord。之后自己再来申请锁,看到 markWord 状态没变,就无须任何同步操作了。这种情况,就可以用 偏向 来形容。
第二个线程来的时候,查看 markWord 状态显示已经有人占用,就开始把锁升级成轻量级锁了,就是乐观锁,就是自旋锁。
统计自旋的消耗。最后升级到重量级锁。
–
jvm 里 c++ 的东西,只能看看书上说的,浅浅了解下了。
–
偏向 这个词就是换了个视角啊,其他形容词都从线程自身的角度出发的。偏向站在了上帝视角从并发量的角度来形容。就 ReentrantLock 在单线程的情况下,能说不是偏向的么。
–
并发量小,锁住的时间短,适合用乐观锁。
锁的本质
大胆的猜测一下,锁的本质是串行化,这个世界上最终能实现串行化的,也许只有cpu从电路层面停掉多核、停掉线程调度才能实现。点到为止吧,不可能再去了解cpu的电路原理啊。
再猜一下, 这个停掉多核、停掉线程调度,也可以是假的吧。只要从单个内存地址的角度看起来像是停的就行了。