【面試(自己看)】一、JUC多线程及高并发(3)之 锁

【大厂原题】
公平锁/非公平锁/可重入锁/递归锁/自旋锁谈谈你的理解, 请手写一个自旋锁

1 公平锁和非公平锁

1.1 是什么?
公平锁 是指多个线程按照申请锁的顺序来获取锁. 类似排队打饭,先来后到.
非公平锁 是指多个线程获取锁的顺序并不是按照申请锁的顺序, 有可能后申请的线程比先申请的线程优先获取锁,在高并发的情况下, 有可能会造成优先级反转或者饥饿现象

并发包中ReentrantLock的创建可以指定构造函数的boolean类型来得到公平锁或非公平锁, 默认是非公平锁(即传入false);

1.2 两者区别?
公平锁: Threads acquire a fair lock in the order in which they requested it
公平锁就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列, 如果为空, 或者当前线程是等待队列的第一个,就占有锁, 否则就会加入到等待队列中, 以后会按照FIFO的规则从队列中取到自己

非公平锁: a nonfair lock permits barging: threads requesting a lock can jump ahead of the queue of waiting threads if the lock happens to be available when it is requested;
非公平锁比较粗鲁, 上来就直接尝试占有锁, 如果尝试失败, 就再采用类似公平锁那种方式;

1.3 题外话
Java ReentrantLock而言, 通过构造函数指定该锁是否是公平锁, 默认是非公平锁. 非公平锁的优点在于吞吐量比公平锁大
对于syncronized而言, 也是一种非公平锁;


2 可重入锁

2.1 是什么
可重入锁也叫递归锁
指的是同一线程外层函数获得锁之后, 内层递归函数仍然能获取该做的代码, 在同一个线程在外层方法获取锁的时候, 进入内层方法会自动获取锁;
也就是说:线程可以进入任何一个它已经拥有的锁的所同步着的代码块;

ReentrantLocksyncronized就是两个典型的可重入锁

2.2 作用
可重入锁最大的作用就是避免死锁

2.3 可重入锁小Demo

syncronized是可重入锁

class Phone{
    public synchronized void sendSMS() throws Exception{
        System.out.println(Thread.currentThread().getName() + "\t invoke sendSMS");
        this.sendEmail();
    }
    public synchronized void sendEmail() throws Exception{
        System.out.println(Thread.currentThread().getName() + "\t ###invoke sendEmail");
    }
}

public class ReentrantLockDemo {
    public static void main(String[] args) {
        Phone phone = new Phone();
        new Thread(()->{
            try {
                phone.sendSMS();
            } catch (Exception e) {
                e.printStackTrace();
            }
        },"t1").start();

        new Thread(()->{
            try {
                phone.sendSMS();
            } catch (Exception e) {
                e.printStackTrace();
            }
        },"t2").start();
    }
	/* 打印结果
	   t1	 invoke sendSMS
	   t1	 ###invoke sendEmail
       t2	 invoke sendSMS
       t2	 ###invoke sendEmail			
	*/	
}

LocK中的ReentrantLock是可重入锁

class Fruit implements Runnable{
    private Lock lock = new ReentrantLock();
    @Override
    public void run() {
        this.set();
    }

    public void set(){
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"\t invoke set");
            this.get();
        }catch (Exception e){e.printStackTrace();}
        finally {
            lock.unlock();
        }
    }
    public void get(){
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"\t ###invoke get");
        }catch (Exception e){e.printStackTrace();}
        finally {
            lock.unlock();
        }
    }
}

public class ReentrantLockDemo {
    public static void main(String[] args) {
        Fruit frult = new Fruit();
        Thread t3 = new Thread(frult,"t3");
        Thread t4 = new Thread(frult,"t4");
        t3.start();
        t4.start();
    }
    /* 执行结果
		t3	 invoke set
		t3	 ###invoke get
		t4	 invoke set
		t4	 ###invoke get
	*/
}

至此我们再温习一下可重入锁的定义👇
可重入锁也叫递归锁
指的是同一线程外层函数获得锁之后, 内层递归函数仍然能获取该做的代码, 在同一个线程在外层方法获取锁的时候, 进入内层方法会自动获取锁;
也就是说: 线程可以进入任何一个它已经拥有的锁的所同步着的代码块;

ReentrantLocksyncronized就是两个典型的可重入锁

可重入锁问题延伸
阅读一下代码,请问代码执行结果

class Fruit implements Runnable{
    private Lock lock = new ReentrantLock();
    @Override
    public void run() {
        this.set();
    }

    public void set(){
        lock.lock();
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"\t invoke set");
            this.get();
        }catch (Exception e){e.printStackTrace();}
        finally {
            lock.unlock();
            lock.unlock();
        }
    }
    public void get(){
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"\t ###invoke get");
        }catch (Exception e){e.printStackTrace();}
        finally {
            lock.unlock();
        }
    }
}

public class ReentrantLockDemo {
    public static void main(String[] args) {
        Fruit frult = new Fruit();
        Thread t3 = new Thread(frult,"t3");
        Thread t4 = new Thread(frult,"t4");
        t3.start();
        t4.start();
    }
}

