每日三道面试题,通往自由的道路13——锁+Volatile

2021年7月16日 6点热度 0条评论 来源: 太子爷哪吒

茫茫人海千千万万,感谢这一秒你看到这里。希望我的面试题系列能对你的有所帮助!共勉!

愿你在未来的日子,保持热爱,奔赴山海!

每日三道面试题,成就更好自我

我们既然聊到了并发多线程的问题,怎么能少得了锁呢?

1. 你知道volatile是如何保证可见性吗?

我们先看一组代码:

public class VolatileVisibleDemo {

    public static boolean initFlag = false;

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("等待initFlag改变!!!");
                // 如果initFlag发生改变了,这是为true的话,才会结束循环
                while(!initFlag) {
                }
                System.out.println("今天的世界打烊了,晚安!");
            }
        }).start();

        // 这里是为了能保证运行完上面的代码
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 这里是Lambda表达式,就是上面的缩写
        new Thread(() -> {
            System.out.println("准备填充数据,修改initFlag的值");
            initFlag = true;
            System.out.println("准备数据完了!");
        }).start();
    }
}

运行得到的结果:

我们可以发现,其实在准备数据完后,我们的initFlag的变量其实已经改变,但是为什么还是没有结束循环输出今天的世界打烊了,晚安!这一句呢?

从之间的JMM模型,我们可以知道,不同线程之间是不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成,并且线程在修改完数值后,也不是马上同步到主内存中,并且另一个线程也是无法感知到数据发生改变的,所以就会有可见性问题。

那我们可以加个volatile关键字修饰变量试下?

 public static volatile boolean initFlag = false;

我们可以发现:

在我们的变量修饰了volatile关键字后,就能输出今天的世界打烊了,晚安!这一句了。

我们来看看图解吧:

先解释下这其中连接的几个单词:

  • read(读取):从主内存中读取数据
  • load (载入):将主内存中读取到的数据写入到本地(工作)内存中
  • user(使用):从本地内存中读取数据给线程使用来计算
  • assign(赋值):线程将计算好的值重新赋值到工作内存中
  • store(存储):将本地内存的数据存储到主内存中
  • write(写入):将stroe过来的变量值赋值给主内存中的变量,重新赋值。

大概讲一下流程:

在线程B读取initFlag变量后,重新赋值true给变量,此时,因为加了volatile修饰,所以会马上将值写入到主内存中修改变量中的值,此时因为有一个cpu总线嗅探机制会监听到主内存的变量值发生改变了,会把本地内存的中initFlag变量设置了失效,重新读取一边主内存的新值,就可以达到解决变量可见性问题。这是它第一个保证可见性的关键。

之前我们也有提到他如果发生指令重排序了,那是不是也不能读取到最新的值呢。答案是不会的呢。

因为被volatile修饰的话,它会禁止指令重排序。那它主要是依靠什么指令重排序呢?它是通过内存屏障来实现的。什么是内存屏障?硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。内存屏障有两个作用:

  1. 阻止屏障两侧的指令重排序;
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。

而编译器在生成字节码时,会在指令序列中插入内存屏障来禁止指令重排序。这样保证了任何程序中都能得到正确的volatile内存语义。这个策略是:

  • 在每个volatile写操作前插入一个StoreStore屏障;
  • 在每个volatile写操作后插入一个StoreLoad屏障;
  • 在每个volatile读操作后插入一个LoadLoad屏障;
  • 在每个volatile读操作后再插入一个LoadStore屏障。

看一下示意图:

总结:

volatile作用:

  1. volatile可以保证内存可见性且禁止重排序。
  2. volatile不具备保证原子性,而锁可以保证整个临界区代码的执行具有原子性。所以而锁可以保证整个临界区代码的执行具有原子性。所以在功能上,锁比volatile更强大;在性能上,volatile更有优势。

不错呀!volatile这么深的底层都有了解,看来你势要我这个offer呀,那咱们继续

2. 悲观锁和乐观锁可以讲下你的理解吗?

悲观锁和乐观锁都是比较老生常谈的了,所以还是得记住呀!

其实听名字,我们就应该有个概念:

悲观对应着我们生活中的人,悲观的人一般看待事物都会相对消极负能量点,会尽可能往坏处去想的。这也是对应着MyGirl,她其实是一个也不能说算是悲观的人,只能说看待事物可能会更往深入,更坏的一方面的去思考。

