深入理解 Java 并发锁(2)

一.并发锁简介

确保线程安全最常见的做法是利用锁机制(Lock,synchronized)来对共享数据做互斥同步,这样在同一时刻,只有一个线程可以执行某个方法或者某个代码块,那么操作必然是原子性的,线程安全的。

在工作,面试中,经常会听到各种五花八门的锁,听的人云里雾里,实际上,锁的概念术语很多,他们说针对不同的问题所提出来的,通过简单的梳理,也不难理解。

二.锁的分类

2.1 可重入锁

含义:是指线程可以重复获取同一把锁,即同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。在一定程度上可以避免死锁。

  1. ReentrantLock,ReentrantReadWriterLock是可重入锁。
  2. synchronized也是一个可重入锁。

2.2 公平锁和非公平锁

  1. 公平锁:多线程按照申请锁的顺序来获取锁。
  2. 非公平锁:多线程不按照申请锁的顺序来获取锁,这就可能出现后来者居上或者饥饿现象(某线程总是抢不到别的线程,导致始终无法执行)。

公平锁为了保证线程申请顺序,势必要付出一定的性能代价,因此其吞吐量一般低于非公平锁。

它们在Java中的典型实现有:

  1. synchronized 只支持非公平锁
  2. ReentrantLock,ReentrantReadWriterLock默认是非公平锁,但支持公平锁

2.3 独享锁和共享锁

独享锁和共享锁是一种广义上的说法,从实际用途上来看,也常被称为互斥锁和读写锁。

  1. 独享锁是指锁一次只能被一个线程所持有
  2. 共享锁是指锁可以被多个线程持有

它们在Java中的典型实现有:

  1. synchronized和ReentrantLock只支持独享锁
  2. ReentrantReadWriterLock 其写锁是独享锁,读锁是共享锁,使得并发读是非常高效的,读写,写读,写写的过程是互斥的。

2.4 悲观锁和乐观锁

这两者不是指具体的什么类型的锁,而是处理并发同步的策略。

  1. 悲观锁:不加锁的并发操作一定会出问题,适合写操作频繁的场景。
  2. 乐观锁:不加锁的并发操作也没什么问题,对于同一个数据的并发操作,是不会发生修改的,适合读多写少的场景。

它们在Java中的典型实现有:

  1. 悲观锁在java中的应用就是通过synchronized 和 Lock显式加锁来进行互斥同步,这是一种阻塞同步。
  2. 乐观锁在java中的应用就是采用CAS操作,CAS是系统层面的操作,意思为compareAndSwap,比较并交换,从步骤上看分为两步,但是CPU实际执行时只有一条指令,保证了操作的原子性。

2.5 偏向锁,轻量级锁,重量级锁

轻量级锁与重量级锁一般指的锁控制粒度的粗细,控制粒度越细,阻塞开销越小,并发能力也就越强。

Java1.6 以前,重量级锁一般指的是synchronized,而轻量级锁指的是volatile。

Java1.6以后,针对synchronized 做了大量优化,引入4种锁状态:无锁,偏向锁,轻量级锁,重量级锁,锁可以单向的从偏向锁升级成轻量级锁,再到重量级锁。

  1. 偏向锁:指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
  2. 轻量级锁:指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级成轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
  3. 重量级锁:指锁为轻量级锁时,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,并且该锁会膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

2.6 分段锁

分段锁是一种锁的设计,并不是具体的某种锁。其含义是将锁的对象分成多段,每段独立控制,使得锁粒度更细,减少阻塞开销,从而提高并发性。这就像是高速公路的收费站,如果只有一个收费口,那所有的车就只能排成一条队缴费,如果有多个收费口,就可以分流了。

HashTable 使用synchronized修饰方法来保证线程安全性,那么面对线程的访问,HashTable就会锁住整个对象,所有的其他线程只能等待,这种阻塞方式的吞吐量显然很低。在java中有个叫concurrentHashMap就是分段锁的典型案例。concurrentHashMap内部维护了一个数组,一般称为分段桶,当有线程访问该map里的数据时,concurrentHashMap会先根据hashCode计算出数据在哪个桶,然后锁住这个桶,不影响其他桶的数据查询或操作。

2.7 显示锁和内置锁

锁的申请和释放都是由JVM控制,这属于内置锁;锁的申请和释放都是由程序控制,这属于显示锁。

他们之间的差异:

  1. 主动获取锁和释放锁
  2. 内置锁不能响应中断,显示锁可以
  3. 内置锁没有超时机制,显示锁可以
  4. 内置锁只支持非公平锁,显示锁支持非公平锁和公平锁
  5. 内置锁不支持共享,即只能被一个线程访问,如果这个线程阻塞,其他线程也只能等待;而显示锁能够灵活的控制同步条件
  6. 内置锁不支持读写锁分离,显示锁可以

