Java 内存模型分析

说明:本篇文章是在阅读《Java 并发编程艺术》过程中的一些笔记和分析,由于本人能力有限,如果有书写错误的地方,欢迎各位大佬批评指正!我们互相交流,学习,共同进步!

该项目的地址:https://github.com/xiaoheng1/concurrent-programming

欢迎有兴趣的小伙伴加入,一起讨论、分析,共同进步!

1.首先要说下 Java 内存模型的抽象,JMM 规定了每个线程都有自己的本地内存,本地内存中存放的是主内存中
共享变量的拷贝. 现在线程 A 需要和线程 B 通讯,则需要通过 A 的本地内存,在 JMM 的控制下,到主内存,
然后从主内存到线程 B 的本地内存,这样就完成了一次通讯.

2.现代编译器为了提高提高性能,会对指令进行重排序. 重排序分为 3 类重排序.
(1)编译器优化的重排序
(2)指令级并行的重排序
(3)内存系统的重排序

因为重排序的问题,我们在编程的时候,是不是会碰到各种莫名其妙的问题?例如 i++,初始时 i=0,但是最终
两个线程的执行结果是不是都是 1?

为了解决可见性问题,Java 有如下策略:

(1)内存屏障
(2)happens-before
1.一个线程中的每个操作,happens-before 于该线程中的任意后续操作
2.对于一个锁的解锁,happens-before 与随后对这个锁的加锁
3.对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读.
4.A happens-before B, B happens-before C, 那么 A happens-before C(传递性).

注意:happens-before 并不意味着一个操作必须先于另一个操作执行,而是说那个操作的结果对后一个操作可见.

对于有数据依赖关系的操作,单线程中是不会进行重排序的,但是在多线程中,数据依赖不被编译器考虑.

有数据依赖关系的:

读后写
写后读
写后写

3.as-if-serial 语义
as-if-serial 语义是说不管怎么重排序,单线程程序的执行结果不能被改变.

4.顺序一致性模型
顺序一致性模式是一个理论的参考模型,它为程序员提供了极强的内存可见性保证. 顺序一致性模型有两大特点:
(1) 一个线程中的所有操作都是按照顺序的先后顺序执行的.
(2) 不管程序是否同步,所有线程都只能看到一个单一的操作顺序. 顺序一致性模型中,所有的操作都必须原子执行且立刻对所有线程可见.

那顺序一致性模型是怎么实现的了?

理论上,顺序一致性模式有一个全局的内存,这个内存有一个开关,可以连接到任意的线程上(就像我们在物理中学到的单刀开关一样,将开关拨到
左边,左边的灯亮,将开关拨到右边,右边的灯亮). 所以在任意的时间点上,只会有一个线程可以连接到内存,进行读写操作. 当多个线程并发读写
时,按照这么一套逻辑,将会被串行化.

根据上面的定义:

来举一个例子:

假设有线程A和线程B两个线程,线程A中有三个操作,A1,A2,A3
线程B中也有三个操作,B1,B2,B3.

现在假设使用同步的话,先执行A线程,后执行B线程. 那么看到的执行顺序是:
A1->A2->A3->B1->B2->B3

如果不使用同步的话,执行顺序可能是:
A1->B1->A2->B2->A3->B3

虽然整体上无序,但是对于线程A或线程B而言,还是有序的.

注:这里说的单一操作顺序,不是说还有一种执行情况:
B1->A1->A2->B2->A3->B3
而是说的每个操作对其他线程可见. 换句话说,一个线程对一个变量进行了修改,那么另一个线程可以看到这个修改后的值,这和JMM不同. JMM 中,
如果修改了某个值,不一定对其他线程可见(还没有刷新会主存),所以其他线程是看不到这个线程做的修改(也就是不可见),换句话说,两个线程看到
的操作顺序不是单一的
.

所以在 JMM 中,如果不小心的话,就会出现内存可见性问题,执行结果会和预期不一致.
因为在 JMM 中,未同步的程序不但整体无序,而且执行顺序也是无序的,而且所欲线程看到的在线顺序也可能不一致(正如上面所说).

测试用例:

