Java内存模型之底层原理

Java内存模型之底层原理

image-20200212215745899

彩蛋:自顶向下的好处

这种方法不止适用于本门课,更是学习新知识的一个思路,学习知识也应该采取这样的方式

先讲使用场景(现象),再讲用法,最后讲原理

这种方法会有直观的了解,具体而感性的认识,有助于加深理解,最后分析源码

例子:计算机网络(自顶向下网络)、c语言学int

什么叫底层原理

重要性

Java面试的必考知识点,只有学会这一章,才说明真正懂了并发

从Java代码到CPU指令

  1. 最开始,编写的Java代码,是*.java文件
  2. 在编译(javac命令)后,从刚才的.java文件会变成一个新的Java字节码文件(\.class)
  3. JVM会执行刚才生成的字节码文件(*.class),并把字节码文件中转化为机器指令
  4. 机器指令可以直接在CPU上执行,也就是最终的程序执行

在早期没有JMM内存模型的时候,JVM实现会带来不同的“翻译”,不同的CPU平台的机器指令又千差万别,无法保证并发安全的效果一致,正是因为此,所以想解决问题,所以需要一套规范与原则,来把转化过程做一个约束

JVM内存模型与Java内存模型与Java对象模型

容易混淆

整体方向

JVM内存结构和Java虚拟机的运行时区域有关

Java内存模型和Java的并发编程有关

Java对象模型和Java对象在虚拟机中的表现形式有关

JVM内存结构

Java对象模型

Java对象自身的存储模型

JVM会给这个类创建一个instanceKlass,保存在方法区中,用来在JVM层表示该Java类

在代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据

JMM是什么

为什么需要JMM

JMM是规范

JMM是规范,全称Java Memory Model,是一组规范,需要各个JVM的是实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便地开发多线程程序,如果没有这样一个JMM内存模型来规范,那么很可能经过不同JVM的不同规则的重排序之后,导致不同虚拟机上运行的结果不同,那是很大的问题

C语言不存在内存模型的概念,由于不存在这样的概念,所以很多行为是依赖于处理器本身的内存一致性模型,这种模型依赖于处理器,不同的的处理器来自于不同的厂商,所以之间的模型也不一样,所以很多C的程序在一个处理器上运行正常,而在另一个处理器上运行却不一样,所以无法保证并发安全

所以需要一种标准,让多线程运行的结果可预期

JMM是工具类和关键字的原理

volatile、synchronized、Lock等的原理都是JMM

如果没有JMM,那就需要我们指定自己什么时候用内存栅栏(工作内存和主内存之间的工作与同步)等,那是相当麻烦的,幸好有了JMM,让用户只需要用同步工具和关键字就可以开发并发程序

最重要的3点内容,重排序、可见性、原子性

重排序

重排序的代码案例

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
* 描述: 演示重排序的现象 “直到达到某个条件才停止”,测试小概率事件
*/
//OutOfOrderExecution表示重排序,重排序不是经常发生的
public class OutOfOrderExecution {

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;

CountDownLatch latch = new CountDownLatch(3);

Thread one = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.countDown();
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.countDown();
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
}
});
two.start();
one.start();
latch.countDown();
one.join();
two.join();

String result = "第" + i + "次(" + x + "," + y + ")";
if (x == 1 && y == 1) {
System.out.println(result);
break;
} else {
System.out.println(result);
}
}
}


}

输出结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
第1次(0,1)
第2次(0,1)
第3次(0,1)
第4次(0,1)
第5次(1,0)
第6次(0,1)
第7次(0,1)
第8次(1,0)
第9次(0,1)
第10次(0,1)
第11次(0,1)
第12次(1,0)
第13次(0,1)
第14次(0,1)
第15次(0,1)
第16次(0,1)
第17次(0,1)
第18次(0,1)
第19次(1,1)

这4行代码的执行顺序决定了最终x和y的结果,一共有3种情况

什么是重排序

在线程1内部的两行代码的实际执行顺序和代码在Java文件中的顺序不一致,代码指令并不是严格按照代码语句顺序执行的,它们的顺序被改变,这就是重排序,这里被颠倒的是y=a和b=1这两行语句

重排序的好处

提高处理速度

对比重排序前后的指令优化

发生重排序的3种情况

编译器优化:包括JVM、JIT编译器等

CPU指令重排:就算编译器不发生重排,CPU也可以对指令进行重排

内存的“重排序”:属于表面现象重排序的一种,线程A的修改,线程B却看不到,引出可见性问题

可见性

什么是可见性问题

