(2021-04-01)常见面试题之Java和“锁”不能说的秘密


前言
Java提供了各种类型丰富的锁,每种锁使用与不同的场景下。本文就大概的来描述一下java提供的那些锁,以及它们不得不提的“故事”。
在我们开始阅读本文时,希望大家带上问题去阅读。

1.线程要不要锁住同步资源?
2.锁住同步资源失败,线程要不要阻塞?
3.多个线程竞争资源的细节?
4.多个线程竞争锁时要不要遵循先来后到?
5.一个线程能不能多次的获取同一把锁,在未释放锁的场景下
6.多个线程能不能共享一把锁


1.悲观锁和乐观锁

悲观锁和乐观锁这一概念,大家应该都很熟悉了。它是一个广义上的概念,体现了对待线程的不同角度。对待先说下概念:

  • 悲观锁,认为自己在使用数据的时候一定会有其他线程来修改数据,所以在获取数据的时候就会先加锁,来确保别的线程没机会搞事情
  • 乐观锁,认为自己再使用数据时不会有其他线程来搞事情,所以不会锁。只是会在更新数据的时候判断有没有其他线程修改了这个数据。如果没有,那么将自己需要修改的数据写入。否则根据不同的方式进行操作(重试或者报错)

乐观锁在java中是通过无锁来实现的,常见的就是CAS操作。
悲观锁
乐观锁
如上图可知,乐观锁更适合度多写少的场景,而悲观锁则适合写多读少的场景。

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }
  public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

从上面的代码可以看出,其实CAS就是一个比较然后替换的过程。它有三个值:内存中,预期值,更新值。当内存值等于预期值时,会进行一个更新操作。会将更新值更新到内存中。但是也可以看出来,如果不等于的话,它会一直循环。

说到CAS,不得不提它的三个问题:
1.ABA问题。一个很直观的例子:比如喝水。A将一杯水倒满,放入冰箱中。B喝了一半,然后又倒满,再次放回原位。C一看水是满的,并且在原位置,以为水是没人喝过的,所以就自己使用。但是其实是B喝过了。
解决方法:设置版本号/使用AtomicStampedReference,这个类会提供一个带有时间戳的方法,它会通过判断原来的值和时间戳进行比对。

2.循环时间开销大。由于以上代码可知,如果一直没办法修改成功,CAS一直进行尝试状态,一直循环,直到成功。

3.只能保证一个共享变量的原子操作。
解决方法:提供AtomicReference可以存放对象

2.自旋锁和自适应自旋锁

首先来谈谈自旋锁,为什么jdk会搞一个自旋锁。大家都知道,线程之间的切换会消耗cpu资源,它所耗费的资源要比你想象的多,在工作繁琐的业务中,线程之间切换的耗时是几乎是可以忽略不计的。但是如果一段代码过于简单,那么线程切换的时间要远远大于代码执行的时间。
在这里插入图片描述
从上图可得知,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
但是大家有没有想过,如果该线程一直无法获取到锁,那么它将会永远进行自旋操作。自旋锁虽然避免了线程切换的开销,但是如果出现上述的情况,那么自旋锁只会白白的浪费资源,变得毫无意义。
自旋锁在JDK1.4中引入了,在1.6中默认开启自旋锁,并引入了自适应自旋锁。自适应自旋锁意味着自旋次数不再固定,它会根据上次获取锁的情况,来决定这次自旋的次数。对于某个锁来说,如果上一次自旋等待获得了锁,那么这一次它会认为自己也能获取锁,进而它会加大自旋的次数。但是如果对于某个锁它连续几次都获取失败,那么它在以后的过程中可能会直接阻塞,避免浪费资源。

3.Java对象头

在JVM中,对象在内存中(堆内存)的布局分为三块区域:对象头,实例变量和填充数据。
实例变量:存放类的属性数据信息。
填充数据:用于保证8字节对齐。
在这里插入图片描述
对象头:Mark Word、Class pointer、Array length
Mark Word:存储对象的HashCode,分代年龄以及锁标记位等信息。
Class pointer:类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。
Array length:数组长度(如果当前对象为数组)。
说完对象头这个概念,我们就可以知道下面所讲的内容的关联性了

4.无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

在JDK1.6之前,synchronized效率都很低,由于它锁的粒度很重。于是在JDK1.6中引入了“偏向锁”和“轻量级锁”,来减少获得锁和释放锁消耗的性能。
存储内容中的信息就是存放于对象头的Mark Word中~
四种锁对应的Mark Word

4.1 无锁

无锁就是没有对资源进行锁定,所以线程都能访问同一个资源,但是同时只有一个线程能访问成功。

