多线程JUC 第2季 JMM的内存结构和作用

一 JMM的作用

1.1 背景描述

正常情况cpu的的运行,需要从缓存中读取数据,缓存从内存加载数据。然后将缓存数据写回内存,基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是就存在一个问题,内存和缓存数据存在不一致的问题。:缓存一致性(Cache Coherence)。

简述:cpu的运行不是直接操作内存,而是先把内存中的数据放到缓存中,但是存在内存和缓存的之间的读写存在不一致的情况。

为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSIMESI(Illinois Protocol)MOSISynapseFireflyDragon Protocol等。

1.2 JMM(JAVA MEMORY MODEL)作用

Java 虚拟机规范定义 Java 内存模型屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果

Java 内存模型规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。围绕着多线程的原子性,可见性,和有序性展开的。

jmm本身是一种抽象的并不真实存在,它仅仅描述的是一组约定或规范,通过这组规范定义了程序中各个变量的读写访问方式并决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见。

1.3 JMM的流程

Java 内存模型规定了所有的变量都存储在主内存中(JVM 内存的一部分)。

每条线程都有自己的工作内存,工作内存中保存了该线程使用的主内存中共享变量的副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,首先要将变量从主内存中拷贝到线程自己的工作内存中,然后对变量进行操作,操作之后在将变量写会主内存,而不能直接读写主内存中的变量;

工作内存在线程间是隔离的,不能直接访问对方工作内存中的变量。所以在多线程操作共享变量时,就通过 JMM 来进行控制。

JMM定义了线程和主内存之间的抽象关系

1.线程之间共享变量存储在主内存中(从硬件角度来说是内存条)

2.每个线程都有一个私有的本地工作内存,本地工作内存中存储了该线程用来读写的变量副本(从硬件角度来说是cpu缓存,比如寄存器,L1,L2,L3等缓存)

1.4 指令重排序

java规范规定,程序的顺序执行和调整顺序执行的结果一致,那么指令的执行顺序和代码顺序不一致,此过程叫指令的重排序。

指令重排序可以保证串行语义一致,但不能保证多线程间的语义的一致。也即两行以上不相干的代码在执行的时候有可能先执行不是第一条,不见得是从上到下顺序执行,执行顺序会被调整优化。

单线程环境中,指令的执行顺序和代码顺序是一致的。

1.5 JMM 是如何保证并发下数据的一致性呢?

1.5.1 背景描述

背景描述:

假设主内存中存在一个共享变量X,现在A和B线程2个线程分别对该变量赋值,接下来A对x进行赋值为2,而B线程想要读取x的值,那么这时候B线程读取到的值到底是A线程更新后的值还是原本主内的值呢?
答案是不确定的,即有可能读取到A更新的值,也有可能读取的更新之前的值。

原因
是因为每个线程的工作内存是私有的,如线程A改变x值时,首先是将变量从主内存COPY到A线程的工作内存中,然后对变量进行操作,操作完成后,再将变量x写回主内存,而B线程也是类似的情况,这样就有可能造成主内存和工作内存的数据存在一致性问题。

假设1:
A线程修改完后正要将数据写回主内存,而B线程此时正在读取主内存,即将x=1,COPY到自己B线程的工作空间,这样B线程就读取到的x值=1.

假设2:
但是如果A线程已经将结果写回了主内存,这时候B才开始读取的话,拿到的值就是x=2。

1.5.2 同步策略

面试官说了解Java内存模型吗?能展开详细说说吗?