案例一

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
51
/**
* 描述: 演示可见性带来的问题
*/
public class FieldVisibility {

int a = 1;
int b = 2;

private void change() {
a = 3;
b = a;
}


private void print() {
System.out.println("b=" + b + ";a=" + a);
}

public static void main(String[] args) {
FieldVisibility test = new FieldVisibility();
while (true) {
//FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();

new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}

}


}

分析

以上程序会出现如下四种情况

a=3,b=2

a=1,b=2

a=3,b=3

a=3,b=1 体现出可见性的重要性

案例二

分析

用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
/**
* 描述: 演示可见性带来的问题
*/
public class FieldVisibility {

volatile int a = 1;
volatile int b = 2;

private void change() {
a = 3;
b = a;
}


private void print() {
System.out.println("b=" + b + ";a=" + a);
}

public static void main(String[] args) {
while (true) {
FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();

new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}

}
}

flush

load

为什么会有可见性问题

image-20200213223516995

CPU有多级缓存,导致读的数据过期

  • 高速缓存的容量比主内存小,但是速度仅次于寄存器,所以在CPU和主存之间就多了Cache层
  • 线程间对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的
  • 如果所有的核心只用一个缓存,那么也就不存在内存可见性问题
  • 每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中,所以会导致有些核心读取的值是一个过期的值

JMM抽象:主内存和本地内存

什么是主内存和本地内存

Java作为高级语言,屏蔽了这些底层细节,用JMM定义了一套读写内存数据的规范,虽然我们不再关心一级缓存和二级缓存的问题,但是,JMM抽象了主内存和本地内存的概念

这里说的本地内存并不是真的是给每个线程分配的一块内存,而是JMM的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象

image-20200213231554758

主内存和本地内存的关系

JMM有以下规定

  1. 所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝
  2. 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中
  3. 主内存是多个线程共享的,但是线程间不共享工作内存,如果线程间需要通信,必须借助主存中转来完成

所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所欲才导致可见性问题

Happns-Before原则

什么是happens-before

happens-before规则是用来解决可见性问题的:在时间上,动作A发生在动作B之前,B保证能看见A,这就是happens-before

两个操作可以用happens-before来确定它们的执行顺序:如果一个操作happens-before于另一个操作,那么就说第一个操作于第二个操作是可见的

什么不是happens-before

两个线程没有相互配合的机制,所以代码X和代码Y的执行结果并不能保证总被对方看到,这就不具备happens-before

happens-before规则有哪些

  1. 单线程规则

    image-20200214003852577

    运用范围在一个线程之内,后面的语句一定能看到前面的语句执行了什么,happens-before不影响重排序,即使发生了重排序,代码中后面的语句也能看到前边的语句

  2. 锁操作(synchronized和Lock)

    image-20200214011140319

    如果一个线程对锁解锁,另一个线程对锁加锁,此时一个线程加锁之后一定能看到另一个线程的所有操作

  3. volatile变量

    image-20200214011711352

    只要写线程完成写的操作,则独读线程一定能看到

  4. 线程启动

    image-20200214011923097

    子线程所执行的所有语句都能看到主线程发生之前的所有结果

  5. 线程join

    image-20200214012147113

    join之后的语句一定能看到等待之前的语句

  6. 传递性

    如果hb(A,B)而且hb(B,C),那么可以推出hb(A,C)

  7. 中断

    一个线程被其他线程interrupt,那么检测中断(isInterrupted)或者抛出InterruptedException一定能看到

  8. 构造方法

    对象构造方法的最后一行指令happens-before于finalize()方法的第一行指令

  9. 工具类的happens-before规则

    1. 线程安全的容器get一定能看到在此之前的put等存入动作
    2. CountDownLatch
    3. Semaphore
    4. Future
    5. 线程池
    6. CyclicBarrier

优质代码案例

happens-before演示

volatile关键字

volatile是什么

volatile是一种同步机制,比synchronized或Lock相关类更轻便,因为volatile并不能发生上下文切换等很大开销的行为

如果一个变量被修饰成volatile,那么JVM就知道这个变量可能会被并发修改

但是开销小,相应的能力也小,虽说volatile是用来同步的保证线程安全的,但是volatile做不到synchronized那样的原子保护,volatile仅在很有限的场景下才能发挥作用

上下文切换(有时也称做进程切换或任务切换)是指CPU从一个进程或线程切换到另一个进程或线程

volatile的适用场合

不适用场景

a++

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
/**
* 描述:不适用于volatile的场景
*/
public class NoVolatile implements Runnable {


volatile int a;
AtomicInteger realA = new AtomicInteger();

public static void main(String[] args) throws InterruptedException {
Runnable r = new NoVolatile();
Thread thread1 = new Thread(r);
Thread thread2 = new Thread(r);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(((NoVolatile) r).a);
System.out.println(((NoVolatile) r).realA.get());
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
a++;
realA.incrementAndGet();
}
}
}

