Java锁机制

JVM虚拟机内存结构

image-20220422110921388

​ JVM运行时内存结构主要包含了程序计数器JVM栈Native方法栈方法区。Java堆中存放的是所有对象,方法区中存放着类信息、常量、静态变量等数据。所以当多个线程在竞争其中一些数据时,有可能回发生难以预料的异常情况,因此需要锁机制对其进行限制。

image-20220422112057875

​ 在代码层面,每个Object都有一个锁,这个锁存放在对象头中,锁记录了当前对象被哪个线程锁占用。

image-20220422112716142

​ Java对象包含了三个部分:对象头实例数据填充字节。其中对齐填充字节时为了满足“Java对象大小必须是8的倍数”这一条件设计的。

image-20220422131827530

​ 其中Mark Word存储了很多和当前对象运行时状态有关的数据,比如说hashCode、锁状态标志、指向所记录的指针等,其中最重要的是锁标志位

synchronized同步机制

​ 以synchronized锁举一个例子,如下代码运行

public class TestSync{
    private int num = 0;
    public void test(){
        for(int i = 0; i < 10; i++){
            synchronized(this){
                System.out.println("thread:" + Thread.currentThread().getId() + ",num:" + num++);
            }
        }
    }
    
    public static void main(String[] args){
        TestSync sync = new TestSync();
        
        //创建两个线程
        Thread td1 = new Thread(() ->{
            sync.test();
        });
        Thread t2 = new Thread(() ->{
            sync.test();
        });
        
        //线程开始执行
        t1.start();
        t2.start();
    }
}

使用javac命令进行编译,再使用javap进行反编译可以得到,monitorenter和moniterexit对业务代码进行了包裹

image-20220422140546566

image-20220422134347799

​ 首先,Entry Set中聚集了一些想要进入monitor的线程,他们正处于waiting状态,

  • 假如线程A成功进入了monitor,那么他就处于active状态。
  • 假设线程A执行途中遇到一个判断条件,需要它暂时让出执行权,那么它将进入wait set,状态也会被标记成waiting,此时entry set中的线程就有机会进入monitor。
  • 假设一个线程B成功进入monitor,并且顺利完成任务,那么它可以通过notify的形式来唤醒wait set中的线程A,让线程A,让线程A再次进入monitor,执行完后A便可以退出。

synchronized可能存在性能问题,因为synchronized被编译之后实质上是monitorenter和monitorexit两个字节码指令,而monitor是依赖于操作系统的mutex lock来实现的,Java线程实际上是对操作系统线程的映射,所以每当挂起或唤醒一个线程都要切换操作系统内核态,这种操作是比较重量级的,在一些情况下可能回出现切换时间超出线程执行任务的时间。

锁的四种状态

但是,从Java6开始,sychronized进行了优化,引入了偏向锁、轻量级锁。需要注意的是锁只能升级,不能降级

​ 此观点被公认,但是,在JRockit JVM的《Understanding Locks》文章中,说明了锁是可以降级的,原文为:A thin lock can be inflated to a fat lock and a fat lock can bedeflated*to a thin lock. The JRockit JVM uses a complex set of heuristics to determine when to inflate a thin lock to a fat lock and when to deflate a fat lock to a thin lock.

​ 还有《JEP draft:Concurrent Monitor Deflation》中也解释到Hotspot JVM锁也是可以降级的,但是锁升降级效率低,如果频繁升降级的话对JVM性能会造成影响,原文为:In its current implementation, monitor deflation is performed during every STW pause, while all Java threads are waiting at a safepoint. We have seen safepoint cleanup stalls up to 200ms on monitor-heavy-applications。重量级锁降级发生于STW阶段,降级对象为仅仅能被VMThread访问而没有其他JavaThread访问的对象。

所以锁从低到高分为四种:

  • 无锁
  • 偏向锁
  • 轻量级锁
  • 重量级锁

无锁

顾名思义就是没有对资源进行锁定,所有线程都能访问到同一资源。可能会出现两种情况:

  • 无竞争:某个对象不会出现在多线程环境下,或者说即使出现在了多线程环境下也不会出现竞争的情况
  • 存在竞争,非锁方式:比如说多个线程竞争同一个资源,通过其他方式限制,同时只有一个线程能修改成功,而其他修改失败的线程将会不断重试,直到修改成功(CAS,在操作系统中通过一条指令来实现,所以它能保证原子性)

偏向锁