内存交互操作有 8 种,虚拟机实现必须保证每一个操作都是原子的,不可再分的(对于 double 和 long 类型的变量来说,load、store、read 和 write 操作在某些平台上允许例外)。问题:lock与unlock需要我们用synchronized自己实现吗?还是jmm内涵就有这样的lock与unlock?姑且理解第一种需要自己实现。

  • lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态。

  • read(读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。

  • load(载入):作用于工作内存的变量,它把 read 操作从主存中得到变量放入工作内存的变量副本中。

  • use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

  • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store(存储):作用于工作内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的 write 使用。

  • write(写入):作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

  • unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

注意:

不允许 read 和 load、store 和 write 操作单独出现。即使用了 read 必须 load,使用了 store 必须 write。 不允许线程丢弃他最近的 assign 操作,即工作变量的数据改变了之后,必须告知主存。 不允许一个线程将没有 assign 的数据从工作内存同步回主内存。 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施 use、store 操作之前,必须经过 assign 和 load 操作。 一个变量同一时间只有一个线程能对其进行 lock。多次 lock 后,必须执行相同次数的 unlock 才能解锁。 如果对一个变量进行 lock 操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新 load 或 assign 操作初始化变量的值。 如果一个变量没有被 lock,就不能对其进行 unlock 操作。也不能 unlock 一个被其他线程锁住的变量。 对一个变量进行 unlock 操作之前,必须把此变量同步回主内存。

1.6  JMM的3大特性

1.6.1 可见性

1.可见性:当一个线程修改了内存中的共享变量,其它线程能够立刻看到最新值;JMM规定所有变量都存储在主内存中。

我们上面说了线程之间的变量是隔离的,线程拿到的是主存变量的副本,更改变量,需要刷新回主存,其他线程需要从主存重新获取才能拿到变更的值。所有变量都要经过这个过程,包括被 volatile 修饰的变量;但 volatile 修饰的变量,可以在修改后强制刷新到主存,并在使用时从主存获取刷新,普通变量则不行(读取时,没有从主内存拿到;有则读本地内存中数据;修改时,修改完刷回主内存。)。

除了 volatile 修饰的变量,synchronized 和 final。synchronized 在执行完毕后,进行 unlock 之前,必须将共享变量同步回主内存中(执行 store 和 write 操作)。前面规则其中一条。

而 final 修饰的字段,只要在构造函数中一旦初始化完成,并且没有对象逃逸(指对象为初始化完成就可以被别的线程使用),那么在其他线程中就可以看到 final 字段的值。

JMM 解决方案【JVM】JMM(三):JMM 如何保证并发时的一致性问题?_jvm 内存一致性-CSDN博客 

 java如何保证可见性
通过 volatile 关键字保证可见性。
通过 内存屏障保证可见性。
通过 synchronized 关键字保证可见性。
通过 Lock保证可见性。
通过 final 关键字保证可见性

1.6.2 原子性

2.原子性

JMM 提供了 read、load、use、assign、store、write 六个指令直接提供原子操作,我们可以认为 Java 的基本变量的读写操作是原子的(long、double除外,因为有些虚拟机可以将 64 位分为高 32 位,低 32 位分开运算)。对于 lock、unlock,虚拟机没有将操作直接开放给用户使用,但提供了更高层次的字节码指令,monitorenterm 和 monitorexit 来隐式使用这两个操作,对应于 Java 的 synchronized 关键字,因此 synchronized 块之间的操作也具有原子性。

JMM 解决方案:java里如何解决原子性问题?

1.我们可以大致认为基本类型变量的读写是具备原子性的。

2.如果应用需要一个更大范围的原子性,Java内存模型还提供了lock和unlock这两个操作来满足这种需求:
通过 synchronized 关键字保证原子性。
通过 Lock保证原子性。
通过 CAS保证原子性。

1.6.3 有序性

有序性在 volatile 已经详细说明了。可以总结为,在本线程观察到的结果,所有操作都是有序的;如果多线程环境下,一个线程观察到另一个线程的操作,就说杂乱无序的。

Java 提供了 volatile 和 synchronized 两个关键字保证线程之间的有序性,volatile 使用内存屏障,而 synchronized 基于 lock 之后,必须 unlock 后,其他线程才能重新 lock 的规则,让同步块在在多线程间串行执行。

JMM 解决方案java如何保证有序性?

1.通过 synchronized关键字保证有序性。
2.通过 Lock保证有序性。

3.Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性。

  • 指令重排必须保证,单线程内重排序后执行结果不变。(单线程内)
  • happens-before原则(多线程)

1.7  volatile变量和普通变量

1.普通变量: 读取时,本地工作内存没有则从主内存复制一份变量,如果本地内存有则操作本地内存;修改后,将本地变量同步到主内存中。

2.volatile变量:读取时,必须从主内存中获取最新变量值(不管本地工作内存是否存在);修改后,将本地变量同步到主内存中。

二  happen-before原则

2.1 happens-before的作用

1.happens-before 规定了对共享变量的写操作对其它线程的读操作可见,这两个操作之间必须存在happens-before关系,它是可见性与有序性的一套规则总结,即先行发生原则

a)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见(可见性),而且第一个操作的执行顺序排在第二个操作之前(有序性)
b)两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法(可以指令重排)
2. 说明
如果Java内存模型中的有序性仅靠volatile和synchronized来完成,那么有很多操作都将会变得非常繁琐,但是我们在编写Java并发代码的时候并没有察觉到这一点,原因是:在JMM中有happens-before原则限制。
这个原则非常重要:它是判断数据是否存在竞争,线程是否安全的有用手段。依赖这个原则,可以解决并发场景下两个操作之间是否存在冲突的所有问题,而不需要陷入JMM晦涩难懂的底层编译原理之中。

