关于锁的思考及总结(线程锁,进程锁,分布式锁)

Java 中提供了种类丰富的锁,每种锁因有不同的特性在不同的场景能够展现出较高的性能

一.线程锁

1.乐观锁 & 悲观锁

乐观锁和悲观锁是一种广义上的概念,体现了线程对互斥资源进行同步的两种不同的态度,在 Java 和数据中都有实际的运用。

对一个互斥资源的同步操作,悲观锁认为自己访问时,一定有其它线程来修改,因此在访问互斥资源时悲观锁会先加锁;而乐观锁认为自己在访问时不会有其它线程来修改,访问时不加锁,而是在更新数据时去判断有无被其他线程修改,若没被修改则写入成功,若被其他线程修改则进行重试或报错。

乐观锁适用于读操作多的场景,而悲观锁适用于写操作多的场景。

我们常见的synchronized、ReentrantLock 都属于悲观锁,而AtomicInteger.incrementAndGet 则属于乐观锁。

        // ----------------- 悲观锁 -------------------------
        synchronized (MUTEX) {
            // 同步代码块
        }

        ReentrantLock lock = new ReentrantLock();
        lock.lock();
        // 同步代码块
        lock.unlock();

        // ----------------- 乐观锁 -------------------------

        AtomicInteger atomicInteger = new AtomicInteger(0);
        atomicInteger.incrementAndGet();

 悲观锁的实现方式很直观,先进行加锁,然后访问互斥资源,最后释放锁;而乐观锁则是通过CAS算法完成的,乐观锁一般与版本号机制或时间戳机制配合使用。

CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的同步。
CAS算法涉及到三个操作数:当前内存值 V、原始值 A、要写入的新值 B。
当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。

但是CAS会出现出现这样的问题:执行一次比较时,V对应的值是A,与预期值相同,但是在这期间A已经被其他线程改为B,又被一个线程改回A,那么CAS仍然会更新值。也就是说当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次,而经过两次修改后,对象的值又恢复为旧值,这样当前线程无法正确判断这个对象是否修改过。所以CAS一般配合版本号机制或时间戳机制使用,即增加版本号,每次修改数据时对比版本号来确保原子性。

2.阻塞 & 非阻塞 

唤醒和阻塞一个Java线程需要操作系统进行用户态到内核态的切换,这种切换是十分耗时处理器时间的,如果同步代码块的内容过于简单,状态转换消耗的时间可能比用户代码执行时间还长,这是十分不划算的,因此我们引入了非阻塞的概念。

多线程访问互斥资源时,当互斥资源已被占用,阻塞线程,当互斥释放时,唤醒线程进行竞争称为阻塞式同步;而当互斥资源被占用时,不进行线程阻塞而通过自旋等待其它线程释放锁或直接返回错误的方式称为非阻塞式同步,自旋方式又可以分为普通自旋和自适应自旋。

非阻塞自旋虽然避免了线程切换的开销但是会占用处理器的时间,如果锁被占用的时间很短,那么自旋等待的效果很好,如果锁被占用时间很长那么只会白白浪费处理器时间。所以自旋一般会设置一定限制,比如Java中默认是10次。这就是普通自旋。

自适应自旋意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。 

因此,阻塞式同步适用于同步代码块执行时间比较长,线程获取锁时间间隔比较长的场景,而非阻塞式同步适用于同步代码块执行比较短,线程获取锁时间间隔比较短的场景。

ReentrantLock以及synchronized中的重量级锁都属于阻塞式同步,而 Java 中的原子操作类中的 CAS 则运用了非阻塞自旋的思想。 

3.公平锁 & 非公平锁

公平锁和非公平锁指的是获取线程获取锁时的顺序。公平锁指按照锁申请的顺序来获取锁,线程直接进入队列中,队列中的第一个线程才能获取锁。非公平锁指多个线程获取锁时,直接尝试获取锁,只有当线程未获取到锁时才放入队列中。

公平锁的优点是不会造成饥饿,但整体性能会比非公平锁低,因为除等待队列中的第一个线程,其它线程都需要进行阻塞和唤醒操作。而非公平锁有几率直接获得锁,减少了线程阻塞和唤醒的次数,但可能会造成饥饿。因此在饥饿无影响或不会产生饥饿的场景下优先考虑非公平锁。