假如一个对象被加锁了,但在实际运行过程中,只有一个对象能拿到这个锁。我们希望对象能够认识这个锁,只要是这个线程过来,那么对象就直接把锁交出去,我们就可以认为这个锁偏爱这个线程,所以被成为偏向锁。

判断线程和锁之间的关系

​ 当锁标志位为01时,判断倒数第三个bit是否为1,如果为1则当前锁状态为偏向锁,于是再去读Mark Word的前23个bit,这23个bit的值就是线程ID,通过线程ID来确认当前想要获得对象锁的这个线程,是不是锁偏向的线程。

image-20220422143646932

​ 假如情况发生了变化,对象发现目前不止有一个线程,而是有多个线程在竞争锁,那么偏向锁将会升级成轻量级锁。

轻量级锁

在升级为轻量级锁之后,会将前30个bit升级为指向栈中锁记录的指针。

image-20220424160810904

​ 当一个线程想要获得猴哥对象的锁时,假如看到锁标志位位00,那么就知道它时轻量级锁。

线程和对象锁之间的绑定

​ 这时线程会在虚拟机栈中开辟一块被称位Lock Record的空间,这个空间存放的是对象头中的Mark Word的副本以及owner指针,线程通过CAS尝试去获取锁,一旦获得那么将会复制该对象头中的Mark Word到Lock Record中,并且将Lock Record中的owner指针指向该对象。另一方面,对象的Mark Word的前30个bit将会生成一个指针,指向线程虚拟机栈中的Lock Record。这样一来就实现了线程和对象锁之间的绑定,它们就互相知道了对象的存在,这时这个对象已经被锁定了,获取这个对象的线程就可以去执行一些任务。

​ 如果存在其他线程想要获取该对象,将会自旋等待。

  • 自旋:可以理解为一种轮询,线程自己在不断地循环,尝试着去看一下目标对象的锁有没有被释放,如果释放了那么就获取,如果没有释放,那么久进行下一轮循环。(区别于被操作系统挂起阻塞,因为如果对象的锁很快就被释放的话,自旋就不需要进行系统中断和现场恢复。自旋≈CPU空转,如果长时间自旋将会浪费CPU资源==》于是出现了适应性自旋的优化
  • 适应性自旋:自旋的时间不再固定,而是由上一次在同一个锁上的自旋时间以及锁状态两个条件来决定的。比如说,在同一个锁上,当前正在自旋等待的线程,刚刚已经成功获得过锁,但是锁目前是被其他线程占用,那么虚拟机就会认为此次自旋也很可能会成功,进而它将允许更长的自旋时间。

​ 假如此时有一个线程正在进行自旋,那么这个线程将会进入等待,如果同时有多个线程想要获得这个对象锁,也就是说一旦自旋等待的线程数超过1个,那么轻量级锁将会升级为重量级锁。

重量级锁

重量级锁则是需要通过monitor来对线程进行控制,此时将会完全锁定资源,对线程的管控最为严格。

重量级锁可以理解为是大致这么一个关系:

synchronized(Java代码) => Monitor(JVM底层C++) => mutex(内核维护)

乐观锁、悲观锁

互斥锁

image-20220424171757082

​ 互斥锁是悲观锁,简单来说就是操作系统将会悲观的认为,如果不严格同步线程,那么一定会产生异常,所以互斥锁将会锁定资源,只供一个线程调用,而阻塞其他线程。因此这种同步机制也叫悲观锁。

  • 大部分调用可能是read操作,没有必要再每次调用的时候都锁定资源
  • 在某些条件下,同步代码块执行的耗时远远小于线程切换的耗时

CAS

​ 当资源对象的状态值为0的一瞬间,A、B线程都读到了,此时这两条线程认为资源对象当前的状态值是0,于时它们将会各自产生两个值Old Value(代表之前读到的资源对象的状态值)、New Value(代表想要将资源对象的状态值更新后的值)。

image-20220424172424851

​ 假设A线程率先获得时间片,将old value与资源对象的状态值进行compare发现一致,于是将牌子上的值swap为new value。

image-20220424172606540

​ 而B线程读取到资源对象的状态值为1,compare之后发现与old value不一致,所以放弃swap操作。但是在实际应用中,我们一般会使其进行自旋(不断重试CAS操作),通常会配置自旋次数来防止死循环。

public int cas(long *addr, long oldValue, long newValue){
    if(*addr != oldValue){
        return 0;
    }
    *addr = newValue;
    return 1;
}

​ 上面这个CAS方法是一个简单的示例,但是这个方法存在漏洞:并没有进行任何的同步操作,说明线程是不安全的。所以compare和swap必须“被绑定”,换句话说,CAS必须是原子性的。

​ 在x86架构下,通过cmpxchg指令支持CAS,在ARM架构下,通过LL/SC来实现CAS。也就是说,不需要通过操作系统的同步原语(比如mutex),CPU已经原生地支持了CAS,上层进行调用即可,不再依赖锁来进行线程同步。

image-20220424202901961

​ 这些通过CAS来实现同步的工具,由于不会锁定资源,而且当线程需要修改共享资源的对象时,总是会乐观的认为,对象状态值没有被其他线程修改过,而是每次自己都会主动尝试去compare状态值,这种同步机制也被称为乐观锁。但其实它是一个无锁编程。

在Java中CAS的应用

需求:使用3条线程,将一个值,从0累加到1000

public class Main{
    static AtomicInteger num = new AtomicInteger(0);
    
    public static void main(String[] args){
        for(int i = 0; i < 3; i++){
            Thread t = new Thread(() -> {
               while(num.get() < 1000){
                   System.out.println("thread:" + Thread.currentThread().getId() + ",num:" + num.incrementAndGet());
               } 
            });
        }
    }
}

image-20220425154225982

AtomicInteger类型主要的成员变量就是一个Unsafe类型的实例和一个long类型的offset,使用unsafe的CAS操作来对值进行更新。

// setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

再看到incrementAndGet方法,可以看到直接调用了unsafe对象的getAndAddInt方法。

public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }

进一步查看getAndAddInt方法,可以看到调用了unsafe的compareAndSwapInt(CAS)方法,这里出现的循环就是之前提到的自旋。

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

假如这边CAS操作一直失败,那么会不会一直死循环下去?————自旋的次数可以通过启动参数来配置,如果不配置的话默认10,所以不会出现死循环。

 public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

可以看到compareAndSwapInt方法存在native修饰符,那么说明这时一个本地方法,和具体的平台实现相关,如果CPU是x86架构,那么这个本地方法将会调用系统的cmpxchag指令。在OpenJDK中找到具体的方法,可以看到

image-20220425155550935

底层是使用C++实现的,调用的汇编指命令,可以说明上层的无锁线程同步依赖的依然是底层的CAS操作。

Unsafe类

Unsafe类是一个不安全的、主要是用来执行一些底层的和平台相关的方法

image-20220425155005266

AQS

AQS也叫AbstractQueuedSynchronizer

在了解AQS之前,可以考虑一下如何设计一个同步管理框架

  • 通用性,下层实现透明的同步机制,同时与上层业务解耦
  • 利用CAS,原子地修改共享标记位
  • 等待队列

AQS成员变量

image-20220426154243920

  • state:判断共享是否正在被占用的标记位
    • volitile保证了线程之间的可见性
    • 为什么不是boolean————线程获取锁地两种模式:独占和共享(独占模式:一旦被占用,其他线程都不能占用;共享模式:一旦被占用,其他共享模式下地线程能占用),说明了在共享模式下,可能会有多个线程正在共享资源,所以state需要表示线程占用的数量,因此时int值

  • Node head、Node tail:如果一个线程没有在当前时刻获取到锁,那么会进入队列等待,这个队列是FIFO先进先出的双向链表

image-20220426155123518

一开始提到了两种状态:

  1. 尝试获取锁(修改标记位),立即返回—————tryAcquire
  2. 获取锁(修改标记位),愿意进入队列等待,直到获取————acquire

image-20220426155357676

​ tryAcquire只有一行实现代码,因为AQS需要继承类必须Override这个tryAcquire方法,否则就直接抛出不支持该操作的异常。

class Syncer extends AbstractQueuedSynchronizer{
    @Override
    protected boolean tryAcquire(int arg){
        //上层业务逻辑,比如
        if (arg != 1){
            return false;
        }
        if (getState() == 1){
            return false;
        }
        return compareAndSetState(0, 1);
    }
}

​ 上层可以Override这个方法自由编写业务逻辑,在上层调用tryAcquire成功时则获得锁,此时可以对相应的共享资源进行操作。使用完之后再进行释放,如果获取锁失败,上层业务不想等待锁,那么可以直接进行相应业务的处理,如果选择等待锁,那么就可以直接调用acquire方法。

image-20220426160145168

​ 可以看到,这个方法不允许继承类Override这个方法,意思是一定能够得到锁。

