CPU的基本工作是执行存储的指令序列,即程序。程序的执行过程实际上是不断地取出指令、分析指令、执行指令的过程。
几乎所有的冯•诺伊曼型计算机的CPU,其工作都可以分为5个阶段:取指令、指令译码、执行指令、访存取数和结果写回。
现代处理器的体系结构中,采用了流水线的处理方式对指令进行处理。指令包含了很多阶段,对其进行拆解,每个阶段由专门的硬件电路、寄存器来处 理,就可以实现流水线处理。实现更高的CPU吞吐量,但是由于流水线处理本身的额外开销,可能会增加延迟。
在计算机系统中,CPU高速缓存(CPU Cache,简称缓存)是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。
当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。
缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。这种局部性既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality)。有效利用这种局部性,缓存可以达到极高的命中率。
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
示例方法:{i++ (i为实例变量)}
这样一个简单语句主要由三个操作组成:
如果对实例变量i的操作不做额外的控制,那么多个线程同时调用,就会出现覆盖现象,丢失部分更新。
另外,如果再考虑上工作内存和主存之间的交互,可细分为以下几个操作:
可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
存在可见性问题的根本原因是由于缓存的存在,线程持有的是共享变量的副本,无法感知其他线程对于共享变量的更改,导致读取的值不是最新的。
while (flag) {//语句1
doSomething();//语句2
}
flag = false;//语句3
线程1判断flag标记,满足条件则执行语句2;线程2flag标记置为false,但由于可见性问题,线程1无法感知,就会一直循环处理语句2。
顺序性:即程序执行的顺序按照代码的先后顺序执行
由于编译重排序和指令重排序的存在,是的程序真正执行的顺序不一定是跟代码的顺序一致,这种情况在多线程情况下会出现问题。
if (inited == false) {
context = loadContext(); //语句1
inited = true; //语句2
}
doSomethingwithconfig(context); //语句3
由于语句1和语句2没有依赖性,语句1和语句2可能 并行执行 或者 语句2先于语句1执行,如果这段代码两个线程同时执行,线程1执行了语句2,而语句1还没有执行完,这个时候线程2判断inited为true,则执行语句3,但由于context没有初始化完成,则会导致出现未知的异常。
Java虚拟机规范定义了Java内存模型(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果(C/C++等则直接使用物理机和OS的内存模型,使得程序须针对特定平台编写),它在多线程的情况下尤其重要。
JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量是指共享变量,存在竞争问题的变量,如实例字段、静态字段、数组对象元素等,不包括线程私有的局部变量、方法参数等,因为私有变量不存在竞争问题。可以认为JMM包括内存划分、变量访问操作与规则两部分。
分为主内存和工作内存,每个线程都有自己的工作内存,它们共享主内存。
线程对共享变量的所有读写操作都在自己的工作内存中进行,不能直接读写主内存中的变量。
不同线程间也无法直接访问对方工作内存中的变量,线程间变量值的传递必须通过主内存完成。
这种划分与Java内存区域中堆、栈、方法区等的划分是不同层次的划分,两者基本没有关系。硬要联系的话,大致上主内存对应Java堆中对象的实例数据部分、工作内存对应栈的部分区域;从更低层次上说,主内存对应物理硬件内存、工作内存对应寄存器和高速缓存。
关于主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存中的实现细节。Java内存模型定义了8种原子操作来完成:
定义原子操作的使用规则
从上面可以看出,把变量从主内存复制到工作内存需要顺序执行read、load,从工作内存同步回主内存则需要顺序执行store、write。总结:
long和double型变量的特殊规则
Java内存模型要求前述8个操作具有原子性,但对于64位的数据类型long和double,在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。即未被volatile修饰时线程对其的读取read不是原子操作,可能只读到“半个变量”值。虽然如此,商用虚拟机几乎都把64位数据的读写实现为原子操作,因此我们可以忽略这个问题。
Java内存模型具备一些先天的“有序性”,即不需要通过任何同步手段(volatile、synchronized等)就能够得到保证的有序性,这个通常也称为happens-before原则。
如果两个操作的执行次序不符合先行原则且无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
由JMM的lock、unlock可实现更大范围的原子性保证,但是这是JVM需要实现支持的功能,对于开发者则是有由synchronized关键字 或者 Lock读写锁 来保证原子性。
volatile 变量值被一个线程修改后会立即同步回主内存、变量值被其他线程读取前立即从主内存刷新值到工作内存。即read、load、use三者连续顺序执行,assign、store、write连续顺序执行。
synchronized/Lock 由lock和unlock的使用规则保证
final 修饰的字段在构造器中一旦初始化完成,且构造器没有把“this”的引用传递出去,则其他线程可立即看到final字段的值。
volatile 禁止指令重排序
synchronized/Lock “一个变量在同一个时刻只允许一条线程对其执行lock操作”
被volatile修饰的变量能保证器顺序性和可见性
顺序性
可见性
volatile相比于synchronized/Lock是非常轻量级,但是使用场景是有限制的:
实现原理
volatile底层是通过cpu提供的内存屏障指令来实现的。硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。
内存屏障有两个作用:
对于final域的内存语义,编译器和处理器要遵守两个重排序规则(内部实现也是使用内存屏障):
public class FinalExample {
int i;//普通域
final int j;//final域
static FinalExample obj;
public FinalExample () {
i = 1;//写普通域。对普通域的写操作【可能会】被重排序到构造函数之外
j = 2;//写final域。对final域的写操作【不会】被重排序到构造函数之外
}
// 写线程A执行
public static void writer () { 
obj = new FinalExample ();
}
// 读线程B执行
public static void reader () { 
FinalExample object = obj;//读对象引用
int a = object.i;//读普通域。可能会看到结果为0(由于i=1可能被重排序到构造函数外,此时y还没有被初始化)
int b = object.j;//读final域。保证能够看到结果为2
}
}
初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如alpha处理器),这个规则就是专门用来针对这种处理器的。
对于final域是引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:
synchronized用于修饰普通方法、修饰静态方法、修饰代码块
实现原理
使用对象的监视器(Monitor,也有叫管程的)进行控制
使用哪个对象的监视器:
MonitorEnter(加锁):
MonitorExit(解锁):
对于 MonitorEnter、MonitorExit 来说,有两个基本参数:
关键结构
在 JVM 中,对象在内存中的布局分为三块区域:对象头、实例数据、对齐填充。如下:
实例变量
填充数据
对象头(Object Header)
Mark Word 存储的并非对象的 实际业务数据(如对象的字段值),属于 额外存储成本。为了节约存储空间,Mark Word 被设计为一个 非固定的数据结构,以便在尽量小的空间中存储尽量多的数据,它会根据对象的状态,变换自己的数据结构,从而复用自己的存储空间。
锁的状态共有 4 种:无锁、偏向锁、轻量级锁、重量级锁。随着竞争的增加,锁的使用情况如下:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
其中偏向锁和轻量级锁是从 JDK 6 时引入的,在 JDK 6 中默认开启。锁的升级(锁膨胀,inflate)是单向的,只能从低到高(从左到右)。不会出现 锁的降级。
偏向锁
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01” (可偏向),即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为“01”,不可偏向)或 轻量级锁定(标志位为“00”)的状态,后续的同步操作就进入轻量级锁的流程。
轻量级锁
进入到轻量级锁说明不止一个线程尝试获取锁,这个阶段会通过自适应自旋CAS方式获取锁。如果获取失败,则进行锁膨胀,进入重量级锁流程,线程阻塞。
重量级锁
重量级锁是通过系统的线程互斥锁来实现的,代价最昂贵
ContentionList,CXQ,存放最近竞争锁的线程
EntryLis,表示胜者组
WaitSet,存放处于等待状态的线程
注意:
当一个线程成功获取到锁时 对象监视器的 owner 字段从 NULL 变为非空,指向此线程 必须将自己从ContentionList或EntryList中出队
竞争型的锁传递机制 线程释放锁时,不保证后继线程一定可以获得到锁,而是后继线程去竞争锁
OnDeck,表示准备就绪的线程,保证任何时候都只有一个线程来直接竞争 锁
OS 互斥锁
重量级锁是通过操作系统的线程互斥锁来实现的,在 Linux 下,锁所用的技术是 pthead_mutex_lock / pthead_mutex_unlock,即线程间的互斥锁。
线程互斥锁是基于 futex(Fast Userspace Mutex)机制实现的。常规的操作系统的同步机制(如 IPC 等),调用时都需要陷入到内核中执行,即使没有竞争也要执行一次陷入操作(int 0x80,trap)。而 futex 则是内核态和用户态的混合,无竞争时,获取锁和释放锁都不需要陷入内核。
初始分配
首先在内存分配 futex 共享变量,对线程而言,内存是共享的,直接分配(malloc)即可,为整数类型,初始值为1。
获取锁
使用CAS对 futex 变量减1,观察其结果:
释放锁
使用CAS给futex变量加1
另写一篇专门讲解AQS的锁机制的文章
作者:VectorJin
来源:https://juejin.cn/post/6844904036026548237