Java面试-不得不说的“锁”事

锁是并发中非常非常重要的部分,从最开始学并发常用的synchronized或者Lock到更进一步了解并发编程,会发现锁非常的多,概念也很多,不容易区分。

在较为全面的了解了之后决定先写下这篇博客打个底,并在后期的学习中进一步完善我的锁的知识体系
本文整理自慕课网《玩转Java并发工具》

Lock接口

简介

  • 锁时一种工具,用于控制对共享资源的访问
  • Lock和synchronized是最常见的两个锁,他们都能够达到线程安全的目录,但是使用和功能上又有较大的不同
  • Lock接口最常见的实现类是ReentrantLock
  • 通常情况下Lock只允许一个线程访问共享资源,特殊情况也允许并发访问,如ReadWriteLock的ReadLock

为什么需要Lock

  • synchronized不够用!!
    • 效率低:锁释放的情况少、不支持尝试锁
    • 不够灵活(比不上读写锁):加锁和释放锁时机单一,每个锁只有个一个条件,不够用
    • 无法知道是否成功获得锁

方法介绍

Lock中声明了四个方法来获取锁

  • lock()
    • 最普通的获取锁,如果所被其他线程获得了,进行等待
    • Lock不会像synchronized一样在异常时自动释放锁
    • 使用时,一定要在finally中释放锁
    • lock不能被中断,一旦死锁就会永久等待
		lock.lock();
        try {
            //获取本锁保护的资源
            System.out.println(Thread.currentThread().getName()+"开始执行任务");
        }finally {
            lock.unlock();
        }
  • tryLock()

    • 尝试获取锁,如果当前锁没有被占用,则获取成功,否则获取失败
    • 可以根据是否获取到锁决定后续程序的行为
    • 该方法立刻返回,即使拿不到也不会等
  • tryLock(long time,TimeUnit unit)

    • 加超时时间的尝试获取锁,一段时间内等待锁,超时就放弃
    • tryLock()避免死锁案例代码
/**
 * 〈用trylock避免死锁〉
 *
 * @author Chkl
 * @create 2020/3/11
 * @since 1.0.0
 */
public class TryLockDeadLock implements Runnable {

    int flag = 1;
    static Lock lock1 = new ReentrantLock();
    static Lock lock2 = new ReentrantLock();

