在开发过程中,如果需要开发者自主实现一把锁,就必须了解锁策略和锁的实现原理。
目录
锁策略
常见的锁策略有:
- 乐观锁和悲观锁
- 互斥锁和读写锁
- 轻量级锁和重量级锁
- 自旋锁和挂起等待锁
- 公平锁和非公平锁
- 可重入锁和不可重入锁
乐观锁和悲观锁
就像名字一样,乐观锁假设一般情况下数据不会发生冲突,只有在修改数据操作时,才会检测数据是否发生冲突,一旦发生冲突,返回错误信息,交给用户做决定;而悲观锁假设每次数据操作时都会发生冲突,每次对数据进行操作时都进行加锁,这时别的线程就无法修改同一个数据了。
乐观锁虽然会造成数据冲突,但是效率高;而悲观锁避免了数据冲突,却降低了效率。因此在选择使用乐观锁还是悲观锁时,需要估计数据冲突的概率,冲突概率小就使用乐观锁,反之使用悲观锁。
互斥锁和读写锁
互斥锁是一种独占锁,同一时间只允许一个线程访问该对象,无论读写;而读写锁则区分读者和写者,同一时间内只允许一个写者,但是允许多个读者同时读对象。synchronized是一种互斥锁,读写不分开。
Java 标准库提供了 ReentrantReadWriteLock 类,实现了读写锁:
//读写锁的使用
public class ThreadDemo {
private static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();//读写锁
public static void read() {
lock.readLock().lock();//读加锁
try {
System.out.println(Thread.currentThread().getName() + "读操作");
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + "读取完毕");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.readLock().unlock();//读解锁
}
}
public static void write() {
lock.writeLock().lock();//写加锁
try {
System.out.println(Thread.currentThread().getName() + "写操作");
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + "写入完毕");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.writeLock().unlock();//写解锁
}
}
public static void main(String[] args) {
new Thread(() -> write()).start();
new Thread(() -> read()).start();
}
}
轻量级锁和重量级锁
轻量级锁和重量级锁是悲观锁的具体实现。重量级锁基于操作系统的mutex锁实现,轻量级锁是基于重量级锁的优化机制。当涉及到大量的内核态用户态切换时,需要用到重量级锁;当涉及到很少的内核态操作而大多是用户态操作时,往往只用到轻量级锁。轻量级锁更加高效,往往在加锁解锁频繁情况下使用;重量级锁的操作成本较高,不轻易使用。
自旋锁和挂起等待锁
当多个对象竞争同一把锁时,就会出现加锁失败的情况,没有抢到锁的对象因为采取的措施不同而分为自旋锁和挂起等待锁。自旋锁指的是加锁失败时,不释放CPU资源,而是进入空循环,直到获取到锁才会退出循环;挂起等待锁则在加锁失败时释放CPU资源,等到锁释放后再去竞争锁。
自旋锁是一种极其耗费CPU资源的手段,但在某些情况下却不得不使用。
自旋锁伪代码:
while (竞争锁 == 失败) {}
公平锁和非公平锁
公平锁指的是遵循“先来后到”原则,竞争锁失败的对象有顺序地进入等待队列,释放锁后再按顺序获取锁;非公平锁则不遵循这个原则,竞争锁失败的对象不管先后都处于同一起跑线,锁释放后同时竞争锁。
比如说线程A、B、C三个线程,A先获取到锁,B首先进入等待,然后是C。锁释放后,如果是公平锁,则B获取到锁,而如果是非公平锁,则B和C同时竞争释放的锁。
可重入锁和不可重入锁
可重入锁指的是同一线程可以重复获取锁,而不会发生死锁的现象;不可重入锁指的是同一线程不能两次获得同一把锁,否则发生死锁现象。
当线程第一次加锁时,可以正常加锁;第二次加锁时,由于线程已被锁,只能进入阻塞等待,而解除阻塞等待只能先解锁,要解锁就要解除阻塞等待,这种情况就称为死锁。就好比家钥匙落在车里,车钥匙落在家里的情况。
可重入锁的实现逻辑是在加锁前先进行判断识别,如果识别到该锁和第一次加锁是同一把锁,则不进行加锁。
死锁
一个线程一把锁,多次加锁,可重入锁不死锁,不可重入锁死锁。但是,多个线程多把锁,可重入锁也会导致死锁现象:
假如t1线程拿到lock1,t2线程拿到lock2,这时t1线程拿不到lock2就只能阻塞等待,但同样t2线程也拿不到lock1,因此也只能阻塞等待,这就发生了死锁。
线程越多,锁越多,就越容易发生死锁现象。
发生死锁的必要条件
发生死锁有四个必要条件,缺一不可,分别是:互斥条件、请求和保持条件、不剥夺条件和循环等待条件。
- 互斥条件:一个线程拿到一把锁后,其他线程不能使用。
- 请求和保持条件:线程在阻塞等待时,不会放弃已获取的资源。
- 不剥夺条件:一个线程在获取锁以后,只能自己使用完释放,不可被提前抢占。
- 循环等待条件:每个线程都在等待获取下一个线程占有的资源。
两个线程发生死锁现象的特点是互不相让,你占有了我需要的资源,我占有了你需要的资源,都想让对方先释放资源。而多个线程发生死锁现象通常会形成循环,A需要的资源被B占有,B需要的资源被C占有,C需要的资源被D占有,D需要的资源又被A占有。
为了解决这种情况,通常的做法是,针对锁进行编号,每次加锁按照顺序加:
例如上述死锁情况,只需要约定第一层加lock1,第二层加lock2即可:
synchronized锁
synchronized是JVM基于操作系统提供的mutex(互斥锁)实现的。synchronized既是乐观锁也是悲观锁,既是重量级锁也是轻量级锁,为轻量级锁时大概率也是自旋锁。synchronized同时也是非公平锁和可重入锁。
synchronized的锁升级
synchronized之所以可以同时是这么多种锁,主要在于synchronized的工作流程是基于锁升级实现的:
- 偏向锁:加偏向锁也是无锁状态,只是一种偏向标记,记录了当前线程,如果后续不存在锁竞争,则处于无锁状态,当后续存在锁竞争时可以第一时间加锁。
- 轻量级锁:当存在锁竞争时,由偏向锁升级为轻量级锁,轻量级锁通常也是自旋锁,通过CAS指令实现,CAS检测更新内存,更新成功则加锁成功,停止自旋,反之继续空转,直到更新成功。(这里的自旋锁是自适应的,如果长时间没有获取到锁,也会停止自旋)
伪代码实现:
public class spinLock {
private Thead cur = null;//代表当前锁对象没有被持有
public void lock() {
//通过CAS判断当前锁对象是否被占有
//this.cur == null代表该锁没有被占用,可以被获取到
//当前锁对象尝试对Thread.currentThread()加锁
while (!CAS(this.cur, null, Thread.currentThread())) {
}
}
public void unlock() {
this.cur = null;//置为null代表该锁没有被占用,也就是锁被释放
}
}
- 重量级锁:当锁竞争比较频繁时,自旋锁会耗费大量的CPU资源,因此轻量级锁会升级为重量级锁,重量级锁需要用到内核的mutex锁,在内核态判断锁是否被占用,没有则加锁成功并切换回用户态;反之则加锁失败,挂起等待,直到锁被释放再重新竞争。
JVM实现的synchronized只能发生锁升级,目前还不能实现降级,一旦锁升级为重量级,就不可能再降级为轻量级。
CAS指令
CAS(Compare and Swap)指令指的是一种通过硬件实现并发安全的常用技术,意为“比较和交换”,CAS是原子的,其实现步骤不可拆分,实现原理如下:
//address代表需要更新的内存,oldValue为旧值,newValue为需要更改的新值
boolean CAS(address, oldValue, newValue) {
if (&address == prevValue) {//&address意为获取到内存address处存放的值
&address = newValue;
return true;
}
return false;
}
注意上述过程是一步完成的,不受多线程的抢占式执行影响。
基于CAS指令的原子性,通常用来作为实现锁的底层原理,以及可以用来进行无锁并发编程。
编译器+JVM的其他优化
通过编译器+JVM还可以实现synchronized的锁消除和锁粗化。
锁消除
在单线程环境下使用synchronized时,并不会真的加锁。Java源码中有很多方法的实现都会提供无锁版本和加锁版本,例如StringBulider类和StringBuffer类的区别就在于StringBulider是无锁版本,StringBuffer是加锁版本。当我们在单线程使用StringBuffer时,就会进行锁消除的优化。
锁粗化
当出现多次加锁时,编译器+JVM就会进行锁粗化的优化。锁粗化的是锁的粒度。锁的粒度是指锁定资源的细化程度。锁的粒度越大,则并发性越低,开销越大;粒度越小,并发性越高,开销越小。
虽然锁的粒度越细,开销越小。但是频繁加锁解锁的开销有时会更高。比如你妈妈让你去超市买东西A、B、C,你有两种方式:
方式一、去超市,买A,回家;去超市,买B,回家;去超市,买C,回家。
方式二、去超市,买A、B、C,回家。
显然呢,方式一是一个非常弱智的做法,明明可以一次性做完,却非得分成三次,吃力不讨好。
编程也同理,对于某些操作,如果需要频繁的加锁,编译器+JVM就会优化为只加锁一次,执行完所有操作后再解锁。
ReentrantLock锁
Java中虽然synchronized锁已经可以解决开发中的大部分需要加锁的场景,但是synchronized锁是一个非公平锁,与之相对的,Java源码也实现了ReentrantLock来补足这方面的缺点。
ReentrantLock为可重入锁,但是可以通过构造方法传入true变成公平锁。
ReentrantLock的用法
- lock():获取锁。
- unlock():释放锁。
- tryLock():尝试获取锁,如果获取成功返回true,否则返回false。
- tryLock(long timeout, TimeUnit unit):在指定时间内尝试获取锁,如果获取成功返回true,否则返回false。
- getHoldCount():查询当前线程保持此锁定的个数,即调用lock()方法的次数。
- getQueueLength():返回正等待获取此锁定的线程估计数。
- isHeldByCurrentThread():查询当前线程是否保持此锁定。
- isLocked():查询此锁定是否由任意线程保持。
代码案例:
ReentrantLock lock = new ReentrantLock(true);
//传入true代表公平锁,不写参数默认非公平锁
lock.lock();//加锁
try {
//执行代码
} finally {
lock.unlock();//解锁
}
synchronized与ReentrantLock的区别
- 底层实现不同:synchronized是关键字,基于JVM内部实现,ReentrantLock是Java的一个类,基于Java实现。
- 使用方式不同:对象在获取synchronized锁时发生阻塞只能死等,而获取ReentrantLock锁可以定义等待的最大时间,超时就放弃锁;使用synchronized出代码块系统自动释放锁,而使用ReentrantLock必须使用lock()加锁,并在最后unlock()解锁,否则发生死锁。
- 公平性不同:synchronized是非公平锁,ReentrantLock默认是非公平锁,构造时传入true变为公平锁。
- 唤醒机制不同:synchronized只能通过wait()和notify()/notifyAll()随机唤醒某个线程或者唤醒全部线程;ReentrantLock通过与Condition搭配唤醒线程更加灵活,Condition与Lock绑定,通过await()方法让线程等待,通过signal()方法唤醒一个等待的线程。
//ReentrantLock唤醒
public class ReentrantLock_test {
private static final ReentrantLock lockA = new ReentrantLock();
private static final ReentrantLock lockB = new ReentrantLock();
private static final Condition conditionA = lockA.newCondition();
private static final Condition conditionB = lockB.newCondition();
public static void main(String[] args) throws InterruptedException {
lockA.lock();
lockB.lock();
conditionA.await();//使lockA锁等待
conditionB.await();//使lockB锁等待
conditionA.signal();//唤醒lockA
conditionB.signal();//唤醒lockB
}
}
没有说哪一种锁就一定优于其他的锁。在不同的情况下需要选择不同的锁,synchronized效率更高,ReentrantLock使用更灵活,具体使用哪一种锁还要根据实际情况判断。