锁
前言
从计算机诞生到现在,人们一直在为提升计算机的执行与展示速度而努力。本文就围绕着对计算机中出现的问题和锁的诞生。
但是这样还是会有一些并生的问题出现,那么我就用这篇文章来讲清楚锁的产生、分类、应用。
计算机中任务的执行图
一个任务执行就是对数据的处理,总结起来就是对计算,存储,如下图。
但是CPU的运算速度要比IO的速度块千倍。
在等待的过程中CPU是无法进行其他的操作的,这样CPU的资源就无法合理的运用起来。
进程和线程的产生
在最原始的计算机中,内存只允许运行一个程序,这个时候的CPU的能力是完全过剩的。
于是一个内存中就被划分出多个区域,分别由不同的进程管理。
因为在一些特定的场景中又需要同一个进程共享同一个内容,还需要在切换时效率高,于是就产生了线程
并发的产生
切换线程
原子性:就是把一个操作或者多个操作当成一个整体,在执行的过程中不能中断,要么成功要么失败。
举例:如下一个场景拆分的步骤。
- 线程A先读取到num的值
- 线程B在读取到num的值
- 线程B对其进行修改、保存
- 线程A在对原始的值进行修改、保存
- 最后结果只有线程A的操作
高速缓存(减少IO时间)
缓存:将预先读取的数据存入到一个容器中,如果命中就返回命中的数据,如果不存在在到内存中将相应的数据载入缓存中,在将其返回给处理器。
如下图
在内存中添加缓存之后,由于多个CPU缓存之间是不可见的,所以就会有数据的一致性问题产生(多核CPU)。
如下图:
指令优化(执行顺序)
可能发生在编译、CPU执行、缓存优化
为了保证数据的最终一致性,需要很多技术方案来支持,下面我就介绍一种在技术上最常用的方式锁来保证在同一时间内只有一条线程对其进行操作修改。
分布式锁
满足锁的条件
- 互斥性同一个方法在同一时间只能被一台机器上的一个线程执行
- 避免死锁
- 高可用的获取和释放
- 性能好
分类
下面我将java中常见的锁进行了分类整理
原理
为了保证数据的一致性和原子性,我们这里提供了两种解决思路
直接对数据操作进行加锁,其他的操作需要等到锁释放之后进行。这种我们称为悲观锁
给数据添加标记,等到最后提交时,判断是否有冲突。这种我们称为乐观锁
悲观锁
之所以称其为悲观锁,是因为我们认为数据并发修改的概率较大。
java中提供了synchronized关键字,如上图就是将资源锁住,不让其他的线程操作,执行完毕之后释放锁。
但是这样操作会使得synchronized的性能很低,于是在JDK1.6之后开始对锁的性能方面做了优化。用来减少获得锁和释放锁带来的性能消耗。
因为我们现在已经知道在当前场景中,一个线程多次来获取一把锁。
锁升级
首先看看锁的结构:包含对象头和Monitor,还有就是一些必要的对其填充。
而在对象头中又包括两部分数据:Mark Word(标记字段),Klass Pointer(类型指针)如下图
偏向锁
Mark中有一个标志位用来记录当前锁的状态,当一个线程获取锁成功之后,Mark Word中还有一个指向锁成功的线程指针。
偏向锁假定将来只有第一个申请锁的线程继续调用,那么就不需要重复获取。这就是偏向锁的优点
轻量级锁
但是如果有另外一条线程过来加锁的时候,就会和第一个线程产生冲突。这个时候偏向锁就会升级为轻量级锁。
它会在栈帧中开辟一片空间其名为锁记录。用来存储Mark Word的指针。
然后另外一个线程就通过自旋进行等待,等释放之后使用
重量级锁
当第二个线程自旋一定的次数,或者有其他的线程来争夺锁的时候,锁就会膨胀为重量级锁
于是原本指向锁记录空间中的指针指向了Object Monitor
Monitor的计数器+1,并且记录当前持有锁的线程。
乐观锁
乐观锁主要采用了CAS算法,也叫非阻塞同步
而它的构成也比较简单,主要是由需要读写的内存值V,期望值A,新值B。
执行流程:
假设只有两条线程进行操作
1. 线程A读取到内存中的数据,获取地址V,预期值为A,A=V。
2. 线程B读取并修改保存数据,修改地址V为B
3. 线程A再次修改提交,发现A!=V
4. 重新获取地址V,修改提交。
问题
ABA
由上述操作可知在操作中会检查一下有没有变化,如果有没有就更新,而如如果有两次操作将其改为B在改回来。
这种问题只需要在变量的版本添加版本号。
自旋
在上述步骤三中重复获取的过程称为自旋
而如果循环的时间开销长,就会给CPU的执行带来开销
共享变量
只能保证一个共享变量的原子操作。
volatile:保证线程的可见性 禁止指令重排
总结
本文主要讲了并发的产生原理,以及一些锁的原理。
喜欢的话记得给我一键三连哦~~