public class SyncTest {
int a=0;
boolean flag = false;

public synchronized void writer(){
    a = 1;
    flag = true;
}

public synchronized void reader(){

    if(flag){
        int i = a;
        System.out.println("i = " + i);
    }
}

@Test
public void test(){
    final SyncTest syncTest = new SyncTest();

    for(int i=0; i<100; i++){
        new Thread(new Runnable() {
            public void run() {
                syncTest.writer();
            }
        }).start();

        new Thread(new Runnable() {
            public void run() {
                syncTest.reader();
            }
        }).start();
    }
}

}

发现最终结果是 100 个 1.
从结果中可以得出如下结论:
正确同步的程序,执行结果和程序在顺序一致性模型中的执行结果相同.

是不是有一个疑问:在线程1 中对变量 a 的修改的结果反映到线程2 中了. 这是为什么了?
变量 a 只是一个普通的 int 类型,又不是 volatile 修饰的(强制刷新到主存).
这个就要说到 JMM 内存模型了,线程中的工作内存拿到的是主存中变量的拷贝. 所以线程 1 和线程 2拿到的都是拷贝(实际对象在堆内存中).
所以当在线程1中进行修改的时候,直接返回到堆上了,所以线程2能够可见.

注:是不是有小伙伴会有上面的疑问?其实不是这样的,为了验证上面的疑惑,我设计了如下程序进行验证:

public class SyncClass implements Runnable{

static Map<Integer, String> map = new HashMap<Integer, String>();

int count = 10000;

public void run() {
    while(true){
        if(count > 0){
            String str = Thread.currentThread().getName() + " -> " + count;
            System.out.println(str);
            if(!map.containsKey(count)){
                map.put(count, str);
            }else{
                System.out.println("ERROR: " + count);
            }
            count = count-1;
        }
    }

}

}

public class Main {

public static void main(String[] args) {
    SyncClass syncClass = new SyncClass();
    new Thread(syncClass, "A").start();
    new Thread(syncClass, "B").start();
}

}

如果安装上面的理论,那么应该正常输入 1 ~ 10000,且不会重复,但是实际结果是(只选取了部分):

A -> 10000
B -> 10000

A -> 9999
B -> 9998
A -> 9997
B -> 9996
A -> 9995
B -> 9994
A -> 9993
B -> 9992
A -> 9991
B -> 9990
A -> 9989
B -> 9988
A -> 9987
B -> 9986
A -> 9985
B -> 9984
A -> 9983
B -> 9982
A -> 9981
A -> 9979
B -> 9980
B -> 9977
B -> 9976
B -> 9975

这就说明上面的理解是错的.

关于上面的疑问,为啥不能实现通讯了?

深入理解 Java 虚拟机中有一句话:假设线程中访问一个 10M 的对象,也会把这 10M 的内存复制一份拷贝出来吗?事实上并不是如此,这个对象
的引用、对象的中某个在线程访问到的字段是有可能存在拷贝的,但是不会有虚拟机实现成把整个对象拷贝一次
.

对这段话怎么理解了?我是这么认为的:比如说有如下代码:

class B {
String name;
}

class A {
B b;
}

现在在线程1 中修改 b 中的 name 属性,那么我理解的是会加载 A 对象的引用,同时也会加载 B 对象的引用以及 B 对象中的属性值 name.
当线程1 完成修改后,由于 JMM 没有将修改刷新到主存中,所以该操作对其他线程不可见.
如果 JMM 将修改刷新到主存,则其他线程可见.

对于未同步或未正确同步的多线程程序,JMM 只提供最小的安全性. 即线程读取到的值,不会凭空出现(要么是默认值,要么是其他线程写入的值).

还有一点要说明的是,JMM 不保证对 64 位的 long 和 double 类型变量的写入操作具有原子性(针对 32 位机器).

原因:与处理器总线的工作机制有关. 总线是沟通内存和处理器的桥梁. 总线事务分为读事务和写事务. 其实多 CPU 通过总线连接内存和我们上面
讲的单刀双掷开关很像. 当多个 CPU 同时发起总线事务时,总线会通过仲裁,判定那个 CPU 获得访问内存的权利. 而其他处理器则需要等待.
换句话说,在任意时刻,只有一个 CPU 能够访问内存.

