Java基础-锁核心

image.png

玉琢.png

前言

​ 在我们编程过程中,锁是一个绕不开的主题,那么什么是锁,锁的分类都有什么,让我们来一点一点掀开她的面纱。在这里我们为 java 的关键字 synchronized ,锁的封装类 reentrantLock ,AQS ,分布式锁等所有锁涉及的点进行了深入的剖析讲解,讲解 synchronized 的原理,reentrantLock 的源码解析等等,相信大家在学习完这篇博客后,对锁的主题一定会有所收获。

锁分类

我们先将锁进行分类,列出他的特点以及实现方式等,让我们对锁的分类有个大体的概念。

  • 乐观锁。特点:竞争小、执行时间短

  • 悲观锁。特点:竞争大、执行时间长

  • 公平锁。特点:让所有请求按照到达顺序排队上锁

  • 非公平锁。特点:随机的请求获取锁

  • 独占锁。特点:单个线程持有

  • 共享锁。特点:多个线程持有

  • 可重入锁。特点:当前线程是否可以再次获取已经拥有的锁

  • 自旋锁。特点:获取锁失败,不放弃 CPU 重试一定次数。使用 CPU 时间换取线程阻塞与调度开销,竞争小场景

    锁的分类是从锁的不同角度来看待的,就是可能他既是独占锁又是可冲入锁。这个只是从不同等级角度来描述,他们之前许多并不是冲突的。下面我么从最简单的锁,也是大多数开发者第一个接触到的锁,java的关键字-- Synchronized 说起 。

Synchronized

JVM内置的便捷的线程同步工具,前期 jdk 版本中 Synchronized 效率较低,在后期版本中做了大量性能优化。

锁的对象

  • 类、静态成员函数等,锁的是类对象。
  • 非静态成员函数、this ,锁的是实例对象。

实现原理

通过 MonitorenterMonitorexit 指令实现:

monitorenter 指令:

  • 获取monitor 的所有权,monitor计 数器为0,一个线程就会立刻获得锁,锁计数器+1,别的线程再想获取,就需要等待

  • monitor 已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加

  • 锁已经被别的线程获取了,等待锁释放

monitorexit 指令:

  • 释放 monitor 的所有权,monitor 的计数器减1,如果计数器不是0,则代表是重入进来的,当前线程还继续持有这把锁,如果计数器变成0,则代表当前线程不再拥有该 monitor 的所有权,即释放锁。

可以保证可见性、原子性、有序性、可重入性,不能保证临界区内部有序性。

锁优化

  • JVM 中 monitorenter 和 monitorexit 指令依赖于底层操作系统 Mutex Lock 来实现的,但是由于使用 Mutex Lock 需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;
  • 大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用 Mutex Lock 那么将严重的影响程序的性能。jdk1.6 中对锁的实现引入了大量的优化来减少锁操作的开销:
  • 锁粗化Lock Coarsening:将多个连续的锁扩展成一个范围更大的锁。
  • 锁消除Lock Elimination:通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地 Stack 上进行对象空间的分配,同时还可以减少 Heap 上的垃圾收集开销。
  • 偏向锁(Biased Locking): 在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。 偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次 CAS ,但偏向锁只有初始化时需要一次 CAS。“偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在 Mark Word 中 CAS 记录 owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于 owner 就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。如果明显存在其他线程申请锁,那么偏向锁将很快膨胀为轻量级锁。使用参数 -XX:-UseBiasedLocking 禁止偏向锁优化(默认打开)
  • 轻量级锁(Lightweight Locking):自旋锁的目标是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。 使用轻量级锁时,不需要申请互斥量,仅仅将 Mark Word 中的部分字节 CAS 更新指向线程栈中的 Lock Record ,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。 由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁。缺点: 如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁,那么维持轻量级锁的过程就成了浪费。
  • 自旋Spinning: 通过自旋锁,可以减少线程阻塞造成的线程切换(包括挂起线程和恢复线程)。这通常发生在锁持有时间长,但竞争不激烈的场景中。 缺点: 1、单核处理器上,不存在实际的并行,当前线程不阻塞自己的话,旧 owner 就不能执行,锁永远不会释放,此时不管自旋多久都是浪费;进而,如果线程多而处理器少,自旋也会造成不少无谓的浪费。2、自旋锁要占用 CPU,如果是计算密集型任务,这一优化通常得不偿失,减少锁的使用是更好的选择。3、如果锁竞争的时间比较长,那么自旋通常不能获得锁,白白浪费了自旋占用的 CPU 时间。这通常发生在锁持有时间长,且竞争激烈的场景中,此时应主动禁用自旋锁。
  • 适应性自旋Adaptive Spinning: 自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定: 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。相反的,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。自适应自旋解决的是“锁竞争时间不确定”的问题。JVM 很难感知到确切的锁竞争时间,而交给用户分析就违反了 JVM 的设计初衷。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定,因此,可以根据上一次自旋的时间与结果调整下一次自旋的时间。缺点 :自适应自旋也没能彻底解决该问题,如果默认的自旋次数设置不合理(过高或过低),那么自适应的过程将很难收敛到合适的值
  • 重量级锁: 内置锁在 Java 中被抽象为监视器锁(monitor)。这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”。

