Java-ReentrantLock原理及解析

一般的锁都是配合synchronized使用的,实际上在java.util.concurrent.locks还提供了其他几个锁的实现,拥有更加强大的功能和更好的性能。

锁的分类

可重入锁

可重入锁:任意线程在获取该锁后能够再次获取锁时不会被阻塞。

当前线程恶可以反复加锁,但必须释放同样多次数的锁,否则会导致锁不会释放。可以避免死锁

原理

通过组合自定义同步器(AQS)实现锁的获取与释放

  • 再次进行lock(),需要判断当前是否为已获得锁的线程,如果是,计数+1
  • 执行unlock(),计数-1

在释放锁后,如果计数不为0,就会导致程序卡死。

分类

  • synchronized修饰的方法或代码块
  • ReentrantLock

公平锁与非公平锁

公平锁

多个线程按照申请锁的先后顺序获取锁。内部持有一个等待队列,按照FIFO取出线程获取锁。

实现:ReentrantLock(true)

非公平锁

多个线程不是按照申请锁的先后顺序去获取锁。

非公平锁的性能高于公平锁,但是可能发生线程饥饿(某个线程长时间无法获得锁)

这里需要注意:**公平并不等于整体效率更高。**公平锁减少了插队,线程获取锁的顺序更稳定、更可预期,但也会增加排队等待和上下文切换成本;非公平锁虽然可能让某些线程等待更久,却往往能得到更高的整体吞吐量,这也是ReentrantLock默认采用非公平模式的原因。

实现:synchronizedReentrantLock(false)默认非公平

读写锁和排他锁

读写锁

同一时刻允许多个读线程访问。分为了读锁写锁读锁允许多个线程获取读锁,访问同一个资源;写锁只允许一个线程获取写锁,不允许同时访问同一资源。

在读多写少的情况下,大大提高了性能。

即使用读写锁,在写线程访问时,所有读线程和其他写线程都会被阻塞。

实现:ReentrantReadWhiteLock

排他锁

同一时刻只允许一个线程访问

实现:ReentrantLocksynchronized

死锁

两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象,若无外力作用,他们都将无法推进下去。

死锁形成必须要求四个条件:

  • 互斥条件:一个资源每次只能被一个线程使用

  • 请求与保持条件:一个线程引请求资源而阻塞时,对已获得的资源保持不放

  • 不剥夺条件:线程已获得的资源,在未使用完之前,不能强行剥夺

  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系

Lock接口

在Java中锁是用来控制多个线程访问共享资源的方式。在Java SE5.0之后新增Lock接口。提供了与synchronized关键字类似的同步功能,只是在使用时需要显式的获取和释放锁,缺点就是无法像synchronized可以隐式的释放锁,但是可以自由操作获取锁和释放锁。

synchronized的不足之处

  • 如果只是只读操作,应该多线程一起执行会更好,但是synchronized同一时间只能一个线程执行
  • synchronized无法知道线程是否获取锁,而且无法主动进行释放锁
  • 使用synchronized获取锁后,如果发生阻塞,就会导致所有线程等待锁释放

提供方法

lock()-获取锁

执行时,如果锁处于空闲状态,当前线程获得锁。如果锁已被其他线程持有,将禁用当前线程,直到该线程获取锁。

不会响应中断,直到获取锁成功才会进行响应。

lockInterruptibly()-获取锁,响应中断

获取锁时,优先响应中断,而不是先去进行获取。

tryLock()-非阻塞获取锁

非阻塞获取锁,立即返回获取锁结果,true-成功,false-失败

tryLock(time,unit)-指定时间获取锁

指定时间获取锁,会响应中断

  • time内获取锁立即返回true
  • time内线程中断会立即返回获取锁结果
  • time时间结束后,立即返回获取锁结果

把这几个获取锁方法放在一起看,会更容易理解它们分别解决什么等待策略问题:

  • lock():一直等待,直到真正拿到锁为止。
  • lockInterruptibly():也会等待,但等待过程中优先响应中断。
  • tryLock():完全不等待,立即返回是否拿到锁。
  • tryLock(time, unit):在给定时间内尝试等待,超时就放弃。