2.2 happen-before原则

在java语言中,happen-before本质是一种可见性,A happens-before B 意味着A发生过的事情对B来说是可见的。

我们只需要理解happen-before原则,其它繁杂的内容有jmm规范结合操作系统给我们搞定,我们只写代码即可。

2.2.1 happen-before的8种原则

先行发生是 Java 内存模型中定义的两个操作的顺序,如果说操作 A 先行发生于线程 B,就是说在发生操作 B 之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值,发送了消息,调用了方法等。

/线程A执行
i = 1
//线程B执行
j = i
//线程C执行
i = 2

我们还是定义 A 线程执行  i = 1 先行发生于 线程B执行的 j = i;那么我们可以确定,在线程 B 执行之后,j 的值是 1。因为根据先行发生原则,线程 A 执行之后,i 的值为 1,可以被 B 观察到;并且线程 A 执行之后,线程 B 执行之前,没有线程对 i 的值进行变更。

这时候我们考虑线程 C,如果我们还是保证线程 A 先行发生于 B,但线程 C 出现在 A 与 B 之间,那么,你可以确定 j 的值是多少吗?答案是否定的。因为线程 C 的结果也可能被 B 观察到,这时候可能是 1,也可能是 2。这就存在线程安全问题。

在 JMM 下具有一些天然的先行发生关系,这些原则在无须任何同步协助下就已经存在,可以直接使用。(JMM-Happen-before能够实现的8种约束规则)如果两个操作之间的关系不在此列,并且无法从以下先行发生原则推导出来,它们就没有顺序性保证,虚拟机就会进行随意的重排序。

也即happen-before默认支持8种重排序规则,不再此8种情况下,就没有顺序保证,可以随意的重排序。

程序次序规则(Program Order Rule):在一个线程内,程序的执行规则跟程序的书写规则是一致的,从上往下执行。

锁定规则(Monitor Lock Rule):一个 Unlock 的操作肯定先于下一次 Lock 的操作。这里必须是同一个锁。同理我们可以认为在 synchronized 同步同一个锁的时候,锁内先行执行的代码,对后续同步该锁的线程来说是完全可见的。

volatile 变量规则(volatile Variable Rule):对同一个 volatile 的变量,先行发生的写操作,肯定早于后续发生的读操作。

线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。

线程终止规则(Thread Termination Rule):Thread 对象的中止检测(如:Thread.join()、Thread.isAlive()等)操作,必晚于线程中所有操作。

线程中断规则(Thread Interruption Rule):对线程的 interruption() 调用,先于被调用的线程检测中断事件 (Thread.interrupted()) 的发生。 对象终止规则(Finalizer Rule):一个对象的初始化方法先于执行它的 finalize() 方法。

传递性(Transitivity):如果操作 A 先于操作 B、操作 B 先于操作 C,则操作 A 先于操作 C。

2.3 案例场景

1.以下代码存在线程不安全的情况

2.解决策略如下:

a) 把getter,setter方法都加上synchronized

b)变量用volatile修饰,保证可见性,setter方法加上synchronized,保证原子性 

如下图:

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值