锁机制

锁机制

一. 什么是锁

锁是用来控制多个线程访问共享资源的方式。

Java提供了多种锁的实现方式,如synchronized关键字,Lock相关实现类等。1.5版本前Java靠synchronized关键字来实现锁功能,1.5后并发包提供了Lock接口和相关实现类。


二. volatile

参考:volatile


三. synchronized

synchronized是嵌入Java语言的锁机制,synchronized修饰方法时,必须有对象锁才可以调用该方法。

3.1 synchronized的条件变量

对比Lock的条件对象,可以通过Object.wait()notifyAll() 来代替。

1
2
3
sufficientFunds.await(); -> wait();
sufficientFunds.signalAll(); -> notifyAll();
sufficientFunds.signal(); -> notify();

3.2 内部锁

每个对象都有一个内部锁,每个锁都有一个内部条件,锁来管理想要调用方法的线程,条件来管理调用wait()的线程。

内部锁有其局限:

  • 不能中断一个正在试图获得锁的线程
  • 试图获取锁时不能设置超时
  • 每个锁只有一个条件,可能是不够的

3.3 更多内容

所以在实际使用时,Java在java.util.concurrent包中提供了不同的机制来处理所有的加锁,即阻塞队列,相关详细内容在《阻塞队列》,《对象流和序列化》,《NIO》这些博客中整理。

当程序比较简单,并且快速开发时,可以尽量使用synchronized,因为代码非常简洁,且易于实现。

更多内容参考:《synchronized


四. Lock接口

更多内容参考:《Lock接口


五. 队列同步器

更多内容参考:《同步器AQS的实现原理


六. 重入锁-ReentrantLock

更多内容参考:《可重入的独占锁ReentrantLock的实现原理


七. 读写锁-ReentrantReadWriteLock

更多内容参考:《读写锁ReentrantReadWriteLock的实现原理


八. LockSupport工具

更多内容参考:《LockSupport工具


九. Condition接口

更多内容参考:《Condition接口及实现原理


十. 同步阻塞

synchronized除了修饰方法外,还可以修饰代码块,即同步阻塞。

1
2
3
4
5
6
7
8
9
10
11
private Object lock = new Object();
public void transfer(int from, int to, double amount) throws InterruptedException
{
synchronized (lock){
accounts[from] -= amount;
System.out.print(Thread.currentThread());
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
}
}

线程调用方法,获得lock的锁,但一般不会这样去实现。


十一. final

若共享域被声明为final时,也可以线程安全的读取此域,因为线程会在构造函数结束后才能访问到此域,当然如果此域存储有其他对象的引用,或者是集合,对于其内的操作无法保证线程安全。


十二. 乐观锁与悲观锁

  • 悲观锁:总是假设最坏的情况。认为每次取数据的时候都会被修改,所以每次在拿数据的时候都会上锁。

  • 乐观锁:总是假设最好的情况。认为每次取数据的时候都不会被修改,所以每次都不会上锁。通过版本号机制和CAS算法实现,乐观锁适用于多读的应用类型,这样可以提高吞吐量。

12.1 应用

  • 悲观锁:数据库中行锁,表锁等,读锁,写锁等,以及 Java中synchronizedReentrantLock 等独占锁。

  • 乐观锁:数据库如MVCC多版本并发控制机制,以及Java中 java.util.concurrent.atomic 包中的递增操作就通过CAS自旋实现的。

12.2 使用场景

  • 悲观锁:多写少读。

  • 乐观锁:多读少写。

12.3 实现方式


十三. 独占锁(排他锁)与共享锁

13.1 介绍

  • 独占锁(排他锁):每次只能有一个线程能持有锁。当锁被头节点获取后,只有头节点获取锁,其余节点的线程继续沉睡,等待锁被释放后,才会唤醒下一个节点的线程。

  • 共享锁:允许多个线程同时获取锁。只要头节点获取锁成功,就在唤醒自身节点对应的线程的同时,继续唤醒AQS队列中的下一个节点的线程,每个节点在唤醒自身的同时还会唤醒下一个节点对应的线程,以实现共享状态的“向后传播”,从而实现共享功能。

ReentrantLock基于aqs实现,基本原理是aqs的status为0时表示锁被占用,为1时表示锁被释放。ReentrantLock就是独占锁,独占锁又分为公平锁和非公平锁。

ReadWriteLock属于独占锁,ReadWriteLock 里面的读锁不是排他锁,它允许多线程同时持有读锁,这是共享锁。共享锁和排他锁是通过 Node 类里面的 nextWaiter 字段区分的。

具体可以参考《可重入的独占锁ReentrantLock的实现原理》,《读写锁ReentrantReadWriteLock的实现原理》。