在 32 位机器上,如果要保证对 64 位数据类型的写操作具有原子性,开销较大. 所以 Java 语言规范不强求 JVM 对 64 位数据类型的写
操作具有原子性.

当处理器在处理 64 位的写操作,可能会拆分为两个 32 为的写操作. 当一个处理器将高位写入的时候,可能另一个处理器读取到了一个不合法
的数(无效值).

5.volatile 的内存语义

可以这么理解:对 volatile 变量的单个读/写操作看成是使用同一个锁对这些单个读/写操作做了同步.

例如:

class A{
volatile long v1 = 0;
public void set(long l){
v1 = l;
}
public long get(){
return v1;
}
}

等价于:

class B{
long v1 = 0;
public synchronized void set(long l){
v1 = l;
}
public synchronized long get(){
return v1;
}
}

但是有一点需要注意的是:
volatile long v1;
v1++ 操作不具备原子性, 因为 v1++ 是一个复合操作.

volatile 变量的特点:
1.对单个 volatile 变量的读/写操作具有原子性
2.内存可见性,即对 volatile 变量的读,总能看到任意线程对改变的最后写入(修改后会立刻刷新到主存中).
3.禁止指令重排.

注意:线程 A写一个 volatile 变量后,线程B读取同一个 volatile 变量. A 线程在写 volatile 变量之前所有可见的共享变量,在线程B
读取同一个 volatile 变量后,将立即变的对线程B可见.

怎么理解了?

举个例子吧:

class A{
int a = 0;
volatile flag b = false;
public void set(){
a = 1;
flag = true;
}
}

当线程A执行完这段代码后,JMM 会将 flag = true 及 a = 1 刷新到主存中去, 换句话说在线程 B 读取一个 volatile 变量后,
写线程A在写这个 volatile 变量之前所有课件的共享变量的值都将变得对读线程B可见
.

6.volatile 的内存语义实现

1.当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序.
2.当第一个操作是 volatile 读时,不管第二个操作是什么,都不能进行重排序.
3.当第一个操作是 volatile 写时,第二个操作是 volatile 读,不能进行重排序.

为了实现这个语义,编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序.

1.在每个 volatile 写操作的前面插入 StoreStore 屏障,在其后插入一个 StoreLoad 屏障
2.在每个 volatile 读操作的后面插入 LoadLoad 屏障,再其后插入一个 LoadStore 屏障.

1.StoreStore 屏障将保证所有的普通写在 volatile 写之前刷新到主内存.
2.StoreLoad 屏障避免 volatile 写与后面可能有的 volatile 读/写操作重排序.
3.LoadLoad 屏障禁止处理器把上面的 volatile 读和下面的普通读重排序.
4.LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序.

说明:编译器可能根据情况省略掉一些屏障(前提是保证结果是对的).

7.锁的内存语义

注意:当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新主内存中, 当线程获取锁时,JMM 会把该线程对应的本地内存置为
无效,从而使得被监视器保护区的临界代码必须从主存中读取共享变量
.

8.锁的内存语义实现

Java 中的锁,主要是通过 volatile + cas 来实现的.

加锁:首先读取 volatile state, cas 更新
释放锁:set volatile state.
cas 同时具有 volatile 读和写的内存语义.

也就是说编译器不能对 cas 和 cas 前后的任意内存操作重排序.

具体是如何做的了?通过添加 lock 前缀来实现的.

lock 前缀的指令会禁止与之前和之后的读、写指令重排序,同时会将写缓冲区的数据刷新到内存中去.

注:volatile + cas 是 Java concurrent 包的基石.

9.final 域的内存语义

对于 final 域,编译器和处理器要遵循两个重排序规则:

1.在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序.
2.初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作不能重排序.

针对第一点说的是,JVM 禁止把 final 域的写重排序到构造函数之外. 编译器会在 final 域的写之后,构造函数 return 之前,插入一个
StoreStore 屏障. 这个屏障禁止处理器把 final 域的写重排序到构造函数之外.

举个例子说明下:

class A {
int a;
final int b;

static A obj;

public A() {
    a = 1;
    b = 2;
}
public static void set(){
    a = new A();
}
public static void get(){
    A ref = obj;
    int a = ref.a;
    int b = ref.b
}

}

