(三)JVM内存模型

Posted by Steven on 2021-05-13
Estimated Reading Time 10 Minutes
Words 2.6k In Total
Viewed Times
加载过程之Linking、Initializing

Linking、Initializing

image-20210514152153556

Linking分为三步:

  • Verfication: 校验,校验是否符合class文件的标准
  • Preparation:将class文件中的静态变量赋默认值
  • Resolution:把class文件中常量池用到的符号引用。转化成内存地址,可以直接使用
    将类、方法、属性等符号引用解析为指针、偏移量等内存地址的直接引用
    常亮池中的各种符号引用解析为指针、偏移量等内存地址的直接引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.shifpeng.scaffold.service.jvm;

/**
* @Project: scaffold
* @Description:
* @Author: Steven
**/
public class ClassLoaderProcedure {
public static void main(String[] args) {
System.out.println(T.count);
}
}

class T {
public static int count = 2;
public static T t = new T();

private T() {
count++;
}
}

//输出结果 3

如果把15、16行代码换个位置,那么输出的就是2,拿交换后的代码分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ClassLoaderProcedure {
public static void main(String[] args) {
System.out.println(T.count);
}
}

class T {
public static T t = new T();
public static int count = 2;

private T() {
count++;
}
}

当我们调用T.class的时候,首先将T.class load进内存,接下来进行Verfication进行校验,校验完进行Preparation给静态变量赋默认值 ,那么此时

1
2
t=null;
count=0;

接下来进行Resolution,这个不分析了,接下来进行initializing,给静态变量赋初始值

此时

1
2
3
4
t=new T();
//这个时候,就会执行count++,t变为1
count=2;
//所以最后T.count=2

还有一点非常类似的就是成员变量的赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ClassLoaderProcedure {
public static void main(String[] args) {
System.out.println(T.count);
}
}

class T {
public static int count = 2;
public static T t = new T();

private int m = 8;

private T() {
count++;
}
}

有一个成员变量,当new T()的时候,是一个申请内存的过程,申请完内存,他里面的成员变量先赋默认值0,然后开始调用构造方法,构造方法才会给赋初始值8。这跟Preparation、initializing有点像

静态变量实在类加载之后进行Preparation、initializing等。而对象中的成员变量是new出来之后才会进行这两步

单例模式&双重检查

