前面孩子生病了,一直照顾孩子。然后自己又感冒了,嗓子难受的要死。今天终于好多了。
显式锁介绍
JDK5 引入了 Lock
接口,与内置加锁机制不同的是,Lock
提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显式的。
显式锁 Lock 接口
Lock
接口位于 java.util.concurrent.locks
包中,是 JUC 显式锁的一个抽象,主要抽象方法如下表。
方法 | 说明 |
---|---|
void lock() | 获取锁,成功则向下运行,失败则阻塞抢锁线程 |
void lockInterruptibly() throws InterruptedException | 可中断的获取锁,和lock()方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程 |
boolean tryLock() | 尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false |
boolean tryLock(long time, TimeUnit unit) throws InterruptedException | 限时获取锁,到达超时时间返回false,并且此限时抢锁方法也可以响应中断信息 |
void unlock() | 释放锁 |
Condition newCondition() | 获取与限时锁绑定的Condition对象,用于“等待-通知”方式的线程间通信 |
显式锁相比Java内置锁多了以下优势:
- 可中断获取锁。
- 可非阻塞获取锁。
- 可显示抢锁。
可重入锁ReentrantLock
ReentrantLock
是JUC包提供的显式锁的一个基础实现类,它是一个可重入的独占锁,具体含义是:
- 可重入的含义:表示该所能够会吃一个线程对资源的重复加锁,同一个线程可以多次进入同一个锁所同步的临界区代码块。比如,同一个线程在外层函数获得锁后,在内层函数能再次获取该所,甚至多次抢占同一把锁。
- 独占的含义:同一时刻只能有一个线程获取到锁,其它线程只能等待。
使用显式锁的模板代码
使用 lock() 方法抢锁的模板代码
lock方法进行阻塞式的锁抢占模板代码如下:
// 创建某个锁对象
Lock lock = new SomeLock();
lock.lock(); // 抢占锁
try {
// 抢锁成功,执行临界区代码
...
} finally {
lock.unlock(); // 释放锁
}
其中有几个注意点:
- 释放锁操作
lock.unlock()
必须在 try-catch 结构的 finally 块中执行,否则,如果临界区代码抛出异常,锁就有可能永远得不到释放。 - 抢占锁的操作
lock.lock()
必须在try语句块之外。一方面是因为lock()
方法没有声明抛出异常,所以可以不包含到try块中,另一方面是lock()
方法并不一定能够抢占锁成功,如果没有抢占成功,当然也不需要释放锁。 - 在抢占锁操作
lock.lock()
和 try 语句之间不要插入任何代码,避免抛出异常而导致释放锁操作lock.unlock()
执行不到,导致锁无法被释放。
调用 tryLock() 方法非阻塞抢锁的模板代码
tryLock()
是非阻塞抢占,在没有抢到锁的情况下,当前线程会立即返回,不会被阻塞。
模板代码大致如下:
// 创建某个锁对象
Lock lock = new SomeLock();
if(lock.tryLock()) { // 尝试抢占锁
try {
// 抢锁成功,执行临界区代码
...
} finally {
lock.unlock(); // 释放锁
}
} else {
// 抢锁失败,执行后备动作
...
}
调用 tryLock(long time, TimeUnit uint) 方法抢锁的模板代码
tryLock(long time, TimeUnit uint)
方法用于限时抢锁,该方法在抢锁时会进行一段时间的阻塞等待。
大致代码模板如下:
// 创建某个锁对象
Lock lock = new SomeLock();
if(lock.tryLock(1, TimeUnit.SECONDS)) { // 限时阻塞抢占
try {
// 抢锁成功,执行临界区代码
...
} finally {
lock.unlock(); // 释放锁
}
} else {
// 抢锁失败,执行后备动作
...
}
Condition 接口
基于Lock显式锁,JUC也为大家提供了一个用于线程间进行“等待-通知”方式通信的接口——java.util.concurrent.locks.Condition
Condition接口的主要方法如下:
public interface Condition {
// 方法1:等待。此方法在功能上与 Object.wait() 语义等效
// 使当前线程加入 await() 等待队列中,并释放当前锁
// 当其它线程调用 signal() 时,等待队列中的某个线程会被唤醒,重新去抢锁。
void await() throws InterruptedException;
// 方法2:通知。此方法在功能上与 Object.notofy() 语义等效
// 唤醒一个在 await() 等待队列中的线程
void signal();
// 方法3:通知全部。唤醒await()等待队列中所有的线程
// 此方法与object.notifyAll()语义上等效
void signalAll();
// 方法4:限时等待,此方法与await()语义等效
// 不同点在于,在指定时间time等待超时后,如果没有被唤醒,线程将中止等待
boolean await(long time, TimeUnit uint) throws InterruptedException;
}
Condition 对象是基于显式锁的,所以不能独立建立一个Condition对象,而是需要借助显式锁实例。
LockSupport
LockSupport
是JUC提供的一个线程阻塞与唤醒的工具类,该工具类可以让线程在任意位置阻塞和唤醒,其所有方法都是静态方法。
LockSupport 常用方法
常用方法如下:
// 无限期阻塞当前线程
public static void park();
// 唤醒某个被阻塞的线程
public static void unpark(Thread thread);
// 阻塞当前线程,有超时时间限制
public static void parkNanos(long nanos);
// 阻塞当前线程,知道某个时间
public static void parkUntil(long deadline);
// 无限期阻塞当前线程,带blocker对象,用于给诊断工具确定线程受阻塞的原因
public static void park(Object blocker);
// 获取被阻塞线程的blocker对象,用于分析阻塞的原因。
public static Object getBlocker(Thread t);
LockSupport.park() 和 Thread.sleep() 的区别
LockSupport.park()
和 Thread.sleep()
方法类似,都是让线程阻塞,二者区别如下:
-
Thread.sleep()
没法从外部唤醒,只能自己醒过来,而被LockSupport.park()
方法阻塞的线程可以通过调用
LockSupport.unpark()
方法给唤醒。 -
Thread.sleep()
方法声明了InterruptepException
中断异常,这是一个受检异常,调用者需要补货这个异常或者再抛出,而调用LockSupport.park()
方法不需要捕获中断异常。 -
二者对中断信号的响应方式不同,
Thread.sleep()
会抛出InterruptepException
中断异常。 -
LockSupport.park()
相比Thread.sleep()
能更精准、更加灵活地阻塞、唤醒指定的线程。 -
Thread.sleep()
本身是一个 Native 方法,LockSupport.park()
不是,他只是调用了一个Unsafe
类的 Native 方法去实现。 -
LockSupport.park()
方法还允许设置一个Blocker
对象,主要用来供监视工具或诊断工具确定线程受阻的原因。
LockSupport.park()与Object.wait()的区别
从功能上来说,LockSupport.park()
和 Object.wait()
方法也类似,都是让线程阻塞,二者的区别如下:
-
Object.wait()
方法需要在synchronized
块中执行,而LockSupport.park()
可以在任意地方执行。 -
当被阻塞线程中断时,
Object.wait()
方法抛出了中断异常,调用者需要捕获或者在抛出,LockSupport.park()
不会抛出异常。 -
如果线程在没有被
Object.wait()
阻塞之前被Object.notify()
唤醒,也就是说在Object.wait()
执行之前去执行Object.notify()
,就会抛出IllegalMonitorStateException
异常,是不被允许的。而线程在没有被LockSupport.park()
阻塞之前被LockSupport.unpark()
唤醒不会抛出异常,是被允许的。
显式锁的分类
从不同的角度来看,显式锁有以下几种分类:
-
可重入锁和不可重入锁,从同一个线程是否可以重复占有同一个锁对象的角度来分,显式锁可以分为可重入锁和不可重入锁。
-
悲观锁和乐观锁,从线程机内临界区前是否锁住同步资源的角度来分,显式锁可以分为悲观锁和乐观锁。
-
公平锁和非公平锁,公平锁是指不同的线程抢占锁的机会是公平的、平等的,供抢占时间上来说,先对锁进行抢占的线程一定先被满足。
-
可中断锁和不可中断锁,如果临界区代码被其他线程占有,本线程由于等待时间过长,不想等待,可以中断自己的阻塞等待,这就是可中断锁。
-
共享锁和独占锁,独占锁是指每次只有一个线程能持有的锁,共享锁允许多个线程同时获取锁。
悲观锁和乐观锁
独占锁就是一种悲观锁,Java的 synchronized
是悲观锁。悲观锁可以确保无论哪个线程持有锁,都能独占式访问临界区。
悲观锁存在的问题
悲观锁存在以下问题:
-
在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
-
一个线程持有锁或,会导致其他所有抢占此锁的线程挂起。
-
如果一个优先级高的线程等待一个优先级低的线程释放锁,就会导致线程的优先级倒置,从而引发性能风险。
解决这些问题的方法是用乐观锁代替悲观锁。例如数据库操作的带版本号更新,JUC包的原子类,都适用乐观锁。
乐观锁的操作包括两个步骤:
- 冲突检测。
- 数据更新。
JUC的CAS原子操作就体现了乐观锁思想,它可以分为两个步骤:
- 检测位置V的值是否为A。
- 如果是,就将位置V更新为B值,否则不要更改该位置。
一般情况下,需要进行自旋操作,即不断循环重试CAS操作直到成功,这也叫CAS自旋。
通过CAS自旋,在不使用锁的情况下实现多线程之间的变量同步,也就是说在没有现成被阻塞的情况下实现变量的同步,这叫做“非阻塞同步”(Non-Blocking Synchronization),使用基于CAS自旋的乐观锁进行同步控制,属于无锁编程(Lock Free)。
不可重入的自旋锁
自旋锁的基本含义为:当一个现成在获取锁的时候,如果锁已经被其他线程获取,调用者就一直在那里循环检查该锁是否已经释放,一直到获取到锁才会退出循环。
CAS自旋锁就是抢锁线程不断进行CAS自旋操作去更新锁的owner,更新成功就表明抢锁成功,不成功就一直循环。
下面给出一个简单版本的不可重入自旋锁:
public class SpinLock implements Lock {
/**
* 当前锁的拥有者,使用Thread作为同步状态
*/
private AtomicReference<Thread> owner = new AtomicReference<>();
/**
* 抢占锁
*/
@Override
public void lock() {
Thread t = Thread.currentThread();
// 自旋
while(owner.compareAndSet(null, t)) {
// Do Nothing
Thread.yield();
}
}
/**
* 释放锁
*/
@Override
public void unlock() {
Thread t = Thread.currentThread();
// 只有拥有者才能释放锁
if(t == owner.get()) {
owner.set(null);
}
}
...
}
这个锁是不支持冲入的,即当一个线程第一次已经获取了该锁,在锁没有被释放之前,如果又一次重新获取该锁,第二次将不能成功获取到。
可重入的自旋锁
为了实现可重入锁,引入一个计数器,用来记录一个线程获取锁的次数。
一个简单的可重入的自旋锁代码大致如下:
public class ReetrajtSpinLock implements Lock {
/**
* 当前锁的拥有者,使用Thread作为同步状态
*/
private AtomicReference<Thread> owner = new AtomicReference<>();
/**
* 记录一个线程重复获取锁的次数
* 此变量为同一个线程在操作,没有必要加上 volatile 保障可见性和有序性
*/
private int count = 0;
/**
* 抢占锁
*/
@Override
public void lock() {
Thread t = Thread.currentThread();
// 如果是重入,增加重入次数后返回
if(t == owner.get()) {
++count;
return;
}
// 自旋
while(owner.compareAndSet(null, t)) {
// Do Nothing
Thread.yield();
}
}
/**
* 释放锁
*/
@Override
public void unlock() {
Thread t = Thread.currentThread();
// 只有拥有者才能释放锁
if(t == owner.get()) {
if(count > 0) {
// 如果重入次数大于0,减少重入次数后返回
--count;
} else {
// 设置拥有者为空
owner.set(null);
}
}
}
...
}
自旋锁的特点:线程获取锁的时候,如果锁被其他线程持有,当前线程将循环等待,直到获取到锁,线程抢锁期间状态不会改变,一直是运行状态,在操作系统层面线程处于用户态。
自旋锁的问题:在争用激烈的场景下,如果某个线程持有锁的事件太长,就会导致其他空自旋的线程耗尽CPU资源。另外,如果大量的线程进行空自旋,还可能导致硬件层面的“总线风暴”。
CAS可能导致“总线风暴”
前面讲到,CPU会通过MESI协议保障变量的缓存一致性。不同的内核需要通过总线来回通信,所产生的流量一般被称为“缓存一致性流量”。因为总线被设计为固定的“通信能力”,如果缓存一致性流量过大,总线将成为瓶颈。这就是所谓的“总线风暴”。
使用 lock 前缀指令的Java操作(包括CAS、volatile)恰恰会产生缓存一致性流量。
在竞争激烈的场景下,Java轻量级锁会快速膨胀为重量级锁,其本质上一是为了减少CAS空自旋,二是为了避免同一时间大量CAS操作所导致的总线风暴。
CLH自旋锁
使用队列对抢锁线程排队,可以减少总线风暴。CLH锁就是一种基于队列(具体为单向链表)排队的自旋锁。
简单的CLH锁可以基于单向链表实现,申请加锁的线程首先会通过CAS操作在单向链表的尾部增加一个节点,之后该线程只需要在其前驱节点上进行普通自旋,等待前驱节点释放锁即可。由于CLH锁只有在节点入队时进行一下CAS的操作,在节点加入队列之后,抢锁线程不需要进行CAS自旋,只需普通自旋即可。因此,在争用激烈的场景下,CLH锁能大大减少CAS操作的数量,以避免CPU的总线风暴。
下面实现一个CLH锁的简单版本:
// 虚拟等待队列的节点
public class Node {
private volatile boolean locked;
private Node prevNode;
public Node(boolean locked, Node prevNode) {
this.locked = locked;
this.prevNode = prevNode;
}
public static final Node EMMPTY = new Node(false, null);
public boolean isLocked() {
return locked;
}
public void setLocked(boolean locked) {
this.locked = locked;
}
public Node getPrevNode() {
return prevNode;
}
public void setPrevNode(Node prevNode) {
this.prevNode = prevNode;
}
}
public class CLHLock implements Lock {
// 当前节点的线程本地变量
private static ThreadLocal<Node> curNodeLocal = new ThreadLocal<>();
// CLH队列的尾部指针,使用AtomicReference方便进行CAS操作
private AtomicReference<Node> tail = new AtomicReference<>(null);
public CLHLock() {
// 设置尾部节点
tail.getAndSet(Node.EMMPTY);
}
@Override
public void lock() {
Node curNode = new Node(true, null);
Node prevNode = tail.get();
// CAS自旋,将当前节点插入队列的尾部
while(!tail.compareAndSet(prevNode, curNode)) {
prevNode = tail.get();
}
// 设置前缀节点
curNode.setPrevNode(prevNode);
// 自旋
while(curNode.getPrevNode().isLocked()) {
// 让出CPU时间片,提高性能
Thread.yield();
}
System.out.println("获取到了锁!");
// 将当前节点缓存在线程本地变量中,释放锁会用到
curNodeLocal.set(curNode);
}
@Override
public void unlock() {
Node curNode = curNodeLocal.get();
curNode.setLocked(false);
curNode.setPrevNode(null); // help for gc
curNodeLocal.set(null); // 方便下一次抢锁
}
}
CLH算法有以下几个要点:
- 初始状态队列尾部属性执行一个EMPTY节点。
- Thread 在抢锁时会创建一个新的Node加入等待队列尾部:tail指向新的Node,同时新的Node的prevNode属性指向tail之前指向的节点,并且以上操作通过CAS自旋完成,确保操作成功。
- Thread加入抢锁队列之后,会在前驱节点上自旋;循环判断前驱节点的locked属性是否为false,如果为false就表示前驱节点释放了锁,当前线程抢占到锁。
- 抢到锁之后,它的locked属性一直为true,一直到临界区代码执行完,然后调用
unlock()
方法释放锁,释放之后其locked属性才为false。
公平锁与非公平锁
理解起来很简单,不展开了~~
可中断锁与不可中断锁
锁的可中断抢占
JUC 显示锁 Lock 接口中,有两个方法可以用于可中断抢占:
-
void lockInterruptibly() throws InterruptedException
,可中断的获取锁,和lock()方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程。 -
boolean tryLock(long time, TimeUnit unit) throws InterruptedException
,限时获取锁,到达超时时间返回false,并且此限时抢锁方法也可以响应中断信息。
死锁的监测与中断
死锁是指两个或两个以上线程因抢占锁而造成的相互等待的现象。多个县城通过AB-BA模式抢占两个锁是造成多线程死锁比较普遍的原因。
JDK8中包含的 ThreadMXBean
接口提供了多种监视线程的方法,其中包括两个死锁监测的方法,具体如下:
-
findDeadLockedThreads
,用于检测由于抢占JUC显式锁、Java内置锁引起死锁的线程。 -
findMonitorDeadlockedThreads
仅仅用于检测由于抢占 Java 内置锁引起死锁的线程。
ThreadMXBean
的实例可以通过 JVM 管理工厂 ManagementFactory
去获取,具体的获取方法如下:
public static ThreadMXBean mbean = ManagementFactory.getThreadMXBean();
JVM 管理工厂 ManagementFactory
提供静态方法,返回各种后去JVM信息的Bean实例。
共享锁与独占锁
独占锁
独占锁在同一时刻只能被一个线程所持有。Sychronized
内置锁和 ReetrantLock
显式锁都是独占锁。
共享锁 Semaphore
共享锁就是在同一时刻允许多个线程持有的锁。获得共享锁的线程只能读取临界区的数据,不能修改临界区的数据。
JUC中的共享锁包括 Semaphore(信号量)、ReadLock(读写锁)中的读锁、CountDownLatch(倒数闩)。
Semaphore 可以用来控制在同一时刻访问共享资源的线程数量,通过协调各个线程以保证共享资源的合理使用。Semaphore维护了一组虚拟许可,它的数量可以通过构造器的参数指定,线程在访问共享资源前必须调用Semaphore的acquire
方法获取许可,如果许可数量为0,该线程就一直阻塞额,线程访问完资源后,必须调用Semaphore的release
方法释放许可。
共享锁 CountDownLatch
CountDownLatch 是一个常用的共享锁,其功能相当于多线程环境下的倒数门闩,CountDownLatch 可以指定一个计数值,在并发环境下由线程进行减一操作,当计数值变为0之后,被 await 方法阻塞的线程就会唤醒,从而实现线程间的计数同步。
读写锁
读写锁的内部包含两把锁:一把是读(操作)锁,是一种共享锁;另一把是写(操作)锁,是一种独占锁。
读写锁 ReentrantReadWriteLock
通过 ReentrantReadWriteLock 类能获取读锁和写锁。下面为一个例子。
public class ReadWriteLockTest {
// 创建一个map,代表共享数据
final static Map<String, String> MAP = new HashMap<String, String>();
// 创建一个读写锁
final static ReentrantReadWriteLock LOCK = new ReentrantReadWriteLock();
// 获取读锁
final static Lock READ_LOCK = LOCK.readLock();
// 获取写锁
final static Lock WRITE_LOCK = LOCK.writeLock();
// 对共享数据的写操作
public static Object put(String key, String value) {
WRITE_LOCK.lock(); // 抢写锁
try {
System.out.println("抢占WRITE_LOCK,开始执行write操作");
Thread.sleep(1000);
String put = MAP.put(key, value);
return put;
} catch (Exception e) {
e.printStackTrace();
} finally {
WRITE_LOCK.unlock(); // 释放写锁
}
return null;
}
public static Object get(String key) {
READ_LOCK.lock();
try {
System.out.println("抢占READ_LOCK,开始执行read操作");
Thread.sleep(1000);
String value = MAP.get(key); // 读取共享数据
return value;
} catch (Exception e) {
e.printStackTrace();
} finally {
READ_LOCK.unlock();
}
return null;
}
public static void main(String[] args) {
Runnable writeTarget = () -> put("key", "value");
Runnable readTarget = () -> get("key");
for(int i = 0; i < 4; i++) {
new Thread(readTarget, "读线程" + i).start();
}
for(int i = 0; i < 2; i++) {
new Thread(writeTarget, "写线程" + i).start();
}
}
}
锁的升级与降级
锁升级是指读锁升级为写锁,锁降级是指写锁降级为读锁。在 ReentrantReadWriteLock 读写锁中,只支持写锁降级为读锁,而不支持读锁升级为写锁。
不支持读锁的升级,主要是避免死锁。
总结来说,与 ReentrantLock 相比,ReentrantReadWriteLock 更适合读多写少的场景,可以提高并发读的效率,而 ReentrantLock 更适合读写比例相差不大或写比读多的场景。
StampedLock
StampLock(印戳锁)是对ReentrantReadWriteLock读写锁的一种改造,主要的改进为:在没有写只有读的场景下,StampLock 支持不用加读锁而是直接进行读操作,最大程度提升读的效率,只有在发生过写操作之后,再加读锁才能进行读操作。
StampLock 的三种模式如下:
- 悲观读锁:与ReadWriteLock的读锁类似,多个线程可以同时获取悲观读锁,悲观读锁是一个共享锁。
- 乐观读锁:相当于直接操作数据,不加任何锁,连读锁都不要。
- 写锁:与 ReadWriteLock 的写锁类似,写锁和悲观读锁是互斥的。虽然写锁与乐观读锁不会互斥,但是在数据被更新之后,之前通过乐观读锁获得的数据已经变成了脏数据。
StampLock 没有实现 ReadWriteLock 接口,而是定义了自己的锁操作API,主要如下:
- 悲观读锁的获取与释放:
// 获取普通读锁(悲观读锁),返回long类型的印戳值
public long readLock()
// 释放普通读锁(悲观读锁),以取锁时的印戳值作为参数
public void unlockRead(long stamp)
- 写锁的获取与释放
// 获取写锁,返回long类型的印戳值
public long writeLock()
// 释放写锁,以获取写锁时的印戳值作为参数
public void unlockWrite(long stamp)
- 乐观读的印戳获取与有效性判断
// 获取乐观锁,返回龙类型的印戳值,返回0表示当前处于写锁模式,不能乐观读
public long tryOptimisticRead()
// 判断乐观度的印戳值是否有效,以tryOptimisticRead返回的印戳值作为参数
public long tryOptimisticRead()
网友评论