    public static void main(String[] args) {
        TryLockDeadLock r1 = new TryLockDeadLock();
        TryLockDeadLock r2 = new TryLockDeadLock();

        r1.flag = 1;
        r2.flag = 0;
        new Thread(r1).start();
        new Thread(r2).start();

    }

    
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            if (flag == 1) {
                try {
                    if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {

                        try {
                            System.out.println("线程1获取到了锁1");
                            Thread.sleep(new Random().nextInt(1000));

                            if (lock2.tryLock(800,TimeUnit.MILLISECONDS)){
                                try {
                                    System.out.println("线程1获取到了锁2");
                                    System.out.println("线程1获取到了两把锁");
                                    break;
                                }finally {
                                    lock2.unlock();
                                }
                            }else {
                                System.out.println("线程1获取锁2失败");
                            }

                        } finally {
                            lock1.unlock();
                            Thread.sleep(new Random().nextInt(1000));
                        }
                    } else {
                        System.out.println("线程1获取锁1失败,已重试");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }


            }
            if (flag == 0) {
                try {
                    if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {

                        try {
                            System.out.println("线程2获取到了锁1");
                            Thread.sleep(new Random().nextInt(1000));

                            if (lock1.tryLock(800,TimeUnit.MILLISECONDS)){
                                try {
                                    System.out.println("线程2获取到了锁2");
                                    System.out.println("线程2获取到了两把锁");
                                    break;
                                }finally {
                                    lock1.unlock();
                                }
                            }else {
                                System.out.println("线程2获取锁2失败");
                            }

                        } finally {
                            lock2.unlock();
                            Thread.sleep(new Random().nextInt(1000));
                        }
                    } else {
                        System.out.println("线程2获取锁1失败,已重试");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
  • lockInterruptibly()
    • 相当于把tryLock(long time,TimeUnit unit)的超时时间设置为无限长,在等待锁的过程中,线程可以中断
/**
 * 〈验证尝试获取锁期间可中断线程〉
 *
 * @author Chkl
 * @create 2020/3/11
 * @since 1.0.0
 */
public class LockInterruptibly implements Runnable {

    private Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        LockInterruptibly lockInterruptibly  = new LockInterruptibly();
        Thread thread0 = new Thread(lockInterruptibly);
        Thread thread1 = new Thread(lockInterruptibly);

        thread0.start();
        thread1.start();
        try {
            Thread.sleep(2000);
        }catch (InterruptedException e){
            e.printStackTrace();
        }
        //线程启动2秒后,一个线程获得锁并处于睡眠,另一个线程处于等待锁状态
        thread1.interrupt();

    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "尝试获取锁");
        try {
            lock.lockInterruptibly();
            try {
                System.out.println(Thread.currentThread().getName()+"获取到了锁");
                //等待5秒,期间第二个线程被中断
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + "睡眠期间被中断");
            } finally {
                lock.unlock();
                System.out.println(Thread.currentThread().getName() + "释放了锁");
            }
        } catch (InterruptedException e) {
            System.out.println(Thread.currentThread().getName() + "等锁期间被中断");
        }
    }
}

运行结果可能如下图所示(线程先执行顺序不一定)
中断thread0

Thread-0尝试获取锁
Thread-0获取到了锁
Thread-1尝试获取锁
Thread-1等锁期间被中断
Thread-0释放了锁

中断thread1

Thread-0尝试获取锁
Thread-1尝试获取锁
Thread-0获取到了锁
Thread-0睡眠期间被中断
Thread-0释放了锁
Thread-1获取到了锁
Thread-1释放了锁
  • unlock()
    • 解锁,最好每次都先把unlock写在finally内再写业务逻辑

可见性保证

  • lock符合happens-before规则,具有可见性
  • 当线程解锁,下一个线程加锁后可以看到所有前一个线程解锁前发生的所有操作

在这里插入图片描述

锁的分类

根据不同的划分标准,常见的锁的划分如思维导图所示
在这里插入图片描述

乐观锁和悲观锁

为什么会诞生非互斥同步锁(乐观锁)
  • 互斥同步锁(悲观锁)的劣势
    • 阻塞和唤醒带来的性能劣势
    • 永久阻塞:如果持有锁的线程被永久阻塞,如无限循环,死锁等活跃性问题,那么等待该线程释放锁的线程永远得不到执行
    • 优先级反转:阻塞的优先级高,持有锁的优先级低,导致优先级反转
什么是乐观锁和悲观锁

悲观锁:

  • 如果我不锁住这个资源,别人就会来争抢,就会造成数据结果错误,所以为了结果的正确性,悲观锁会在每次获取并修改结果时把数据锁住,让别人无法访问
  • Java中悲观锁典型的实现就是synchronized和lock相关类

乐观锁:

  • 认为自己在处理操作的时候不会有其他线程来干扰,所以并不会锁住操作对象
  • 在更新的时候,去对比在我修改的期间数据有没有被其他人改变过。
    • 如果没有被改变过,就说明只有自己在操作,就正常修改数据
    • 如果数据与最初拿到的不一致,说明其他人在这段时间内修改过数据,就会执行放弃、报错或重试等策略
  • 乐观锁的实现通常是利用CAS算法,典型例子是原子类,并发容器

案例演示:实现累加器

public class PessimismOptimismLock {
    int a;

    //悲观锁
    public synchronized void testMethod(){
        a++;
    }
    public static void main(String[] args) {
        //乐观锁
        AtomicInteger atomicInteger = new AtomicInteger();
        atomicInteger.incrementAndGet();
        //悲观锁
        new PessimismOptimismLock().testMethod();
    }
}
典型例子

Git:Git是乐观锁的典型应用,当我们向远程仓库push的时候,git会检查远程仓库的版本是不是领先我们现在的版本,

  • 如果远端版本和本地版本不一致,表明远端代码被人修改过了,提交就失败
  • 如果版本一直,才能顺利提交到远程仓库

数据库:

  • select for update就是悲观锁
  • 用version控制就是乐观锁
    • 添加一个字段lock_version
    • 更新操作前先查出这条数据的version 记为mversion
    • 进行更新操作时:update set num = 2 ,version = version = vsersion+1 where version = mversion and id = 5
    • 如果version更新了不等于查询出来的值了,更新就无效
开销对比
  • 悲观锁的原始开销要高于乐观锁,但是一劳永逸,临界区持锁时间就算越来越差,也不会对互斥锁的开销造成影响
  • 乐观锁一开始的开销比悲观锁小,如果自旋时间很长或者不停重试,name消耗的资源也会越来越多
使用场景
  • 悲观锁:适合于并发写入多的情况,适合于临界区持锁时间较长的情况,悲观锁可以避免大量的无用自旋锁等消耗
    • 临界区有IO操作
    • 临界区代码复杂或者循环量大
    • 临界区竞争非常激烈
  • 乐观锁:适合并发写入少,大部分都是读取的场景,不加锁能让读取性能大幅提高

可重入锁和非可重入锁

非可重入锁就是最常见的锁,一旦锁被使用,如果没有释放,就不能再使用这个锁了

可重入锁以ReentrantLock为例进行展开

  • 什么是可重入:再次获取同一把锁时不需要释放之前的锁
  • 代码演示1,反复调用:
	private static ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {
        System.out.println(lock.getHoldCount());
        lock.lock();
        System.out.println(lock.getHoldCount());
        lock.lock();
        System.out.println(lock.getHoldCount());
        lock.lock();
        System.out.println(lock.getHoldCount());
        lock.unlock();
        System.out.println(lock.getHoldCount());
        lock.unlock();
        System.out.println(lock.getHoldCount());
        lock.unlock();
        System.out.println(lock.getHoldCount());

    }

运行结果:

0
1
2
3
2
1
0
  • 代码演示2:递归调用
	public class RecursionDemo {
    private static ReentrantLock lock = new ReentrantLock();

    private static void accessResource(){
        lock.lock();
        try {
            System.out.println("已经对资源进行处理");
            if (lock.getHoldCount()<5){
                //递归调用
                System.out.println(lock.getHoldCount());
                accessResource();
                System.out.println(lock.getHoldCount());
            }
        }finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        new RecursionDemo().accessResource();
    }
}

运行结果:

已经对资源进行处理
1
已经对资源进行处理
2
已经对资源进行处理
3
已经对资源进行处理
4
已经对资源进行处理
4
3
2
1

从结果可以看出获得锁之后是可以重复获得锁再最后释放的,这就是可重入锁

  • 可重入锁的好处
    • 避免了死锁
    • 提高了封装性

公平锁和非公平锁

什么是公平和非公平
  • 公平:指按照线程请求的顺序来分配锁
  • 非公平:不完全按照请求的顺序,在合适的时机下,可以插队
为什么要有非公平锁
  • 为了提高效率(大多数都默认采用非公平锁)
  • 避免唤醒带来的空档期
    在这里插入图片描述
公平的情况(以ReentrantLock 为例)
  • 如果创建ReentrantLock 对象时,参数填写为true,那么这个锁就是公平锁

演示案例:模拟打印机打印任务,有两个类,一个是打印作业Job类,一个是打印队列PrintQueeue 类,一个打印任务包含两次打印,两次获得锁。在main方法中创建10个线程执行Job,当锁使用公平锁时:

/**
 * 〈演示公平锁和不公平锁〉
 *
 * @author Chkl
 * @create 2020/3/11
 * @since 1.0.0
 */
public class FairLock {

    private static ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {
        PrintQueeue printQueeue = new PrintQueeue();
        Thread thread[] = new Thread[10];
        for (int i = 0; i < 10; i++) {
            thread[i] = new Thread(new Job(printQueeue));
        }
        for (int i = 0; i < 10; i++) {
            thread[i].start();
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }


    }
}

class Job implements Runnable {
    PrintQueeue printQueeue;

    public Job(PrintQueeue printQueeue) {
        this.printQueeue = printQueeue;
    }

    @Override
    public void run() {
        System.out.println(
                Thread.currentThread().getName() + "开始打印");
        printQueeue.printJob(new Object());
        System.out.println(
                Thread.currentThread().getName() + "打印结束");

    }
}


class PrintQueeue {
    //公平锁
    private Lock queueLock = new ReentrantLock(true);
    //非公平锁
//    private Lock queueLock = new ReentrantLock();

    public void printJob(Object document) {
        queueLock.lock();

        try {
            int duration = new Random().nextInt(10) + 1;
            System.out.println(Thread.currentThread().getName() + "正在打印,需要时间" + duration);
            Thread.sleep(duration * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            queueLock.unlock();
        }

        queueLock.lock();
        try {
            int duration = new Random().nextInt(10) + 1;
            System.out.println(Thread.currentThread().getName() + "正在打印,需要时间" + duration);
        } finally {
            queueLock.unlock();
        }
    }
}

使用公平锁进行打印操作,每个锁会依次执行,一定是一个锁结束之后另一个锁开始打印,不会出现插队。一次运行结果如下,因为每次打印后需要休眠n秒模拟打印耗时,休眠时间足够所有的线程依次启动,所以执行顺序一定是线程0-9执行第一个打印后线程0-9执行第二次打印,顺序一定不会变

Thread-0开始打印
Thread-0正在打印,需要时间1
Thread-0正在打印,需要时间2
Thread-0打印结束
Thread-1开始打印
Thread-1正在打印,需要时间9
Thread-2开始打印
Thread-3开始打印
Thread-4开始打印
Thread-5开始打印
Thread-6开始打印
Thread-7开始打印
Thread-8开始打印
Thread-9开始打印
Thread-2正在打印,需要时间9
Thread-3正在打印,需要时间1
Thread-4正在打印,需要时间8
Thread-5正在打印,需要时间3
Thread-6正在打印,需要时间5
Thread-7正在打印,需要时间2
Thread-8正在打印,需要时间6
Thread-9正在打印,需要时间2
Thread-1正在打印,需要时间4
Thread-1打印结束
Thread-2正在打印,需要时间6
Thread-2打印结束
Thread-3正在打印,需要时间6
Thread-3打印结束
Thread-4正在打印,需要时间7
Thread-4打印结束
Thread-5正在打印,需要时间8
Thread-5打印结束
Thread-6正在打印,需要时间1
Thread-6打印结束
Thread-7正在打印,需要时间1
Thread-7打印结束
Thread-8正在打印,需要时间3
Thread-8打印结束
Thread-9正在打印,需要时间5
Thread-9打印结束
不公平的情况(以ReentrantLock 为例)

修改PrintQueeue 中的锁为非公平锁

	//非公平锁
    private Lock queueLock = new ReentrantLock();

一次运行结果如下,从结果可以看到,打印顺序并没有再按照0-9、0-9执行了,线程2的第一次打印结束后马上又开始了第二次打印,这就是非公平锁的好处了,线程2执行完第一个打印之后,线程3准备打印,但是准备的空窗期线程2干脆一次性把第二次打印也完成了,不影响线程3打印的正常运行,同理下面的线程56789都是这种情况,提高了效率,充分利用了空窗期

Thread-0开始打印
Thread-0正在打印,需要时间1
Thread-1开始打印
Thread-2开始打印
Thread-3开始打印
Thread-4开始打印
Thread-5开始打印
Thread-6开始打印
Thread-7开始打印
Thread-8开始打印
Thread-9开始打印
Thread-1正在打印,需要时间4
Thread-2正在打印,需要时间5
Thread-2正在打印,需要时间5
Thread-3正在打印,需要时间8
Thread-2打印结束
Thread-3正在打印,需要时间9
Thread-3打印结束
Thread-4正在打印,需要时间8
Thread-5正在打印,需要时间10
Thread-5正在打印,需要时间5
Thread-5打印结束
Thread-6正在打印,需要时间2
Thread-6正在打印,需要时间10
Thread-6打印结束
Thread-7正在打印,需要时间2
Thread-7正在打印,需要时间5
Thread-7打印结束
Thread-8正在打印,需要时间5
Thread-8正在打印,需要时间9
Thread-8打印结束
Thread-9正在打印,需要时间8
Thread-9正在打印,需要时间6
Thread-9打印结束
Thread-0正在打印,需要时间5
Thread-0打印结束
Thread-1正在打印,需要时间6
Thread-1打印结束
Thread-4正在打印,需要时间1
Thread-4打印结束
特例
  • trylock()方法不准守公平规则,自带插队属性
  • 当trylock()执行时,一旦有线程释放了锁,就一定被使用trylock()的线程获得,即使现在这个锁的等待队列里有线程在等待
对比非公平和公平的优缺点

在这里插入图片描述

共享锁和排它锁

以ReetrantReadWriteLock读写锁为例

什么是共享锁和排它锁
  • 排它锁:又称独占锁,独享锁
  • 共享锁:又称为读锁,获得共享锁后,可以查看但是无法修改和删除数据,其他线程此时也可以蝴蝶共享锁,同样无法修改和删除数据
  • 共享锁和排它锁的典型就是读写锁ReetrantReadWriteLock,其中读锁是共享锁,写锁是排它锁
读写锁的作用
  • 在没有读写锁之前,假设我们使用ReetrantLock,虽然保证了线程安全,但是也浪费了一定的资源:多个读操作同时进行,并没有线程安全问题
  • 在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率
读写锁的规则
  • 当一个线程占用读锁时,其他线程可以申请读锁,不能申请写锁
  • 当一个线程占用写锁时,其他线程读锁写锁都不可以申请
  • 总结:要么多读,要么一写
ReetrantReadWriteLock的具体用法

创建4个线程,前两个获取读锁,后两个获取写锁
运行后可以看到读锁可以同时获取,写锁必须获取释放了才能再获取

public class CinemaReadWrite {
    private static ReentrantReadWriteLock
            reentrantReadWriteLock = new ReentrantReadWriteLock();
    //读锁
    private static ReentrantReadWriteLock.ReadLock
            readLock = reentrantReadWriteLock.readLock();
    //写锁
    private static ReentrantReadWriteLock.WriteLock
            writeLock = reentrantReadWriteLock.writeLock();


    public static void main(String[] args) {
        new Thread(()->read(),"Thread1").start();
        new Thread(()->read(),"Thread2").start();
        new Thread(()->write(),"Thread3").start();
        new Thread(()->write(),"Thread4").start();

    }
    
    private static void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName()
                    + "得到了读锁,正在读取ing");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName()+"释放读锁");
            readLock.unlock();
        }
    }


    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName()
                    + "得到了写锁,正在写入ing");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName()
                    +"释放写锁");
            writeLock.unlock();
        }
    }
}

