并发编程中有关锁的相关知识点

参考学习视频:2021字节跳动、腾讯、阿里巴巴、美团、OPPO、华为、百度等一线互联网ANDRIOD面试真题全套解析

一、现代计算机中的缓存机构:

在这里插入图片描述
出现CPU缓存的原因: 是因为CPU执行速度远远大于内存的执行速度,如果不加缓存,CPU会有大部分时间处于等待状态。在cpu和内存中间加个缓存,会让处理速度会相对快些。
比如我们的电脑中其实也是有三级缓存机制的,打开“任务管理器”中的“性能”菜单功能项,如下图所示:
在这里插入图片描述

二、Java内存模型

Java内存模型,即Java Memory Model,简称JMM。
在这里插入图片描述
工作内存其实就如下图,数据都是加载到栈帧中进行执行。
在这里插入图片描述

在java中,就好比下图中的红色矩形框中的部分为主内存,绿色矩形框中的部分为工作内存。
在这里插入图片描述
JMM中的8大原子操作:

  • read(读取)
  • load(载入)
  • store(存储)
  • write(写入)
  • use(使用)
  • assign(赋值)
  • lock(锁定)
  • unlock(解锁)
    在这里插入图片描述

read操作是从主内存中读取一个变量到工作内存的临时区域(操作数栈中,操作数栈中的数据是没有存下来的,只是一块临时区域)。
load操作是将数据从操作数栈载入到变量副本中,其实就是存到临时变量表中。
lock操作/unlock操作是如果变量是被synchronized给包裹的,就会执行lock操作,用完之后就会执行unlock操作。

如果变量加了volatile,其线程之间的变量的变化如下所示,如果其中有个线程的变量发生了变化,通过store/write写入到主内存中,总线就会监听到这个变量发生了变更,总线就会通知其他所有的线程,原来所有的副本都会情空掉,然后下次需要用到这个值,会从主内存中重新拷贝一份到副本中。只要变量是volatile,所有线程都会有总线嗅探器,并且会被激活。
在这里插入图片描述
volatile两个作用:

  1. 线程间的可见性
  2. 禁止指令重排
  • 1. volatile缓存可见性实现原理
    汇编指令lock指令
    1) 将当前处理器缓存中数据立刻写回主内存
    2)这个写操作,会触发总线嗅探机制(MESI(缓存一致性协议)协议)

其实变量加与没加volatile修饰,如果加了查看该行汇编码的时候会发现这行代码中会多了一个lock指令

Java程序汇编代码查看:
在这里插入图片描述

  • 2.禁止指令重排
    指令重排可能会涉及到对象的“半初始化”问题。
public class ObjectBytecode {
	public static void main(String[] args) {
		Obj o = new Obj(); //创建了一个对象,方法中创建
		// =引用
		... ...
	}
}

class Obj {
	int i = 13;   //成员变量  初始值:0
}

在上面的代码段中这条语句:

Obj o = new Obj(); 

通过汇编指令转换成二进制字节码,会生成如下四行指令:
在这里插入图片描述

  • 第一行表示new一个对象
  • 第二行表示赋值一个引用,在操作数栈中赋值一个引用。在new一个对象之后,会执行这个对象的构造方法,会需要一个this引用。本地变量表的0一定是用于这个对象的引用(this);如果是类方法(静态方法)的引用,本地变量表中的0用于存放参数,没有this的存放。
  • 第三行表示执行构造方法;
  • 第四行表示将局部变量表中对象引用与局部变量表1的位置存进去,即建立关联;

上面代码执行流程为:
首先会有一个堆内存和栈内存,
① 在堆内存中new了一个对象,这个对象里面会有一个int i,当前 i 在没有初始化的状态下值是为0;
② 在栈内存中生成一个引用;
③ 在执行构造方法时,会将 i 的值改成13;
④ 将这个引用建立关联。

  如果在多线程环境下,变量没有加volalite,上面流程可能会发生改变,步骤3和步骤4可能会发生顺序对调。

  比如在DCL单例模式下,如果线程A执行mInstance = new DoubleCheckSingle();这条语句的时候,只执行了汇编指令的①②④三条指令,还没有进行初始化操作(此时的i的值还是0),这个时候线程B进行执行,发现mInstance 值不为null,直接执行return mInstance 语句,这个时候线程B获取i的值还是0 ,就会出现问题。

public class DoubleCheckSingle {
	private volatile static DoubleCheckSingle mInstance;
	int i = 0;
	private DoubleCheckSingle {
		i = 13;
	}
	public static DoubleCheckSingle  getInstance() {
		if (mInstance == null)  //第一次检查
			synchronized (DoubleCheckSingle.class) {  //加锁
				if (mInstance == null) {  //第二次检查
					mInstance = new DoubleCheckSingle();
				}
			}
			return mInstance;
		}
}

三、线程

启动线程的方式只有
1、X entends Thread; 然后X.start
2、X implements Runable, 然后交给Thread运行

下面这种启动线程的方式:
UserCall userCall = new UserCall();
FutureTask<String> futureTask = new FutureTask<>(userCall);
new Thread(futureTask).start();

