关于程序员:Synchronized-和-ReentrantLock-的实现原理是什么它们有什么区别

8次阅读

共计 7114 个字符,预计需要花费 18 分钟才能阅读完成。

synchronized 和 ReentrantLock 是如何实现的?它们有什么区别?

在 JDK 1.5 之前共享对象的协调机制只有 synchronizedvolatile,在 JDK 1.5 中减少了新的机制 ReentrantLock,该机制的诞生并不是为了代替 synchronized,而是在 synchronized 不实用的状况下,提供一种能够抉择的高级性能。

典型答复

synchronized 属于独占式乐观锁,是通过 JVM 隐式实现的,synchronized 只容许同一时刻只有一个线程操作资源。

在 Java 中每个对象都隐式蕴含一个 monitor(监视器)对象,加锁的过程其实就是竞争 monitor 的过程,当线程进入字节码 monitorenter 指令之后,线程将持有 monitor 对象,执行 monitorexit 时开释 monitor 对象,当其余线程没有拿到 monitor 对象时,则须要阻塞期待获取该对象。

ReentrantLock 是 Lock 的默认实现形式之一,它是基于 AQS(Abstract Queued Synchronizer,队列同步器)实现的,它默认是通过非偏心锁实现的,在它的外部有一个 state 的状态字段用于示意锁是否被占用,如果是 0 则示意锁未被占用,此时线程就能够把 state 改为 1,并胜利取得锁,而其余未取得锁的线程只能去排队期待获取锁资源。

synchronizedReentrantLock 都提供了锁的性能,具备互斥性和不可见性。在 JDK 1.5 中 synchronized 的性能远远低于 ReentrantLock,但在 JDK 1.6 之后 synchronized 的性能略低于 ReentrantLock,它的区别如下:

  1. synchronized 是 JVM 隐式实现的,而 ReentrantLock 是 Java 语言提供的 API;
  2. ReentrantLock 可设置为偏心锁,而 synchronized 却不行;
  3. ReentrantLock 只能润饰代码块,而 synchronized 能够用于润饰办法、润饰代码块等;
  4. ReentrantLock 须要手动加锁和开释锁,如果遗记开释锁,则会造成资源被永恒占用,而 synchronized 无需手动开释锁;
  5. ReentrantLock 能够晓得是否胜利取得了锁,而 synchronized 却不行。

考点剖析

synchronizedReentrantLock 是比线程池还要高频的面试问题,因为它蕴含了更多的知识点,且波及到的知识点更加深刻,对面试者的要求也更高,后面咱们简要地介绍了 synchronizedReentrantLock 的概念及执行原理,但很多大厂会更加深刻的诘问更多对于它们的实现细节,比方:

  • ReentrantLock 的具体实现细节是什么?
  • JDK 1.6 时锁做了哪些优化?

常识扩大

ReentrantLock 源码剖析

从源码登程来解密 ReentrantLock 的具体实现细节,首先来看 ReentrantLock 的两个构造函数:

public ReentrantLock() {sync = new NonfairSync(); // 非偏心锁
}
public ReentrantLock(boolean fair) {sync = fair ? new FairSync() : new NonfairSync();}

无参的构造函数创立了一个非偏心锁,用户也能够依据第二个构造函数,设置一个 boolean 类型的值,来决定是否应用偏心锁来实现线程的调度。

偏心锁 VS 非偏心锁

偏心锁的含意是线程须要依照申请的程序来取得锁;而非偏心锁则容许“插队”的状况存在,所谓的“插队”指的是,线程在发送申请的同时该锁的状态恰好变成了可用,那么此线程就能够跳过队列中所有排队的线程间接领有该锁。

而偏心锁因为有挂起和复原所以存在肯定的开销,因而性能不如非偏心锁,所以 ReentrantLocksynchronized 默认都是非偏心锁的实现形式。

ReentrantLock 是通过 lock() 来获取锁,并通过 unlock() 开释锁,应用代码如下:

Lock lock = new ReentrantLock();
try {
    // 加锁
    lock.lock();
    //...... 业务解决
} finally {
    // 开释锁
    lock.unlock();}

