Java实习生面试复习(七):synchronized和ReentrantLock的学习

2020/11/19 更新部分内容

如果你觉得内容对你有帮助的话,不如给个赞,鼓励一下更新😂。

synchronized 和 ReentrantLock 也是高频的面试问题,我们这篇文章就来深入学习一下。

Synchronized 和 ReentrantLock它们有什么区别?

Synchronized属于独占式悲观锁,是通过JVM 层面实现的。 synchronized 只允许同一时刻只有一个线程操作资源。在Java中每个对象都隐式包含一个monitor (监视器)对象,加锁的过程其实就是竞争monitor的过程,当线程进入字节码monitorenter指令之后,线程将持有monitor对象,执行monitorexit时释放monitor对象,当其他线程没有拿到monitor对象时,则需要阻塞等待获取该对象。
ReentrantLock是Lock的默认实现方式之一,它是基于AQS (Abstract Queued Synchronizer,队列同步器)实现的。 它默认是通过非公平锁实现的,在它的内部有一个state的状态字段用于表示锁是否被占用,如果是0则表示锁未被占用,此时线程就可以把state改为1,并成功获得锁,其他未获得锁的线程只能去排队等待获取锁资源。
Synchronized和ReentrantLock都具备斥性和不可见性。 它们的区别如下:

  • synchronized 是 JVM 层面实现的,而 ReentrantLock 是 基于Java 语言实现的 API。
  • ReentrantLock 可设为公平锁,而 synchronized 却不行。
  • ReentrantLock 只能修饰代码块,而 synchronized 可以用于修饰方法、修饰代码块等。
  • ReentrantLock 需要手动加锁和释放锁,如果忘记释放锁,则会造成资源被永久占用,而 synchronized 无需手动释放锁。
  • ReentrantLock 可以知道是否成功获得了锁,而 synchronized 却不行。
  • ReentrantLock 可以被中断,所以也被称为可中断锁。

但在JDK 1.6以前synchronized的性能低于ReentrantLock,Jdk1.6以后Synchronized的优化:锁膨胀机制:无锁 => 偏向锁 => 轻量级锁 => 重量级锁

公平锁VS非公平锁

公平锁的含义是线程需要按照请求的顺序来获得锁;而非公平锁则允许“插队”的情况存在,所谓的“插队”指的是,线程在发送请求的同时该锁的状态恰好变成了可用,那么此线程就可以跳过队列中所有排队的线程直接拥有该锁。
而公平锁由于有挂起和恢复所以存在一定的开销,因此性能不如非公平锁,所以 ReentrantLocksynchronized 默认都是非公平锁的实现方式。

ReentrantLock的源码解读

lock实现(公平锁)

这里我们主要分析一下公平锁的实现,我们都知道Reentrantlock分为非公平锁和公平锁,通过new ReentrantLock(true);可以变成公平锁。我们主要看当前主流版本JDK1.8时候的lock的实现,ReentrantLock中的lock()是通过sync.lock()实现的,但Sync类中的lock()是一个抽象方法,需要子类NonfairSync或FairSync去实现:

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

FairSync(公平锁机制下)中的 lock() 源码如下:

final void lock() {    acquire(1);}

NonfairSync 中的 lock() 源码如下:

final void lock(){
	if (compareAndSetState(01))
		//将当前线程设置为此锁的持有者
		setExc lusive0wnerThread (Thread. currentThread());
	else
		acquire(1) ;
}

可以看出非公平锁比公平锁只是多了一行compareAndSetState方法,该方法是尝试将state值由0置换为1,如果设置成功的话,则说明当前没有其他线程持有该锁,不用再去排队了,可直接占用该锁,否则,则需要通过acquire 方法去排队。

下图在线浏览链接:https://www.processon.com/view/link/5e3102efe4b05b335ff6b35d

