2024-03-03

【谈一谈】并发编程_锁的分类

Hello!~大家好!~每天进步一点点,日复一日,我们终将问剑顶峰

这里主要是介绍下我们常用的锁可以分为几类,目的是整体框架作用~方便后续的并发文章

说白了,这篇就是开头哈~

本文总纲:

image.png

一.可重入锁和不可重入锁

我们开发中一般用到的都是可重入锁比如Synchronized,ReentrantReadWriteLock,ReentrantLock都是可重入的

不可重入的锁很少见,也不怎么用到(如果要用,一般都自己通过Lock定义实现)

1.可重入锁:

它也称为递归锁,啥意思呢?

  • 是指同一线程在获取锁(如A锁)之后,可以再次对该锁(A)进行获取,而不会造成死锁。
  • 这种锁支持同一个线程对资源的重复加锁,并且在释放锁时,必须是获取锁的次数与释放锁的次数相等时,才会真正释放锁

还是有点迷糊吗?我们举个JavaReentrantLock实现可重入锁的简单例子来加深理解:

ReentrantLock锁机制:(我先说下,不然看下面代码,估计不怎么好理解哈)

  • 内部维护了一个计数器
  • 每当线程获取锁时,计数器加1;每当线程释放锁时,计数器减1
  • 只有当计数器为0时,其他线程才有机会获取该锁

下面代码:

  • doSomething()方法调用了doSomethingElse()方法,由于ReentrantLock可重入特性,第二次调用lock()不会导致线程阻塞,而是使锁计数器加1
  • 当对应的unlock()方法被调用两次后,锁才会真正释放允许其他线程获取锁
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private final ReentrantLock lock = new ReentrantLock();

    public void doSomething() {
        lock.lock(); // 获取锁
        try {
            // 在这里执行临界区代码
            doSomethingElse();
        } finally {
            lock.unlock(); // 无论是否发生异常,最终都要释放锁
        }
    }

    private void doSomethingElse() {
        lock.lock(); // 同一线程内再次获取锁,不会阻塞
        try {
            // 在这里执行另一段临界区代码
        } finally {
            lock.unlock(); // 释放锁
        }
    }
}

2.不可重入锁

是指一个线程获取锁后,在未释放该锁的情况下,无法再次获取该锁的同步机制,必须等此锁释放后,才能再获取锁

细心的同学可能会发现,在Java的标准库中,并没有直接提供不可重入锁的实现,为什么??

  • 因为在多层级调用或递归场景下(大多数的并发场景中),它们很容易造成意外死锁问题,
  • 而可重入锁(如ReentrantLock)则可以安全地支持这些复杂情况。

举个例子:(不考虑复杂场景哈~你别杠,我们只是演示下,目的是懂)

通过简单的计数器模拟,当锁被获取时,增加计数器,有且仅有计数器为0是才允许获取锁

下面的代码:

  • 这样的锁如果在线程内部递归调用lock方法,将会导致后续尝试获取锁的操作阻塞,从而表现出不可重入的特性。
public class NonReentrantLock {
    private boolean isLocked = false;
    private Thread holdingThread;

    public synchronized void lock() throws InterruptedException {
        while (isLocked && holdingThread != Thread.currentThread()) {
            wait();
        }
        isLocked = true;
        holdingThread = Thread.currentThread();
    }

    public synchronized void unlock() {
        if (holdingThread == Thread.currentThread()) {
            isLocked = false;
            holdingThread = null;
            notifyAll();
        } else {
            throw new IllegalMonitorStateException("当前线程并未持有此锁");
        }
    }
}

二.乐观锁和悲观锁

1.乐观锁 (Optimistic Locking)

  • 是一种在读取数据时不会立即加锁,而是在更新数据时才会检查在此期间是否有其他事务对数据进行了修改的并发控制策略
  • 假设大多数情况下不会有冲突发生(很乐观吧~哈哈哈,),因此在进行数据操作时保持乐观态度

在补充下:

数据库系统中,乐观锁通常通过版本号时间戳等机制实现

  • 当一个事务准备更新数据时,它会首先检查该数据的版本号时间戳是否与最初读取时一致
  • 如果一致: 则执行更新操作并更新版本号或时间戳;
  • 如果不一致,则表示在此期间有其他事务对该数据进行了修改,此时当前事务通常会选择回滚以避免覆盖其他事务的更改。

例子:

Hibernate中使用@Version注解:

  • 每次更新MyEntity实例时,Hibernate都会自动检查并更新version字段,从而实现了乐观锁的效果