运行结果:

Thread1得到了读锁,正在读取ing
Thread2得到了读锁,正在读取ing
Thread1释放读锁
Thread2释放读锁
Thread3得到了写锁,正在写入ing
Thread3释放写锁
Thread4得到了写锁,正在写入ing
Thread4释放写锁
读锁和写锁的交互方法
  • 读锁插队策略
    • 公平锁:不允许插队
    • 非公平锁:
      • 写锁可以随时插队
      • 读锁仅在等待队列头节点不是想要获取写锁的线程的时候可以插队(有写锁马上要执行了就不允许插队)

不能插队的代码演示:将上面的案例的调用进行修改,顺序为w,r,r,w,r

线程2和线程3执行读的操作的时候,线程5不能插队,因为等待队列头的线程4是写锁

public static void main(String[] args) {
        new Thread(()->write(),"Thread1").start();
        new Thread(()->read(),"Thread2").start();
        new Thread(()->read(),"Thread3").start();
        new Thread(()->write(),"Thread4").start();
        new Thread(()->read(),"Thread5").start();

}

运行结果:

Thread1得到了写锁,正在写入ing
Thread1释放写锁
Thread2得到了读锁,正在读取ing
Thread3得到了读锁,正在读取ing
Thread3释放读锁
Thread2释放读锁
Thread4得到了写锁,正在写入ing
Thread4释放写锁
Thread5得到了读锁,正在读取ing
Thread5释放读锁
发布了30 篇原创文章 · 获赞 58 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/qq_41170102/article/details/104796175