public final void acquire(int arg) {
    // 尝试获得锁
    if (!tryAcquire(arg) &&
        // 获得锁失败则将当前线程变成Node节点add进等待队列
        // Node.EXCLUSIVE互斥模式、Node.SHARED共享模式
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

tryAcquire 方法尝试获取锁,如果获取锁失败,则把它加入到阻塞队列中,来看 tryAcquire 的源码:

protected final boolean tryAcquire(int acquires) {
    // 获得当前线程
    final Thread current = Thread.currentThread();
    // 取出锁状态
    int c = getState();
    // 为0代表还无线程占用
    if (c == 0) {
        // hasQueuedPredecessors判断等待队列是否初始化,简单来说就是判断当前线程是否是队列中的第一个线程
        if (!hasQueuedPredecessors() &&
            // CAS,将状态+1
            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;
}

hasQueuedPredecessors源码:

case 1:当前等待队列未初始化,如果为未初始化则head和tail都为null,那么h != t肯定不成立

case 2:当前等待队列中等于1,则也直接h != t不成立

case 3:当前等待队列中大于1,则&&后的判断,先判断是否还有下一个节点,然后判断当前线程是否和队列中第一个排队的节点的thread相等,不相等则代表有比当前线程更早的获取锁的线程在等待,因为公平锁需要先来后到的执行。返回true,不会进入if。

public final boolean hasQueuedPredecessors() {
    Node t = tail;
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

如果获取锁失败,则调用addWaiter方法把线程包装成Node对象,同时放入到队列中,但addWaiter方法并不会尝试获取锁,acquireQueued方法才会尝试获取锁,如果获取失败,则此节点会被挂起,acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) 源码如下:

addWaiter将自己入队,看源码实现:

private Node addWaiter(Node mode) {
    // 将当前线程以指定的模式创建节点node
    Node node = new Node(Thread.currentThread(), mode);
    // 获取当前等待队列的尾节点
    Node pred = tail;
    // 队列不为空,将新的node加入等待队列中
    if (pred != null) {
        node.prev = pred;
         // CAS方式将当前节点尾插入队列中
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 当队列为empty或者CAS失败时会调用enq方法处理
    enq(node);
    return node;
}

其中,队列为empty,使用enq(node)处理,将当前节点插入等待队列,如果队列为空,则初始化当前队列。所有操作都是CAS自旋的方式进行,直到成功加入队尾为止。
private Node enq(final Node node) {
    // 不断自旋
    for (;;) {
        Node t = tail;
        // 当前队列为empty
        if (t == null) {
            // 完成队列初始化操作,头结点中不放数据,只是作为起始标记,lazy-load,在第一次用的时候new
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            // 不断将当前节点使用CAS尾插入队列中直到成功为止
            // compareAndSetTail(t, node) 判断尾部节点是不是t,是的话就将尾部指向node
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

acquireQueued用于已在队列中的线程以独占且不间断模式获取state状态,直到获取锁后返回。主要流程:

  • 结点node进入队列尾部后,检查状态;
  • 调用park()进入waiting状态,等待unpark()或interrupt()唤醒;
  • 被唤醒后,是否获取到锁。如果获取到,head指向当前结点,并返回从入队到获取锁的整个过程中是否被中断过;如果没获取到,继续流程1

Lock类对于锁的实现不会令线程进入阻塞状态,Lock底层调用LockSupport.park()方法,使线程进入的是等待状态。

acquireQueued(addWaiter(Node.EXCLUSIVE), arg))的实现:

final boolean acquireQueued(final Node node, int arg) {
  // 是否已获取锁的标志,默认为true 即为尚未
  boolean failed = true;
  try {
      // 等待中是否被中断过的标记
      boolean interrupted = false;
      for (;;) {
          // 获取当前节点的前节点
          final Node p = node.predecessor();
          // 如果前节点是头结点,则意味着自己是第一个排队的节点,尝试获取锁
          if (p == head && tryAcquire(arg)) {
              // 获得锁成功后将当前节点设置为头结点
              setHead(node);
              p.next = null; // help GC
              failed = false;
              return interrupted;
          }
          // shouldParkAfterFailedAcquire翻译过来“在获取锁失败之后应该等待”
          // shouldParkAfterFailedAcquire根据对当前节点的前一个节点的状态进行判断,对当前节点做出不同的操作
          // parkAndCheckInterrupt让线程进入等待状态,并检查当前线程是否被可以被中断
          if (shouldParkAfterFailedAcquire(p, node) &&
              parkAndCheckInterrupt())
              interrupted = true;
      }
  } finally {
      // 将当前节点设置为取消状态(Node.CANCELLED),为1
      if (failed)
          cancelAcquire(node);
  }
}


private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    // waitStatus默认为0
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        return true;
    if (ws > 0) {
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 使用CAS将前一个节点状态由 INITIAL 设置成 SIGNAL(这里修改之后会自旋再次尝试获得锁)
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

会使用 for(;;) 无限循环的方式来尝试获取锁,若获取失败,则调用 shouldParkAfterFailedAcquire 方法,尝试挂起当前线程。
shouldParkAfterFailedAcquire 方法

  • 主要逻辑是使用compareAndSetWaitStatus(pred, ws, Node.SIGNAL)使用CAS将前一个节点状态由 INITIAL 设置成 SIGNAL,表示当前线程处于等待状态。

    为什么要设置前一个线程的状态为SIGNAL而不是自己呢? 我是这么理解的

    打个比方,你自己在房间睡着的时候知道自己睡着了吗?当然是不知道,那么你睡着了能自己关门嘛?不能,那你关了门万一没睡呢?所以就需要别人来帮你把门关上。

    你也可以理解为修改状态的操作和使线程睡眠的操作不具有原子性,可能出现修改完状态之后却没睡着的情况。

  • shouldParkAfterFailedAcquire方法会在该方法体中反复重试,直至compareAndSetWaitStatus 设置节点状态位为 SIGNAL 时 shouldParkAfterFailedAcquire 返回 true 时才会执行方法 parkAndCheckInterrupt 方法。

parkAndCheckInterrupt 该方法的关键是会调用 LookSupport.park 方法,该方法是用来当前线程。

waitStatus的各个值都是什么意思:

静态变量描述
Node.CANCELLED1节点对应的线程已经被取消了(我们后边详细会说线程如何被取消)
Node.SIGNAL-1表示后边的节点对应的线程处于等待状态
Node.CONDITION-2表示节点在等待队列中(稍后会详细说什么是等待队列)
Node.PROPAGATE-3表示下一次共享式同步状态获取将被无条件的传播下去(稍后再说共享式同步状态的获取与释放时详细唠叨)
0初始状态

interrupt(),interrupted() 和 isInterrupted() 的区别

interrupt():将调用该方法的对象所表示的线程标记一个停止标记,并不是真的停止该线程。

interrupted():获取当前线程的中断状态,并且会清除线程的状态标记。是一个是静态方法。

isInterrupted():获取调用该方法的对象所表示的线程的中断状态,不会清除线程的状态标记。是一个实例方法。

简单的中断案例:t1先执行,但是sleep不释放锁资源,在这期间t2等候两秒钟还没拿到锁就中断

public class Test {
    static ReentrantLock lock = new ReentrantLock();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            testsync();
        },"t1");
        Thread t2 = new Thread(() -> {
            testsync();
        },"t2");
        t1.start();
        TimeUnit.SECONDS.sleep(2);
        t2.start();
        TimeUnit.SECONDS.sleep(2);
        System.out.println("main");
        /**
         * 如果t2两秒钟还拿不到就中断
         */
        t2.interrupt();
    }
    public static void testsync(){
        try {
            /**
             * lockInterruptibly 和 lock的区别,前者会直接抛出异常可以响应中断,后者则不可以
             */
            lock.lockInterruptibly();
            System.out.println(Thread.currentThread().getName());
            TimeUnit.SECONDS.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

lock和lockInterruptibly的区别就在如下:

public final void acquireInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    if (!tryAcquire(arg))
        doAcquireInterruptibly(arg);
}

重点在doAcquireInterruptibly方法
private void doAcquireInterruptibly(int arg)
    throws InterruptedException {
    final Node node = addWaiter(Node.EXCLUSIVE);
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                return;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                // 区别在这
                throw new InterruptedException();
        }
    } catch (Throwable t) {
        cancelAcquire(node);
        throw t;
    }
}

acquireQueued() 基本相同,唯一的区别是对中断信号的处理。

acquireQueued() 被中断后,将中断标志传给外界,外界再调用Thread的interrupt() 复现中断;而doAcquireInterruptibly() 则直接抛出InterruptedException。

二者本质上没什么不同。但**doAcquireInterruptibly()**显示抛出了InterruptedException,调用者必须处理或继续上抛该异常。


unlock实现

这里我们看release的实现,sync是reentrantlock的一个内部抽象类,继承了AbstractQueuedSynchronizer

reentrantlock的公平锁FairSync和非公平锁NonfairSync都实现了sync

public void unlock() {
    sync.release(1);
}
public final boolean release(int arg) {
    // 尝试释放锁
    if (tryRelease(arg)) {
        Node h = head;
        // 当waitStatus不为0时,它会唤醒等待队列里的其他线程来获取资源。
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

protected final boolean tryRelease(int releases) {
    // 将状态值减1(因为是可重入锁,所以释放锁的次数和加锁的次数要一一对应)
    int c = getState() - releases;
    // 判断当前线程是否是持有锁的线程
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    // 是否成功释放锁标志,默认为尚未
    boolean free = false;
    // 当状态值为0就会进行解锁,清空锁持有线程。
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    // 设置可重入次数为原始值0
    setState(c);
    return free;
}

我们继续看unparkSuccessor():

private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    // 如果waitStatus为-1,即表示后边的节点对应的线程处于等待状态
    if (ws < 0)
        // 将waitStatus改为0
        compareAndSetWaitStatus(node, ws, 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);
}

这里当unpark唤醒下一个线程的时候,我们之前阻塞的线程就会在acquireQueued这个地方被唤醒,且继续执行

// parkAndCheckInterrupt 这里被唤醒后会获取当前线程的中断状态,并且会清除线程的状态标记。
// 如果没有被中断就if不成立,重新进行for循环
if (shouldParkAfterFailedAcquire(p, node) &&
    parkAndCheckInterrupt())      


for (;;) {
          // 获取当前节点的前节点
          final Node p = node.predecessor();
          // 如果前节点是头结点,则意味着自己是第一个排队的节点,尝试获取锁
          if (p == head && tryAcquire(arg)) {
              // 成功则将当前节点设置为头结点
              setHead(node);
              p.next = null; // help GC
              failed = false;
              // 最后返回false
              return interrupted;
          }
          if (shouldParkAfterFailedAcquire(p, node) &&
              parkAndCheckInterrupt())
              interrupted = true;
      }

synchronized的锁升级

锁升级其实就是从偏向锁到轻量级锁再到重量级锁升级的过程,也称之为锁膨胀。

  • 偏向锁是指在无竞争的情况下设置的一种锁状态。偏向锁的意思是它会偏向于第一个获取它的线程,也即当没有竞争出现时,默认会使用偏向锁。JVM 会利用 CAS 操作(compare and swap),在对象头上的 Mark Word 部分设置线程 ID,这种情况下,如果是持有偏向锁的线程每次在进入的话,不再进行任何同步操作。
  • 当另一个线程尝试获取此锁的时候,偏向锁模式会结束,然后切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁,否则,则说明此锁已经被其他线程占用了。
  • 当两个以上的线程争抢此锁时,轻量级锁就膨胀为重量级锁,这就是锁升级的过程。

对于上述锁概念不熟悉的可以看看这篇文章:求求你不要再问我重量级锁、自旋锁、轻量级锁、偏向锁、悲观、乐观锁等各种锁 ---- 不看后悔系列

看完这篇文章应该收获不少吧,至少lock锁方面还是可以勉强应付的了,不妨问问自己以下问题你答的上了吗?

  • synchronize和lock有啥区别?
  • ReentrantLock 的具体实现细节是什么?
  • JDK 1.6 时锁做了哪些优化?(自适应自旋锁,锁升级)
  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Linn-cn

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值