Java中的锁 | 8月更文挑战

824 阅读11分钟

这是我参与 8 月更文挑战的第 3 天

往期推荐

Java中常见的锁介绍

Java中往往是按照是否含有某一特性来定义锁,我们通过特性将锁进行分组归类,再使用对比的方式进行介绍,帮助大家更快捷的理解相关知识。下面给出本文内容的总体分类目录:

23.png

乐观锁、悲观锁

  • 乐观锁:是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。java 中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则读取最新的值。

  • 悲观锁:就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都其他线程会修改,所以每次在读写数据的时候都会上锁,采用的是一种先取锁再访问的策略,这样别人想读写这个数据就会阻塞直到拿到锁。java中的悲观锁就是synchronized,AQS框架下的锁则是先尝试CAS乐观锁去获取锁,获取不到,才会转换为悲观锁,如 RetreenLock

  • 优缺点对比

  • 悲观锁

  • 优点

    • 利用数据库中的锁机制来实现数据变化的顺序执行,这是最有效的办法。
  • 缺点

    • 当一个线程进入悲观锁时,如果该线程永久阻塞(如死锁),那其他等待该锁线程只能进行等待,并且永远不会得到执行。 :
  • 乐观锁

  • 优点

    • 乐观锁不在数据库上加锁,任何事务都可以对数据进行操作,在更新时才进行校验,这样就避免了悲观锁造成的吞吐量下降的劣势。
  • 缺点

    • ABA问题:如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
    • 自旋时间长开销大:如果数据修改不成功,就会一直循环执行直到成功,会给CPU带来非常大的执行开销。
    • 功能限制:CAS只能保证单个变量的原子性,如果我们需要保证一段代码的原子性,乐观锁就不再适合。

24.png

  • 适用场景
  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

自旋锁、非自旋锁

  • 自旋锁:当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

  • 非自旋锁:没有自旋的过程,如果拿不到锁就直接放弃或者进行其他逻辑处理。

  • 自旋锁的优缺点如下

    优点:自旋锁可以减少 CPU 上下文的切换,对于占用锁的时间非常短或锁竞争不激烈的代码块来说性能大幅度提升,因为自旋的 CPU 耗时明显少于线程阻塞、挂起、再唤醒时两次 CPU 上下文切换所用的时间。

    缺点:在持有锁的线程占用锁时间过长或锁的竞争过于激烈时,线程在自旋过程中会长时间获取不到锁资源,将引起 CPU 的浪费。所以在系统中有复杂锁依赖的情况下不适合采用自旋锁。

25.png

公平锁、非公平锁

  • 公平锁指在分配锁前检查是否有线程在排队等待获取该锁,优先将锁分配给排队时间最长的线程。
    • 优点:所有的线程都能得到资源,不会饿死在队列中。
    • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
  • 非公平锁指在分配锁时不考虑线程排队等待的情况,直接尝试获取锁,在获取不到锁时再排到队尾等待。
    • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
    • 缺点导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。

ReenTrantLock有两种自带公平非公平两种实现方式,具体示例代码如下:

/**
 * Performs non-fair tryLock.  tryAcquire is implemented in
 * subclasses, but both need nonfair try for trylock method.
 */
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

/**
 * Fair version of tryAcquire.  Don't grant access unless
 * recursive call or no waiters or is first.
 */
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

可重入锁、非可重入锁

可重入锁:又叫递归锁,指在同一个线程在外层方法获取锁的时候,进入内层方法会自动获取锁。

  • 优点
  1. 避免死锁
  2. 提升封装性

非可重入锁: 是指当前线程执行中已经获取了锁,但是如果想要再次获得这把锁,必须要先释放锁后才能再次尝试获取,否则就会被阻塞。

private static ReentrantLock lock = new ReentrantLock();

public static void main(String[] args) {
    System.out.println(lock.getHoldCount());
    lock.lock();
    System.out.println(lock.getHoldCount());
    lock.lock();
    System.out.println(lock.getHoldCount());
    lock.unlock();
    System.out.println(lock.getHoldCount());
    lock.unlock();
    System.out.println(lock.getHoldCount());
}

打印结果:
0
1
2
1
0
结果显示:同一个线程获取到ReentrantLock锁后,在不释放该锁的情况下可以再次获取。

共享锁、独占锁

独占锁(写锁):也叫互斥锁,每次只允许一个线程持有该锁,ReentrantLockSynchronized而言都是独占锁的实现。

共享锁(读锁):允许多个线程同时获取该锁 ,并发访问 共享资源 。ReentrantReadWriteLock 中的读锁为共享锁的实现。

读写锁规则

  • 如果一个线程已经占用了读锁,则此时其他线程如果要申请读锁,可以申请成功。
  • 如果一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁,因为读写不能同时进行。
  • 如果一个线程已经占用了写锁,则此时其他线程申请读锁或者写锁,必须等待之前的线程释放了写锁,因为读写不能同时进行。
  • 总结就是要么多读(同一时刻有多个读操作),要么一写(同一时刻有一个写操作),两者不会同时出现。

ReentrantReadWriteLock的读写锁使用:

public class ReadWriteLock {
    //定义读写锁
    private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);
    //获取读锁
    private static final ReentrantReadWriteLock.ReadLock readLock= reentrantReadWriteLock.readLock();
    //获取写锁
    private static final ReentrantReadWriteLock.WriteLock writeLock= reentrantReadWriteLock.writeLock();