线程1调用 set 方法,线程2 调用 get 方法.
站在线程2的角度,可能看到如下情况:
即:写普通域的操作被编译器重排序到了构造函数之外,那么在线程2看到对象 obj 时,可能 obj 对象还没有构造完成,
那么此时初始值 1 还没有写入 a
.

针对第二点进行说明:编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障.

借用上面的例子,来分析下 get 操作. 一种可能的重排序是:

  1. int a = ref.a;
  2. A ref = obj;
  3. int b = ref.b;

显然 1 是一个非法的读取操作.

final 域为引用类型

对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:
1.在构造器内对一个 final 引用的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋值给一个引用变量,这两个操作间
不能重排序.

如何理解了?

针对上面的语义规则,总结如下:
1.如果构造器中,有对 final 修饰的变量进行写操作,则该操作一定先于将该对象的引用赋值给另一个引用变量.
2.在读取一个对象的 final 域时,一定先读取到包含这个 final 域对象的引用.
还是借用上面的例子,假设线程1调用 set 方法,线程2调用 get 方法. 线程2要么读到空的引用,要么一定是待 final 域初始化完后,obj
才被构造出来.

为了验证猜想,编写如下代码进行测试.

public class FinalTest {

class A{
    final int a;
    int b;

    private A obj;

    public A() {
        this.a = 1;
        this.b = 2;
    }

    public void set(){
        obj = new A();
    }

    public void get(){
        A ref = obj;
        System.out.println("A->" + ref.a);
        System.out.println("B->" + ref.b);
    }

}

@Test
public void test(){
    for(int i=0; i<10000; i++){
        final A a = new A();
        Thread threadA = new Thread(new Runnable() {
            public void run() {
                a.set();
            }
        });

        Thread threadB = new Thread(new Runnable() {
            public void run() {
                a.get();
            }
        });

        threadA.start();
        threadB.start();
    }
}

}

结果如预期,出现了空指针异常.

为什么 final 引用不能从构造函数内 “溢出” 了?

根据前面我们知道,写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量执行的对象的 final 域已经在构造函数中
被正确初始化了. 要的到这个保证,就需要在构造函数内部,不能让这个被构造的对象的引用为其他线程所见,也就是对象引用不能再构造函数中
“溢出”
.

下面就是一个溢出的例子:

class A {
final int a;
static A obj;
public A(){
i = 1;
obj = this;
}

public void set(){
    new A();
}

public void get(){
    if(null != obj){
        int temp = obj.a;
    }
}

}

obj = this 这步会是的对象还未完成构造前就为其他线程可见,导致可能在其他线程中无法看到 a 被正确初始化后的值.

final 语义在处理器中的实现

由于处理器的实现不同,所以不同的处理器会依据自身实现,省略掉部分内存屏障. 例如:
x86 处理器中,final 域的读写不会插入任何内存屏障.

总结:对于 final 域,只要对象是正确构造的(没有溢出),那么不需要同步,就可以保证任意线程都能看到这个 final 域在构造函数中被初始化
后的值
.

  1. happens-before

其实只有一条:只要不改变程序的执行结果,可以随意优化.

happens-before 规则用于描述两个操作之间的执行顺序. 即:A happens-before B, 则 A 操作的结果对 B 可见,而且第一个操作的执行
顺序排在第二个操作之前. 但是两个操作间存在 happens-before 关系,并不意味着 java 平台的具体实现必须按照 happens-before 来实现.

还是上面的那句话,不管怎么优化,不能改变程序的执行结果.

as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不被改变.

happens-before 规则总结:

1.一个线程中的每个操作,happens-before 于该线程中的任意后续操作.
2.对于一个锁的解锁,happens-before 于随后对该锁的加锁
3.对一个 volatile 变量的写,happens-before 于任意后续对这个 volatile 变量的读.
4.A happens-before B, B happens-before C -> A happens-before C.
5.如果线程A执行 ThreadB.start(), 那么线程 A 的 ThreadB.start() happens-before 线程B中的任意操作.(共享变量对B线程可见)
6.线程 A 执行 ThreadB.join(), 那么线程B中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功的返回.(共享变量对A线程可见)

