java并发编程-java内存模型JMM

关注微信公众号:程序猿的日常分享,定期更新分享。

JMM是什么

JMM 是和多线程相关的一组规范,需要各个 JVM 的实现来遵守 JMM 规范,以便于开发者可以利用这些规范,更方便地开发多线程程序。这样一来,即便同一个程序在不同的虚拟机上运行,得到的程序结果也是一致的。

如果没有 JMM 内存模型来规范,那么很可能在经过了不同 JVM 的“翻译”之后,导致在不同的虚拟机上运行的结果不一样,那是很大的问题。

因此,JMM 与处理器、缓存、并发、编译器有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的结果不可预期的问题。

JMM 里最重要 3 点内容,分别是:重排序、原子性、内存可见性。

重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
我们来看一个例子:

重排序前的执行指令:
1、load a, set 5, store 5
2、load b, set 10, store 10
3、load a, set 5+10 ,store 15
重排序后的执行指令:
1、load a, set 5, set 5+10=15,store 15
2、load b, set 10, store 10
由于第二行和第三行并没有依赖关系,所以编译器会将第三行和第二行重排序,这样系统直接将a的所有操作放在一起,减少了一次load a和store a的操作,重排序通过减少执行指令,从而提高整体的运行速度,这就是重排序带来的优化和好处。

重排序对多线程的影响

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

    public void write() {
        a = 1;                    //1
        flag = true;            //2
    }

    public void read() {
        if (flag) {              //3
            int i =a;            //4
        }
    }
}

假如有两个线程,线程A执行write(),线程B执行read(),线程B在执行操作注释4时不一定看到注释1的操作结果,由于注释1和注释2之间没有数据依赖关系,编译器和处理器可以对这两个操作重排序,也就是说线程A先执行了注释2,此时线程B执行注释3,进入了if内逻辑,此时变量a还没有被线程A执行a=1操作,导致语义被重排序破坏。

顺序一致性

当程序未正确执行同步时,当代码中存在数据竞争时,程序的执行结果往往可能会不符合预期。如果一个多线程能正确同步,那么就不会存在数据竞争。JMM对正确同步的程序做了顺序一致性的保证,程序的执行结果与在一致性内存模型的执行结果相同,这些同步操作包含正确的使用syncrhronized、volatile、final等原语。
我们对前边的例子进行同步,代码如下:

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

    public syncrhronized void write() {    
        a = 1;                                
        flag = true;                        
    }

    public syncrhronized void read() {
        if (flag) {              
            int i =a;                
        }
    }
}

通过增加同步锁,程序的执行结果和顺序一致性模型结果一致,被锁定的范围属于临界区,而在JMM中临界区的代码可以重排序,但JMM不允许临界区的代码逸出到临界区之外。也就是线程A在执行write()方法时,及时重排序,但线程B并看不到临界区内的重排序,这样重排序既提高了执行效率,又不会影响结果。

happens-before规则

1、程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2、监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
3、volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
4、传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
5、start()规则:如果线程A执行操作ThreadB.start(),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
6、join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

volatile的内存语义

volatile是什么
volatile,它是 Java 中的一个关键字,是一种同步机制。当某个变量是共享变量,且这个变量是被 volatile 修饰的,那么在修改了这个变量的值之后,再读取该变量的值时,可以保证获取到的是修改后的最新的值,而不是过期的值。

相比于 synchronized 或者 Lock,volatile 是更轻量的,因为使用 volatile 不会发生上下文切换等开销很大的情况,不会让线程阻塞。但正是由于它的开销相对比较小,所以它的效果,也就是能力,相对也小一些。

下面是 Brian Goetz 提供的一个经典例子:

Map configOptions;
char[] configText;
volatile boolean initialized = false;
 
. . .
 
// In thread A
 
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
 
. . .
 
// In thread B
 
while (!initialized) 
  sleep();
// use configOptions

在这段代码中可以看到,我们有一个 map 叫作 configOptions,还有一个 char 数组叫作 configText,然后会有一个被 volatile 修饰的 boolean initialized,最开始等于 false。再下面的这四行代码是由线程 A 所执行的,它所做的事情就是初始化 configOptions,再初始化 configText,再把这两个值放到一个方法中去执行,实际上这些都代表了初始化的行为。那么一旦这些方法执行完毕之后,就代表初始化工作完成了,线程 A 就会把 initialized 这个变量设置为 true。