ReentrantLock 中的 lock() 是通过 sync.lock() 实现的,但 Sync 类中的 lock() 是一个形象办法,须要子类 NonfairSync 或 FairSync 去实现,NonfairSync 中的 lock() 源码如下:

final void lock() {if (compareAndSetState(0, 1))
        // 将以后线程设置为此锁的持有者
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

FairSync 中的 lock() 源码如下:

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

能够看出非偏心锁比偏心锁只是多了一行 compareAndSetState 办法,该办法是尝试将 state 值由 0 置换为 1,如果设置胜利的话,则阐明以后没有其余线程持有该锁,不必再去排队了,可间接占用该锁,否则,则须要通过 acquire 办法去排队。

acquire 源码如下:

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

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

protected final boolean tryAcquire(int acquires) {final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {// 偏心锁比非偏心锁多了一行代码 !hasQueuedPredecessors() 
        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); // set state=state+1
        return true;
    }
    return false;
}

对于此办法来说,偏心锁比非偏心锁只多一行代码 !hasQueuedPredecessors(),它用来查看队列中是否有比它等待时间更久的线程,如果没有,就尝试一下是否能获取到锁,如果获取胜利,则标记为曾经被占用。

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

/**
 * 队列中的线程尝试获取锁,失败则会被挂起
 */
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true; // 获取锁是否胜利的状态标识
    try {
        boolean interrupted = false; // 线程是否被中断
        for (;;) {
            // 获取前一个节点(前驱节点)final Node p = node.predecessor();
            // 以后节点为头节点的下一个节点时,有权尝试获取锁
            if (p == head && tryAcquire(arg)) {setHead(node); // 获取胜利,将以后节点设置为 head 节点
                p.next = null; // 原 head 节点出队,期待被 GC
                failed = false; // 获取胜利
                return interrupted;
            }
            // 判断获取锁失败后是否能够挂起
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                // 线程若被中断,返回 true
                interrupted = true;
        }
    } finally {if (failed)
            cancelAcquire(node);
    }
}

该办法会应用 for(;;) 有限循环的形式来尝试获取锁,若获取失败,则调用 shouldParkAfterFailedAcquire 办法,尝试挂起以后线程,源码如下:

