feling.net/_posts/2022-12-25-jvm-jdk-lock.md

14 KiB
Raw Permalink Blame History

layout title categories tags
pages JVM 和 JDK 中的锁 Java
jvm
lock

锁......就是关住厕所门,让别人进不来,能独占茅坑的那玩意儿。占坑的花样多了,就出现了各种不同的花样手法的名字。每个名字还都用锁结尾,吓得不查攻略都不敢靠近城市里的公共卫生间。

把最后一个锁字拿掉。把它们读作 乐观的、悲观的、可重入的、不可重入的、公平的,非公平的、自旋的、偏向、读写分离的 就会舒服很多。 这些只是形容词,这些不是锁,我们不要从形容词开始学。爆多的、没见过的、甚至是不贴切的形容词是老手之间交流的内行话、装逼话。

新手应该先了解锁的各个型号。型号才能精确对应实物。把实物观察明白的话,甚至可以自己再编几个新的形容词出来。

希望这篇笔记,能帮助我们,消除对锁的恐惧,无视形容词。

自己实现一把锁

如果要造轮子,自己实现一把锁,应该怎么做呢?

--

首先需要一个共享的标志位,用来给各个线程抢占,抢占的意思,就是谁先给标志位设置了值,差不多就算谁获得了锁。

如果标志位是空的,说明没人拿到锁,这时候是可以去抢的。

标志位有值,标记的是其他线程,那就该做 阻塞,重试,等待重试,放弃执行 之类的操作了。

标志位有值,而且是自己线程标记的,那就可以做 继续运行 之类的操作了。

释放锁的操作,就是把这个标志位设置回空。如果其他线程没抢到锁后的操作是阻塞,还得记得把他们叫起来,通知他们可以再来试着抢锁了。

--

上面的逻辑,严重依赖标志位,抢着给标志位设置值的时候,至少要 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

AQSAbstractQueuedSynchronizer是 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返回值才会是truetrue 代表释放成功的含义,以触发 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的电路原理啊。

再猜一下, 这个停掉多核、停掉线程调度,也可以是假的吧。只要从单个内存地址的角度看起来像是停的就行了。

  • 文章目录 {:toc}