​ if判断包含了两个部分,如果tryAcquire获得锁,则跳出判断条件,不用再执行后续的判断条件以及selfInterrupt方法,假如tryAcquire返回false,那么就执行acquireQueued方法,进而排队等待锁。而acquired和addWaiter方法是嵌套的。

image-20220426161448568

addWaiter方法作用就是将当前线程封装成node加入等待队列,返回值为当前的节点。

Node node = new Node(Thread.currentThread(), mode);

首先创建了一个Node对象,由于队列是先进先出的,所以将新建的节点出入到队尾。

Node pred = tail;
if (pred != null){
    node.prev = pred;
    if (compareAndSetTail(pred, node)){
        pred.next = node;
        return node;
    }
    enq(node);
    return node;
}

​ 首先获取尾节点的指针,将其作为当前节点的前置节点,如果尾节点不为空,那么通过CAS操作将当前节点置为尾节点,然后再将前置节点的指针指向已经成为尾节点的当前指针。

​ 但是可能出现的问题是:不能保证if代码块李的内容也是原子执行的,也就是说,当但钱线程正在执行if代码块的内容时,其他线程很可能正在修改尾节点。

pred.next = node;

但是,这行代码知识将前置节点的next指针,指向了当前节点,即使这时候尾节点发生了变动,对这个操作其实也是没什么影响的。而如果尾节点为空或者第一次尝试CAS操作失败,那么将会进入完整的入队方法。

image-20220426162614100

​ 在完整的队列方法里,会对当前队列进行初始化并自旋的通过CAS将当前节点插入,直到入队成功为止。

image-20220426163028417

​ 可以发现,这两个方法有重复的代码,一样的操作,知识完整入队(enq方法)里多了一个判空初始化的操作。

Try the fast path of enq; backup to full enq on failure

先尝试进行快速入队,如果失败再进行完整的入队。

image-20220426163510991

​ 这个方法配合release方法,是对线程进行挂起和响应,以此来实现队列的先进先出。首先定义了一个局部变量failed,初始值为true,只有再return之前,failed值会改为false,而在finally块中,通过判断failed来进行cancelAcquire操作。

​ 基本可以判断,在acquireQueued方法正常执行并且return时,failed最终值永远是false,只有执行抛出了异常,然后进入finally块中,才会进行cancelAcquire操作。cancelAcquire方法简单来说,就是将Node的waitStatus置为CANCEL,以及其他的一些清理工作。

for (;;){
    final Node p = node.predecessor();
    if (p == head && tryAcquire(arg)){
        setHead(node);
        p.next = null; // help GC
        failed = false;
        return interrupted;
    }
    
    ....
}

​ 如果当前节点的前置节点是头节点,且当前线程尝试获取锁成功了,那么就已经达到目的,直接返回就好。(这里需要说明:在AQS的FIFO队列中,头节点只是一个虚节点,虚节点的意思就是,头节点并不是当前需要去拿锁的节点,只是充当一个占位的摆设,而第二个节点才是需要那锁的节点,当第二个节点拿到锁之后,它就会变成头节点,头节点就会出队)。

​ 能够直接进入if块并直接return的情况是比较少的,当前节点的前置节点很可能不是头节点,或者说是头节点但是尝试获取锁失败了,那么这时候就要进入下一个判断了。

if (shouldParkAfterFailedAcquire(p, node) &&
   	parkAndCheckeInterrupt()){
    	interrupted = true;
	}

​ 判断当前线程是否需要挂起(如果大量CPU自旋等待,那么一定会出现性能问题)。

  • Java中断:作用于线程对象,它并不会直接促成该线程挂起,而是会根据Thread当前的活动状态来产生不同的效果。加入当前线程是处于等待状态(WAITING),那么对该线程interrupt将会使其抛出中断异常;如果当前线程处于运行状态(RUNNABLE),对该线程interrupt,只会改变这个Thread对象中的一个中断状态值,并不会影响该线程继续运行。

    /**
    * 线程a运行时被中断,任然保持运行
    * 输出:
    * 1
    * 中断a线程
    * 1
    * 1
    */
    Thread a = new Thread(() -> {
        while (true){
            System.out.println("1");
        }
    });
    a.start();
    a.interrupt();
    System.out.println("中断a线程");
    
    /**
    * 线程b处于等待状态时被中断,
    * 抛出IlleggalMonitorStateException异常
    */
    Thread b = new Thread(() -> {
         while (true){
            System.out.println("1");
        }
    });
    try{
        b.wait();
    } catch (Exception e){
        e.printStackTrace();
        return;
    }
    b.interrupt();
    