而对于线程 B 而言,它一开始会在 while 循环中反复执行 sleep 方法(例如休眠一段时间),直到 initialized 这个变量变成 true,线程 B 才会跳过 sleep 方法,继续往下执行。重点来了,一旦 initialized 变成了 true,此时对于线程 B 而言,它就会立刻使用这个 configOptions,所以这就要求此时的 configOptions 是初始化完毕的,且初始化的操作的结果必须对线程 B 可见,否则线程 B 在执行的时候就可能报错。

你可能会担心,因为这个 configOptions 是在线程 A 中修改的,那么在线程 B 中读取的时候,会不会发生可见性问题,会不会读取的不是初始化完毕后的值?如果我们不使用 volatile,那么确实是存在这个问题的。

但是现在我们用了被 volatile 修饰的 initialized 作为触发器,所以这个问题被解决了。根据happens-before 关系的单线程规则,线程 A 中 configOptions 的初始化 happens-before 对 initialized 变量的写入,而线程 B 中对 initialzed 的读取 happens-before 对 configOptions 变量的使用,同时根据 happens-before 关系的 volatile 规则,线程 A 中对 initialized 的写入为 true 的操作 happens-before 线程 B 中随后对 initialized 变量的读取。

如果我们分别有操作 A 和操作 B,我们用 hb(A, B) 来表示 A happens-before B。而 Happens-before 是有可传递性质的,如果hb(A, B),且hb(B, C),那么可以推出hb(A, C)。所以根据上面的条件,我们可以得出结论:线程 A 中对于 configOptions 的初始化 happens-before 线程 B 中 对于 configOptions 的使用。所以对于线程 B 而言,既然它已经看到了 initialized 最新的值,那么它同样就能看到包括 configOptions 在内的这些变量初始化后的状态,所以此时线程 B 使用 configOptions 是线程安全的。这种用法就是把被 volatile 修饰的变量作为触发器来使用,保证其他变量的可见性。

volatile 的作用

第一层的作用是保证可见性。Happens-before 关系中对于 volatile 是这样描述的:对一个 volatile 变量的写操作 happen-before 后面对该变量的读操作。

这就代表了如果变量被 volatile 修饰,那么每次修改之后,接下来在读取这个变量的时候一定能读取到该变量最新的值。

第二层的作用就是禁止重排序。先介绍一下 as-if-serial语义:不管怎么重排序,(单线程)程序的执行结果不会改变。在满足 as-if-serial 语义的前提下,由于编译器或 CPU 的优化,代码的实际执行顺序可能与我们编写的顺序是不同的,这在单线程的情况下是没问题的,但是一旦引入多线程,这种乱序就可能会导致严重的线程安全问题。用了 volatile 关键字就可以在一定程度上禁止这种重排序。

volatile的写和读内存语义总结:
1、线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
2、线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
3、线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程上是线程A通过主内存向线程B发送消息。

锁的内存语义

锁是java并发中最重要的同步机制,锁除了可以让临界区互斥执行外,还可以让释放锁的线程向同样获取一个锁的另一个线程发送消息。
锁的释放-获取与volatile的写-读具有相同的内存语义。同volatile内存语义

锁和volatile区别
相似性:volatile 可以看作是一个轻量版的 synchronized,比如一个共享变量如果自始至终只被各个线程赋值和读取,而没有其他操作的话,那么就可以用 volatile 来代替 synchronized 或者代替原子变量,足以保证线程安全。实际上,对 volatile 字段的每次读取或写入都类似于“半同步”——读取 volatile 与获取 synchronized 锁有相同的内存语义,而写入 volatile 与释放 synchronized 锁具有相同的语义。

不可代替:但是在更多的情况下,volatile 是不能代替 synchronized 的,volatile 并没有提供原子性和互斥性。

性能方面:volatile 属性的读写操作都是无锁的,正是因为无锁,所以不需要花费时间在获取锁和释放锁上,所以说它是高性能的,比 synchronized 性能更好。

final域的内存语义

与锁和volatile相比,final域的读和写更像是普通的变量访问。
对于final域,编译器和处理器要遵守两个重排序规则
1、在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
2、初次读一个包含final与的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
通过final域的写和读重排序规则,可以提供初始化的安全保证,只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步操作就可以保证任意线程都能看到这个final域在构造函数中被初始化的值。

关注微信公众号:程序猿的日常分享,定期更新分享。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值