unlock()-释放锁

当前线程释放持有锁,锁只能由持有者释放,如果并未持有锁,执行解锁方法,就会抛出异常

newCondition()-获取锁条件

返回该锁的Condition实例,实现多线程通信。该组件会与当前锁绑定,当前线程只有获取了锁,才能调用组件的await()方法,调用后,线程释放锁。

如果把它和synchronized体系做类比,会更好理解:

  • Condition.await()类似于Object.wait()
  • Condition.signal()类似于Object.notify()
  • Condition.signalAll()类似于Object.notifyAll()

不同点在于:Object监视器天然只有一组等待队列,而ReentrantLock可以通过多次调用newCondition()拆出多个条件队列,让不同等待原因的线程分开管理。

进一步结合ReentrantLock来看,Condition.await()并不是简单“睡一会儿”,而是会:

  • 先保存并释放当前线程持有的锁状态
  • 进入对应的条件队列等待
  • signal()后再转移回同步队列
  • 重新竞争锁成功后,才真正从await()返回

所以被signal()不等于线程立刻继续执行,重新拿到锁才是等待结束的真正标志。

ReentrantLock

一个可重入的互斥锁,具备一样的线程重入特性

ReentrantLock底层并不是自己从零维护完整的排队、唤醒和状态流转逻辑,而是建立在AQS(AbstractQueuedSynchronizer)之上。可以把它理解成:ReentrantLock负责定义“这是一把怎样的锁”,而AQS负责提供“线程如何排队、如何竞争、如何被唤醒”的通用同步框架。

在独占模式下,AQS里的state通常会被用来表示当前锁的持有状态和重入次数:第一次获取锁时把状态从0改成1,同一线程重入时继续累加,释放锁时再逐步减回去,直到恢复为0才表示真正释放完成。

如果继续往实现层看,ReentrantLock内部通常会拆成两种同步器实现:

  • FairSync:公平锁实现
  • NonfairSync:非公平锁实现

它们都建立在同一套AQS独占同步骨架之上,真正的差异主要体现在“获取锁之前是否允许先抢一次”和“是否严格遵循队列顺序”。

非公平锁之所以吞吐量通常更高,一个关键原因就是:在进入同步队列之前,它往往会先做一次快速CAS抢锁。如果此时锁刚好空闲,当前线程可以直接拿到锁,而不必老老实实先排队;只有抢占失败后,才会进入AQS队列等待。

如果把tryAcquire()的主线抽出来看,逻辑通常可以概括成三步:

  • state == 0:说明当前没有线程持有锁,此时尝试CAS占有锁
  • 当前线程已经是持有者:走重入逻辑,让state继续累加
  • 其他线程持有锁:本次获取失败,进入后续排队等待逻辑

而释放锁时的tryRelease()主线则刚好对应回来:

  • 先检查当前线程是不是锁持有者
  • 是的话就把重入次数减1
  • 只有当state真正减到0时,才表示锁被彻底释放,可以去唤醒后继节点

这也是可重入锁最容易忽略的地方:unlock一次不一定真的释放锁,只有把重入层数全部退回去才算彻底释放。

特性

  • 尝试获得锁
  • 获取到锁的线程能够响应中断

读写锁

ReentrantLock是完全互斥排他的,这样其实效率不高

使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class ReenTrantLockTest implements Runnable {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();

public void test() {
try {
//获得同步锁
lock.lock();
System.err.println("获取锁" + System.currentTimeMillis());
condition.await();
System.err.println();
} catch (
InterruptedException e) {
e.printStackTrace();
} finally {
//释放同步锁
lock.unlock();
}
}

public static void main(String[] args) throws InterruptedException {
ReenTrantLockTest test = new ReenTrantLockTest();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.start();
t2.start();
t1.join();
t2.join();
System.err.println("结束");
}

@Override
public void run() {
test();
}
}