三. Lock和Condition

3.1 为何引入Lock和Condition?

在并发编程领域一直有两大核心问题:互斥与同步。互斥是指同一时刻只允许一个线程访问资源,同步是指线程之间的通信,协作。

这两个问题管程都是能够解决的,在java中,Lock用于解决互斥问题,Condition用于解决同步问题。

与内置锁synchronized不同的是,Lock提供了一组无条件,可轮询的,定时的以及可中断的锁操作,所有获取锁,释放锁的操作都是显式的操作。

  1. 能够响应中断:synchronized的问题是,持有锁A后,如果尝试获取锁B失败,那么线程就会进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号时,能够唤醒它,那它就有机会释放曾经持有的锁A,这样就破坏了不可抢占条件了。
  2. 支持超时:如果线程再一段时间内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那么它也有机会释放曾经持有的锁,同样也可以破坏不可抢占的条件。
  3. 非阻塞的获取锁:如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,同样也可以破坏不可抢占的条件。

3.2 Lock接口

具有以下方法:

  • lock() - 获取锁。
  • unlock() - 释放锁。
  • tryLock() - 尝试获取锁,仅在调用时锁未被另一个线程持有的情况下,才获取该锁。
  • tryLock(long time, TimeUnit unit) - 和 tryLock() 类似,区别仅在于限定时间,如果限定时间内未获取到锁,视为失败。
  • lockInterruptibly() - 锁未被另一个线程持有,且线程没有被中断的情况下,才能获取锁。
  • newCondition() - 返回一个绑定到 Lock 对象上的 Condition 实例。

3.3 Condition

condition实现了管程模型的条件变量。

在单线程中,一段代码的执行可能依赖于某个状态,如果不满足状态条件,代码就不会执行,例如if...else...语句。在并发环境中,当一个线程判断某个状态条件时,其状态可能是由于其他线程的操作而改变,这时就需要有一定的协调机制来确保在同一时刻,数据只能被一个线程锁修改,且修改的数据状态被所有线程感知。

在Lock之前,主要是使用wait,notify,notifyAll配合synchronized来使用。当使用Lcok 的线程,彼此间通信应该使用Condition。

Condition的接口定义:

  • await() - 使当前线程等待,直到收到信号或被中断。
  • awaitUninterruptibly() - 使当前线程等待,直到收到信号为止。
  • awaitNanos() - 使当前线程等待,直到收到信号或中断,或者指定的等待时间过去。
  • signal() - 唤醒一个等待的线程。
  • signalAll() - 唤醒所有等待的线程。

用法:

public static void main(String[] args) {
        AtomicInteger count = new AtomicInteger();
        Lock lock = new ReentrantLock();
        Condition c1 = lock.newCondition();
        Condition c2 = lock.newCondition();
        Condition c3 = lock.newCondition();
        Thread t1 = new Thread(() -> {
            lock.lock();
            try{
                for (int i = 0; i < 100; i++) {
                    while(count.get() % 3 != 0){
                        c1.await();
                    }
                    System.out.println("aaaaaaaa");
                    count.getAndIncrement();
                    c2.signal();
                }
            }catch (Exception e){
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        });
        Thread t2 = new Thread(() -> {
            lock.lock();
            try {
                for (int i = 0; i < 100; i++) {
                    while (count.get() % 3 != 1) {
                        c2.await();
                    }
                    System.out.println("bbbbbbbb");
                    count.getAndIncrement();
                    c3.signal();
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        });
        Thread t3 = new Thread(() -> {
            lock.lock();
            try {
                for (int i = 0; i < 100; i++) {
                    while (count.get() % 3 != 2) {
                        c3.await();
                    }
                    System.out.println("cccccccc");
                    count.getAndIncrement();
                    c1.signal();
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        });
        t2.start();
        t1.start();
        t3.start();
    }

使用semaphore信号量也可以实现类似功能:

public static void semaphore(){
        //初始化信号量
        Semaphore s1 = new Semaphore(1);
        Semaphore s2 = new Semaphore(0);
        Semaphore s3 = new Semaphore(0);
        Thread t1 = new Thread(() -> {
            try{
                for (int i = 0; i < 100; i++) {
                    //执行后,信号量减1
                    s1.acquire();
                    System.out.println("aaaaaaaa");
                    //s2信号量加1
                    s2.release();
                }
            }catch (Exception e){
                e.printStackTrace();
            }
        });
        Thread t2 = new Thread(() -> {
            try {
                for (int i = 0; i < 100; i++) {
                    //执行后,信号量减1
                    s2.acquire();
                    System.out.println("bbbbbbbb");
                    //s2信号量加1
                    s3.release();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        Thread t3 = new Thread(() -> {
            try {
                for (int i = 0; i < 100; i++) {
                    //执行后,信号量减1
                    s3.acquire();
                    System.out.println("cccccccc");
                    //s2信号量加1
                    s1.release();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        t2.start();
        t1.start();
        t3.start();
    }

四. ReentrantLock

ReentrantLock类是Lock接口的具体实现,与内置锁synchronized相同的是,它是一个可重入锁。

4.1 用法

ReentrantLock中有两个构造方法:

public ReentrantLock() {}
public ReentrantLock(boolean fair) {}
  1. ReentrantLock() - 默认构造方法会初始化一个非公平锁
  2. ReentrantLock(boolean fair) - 可以根据fair的值来决定初始化公平锁还是非公平锁

4.2 lock和unlock方法

  1. lock - 无条件获取锁,如果当前线程无法获取锁,则当前线程进入休眠状态不可用,直至当前线程获取到锁。如果该锁没有被另一个线程持有,则获取该锁并立即返回,将锁的持有计数设置为1
  2. unlock - 用于释放锁

注意:请务必牢记,获取锁操作lock必须在try- catch块中进行,并且将释放锁操作unlock放在finally中进行,以保证锁一定被释放,防止死锁的发生。

4.3 tryLock方法

与无条件获取锁相比,tryLock具有更完善的容错机制。

  1. tryLock() - 可轮询获取锁,如果成功,则返回true,如果失败返回false。也就是说,无论成功与否这个方法都会立即返回,不会阻塞
  2. tryLock(long,TimeUnit) - 可定时获取锁。和tryLock类似,区别是获取不到锁会有一定的等待时间。

4.4 lockInterruptibly方法

lockInterruptibly()可中断获取锁。可以在获得锁的同时保持对中断的响应,比其他获取锁的方式稍微复杂一点,需要两个try-catch块(如果在获取锁的操作中抛出了InterruptedException,那么可以使用标准的try- finally加锁模式)举例来说,假设有两个线程同时通过lock.lockInterruptibly()获取某个锁,若线程A获取到了锁,则线程B只能等待,若此时对线程B调用threadB.interrupt方法能够中断线程B的等待过程。(注意:当一个线程获取了锁后,是不会被中断方法中断的,只能中断阻塞状态的线程)

4.5 ReentrantLock原理

在ReentrantLock内部持有一个volatile的成员变量state,获取锁和释放锁的时候会读写state的值,根据相关的happens-before规则:

  1. 顺序性规则:对于线程T1,value+=1 happend-before释放锁的操作unlock;
  2. volatile变量规则:由于state = 1 会先读区state,所以线程T1都unlock操作happend-before线程T2的lock操作;
  3. 传递性规则:线程T1的value+=1 happend-before线程T2都lock操作。

在阅读ReentrantLock源码,可以发现它有个核心字段:

private final Sync sync;

sync:这是一个内部抽象类,Sync继承于AQS,它有两个子类:ReentrantLock.FairSync(公平锁),ReentrantLock.NonfairSync(非公平锁)。

4.6 ReentrantLock的获取锁和释放锁

ReentrantLock 获取锁和释放锁的接口,从表象看,是调用 ReentrantLock.FairSync 或 ReentrantLock.NonfairSync 中各自的实现;从本质上看,是基于 AQS 的实现。具体的AQS原理在上篇文章中已有讲解。

  1. void lock() 调用 Sync 的 lock() 方法。
  2. void lockInterruptibly() 直接调用 AQS 的 获取可中断的独占锁 方法 lockInterruptibly()。
  3. boolean tryLock() 调用 Sync 的 nonfairTryAcquire() 。
  4. boolean tryLock(long time, TimeUnit unit) 直接调用 AQS 的 获取超时等待式的独占锁 方法 tryAcquireNanos(int arg, long nanosTimeout)。

5.void unlock() 直接调用 AQS 的 释放独占锁 方法 release(int arg) 。

nonfairTryAcquire 方法源码如下:

// 公平锁和非公平锁都会用这个方法区尝试获取锁
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
    if (compareAndSetState(0, acquires)) {
        // 如果同步状态为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;
}

处理流程:

  1. 如果同步状态为0,设置同步状态为acpuires,并设置当前线程为排他线程,然后返回true,获取锁成功
  2. 如果同步状态不为0,且当前线程为拍它线程,设置同步状态为当前状态值+acpuires的值,然后返回true,获取锁成功
  3. 否则,返回false,获取锁失败

五.ReentrantReadWriteLock

5.1 原理

ReadWriteLock适合读多写少的场景。

ReentrantReadWriteLock类是ReadWriteLock接口的具体实现,它是一个可重入的读写锁,ReentrantReadWriteLock维护了一对读写锁,将读写分开,有利于提高开发效率。

读写锁需要遵守三条基本原则:

  1. 允许多个线程同时共享变量
  2. 只允许一个线程写共享变量
  3. 如果一个写线程正在执行写操作,此时禁止读线程读共享变量

读写锁和互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。

5.2 特性

  1. ReentrantReadWriteLock适用于读多写少的场景,如果是写多读少的场景,由于ReentrantReadWriteLock其内部实现比ReentrantLock复杂,性能可能反而要差一些。
  2. 允许多个读操作并发执行,但每次只允许一个写操作。
  3. ReentrantReadWriteLock为读写锁都提供了可冲入的加锁语义。
  4. 支持公平锁和非公平锁两种模式

ReadWriteLock的接口定义:

public interface ReadWriteLock {
    //返回用于读取的锁。返回:用于读取的锁
    Lock readLock();

    //返回用于写入的锁。返回:用于写入的锁
    Lock writeLock();
}

在读写锁和写入锁之间的交互可以采用多种方式,ReadWriteLock的一些可选实现包括:

  1. 释放优先:当一个写入操作释放写锁,并且队列中同时存在读线程和写线程,那么应该优先选择读线程,写线程,还是最先发出请求的线程?
  2. 读线程插队:如果锁是由读线程持有,但是写线程正在等待,那么新线程能否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前,那么将提高并发性,但可能造成线饥饿问题。
  3. 重入性:读锁和写锁是否是可重入的?
  4. 降级:如果一个线程持有写锁,那么它能否在不释放该锁的情况下获得读锁?这可能会使得写锁被降级为读锁,同时不允许其他写线程修改被保护的资源
  5. 升级:读锁能否优先于其他正在等待的读线程和写线程而升级为一个死锁?在多数的读写锁实现中并不支持升级,因为如果没有显式的升级操作,那么很容易造成死锁。

六. StampedLock

ReadWriteLock支持两种模式:一是读锁,二是写锁。而StampedLock支持三种模式,分别是写锁悲观读锁乐观读。其中写锁和悲观读锁的语义和ReadWriteLock的写锁,读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。不同的是StampedLock里的写锁和悲观读锁加锁成功后都会返回一个stamp,然后解锁的时候需要传入这个stamp。

⚠️注意这里用的是“乐观读”这个词,相较于乐观读锁,乐观读这个操作是无锁的,所以性能会更好。

执行乐观读时,有几个特性:

  1. ReadWriteLock支持多个线程同时读,但是当多个线程同时读的时候,所有写操作会被阻塞
  2. 而StampedLock提供的乐观读,是允许一个线程获取写锁的

对于读多写少的场景StampedLock性能很好,基本上可以替代ReadWriteLock,但是StampedLock的功能仅仅是ReadWriteLock的子集,在使用的时候,有几个地方需要注意下:

  1. StampedLock不支持重入
  2. StampedLock的悲观读锁,写锁都不支持条件变量
  3. 如果线程阻塞在StampedLock做的readLock或者writeLock上时,此时带哦用该阻塞线程的interrupt方法,会导致cpu飙升,使用StampedLock一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁readLockInterruptibly和写锁writeLockInterruptibly

七.死锁

7.1 什么是死锁?

死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。通常来说,我们大多是聚焦在多线程场景的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。

7.2 如何定位死锁?

定位死锁最常见的方式就是利用jstack等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁,如果是比较明显的死锁,往往jstack就能直接定位,类似JConsole甚至可以图形化界面进行有限的死锁检测。

如果我们是开发自己的管理工具,需要用更加程序化的方式扫描服务进程、定位死锁,可以考虑使用 Java 提供的标准管理 API,ThreadMXBean,其直接就提供了 findDeadlockedThreads() 方法用于定位。

7.3 如何避免死锁?

基本上死锁的发生是因为:

  1. 互斥,类似 Java 中 Monitor 都是独占的。
  2. 长期保持互斥,在使用结束之前,不会释放,也不能被其他线程抢占。
  3. 循环依赖,多个个体之间出现了锁的循环依赖,彼此依赖上一环释放锁。

由此,我们可以分析出避免死锁的思路和方法:

  1. 避免一个线程同时获取多个锁。
  2. 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
  3. 尝试使用定时锁 lock.tryLock(timeout),避免锁一直不能释放。
  4. 对于数据库锁,加锁和解锁必须在一个数据库连接中里,否则会出现解锁失败的情况。
  • 11
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值