实现 Synchronized 对象结构

1,Mark Word ; 2,指向类的指针,数组长度(只有数组对象才有)

备注:在32位 JVM 中的长度是 32bit,在64位 JVM 中长度是 64bit

在对象头中(Object Header)存在两部分。第一部分用于存储对象自身的运行时数据,HashCodeGC Age锁标记位是否为偏向锁。等。一般为32位或者64位(视操作系统位数定)。官方称之为Mark Word,它是实现轻量级锁和偏向锁的关键。 另外一部分存储的是指向方法区对象类型数据的指针(Klass Point),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度。

锁升级过程

1、当没有被当成锁时,这就是一个普通的对象,Mark Word 记录对象的 HashCode ,锁标志位是 01,是否偏向锁那一位是 0。

2、当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是 01,但是否偏向锁那一位改成1,前 23bit 记录抢到锁的线程id,表示进入偏向锁状态。

3、当线程A再次试图来获得锁时,JVM 发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word 中记录的线程id就是线程A自己的 id ,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。

4、当线程B试图获得这个锁时,JVM 发现同步锁处于偏向状态,但是 Mark Word 中的线程id 记录的不是B,那么线程B会先用 CAS 操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把 Mark Word 里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。

5、偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM 会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁 Mark Word 的指针,同时在对象锁 Mark Word 中保存指向这片空间的指针。上述两个保存操作都是 CAS 操作,如果保存成功,代表线程抢到了同步锁,就把 Mark Word 中的锁标志位改成 00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。

6、轻量级锁抢锁失败,JVM 会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从 JDK1.7 开始,自旋锁默认启用,自旋次数由 JVM 决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。

7、自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

指向类的指针:Java 对象的类数据保存在方法区,JVM 要求 java 的对象占的内存大小应该是 8bit 的倍数,有几个字节用于把对象的大小补齐至 8bit 的倍数。

数组长度:只有数组对象保存了这部分数据。

适用并发场景:

  • 偏向锁:无实际竞争,且将来只有第一个申请锁的线程会使用锁。

  • 轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争。

  • 重量级锁:有实际竞争,且锁竞争时间长。

另外,如果锁竞争时间短,可以使用自旋锁进一步优化轻量级锁、重量级锁的性能,减少线程切换。

如果锁竞争程度逐渐提高(缓慢),那么从偏向锁逐步膨胀到重量锁,能够提高系统的整体性能。

介绍完 Synchronized 关键字,下面我们来看一看java并发包内自己封装的锁。

java.util.concurrent.locks 包核心类

锁核心类继承关系图

LockSupport 提供基础的锁操作原语,由 Unsafe 类实现,调用 JNI native 方法

Unsafe:
public native void unpark(Object var1);
public native void park(boolean var1, long var2);

ReentrantLock 逻辑都由内部类 Sync 实现。

public void lock() {
   sync.lock();
}

// 公平锁
final void lock() {
  acquire(1);
}

// 非公平锁
final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