其中,FutureTask 实现RunnableFuture接口,RunnableFuture接口又是继承Runnable和Future 这两个接口(类是单继承,但是接口可以继承多个)。所以这种方式本质上还是实现了Runnable接口。

线程的状态
Java 中线程的状态分为 6 种:

  1. 初始(NEW):新创建了一个线程对象,但还没有调用 start()方法。
  2. 运行(RUNNABLE):Java 线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
      线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 CPU 的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得 CPU 时间片后变为运行中状态(running)。
  3. 阻塞(BLOCKED):表示线程阻塞于锁。
  4. 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作
    (通知或中断)。1
  5. 超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的时间后自行返回。
  6. 终止(TERMINATED):表示该线程已经执行完毕。

状态之间的变迁如下图所示
在这里插入图片描述
  其中,等待状态和等待超时状态的区别就是等待有没有一个时间戳,wait()操作如果没有唤醒操作它会一直等;如果不想一直等待可以加入一个时间戳wait(long),时间到了,哪怕没有被唤醒,也会有等待状态转换为就绪状态或者运行状态。
执行sleep(long)操作是进入等待超时状态, 执行lock这种显示锁操作是进入等待或等待超时状态,Lock底层实现的时候是运用LockSupport
  当前线程仅有且有调用了synchronized关键字修饰的代码块或者方法的时候才会进入阻塞状态,它没有获取到锁,就一直处于阻塞状态,重新获取到锁就从阻塞状态转为运行态。
等待状态和阻塞状态的区别:等待是主动进入,阻塞是被动进入

四、死锁

  是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。

死锁发生的条件:

  1. 是必然发生在多操作者(M>=2 个)情况下,争夺多个资源(N>=2 个,且 N<=M)才会发生这种情况
  2. 争夺资源的顺序不对,如果争夺资源的顺序是一样的,也不会产生死锁;
  3. 争夺者拿到资源不放手

学术化的定义
死锁的发生必须具备以下四个必要条件。
1)互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
2)请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
3)不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
4)环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的 P0 正在等待一个 P1 占用的资源;P1正在等待 P2 占用的资源,……,Pn 正在等待已被 P0 占用的资源。

  理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。
只要打破四个必要条件之一就能有效预防死锁的发生
打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无
法满足,则退出原占有的资源。
打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,
满足则运行,不然就等待,这样就不会占有且申请。
打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所
有进程只能采用按序号递增的形式申请资源。
避免死锁常见的算法有有序资源分配法银行家算法

危害
  1、线程不工作了,但是整个程序还是活着的 2、没有任何的异常信息可以供我们检查。3、一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对正式已发布程序来说,这是个很严重的问题。
解决
关键是保证拿锁的顺序一致
两种解决方式
1、 内部通过顺序比较,确定拿锁的顺序;
2、采用尝试拿锁的机制。

其他线程安全问题
活锁
两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。
解决办法:每个线程休眠随机数,错开拿锁的时间。

线程饥饿
低优先级的线程,总是拿不到执行时间

五、锁分哪几种

在这里插入图片描述

上图最下面补充:多个线程能不能共享一把锁,能–共享锁,不能–排他锁。

比如多线程并发过程执行i–操作,如何解决这种并发问题。

package com.example.lock;

import sun.rmi.runtime.Log;

/**
 * 多线程操作共享变量
 */
public class RushOrder {
    public static void main(String[] args) {
        RushOrder rushOrder = new RushOrder();
        rushOrder.order();
    }
    int i = 10000;  //共享变量

    public void order() {
        i--;
    }

    //模拟高并发操作
    public void test() {
        //多线程同时跑order方法
        for (int i = 0; i < 6; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    order();
                }
            }).start();
        }
        try {
            Thread.sleep(3000); //等待让线程跑完
            System.out.println("i=" + i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
}

  1. 内置锁
int i = 10000;  //共享变量
Object o  = new Object();
public void order()  {
	synchronized (o) {  //锁变量
		i--;
	}
}

  执行synchronized 操作其实就会执行JMM种lock操作,则这个变量其他线程就不能被访问了,当执行了unlock操作,其他线程对该变量进行操作。

  1. 乐观锁(无锁机制)—原子操作类

这种方式执行速度比synchronized快很多,在吞吐量比较小的时候执行性能非常高,但是这种方式有个缺陷,不能执行类似(i*2-10)复杂的操作。对于复杂的操作可以用synchronized 和 ReentrantLock 方式。

AtomicInteger i = new AtomicInteger(10000);
public void order() {
	i.decrementAndGet();
}
  1. 可重入锁ReentrantLock(也是一种显示锁)
int i = 10000;
Lock lock = new ReentrantLock();
 public void order() {
     lock.lock();  //加锁
     try {
         i--;  //可以抛出异常的代码
     } finally {  //标准规范的Lock锁的加锁和解锁写法
         lock.unlock();  //解锁
     }
 }

六、CAS(CompareAndSwap)机制原理分析

CAS是一种乐观锁

AtomicInteger.java

private static final sun.misc.Unsafe unsafe = sun.misc.Unsafe.getUnsafe();
private static final long valueOffset;
private volatile int value;

... ...
public AtomicInteger(int var1) {
    this.value = var1;
}

... ...
static {
     try {
         valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
     } catch (Exception var1) {
         throw new Error(var1);
     }
 }
 
... ...
public final int decrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, -1) - 1;
 }

  sun.misc.Unsafe 这个类我们自己写的java代码一般是不能直接使用的,没有.java文件, 只有一个.class文件,是提供jdk其他类使用的。如果要用可以通过反射会获取,但是一般不会去使用。
  objectFieldOffset 通过偏移量的方式找到内存中的数据,这样可以减少拷贝副本中取数据的流程,这样即时生效的效果非常快。

