0%

AQS-锁

AQS锁

ReentrantLock

ReentrantLock的使用

1
2
3
4
5
6
7
8
9
10
11
//创建锁:使用Lock对象声明,使用ReentrantLock接口创建
private final static Lock lock = new ReentrantLock();
//使用锁:在需要被加锁的方法中使用
private static void add() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}

源码分析:

1
2
3
4
5
6
7
8
9
//初始化方面:
//在new ReentrantLock的时候默认给了一个不公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
//也可以加参数来初始化指定使用公平锁还是不公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}

功能:

  • tryLock():仅在调用时锁定未被另一个线程保持的情况下才获取锁定。
  • tryLock(long timeout, TimeUnit unit):如果锁定在给定的时间内没有被另一个线程保持且当前线程没有被中断,则获取这个锁定。
  • lockInterruptbily:如果当前线程没有被中断的话,那么就获取锁定。如果中断了就抛出异常。
  • isLocked:查询此锁定是否由任意线程保持
  • isHeldByCurrentThread:查询当前线程是否保持锁定状态。
  • isFair:判断是不是公平锁
  • ……

与synchronized的区别

  • 可重入性:两者的锁都是可重入的,差别不大,有线程进入锁,计数器自增1,等下降为0时才可以释放锁
  • 锁的实现:synchronized是基于JVM实现,ReentrantLock是JDK实现的。
  • 性能区别:在最初的时候,二者的性能差别差很多,当synchronized引入了偏向锁、轻量级锁(自旋锁)后,二者的性能差别不大,官方推荐synchronized(优化时其实是借用了ReentrantLock的CAS技术,试图在用户态就把问题解决,避免进入内核态造成线程阻塞)
  • 功能区别:
    • 便利性:synchronized更便利,它是由编译器保证加锁与释放。ReentrantLock是需要手动释放锁,所以为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。
    • 锁的细粒度和灵活度,ReentrantLock优于synchronized

ReentrantLock独有的功能

  • 可以指定是公平锁还是非公平锁,sync只能是非公平锁。

    公平锁就是先等待的线程先获得锁

  • 提供了一个Condition类,可以分组唤醒需要唤醒的线程,而synchronized要么随机唤醒一个线程,要么全部唤醒。

  • 通过lock.lockInterruptibly()提供能够中断等待锁的线程的功能

  • ReentrantLock是一种自旋锁,通过循环调用CAS操作来实现加锁。由于避免了进入内核态的阻塞状态,性能比较好。

是否弃用synchronized?

从上边的介绍,看上去ReentrantLock不仅拥有synchronized的所有功能,而且有一些功能synchronized无法实现的特性。性能方面,ReentrantLock也不比synchronized差,那么到底我们要不要放弃使用synchronized呢?答案是不要这样做

J.U.C包中的锁类是高级用户的工具,除非说你对Lock的高级特性有特别清楚的了解以及有明确的需求,或有明确的证据表明同步已经成为可伸缩性的瓶颈的时候,否则我们还是继续使用synchronized。相比较这些高级的锁定类,synchronized还是有一些优势的,比如synchronized不需要手动释放锁。还有当JVM使用synchronized管理锁定请求和释放时,JVM在生成线程转储时能够包括锁定信息,这些信息对调试非常有价值,它们可以标识死锁以及其他异常行为的来源。

如何选择锁?

  • 当只有少量竞争者,使用synchronized
  • 竞争者不少但是线程增长的趋势是能预估的,使用ReetrantLock

ReentrantReadWriteLock

ReentrantReadWriteLock(读写锁)允许多个线程在没有写入时同时读取,只允许一个线程写入。如果一直存在读操作,那么写锁一直在等待没有读的情况出现,这样我的写锁就永远也获取不到,就会造成等待获取写锁的线程饥饿。平时使用的场景并不多,平时使用的场景并不多。

StempedLock

要进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock。改进之处在于:读的过程中也允许获取写锁后写入,这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入。

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
public class Point {
private final StampedLock stampedLock = new StampedLock();

private double x;
private double y;

public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}

public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}

和ReadWriteLock相比,写入的加锁是完全一样的,不同的是读取。注意到首先我们通过tryOptimisticRead()获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过validate()去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。

可见,StampedLock把读锁细分为乐观读和悲观读,能进一步提升并发效率。但这也是有代价的:一是代码更加复杂,二是StampedLock是不可重入锁,不能在一个线程中反复获取同一个锁。

乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行