这里把unlock()放在finally里并不是一种书写习惯上的偏好,而是必须的保护措施。因为ReentrantLock不像synchronized那样在代码异常退出时由JVM自动释放锁;如果线程拿到锁后中途抛异常又没有执行unlock(),其他线程就可能一直阻塞在这把锁上。

相比synchronized增加了一些高级功能:

  • 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,去操作其他事情。

  • 公平锁多个线程在等待同一个锁时,必须按照申请锁的时间来依次获得锁。 synchronized是非公平锁,即在锁被释放时,任何一个等待锁的线程都有机会获得锁。这样就有可能会产生 饥饿现象(有些线程可能永远无法获得锁)ReenTrantLock默认非公平锁,在构造时修改参数即可变为公平锁。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    public class LockFairTest implements Runnable {
    //true为公平锁 false为非公平锁 默认false
    private static Lock lock = new ReentrantLock(true);
    AtomicInteger iii = new AtomicInteger(0);

    @Override
    public void run() {
    while (iii.get() < 20) {
    lock.lock();
    iii.getAndIncrement();
    try {
    System.err.println(Thread.currentThread().getName() + "获得锁");
    } finally {
    lock.unlock();
    }
    }
    }

    public static void main(String[] args) {
    LockFairTest test = new LockFairTest();

    Thread t1 = new Thread(test);
    Thread t2 = new Thread(test);
    Thread t3 = new Thread(test);
    Thread t4 = new Thread(test);

    t1.start();
    t2.start();
    t3.start();
    t4.start();
    }
    }
    输出结果:
    公平锁:
    Thread-0获得锁
    Thread-1获得锁
    Thread-2获得锁
    Thread-3获得锁
    非公平锁:
    Thread-2获得锁
    Thread-2获得锁
    Thread-2获得锁
    Thread-2获得锁
  • 锁绑定多个条件:一个ReenTrantLock对象可以通过多次调用newCondition()同时绑定多个Condition对象。在synchronized只能实现一个隐含的条件,要多关联只能额外添加锁。

    在实际使用时,signal()signalAll()也需要区分:

    • 一次条件变化只需要唤醒一个等待线程时,优先使用signal()
    • 条件变化可能影响整批线程,或者无法精确判断该唤醒哪个线程时,再考虑signalAll()

    如果每次都无脑使用signalAll(),往往会带来更多无效唤醒和额外锁竞争。

适用场景

ReentrantLock相比synchronized更适合下面这些场景:

  • 需要可中断地获取锁
  • 需要带超时地尝试获取锁
  • 需要多个条件队列表达复杂线程协作
  • 需要更明确地控制加锁、解锁时机

如果只是很简单的同步块保护,而且并不需要这些额外能力,那么synchronized往往已经足够,代码也会更直接。

常见误用

  • lock()之后忘记在finallyunlock()
  • 没有持有锁就调用Condition.await()/signal()
  • 误以为公平锁一定更先进、更高效
  • 在很简单的场景里过度使用ReentrantLock,徒增代码复杂度
  • 把“线程被signal唤醒”误解成“线程已经拿到锁并开始执行”

总结

  • Lock类可以实现线程同步,获得锁需要执行lock,释放锁使用unlock
  • Lock分为公平锁(按照顺序)和不公平锁(不按顺序)
  • Lock还有读锁和写锁。读读共享,写写互斥,读写互斥

自定义重入锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CustomReetrantLock {
boolean isLocked = false;
Thread lockedBy = null;
int lockedCount = 0;

public synchronized void lock() throws InterruptedException {
Thread callThread = Thread.currentThread();
while (isLocked && lockedBy != Thread.currentThread()) {
wait();
}
isLocked = true;
lockedCount++;
lockedBy = callThread;
}

public synchronized void unLock() {
if (Thread.currentThread() == this.lockedBy) {
lockedCount--;
if (lockedCount == 0) {
isLocked = false;
notify();
}
}
}
}


Java-ReentrantLock原理及解析
https://leo-wxy.github.io/2018/12/19/Java-ReentrantLock原理及解析/
作者
Leo-Wxy
发布于
2018年12月19日
许可协议