JavaEE——锁相关

在开发过程中,如果需要开发者自主实现一把锁,就必须了解锁策略和锁的实现原理。

目录

锁策略

乐观锁和悲观锁

互斥锁和读写锁

轻量级锁和重量级锁

自旋锁和挂起等待锁

公平锁和非公平锁

可重入锁和不可重入锁

死锁

发生死锁的必要条件

synchronized锁

synchronized的锁升级

CAS指令

编译器+JVM的其他优化

锁消除

锁粗化

ReentrantLock锁

ReentrantLock的用法

synchronized与ReentrantLock的区别


锁策略

常见的锁策略有:

  1. 乐观锁和悲观锁
  2. 互斥锁和读写锁
  3. 轻量级锁和重量级锁
  4. 自旋锁和挂起等待锁
  5. 公平锁和非公平锁
  6. 可重入锁和不可重入锁

乐观锁和悲观锁

就像名字一样,乐观锁假设一般情况下数据不会发生冲突,只有在修改数据操作时,才会检测数据是否发生冲突,一旦发生冲突,返回错误信息,交给用户做决定;而悲观锁假设每次数据操作时都会发生冲突,每次对数据进行操作时都进行加锁,这时别的线程就无法修改同一个数据了。

乐观锁虽然会造成数据冲突,但是效率高;而悲观锁避免了数据冲突,却降低了效率。因此在选择使用乐观锁还是悲观锁时,需要估计数据冲突的概率,冲突概率小就使用乐观锁,反之使用悲观锁。

互斥锁和读写锁

互斥锁是一种独占锁,同一时间只允许一个线程访问该对象,无论读写;而读写锁则区分读者和写者,同一时间内只允许一个写者,但是允许多个读者同时读对象。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锁

synchronized是JVM基于操作系统提供的mutex(互斥锁)实现的。synchronized既是乐观锁也是悲观锁,既是重量级锁也是轻量级锁,为轻量级锁时大概率也是自旋锁。synchronized同时也是非公平锁和可重入锁。

synchronized的锁升级

synchronized之所以可以同时是这么多种锁,主要在于synchronized的工作流程是基于锁升级实现的:

  1. 偏向锁:加偏向锁也是无锁状态,只是一种偏向标记,记录了当前线程,如果后续不存在锁竞争,则处于无锁状态,当后续存在锁竞争时可以第一时间加锁。
  2. 轻量级锁:当存在锁竞争时,由偏向锁升级为轻量级锁,轻量级锁通常也是自旋锁,通过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代表该锁没有被占用,也就是锁被释放
}
}
  1. 重量级锁:当锁竞争比较频繁时,自旋锁会耗费大量的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使用更灵活,具体使用哪一种锁还要根据实际情况判断。

猜你喜欢

转载自blog.csdn.net/weixin_71020872/article/details/129846751