深入理解并发编程之锁的分类

深入理解并发编程之锁的分类


前言

锁是为了保障多线程在同时操作一组资源时的数据一致性,当多个线程在同时共享到同一变量的时候,可能会收到其他线程的干扰,给资源加上锁之后,只有拥有此锁的线程才能操作此资源,而其他线程只能排队等待使用此锁。我们根据不同的评判标准可以把锁分为很多种类。

一、悲观锁/乐观锁

悲观锁

悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。(百度百科)

Mysql的行锁就是使用的悲观锁,看下面例子:
正常我们的事务操作是这样的:首先开启一个事务Begin -> 然后是我们的具体执行的代码 -> 最后是提交事务,看一下这个流程是不是和lock锁有点相似。其实MySQL的事务操作就是对锁的操作。
建立个User表(假设有:id和name两个字段),我们写段代码来操作User表:

	//一会执行update ID为1的用户
    public String updateUser(UserEntity userEntity) {
        TransactionStatus begin = transactionUtils.begin();
        int updatesult = userMapper.updateById(userEntity);
        // 这里把事务的提交屏蔽掉,去查看下MySQL的锁
//        transactionUtils.commit(begin);
        return updatesult > 0 ? "success" : "fail";
    }

执行下面命令:

select * from information_schema.innodb_trx t

可以看到这样的结果,这就证明了我们的猜测,MySQL的事务其实是锁的操作,因为我们没有提交或回滚,所以锁一直没有释放。
在这里插入图片描述
我们再次执行下update操作(操作同一条数据),因为数据被上了锁,而且锁没有被释放,我们会得到下面的结果,多了一条锁的等待,因为请求的数据被上了锁,然后超过等待时间后会报个异常,锁等待超时。
在这里插入图片描述
上面说的就是Mysql的行锁,悲观锁典型的特征就是把资源给锁死了,其它线程是无法操作锁死的资源的。

我们在查询订单的时候就已经把数据给锁了
常见的悲观锁例如MySQL的行锁、表锁、Lock锁、Synchronized修饰符等。

乐观锁

乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。而乐观锁机制在一定程度上解决了这个问题。乐观锁,大多是基于数据版本( Version )记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号等于数据库表当前版本号,则予以更新,否则认为是过期数据。(百度百科)

乐观锁是是没有锁的概念的,底层是通过阈值或版本号来控制的。
乐观锁的步骤就是:比较修改的数据版本号是否与之前一致,如果一致修改数据并且版本号+1结束操作,如果不一致获取新的版本号,重新操作一直循环到操作结束为止。
我们再修改一下user表,增加一个version版本字段,现在user表有(id,name,version)三个字段,然后看下面代码:

    public String optimisticLock(UserEntity userEntity) {
        Long userId = userEntity.getUserId();
        // 标记该线程是否修改成功
        Integer resultCount = 0; //cas 灵活控制超时
        while (resultCount <= 0) { //如果操作的结果是0条继续循环操作,当然也可以控制最多循环几次
            // 1.根据Id 查找到对应的VERION版本号码 获取当前数据的版本号码 VERION=1
            UserEntity dbUserEntity = userMapper.selectById(userId);
            if (dbUserEntity == null) {
                return "未查询到该用户";
            }
            // 2.做update操作的时候,放入该版本号码  乐观锁
            userEntity.setVersion(dbUserEntity.getVersion());
            resultCount = userMapper.updateById(userEntity);
        }
        return resultCount > 0 ? "success" : "fail";
    }

上面的更新SQL:

UPDATE user SET name=?, version=version + 1 WHERE id =? AND version=?

debug启动代码,首先执行一次操作,把代码停在update操作之前,然后再发一次请求,直接执行通过,因为第二次修改成功了,所以第一次的版本号就不一致了,第一次操作的断点放开,会发现循环又走了一遍。这就是乐观锁执行的方式。
常见的除了版本号控制还有乐观锁有基于CAS实现的原子类(后面细讲)

乐观锁与悲观锁的比较

  • 首先悲观锁比乐观锁的使用范围更广,能用乐观锁实现的地方肯定能用悲观锁实现,悲观锁会锁住代码块或数据,导致其他线程无法访问,加锁和释放锁也需要消耗资源,这肯定影响效率,而且悲观锁可能会造成死锁。
  • 乐观锁一般操作的是单一数据,如果操作多个数据比较复杂,需要判断多个数据的版本不一致同时一致才能修改,容易出逻辑问题,所以一遍操作的数据比较多的时候最好使用悲观锁。
  • 当线程并发量非常大的时候,乐观锁一直循环执行查询比较修改这是非常消耗CPU和内存资源的,所以一般线程并发大的时候尽量使用悲观锁,线程并发比较小的时候使用乐观锁。