无锁的状态下是在循环中进行,不会进行线程切换。在有些场合下性能是很高的。

4.2 偏向锁

偏向锁的情况,是一个资源长时间被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

大多数情况,锁总是由同一线程获取,不存在多线程竞争,所以出现了偏向锁。

4.3 轻量级锁

指当锁是偏向锁时,此资源这时被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
如果当前只有一个等待线程,则该线程通过自旋进行等待,但是如果自旋超过一定次数。或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

4.4 重量级锁

升级为重量级锁时,等待锁的线程都会进入阻塞状态。

注意:只有重量级锁状态时,等待的线程才会进入阻塞状态。
整体流程

5.公平锁和非公平锁

简单的先解释一个公平锁和非公平锁。
公平锁:
公平锁公平锁如上图所示,新来的线程想要获取资源。回到队列的末尾进行排队。然后除了队列第一个线程其余的都会进行阻塞,等待该它们使用资源时,才会被上一个线程唤醒。在执行资源时,它会去管理员那里获取锁。缺点就是效率低。

非公平锁:
非公平锁
非公平锁比公平锁多了一个步骤,就是会先尝试插队,如果插队失败,那么才会去排队。前提是,A执行完毕。把锁还给管理员,但是管理员还没有允许下一个人去吃饭时。由于插队,不需要入队列,原本队列的人只能继续等待。

公平锁代码
非公平锁代码
如上图可以知,公平锁和非公平锁最大的区别就是hasQueuedPredecessors()这个方法。我们进去看看
在这里插入图片描述
可以看到它就是判断是否还有队列在排队,并且
1.判断当前是否有队列在等待
2.判断头节点后面是否还有其他节点在排队
3.判断当前想插队的线程是否是头节点后面的那个节点所对应的线程

简单来说,这就是公平锁和非公平锁的区别。

6.独占锁和共享锁

独占锁:这个锁只能被唯一一个线程使用;
共享锁:这个锁可以被多个线程使用;
这里大家其实可能知道ReentrantReadWriteLock提供了读写锁,读锁就是刚刚讲到的共享锁,写锁则是独占锁。
写锁的上锁流程
简单的来说,它就做了这几件事情

      /*
             * Walkthrough:
             * 1. If read count nonzero or write count nonzero
             *    and owner is a different thread, fail.
             * 2. If count would saturate, fail. (This can only
             *    happen if count is already nonzero.)
             * 3. Otherwise, this thread is eligible for lock if
             *    it is either a reentrant acquire or
             *    queue policy allows it. If so, update state
             *    and set owner.
             */
  1. 如果读取计数不为零或写入计数不为零,而且持有者不是当前线程,失败。
  2. 如果计数将饱和(这就是有一个),则失败。(这只能如果计数已非零时发生。)
  3. 否则,如果它要么是可重入的获取,要么是队列策略允许它。如果是,请更新状态设置所有者。

读锁获取过程
简单来说,读锁支持共享。但是如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。

下面用一个demo来简单的演示一下:

public class ReadWriteLockDemo {

    public static void main(String[] args) {
        MyCache myCache = new MyCache();
        for(int i=1;i<=1;i++){
          final int tempInt = i;
//            new Thread(()->{
//                myCache.get(tempInt+"");
//            },String.valueOf(i)).start();

          new Thread(()->{
                myCache.put(tempInt+"",tempInt+"");
            },String.valueOf(i)).start();
        }

        for(int i=1;i<=5;i++){
            final int tempInt = i;
            new Thread(()->{
                myCache.get(tempInt+"");
            },String.valueOf(i)).start();
        }
    }

}
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class MyCache {

    private volatile Map<String,Object> map = new HashMap<>();

    private ReentrantReadWriteLock  lock = new ReentrantReadWriteLock();

    public void put(String key,String val){
        lock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName()+"\t 正在写入:"+key);
            TimeUnit.MILLISECONDS.sleep(300);
            map.put(key, val);
            System.out.println(Thread.currentThread().getName()+"\t 写入完成");
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock.writeLock().unlock();
        }
    }


    public void get(String key){
        lock.readLock().lock();
        try {
            System.out.println(Thread.currentThread().getName()+"\t 正在读取:"+key);
            TimeUnit.MILLISECONDS.sleep(300);
            Object result = map.get(key);
            System.out.println(Thread.currentThread().getName()+"\t 读取完成"+result);
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock.readLock().unlock();
        }
    }
}

通过该实例可以看出来,写锁的独占锁,读锁是共享锁。
再将注释的代码放开,可以看出。读写和写锁互不干扰。加了读锁就无法加在写锁,反之亦然。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值