public final void acquire(int arg) {
  if (!tryAcquire(arg) &&
      acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}

锁的公平性与非公平性:公平是排在队尾;非公平是直接竞争锁。原因:提高效率,减少线程切换。

ReadWriteLock 可以解决多线程同时读,但只有一个线程能写的问题。深入分析 ReadWriteLock ,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。要进一步提升并发执行效率,Java 8 引入了新的读写锁: StampedLock

StampedLockReadWriteLock` 相比,改进之处在于:读的过程中也允许获取写锁后写入!我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁,有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。

实现一把锁的几个核心要素

1、一个 state 变量,标记锁状态,0,1 标识无锁、有锁,大于1标识可重入,对 state 操作保证线程安全,用 CAS。

2、记录当前持有锁的线程。

3、需要支持对一个线程进行阻塞和唤醒操作。

4、需要一个队列维护所有阻塞的线程,线程安全的无锁队列,用 CAS。

针对1:

java.util.concurrent.locks.AbstractQueuedSynchronizer#state
/**
 * The synchronization state.
 */
private volatile int state;

针对2:

java.util.concurrent.locks.AbstractOwnableSynchronizer#exclusiveOwnerThread
/**
 * The current owner of exclusive mode synchronization.
 */
private transient Thread exclusiveOwnerThread;

state=0,没有线程持有锁,exclusiveOwnerThread=null

state=1,有线程持有锁,exclusiveOwnerThread=持有线程

state>1,有线程持有锁,重入持有,exclusiveOwnerThread=持有线程

针对3:使用LockSupport中UNSAFE阻塞和唤醒的操作原语。

public static void park(Object blocker) {
    Thread t = Thread.currentThread();
    setBlocker(t, blocker);
    UNSAFE.park(false, 0L);
    setBlocker(t, null);
}
public static void unpark(Thread thread) {
    if (thread != null)
        UNSAFE.unpark(thread);
}

unpark 和 notify 区别:unpark() 可以对另外一个线程进行精准唤醒,notify 只能唤醒一个线程,但是无法具体指定。

针对4:在 AQS 中使用CLH队列(虚拟的双向队列),将暂时获取不到锁的线程加入到等待队列中。

AQS 核心

如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列(虚拟的双向队列)锁实现的,即将暂时获取不到锁的线程加入到队列中。AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。

优势:

  • AQS 解决了在实现同步器时涉及的大量细节问题,例如自定义标准同步状态、FIFO 同步队列。
  • 基于 AQS 来构建同步器可以带来很多好处。它不仅能够极大地减少实现工作,而且也不必处理在多个位置上发生的竞争问题。
private volatile int state;//共享变量,使用 volatile 修饰保证线程可见性

// CAS操作
protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

AQS定义两种资源共享方式

  • Exclusive(独占):只有一个线程能执行,如 ReentrantLock 。又可分为公平锁和非公平锁:

    • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
    • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
  • Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatCh、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。

    不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在上层已经帮我们实现好了。

//该线程是否正在独占资源。只有用到condition才需要去实现它。
protected boolean isHeldExclusively() {
    throw new UnsupportedOperationException();
}
//独占方式。尝试获取资源,成功则返回true,失败则返回false。
protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}
//独占方式。尝试释放资源,成功则返回true,失败则返回false。
protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}
//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
protected int tryAcquireShared(int arg) {
    throw new UnsupportedOperationException();
}
//共享方式。尝试释放资源,成功则返回true,失败则返回false。
protected boolean tryReleaseShared(int arg) {
    throw new UnsupportedOperationException();
}

以 ReentrantLock 为例,state初始化为 0,表示未锁定状态。A线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1。此后,其他线程再 tryAcquire() 时就会失败,直到A线程 unlock() 到 state=0 (即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的( state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。

AbstractQueuedSynchronizer 数据结构

使用 CLH(Craig,Landin,and Hagersten)队列 是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。其中 Sync queue ,即同步队列,是双向链表,包括 head 结点和 tail 结点,head 结点主要用作后续的调度。而 Condition queue 不是必须的,其是一个单向链表,只有当使用 Condition 时,才会存在此单向链表。并且可能会有多个 Condition queue。

AQS 是 JUC 的核心,而 CLH 锁又是 AQS 的基础,AQS 用了变种的 CLH 锁。

由于 CLH 锁是一种自旋锁,自旋锁说白了也是一种互斥锁,只不过没有抢到锁的线程会一直自旋等待锁的释放,处于 busy-waiting 的状态,此时等待锁的线程不会进入休眠状态,而是一直忙等待浪费 CPU 周期。因此自旋锁适用于锁占用时间短的场合。

这里谈到了自旋锁,那么我们也顺便说下互斥锁。这里的互斥锁说的是传统意义的互斥锁,就是多个线程并发竞争锁的时候,没有抢到锁的线程会进入休眠状态即 sleep-waiting,当锁被释放的时候,处于休眠状态的一个线程会再次获取到锁。缺点就是这一些列过程需要线程切换,需要执行很多CPU指令,同样需要时间。如果 CPU 执行线程切换的时间比锁占用的时间还长,那么可能还不如使用自旋锁。因此互斥锁适用于锁占用时间长的场合。

CLH锁 其实就是一种是基于逻辑队列非线程饥饿的一种自旋公平锁,由于是 Craig、Landin 和 Hagersten 三位大佬的发明,因此命名为CLH锁。

CLH锁原理如下:

  1. 首先有一个尾节点指针,通过这个尾结点指针来构建等待线程的逻辑队列,因此能确保线程线程先到先服务的公平性,因此尾指针可以说是构建逻辑队列的桥梁;此外这个尾节点指针是原子引用类型,避免了多线程并发操作的线程安全性问题;
  2. 通过等待锁的每个线程在自己的某个变量上自旋等待,这个变量将由前一个线程写入。由于某个线程获取锁操作时总是通过尾节点指针获取到前一线程写入的变量,而尾节点指针又是原子引用类型,因此确保了这个变量获取出来总是线程安全的。

基于 AQS 构建同步器:

  • ReentrantLock

  • Semaphore

  • CountDownLatch

  • ReentrantReadWriteLock

  • SynchronusQueue

  • FutureTask

参考:

zhuanlan.zhihu.com/p/197840259 juejin.cn/post/684490…

并发编程离不开线程对线程状态的操作,下面让我们来看一看线程等待的两种方式的区别。

Object.wait() 和 Condition.await() 的区别

Object.wait() 和 Condition.await() 的原理是基本一致的,不同的是 Condition.await() 底层是调用 LockSupport.park() 来实现阻塞当前线程的。实际上,它在阻塞当前线程之前还干了两件事,一是把当前线程添加到条件队列中,二是“完全”释放锁,也就是让 state 状态变量变为0,然后才是调用 LockSupport.park() 阻塞当前线程。

前面说了一堆基础理论知识,介绍了实现锁的基础的思想,下面让我们来看一看并发包中的真正是锁的对象是如何实现的。

ReentrantLock

  • 1.类的内部结构:
  • 2.AbstractQueuedSynchronizer。抽象队列同步器。是锁的基础组件。

  • 3.ReentrantLock 默认实现的是公平还是非公平锁?默认是非公平锁。

  • 4.如何实现锁的? 基于无锁化的 CAS 机制实现高性能的加锁。

第一步先执行if (compareAndSetState(0, 1)):AQS 里有一个核心的变量 state,代表了锁的状态;看一下 state 是否是0?如果是0的话,代表没人加过锁,此时我就可以加锁,把这个 state 设置为1。相当于是在尝试加锁,底层原来是基于 Unsafe 来实现的,JDK 内部使用的 API,指针操作,基于 cpu 指令实现原子性的 CAS。 如果加锁成功,需要设置一下自己就是当前的加锁线程。setExclusiveOwnerThread(Thread.currentThread());

如果是锁重入会执行 acquire(1) 方法。

tryAcquire(arg) 方法简单的说就是会判断当前线程是否是加锁线程,如果是就将 state+1,否则就返回 false。

返回 false 后第一个条件就为真,然后进入 addWaiter 方法。简单的说就是将当前线程封装到一个 Node 对象中,通过CAS的方式去链表上挂载当前这个Node。如果没有链表,会先创建个空节点作为head,然后再把当前 node 挂上去。其他的就是一些指针的操作,就不赘述了。

acquireQueued 方法里面会再尝试去加一次锁,如果失败,就会使用 park 操作,挂起当前线程。

  • 5.锁的释放

如果不是当前加锁线程释放锁,会抛异常。

如果是重入的情况会将 state-1。

如果 state=0 了,会将排队的第一个线程唤起,然后继续获取锁。

  • 6.非公平锁

在 state=0 的时候,突然一个线程来获取锁,直接加锁成功,不会管队列中的线程。

  • 7.公平锁

公平锁,任何一个线程过来会先判断一下,当前是否有人在排队,而且是不是自己在排队,如果不是的话,说明有别人在排队,此时自己不能尝试加锁,直接入队阻塞等待

  • 8.获取锁设置超时时间

tryAcquireNanos(int arg, long nanosTimeout)。线程挂起时会设置超时时间。

  • 9.LockSupport 对比 wait 的优势

不需要再同步代码块里,然后呢先调用释放,在调用挂起也无所谓。

并发包中还封装了更新粒度的读写锁。

ReentrantReadWriteLock

除了读锁和读锁,其他全互斥。

1.写锁是如何加锁的

c & (1<<16) -1

protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    // 获取到一个state = 0
    int c = getState(); //表示是否加了锁
    int w = exclusiveCount(c); //判断加的读锁还是写锁
    // 如果c != 0,说明有人加过锁,但是此时c = 0
    if (c != 0) {
        // (Note: if c != 0 and w == 0 then shared count != 0)
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // Reentrant acquire
        setState(c + acquires);
        return true;
    }
    //如果是非公平锁直接返回false,公平锁判断等待队列长度
    if (writerShouldBlock() ||
        !compareAndSetState(c, c + acquires))
        return false;
    //基本跟之前看到的是一样的,如果说你来加写锁的话,state += 1,锁占用线程
    setExclusiveOwnerThread(current);
    return true;
}

2.读锁的上锁

state = 1<<16 + state

3.写锁的重入

c != 0,w == 0,c低16位是0,说明有人加了读锁,返回 false,如果你不是当前加锁的线程,返回 false。

同一时间只能有一个线程加写锁,如果线程1比如加了写锁,线程2也要加写锁,是不行的。

加写锁的是你自己。如果加写锁的人是你自己,说明你就是在可重入的加写锁,将 state += 1。

4.写锁失败的入队

线程1加了写锁,此时线程2来加写锁会被互斥,此时就会被卡住,阻塞,在队列里等待唤醒。入队逻辑跟重入锁是相同的。

锁降级中读锁的获取是否必要呢? 答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。

分布式锁

分布式锁不同于上面介绍的锁,之前我们所说的是单机的只是控制本实例内代码块执行的锁。而分布式锁是在分布式系统中的一种锁,它是用来控制整个分布式系统中资源竞争的问题。分布式锁常用的实现方式有两种,一种是基于 redis 来实现,一种是基于 zk 来实现。

具体如何实现可以根据我们系统的实际情况来,如果我们的系统现在只有 redis 外部依赖,那就基于 reids 实现即可,如果这个时候非要基于zk 来实现,只会增加系统的外部依赖,让系统更容易出现依赖问题的风险,提高了开发运维的成本。

基于 redis 来实现的话缺点就是如果 reids 挂了可能造成锁的丢失,因为可能刚加的锁 reids 还没来及刷盘,而zk不会出现这种问题,因为zk创建了临时节点,只要节点没有删除,重启zk后,锁还会在。

下面我们来简单的看一下 jedisAPI 的调用。

jedisClientUtil.set(lockKey.toString(),
        CLIENT_ID + Thread.currentThread().getId(), JedisClientUtil.SetPremise.NX,
        JedisClientUtil.ExpireType.Milliseconds, unit.toMillis(expiration))
jedisClientUtil.eval(DELETE_SCRIPT, 1, lockKey.toString(), CLIENT_ID + Thread.currentThread().getId())
  
  private static final String DELETE_SCRIPT = 
  "if redis.call('get',KEYS[1]) == ARGV[1] then\n" +
  "  return redis.call('del',KEYS[1])\n" +
  "else\n" +
  "  return 0\n" +
  "end";

使用分布式锁要注意几个问题:锁一定要有超时时间,使用完毕或者系统出现异常一定要释放锁,锁可不可以允许其他线程的释放。这几个问题很关键,如果不注意很可能造成严重的后果。

基于 reids 市面上还有一种开源的封装好的分布式锁的实现,就是 redisson。redission 分布式锁自动续期,是在超时时间 1/3 的时候,会触发锁检查,发现线程ID未解锁,则触发续锁操作。续锁会创建 redission 自己实现的 TimerTask,然后放到时间轮中触发,触发延迟 1500ms。时间轮相当于一个倒计时的秒表,时间轮的格子数,每个格子代表的时间间隔(秒表倒计时指针多久走一格)都可以设置,每个格子内的任务有个队列缓存,初始长度是1024。然后当倒计时指针走到当前格子时,格子内的任务,当 round 轮数是0的时候触发,如果 round 轮数>0则减1;然后倒计时指针继续走下一个格子。当倒计时指针走到最后一个格子的时候,复位到第一个格子(格子虽然是个列表,但是这种行为看起来像个环)。因为采用了时间轮,只有一个倒计时主线程,所以不会太费性能。

锁的主题就介绍到这里,从最开始的 Synchronized 关键字,到 JDK 并发包中封装的读写锁,再到分布式系统中的分布式锁,我们基本将开发中所涉及到的锁的类型,原理,使用都给大家介绍了一遍,笔者水平有限也不常分享技术文章,文章中难免有些纰漏,如有错误欢迎大家指正,谢谢大家。

推荐阅读

一次搜索性能的排查过程和优化效果

kubernetes scheduler 源码解析及自定义资源调度算法实践

初识 JVM(带你从不同的视角认识 JVM) mysql快照读原理实现

招贤纳士

政采云技术团队(Zero),一个富有激情、创造力和执行力的团队,Base 在风景如画的杭州。团队现有300多名研发小伙伴,既有来自阿里、华为、网易的“老”兵,也有来自浙大、中科大、杭电等校的新人。团队在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注

image.png