ReentrantLock 提供了公平锁和非公平锁两种实现,默认使用非公平锁。而synchronized锁只提供非公平锁。

4.可重入锁(递归锁) & 不可重入锁

可重入锁又称递归锁,是指同一线程在外层获取锁后,进入内层方法再次获取同一锁时会自动获取锁。可重入锁的好处是可以一定程度避免死锁。

不可重入锁是一种简单的锁,它不允许同一个线程多次获取同一个锁。如果一个线程已经持有了这个锁,再次尝试获取这个锁的时候,会被阻塞,直到之前持有锁的线程释放了这个锁。这种锁的实现通常比较简单,因为不需要考虑同一个线程多次获取锁的情况,不会出现死锁的情况。然而,不可重入锁也有一些局限性,因为在某些情况下,需要同一个线程多次获取同一个锁的能力。因此,在实际开发中,一般会选择可重入锁来实现更灵活的同步控制。

Java 中 ReentrantLock 和 synchronized 都是可重入锁。

// ReentrantLock FairSync
// 获取锁
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;
        }
    }
    // 重点:在已经获取锁的情况下,对比当前线程ID和占用锁线程ID是否一致,若一致锁计数器 +1
    // 不可重入的情况下,则无此判断
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

// 释放锁
protected final boolean tryRelease(int releases) {
    // 每次释放时进行-1
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    // 直到计数器为 0 代表锁释放
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}
 5.排它锁 & 共享锁(读写锁)

排它锁和共享锁的主要区别在于互斥资源锁是否能被多个线程同时持有。同时只能被一个线程持有称为排它锁;当能够被多个线程同时持有称为共享锁。

进一步细化加锁粒度,提高并发性能。比如我们常见读写锁,实现读读不互斥,高效并发读,而读写、写读、写写的过程互斥。

例如ReentrantReadWriteLock 读写锁,ReentrantReadWriteLock 中有两把锁 ReadLock 和 WriteLock ,一个是读锁为共享锁,一个是写锁为排它锁:当前线程已获取读锁无写锁,其它线程可以获取读锁;当前线程已获取写锁,仅当前线程可以获取读锁。

synchronized则为排他锁(独占锁)。

6.无锁、偏向锁、轻量级锁、重量级锁(synchronized锁升级过程)

synchronized最开始是无锁状态,无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。

当一个synchronized锁连续只被一个线程访问时,它会从无锁状态升级为偏向锁。偏向锁是一种优化锁性能的策略,其核心思想是减少不必要的锁竞争开销。当只有一个线程访问锁时,JVM 将这个锁"偏向"到这个线程,会将这个线程的ID记录在对象头中,意味着在此后的几次尝试中,该线程可以无需同步操作就能获取这个锁。这大大减少了锁获取和释放的开销,提升了程序的运行效率。

当有多个线程想访问偏向锁时,则会撤销偏向锁,升级为轻量级锁。线程会尝试使用CAS操作获取锁。如果CAS操作成功,则线程获得轻量级锁;如果失败,线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

如果锁竞争加剧(如线程自旋次数或者自旋的线程数超过某阈值,JDK1.6 之后,由 JVM 自己控制改规则),就会升级为重量级锁。重量级锁依赖于系统层面的Mutex Lock,会使线程阻塞,性能开销较高。

7.死锁

死锁是一种现象:如线程A持有资源x,线程B持有资源y,线程A等待线程B释放资源y,线程B等待线程A释放资源x,两个线程都不释放自己持有的资源,则两个线程都获取不到对方的资源,就会造成死锁。Java中的死锁不能自行打破,所以线程死锁后,线程不能进行响应。所以一定要注意程序的并发场景,避免造成死锁。

8.锁粗化&锁消除

锁粗化就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求。

锁消除是指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。

9.Lock&Synchronized&ReentrantLock 区别

关键字synchronized:synchronized是Java语言内置的关键字,可以用来实现对象级别的锁,也可以用来实现方法级别的锁。使用synchronized时,不需要手动释放锁,当程序执行完同步代码块或同步方法后,锁会自动释放。

Lock接口:Lock接口是JDK提供的用于替代synchronized的机制,通过Lock接口及其实现类,可以实现更灵活的线程同步和互斥访问。Lock接口提供了lock()、tryLock()和unlock()等方法,需要手动释放锁,使用更加灵活。

