在开发过程中,如果需要开发者自主实现一把锁,就必须了解锁策略和锁的实现原理。
目录
锁策略
乐观锁和悲观锁
互斥锁和读写锁
轻量级锁和重量级锁
自旋锁和挂起等待锁
公平锁和非公平锁
可重入锁和不可重入锁
死锁
发生死锁的必要条件
synchronized锁
synchronized的锁升级
CAS指令
编译器+JVM的其他优化
锁消除
锁粗化
ReentrantLock锁
ReentrantLock的用法
synchronized与ReentrantLock的区别
常见的锁策略有:
就像名字一样,乐观锁假设一般情况下数据不会发生冲突,只有在修改数据操作时,才会检测数据是否发生冲突,一旦发生冲突,返回错误信息,交给用户做决定;而悲观锁假设每次数据操作时都会发生冲突,每次对数据进行操作时都进行加锁,这时别的线程就无法修改同一个数据了。
乐观锁虽然会造成数据冲突,但是效率高;而悲观锁避免了数据冲突,却降低了效率。因此在选择使用乐观锁还是悲观锁时,需要估计数据冲突的概率,冲突概率小就使用乐观锁,反之使用悲观锁。
互斥锁是一种独占锁,同一时间只允许一个线程访问该对象,无论读写;而读写锁则区分读者和写者,同一时间内只允许一个写者,但是允许多个读者同时读对象。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();
}
}
当涉及到大量的内核态用户态切换时,需要用到重量级锁;当涉及到很少的内核态操作而大多是用户态操作时,往往只用到轻量级锁。轻量级锁更加高效,往往在加锁解锁频繁情况下使用;重量级锁的操作成本较高,不轻易使用。轻量级锁往往频繁使用,因此通常也是乐观锁;而重量级锁也往往是悲观锁。
当多个对象竞争同一把锁时,就会出现加锁失败的情况,没有抢到锁的对象因为采取的措施不同而分为自旋锁和挂起等待锁。自旋锁指的是加锁失败时,不释放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是JVM基于操作系统提供的mutex(互斥锁)实现的。synchronized既是乐观锁也是悲观锁,既是重量级锁也是轻量级锁,为轻量级锁时大概率也是自旋锁。synchronized同时也是非公平锁和可重入锁。
synchronized之所以可以同时是这么多种锁,主要在于synchronized的工作流程是基于锁升级实现的:
伪代码实现:
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代表该锁没有被占用,也就是锁被释放
}
}
JVM实现的synchronized只能发生锁升级,目前还不能实现降级,一旦锁升级为重量级,就不可能再降级为轻量级。
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还可以实现synchronized的锁消除和锁粗化。
在单线程环境下使用synchronized时,并不会真的加锁。Java源码中有很多方法的实现都会提供无锁版本和加锁版本,例如StringBulider类和StringBuffer类的区别就在于StringBulider是无锁版本,StringBuffer是加锁版本。当我们在单线程使用StringBuffer时,就会进行锁消除的优化。
当出现多次加锁时,编译器+JVM就会进行锁粗化的优化。锁粗化的是锁的粒度。锁的粒度是指锁定资源的细化程度。锁的粒度越大,则并发性越低,开销越大;粒度越小,并发性越高,开销越小。
虽然锁的粒度越细,开销越小。但是频繁加锁解锁的开销有时会更高。比如你妈妈让你去超市买东西A、B、C,你有两种方式:
方式一、去超市,买A,回家;去超市,买B,回家;去超市,买C,回家。
方式二、去超市,买A、B、C,回家。
显然呢,方式一是一个非常弱智的做法,明明可以一次性做完,却非得分成三次,吃力不讨好。
编程也同理,对于某些操作,如果需要频繁的加锁,编译器+JVM就会优化为只加锁一次,执行完所有操作后再解锁。
Java中虽然synchronized锁已经可以解决开发中的大部分需要加锁的场景,但是synchronized锁是一个非公平锁,与之相对的,Java源码也实现了ReentrantLock来补足这方面的缺点。
ReentrantLock为可重入锁,但是可以通过构造方法传入true变成公平锁。
代码案例:
ReentrantLock lock = new ReentrantLock(true);
//传入true代表公平锁,不写参数默认非公平锁
lock.lock();//加锁
try {
//执行代码
} finally {
lock.unlock();//解锁
}
//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使用更灵活,具体使用哪一种锁还要根据实际情况判断。