前言
- 这篇博客主要讲了
class 文件的初始化的流程和两个案例
、DCL之单例模式
、引出的volicate的作用和原理
、进而引出指令重排序
、线程(内存)一致性
的概念、解决重排序的代码层面volicate 和JVM层次的规范以及CPU层次的内存屏障
的三个层次、缓存行
的概念、还有指令重排(乱序)的证明
过程。
一、class文件初始化过程
1、概述
上一篇博文主要讲的类初始化的类加载过程,也就是loading。
这里就说一下其他部分,通过案例进行讲解。
- loading ,class文件加载到内存
- linking,包括三部分
- verification
- preparation
- resolution
- initializing,初始化部分
2、初始化过程-案例1
a、代码T001_ClassLoadingProcedure 类加载过程
package com.mashibing.jvm.c2_classloader;
public class T001_ClassLoadingProcedure {
public static void main(String[] args) {
System.out.println(T.count);
}
}
class T {
public static int count = 2; //0
public static T t = new T(); // null
//private int m = 8;
private T() {
count ++;
//System.out.println("--" + count);
}
}
b、解析
- loading过程:代码执行到
main
方法打印语句时,先加载T.class
类到内存中。 - verification: 检测过程
- preparation :
静态变量
赋默认值过程,此时count = 0, t = null
。 - resolution :解析过程
- initializing:初始化过程,此时
count = 2,t = new T(),执行无参构造函数,然后count自加为3
。所以输出3。
3、初始化过程-案例2
a、代码
还是上面案例1 的代码,改成两个静态变量的顺序,如下:
打印的结果为2,这是为啥呢。
b、解析
调换顺序,打印的数据就变成了2,还是初始化的过程,解说如下
loading,类加载过程
verification ,校验过程
preparation ,静态变量赋默认值,此时
t=null,count = 0
,resolution ,解析过程
initializing,赋值过程,此时
t=new T() 执行无参构造函数,此时 count 自加后为1,然后执行第二行,count = 2,则覆盖了第一次的count 的自加操作,所以为2
。也说明类初始化过程中的重要性 以及 代码顺序的重要性。
当然,一般不会这么赋初值的,一般会用静态代码块或者在构造函数里进行 赋初值。都是为了面试而准备的,当然也是说明类初始化的顺序
二、DCL(双重检查) 之 单例模式
1、Double Check Lock
DCL(Double Check Lock)
即双重锁定检查;单例模式中 要加volicate 关键字,目的是为了防止指令重排
。参考资料,仔细读:
2、volicate 关键字作用及原理
被volatile修饰的变量在编译成字节码文件时会多个lock指令
,该指令在执行过程中会生成相应的内存屏障,以此来解决可见性跟重排序的问题。
a、volicate的作用
线程(内存)可见性
基于缓存一致性协议
,当用voliate关键字修饰的变量改动时,cpu会通知其他线程,缓存已被修改,需要更新缓存。这样每个线程都能获取到最新的变量值。当一条线程修改了voliate变量的值,新值对于其他线程来说是可以立即得知的
。静止重排序:基于内存屏障的防止指令重排
voliate修饰的变量,保证变量赋值操作的顺序与程序代码中的执行顺序一致。可以防止cpu指令重排序。底层的实现方式是基于4种内存屏障:读读、读写、写读、读读屏障。
b、预备知识
- 指令重排序(下面第五节也有细讲)
- 为什么指令重排序:一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它
不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的
。 - 指令重排序遵守的准则:编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
- 什么办法来禁止指令重排序呢:添加内存屏障
- 为什么指令重排序:一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它
- 内存屏障
- 内存屏障分类:内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。
- 内存屏障作用:
(1)阻止屏障两侧的指令重排序,即屏障下面的代码不能和屏障上面的代码交换顺序(静止重排序)
(2)在有内存屏障的地方,线程修改完共享变量以后会马上把该变量从本地内存写回到主内存,并且让其他线程本地内存中该变量副本失效(使用MESI协议)(线程可见性)
对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,其他线程程可见
b、volicate的原理
原理
- volatile关键字修饰的变量会存在一个“
lock:
”的前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线或高速缓存加锁(一般只是对缓存行
枷锁),可以理解为CPU指令级的一种锁。 - 在具体的执行上,它先对总线或缓存加锁,然后执行后面的指令,在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。最后释放锁后会把高速缓存中的脏数据(修改过的数据)全部刷新回主内存,且这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。
- 在java内存层面可以理解为:当需要使用(use)这个变量时,必须从主存中read–>load这个变量(即要使用这个变量时,必须从主存中读取这个变量,这就保证了该变量是最新的);当线程工作内存中这个变量被赋值时(assign),那么立刻store–>write这个变量(即当该值计算完成,立刻把这个变量写会主存,并且使得该值在其他内存的工作变量中无效)
java内存屏障
volatile语义中的内存屏障
volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
在每个volatile写操作前插入StoreStore屏障(这个屏障前后的2个Store指令不能交换顺序),在写操作后插入StoreLoad屏障(这个屏障前后的2个Store Load指令不能交换顺序);
在每个volatile读操作前插入LoadLoad屏障(这个屏障前后的2个Load指令不能交换顺序),在读操作后插入LoadStore屏障(这个屏障前后的2个Load Store指令不能交换顺序);
由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。
在Java中对于volatile修饰的变量,编译器在生成字节码时,会在指令序列中插入内存屏障禁止处理器重排序。
举例
两条线程Thread-A与Threab-B同时操作主存中的一个volatile变量i时。Thread-A写了变量i,那么:
Thread-A发出LOCK#指令
(1)发出的LOCK#指令锁总线(或锁缓存行)(因为它会锁住总线,导致其他CPU不能访问总线,不能访问总线就意味着不能访问系统内存),然后释放锁,最后刷新回主内(瞬间完成的,写回时候其他缓存行失效),同时让Thread-B高速缓存中的缓存行内容失效。
(2)Thread-A向主存回写最新修改的i
Thread-B读取变量i,那么:
Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值(重新从主存读)
由此可以看出,volatile关键字的读和普通变量的读取相比基本没差别,差别主要还是在变量的写操作上。
举例
使用场景
满足以下两点,那么volatile修饰的共享变量,不用加锁也能保证线程安全:
- 运算结果不依赖变量的当前值(即变量计算的结果和当前的值没有关系,比如一个boolean变量的改变,但是i++这种运算就存在依赖关系,以为新值是在旧值的基础上加1),或者能够确保只有单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变性约束(即该变量不和其他变量关联)
三、硬件层数据一致性
0、JMM Java内存模型
- JMM :java memory mode。java内存模型。
- 看完这篇文章,在继续接着看 资料 多线程之内存可见性Volatile (一)
- 扩展知识 CAS 多线程之原子变量CAS算法(二)
1、硬件层的并发优化基础知识
- 存储器的层次结构 (深入理解计算机系统 原书第三版 P421)
计算机模型:CPU-内存 模型
:从下面这张图中可以看出,CPU 到内存直接还有多缓存(L1_cache、L2_cache、L3_cache),速度也是逐级递减(L1_cache基本能和cpu持平,其他的均明显低于cpu,L2_cache的速度大约比cpu慢20-30倍),L1_cache 和 L2_cache 是与CPU的内核在一块儿的,L3_cache 是共享的。- 总线锁会锁住总线,使得其他CPU甚至不能访问内存中其他的地址,因而效率较低
b、Intel 的缓存一致性协议:MESI
协议很多,Intel 的Cache(缓存)一致性协议:MESI。
可参考此网址:https://www.cnblogs.com/z00377750/p/9180644.html
- MESI:modified、Exclusive、Shared、Invalid
- 现代CPU的数据一致性实现 = 缓存锁(MESI …) + 总线锁
四、缓存行(面试可能会被问道)-伪共享
1、定义
缓存行:当我们要把内存里面的某一些数据放到CPU自己的缓存时,不会只把这一个数据放进去,比如一个int型数据 12 ,只有 4 个字节,读缓存时不会只把这4个字节的数据读入到缓存,而且为了提高效率,把4个字节后面的一块儿内容全部读进去,读一个内容把一块儿内容全都读进去,这一块儿内容是一个基本的缓存单位,就是 缓存行,读取缓存以cache line为基本单位,目前64bytes。
伪共享问题: 位于同一
缓存行
的两个不同数据,被两个不同CPU锁定,产生互相影响的伪共享问题使用
缓存行
的对齐能够提高效率 。
2、案例
a、T01_CacheLinePadding
- 案例中, 数组开辟的两个数值的地址,应该在同一个缓存行。
- 通过两个线程去频繁的改变他们数值。
- 两个变量在
一个缓存行
,两个线程应该在两个CPU内核
,那么会涉及到数据共享问题
(上面说过Intel 的CPU 会用 MESI 缓存协议一致性) - 因为涉及到缓存一致性,所以会导致时间会比较长
package com.mashibing.juc.c_001_02_FalseSharing;
public class T01_CacheLinePadding {
public static long COUNT = 10_0000_0000L;
private static class T {
public long x = 0L; //8bytes
}
public static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
for (long i = 0; i < COUNT; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < COUNT; i++) {
arr[1].x = i;
}
});
final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start) / 100_0000);
}
}
b、T02_CacheLinePadding
- 案例中, 和上面的案例不同的是,一个数组。这里的是两个缓存行(两个缓存快),因为一个 Long 占 8个字节, 7个变量(p1-p7)就是 7*8 = 56个字节,在加一个 long 类型 变量x 正好是64个字节,缓存行的默认大小是 64个字节。
- 通过两个线程去频繁的改变他们数值。
- 两个变量在
两个缓存行
,两个线程应该在两个CPU内核
,那么不会涉及到数据共享问题
(上面说过Intel 的CPU 会用 MESI 缓存协议一致性) - 因为涉及到缓存一致性,所以会导致时间会比较慢
package com.mashibing.juc.c_001_02_FalseSharing;
public class T02_CacheLinePadding {
public static long COUNT = 10_0000_0000L;
private static class Padding {
private volatile long p1, p2, p3, p4, p5, p6, p7; // 7*8=56个字节
}
private static class T extends Padding {
public volatile long x = 0L; //8bytes 8+56 = 64 个字节,所以 一个 T 就是一个缓存行
}
public static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
for (long i = 0; i < COUNT; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < COUNT; i++) {
arr[1].x = i;
}
});
final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start) / 100_0000);
}
}
c、解析
- 按照正常的设想,按照视频的学习,确实代码1要比代码2 的时间要长一些,因为涉及到了缓存一致性的问题。
- 但是我这里测试的确实代码1要比代码2的时间要短很多。可能会涉及到电脑类型、CPU内核数等等相关才照成这个问题的。
五、指令乱序执行问题(指令重排序)(二.2.2也有讲)
1、乱序执行指令(并行)
- CPU为了提高指令执行效率,会在一条指令执行过程中(比如去内存读数据(去内存读数据要比CPU执行指令慢100倍)),去同时执行另一条指令,前提是,两条指令没有依赖关系
2、合并写操作
3、合并写案例:WriteCombining
该案例说明:合并写的速度快。
package com.mashibing.juc.c_029_WriteCombining;
/**
* 原封不动老外的代码:合并写案例
*/
public final class WriteCombining {
private static final int ITERATIONS = Integer.MAX_VALUE;
private static final int ITEMS = 1 << 24;
private static final int MASK = ITEMS - 1;
private static final byte[] arrayA = new byte[ITEMS];
private static final byte[] arrayB = new byte[ITEMS];
private static final byte[] arrayC = new byte[ITEMS];
private static final byte[] arrayD = new byte[ITEMS];
private static final byte[] arrayE = new byte[ITEMS];
private static final byte[] arrayF = new byte[ITEMS];
public static void main(final String[] args) {
for (int i = 1; i <= 3; i++) {
System.out.println(i + " SingleLoop duration (ns) = " + runCaseOne());
System.out.println(i + " SplitLoop duration (ns) = " + runCaseTwo());
}
}
public static long runCaseOne() {
long start = System.nanoTime();
int i = ITERATIONS;
while (--i != 0) {
int slot = i & MASK;
byte b = (byte) i;
arrayA[slot] = b;
arrayB[slot] = b;
arrayC[slot] = b;
arrayD[slot] = b;
arrayE[slot] = b;
arrayF[slot] = b;
}
return System.nanoTime() - start;
}
public static long runCaseTwo() {
long start = System.nanoTime();
int i = ITERATIONS;
while (--i != 0) {
int slot = i & MASK;
byte b = (byte) i;
arrayA[slot] = b;
arrayB[slot] = b;
arrayC[slot] = b;
}
i = ITERATIONS;
while (--i != 0) {
int slot = i & MASK;
byte b = (byte) i;
arrayD[slot] = b;
arrayE[slot] = b;
arrayF[slot] = b;
}
return System.nanoTime() - start;
}
}
4、如何禁止CPU指令重排
- 如何禁止CPU指令重排,一定要看
- 上面文章的三个层次,是贯穿性质的,volicate层次作用在JVM级别的规范层次,然后作用在CPU的内存屏障层次。
六、指令乱序执行证明
1、指令案例:T04_Disorder
a、代码
package com.mashibing.jvm.c3_jmm;
public class T04_Disorder {
private static int x = 0, y = 0;
private static int a = 0, b =0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for(;;) {
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread one = new Thread(new Runnable() {
public void run() {
//由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
//shortWait(100000);
a = 1;
x = b;
}
});
Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();other.start();
one.join();other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
//System.out.println(result);
}
}
}
public static void shortWait(long interval){
long start = System.nanoTime();
long end;
do{
end = System.nanoTime();
}while(start + interval >= end);
}
}
b、说明
x,y的值可能的取值:(1,0)(0,1)(1,1)
一旦出现了(0,0)说明出现了指令乱序执行。如果顺序执行,执行顺序可能是
- a=1 x=b b=1 y=a,此时 x=0,y=1
- a=1 b=1 x=b y=a,此时 x=1,y=1
- b=1 y=a a=1 x=b,此时 x=1,y=0
如果是乱序执行,比如说执行顺序如下
- x=b y=a a=1 b=1,此时 x=0,y=0
- x=b a=1 y=a b=1,此时 x=0,y=1
- 如果结果出现 x=0,b=0,证明cpu乱序执行。事实证明,在结果输出中,确实发生了指令重排。
2、如何保证特定情况下不乱序(这里与五.4的内容类似)
a、硬件内存屏障 Intel X86
加锁是肯定的可以的,但是在不同的CPU(很多CPU)都添加了
硬件内存屏障
(CPU级别的内存屏障,和java的内存屏障无关系)Intel 设置的比较简单,只有三条指令。
sfence
(store fence 存栅栏的意思): store| 在sfence指令前的写操作当必须在sfence指令后的写操作前完成。lfence
(load fence 读屏障):load | 在lfence指令前的读操作当必须在lfence指令后的读操作前完成。mfence
(二者之和):modify/mix | 在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。
intel lock汇编指令(java的),原子指令,如x86上的”lock …” 指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。Software Locks通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序
b、JVM级别如何规范(JSR133)
LoadLoad屏障:
对于这样的语句Load1; LoadLoad; Load2,
在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。StoreStore屏障:
对于这样的语句Store1; StoreStore; Store2,
在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。LoadStore屏障:
对于这样的语句Load1; LoadStore; Store2,
在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。StoreLoad屏障:
对于这样的语句Store1; StoreLoad; Load2,
在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。上面的JVM级别的四个指令屏障 都是依赖于 硬件去实现的,硬件的实现不是只有内存屏障能够实现JVM级别的内存屏障,即JVM的内存屏障或者交JVM级别的有序性,它的硬件级别的实现并不一定依赖于硬件级别的内存屏障,还依赖于硬件级别的
lock 指令
。硬件界别的和JVM级别不是一回事儿。
3、volatile的实现细节
volatile 的实现过程
,分不同的层面,包含以下三个层面。
.java
编译成字节码(byte code)
.class
这是字节码层面
。- 字节码 在JVM层级实现,这是
JVM层面
。 - JVM 交付到 硬件 去执行,这是
OS硬件层面
a、字节码层面
- ACC_VOLATILE
- 只需要一个
volatile
关键字即可 - 访问标志(access flag):就是修饰符,比如 public、private、protect等。
b、JVM层面
到了JVM屏障,volatile内存区的读写 都加屏障
StoreStoreBarrier
volatile 写操作
StoreLoadBarrier
LoadLoadBarrier
volatile 读操作
LoadStoreBarrier
c、OS和硬件层面
https://blog.csdn.net/qq_26222859/article/details/52235930hsdis 工具
- HotSpot Dis Assembler
在 windows 使用 lock 指令实现 | MESI 实现
4、synchronized实现细节
同 volatile 一样,也是三个层级。
- 字节码层面
- JVM层面
- OS 硬件层面
a. 字节码层面
从下面截图中可以看出,使用的是 ACC_SYNCHRONIZED
修饰符。
monitorenter monitorexit
i、小案例
package com.mashibing.jvm.c3_jmm;
public class TestSync {
synchronized void m() {
}
void n() {
synchronized (this) {
}
}
public static void main(String[] args) {
}
}
从编译后的字节码看到如下图
ii、解析
synchronized 块 中可以看出,有三个指令, monitorenter
,monitorexit
,monitorexit
。后两个是一个指令。
为什么会三个,后两个是一样的呢,逻辑如下图,第三个指令是如果出现了异常进行捕捉,才用到第三个 monitorexit
指令。
b. JVM层面
是C 和 C++ 写的,则 C 和 C++ 调用了操作系统提供的同步机制。
c. OS和硬件层面
CPU Intel X86 : 使用 lock cmpxchg / xxx 等指令。
https://blog.csdn.net/21aspnet/article/details/88571740