二、公平锁/非公平锁

公平锁非公平锁是通过new ReentrantLock(true)的参数来设置的,当传递true的时候是公平锁,当传递false的时候是非公平锁。

公平锁

公平锁:就是比较公平,根据请求锁的顺序排列,先来请求的就先获取锁,后来获取锁就最后获取到, 采用队列存放 类似于吃饭排队。
看一下下面代码:

public class MayiktLock implements Runnable {
    private static int count = 0;
    private static Lock lock = new ReentrantLock(true);
    private static CountDownLatch latch = new CountDownLatch(10);

    @Override
    public void run() {
        while (count < 200) {
            lock.lock();
            System.out.println(Thread.currentThread().getName() + ",count:" + count);
            count++;
            lock.unlock();
        }
        latch.countDown();
    }

    public static void main(String[] args) throws InterruptedException {
        long startTime = System.currentTimeMillis();
        ArrayList<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new MayiktLock());
            thread.start();
            threads.add(thread);
        }
        latch.await();
        long endTime = System.currentTimeMillis();
        System.out.println(endTime - startTime);
    }
}

执行上面的代码我们可以看到,每个线程都参与执行了而且参与的时间比较均匀,最后用时为9毫秒:
在这里插入图片描述

非公平锁

非公平锁:不是根据根据请求的顺序排列, 通过争抢的方式获取锁。
再看上面的代码,修改为false非公平锁:

public class MayiktLock implements Runnable {
    private static int count = 0;
    private static Lock lock = new ReentrantLock(false);
    private static CountDownLatch latch = new CountDownLatch(10);

    @Override
    public void run() {
        while (count < 200) {
            lock.lock();
            System.out.println(Thread.currentThread().getName() + ",count:" + count);
            count++;
            lock.unlock();
        }
        latch.countDown();
    }

    public static void main(String[] args) throws InterruptedException {
        long startTime = System.currentTimeMillis();
        ArrayList<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new MayiktLock());
            thread.start();
            threads.add(thread);
        }
        latch.await();
        long endTime = System.currentTimeMillis();
        System.out.println("总共消耗时间:"+(endTime - startTime));
    }
}

最后的执行结果,我们可以看到其中有线程大批量执行,最后的执行时间为5毫秒:
在这里插入图片描述

公平锁与非公平锁的比较

根据上面的例子我们可以得到结论,非公平锁比公平锁的效率比较高,因为公平锁需要控制每个线程按照一定的顺序去唤醒,然后阻塞其他线程,损耗了时间,而非公平锁不需要控制;但是非公平锁可能执行到结束都有可能有线程没有参与,而公平锁每个线程都参与了(但是这一点其实不重要,没有多少需要这个场景的),所以推荐使用非公平锁。

三、共享锁/独占锁

共享锁

共享锁:多个线程可以同时持有锁,例如ReentrantLock读写锁。读读可以共享、写写互斥、读写互斥、写读互斥。
如下代码:

public class MyTask extends Thread {
    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public void read() {
        lock.readLock().lock();
        System.out.println(">>>" + Thread.currentThread().getName() + ",正在读取锁开始");
        try {
            Thread.sleep(1000);
        } catch (Exception e) {

        }
        System.out.println(">>>" + Thread.currentThread().getName() + ",正在读取锁结束");
        lock.readLock().unlock();
    }

    public void write() {
        lock.writeLock().lock();
        System.out.println(">>>" + Thread.currentThread().getName() + ",正在写入锁开始");
        try {
            Thread.sleep(1000);
        } catch (Exception e) {

        }
        System.out.println(">>>" + Thread.currentThread().getName() + ",正在写入锁结束");
        lock.writeLock().unlock();
    }

    public static void main(String[] args) {
        MyTask myTask = new MyTask();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> myTask.read()).start();
        }
        for (int i = 0; i < 10; i++) {
            new Thread(() -> myTask.write()).start();
        }
//        lock.writeLock()
    }
}

执行结果可以看到多个线程可以同时读取数据,不需要等待释放锁:
在这里插入图片描述

独占锁

独占锁:在多线程中,只允许有一个线程获取到锁,其他线程都会等待。
还是上面的代码打印下半部分写操作可以看到当做写操作的时候多个线程不可以同时操作数据,只有等一个线程结束另一个线程才能开始:
在这里插入图片描述

四、可重入锁/不可重入锁

可重入锁

可重入锁,指的是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以,synchronized(后面详细分析) 和 ReentrantLock 都是可重入锁。
看下面的例子理解起来就比较方便了:

public class MyThread {

    public synchronized  void create(){
        a();
    }

    private synchronized void a() {
    }
}

我们知道方法加上synchronized修饰符后锁的是this,也就是MyThread的对象,create()方法和 a()方法同时加了synchronized,而a()是在create()里面的,执行create()后a()线程不会阻塞,因为锁标记跟线程是关联的,锁标记已经被当前线程持有了,所以当前线程可以继续加锁,这就是锁的可重入性,可重入锁。是指在当前锁中可以继续对当前锁锁住的对象加锁,这样不会造成死锁。因为是锁的同一个锁标记。

不可重入锁

不可重入锁,指的也是以线程为单位,当一个线程获取对象锁之后,这个线程不可以再次获取本对象上的锁。当然我们平常使用的锁都是可重入锁,上面也说过原因了:因为锁标记跟线程是关联的,锁标记已经被当前线程持有了。
当然我们可以自己设计一个不可重入锁:

//这是我们自定义的一个锁
public class Lock{
    private boolean isLocked = false;
    public synchronized void lock() throws InterruptedException{
        while(isLocked){    
            wait();
        }
        isLocked = true;
    }
    public synchronized void unlock(){
        isLocked = false;
        notify();
    }
}

来解释下上面的代码:首先我们肯定需要一个原生态的锁来锁住代码,这里我们使用了synchronized(原因是我们锁的lock实体类,这样就不会造成线程1创建锁而被线程2释放掉了)。锁是有lock()和unlock()两个操作的。我们现在要做的就是在lock()里面,禁止所有的线程再次进行lock()。这里我们加一个标志位,如果在执行lock的时候,其他线程已经执行lock()了,标志位修改为true,然后线程进入等待,直到线程释放锁修改回标志位来,唤醒正在等待的线程。

五、自旋锁/非自旋锁

自旋锁

自旋锁是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。锁在原地循环的时候,是会消耗cpu的,差不多相当于一直执行一个while死循环判断是否可以拿到锁了。
CAS就是典型的自旋锁,后面细讲

非自旋锁

非自旋锁尝试获取同步资源锁失败,不会一直来判断是否可以拿到锁了,而是等待其它线程释放锁后通知它。
互斥锁就是一种非自旋锁。

六、可中断锁/不可中断锁

可中断锁

中断锁就是可以相应中断的锁。
如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。
Lock是可中断锁
看下面代码,线程1未释放锁则线程2永远得不到锁:

public class InterruptiblyExample {
    public static void main(String[] args) {
        Lock lock = new ReentrantLock();

        // 创建线程 1
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                lock.lock();
                System.out.println("线程 1:获取到锁.");
                // 线程 1 未释放锁
            }
        });
        t1.start();

        // 创建线程 2
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                // 先休眠 0.5s,让线程 1 先执行
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 获取锁
                System.out.println("线程 2:等待获取锁.");
                lock.lock();
                try {
                    System.out.println("线程 2:获取锁成功.");
                } finally {
                    lock.unlock();
                }
            }
        });
        t2.start();
    }
}

执行结果:
在这里插入图片描述
lock有个方法lockInterruptibly可以设置为可中断锁:

public class InterruptiblyExample{

    public static void main(String[] args) throws InterruptedException {
        Lock lock = new ReentrantLock();

        // 创建线程 1
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    // 加锁操作
                    lock.lock();
                    System.out.println("线程 1:获取到锁.");
                } catch (Exception e) {
                    e.printStackTrace();
                }
                // 线程 1 未释放锁
            }
        });
        t1.start();

        // 创建线程 2
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                // 先休眠 0.5s,让线程 1 先执行
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 获取锁
                try {
                    System.out.println("线程 2:尝试获取锁.");
                    lock.lockInterruptibly(); // 可中断锁,不加这行则不起效果
                    System.out.println("线程 2:获取锁成功.");
                } catch (InterruptedException e) {
                    System.out.println("线程 2:执行已被中断.");
                }
            }
        });
        t2.start();

        // 等待 2s 后,终止线程 2
        Thread.sleep(2000);
        if (t2.isAlive()) { // 线程 2 还在执行主动的去中断线程2不让它等了
            System.out.println("执行线程的中断.");
            t2.interrupt();
        } else {
            System.out.println("线程 2:执行完成.");
        }
    }
}

执行结果:
在这里插入图片描述

不可中断锁

中断锁就是不可以相应中断的锁。
Synchronized不是可中断锁,只能一直处于等待状态。

总结

在这里插入图片描述

内容来源:蚂蚁课堂

  • 6
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值