/**
 * 判断线程是否能够被挂起
 */
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    // 取得前驱节点的状态
    int ws = pred.waitStatus;
    // 前驱节点的状态为 SIGNAL,以后线程能够被挂起(阻塞)if (ws == Node.SIGNAL)
        return true;
    if (ws > 0) { 
        do {
        // 若前驱节点状态为 CANCELLED,那就始终往前找,直到找到一个失常期待的状态为止
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        // 并将以后节点排在它后边
        pred.next = node;
    } else {
        // 把前驱节点的状态批改为 SIGNAL
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

线程入列被挂起的前提条件是,前驱节点的状态为 SIGNAL,SIGNAL 状态的含意是后继节点处于期待状态,以后节点开释锁后将会唤醒后继节点。所以在下面这段代码中,会先判断前驱节点的状态,如果为 SIGNAL,则以后线程能够被挂起并返回 true;如果前驱节点的状态 >0,则示意前驱节点勾销了,这时候须要始终往前找,直到找到最近一个失常期待的前驱节点,而后把它作为本人的前驱节点;如果前驱节点失常(未勾销),则批改前驱节点状态为 SIGNAL。

到这里整个加锁的流程就曾经走完了,最初的状况是,没有拿到锁的线程会在队列中被挂起,直到领有锁的线程开释锁之后,才会去唤醒其余的线程去获取锁资源,整个运行流程如下图所示:

unlock 相比于 lock 来说就简略很多了,源码如下:

public void unlock() {sync.release(1);
}
public final boolean release(int arg) {
    // 尝试开释锁
    if (tryRelease(arg)) {
        // 开释胜利
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

锁的开释流程为,先调用 tryRelease 办法尝试开释锁,如果开释胜利,则查看头结点的状态是否为 SIGNAL,如果是,则唤醒头结点的下个节点关联的线程;如果开释锁失败,则返回 false。

tryRelease 源码如下:

/**
 * 尝试开释以后线程占有的锁
 */
protected final boolean tryRelease(int releases) {int c = getState() - releases; // 开释锁后的状态,0 示意开释锁胜利
    // 如果领有锁的线程不是以后线程的话抛出异样
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) { // 锁被胜利开释
        free = true;
        setExclusiveOwnerThread(null); // 清空独占线程
    }
    setState(c); // 更新 state 值,0 示意为开释锁胜利
    return free;
}

tryRelease 办法中,会先判断以后的线程是不是占用锁的线程,如果不是的话,则会抛出异样;如果是的话,则先计算锁的状态值 getState() - releases 是否为 0,如果为 0,则示意能够失常的开释锁,而后清空独占的线程,最初会更新锁的状态并返回执行后果。

JDK 1.6 锁优化

自适应自旋锁

JDK 1.5 在降级为 JDK 1.6 时,HotSpot 虚拟机团队在锁的优化高低了很大功夫,比方实现了自适应式自旋锁、锁降级等。

JDK 1.6 引入了自适应式自旋锁意味着自旋的工夫不再是固定的工夫了,比方在同一个锁对象上,如果通过自旋期待胜利获取了锁,那么虚拟机就会认为,它下一次很有可能也会胜利 (通过自旋获取到锁),因而容许自旋期待的工夫会绝对的比拟长,而当某个锁通过自旋很少胜利取得过锁,那么当前在获取该锁时,可能会间接疏忽掉自旋的过程,以避免浪费 CPU 的资源,这就是自适应自旋锁的性能。

锁降级

锁降级其实就是从偏差锁到轻量级锁再到重量级锁降级的过程,这是 JDK 1.6 提供的优化性能,也称之为锁收缩。

偏差锁是指在无竞争的状况下设置的一种锁状态。偏差锁的意思是它会偏差于第一个获取它的线程,当锁对象第一次被获取到之后,会在此对象头中设置标示为“01”,示意偏差锁的模式,并且在对象头中记录此线程的 ID,这种状况下,如果是持有偏差锁的线程每次在进入的话,不再进行任何同步操作,如 LockingUnlocking 等,直到另一个线程尝试获取此锁的时候,偏差锁模式才会完结,偏差锁能够进步带有同步但无竞争的程序性能。但如果在少数锁总会被不同的线程拜访时,偏差锁模式就比拟多余了,此时能够通过 -XX:-UseBiasedLocking 来禁用偏差锁以进步性能。

轻量锁是绝对于分量锁而言的,在 JDK 1.6 之前,synchronized 是通过操作系统的互斥量(mutex lock)来实现的,这种实现形式须要在用户态和外围态之间做转换,有很大的性能耗费,这种传统实现锁的形式被称之为分量锁。

而轻量锁是通过比拟并替换(CAS,Compare and Swap)来实现的,它比照的是线程和对象的 Mark Word(对象头中的一个区域),如果更新胜利则示意以后线程胜利领有此锁;如果失败,虚构机会先查看对象的 Mark Word 是否指向以后线程的栈帧,如果是,则阐明以后线程曾经领有此锁,否则,则阐明此锁曾经被其余线程占用了。当两个以上的线程争抢此锁时,轻量级锁就收缩为重量级锁,这就是锁降级的过程,也是 JDK 1.6 锁优化的内容。

总结

本文首先讲了 synchronizedReentrantLock 的实现过程,而后讲了 synchronizedReentrantLock 的区别,最初通过源码的形式讲了 ReentrantLock 加锁和解锁的执行流程。接着又讲了 JDK 1.6 中的锁优化,包含自适应式自旋锁的实现过程,以及 synchronized 的三种锁状态和锁降级的执行流程。

synchronized 刚开始为偏差锁,随着锁竞争越来越强烈,会降级为轻量级锁和重量级锁。如果大多数锁被不同的线程所争抢就不倡议应用偏差锁了。


浏览更多精彩文章,请拜访 https://javaessay.cn

本文由 mdnice 多平台公布

正文完
 0