​
    public static void read(){
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() +"得到读锁,正在读取");
            Thread.sleep(500);
        }catch (InterruptedException e){
            e.printStackTrace();
        } finally {
            System.out.println("释放读锁");
            readLock.unlock();
        }
    }
​
    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放写锁");
            writeLock.unlock();
        }
    }
​
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> read()).start();
        new Thread(() -> read()).start();
        new Thread(() -> write()).start();
        new Thread(() -> write()).start();
    }
}
运行结果如下:
Thread-0得到读锁,正在读取
Thread-1得到读锁,正在读取
释放读锁
释放读锁
Thread-2得到写锁,正在写入
Thread-2释放写锁
Thread-3得到写锁,正在写入
Thread-3释放写锁

可中断锁、不可中断锁

可中断锁:在获取锁的过程中,可以中断锁之后去做其他事情,不需要一直等到获取到锁才继续处理。

不可中断锁(synchronized):指一旦申请了锁,只能等到拿到锁以后才能进行其他的逻辑处理。

无锁、偏向锁、轻量级锁、重量级锁

无锁:是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

偏向锁:是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

轻量级锁:是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

重量级锁:如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁。

偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

锁的优化

一、锁升级

锁的4种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态(级别从低到高)

27.png 执行流程:

26.png

偏向锁、轻量级锁和重量级锁的比较:

28.png

二、锁粗化

在使用锁的时候,我们一般认为都应该将被锁保护的临界区要尽可能小使需要进行同步的操作数尽可能小,在竞争锁的情况下,其他等待锁的线程就可以比较快地获取到锁。但是!但是后面很重要,如果在一个程序中存在一系列连续的加锁、解锁操作,那么这些加锁和解锁的过程就会导致不必要的性能消耗。因此,我们就可以将多个连续的小范围内的加锁和解锁操作连接在一起,扩展成为一个范围更大的锁,这就是锁粗化。

三、锁消除

在多线程中,为保证数据的完整性,在多线程下对共享数据的操作需要进行同步控制。但是在某些情况下,Java虚拟机会检测到多线程不可能存在共享数据的竞争,这是就不需要对共享资源进行加锁。所以锁消除可以节省无意义的请求锁时间,提高程序运行效率。

Lock锁

1. Lock是一个接口(以下为其三个实现类)

  • ReentrantLock (可重入锁)
  • ReentrantReadWriteLock.ReadLock(读锁)
  • ReentrantReadWriteLock.WriteLock (写锁)

2. Lock接口的主要方法

  • void lock(): 阻塞式加锁方法, 如果锁处于空闲状态, 当前线程将获取到锁. 相反, 如果锁已经被其他线程持有,调用该方法的线程将会被阻塞, 直到当前线程获取到锁。

  • void unlock(): 执行此方法时, 当前线程将释放持有的锁. 锁只能由持有者释放, 如果线程并不持有锁, 却执行该方法, 可能导致异常的发生。

  • boolean tryLock(): 如果锁可用, 则获取锁, 并立即返回 true, 否则返回 false. 该方法和lock()的区别在于, tryLock()只是"试图"获取锁, 如果获取不到, 不会导致当前线程被禁用,当前线程仍然继续往下执行代码. 而 lock()方法则是一定要获取到锁, 如果锁不可用, 就一直等待, 在未获得锁之前,当前线程并不继续向下执行。

  • tryLock(long timeout TimeUnit unit): 如果锁在给定等待时间内没有被另一个线程保持,则获取该锁。

  • isLock(): 判断此锁是否已经被其他线程占有。

  • Condition newCondition(): 该方法将创建一个绑定在当前Lock对象上的Condition对象,一个Lock对象可以绑定多个Condition对象。Condition实现的就是当线程挂起时指明在什么样的条件上挂起,同时在等待的事件发生后,只唤醒在等待该事件上的线程

  • getHoldCount() : 查询当前线程保持此锁的次数,也就是执行此线程执行 lock 方法的次数。

  • getQueueLength(): 返回正等待获取此锁的线程估计数,比如启动 10 个线程,1 个线程获得锁,此时返回的是 9。

  • getWaitQueueLength:(Condition condition) 返回等待与此锁相关的给定条件的线程估计数。比如 10 个线程,用同一个 condition 对象,并且此时这 10 个线程都执行了condition 对象的 await 方法,那么此时执行此方法返回 10。

  • hasWaiters(Condition condition): 查询是否有线程等待与此锁有关的给定条件(condition),对于指定 contidion 对象,有多少线程执行了 condition.await 方法。

  • hasQueuedThread(Thread thread): 查询给定线程是否等待获取此锁。

  • hasQueuedThreads(): 是否有线程等待此锁。

  • isFair(): 该锁是否公平锁。

  • isHeldByCurrentThread(): 当前线程是否保持锁锁定,线程执行lock()方法的前后分别是 false 和 true。

  • tryLock(): 尝试获得锁,仅在调用时锁未被线程占用,获得锁。

Lock锁和synchronized的区别

1. synchronized是java中的关键字,Lock是Java中的一个接口,它有许多的实现类来为它提供各种功能。

2. synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁。

3.synchronized可以锁对象、类、代码块,而lock锁住的是代码块。

4.Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断

5.通过Lock可以知道线程有没有拿到锁,而Synchronized不能。

性能上来说,在资源竞争不激烈的情形下,Lock性能稍微比synchronized差点(编译程序通常会尽可能的进行优化synchronized)。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍。而ReentrantLock确还能维持常态。

Lock的效率是明显高于synchronized关键字的,一般对于数据结构设计或者框架的设计都倾向于使用Lock而非Synchronized.