校招笔记(一)_Java_锁
我的校招记录:校招笔记(零)_写在前面 ,以下是校招笔记总目录。
备注 | ||
---|---|---|
算法能力(“刷题”) | 这部分就是耗时间多练习,Leetcode-Top100 是很好的选择。 | 补充练习:codeTop |
计算机基础(上)(“八股”) | 校招笔记(一)__Java_Java入门 | C++后端后续更新 |
校招笔记(一)__Java_面对对象 | ||
校招笔记(一)__Java_集合 | ||
校招笔记(一)__Java_多线程 | ||
校招笔记(一)__Java_锁 | ||
校招笔记(一)__Java_JVM | ||
计算机基础(下)(“八股”) | 校招笔记(二)__计算机基础_Linux&Git | |
校招笔记(三)__计算机基础_计算机网络 | ||
校招笔记(四)__计算机基础_操作系统 | ||
校招笔记(五)__计算机基础_MySQL | ||
校招笔记(六)__计算机基础_Redis | ||
校招笔记(七)__计算机基础_数据结构 | ||
校招笔记(八)__计算机基础_场景&智力题 | ||
校招笔记(九)__计算机基础_相关补充 | ||
项目&实习 | 主要是怎么准备项目,后续更新 |
1.5 锁
【新增】 java常用的并发工具类?
JUC就是java.util.concurrent包,这个包俗称JUC,里面都是解决并发问题。
常用四大并发工具包(以下都是基于AQS实现的):
-
CountDownLatch: CyclicBarrier描述的是“允许一组线程相互等待,直到到达某个公共屏障点,才会进行后续任务”。
而CountDownLatch所描述的是“在完成一组正在其他线程中执行的操作之前,它允 一个或多个线程一直等待”。
在API中是这样描述的:用给定的计数初始CountDownLatch。由于调用了countDown方法,所以在当前计数到达零之前,await方法会一直受阻塞。之后,会释放所有等待的线程,await的所有后续调用都将立即返回。这种现象只出现一次(计数无法被重置。如果需要重置计数,请考虑使CyclicBarrier)。
-
CyclicBarrier:CyclicBarrier是一个同步辅助类。它允许一组线程互相等待,直到到达某个公共屏障点。在涉及一组固定大小的线程的程序里,这些线程必须不时的互相等待,此时CyclicBarrier 很有用。因为CyclicBarrier在释放等待线程后可以重用,因此成为循环的屏障。
使用**await()**方法,每个线程调用await()方法告诉CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。当所有线程都到达了屏障,结束阻塞,所有线程可继续执行后续逻辑。
-
Semaphore:信号量Semaphore是一个控制访问多个共享资源的计数器,和CountDownLatch一样,其本质上是一个“共享锁”。在API是这么介绍信号量的:一个计数信号量,从概念上讲,信号量维护了一个许可集。
-
ExChanger :Exchanger是一个同步器,字面上就可以看出这个类的主要作用是交换数据。Exchanger有点类似CyclicBarrier,前面说到CyclicBarrier是一个栅栏,到达栅栏的 线程需要等待一定数量的线程到达后,才能通过栅栏。Exchanger可以看成是一个双向的栅栏。线程1到达栅栏后,会首先观察有没有其他线程已经到达栅栏,如果没有就会等待。如果已经有其他线程(比如线程2)到达了,就会以成对的方式交换各自携带的信息,因此Exchanger非常适合两个线程之间的数据交换。
1.5.1 synchronized 相关
1.1 [重点]说一说自己对于 synchronized 关键字的了解? synchronized 底层原理 ?
-
基本了解
synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
- synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock 来实现的;
- Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。
-
moniter介绍
Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。
在HotSpot虚拟机中,Monitor是基于C++的ObjectMonitor类实现的,其主要成员包括:
- _owner:指向持有ObjectMonitor对象的线程
- _WaitSet:存放处于wait状态的线程队列,即调用wait()方法的线程
- EntryList:存放处于等待锁block状态的线程队列
- _count:约为_WaitSet 和 _EntryList 的节点数之和
- _cxq: 多个线程争抢锁,会先存入这个单向链表
- _recursions: 记录重入次数
-
底层原理
synchronized 关键字底层原理属于 JVM 层面。
① synchronized同步语句块的情况
1
2
3
4
5
6
7public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}通过 JDK ⾃带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:
-
⾸先切换到类的对应⽬录执行 javac SynchronizedDemo.java 命令⽣成编译后的 .class ⽂件,然后执行
javap -c -s-v -l SynchronizedDemo.class
-
synchronized同步语句块的实现使用的是monitorenter和 monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。
-
当执行monitorenter 指令时,线程试图获取锁也就是获取 monitor的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1 ;
monitor对象存在于每个Java对象的对象头中 synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因。
-
相应的在执行monitorexit 指令后,将锁计数器设为0,表明锁被释放;
-
如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。
-
② synchronized修饰方法的的情况
1 | public class SynchronizedDemo2 { |
-
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是
ACC_SYNCHRONIZED
标识,该标识指明了该方法是⼀个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
1.2 请你谈谈关于Synchronized和ReentrantLock?
-
相似点
-
都是阻塞式同步:一个线程获得了对象锁,进入了同步块,其他访问该同步块的线程都必须阻塞在同步块外面等待;
线程阻塞和唤醒的代价是比较高的(操作系统需要在用户态与内核态之间来回切换,代价很高,不过可以通过对锁优化进行改善)。
-
都是可重入锁:是同一个线程可重复获得锁,每获得一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
-
-
不同点
-
实现原理: Synchronized是java语言的关键字,是原生语法层面的互斥, JVM 层面;ReentrantLock是JDK 1.5后的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成;
-
是否避免死锁: synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;Lock不会主动适应
unLock()
释放,必须手动在finally
释放;相⽐synchronized,ReentrantLock增加了⼀些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件) -
线程等待可中断: Lock可以让等待锁的线程响应中断,线程可以中断去干别的事务;而synchronized却不行,使用synchronized时,等待的线程会一直等待下去;
-
公平锁: synchronized的锁是非公平锁,ReentrantLock默认情况下也是非公平锁,但可以通过带布尔值的构造函数要求使用公平锁;
- 选择性通知: synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition() 方法。
Condition是JDK1.5之后才有的,它具有很好的灵活性,⽐如可以实现多路通知功能也就是在⼀个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使⽤notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,⽤ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而synchronized关键字就相当于整个Lock对象中只有⼀个Condition实例,所有的线程都注册在它⼀个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。
- 选择性通知: synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition() 方法。
-
1.3 synchronized锁住的是什么,在项目中遇到了吗?
synchronized本身并不是锁,锁本身是一个对象,synchronized最多相当于“加锁”操作,所以synchronized并不是锁住代码块。
(重点)Java中的每一个对象都可以作为锁,具体表示有三种形式:
面试中面试官经常会说:“单例模式了解吗?来给我⼿写⼀下!给我解释⼀下双重检验锁方式实现单例模式的原理呗!”
1 | public class Singleton { |
uniqueInstance 采用 volatile 关键字修饰也是很有必要的,
1 | uniqueInstance = new Singleton(); |
这段代码其实是分为三步执行:
-
为 uniqueInstance 分配内存空间
-
初始化 uniqueInstance
-
将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1>3>2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致⼀个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回uniqueInstance,但此时 uniqueInstance 还未被初始化。
1.4 synchronized锁的优化机制了解吗?
synchronized本身也在不断优化锁的机制,有些情况下他并不会是一个很重量级的锁了。优化机制包括:
- 自适应锁、自旋锁、锁消除、锁粗化、轻量级锁和偏向锁;
- 锁的状态从低到高依次为:无锁->偏向锁->轻量级锁->重量级锁。
常见的锁:
-
无锁:无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
锁到底存在哪里呢?答案就是对象头中。
对象头主要又包括了两部分数据:Mark Word(标记字段)、Class Point(类型指针)。
-
初始mark word 将是可偏向状态,此时的 是否偏向锁 为 0,表示当前没有任何一个线程持有该锁。
-
-
偏向锁:在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
JDK1.6 中为了提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能,引入了偏向锁。在第一次获得锁时,会有一个 CAS 操作(见下);之后该线程再获取锁,只会执行几个简单的命令,而不是开销相对较大的 CAS 命令。
-
CASE 1 : 线程第一次获得锁,如果未偏向,通过 CAS 指令:
- 插入线程ID : 向mark word插入线程ID
- 偏向锁标识:将 mark word 中的偏向锁标识从
0→1
锁标志位:不修改!因为不变!
如果操作成功:,则说明获得了偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;
如果操作失败,说明有其它线程获取了锁:
- 如果偏向线程还存在:直接进行升级为轻量级锁;
- 如果偏向线程不存在:先修改锁标识为01→00 ,再升级为轻量级锁。
-
CASE 2 : 这是一次可重入,偏向线程是自己。
在当前线程栈中找到一个可用的 Lock Record :并将其 obj 指向锁对象 & Displaced Mark Word 置为null
-
-
轻量级锁:当发现多线程竞争时,偏向锁会升级为轻量级锁,一般来说,会在 safepoint(此时用户代码不会执行)中去查看偏向的线程是否还存活。
- 如果偏向的线程已经不存活或者不在同步块中,则将对象头的 mark word 改为无锁状态(unlocked),重新偏向新的线程;
- 如果存活且还在同步块中,原偏向的线程继续拥有锁,当前线程则走入到轻量级锁的加锁逻辑中;
轻量级锁的处理流程 :
- 发现已经有偏向的线程了,则会先 撤 销偏向锁,然后升级为轻量锁 。通过CAS命令更新:
-
修改此前 当前线程栈帧Lock Record: (1)Displaced Mark Word 复制 mark word (此时无锁状态)中的现有内容
-
修改mark word :mark word 指向当前线程栈帧Lock Record的 Displaced Mark Word的地址,见上图;
-
修改锁标志位:
01 → 00
-
上述CAS更新成功,则当前线程获得了对象的锁 ;
如果不成功:
-
检查Mark Word是否指向当前线程的栈帧的Lock Record ,是则是一次可重入
设置
Lock Record
第一部分(Displaced Mark Word
)为null,起到了一个重入计数器的作用。然后结束。 -
如果不是则是进行自旋等待
-
-
当1.自旋超过一定的次数(默认10),或者2.一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
-
重量级锁:内置锁在Java中被抽象为监视器锁(monitor)。在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)。这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁Monitor,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁。线程去获取重量级锁,其实就是就尝试获取对象的monitor锁。
即将 monitor锁的 Owner字段修改为当前线程ID 。
如果获取成功,此时线程获得了锁,CAS修改 :
-
修改mark word :然后将对象头mark word 改为指向该 monitor 的指针。
-
锁标志位:
00 → 10
-
1.5 为什么说Synchronized是非公平锁,这样的优缺点是什么?
并非是按照申请锁的时间前后给等待线程分配锁的,每当锁被释放后,任何一个线程都有机会竞争到锁。
- 优点:这样做的目的是为了提高执行性能;
- 缺点:是可能产生线程饥饿现象。
1.6 为什么说synchronized是一个悲观锁?乐观锁的实现原理又是什么?
没看完,了解了下核心问题:https://www.cnblogs.com/jojop/p/14022029.html
-
synchronized悲观锁
Synchronized显然是一个悲观锁,因为它的并发策略是悲观的:
- 不管是否会产生竞争,任何的数据都必须加锁。
-
synchronized原理
Synchronized是通过获取对象内部的一个叫做监视器锁(monitor)来实现的,每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权。
监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。
-
如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
-
如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
-
如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
-
-
乐观锁实现原理:CAS
参考好文:一文彻底搞懂CAS实现原理
1.7 (CAS原理重点)什么是CAS?CAS的缺点?说说CAS源码实现?
乐观锁的核心算法是CAS(Compared And Swap,比较并交换):
-
关键逻辑: CAS,有几个重要的参数:
(1)this,Unsafe 对象本身,需要通过这个类来获取 value 的内存偏移地址。
(2)valueOffset,value 变量的内存偏移地址。
(3)expect,期望更新的值。
(4)update,要更新的最新值。
如果原子变量中的 value 值等于 expect,则使用 update 值更新该值并返回 true,否则返回 false。
-
CAS特性: CAS具有原子性,它的原子性由CPU硬件指令实现保证。
- 【缺点1】ABA问题:如果另一个线程修改V值假设原来是A,先修改成B,再修改回成A。当前线程的CAS操作无法分辨当前V值是否发生过变化。
- 解决ABA: 在变量前面加上版本号,每次变量更新的时候变量的版本号都
+1
,即A->B->A
就变成了1A->2B->3A
- 解决ABA: 在变量前面加上版本号,每次变量更新的时候变量的版本号都
- 【缺点2】只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。
- 【缺点3】循环时间长开销大:对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
- 【缺点1】ABA问题:如果另一个线程修改V值假设原来是A,先修改成B,再修改回成A。当前线程的CAS操作无法分辨当前V值是否发生过变化。
-
CAS源码分析
CAS 全称是 compare and swap,是一种用于在多线程环境下实现同步功能的机制。
CAS 操作包含三个操作数 – 内存位置、预期数值和新值。CAS 的实现逻辑是将内存位置处的数值与预期数值想比较,若相等,则将内存位置处的值替换为新值。若不相等,则不做任何操作。
Java 并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的,下面是具体分析。
-
背景介绍
在多核心时代下,多个核心通过同一条总线和内存以及其他硬件进行通信。
CPU 的多个核心同时对同一片内存进行操作,会导致错误。例如,递增指令
inc dword ptr [...]
,等价于DEST = DEST + 1
。该指令包含三个操作读->改->写
,涉及两次访存。- 核心1,2从内存读取数据
1
,并写到各自寄存器中 - 核心1将寄存器中数据
1→2
- 核心2将寄存器中数据
1→2
- 然后都写回主存,此时为
2
可以看到,由于核心2在核心1写入主存操作完成前进行读取,导致并不是我们期望的
3
。通过在递增
inc
指令前添加 lock 前缀,可以让核心独占某个内存区域,由此可以避免上面问题。lock 前缀保证核心独占某片内存区域,有两种方式:- 总线锁。总线被锁定后,其他核心就不能访问内存了,可能会导致其他核心短时内停止工作;
- 缓存锁。若某处内存数据被缓存在处理器缓存中,处理器发出的 LOCK# 信号不会锁定总线,而是锁定缓存对应的内存区域。其他处理器在这片内存区域锁定期间,无法对这片内存区域进行相关操作。(不是乐观机制都可以操作吗?不能写入内存?)
- 核心1,2从内存读取数据
-
源码分析
我们分析,
java.util.concurrent.atomic
包下的原子类AtomicInteger
中的compareAndSet
方法 。-
AtomicInteger具体实现中,compareAndSet 实际上只是一个壳子,主要的逻辑封装在 Unsafe 的 compareAndSwapInt 方法中;
-
compareAndSwapInt是一个native方法
1
public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33public class AtomicInteger extends Number implements java.io.Serializable {
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
// 计算变量 value 在类对象中的偏移
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
public final boolean compareAndSet(int expect, int update) {
/*
* compareAndSet 实际上只是一个壳子,主要的逻辑封装在 Unsafe的compareAndSwapInt 方法中
*/
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
// ......
}
public final class Unsafe {
// compareAndSwapInt 是 native 类型的方法,继续往下看
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);
// ......
}下面我们进入unsafe.cpp(compareAndSwapInt是native方法)具体实现中,看看调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25// unsafe.cpp
/*
* 这个看起来好像不像一个函数,不过不用担心,不是重点。UNSAFE_ENTRY 和 UNSAFE_END 都是宏,
* 在预编译期间会被替换成真正的代码。下面的 jboolean、jlong 和 jint 等是一些类型定义(typedef):
*
*/
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
// 根据偏移量,计算 value 的地址。这里的 offset 就是 AtomaicInteger 中的 valueOffset
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
// 调用 Atomic 中的函数 cmpxchg,该函数声明于 Atomic.hpp 中
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END
// atomic.cpp
unsigned Atomic::cmpxchg(unsigned int exchange_value, volatile unsigned int* dest, unsigned int compare_value) {
assert(sizeof(unsigned int) == sizeof(jint), "more work to do");
/*
* 根据操作系统类型调用不同平台下的重载函数,这个在预编译期间编译器会决定调用哪个平台下的重载
* 接下来分析 atomic_windows_x86.inline.hpp 中的 cmpxchg 函数实现
*/
return (unsigned int)Atomic::cmpxchg((jint)exchange_value, (volatile jint*)dest,
(jint)compare_value);
}分析 Windows 平台下的 Atomic::cmpxchg 函数为例,看看具体实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// atomic_windows_x86.inline.hpp
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
// 判断是否是多核 CPU
int mp = os::is_MP();
__asm {
// 将参数值放入寄存器中
mov edx, dest // 注意: dest 是指针类型,这里是把内存地址存入 edx 寄存器中
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp) // 核心比较写入操作
cmpxchg dword ptr [edx], ecx
}
}其中LOCK_IF_MP实际内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25// LOCK_IF_MP
cmp mp, 0
/*
* 如果 mp = 0,表明是线程运行在单核 CPU 环境下。此时 je 会跳转到 L0 标记处,
* 也就是越过 _emit 0xF0 指令,直接执行 cmpxchg 指令。也就是不在下面的 cmpxchg 指令前加 lock 前缀。
*/
je L0
/* 0xF0 是 lock 前缀的机器码,这里没有使用 lock,而是直接使用了机器码的形式。*/
_emit 0xF0
L0:
/*
* 比较并交换。简单解释一下下面这条指令,熟悉汇编的朋友可以略过下面的解释:
* cmpxchg: 即“比较并交换”指令
* dword: 全称是 double word,在 x86/x64 体系中,一个
* word = 2 byte,dword = 4 byte = 32 bit
* ptr: 全称是 pointer,与前面的 dword 连起来使用,表明访问的内存单元是一个双字单元
* [edx]: [...] 表示一个内存单元,edx 是寄存器,dest 指针值存放在 edx 中。
* 那么 [edx] 表示内存地址为 dest 的内存单元
*
* 这一条指令的意思就是,将 eax 寄存器中的值(compare_value)与 [edx] 双字内存单元中的值
* 进行对比,如果相同,则将 ecx 寄存器中的值(exchange_value)存入 [edx] 内存单元中。
*/
cmpxchg dword ptr [edx], ecx
}
} -
-
实际举例说明
AtomicInteger 类主要利用 CAS (compare and swap) + volatile 来保证原子操作。AtomicInteger 的主要方法都是通过调用Unsafe类方法去实现,如 compareAndSet 实际是调用AtomicInteger 的compareAndSwapInt方法。
下面以 getAndIncrement实现来说明。
-
getAndIncrement调用了 Unsafe的getAndAddInt方法,传递了(1)当前this对象,(2)value偏移量,用来计算得到value值(3)要加上的值,由于是递增所以是1
⚠️ 为什么不传value的值,而是偏移量? 传偏移量是为了计算value所在的内存地址,进而获取最新的value值。
-
getAndAddInt采用CAS方式进行更新,还需要进行当前期望值的计算
- 通过
getIntVolatile
获取到线程此时内存value值(期望值),也就是记录执行CAS前的内存最新value值;
- 通过
-
然后开始执行Unsafe的
compareAndSwapInt
,主要是通过Atomic::cmpxchg
逻辑来实现(1)将要dest(value内存地址),compareValue(期望值),exchange_value(要更新的值)写入寄存器中
(2)线程如果是运行多核CPU,上LOCK#锁,将dest内存区域锁住 ;否则不上LOCK#锁
(3)执行cmpxchg(比较并交换命令),如果dest的value值(执行CAS中的最新value值) == compareValue,则写入exchange_value ;
(4)否则写入失败,通过不断自旋(循环)期望得到执行
-
-
1.8 请说明一下synchronized的可重入怎么实现?
每个锁关联一个线程持有者对象和一个计数器。
- 当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁(即monitor对象)而调用相应方法。
- 当一个线程请求成功后,JVM对象头会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。
- 而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。
- 当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。
1.9 在synchronized偏向锁过程中,调用hashcode方法,markword会发生什么?
第一次调用Hashcode:当对象的hashCode()方法(非用户自定义)第一次被调用时,JVM会生成对应的identity hash code值,并将该值存储到Mark Word中 。
后续如果该对象的hashCode()方法再次被调用则不会再通过JVM进行计算得到,而是直接从Mark Word中获取,保证唯一相同。
- 无锁状态:在无锁状态下,Mark Word中可以存储对象的identity hash code值 ;
- 偏向锁状态:需要计算其identity hash code的话,则它的偏向锁会被撤销(因为没有保存的位置),并且锁会膨胀为轻量级锁或者重量锁 ;
- 轻量锁状态:线程栈帧的Lock Recode可以记录存储Displaced Mark Word ,所以轻量级锁可以和identity hash code 共存 ;
- 重量级锁状态:ObjectMonitor类里有字段HashCode可以记录非加锁状态下的mark word,所以重量级锁也可以和identity hash code共存;
1.10 Synchronized 确定不可中断吗?如果一个线程访问Synchronized 代码,其它线程可以能否中断?比如使用Stop?是在中断前还是中断后获取锁?
参考:https://blog.csdn.net/deel_feel/article/details/105771902
正确说法时:只有获取到锁之后才能中断,等待锁时不可中断。
1.5.2 Reetrantlock 相关
ReentrantLock意思为可重入锁 。
2.1 非公平锁和公平锁在reetrantlock里的实现过程是怎样的?
-
公平锁:那么锁的 获取顺序 就应该符合请求的 绝对时间顺序,FIFO
-
非公平锁:只要CAS设置同步状态成功state,则表示当前线程获取了锁
但公平锁还需要判断当前节点是否有前驱节点,如果有,则表示有线程比当前线程更早请求获取锁,因此需要等待。
【源码分析】
-
基本结构
根据代码可知,ReentrantLock里面有一个内部类Sync,Sync继承AQS,添加锁和释放锁的大部分操作实际上都是在Sync中实现的。
- Sync有公平锁FairSync和非公平锁NonfairSync两个子类;
- ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
-
公平和非公平锁区分
公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在CAS获取同步状态时,多了一个限制条件:hasQueuedPredecessors()。
- 该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。
2.2 ReentrantLock的实现原理?
ReentrantLock的实现基于队列同步(AbstractQueuedSynchronizer,后面简称AQS)。关于AQS的实现原理见下。
ReentrantLock的核心,是通过修改AQS中state的值来同步锁的状态。
2.3 希望等待一段时间锁没有获取,可以自动放弃用哪种锁?
Lock(ReentranLock)
但是基于AQS的源码哪里体现了? 应该Lock锁的代码里自己的实现吧。
1.5.3 AQS 相关
3.1 什么是AQS请你简单介绍一下?
AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面。
AQS是⼀个用来构建锁和同步器的框架,使⽤AQS能简单且高效地构造出应用⼴泛的大量的同步器。
- 例如:ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue等等皆是基于AQS的。当然,我们⾃⼰也能利⽤AQS非常轻松容易地构造出符合我们⾃⼰需求的同步器。
3.2 请介绍一下AQS原理?
后续建议研读:从ReentrantLock的实现看AQS的原理及应用
先带着问题来进行看下面内容:
Q:某个线程获取锁失败的后续流程是什么呢?
A:存在某种排队等候机制,线程继续等待,仍然保留获取锁的可能,获取锁流程仍在继续。
Q:既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?
A:是CLH变体的FIFO双端队列。
Q:处于排队等候机制中的线程,什么时候可以有机会获取锁呢?
A:可以详细看下2.3.1.3小节。
Q:如果处于排队等候机制中的线程一直无法获取锁,需要一直等待么?还是有别的策略来解决这一问题?
A:线程所在节点的状态会变成取消状态,取消状态的节点会从队列中释放,具体可见2.3.2小节。
Q:Lock函数通过Acquire方法进行加锁,但是具体是如何加锁的呢?
A:AQS的Acquire会调用tryAcquire方法,tryAcquire由各个自定义同步器实现,通过tryAcquire完成加锁过程。
-
核心原理概览
如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的⼯作线程,并且将共享资源设置为锁定状态。
如果被请求的共享资源被占用,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是⽤CLH队列锁实现的,即将暂时获取不到锁的线程加⼊到队列中。
CLH(Craig,Landin,and Hagersten)队列是⼀个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成⼀个CLH锁队列的⼀个结点(Node)来实现锁的分配。
AQS使用⼀个int成员变量
state
来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队⼯作。AQS使⽤CAS对该同步状态进行原子操作实现对其值的修改。1
private volatile int state; //共享变量,使⽤volatile修饰保证线程可见性
状态信息通过protected类型的getState,setState,compareAndSetState进行操作 。
-
AQS 对资源的共享方式
AQS定义两种资源共享方式,可以通过修改state字段来实现多线程的独占(经典如ReentranLock)和共享模式。- Exclusive(独占):只有⼀个线程能执行,如ReentrantLock。⼜可分为公平锁和非公平锁:
- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
- Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。
- Exclusive(独占):只有⼀个线程能执行,如ReentrantLock。⼜可分为公平锁和非公平锁:
-
AQS详细原理
以下是基于ReentractLock语境下进行分析。
-
加锁过程:非公平锁
简单来说:新建线程→CAS尝试快速获取锁→tryAcquire()CAS修改state→addWaiter() 获取失败CAS尝试插入队尾入队→acquireQueued等待前驱线程唤醒继续CAS获取锁
加锁流程从
lock.lock()
开始1
2
3public void lock() {
sync.lock();
}进入该源码,正确找到sycn的实现类后可以看到真正有内容的入口方法:
- CAS尝试快速加锁,在ReentranLock只有state=0,才能更新成功(因为是非多线程共享资源)
- 获取锁失败执行acquire方法
1
2
3
4
5
6
7
8// 加锁流程真正意义上的入口
final void lock() {
//以cas方式尝试将AQS中的state从0更新为1
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());//获取锁成功则将当前线程标记为持有锁的线程,然后直接返回
else
acquire(1);//获取锁失败则执行该方法
}acquire
在主要的逻辑都在if判断条件中,这里面有3个重要的方法tryAcquire(),addWaiter() 和 acquireQueued() 。1
2
3
4
5public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}-
acquire流程1:tryAcquire()
tryAcquire()在公平和非公平下获取的方式不一样,这里只说非公平实现。
-
如果state==0(未被其它线程持有),CAS尝试获取锁,
-
如果当前线程==持有锁的线程,可重入 ,state+1
-
否则 reture false获取锁失败,加入等待队列
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();//获取当前线程实例
int c = getState();//获取state变量的值,即当前锁被重入的次数
if (c == 0) { //state为0,说明当前锁未被任何线程持有
if (compareAndSetState(0, acquires)) { //以cas方式获取锁
setExclusiveOwnerThread(current); //将当前线程标记为持有锁的线程
return true;//获取锁成功,非重入
}
}
else if (current == getExclusiveOwnerThread()) { //当前线程就是持有锁的线程,说明该锁被重入了
int nextc = c + acquires;//计算state变量要更新的值
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);//非同步方式更新state值
return true; //获取锁成功,重入
}
return false; //走到这里说明尝试获取锁失败
}
-
-
-
acquire流程2:addWaiter()
主要逻辑如下:
- 首先通过new Node()创建一个空结点;
- 如果队列不空**,以CAS方式让新节点插入到队尾;
- 如果队列为空,执行
enq(node)
逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);//首先创建一个新节点,并将当前线程实例封装在内部,mode这里为null
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);//入队的逻辑这里都有
return node;
}
private Node enq(final Node node) {
for (;;) {
Node t = tail;//t指向当前队列的最后一个节点,队列为空则为null
if (t == null) { // Must initialize //队列为空
if (compareAndSetHead(new Node())) //构造新结点,CAS方式设置为队列首元素,当head==null时更新成功
tail = head;//尾指针指向首结点
} else { //队列不为空
node.prev = t;
if (compareAndSetTail(t, node)) { //CAS将尾指针指向当前结点,当t(原来的尾指针)==tail(当前真实的尾指针)时执行成功
t.next = node; //原尾结点的next指针指向当前结点
return t;
}
}
}
} -
acquire流程3:acquireQueued()
线程加入同步队列后,获取锁的流程是什么呢?
简单来说,就是不断判断当前是否是老二,并尝试去获取锁。
SIGNAL :
意味着线程释放锁后会唤醒后面阻塞的线程。毕竟,只有确保能够被唤醒,当前线程才能放心的阻塞
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
//死循环,正常情况下线程只有获得锁才能跳出循环
for (;;) {
final Node p = node.predecessor();//获得当前线程所在结点的前驱结点
//第一个if分句
if (p == head && tryAcquire(arg)) {
setHead(node); //将当前结点设置为队列头结点
p.next = null; // help GC
failed = false;
return interrupted;//正常情况下死循环唯一的出口
}
//第二个if分句
if (shouldParkAfterFailedAcquire(p, node) && //判断是否要阻塞当前线程
parkAndCheckInterrupt()) //阻塞当前线程
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
-
解锁:非公平锁
-
加锁:公平锁
简单来说:新来线程→【必须】先CAS加入等待队列→等待前驱节点释放锁(state=0),如果是老二则获取锁。
公平锁加锁入口加锁从:
1
2
3finally void lock() {
acqiuire();
}在之前非公平锁的逻辑中,线程有三次机会获取锁:
- 新创建时,CAS尝试修改state=1,去获取
- 可重入,当前获取锁线程为自己
- 前驱节点释放锁,自己作为老二被唤醒
而公平锁,只能按加入队列的先后次序 & 可重入获得锁 :
-
所有线程在获取锁前必须先加入同步队列
-
如果state=0,
hasQueuedPredecessors
判断当前是头节点,则获取锁
-
3.3 为什么基于FIFO的同步队列可以实现非公平锁?
因为非公平锁,除了等前驱节点唤醒去获取锁 ,还有以下三种方式获取锁:
-
新创建时,CAS尝试修改state=1,去获取
公平锁:进来先执行
hasQueuedPredecessors()
, 看等待队列是否有有效节点,有的话不能获取锁! -
可重入,当前获取锁线程为自己
公平锁:也可以
-
同步队列等待,等待唤醒获取锁
公平锁:也可以
并且在锁释放时:是先释放锁(修改state=-1),再去唤醒后继节点
- 会导致新来的线程,可能在后继节点被唤醒前就获取了锁,这就不会公平
3.4 【易忘】为什么非公平锁性能好?
- .线程不必加入等待队列就可以获得锁,不仅免去了构造结点并加入队列的繁琐操作 ,节省了线程阻塞、唤醒的开销(这涉及到上下文的切换);
- 减少CAS竞争。如果线程必须要加入阻塞队列才能获取锁,那0将变得异常激烈,CAS操作虽然不会导致失败线程挂起,但不断失败重试导致的对CPU的浪费也不能忽视
3.4 AQS 有哪些组件,请简单介绍一下?介绍一下CountDownLatch的应用场景 ?
-
Semaphore(信号量):Semaphore(信号量)可以指定多个线程同时访问某个资源; synchronized 和 ReentrantLock 都是⼀次只允许⼀个线程访问同时某个资源。
计数信号量具备两种操作动作,称为V(signal())与P(wait())(即部分参考书常称的“PV操作”)。V操作会增加信号标S的数值,P操作会减少它。
运行P(wait()),信号标S的值将被减少。企图进入临界区段的进程,需要先运行P(wait())。当信号标S减为负值时,进程会被挡住,不能继续;当信号标S不为负值时,进程可以获准进入临界区段。
-
CountDownLatch (倒计时器): CountDownLatch是⼀个同步⼯具类,用来协调多个线程之间的同步。这个⼯具通常用来控制线程等待,它可以让某⼀个线程等待直到倒计时结束,再开始执行。
-
CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能⽐ CountDownLatch 更加复杂和强大。主要应用场景和CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让⼀组线程到达⼀个屏障(也可以叫同步点)时被阻塞,直到最后⼀个线程到达屏障时,屏障才会开⻔,所有被屏障拦截的线程才会继续⼲活。
CyclicBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调⽤**await()**方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
CountDownLatch的应用场景
我们要读取处理6个⽂件,这6个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这6个⽂件的处理的结果进行统计整理。
为此我们定义了⼀个线程池和count为6的 CountDownLatch 对象 。使用线程池处理读取任务,每⼀个线程处理完之后就将count-1,调用 CountDownLatch 对象的 await() 方法,直到所有⽂件读取完之后,才会接着执行后面的逻辑。
1 | public class CountDownLatchExample1 { |
1.5.4 Volatile 关键字
4.0 JMM 是什么 ? 缓存一致性协议MESI ? CPU内存屏障?JAVA内存屏障?
-
JMM定义
全称Java Memory Model(java内存模型)是一系列的Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否可以重排序等问题的无关具体平台的统一的保证。
-
MESI 缓存一致性协议协议
-
缓存不一致问题
CPU处理速度,远大于I/O设备(磁盘),为了解决了处理器与内存的速度矛盾,引入了高速缓存。但是由此也带来了缓存不一致的问题。
- 每个线程都会缓存内存的数据在各自寄存器中,在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。
-
MESI协议内容
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,最常见的就是 MESI 协议:
MESI 表示缓存行的四种状态,分别是:在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的 读写操作,而且也监听(snoop)其它 Cache 的读写操作。
- M(Modify) 表示共享数据只缓存在当前 CPU 缓存中, 并且是被修改状态,也就是缓存的数据和主内存中的数据不一致。
- E(Exclusive) 表示缓存的独占状态,数据只缓存在当前 CPU 缓存中,并且没有被修改。
- S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致。
- I(Invalid) 表示缓存已经失效。
对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
- CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状 态 CPU 只能从主存中读取数据;
- CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状 态的写,需要将其他 CPU 中缓存行置为无效才可写。
-
Store Bufferes(存储缓存)
CPU 缓存行的状态是通过消息传递来进行的,如果 CPU0 要对一个在缓存中共享的变量进行写入,首先发送一个失效的消息给到其他缓存了该数据的 CPU。并且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态。
为了避免阻塞带来的资源浪费。在 cpu 中引入 了 Store Bufferes(存储缓存) 和 Invalidate Queue(无效队列)。
- CPU0 写入共享数据时,直接把数据写入到 store bufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令;
- 收到其他所有 CPU 发送了 invalidate ACK消息时,再将 store bufferes 中的数据数据存储至 cache 中;
- 最后再从本地Cache同步到主内存。
-
CPU层面内存屏障
内存屏障就是将 Store Bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。
硬件层的内存屏障分为两种:Load Barrier (读屏障)和 Store Barrier(写屏障)及 Full Barrier(全屏障)是读屏障和写屏障的合集。
- 写屏障:强制把写缓冲区/高速缓存中的脏数据等写回主内存
- 读屏障:将缓冲区/高速缓存中相应的数据失效。
-
JAVA 内存屏障
java的内存屏障通常所谓的四种,LoadLoad(LL), StoreStore(SS), LoadStore(LS), StoreLoad(SL)实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。
- LoadLoad(LL)屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore(SS)屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore(LS)屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad(SL)屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
-
4.1 什么是HappenBefore原则?
在 JMM 中,如果一个操作执行的结果需要对另一个操作【可见】,那么这两个操作必须要存在 happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程。
它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见 。
-
HappenBefore原则
-
as-if-serial 规则(程序顺序执行):单个线程中的代码顺序不管怎么重排序,对于结果来说是不变的。
-
volatile 变量规则,对于 volatile 修饰的变量的写操作, 一定 happen-before 后续对于 volatile 变量的读操作;
-
监视器锁规则(monitor lock rule):对一个监视器的解锁,happens-before于随后对这个监视器的加锁。
-
传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C。
-
start 规则:如果线程 A 执行操作 ThreadB.start(),那么线程 A 的 ThreadB.start()操作 happens-before 线程 B 中的任意操作。
-
join 规则:如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回。
-
-
举例说明:什么是指令重排序?
请看下面代码:
假设 线程A执行writer()方法之后,线程B执行reader()方法,那么线程B执行4的时候一定能看到线程A写入的值吗?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a; //4
...
}
}
}答案是肯定的。因为根据happens-before规则,我们可以得到如下关系:
- 根据程序顺序规则,1 happens-before 2;3 happens-before 4
- 根据volatile规则,2 happens-before 3;
- 根据传递性规则,1 happens-before 4 。
因此,综合运用程序顺序规则、volatile规则及传递性规则,我们可以得到1 happens-before 4,即线程B在执行4的时候一定能看到A写入的值。
4.2 Volatile 关键字原理
此题考察的是volatile
这个关键字。可以从volatile
的作用和volatile
的原理这三个方面来进行回答。volatile只能保证变量的可见性、有序性,但是不能保证原子性。
- 可见性实现原理
为了实现volatile可见性和happen-befor的语义。JVM底层是通过一个叫做“内存屏障(基于MESI)”的东西来完成(也实现了有序性 ?)。
线程本身并不直接与主内存进行数据的交互,而是通过线程的工作内存来完成相应的操作。这也是导致线程间数据不可见的本质原因。
使用MESI 协议,使得任意一个线程修改了 volatile 修饰的变量,其他线程可以马上识别到最新值。
最终目标:保证了缓存的一致性。
具体的话,下面是用内存屏障来实现的。
- 修改本地工作内存,强制刷回主内存;
-
强制让其他线程的工作内存失效过期;
-
其他线程重新从主内存加载最新值;
-
有序性实现原理
参考:https://blog.csdn.net/qq_42764468/article/details/106898608
从源代码到最终执行的指令,可能会经过三种重排序:
-
【指令】重排序,JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止:①特定类型的编译器重排序和②处理器重排序,为程序员提供一致的内存可见性保证。
-
编译器重排序,JMM 禁止了特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
-
内存重排序:由于处理器会使用读/写缓冲区,出于性能的原因,内存会对读/写进行重排序
JVM 是使用内存屏障来禁止【指令】重排,从而达到:可见性 + 部分有序性效果。
lock前缀指令实际相当于一个内存屏障? 下面不是可见性吗???
对volatile变量的写操作与普通变量的主要区别有两点:
-
修改volatile变量前,会强制将修改后的值刷新的主内存中。
每个volatile写操作前插入StoreStore(SS)屏障
-
修改volatile变量后,会导致其他线程工作内存中对应的变量值失效,因此,再读取该变量值的时候就需要重新从读取主内存中的值。
在写操作后插入StoreLoad屏障;
对volatile变量的读操作类似:
-
在每个volatile读操作前插入LoadLoad(LL)屏障;
确保Load2及后续Load指令加载数据之前能访问到Load1加载的数据。
-
在读操作后插入LoadStore(LS屏障)。
确保Store2和后续Store指令执行前,可以访问到Load1加载的数据。
4.3 volatile为什么不能保证原子性?
对于i++这种复合操作,即使使用volatile关键字修饰也不能保证操作的原子性,可能会引发数据不一致问题。
1 | private volatile int i = 0; |
上述i++
操作,其实分为三个操作:
-
线程读取i
-
temp = i + 1
-
i = temp
A,B两个线程多线程操作时:
-
A线程读取i并执行了 temp = i + 1的操作, 此时的 i(0) 的值还没有变化
-
此时B也读入i并执行temp = i + 1操作,此时i(0)也没变化
⚠️ 虽然有MESI协议,但是
temp
不保存变量i
所在内存区域,是cpu内部的计算,不会被立马刷新内存! -
当A写入i = temp(1)时,由于可见性立马在主存被刷新了值 i=1
-
当B也写入i = temp时,此时A依旧是1,而不是2
4.4 并发编程的三个重要特性 ?
- 原子性 : ⼀个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的⼲扰而中断,要么所有的操作都执行,要么都不执行。
synchronized
可以保证代码片段的原子性。 - 可见性 :当⼀个变量对共享变量进行了修改,那么另外的线程都是⽴即可以看到修改后的最新值。 volatile 关键字可以保证共享变量的可见性。
- 有序性:代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。
volatile
关键字可以禁⽌指令进行重排序优化。
4.5 说说 synchronized 关键字和 volatile 关键字的区别?
synchronized关键字和volatile关键字比较:
-
volatile关键字是线程同步的轻量级实现,所以volatile性能肯定⽐synchronized关键字要好。
-
volatile关键字只能用于变量,而synchronized关键字可以修饰方法以及代码块。
synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引⼊的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用
synchronized 关键字的场景还是更多⼀些。 -
volatile关键字只能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
-
多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发⽣阻塞
1.5.5 Atomic 原子类
5.1 什么是Atomic 原子类?
所以,所谓原子类说简单点就是具有原子/原子操作特征的类。
在我们这⾥ Atomic 是指⼀个操作是不可中断的。即使是在多个线程⼀起执行的时候,⼀个操作⼀旦开始,就不会被其他线程⼲扰。
5.2 JUC 包中的原子类是哪4类?
5.3 Volatile 和 atomic 变量区别?
- Volatile变量可以确保先行关系,即写操作会发生在后续的读操作之前, 但它并不能保证原子性。例如用volatile修饰count变量那么 count++ 操作就不是原子性的。
- 而AtomicInteger类提供的atomic方法可以让这种操作具有原子性如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。
5.4 讲讲 AtomicInteger 的使用 ?
1 | public final int get() //获取当前的值 |
使用示例:
1 | class AtomicIntegerTest { |
5.5(重点提问) AtomicInteger 原理?
AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
我们以自增方法为例: getAndIncrement
1 | public final int getAndIncrement() { |
可以看到,本质是在调用 unsafe中的 getAndAddInt
unsafe中的compareAndSwapInt方法参数。
1 compareAndSwapInt(Object o, long offset,int expected,int x); // x是准备更新的值
1 | public final int getAndIncrement() { |
最终总结如下
AtomicInteger 类主要利用 CAS (compare and swap) + volatile 来保证原子操作。AtomicInteger 的主要方法都是通过调用Unsafe类方法去实现,如 compareAndSet 实际是调用AtomicInteger 的compareAndSwapInt方法。
下面以 getAndIncrement实现来说明。
-
getAndIncrement调用了 Unsafe的getAndAddInt方法,传递了(1)当前this对象,(2)value偏移量valueoffset,用来计算得到value值(3)要加上的值,由于是递增所以是1
⚠️ 为什么不传value的值,而是偏移量? 传偏移量是为了计算value所在的内存地址,进而获取最新的value值。
-
getAndAddInt采用CAS方式进行更新,还需要进行当前期望值的计算
- 通过
getIntVolatile
获取到线程此时内存value值(期望值),也就是记录执行CAS前的内存最新value值;
- 通过
-
然后开始执行Unsafe的
compareAndSwapInt
,主要是通过Atomic::cmpxchg
逻辑来实现(1)将要dest(value内存地址),compareValue(期望值),exchange_value(要更新的值)写入寄存器中
(2)线程如果是运行多核CPU,上LOCK#锁,将dest内存区域锁住 ;否则不上LOCK#锁
(3)执行cmpxchg(比较并交换命令),如果dest的value值(执行CAS中的最新value值) == compareValue,则写入exchange_value ;
(4)否则写入失败,通过不断自旋(循环)期望得到执行