Unsafe .class

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;
}

从这条语句 unsafe.getAndAddInt(this, valueOffset, -1) - 1; 调用到上面的getAndAddInt的接口方法中
其中:
var1–>this
val2–>valueOffset
var4–>-1

var5 = this.getIntVolatile(var1, var2); 这条语句的是意思是从对象的起始地址var1,偏离val2距离拿到var5数据。其中var5值为对应于上面代码 i的值10000

this.compareAndSwapInt(var1, var2, var5, var5 + var4) 这条语句的是意思是先把var5 + var4(即i -1的值9999)执行保存下来,保存之后通过第二次读取var1var2取一次数据,取出来的数据与var5进行比较,如果比较结果相同,则var5的数据就允许返回为var5 + var4的值,如果不相等就执行do语句,直到找到var5的值与通过var1var2第二次读到的值相同的情况才会退出while循环。

CAS原理机制图:
在这里插入图片描述
CAS实现原子操作的三大问题
1. ABA问题
  CAS机制存在ABA问题,在对象操作过程中,可能会有影响。
  所谓ABA问题是指:比如变量i的初始值为0,即E=0,线程A对变量i进行加一操作,V的指为1,此时线程B也获取了变量i,对i进行了多次操作,但是最后使i的值为0,这时候线程A执行到比较E和当前新值N操作过程中是相等,最后更新为新值V,这个流程就是ABA问题。
解决方法:利用版本戳的形式记录了每次改变以后的版本号,这样的话就不会存在 ABA问题了。这就是 AtomicStampedReference 的解决方案。AtomicMarkableReference跟 AtomicStampedReference 差不多,AtomicStampedReference 是使用 pair 的 int stamp 作为计数器使用,AtomicMarkableReference 的 pair 使用的是 boolean mark。AtomicMarkableReference 关心的是变量有没有更改过,方法都比较简单;而AtomicStampedReference 不仅考虑变量有没有更改过,还要关心变量更改过几次。

2. 循环时间长开销大
自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。
解决方法:可以采用Synchronized锁进行解决

3. 只能保证一个共享变量的原子操作
  当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
解决方法:把多个共享变量合并成一个共享变量来操作。比
如,有两个共享变量 i=2,j=a,合并一下 ij=2a,然后用 CAS 来操作 ij,或者通过多个变量放到一个bean对象中进行操作。从 Java 1.5开始,JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS 操作。

七、ReentrantLock实现原理

在这里插入图片描述

  ReentrantLock可重入锁的执行过程:多个线程(人)竞争一个资源(房间钥匙),第一个获取资源(钥匙)的线程(人),就会执行lock()操作,没有获取资源的线程(人)就会进入等待队列中,之后获取资源(钥匙)的线程(人)执行相关操作(住宿房间),当这个线程执行完成之后,执行unlock()操作,释放该资源(房间钥匙),从队列最前面取一个线程(人)获取该资源,执行对应的操作,从而进行周而复始的流程操作。

八、sychronized锁升级基础

对象的组成:
在这里插入图片描述
三级缓存(LRU+复用池/对象池)中缓存数据的单位是以M或者byte为单位。
每个对象的大小必须是8的倍数,对象在内存中的大小最少是16个字节。

在这里插入图片描述

在这里插入图片描述
  锁升级流程:如果一个普通对象,系统开机几秒钟之后,这个代码块运行这个对象的时候,比如上面代码synchronized (o),在4秒钟之后,这个o只有单个线程访问,那它会上一个偏向锁(偏向锁就是CAS机制中的while改成if判断操作);开启了偏向锁如果出现了多个线程访问,就会升级为轻量级锁(轻量级锁的底层就是用CAS来完成的),如果是轻量级锁这时候发现很多线程在抢占这把锁的过程中,在3~5ms还抢不到就会全自动转换成重量级锁,或者偏向锁过程中调用wait也会转换成重量级锁。

偏向锁–>if版的CAS
轻量级锁–>循环版的CAS
重量级锁–>调用操作系统的阻塞功能来实现的

不同锁的性能比较

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。如果线程间存在竞争,会带来额外的锁撤销的消耗。适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程使用自旋会消耗CPU。追求响应时间。同步块执行速度非常快。
重量级锁线程竞争不使用自旋,不会消耗CPU。线程阻塞,响应时间缓慢。追求吞吐量。同步块执行速度较长。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值