ReentrantLock类:ReentrantLock是Lock接口的一个具体实现类,其与synchronized相比,提供了更多的功能和灵活性。例如,ReentrantLock支持可重入锁,即同一个线程可以多次获取同一把锁而不会造成死锁;可以实现公平锁和非公平锁;支持超时获取锁等功能。

总的来说,synchronized是最简单的实现方式,但灵活性较差,而Lock接口及其实现类提供了更多的功能和灵活性,适用于更复杂的线程同步场景。在实际应用中,可以根据具体需求来选择合适的同步机制。

 二.进程锁

进程锁的作用是保证同一时刻只有一个进程或线程可以访问共享资源,其他进程或线程需要等待当前进程锁的释放才能进行访问。进程锁通常包括两种类型:互斥锁和信号量。

首先来看一下互斥量。互斥量是最基本的同步原语,它用于确保在任意时刻只有一个线程可以访问共享资源。互斥量的特点是它只有两种状态:锁定和非锁定。当一个线程获得了互斥量的锁时,其他线程就会被阻塞,直到该线程释放了锁。这种特性使得互斥量非常适合于对临界资源的互斥访问,保证了共享资源的完整性和一致性。

而信号量则是一种更加灵活的同步原语。信号量不仅可以用于实现互斥访问,还可以用于控制对资源的访问权限和数量。信号量可以有多个状态,通常包括二进制信号量和计数信号量两种类型。二进制信号量只有两种取值:0和1,用于实现互斥访问;而计数信号量可以有多个取值,用于控制资源的访问数量。

三.分布式锁 

为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。在单机环境中,Java中提供了很多并发处理相关的API。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题。

1.基于数据库实现排他锁

悲观锁: 悲观锁基于数据库的排他锁机制,即在获取锁时直接对数据库记录进行锁定,防止其他事务修改。在实现中,可以使用数据库支持的锁语句,如 SELECT ... FOR UPDATE。当事务想要获取锁时,会阻塞其他事务对同一行记录的修改,从而实现锁的效果。

乐观锁: 乐观锁通过记录版本号或时间戳来实现。在获取锁前,先读取记录的版本号或时间戳,然后在修改时检查是否与之前读取的值相同,如果相同则表示没有其他事务干扰,可以执行更新操作。如果不同,则说明其他事务已经修改了记录,需要处理冲突。

 基于数据库表唯一主键:利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个插入操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。

2. 基于Zookeeper实现分布式锁 

Zookeeper 的数据存储结构就像一棵树,这棵树由节点组成,这种节点叫做 Znode。

Znode 分为四种类型:

1.持久节点 :默认的节点类型。创建节点的客户端与 zookeeper 断开连接后,该节点依旧存在 。

2.持久节点顺序节点:所谓顺序节点,就是在创建节点时,Zookeeper 根据创建的时间顺序给该节点名称进行编号。

3.临时节点:和持久节点相反,当创建节点的客户端与 zookeeper 断开连接后,临时节点会被删除。

4.临时顺序节点:顾名思义,临时顺序节点结合和临时节点和顺序节点的特点,在创建节点时,Zookeeper 根据创建的时间顺序给该节点名称进行编号;当创建节点的客户端与 zookeeper 断开连接后,临时节点会被删除。

首先,在Zookeeper当中创建一个持久节点 A。当第一个客户端想要获得锁时,需要在 A 这个节点下面创建一个临时顺序节点 a。 之后,Client1 查找 A下面所有的临时顺序节点并排序,判断自己所创建的节点 a是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。

这时候,如果再有一个客户端 Client2 前来获取锁,则在A下再创建一个临时顺序节点b。Client2查找A下面所有的临时顺序节点并排序,判断自己所创建的节点b是不是顺序最靠前的一个,结果发现节点b并不是最小的。于是,Client2向排序仅比它靠前的节点a注册Watcher,用于监听a节点是否存在。这意味着Client2抢锁失败,进入了等待状态。

3.基于redis实现 
SETNX key value

setnx 是SET if Not eXists(如果不存在,则 SET)的简写。如果不存在set成功返回int的1,这个key存在了返回0。

SETEX key seconds value

这样避免了因为持有锁的进程崩溃导致的锁无法释放的问题。

DEL lock

当我们不再需要锁的时候,可以使用DEL命令来删除这个锁

或者通过Redis的Lua脚本也可以 实现分布式锁。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值