判断是否需要挂起当前线程:

image-20220426172829366

  • 如果当前节点的前置节点为SIGNAL,那说明前置节点也在等待拿锁,直接返回true;

  • 如果waitStatus大于0,那么说明状态只可能使CANCEL,所以可以将其从队列中删除;

  • 如果waitStatus是其他状态,那么前置节点就应该做好准备来等待锁,所以通过CAS将前置节点的waitStatus志位SIGNAL,这两种情况下返回false,进行下一轮的判断。

​ 如果shouldParkAfterFailedAcquire返回true,那么代表当前节点需要被挂起,则执行真正的挂起。

image-20220426173547184

​ 这样就能够保证head之后只有一个节点在通过CAS来获取锁,队列里面其他线程都已经被挂起或正在被挂起,这样就能最大限度地避免无用的自旋消耗CPU。

image-20220426173754162

​ 而这么多被挂起的线程什么时候唤醒呢?————在一个线程使用完了共享资源,释放锁的时候,去唤醒其他正在等待锁的线程。

image-20220426174033788

​ 和tryAcquire一样,tryRelease也是AQS开放给上层业务自由实现的抽象方法,虽然没有使用abstract修饰,但是作用是差不多的,唯一的不同是:加入继承类上层业务没有去Override这个tryRelease方法,那么将会直接抛出异常。

if (tryRelease(arg)){
    Node h = head;
    if (h != null && h.waitStatus != 0){
        unparkSuccessor(h);
        return true;
    }
    return false;
}

​ 假如尝试释放锁成功,那么下一步,就要唤醒等待队列里的其他节点。

image-20220428153627229

​ 这里的Head节点其实就是acquireQueued方法中的幸运儿,它获得了锁,获得了操作共享资源的权限,并且被置为head,到这里需要release的时候,它的使命其实已经完成了,这时候head只是作为一个占位的虚节点,所以需要首先将它的waitStatus置为0这个默认值,才不会影响其他函数的判断。

Node s = node.next;
if (s == null || s.waitStatus > 0){
    s = null;
    for (Node t = tail; t != null && t != node; t = t.prev){
        if (t.waitStatus <= 0){
            s = t;
        }
    }
}
if (s != null){
    LockSupport.unpark(s.thread);
}

​ 然后程序将会从FIFO的队列的尾节点开始搜索,找到除了head节点之外一个最靠前的(非head),并且waitStatus<0的节点,并对其进行LockSupport.unpark(s.thread)操作。

​ 即唤醒该挂起的线程。之前挂起的某个线程,一旦被唤醒,那么它将会继续执行aquireQueued方法,进行自旋尝试获取锁。这时候便形成一个能够良好工作的闭环。

为什么唤醒操作不直接从头开始搜索,而是从后往前搜索?

  • 多线程情况下,若同步代码块执行时间较短,release方法执行之前,可能存在第二个节点的线程再doAquire方法中没有park时,已经CAS拿到了锁,自己成为了头节点,切断了与原head的联系

image-20220428154440954

image-20220428155034106

​ 在acquireQueued的parkAndCheckInterrupt这个方法里面,若当前线程被LockSupport.park(this)挂起,在此期间很有可能在AQS外的其他操作想要中断这个线程,调用了这个线程的interrupt方法(为什么没有抛出中断异常?假如一个线程是通过wait、sleep等方法进行挂起,那么再调用interrupt方法,将会直接抛出异常,而使用原语LockSupport.park方法挂起,即使在调用interrupt方法也是不会抛出异常的)。

​ 此时如果其他的某个地方调用了该线程的interrupt方法,只会改变这个线程对象内部的一个中断的状态值,所以我们需要一个变量把这个值记录下来。如果外部调用了这个线程的interrupt,那么当该线程被唤醒的时候,将会走到return语句中,此时的返回值时true。这样就会走到外层的interrupt = true,当acquireQueued方法执行完毕时,就会将这个中断信息带到外层方法,就会走到selfInterrupt方法,在selfInterrupt里面,执行了Thread.currentThread().interrupt使其中断。简单来说就是当线程处于等待队列中时,无法响应外部的中断请求。只有这个线程拿到锁之后,再进行中断响应。

posted @ 2022-05-30 10:56  江亭夕望  阅读(367)  评论(0)    收藏  举报