输出结果

1
2
19672
20000

适合场合1

纯赋值操作

boolean flag(标记位)如果一个共享变量自始至终只被各个线程赋值,而没有其他操作,那么就可以用volatile来代替,没有其他操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值自身就是有原子性,而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
/**
* 描述:volatile适用的情况1
*/
public class UseVolatile1 implements Runnable {

volatile boolean done = false;
AtomicInteger realA = new AtomicInteger();

public static void main(String[] args) throws InterruptedException {
Runnable r = new UseVolatile1();
Thread thread1 = new Thread(r);
Thread thread2 = new Thread(r);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(((UseVolatile1) r).done);
System.out.println(((UseVolatile1) r).realA.get());
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
setDone();
realA.incrementAndGet();
}
}

private void setDone() {
done = true;
}
}

输出结果

1
2
true
20000

适用场合2

作为刷新之前变量的触发器

首先volatile boolean initialized=false是一个触发器,在第一个线程中这个触发器最后被设置为true,在此之前有各种各样的准备工作,第二个线程只有在initialized设置为true才能继续运行,当线程B看到initialized为true,由于可见性的保证,在volatile boolean initialized=false前的三个操作,一定能被线程B 完全看到,此时volatile boolean initialized=false前的三个操作都可以被B正确的读取的最新的值

volatile的作用

可见性

读取一个volatile变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性会立即刷入到主内存

禁止重排序

禁止指令重排序优化:解决单例双重锁乱序问题

volatile和synchornized的关系

volatile在这方面可以看做是轻量版的synchronized

如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证可见性,所以就满足保证线程安全

用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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* 描述:演示重排序的现象 “直到达到某个条件才停止”,测试小概率事件
*/
//OutOfOrderExecution表示重排序,重排序不是经常发生的
public class OutOfOrderExecution {

//注意这里
private static volatile int x = 0, y = 0;
private static volatile 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;

CountDownLatch latch = new CountDownLatch(3);

Thread one = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.countDown();
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.countDown();
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
}
});
two.start();
one.start();
latch.countDown();
one.join();
two.join();

String result = "第" + i + "次(" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.out.println(result);
break;
} else {
System.out.println(result);
}
}
}


}

volatile小结

image-20200215121618595 image-20200215121759822

能保证可见性的措施

除了volatile可以让变量保证可见性以外,synchronized、Lock、并发集合、Thread.join()和Thread.start()等都可以保证可见性

具体看happens-before原则的规定

升华:对synchornized可见性的正确理解

文章目录
  1. 1. Java内存模型之底层原理
    1. 1.1. 彩蛋:自顶向下的好处
    2. 1.2. 什么叫底层原理
      1. 1.2.1. 重要性
      2. 1.2.2. 从Java代码到CPU指令
    3. 1.3. JVM内存模型与Java内存模型与Java对象模型
      1. 1.3.1. 整体方向
      2. 1.3.2. JVM内存结构
      3. 1.3.3. Java对象模型
    4. 1.4. JMM是什么
      1. 1.4.1. 为什么需要JMM
    5. 1.5. 重排序
      1. 1.5.1. 重排序的代码案例
      2. 1.5.2. 什么是重排序
      3. 1.5.3. 重排序的好处
        1. 1.5.3.1. 对比重排序前后的指令优化
      4. 1.5.4. 发生重排序的3种情况
    6. 1.6. 可见性
      1. 1.6.1. 什么是可见性问题
        1. 1.6.1.1. 案例一
        2. 1.6.1.2. 分析
        3. 1.6.1.3. 案例二
        4. 1.6.1.4. 分析
      2. 1.6.2. 为什么会有可见性问题
      3. 1.6.3. JMM抽象:主内存和本地内存
        1. 1.6.3.1. 什么是主内存和本地内存
        2. 1.6.3.2. 主内存和本地内存的关系
      4. 1.6.4. Happns-Before原则
        1. 1.6.4.1. 什么是happens-before
        2. 1.6.4.2. 什么不是happens-before
        3. 1.6.4.3. happens-before规则有哪些
        4. 1.6.4.4. 优质代码案例
      5. 1.6.5. volatile关键字
        1. 1.6.5.1. volatile是什么
        2. 1.6.5.2. volatile的适用场合
        3. 1.6.5.3. volatile的作用
        4. 1.6.5.4. volatile和synchornized的关系
        5. 1.6.5.5. 用volatile修正重排序问题
        6. 1.6.5.6. volatile小结
      6. 1.6.6. 能保证可见性的措施
      7. 1.6.7. 升华:对synchornized可见性的正确理解
|