我们可以看到Fruit类的set()方法中加了两把锁,这个时候如果执行main()方法会有什么问题?

以下答案👇

编译无错
运行也无错
记住,不管加几把锁,只要对应匹配解锁几次就没有问题!!!


3 自旋锁(spinLock)

自旋锁我们在CAS中讲过一点.
AtomicInteger 作为原子类中的一个例子,被我们拿出来举例, 我们知道 **AtomicIntegerCAS**实现就是Unsafe类 + 自旋

再重温下代码👇
在这里插入图片描述

3.1 是什么

自旋锁,是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁, 这样的好处是减少线程上下文切换的消耗, 缺点是循环会消耗CPU

3.2 手写一个自旋锁

自旋锁Demo

public class SpinlockDemo {
    //原子引用线程
    AtomicReference<Thread> atomicReference = new AtomicReference<>();

    public void myLock() {
        Thread thread = Thread.currentThread();
        System.out.println("线程"+Thread.currentThread().getName()+"\t come in ^_^");
        /**
         *  这里某线程进入CAS成功之后,其它线程再进入该方法到此处CAS取反就一直是true,就会卡在这里
         *  直到atomicReference Set进去的这个线程调用myUnlock方法,将atomicReference
         *  存储的线程引用值重新改为null
         */
        while (!atomicReference.compareAndSet(null, thread)){
            //就旋着
        }
    }

    public void myUnlock() {
        Thread thread = Thread.currentThread();
        //用完了,将原子引用改回到最初的值
        atomicReference.compareAndSet(thread,null);
        System.out.println("线程"+Thread.currentThread().getName()+"\t invoke myUnlock^_^");
    }