又如Java中提供的CAS操作,典型的乐观锁实现

再举个实际点: 一个整数版本号来模拟乐观锁机制

transfer方法尝试进行转账操作时,

  1. 首先记录下当前账户的版本号。
  2. 然后模拟可能存在其他事务的情况,这里简单地直接增加版本号以示意图。
  3. 最后,在真正执行更新操作前,再次检查版本号是否与最初读取时一致。
  • 如果一致,则执行转账逻辑并递增版本号;
  • 如果不一致,则表示存在并发冲突,转账操作失败。

这样就实现了一个基于版本号的乐观锁机制,它可以防止在并发环境下的数据不一致性问题。

public class Account {
    private int balance; // 账户余额
    private int version; // 数据版本号

    public Account(int initialBalance) {
        this.balance = initialBalance;
        this.version = 0;
    }

    // 使用乐观锁进行转账操作
    public boolean transfer(Account to, int amount) {
        // 保存当前账户和目标账户的原始版本号
        int originalVersion = this.version;
        
        // 模拟并发环境下可能出现的其他事务操作
        simulateOtherTransactions();

        // 尝试更新账户余额和版本号
        if (this.version == originalVersion) {
            // 更新前检查版本号未变
            if (this.balance >= amount) {
                this.balance -= amount;
                to.balance += amount;
                // 成功更新数据后,将版本号递增
                this.version++;
                return true;
            } else {
                System.out.println("余额不足,转账失败");
            }
        } else {
            System.out.println("并发冲突,有其他事务修改了账户数据,转账失败");
        }
        return false;
    }

    // 模拟在转账操作过程中可能存在的其他事务对账户数据的修改
    private void simulateOtherTransactions() {
        // 这里仅用于演示,在实际应用中可能是由其他线程或事务引起的
        // 假设另一个事务在此时修改了账户数据并增加了版本号
        this.version++;
    }
}

2.悲观锁(Pessimistic Locking)

获取不到锁资源时,会将当前线程挂起(进入Blocked或者waitting)(有着一种生于忧患意识)

官方术语:

  • 是一种在访问数据时假设会发生并发冲突,并立即对数据进行加锁以防止其他事务或线程对其进行修改的并发控制策略。
  • 倾向于认为每次对数据的操作都可能引发并发问题,所以在获取数据前就先锁定资源

这种操作例子在数据库层面经常能看见:

如: 当第一个事务执行SELECT ... FOR UPDATE时,

  • 会对当前的查询记录进行锁定,此时其他任何事务试图读取或修改这条记录都会被阻塞,直到第一个事务提交或回滚释放锁

如: 在Java应用层面,

  • JDBC中的java.sql.Connection提供的setAutoCommit(false)方法:
  • 可以开启手动事务管理,配合数据库的悲观锁机制实现更细粒度的并发控制。

再举个: 以synchronized关键字为例,提供一个简单的线程安全的银行账户转账操作

  • transfer()方法通过synchronized关键字修饰,这意味着在同一时间只能有一个线程访问这个方法。

  • 当一个线程调用transfer进行转账操作时,其他线程必须等待当前线程完成操作并释放锁后才能继续执行。

    这就是悲观锁的应用,它假设并发环境下会存在数据冲突,并直接对资源进行锁定,以防止多个线程同时修改共享资源导致的数据不一致问题。

public class BankAccount {
    private double balance;
    
    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    // 使用synchronized实现悲观锁
    public synchronized void transfer(BankAccount to, double amount) throws InterruptedException {
        if (this.balance >= amount) {
            // 锁定当前对象(即锁定该方法),确保同一时间只有一个线程能执行此方法
            Thread.sleep(100); // 模拟耗时操作,如数据库查询或更新
            this.balance -= amount;
            to.balance += amount;
            System.out.println("From: " + Thread.currentThread().getName() + ", Transfer " + amount + " completed.");
        } else {
            throw new IllegalArgumentException("Insufficient balance.");
        }
    }

    public static void main(String[] args) {
        BankAccount accountA = new BankAccount(100);
        BankAccount accountB = new BankAccount(0);

        Thread thread1 = new Thread(() -> accountA.transfer(accountB, 50));
        Thread thread2 = new Thread(() -> accountA.transfer(accountB, 60));

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final balances: A=" + accountA.balance + ", B=" + accountB.balance);
    }
}

三.公平锁和非公平锁