有一道面试题:下面程序中的Mgr06需不需要加volatile?(DCL单例为什么要加volatile

答案:需要加,主要是指令重排的问题

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.shifpeng.scaffold.service.jvm;

/**
* 单例模式 DCL单例 (Double Check Loading 双重检查)
* 需要给Mgr06做个单例
* @Project: scaffold
* @Description:
* @Author: Steven
**/
public class Mgr06 {
private static volatile Mgr06 INSTANCE; //JIT

private Mgr06() {
}

public static Mgr06 getInstance() {
//业务代码省略
//先检查INSTANCE是否为空
if (INSTANCE == null) { //还没有任何线程对它初始化
//双重检查
synchronized (Mgr06.class) {
if(INSTANCE == null) { //这里做第二次检查,是因为在第一次判断到加锁成功这段时间,有可能被其他线程初始化
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
INSTANCE = new Mgr06(); //依然为空,那么开始进行初始化
}
}
}
return INSTANCE;
}

public void m() {
System.out.println("m");
}

public static void main(String[] args) {
for(int i=0; i<100; i++) {
new Thread(()->{
System.out.println(Mgr06.getInstance().hashCode());
}).start();
}
}

}

如果说不加volatile会发生什么情况?

INSTANCE = new Mgr06(); 在对这个INSTANCE进行初始化的时候,如果初始化到一半的时候(什么叫初始化到一半?就是new Mgr06(),并且申请到了内存,里面的成员变量赋了默认值0;这个时候INSTANCE已经指向到这个内存了,所以不再是空了),这个时候另外一个线程来了,首先判断INSTANCE == null,发生不为空,所以就第二个线程就开始直接使用成员变量的默认值0了,而不是初始值了,这个时候就出问题了

解决这个问题的关键就是加volatile

那为什么加上这个volatile就可以解决,这里是指令重排的问题

下面的代码,编译一下,查看字节码文件:

其中第一行,表示new T1,申请好内存空间

第三行:invokespecial调用构造方法,将成员变量属性a赋值为8

第4行:astore_1将上面的引用值赋值给t

image-20210519225011123

正常情况下,应该是先调用完invokespecial,再赋值给t,这样就不会出问题,由于指令有可能会重排,这两句指令发生的顺序有可能会不一样,即先把内存地址扔到t里了,然后在进行的初始化,就有可能发生有别的线程读到半初始化的现象

JMM Java内存模型(Java Memory Model)

1、高并发的情况下,Java内存内存模型是怎么提供支持的

2、一个对象在内存中是怎么布局的?

这里需要先说一下硬件层的并发优化基础知识,再将java的内存模型到底在硬件的基础上怎么实现的

硬件层的并发优化

存储器的层次结构

(深入理解计算机系统 原书第三版 P421)

image-20210520074231139

离CPU越近,存储空间越小,但是速度越快,CPU要读数据的时候,假如一个数据是在硬盘上的(L5:),他首先是被load到内存,如果读数据的时候,CPU先高速缓存中去找(L1:),如果没有,继续去下一层(L2:),如果有,先load到最近的一层(L1:)(下次再去找的话就快了)

image-20210520075248729

系统总线-总线锁

但是这里就会产生一个很严重的问题:

image-20210520143058339

现在有两个CPU(或者两个核),假如有一个数在内存中(main memory),这个数会被load到L3 Cache中,L2和L1这两级缓存是在CPU的内部的,根据上面的图,我们就会想到,主存或者L3中的数据有可能会被load到不同的CPU内部,如果CPU1对修改X为1,CPU2修改X为2,那么就会存在数据不一致的情况。线程1拍跑在CPU1上,线程2跑在CPU2上,读到的数据是不一致的

那如何解决? 有许多种解决方式

多线程一致性的硬件层支持

通过系统总线 ,即L2通过一个系统总线 访问L3缓存数据(加把锁)

总线锁(bus lock)会锁住总线,使得其他CPU甚至不能访问内存中其他的地址,因为效率较低

image-20210520144741337

因此,总线锁 是老的CPU使用的

那新的CPU怎么解决的?各种各样的一致性协议 解决这个问题,而IntelCPU使用的是MESI,所以我们大多数聊的是MESI

因此,低层的一致性是怎么实现的? MESI

Cache Line 的概念、缓存行对其 伪共享

关于MESI Cache一致性协议

有一篇文章可以看一下 【并发编程】MESI–CPU缓存一致性协议 可以参考学习一下,防止丢失,我截图出来:

image-20210520141850363

通过MESI协议让来让各个CPU之间的缓存保持一致性

MESI称为缓存锁,缓存锁的一个实现就是MESI

有些无法被缓存的数据,活着跨越多个缓存行的数据,依然必须使用总线锁

现代CPU的数据一致性实现=缓存锁(MESI等)+ 总线锁

缓存行(面试)

从内存中读取数据放到CPU缓存中,读取缓存是以cache line为单位,目前为64byte

1、假如X、Y在一个缓存行,CPU1只用X,读取的时候,X、Y都会被读进来,CPU2只用Y,同样都会读进来

那么当CPU1把X做了修改,要通知其他CPU,通知其他CPU说“整个缓存行被改过了,这个缓存行已经是invalid的状态了 ,麻烦你更新一下

”,那么CPU2就会把缓存行重新再读一遍。

2、同理,CPU2改完Y,通知一下,CPU1跟Y没有关系,但是还是重新读了一遍缓存行

两个互相无关的值因为变化,都需要重新读取一次缓存行

伪共享

位于同一缓存行的两个不同数据,被两个不同的CPU锁定,产生互相影响, 这种就叫伪共享

举个例子证明一下这个问题:

例子01:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.shifpeng.scaffold.service.juc;

/**
* @Project: scaffold
* @Description:
* @Author: Steven
**/
public class CacheLinePadding01 {

//创了了一个T类,这里面只有一个long类型的x(8个字节)
private static class T {
public volatile long x = 0L;
}

//T类型的数组
public static T[] arr = new T[2];

//静态初始化完成之后,内存里面有两个数组,这两个数组里面指向的new 的对象,每个里面就只有一个8个字节的long类型
static {
arr[0] = new T();
arr[1] = new T();
}

//启动两个线程,循环一千万次,让X的值不断发生变化,刚好这两个值位于一个缓存行,又正好位于一个CPU,那就会发生两个CPU不断的更新缓存行
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
});

Thread t2 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
});

final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
//为了观察起来方便,这里都除以100_0000
System.out.println((System.nanoTime() - start)/100_0000);
}

}

//执行结果 215

例子02: 缓存行对齐

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.shifpeng.scaffold.service.juc;

/**
* @Project: scaffold
* @Description:
* @Author: Steven
**/
public class CacheLinePadding02 {
private static class Padding {
//创建7个long类型的数,7*8为56个字节
public volatile long p1, p2, p3, p4, p5, p6, p7;
}

//从Padding继承,而T又有一个8字节的x,因此56+8等于64字节,也就是说T64个字节,也就是一个缓存行,这样的话,另外一个肯定不会存在同一个缓存行了,效率肯定就高了
private static class T extends Padding {
public volatile long x = 0L;
}

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 < 1000_0000L; i++) {
arr[0].x = i;
}
});

Thread t2 = new Thread(()->{
for (long i = 0; i < 1000_0000L; 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);
}

}

//执行结果 67

这个案例在开源软件disruptor中也有用到高性能队列——Disruptor

综上,使用缓存行的对齐可以解决伪共享的问题,虽然会浪费一些空间,但是能够提升效率

乱序问题


如果您喜欢此博客或发现它对您有用,则欢迎对此发表评论。 也欢迎您共享此博客,以便更多人可以参与。 如果博客中使用的图像侵犯了您的版权,请与作者联系以将其删除。 谢谢 !