7.JUC之五-Lock
1.基础
1.1.锁的分类
1.1.1.公平锁 / 非公平锁
- 公平锁:先判断是否有排序任务,有就排到队尾;
- 非公平锁:先抢锁,不成功了再排队尾。
默认都是非公平锁
- 优点:非公平锁的吞吐量高,因为后来的线程尚未阻塞,不用唤醒,可能比阻塞的线程更有机会获得锁。
- 缺点:可能造成优先级反转、饥饿现象等。
对于ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。
公平锁与非公平锁有啥区别?在reentrantLock的实现有啥不同,释放锁有啥不同?
https://www.jianshu.com/p/cfb7eb66cbf0
1.1.2.可重入锁/ 不可重入锁
使用 ReentrantLock获取锁的时候会判断当前线程是否为获取锁的线程,如果是则将同步的状态 +1,释放锁的时候则将状态 -1。 只有将同步状态的次数置为 0 的时候才会最终释放锁。不可重入锁,与可重入锁相反,不可递归调用,递归调用就发生死锁。
由于公平锁需要关心队列的情况,得按照队列里的先后顺序来获取锁(会造成大量的线程上下文切换),而非公平锁则没有这个限制。 所以也就能解释非公平锁的效率会被公平锁更高。
1.1.3.独享锁/共享锁
独享锁和共享锁在你去读C.U.T包下的ReentrantLock和ReentrantReadWriteLock你就会发现,它俩一个是独享一个是共享锁。
独享锁:该锁每一次只能被一个线程所持有。
共享锁:该锁可被多个线程共有,典型的就是ReentrantReadWriteLock里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占。
另外读锁的共享可保证并发读是非常高效的,但是读写和写写,写读都是互斥的。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
对于Synchronized而言,当然是独享锁。
1.1.4.互斥锁/读写锁
- 互斥锁
在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作。加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。
如果解锁时有一个以上的线程阻塞,那么所有该锁上的线程都被编程就绪状态,第一个变为就绪状态的线程又执行加锁操作, 那么其他的线程又会进入等待。 在这种方式下,只有一个线程能够访问被互斥锁保护的资源
- 读写锁
使用 ReentrantReadWriteLock,同时维护一对锁:读锁和写锁。当写线程访问时则其他所有锁都将阻塞,读线程访问时则不会。 通过读写锁的分离可以很大程度的提高并发量和吞吐量。
1.1.5.乐观锁 / 悲观锁
- 悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
- 乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
1.1.6.分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
并发容器类的加锁机制是基于粒度更小的分段锁,分段锁也是提升多并发程序性能的重要手段之一。
在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。
1.1.7.偏向锁/轻量级锁/重量级锁
锁的状态:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
锁的状态是通过对象监视器(monitor)在对象头中的字段来表明的。
四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。
这四种状态都不是Java语言中的锁,而是Jvm为了提高锁的获取与释放效率而做的优化(使用synchronized时)。
- 偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
- 轻量级
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
- 重量级锁
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
1.1.8.自旋锁
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名
- 自旋锁存在的问题
- 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
- 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在"线程饥饿"问题。
- 自旋锁的优点
- 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
- 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
1.2.分布式锁
1.2.1.基于数据库
可以创建一张表,将其中的某个字段设置为唯一索引,当多个请求过来的时候只有新建记录成功的请求才算获取到锁,当使用完毕删除这条记录的时候即释放锁。
存在的问题:
- 数据库单点问题,挂了怎么办?
- 不是重入锁,同一进程无法在释放锁之前再次获得锁,因为数据库中已经存在了一条记录了。
- 锁是非阻塞的,一旦 insert 失败则会立即返回,并不会进入阻塞队列只能下一次再次获取。
- 锁没有失效时间,如果那个进程解锁失败那就没有请求可以再次获取锁了。
解决方案:
- 数据库切换为主从,不存在单点。
- 在表中加入一个同步状态字段,每次获取锁的是加 1 ,释放锁的时候-1,当状态为 0 的时候就删除这条记录,即释放锁。
- 非阻塞的情况可以用 while 循环来实现,循环的时候记录时间,达到 X 秒记为超时,break。
- 可以开启一个定时任务每隔一段时间扫描找出多少 X 秒都没有被删除的记录,主动删除这条记录。
即使上面问题解决了,使用数据库任然存储性能差、阻塞时间长等缺点无法解决。
1.2.2.基于Redis
实现方式比较多
- 使用 set NX PX命令,只有 key 不存在的时候才执行,就相当于获取了锁。由于有超时时间,所以过了规定时间会自动删除,这样也可以避免死锁。
- 使用Redisson提供的分布式锁。
- Redis作者提供的redlock。多个集群加锁与解锁。
1.2.3.其它分布式锁
- 基于ZooKeeper:使用ZkClient,实现方式有很多,大多使用临时有序节点实现。除此之外还可以使用zk的高级客户端curator提供的分布式锁功能。
- 基于Consul:基于Consul的分布式锁主要利用Key/Value存储API中的acquire和release操作来实现
- 基于Etcd。
1.2.4.注意事项
误解、续期、超时、过期、重试、死锁、惊群、脑裂
https://blog.csdn.net/weixin_30955617/article/details/95156810
1.3.与synchronized对比
lock优点
- 可中断:等锁时能 "放弃"
- 可尝试:tryLock()
- 可超时:等多久自己定。
- 支持公平锁:按顺序拿锁。
2.LockSupport
使用Unsafe实现的锁线程进行加锁解锁的工具类,JDK中很多线程等待/唤醒,等待时间控制都使用这个实现的
比如: AQS、ScheduleExecutorsThreadPool等
对线程使用LockSupport控制后,线程的状态是wait或time_wait,不是blocked。只有synchronized是blocked的。
- park底层调用的和synchronized一样也是posix提供的功能。
3.ReentrantLock
3.1.ReentrantLock 实现原理
使用 synchronized 来做同步处理时,锁的获取和释放都是隐式的,实现的原理是通过编译后加上不同的机器指令来实现。 而 ReentrantLock 就是一个普通的类,它是基于 AQS(AbstractQueuedSynchronizer)来实现的。 是一个重入锁:一个线程获得了锁之后仍然可以反复的加锁,不会出现自己阻塞自己的情况。
3.2.锁类型
ReentrantLock 分为 公平锁 和 非公平锁,可以通过构造方法来指定具体类型:
//默认非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
//公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
默认一般使用非公平锁,它的效率和吞吐量都比公平锁高的多(后面会分析具体原因)。
3.2.1.获取锁
通常的使用方式如下:
private ReentrantLock lock = new ReentrantLock();
public void run() {
lock.lock();
try {
//do bussiness
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
3.2.1.1.公平锁
首先看下获取锁的过程:
public void lock() {
sync.lock();
}
可以看到是使用 sync 的方法,而这个方法是一个抽象方法,具体是由其子类(FairSync)来实现的,以下是公平锁的实现:
final void lock() {
acquire(1);
}
//AbstractQueuedSynchronizer 中的 acquire()
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
第一步是尝试获取锁(tryAcquire(arg)),这个也是由其子类实现:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
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);
return true;
}
return false;
}
首先会判断 AQS 中的 state 是否等于 0,0表示目前没有其他线程获得锁,当前线程就可以尝试获取锁。
注意:尝试之前会利用 hasQueuedPredecessors() 方法来判断 AQS的队列中中是否有其他线程,如果有则不会尝试获取锁(这是公平锁特有的情况)。
如果队列中没有线程就利用 CAS 来将 AQS 中的 state 修改为1,也就是获取锁,获取成功则将当前线程置为获得锁的独占线程(setExclusiveOwnerThread(current))。
如果 state 大于 0时,说明锁已经被获取了,则需要判断获取锁的线程是否为当前线程(ReentrantLock 支持重入),是则需要将 state + 1,并将值更新。
写入队列
如果 tryAcquire(arg) 获取锁失败,则需要用 addWaiter(Node.EXCLUSIVE)将当前线程写入队列中。
写入之前需要将当前线程包装为一个 Node 对象(addWaiter(Node.EXCLUSIVE))。
AQS 中的队列是由 Node 节点组成的双向链表实现的。
包装代码:
private Node addWaiter(Node mode) {
//根据给定的模式(独占或者共享)新建Node
Node node = new Node(Thread.currentThread(), mode);
//快速尝试添加尾节点
Node pred = tail;
if (pred != null) {
node.prev = pred;
//CAS设置尾节点
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//多次尝试
enq(node);
return node;
}
首先判断队列是否为空,不为空时则将封装好的 Node 利用 CAS 写入队尾,如果出现并发写入失败就需要调用 enq(node); 来写入了。
private Node enq(final Node node) {
//多次尝试,直到成功为止
for (;;) {
Node t = tail;
//tail不存在,设置为首节点
if (t == null) {
if (compareAndSetHead(new Node()))
tail = head;
} else {
//设置为尾节点
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
这个处理逻辑就相当于自旋加上 CAS 保证一定能写入队列。
挂起等待线程
写入队列之后需要将当前线程挂起(利用acquireQueued(addWaiter(Node.EXCLUSIVE),arg))
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)) {
/*将head指向该节点*/
setHead(node);
/* 方便GC回收垃圾 */
p.next = null;
failed = false;
/*返回等待过程中是否被中断过*/
return interrupted;
}
/*获取失败,线程就进入waiting状态,直到被unpark()*/
if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())
/*如果等待过程中被中断过一次,就标记为true*/
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;//拿到前驱节点的状态
if (ws == Node.SIGNAL)
//状态为SIGNAL,如果前驱节点处于等待状态,直接返回true
return true;
if (ws > 0) {
/*
* 如果前驱节点放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
* 注意:那些放弃的结点,由于被自己"加塞"到它们前边,它们相当于形成一个无引用链,稍后就会被GC回收
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
//如果前驱节点正常,那就把前驱的状态通过CAS的方式设置成SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
首先会根据 node.predecessor()获取到上一个节点是否为头节点,如果是则尝试获取一次锁,获取成功就万事大吉了。
如果不是头节点,或者获取锁失败,则会根据上一个节点的 waitStatus 状态来处理(shouldParkAfterFailedAcquire(p, node))。
waitStatus 用于记录当前节点的状态,如节点取消、节点等待等。
shouldParkAfterFailedAcquire(p, node)返回当前线程是否需要挂起,如果需要则调用 parkAndCheckInterrupt():
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
他是利用 LockSupport 的 part 方法来挂起当前线程的,直到被唤醒。
3.2.1.2.非公平锁
公平锁与非公平锁的差异主要在获取锁:
公平锁就相当于买票,后来的人需要排到队尾依次买票,不能插队。
而非公平锁则没有这些规则,是抢占模式,每来一个人不会去管队列如何,直接尝试获取锁。
非公平锁:
final void lock() {
//直接尝试获取锁
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
公平锁:
final void lock() {
acquire(1);
}
还要一个重要的区别是在尝试获取锁时tryAcquire(arg),非公平锁是不需要判断队列中是否还有其他线程,也是直接尝试获取锁:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//没有 !hasQueuedPredecessors() 判断
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
3.2.2.释放锁
公平锁和非公平锁的释放流程都是一样的:
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;
}
//尝试释放锁
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
首先会判断当前线程是否为获得锁的线程,由于是重入锁所以需要将 state 减到 0 才认为完全释放锁。
释放之后需要调用 unparkSuccessor(h) 来唤醒被挂起的线程。
3.3.其他实用的方法
boolean isLocked = lock.isLocked();//锁是否被上锁
boolean heldByCurrentThread = lock.isHeldByCurrentThread();//当前线程是否被上锁
boolean fair = lock.isFair();//是否是公平锁
int holdCount = lock.getHoldCount();//当前调用锁的线程的个数(就是当前调用lock()方法的个数)
boolean hasQueuedThreads = lock.hasQueuedThreads();//是否有线程在等待使用锁
boolean hasQueuedThread = lock.hasQueuedThread(Thread.currentThread());//某个线程是否在等待使用锁
4.Condition
用于多线程之间在等待某个条件达成的时候再去执行某些线程的场景。
@Slf4j
public class Demo_07 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
Condition condition = reentrantLock.newCondition();
new Thread(() -> {
try {
reentrantLock.lock();
log.info("wait signal"); // 1
condition.await();//进入等待状态,等待condition的信号
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("get signal"); // 4
reentrantLock.unlock();
}).start();
new Thread(() -> {
reentrantLock.lock();
log.info("get lock"); // 2
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
condition.signalAll();//通知所有的condition,可以运行了
log.info("send signal ~ "); // 3
reentrantLock.unlock();
}).start();
}
}
5.ReentrantReadWriteLock
读写锁,内部使用了一个读锁,一个写锁。读和写都能进行控制。其中想要获得写锁的时候,必须是读锁和写锁都没有人在使用的情况下。
这是一种悲观的锁。适合在读多写少的场景,但是可能出现写的饥饿情况,就是一直有线程在读取,写入一直在等待的情况。 实际开发中使用的场景较少
作用:
- 保证读锁内的数据是一致的。也就是在读锁生效期间,别的线程无法获得写锁。确保读锁内的数据不变。和数据库一样,防止脏读出现
- 提升锁的性能。读锁也是共享锁,支持多个线程同时持有,更像是一种标识。同步集合的读也加了同步性能低
6.StampedLock
JDK 1.8新增的锁,性能、稳定性很好的锁。功能很全面
实现原理
- https://blog.csdn.net/qq_37939251/article/details/83536984
- https://blog.csdn.net/LightOfMiracle/article/details/73223614
- 性能浅析:https://blog.csdn.net/pange1991/article/details/85096408
- Jdk bug: http://ifeve.com/stampedlock-bug-cpu/
常见面试题:stampedLock与readWriteLock的区别?
- readWriteLock是可重入锁,stampedLock不是
- stampedLock提供了乐观锁机制,所以在读多写少的场景,性能高10倍以上。使用起来比较复杂。
stampedLock实现原理
- 通过戳(stamp)来表示锁的状态,提供了写锁、悲观读锁和乐观读锁三种访问模式。
- 乐观读锁的引入使得在大多数情况下读操作不会阻塞写操作,从而提高了系统的并发性能。
- 不支持重入,并且在使用乐观读锁时需要手动验证数据的一致性。
- 本文作者:zhangxue
- 本文链接:https://lucksnow1989.github.io/article/3.多线程&高并发/7.JUC之五-Lock.html
- 版权声明:本文为本人原创文章,遵循 CC 4.0 BY-NC-SA版权协议,转载请附上原文出处链接和本声明。