十四. 公平锁和非公平锁

  • 公平锁:保证按照获取锁的顺序来得到锁。

  • 非公平锁:可以进行抢占。

公平锁运行过程:

  1. 调用aqs的lock方法尝试获取锁。
  2. 调用上层组件reentrantlock的trylock方法尝试获取同步状态。
  3. 如果获取成功,则成功获取锁,如果获取失败,则被构造成node节点后,利用cas线程安全的加到同步对列的末尾。
  4. 然后该线程进入自旋状态 ,自旋时首先判断前驱节点是否为头节点并且能否成功获取到同步状态。
  5. 如果都成立,则成功获取锁,如果不成立,则先讲将其前驱节点等待状态设置为signal,然后利用Locksupport挂起,等待前驱线程唤醒当被前驱节点唤醒,且成功回去同步状态后,才成功获取到了锁。对于释放锁,就是通过aqs设置同步状态为1的过程,同时唤醒后继节点。

独占锁获取锁时,设置节点模式为Node.EXCLUSIVE

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

共享锁获取锁,节点模式则为Node.SHARED

1
2
3
4
5
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
.....
}

非公平锁与公平锁的唯一区别是,在获取锁时,不管是否有线程在等待锁,直接通过aqs修改同步状态,进行锁抢占,如果抢占失败,那后面的流程就与公平锁一致了。

共享锁的基本流程与独占锁相同,主要区别在于判断锁获取的条件上,由于是共享锁,也就允许多个线程同时获取,所以同步状态的数量同时的大于1的,如果同步状态为非0,则线程就可以获取锁,只有当同步状态为0时,才说明共享数量的锁已经被全部获取,其余线程只能等待。共享锁的释放过程正好与之相反,释放锁对应的AQS操作时增加同步状态的值。


十五. 读锁和写锁

读写锁分为两个锁对象 ReadLock 和 WriteLock,这两个锁对象共享同一个AQS。

AQS 的锁计数变量 state 将分为两个部分,前 16bit 为共享锁 ReadLock 计数,后 16bit 为互斥锁 WriteLock 计数。互斥锁记录的是当前写锁重入的次数,共享锁记录的是所有当前持有共享读锁的线程重入总次数。

读写锁同样也需要考虑公平锁和非公平锁。共享锁和互斥锁的公平锁策略和 ReentrantLock 一样,就是看看当前还有没有其它线程在排队,自己会乖乖排到队尾。非公平锁策略不一样,它会比较偏向于给写锁提供更多的机会。如果当前 AQS 队列里有任何读写请求的线程在排队,那么写锁可以直接去争抢,但是如果队头是写锁请求,那么读锁需要将机会让给写锁,去队尾排队。

毕竟读写锁适合读多写少的场合,对于偶尔出现一个写锁请求就应该得到更高的优先级去处理。

具体可以参考《读写锁ReentrantReadWriteLock的实现原理》。


十六. 自旋锁与适应性自旋锁

16.1 什么是自旋锁

锁的开销之一即线程切换,在一些场景中同步资源的锁定时间很短,为了这一点时间就去切换、挂起线程以及恢复线程有些得不偿失。自旋锁即使当前线程自旋一段时间尝试获取锁,在此期间线程不放弃CPU时间片,推迟一段时间后仍未获取锁再阻塞

16.2 自适应是指什么

JDK 1.4.2 时引入自旋锁,通过参数 -XX:+UseSpinning 开启。JDK 6 后默认开启,并且引入了自适应的自旋锁:适应性自旋锁

自适应是指自旋时间/次数不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

在自旋锁中 另有三种常见的锁形式: TicketLockCLHlockMCSlock ,这里不作扩展。


十七. 死锁

即线程之间互相等待,导致程序瘫痪。

死锁产生的四个必要条件:

  1. 互斥条件:资源同时只能被一个线程占有,线程间互斥等待。
  2. 不可剥夺条件:线程获得资源后不能被夺走,只能主动释放。
  3. 请求和保持条件:线程请求的资源被其他线程占据,此线程被阻塞但不会释放已有的资源。
  4. 循环等待条件:存在线程间的循环等待链路,前一个线程请求资源被下个线程持有。

处理死锁的方法:

  1. 避免一个线程同时获得多个锁
  2. 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
  3. 尝试使用定时锁,使用 lock.tryLock(timeout) 来代替内部锁机制
  4. 对于数据库锁,加锁和解锁必须在同一个数据库连接里,避免解锁失败情况

Q&A

锁Q&A


参考:

🔗 《Java核心技术 卷Ⅰ》

🔗 《Java并发编程的艺术》

🔗 《不可不说的Java“锁”事

🔗 《漫画|Linux 并发、竞态、互斥锁、自旋锁、信号量都是什么鬼?