    public static void main(String[] args) throws InterruptedException {
        SpinlockDemo spinlockDemo = new SpinlockDemo();
        //线程A 获取自旋锁, 假设业务操作耗时五秒
        new Thread(()->{
            try {
                //获取自旋锁
                spinlockDemo.myLock();
                //业务操作5秒
                System.out.println("线程"+Thread.currentThread().getName()+"\t 开始业务操作");
                TimeUnit.SECONDS.sleep(5);
                System.out.println("线程"+Thread.currentThread().getName()+"\t 业务操作完成");
                //释放锁
                spinlockDemo.myUnlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"A").start();

        //我们模拟中, 希望A先获取锁,因此在这里暂停1秒,保证线程A能够启动
        TimeUnit.SECONDS.sleep(1);

        //线程B 获取自旋锁, 进行业务操作,释放锁
        new Thread(()->{
            try {
                //获取自旋锁
                spinlockDemo.myLock();
                //业务操作1秒
                System.out.println("线程"+Thread.currentThread().getName()+"\t 开始业务操作");
                TimeUnit.SECONDS.sleep(1);
                System.out.println("线程"+Thread.currentThread().getName()+"\t 业务操作完成");
                //释放锁
                spinlockDemo.myUnlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"B").start();
    }
	/**打印结果
		线程 A	 come in ^_^               进入lock
		线程 A	 开始业务操作		           说明获取到了锁
		线程 B	 come in ^_^	           进入lock但是会在while()这里一直自旋,直到线程A释放锁
		线程 A	 业务操作完成
		线程 A	 invoke myUnlock^_^		   释放锁 	
		线程 B	 开始业务操作                线程B获取到了锁  
		线程 B	 业务操作完成
		线程 B	 invoke myUnlock^_^
	*/
}

4 读/写锁

共享锁(读锁)

指该锁可被多个线程所持有.

独占锁(写锁)

指该锁一次只能被一个线程所持有. 对ReentrantLockSyncronized而言都是独占锁.

互斥锁

读锁的共享锁可保证并发读是非常高效的, 读写, 写读, 写写 的过程是互斥的

4.1 读写锁 是什么

多个线程同时读一个资源类没有任何问题, 所以为了满足并发量, 读取共享资源应该可以同时进行
但是
如果有一个线程想去写共享资源时, 就不应该再有其它线程可以对该资源进行读或写

高并发场景下的写操作要注意两点: 原子+独占, 整个过程必须是一个完整的统一体, 中间不允许被分割, 不允许被打断;

ReentrantReadWriteLock其读锁是共享锁, 其写锁时独占锁
ReentrantReadWriteLock读写分离,既保证了数据的一致性,又保证了并发性

4.2 读写锁小总结

读-读 能共存
读-写 不能共存
写-写 不能共存

4.3 读写锁小Demo

我们先看一下没有加锁的时候,5个线程写,5个线程读,会出现什么错乱的情况

/**
	无锁版,并发场景的读写
*/
/**
 * 该资源类加上 清理操作就变成 手写简单缓存的demo
  */
class DataCatch{
    private volatile Map<String, Object> map = new HashMap<>();

    public void write(String key, String val) {
        System.out.println("线程 "+Thread.currentThread().getName()+"\t 开始写入:{"+key+","+val+"}");
        //暂停一会线程,模拟执行业务操作
        try {
            TimeUnit.MILLISECONDS.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        map.put(key,val);
        System.out.println("线程 "+Thread.currentThread().getName()+"\t 写入完成");
    }

    public void read(String key) {
        System.out.println("线程 "+Thread.currentThread().getName()+"\t 开始读取");
        //暂停一会线程,模拟执行业务操作
        Object o = map.get(key);
        System.out.println("线程 "+Thread.currentThread().getName()+"\t 读取完成:"+o);
    }

    //加一个cleanmap的操作,就是清除缓存
}
public class ReadWriteLockDemo {
    public static void main(String[] args) {
        DataCatch dc = new DataCatch();
        //5个线程执行写操作
        for (int i = 0; i < 5; i++) {
            final int tempInt = i;
            new Thread(()->{
            dc.write(tempInt+"",tempInt+"");
            },String.valueOf(i)).start();
        }

        //5个线程执行读操作
        for (int i = 0; i < 5; i++) {
            final int tempInt = i;
            new Thread(()->{
                dc.read(tempInt+"");
            },String.valueOf(i)).start();
        }
    }
    /* 看一下执行结果
		线程 1	 开始写入:{1,1}
		线程 4	 开始写入:{4,4}
		线程 3	 开始写入:{3,3}
		线程 0	 开始写入:{0,0}
		线程 2	 开始写入:{2,2}
		线程 1	 开始读取
		线程 2	 开始读取
		线程 2	 读取完成:null
		线程 1	 读取完成:null
		线程 0	 开始读取
		线程 0	 读取完成:null
		线程 3	 开始读取
		线程 3	 读取完成:null
		线程 4	 开始读取
		线程 4	 读取完成:null
		线程 1	 写入完成
		线程 4	 写入完成
		线程 2	 写入完成
		线程 3	 写入完成
		线程 0	 写入完成
	*/
}

线程的写操作,并非原子性,而写没有独占,
线程的读操作,读取数据错误,原因是还没有写入完成,就开始读导致的
此时在高并发场景下就是一个很严重的bug

我们再看一下通过可重入读写锁ReentrantReadWriteLock进行优化,其结果如何

/**
 ReentrantReadWriteLock版,并发场景的读写操作
*/
/**
 * 该资源类加上 清理操作就变成 手写简单缓存的demo
  */
class DataCatch{
    private volatile Map<String, Object> map = new HashMap<>();
    private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    public void write(String key, String val) {
        //读写锁->读锁加锁
        rwLock.writeLock().lock();
        //暂停一会线程,模拟执行业务操作
        try {
            System.out.println("线程 "+Thread.currentThread().getName()+"\t 开始写入:{"+key+","+val+"}");
            TimeUnit.MILLISECONDS.sleep(300);
            map.put(key,val);
            System.out.println("线程 "+Thread.currentThread().getName()+"\t 写入完成");
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            //读写锁->读锁释放锁
            rwLock.writeLock().unlock();
        }
    }

    public void read(String key) {
        rwLock.readLock().lock();
        try {
            System.out.println("线程 "+Thread.currentThread().getName()+"\t 开始读取");
            //暂停一会线程,模拟执行业务操作
            TimeUnit.MILLISECONDS.sleep(300);
            Object o = map.get(key);
            System.out.println("线程 "+Thread.currentThread().getName()+"\t 读取完成:"+o);
        }catch (Exception e){e.printStackTrace();}
        finally {
            rwLock.readLock().unlock();
        }

    }

    //加一个cleanmap的操作,就是清除缓存
}
public class ReadWriteLockDemo {
    public static void main(String[] args) {
        DataCatch dc = new DataCatch();
        //5个线程执行写操作
        for (int i = 0; i < 5; i++) {
            final int tempInt = i;
            new Thread(()->{
            dc.write(tempInt+"",tempInt+"");
            },String.valueOf(i)).start();
        }

        //5个线程执行读操作
        for (int i = 0; i < 5; i++) {
            final int tempInt = i;
            new Thread(()->{
                dc.read(tempInt+"");
            },String.valueOf(i)).start();
        }
    }
	/* 执行结果
	   	线程 0	 开始写入:{0,0}
		线程 0	 写入完成
		线程 1	 开始写入:{1,1}
		线程 1	 写入完成
		线程 3	 开始写入:{3,3}
		线程 3	 写入完成
		线程 2	 开始写入:{2,2}
		线程 2	 写入完成
		线程 4	 开始写入:{4,4}
		线程 4	 写入完成
		线程 0	 开始读取
		线程 1	 开始读取
		线程 2	 开始读取
		线程 3	 开始读取
		线程 4	 开始读取
		线程 2	 读取完成:2
		线程 4	 读取完成:4
		线程 0	 读取完成:0
		线程 3	 读取完成:3
		线程 1	 读取完成:1
	*/
}

看结果就知道啦, ReentrantReadWriteLock完全将写操作变成原子性+独占的,某个线程在进行写操作的时候,其它线程不能读更不能写
而对读操作并没有做独占限制



以上,就是我们多线程场景下锁相关的要点知识的梳理

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值