[整理] Java 锁的分类

0. Java锁的分类及简单介绍

1. 悲观锁与乐观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。

先说概念。对于同一个数据的并发操作:

  • 悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。
  • 乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试),乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的

根据从上面的概念描述我们可以发现:

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

代码举例:ReentranLock中采用lock()与unlock方法锁住同步资源块

注意事项:

  • 正确的写法:一开始就lock()方法,紧跟着try...finally,unlock()方法一定要在finally{}中的第一行。

  • 错误的写法:lock没有紧跟着try...语句;没有一开始就lock()方法锁住资源

2. 自旋锁与适应性自旋锁

(1)自旋锁
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

  • 自旋锁的缺点:
    从概念上来看,就知道自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。
    所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。简单来说就是自旋的次数跟时间超过一定的阈值就可能浪费处理器的资源。

  • 自旋锁的CAS实现:
    在AtomicInteger源码中就使用了CAS思想(实际上就是调用unsafe中方法),采用do-while循环(这是一个CAS常用的do{}while(){},还有就是for(;😉{if(...) return}),这里就是一个CAS操作,首先do{...}读取值,之后在通过循环while中CAS自旋修改值,直到成功为止。

自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

(2)自适应自旋锁
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。

如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源,即根据实际情况,决定自旋的时间。

3. 公平锁与非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁(排队获取锁)。

  • 公平锁的优点是等待锁的线程不会饿死。
  • 缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景(尝试获取锁,不行就重新进队尾等待)。

  • 非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
  • 缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

我们先结合ReentranLock中的源码结构及部分源码分析下,可以得到以下两点:

(1)实际上,ReentranceLock中有一个内部类Sync,ReentranceLock添加锁/释放锁等关键操作都是由它完成的,并且它继承了AQS(AbstractQueuedSynchronizer,这是一个很重要的能学到很多知识的需要好好分析源码的类),源码注释有有这么一句话:Synchronizer providing all implementation mechanics。

(2)ReentrantLock它还有公平锁FairSync和非公平锁NonfairSync两个子类,ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。

接下来我们分别看公平锁与非公平锁加锁的实现对比:

公平锁加锁方法 非公平锁加锁方法

我们可以清晰的看出有一个公平锁中有一个hasQueuePredecessors()方法:判断当前线程是否是队头,不是的话不会去处理。这也就是公平锁与非公平锁最大的区别。

4. 可重入锁与非可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁 。

关键字synchronized在使用时,当一个线程得到一个对象锁后,再次请求对象锁时是可以再次得到该对象锁的,这也证明synchronized块/方法的内部调用本类的其它synchronized方法/块时,是可以永远得到锁的。  

编写测试类:

运行结果:

service1()
service2()
service3()

从结果上来说,“可重入锁”概念就是:自己能够再次获取自己的内部锁,同时当存在子类继承关系时,子类也完全可以通过“可重入锁”调用父类的同步方法的。

好了,以上都是通过synchronized关键字的举例,接下来我们同样采用对比的方法对比ReentranLock部分关键源码来说明可重入锁与非可重入锁细节。实际上ReentranLock是没有非可重入锁的实现的,那么我们可以类比就行。

ReentranLock中可重入锁与非可重入锁与区别(结合源码)

ReentrantLock继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。当线程尝试获取锁时:

可重入锁:

  • 可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。
  • 判断如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁
        final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) { // state为0锁处于空闲状态
                if (compareAndSetState(0, acquires)) {
                    // 获取成功之后,当前线程是该锁的持有者
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            // 锁不是空闲状态,但是当前线程是该锁的持有者的话,实现可重入
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires; // state+1 可重入数
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true; // 返回true,表示获取锁成功(可重入的)
            }
            return false;
        } 

非可重入锁:

  • 是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞
   /**
     * 类似可重入操作类比出非可重入操作
     * @param acquires
     * @return
     */
    final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        // 非重入锁直接尝试获取该锁
        if (compareAndSetState(0, acquires)) { // 这里acquires实际就是1,对state+1
            // 获取之后设置为持有者,返回true,表示成功,其它情况都是false,即不能被重入了
            setExclusiveOwnerThread(current);
            return true;
        }
        // 锁不是空闲状态,但是当前线程是该锁的持有者的话,实现可重入
        else if {
            return false;
        }
    }

释放锁时,同样都是线程先尝试获取当前status的值,并判断当前线程是不是持有锁的线程的前提下

可重入锁:
执行判断status-1==0,如果true则说明所有重复所有锁的操作已经完成,接下来就是真正的释放锁,如果为false说明还有内部持有锁的操作未完成。

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    // 保证释放锁的必须是当前线程
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    // 释放后state为0,则持有者置为null
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    // 否则设置重置后的state
    setState(c);
    return free;
}

非可重入锁:
只需要判断是不是当前持有锁的线程,是的话status=0,锁释放操作完成

  /**
     * 类似可重入锁释放锁操作 得到非可重入锁操作
     * @param releases
     * @return
     */
    protected final boolean tryRelease(int releases) {
        // 保证释放锁的必须是当前线程
        if (Thread.currentThread() != getExclusiveOwnerThread()) {
            throw new IllegalMonitorStateException();
        } else {
            // 非可重入锁释放锁直接将持有者置为null
            setExclusiveOwnerThread(null);
            // state直接置为0
            setState(0);
            return true;
        }
    }
posted @ 2020-12-07 10:28  哆啦梦乐园  阅读(212)  评论(0编辑  收藏  举报