这当中的关键点就是: 是否正常排队

1.公平锁

  • 是一种线程调度策略,它保证了等待锁的线程按照它们请求锁的顺序获得锁。
  • 公平锁机制下,当锁释放时,会优先分配给已经在队列中等待时间最长的线程,而不是随机选择一个等待的线程。

注意:

  • 在公平锁环境下,如果有多个线程在等待获取锁,那么锁会被分配给等待时间最久的那个线程,这种策略能够减少"线程饥饿"(即某些线程长时间无法获取到锁)的问题,提高系统的整体公平性
  • 公平锁虽然在理论上提供了更好的公平性,但可能会降低系统的整体吞吐量
    • 因为每次释放锁时都需要维护和检查等待队列,并且需要考虑线程上下文切换的成本。
    • 而在非公平锁(默认情况下)中,获取锁的线程可能是最近刚刚尝试获取锁的线程,这可能导致更高的并发性和系统性能,但可能也会导致某些线程长期得不到执行机会。

举个例子:

假设我们有一个共享资源(一个计数器)需要多个线程安全地进行递增操作:

ReentrantLock类:参数设为true,可以创建一个公平锁

在这个例子中:

我们创建了一个公平锁,并在一个共享的计数器上进行了递增操作

  • 当多个线程同时调用increment()方法时,公平锁会确保等待时间最长的线程优先获得锁并执行操作。

  • 由于每个线程在操作后都休眠了100毫秒,这有助于模拟实际的并发环境,使得不同线程之间的执行顺序更易于观察。

  • 公平锁策略下,理论上线程获取锁的顺序将尽可能按照它们请求锁的时间顺序进行,因此输出的结果应能体现出相对有序的执行过程。

import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.IntStream;

public class FairLockExample {
    private final ReentrantLock lock = new ReentrantLock(true); // 创建一个公平锁
    private int counter = 0;

    public void increment() {
        lock.lock();
        try {
            counter++;
            System.out.println(Thread.currentThread().getName() + " incremented the counter to: " + counter);
            Thread.sleep(100); // 模拟耗时操作
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        FairLockExample example = new FairLockExample();

        IntStream.range(0, 10)
                .forEach(i -> new Thread(() -> example.increment(), "Thread-" + i).start());

        Thread.sleep(5000); // 等待所有线程执行完成
        System.out.println("Final counter value: " + example.counter);
    }
}

2.非公平锁

是一种线程调度策略,与公平锁相反,

  • 在释放锁后并不保证等待时间最长的线程一定能获得锁
  • 当锁可用时,非公平锁可能会允许任何一个正在等待获取锁的线程获取锁,即使有其他线程已经等待了更长的时间。(适者生存,能者居之)

模拟场景说明:

线程A获取到锁资源,线程B没有拿到,线程B去排队,这时线程C跑来了,线程C咋么做呢?

  • 首先去尝试竞争一波
  • 竞争成功: 拿到锁,美滋滋进行执行
  • 竞争失败: 没有拿到锁资源,老老实实的排到B的后面,直到B拿到锁资源或者B取消后,才去竞争锁资源

举个例子:

使用非公平锁实现多线程安全递增操作的例子

在这个例子中

  1. 我们创建了一个非公平锁,并在一个共享的计数器上进行了递增操作
  2. 当多个线程同时调用increment()方法时,非公平锁可能让任何等待锁的线程获取到锁,而不考虑它们等待的先后顺序。
  3. 因此,输出的结果可能显示出线程获取锁和执行的相对无序性
  4. 虽然非公平锁可能导致某些线程“饥饿”(长时间无法获取锁),但在某些情况下,它能提供更高的吞吐量,因为减少了线程上下文切换的成本和队列维护的开销
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.IntStream;

public class NonFairLockExample {
    private final ReentrantLock lock = new ReentrantLock(); // 创建一个非公平锁(默认false)
    private int counter = 0;

    public void increment() {
        lock.lock();
        try {
            counter++;
            System.out.println(Thread.currentThread().getName() + " incremented the counter to: " + counter);
            Thread.sleep(100); // 模拟耗时操作
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        NonFairLockExample example = new NonFairLockExample();

        IntStream.range(0, 10)
                .forEach(i -> new Thread(() -> example.increment(), "Thread-" + i).start());

        Thread.sleep(5000); // 等待所有线程执行完成
        System.out.println("Final counter value: " + example.counter);
    }
}

完结撒花

[图片上传失败...(image-485011-1709439057683)]

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容