这其实跟我很互补,因为算是个乐天派吧,而乐观对应着我们生活中的人,乐观的人一般看待事物都会相对积极正能量,会尽可能往好处去想的。我其实对待生活的方方面面可能会更乐观点,但有时带来的一些坏处也是难以估计的。

所以说这两者不能说谁好谁坏,只能对应着场景选择对应的方法。

悲观锁:

MyGilr这个人呢,她总是会假设一种最坏的情况。比如,她每次要去拿数据的同时,认为别人也会来修改数据跟她作对,所以每次在拿数据的时候她都会上锁,堵上一个界限,这样别人想拿这个数据就只能等待她出去解锁成功后,直到它拿到锁。

在Java中,synchronizedReentrantLock等独占锁就是悲观锁思想的实现。而在数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。

乐观锁
我这个人呢,总是会假设一种最好的情况。比如, 我每次要去拿数据的同时,认为别人绝对不会来修改数据滴,所以每次拿数据的时候都不会上锁。但是人还是要点防备心里的,不是吗?所以在更新的时候会判断一下在此期间别人有没有去更新过这个数据。

而常见的有CAS算法+版本号实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量。

在Java中,像原子类就是使用了乐观锁的一种实现方式CAS实现的。而在数据库提供的类似于write_condition机制,其实都是提供的乐观锁。

两者对应的场景的区别:

乐观锁多用于读多写少的环境,避免频繁加锁影响性能,加大了系统的整个吞吐量;而悲观锁多用于写多读少的环境,避免频繁失败和重试影响性能。

不错,这个常规的锁也懂嘛,最后问你一道:

3. 你还知道什么其他的锁吗?

可重入锁和非可重入锁

所谓重入锁又名递归锁,顾名思义。就是支持重新进入的锁,也就是说这个锁支持一个线程对资源重复加锁。指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。不会因为之前已经获取过还没释放而阻塞。

在Java中,ReentrantLocksynchronized都是可重入锁,可重入锁的还有一个优点是可一定程度避免死锁。

public static void main(String[] args) {
    doOne();
}

public static synchronized  void doOne(){
    System.out.println("执行第一个任务");
    try {
        Thread.sleep(10);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    // 执行第二个任务
    doTwo();
}

public static synchronized  void doTwo(){
    System.out.println("执行第二个任务");
}

简单的测试下结果:

执行第一个任务
执行第二个任务

可以验证得到,类中的两个方法都是被内置锁synchronized修饰的,而在doOne方法去调用doTwo方法时,因为是可重入锁,所以同个线程下可以直接获得当前对象锁,所以synchronized是可重入锁。

而如果我们自己在继承AQS实现同步器的时候,没有考虑到占有锁的线程再次获取锁的场景,可能就会导致线程阻塞,那这个就是一个非可重入锁。

公平锁和非公平锁

这里的公平,可以按生活上来讲,如果你跟你女朋友吵架,你觉得你是正确的,最后的结果却你必须得哄你女朋友还得道歉,你信吗?所以这是公平的吗?

如果对一个锁来说,先对锁获取请求的线程一定会先被满足,后对锁获取请求的线程后被满足,那这个锁就是公平的。反之,那就是不公平的。

公平锁:

多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。

缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁:

多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。

非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

在Java中,对于ReentrantLock而言,可以通过构造函数指定该锁是否是公平锁,默认是非公平锁。

独享锁和共享锁

对于独享和共享,这两个概念应该可以见名知意,对于MyGirl喜欢的东西,是碰都碰不得,而对于不喜欢,或者还可以的东西,可以和她共享。

独享锁:

也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程B对变量A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得独享锁的线程即能读数据又能修改数据。

在Java中,synchronized就是一种独享锁。

共享锁:

代表该锁可被多个线程所持有。如果线程B对变量A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

小伙子不错嘛!今天就到这里,期待你明天的到来,希望能让我继续保持惊喜!

注: 如果文章有任何错误和建议,请各位大佬尽情留言!如果这篇文章对你也有所帮助,希望可爱亲切的您给个三连关注下,非常感谢啦!也可以微信搜索太子爷哪吒公众号私聊我,感谢各位大佬!

    原文作者:太子爷哪吒
    原文地址: https://www.cnblogs.com/taiziyenezha/p/15017473.html
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系管理员进行删除。