其实 happens-before 规则,说到的都是共享变量可见性问题. 不管是锁,还是 volatile 变量,亦或是 Thread.start(),
Thread.join() 等,都谈到的是共享变量可见性问题.

  1. double check

例如:我们常常会这么写:

class A {
private static Instance instance;

public static Instance getInstance() {
    if(null == instance){
        instance = new Instance();
    }
    return instance;
}

}

其实这样写是线程不安全的,当在多线程程序中时,可能两个线程都看到 instance = null, 其中一个线程执行了 instance = new Instance();
后一个线程同样执行了 instance = new Instance();

可以这么修改,是的其线程安全()加锁.

class A {
private static Instance instance;

public synchronized static Instance getInstance() {
    if(null == instance){
        instance = new Instance();
    }
    return instance;
}

}

但是加锁会带来性能上的开销,如果被多线程频繁调用的话,这个方法将不能提供令人满意的性能.

所以,后来有了 double-check 这种做法.

class A {
private static Instance instance;

public static Instance getInstance() {
    if(null == instance){
        synchronized(A.class){
            if(null == instance){
                instance = new Instance();
            }
        }
    }
    return instance;
}

}

似乎这样就非常完美了,但是上面的代码也存在问题. 因为返回的 instance 引用的对象有可能未初始化完成.

执行 instance = new Instance(); 时可以分为三个步骤
1.分配对象的内存空间(memory = allocate())
2.初始化对象(ctorInstance(memory))
3.收割者 instance 执行刚分配的内存地址(instance = memory)

但是 2 和 3 可能发生重排序,所以其他线程可能看到一个未被初始化的对象.

问题:不是说 synchronized 会在释放锁的时候,将值刷新到主存中去吗?那么其他线程是如何发现 instance 不为空的?

测试如下:

class SyncTest(){
static Person person;

Object object = new Object();

public static void set(){
    if(null == person){
        synchronized(object){
            if(null == person){
                person = new Person();
            }
        }
    }
}

}

class A {
@Test
public void test(){
final SyncTest = new SyncTest();

    new Thread(new Runnable(){
        public void run(){
            syncTest.set();
        }
    }, "A").start();
    new Thread(new Runnable(){
        public void run(){
            syncTest.set();
        }
    }, "B").start();
    new Thread(new Runnable(){
        public void run(){
            syncTest.set();
        }
    }, "C").start();
}

}

进过测试发现:当一个线程进入到 synchronized 内实例化 person 时,由于 cpu 时间片用完了,切换到其他 cpu 执行,发现 person 对其他
线程可见了(进入到 synchronized 内的线程还没有执行完).

所以可以得出一个结论:synchronized 并不是说在释放锁的时候才会将修改的数据刷新到主存.

针对上面的问题,有两种解决办法:

  1. 使用 volatile

class A {
private volatile static Instance instance;

public static Instance getInstance() {
    if(null == instance){
        synchronized(A.class){
            if(null == instance){
                instance = new Instance();
            }
        }
    }
    return instance;
}

}

本质是通过禁止 2-3 步重排序来实现线程安全的.

2.基于类初始化

JVM 在类的初始化阶段(即在 Class 被加载后,且被线程使用前),会执行类的初始化. 在执行类的初始化期间,JVM 会去获取一个锁. 这个锁
可以同步多个线程对同一个类的初始化.

class A {
private static Instance instance = new Instance();

public static Instance getInstance() {
    return A.instance; // 这里将导致 A 类被初始化.
}

}

关于这二者的区别:

1.如果确实需要对实例字段使用线程安全的延迟初始化,请使用 volatile 方案.
2.如果需要对静态字段使用线程安全的延迟初始化,请使用基于类初始化方案.

12.java 内存模型概述

(1) JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性模型是一个理论参考模型.

(2) Java 内存可见性保证分为 3 类:
1.单线程程序不会出现内存可见性问题
2.正确同步的多线程程序的执行将具有顺序一致性
3.未同步或未正确通同步的多线程程序,JMM 为他们提供最小的安全保证(要么读到的是默认值,要么是前某个程序写入的值,不会凭空产生)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值