当前位置: 代码迷 >> 综合 >> Java Concurrent--Java内存模型
  详细解决方案

Java Concurrent--Java内存模型

热度:20   发布时间:2023-09-21 21:12:22.0

缓存一致性

“让计算机并发处理多个任务”和“更充分利用计算机处理器的效能”之间看起来是因果关系,但实现起来非常麻烦。因为绝大多数运算任务都需要与内存交互,并非纯粹的计算。由于处理器和内存的处理速度不匹配(处理器运算速度远大于从内存中读取数据的速度),所以现代计算机系统通常加入一层高速缓存(Cache)来作为内存和处理器之间的缓冲:将运算需要的数据复制到Cache中,让运算能快速进行;运算结束后再从缓存同步到内存中。这样处理器可以不用等待缓慢的内存读写。

这种方法解决了处理器和内存之间的速度问题,但引入了一个更复杂的问题:缓存一致性。在多处理器系统中每个处理器都有自己的高速缓存(Cache),而他们又共享同一个主内存,很容易想到,不同处理器对主内存的数据进行缓存和回写会带来数据的不一致问题。这种情况下要引入一些协议来解决这个问题。

乱序执行

为了使处理器内部运算单元能尽量被充分运用,处理器会对代码进行乱序执行优化,然后在计算后将结果重组,保证该结果与顺序执行的结果一致,但不保证各语句的先后执行顺序与输入时的顺序一致。Java虚拟机的即时编译器中也有类似的指令重排序优化。

Java内存模型

内存模型可以理解为:在特定操作协议下,对特定的内存或缓存进行读写访问的过程抽象。

Java内存模型的主要目标是定义程序中的各个变量的访问规则,即在虚拟机中将变量存储在内存和从内存中读取变量这样的底层细节。注意:这里的变量和Java编程中的变量意义不同,它包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的,不会被共享,也就不存在竞争问题。

Java内存模型规定了所有变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存保存了该线程所需要用到的变量的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存的变量。线程间的变量访问和传递不可直接进行,必须要通过主内存来完成。

可以将下面这张图和缓存一致性中的图对比理解:

内存间的交互操作:

关于主内存和工作内存之间的交互协议,即读取和回写的实现细节,Java内存模型定义了8种操作来完成,这些操作含有原子性:

  1. lock:主内存操作,锁定变量,标识其为线程独占的状态。
  2. unlock:主内存操作,解锁变量,将其从线程独占的状态中释放出来。
  3. read:主内存操作,读取变量到工作内存。
  4. load:工作内存操作,将读取到的变量赋值给工作内存中的变量副本。
  5. use:工作内存操作,将变量值传递给执行引擎以供操作。
  6. assign:工作内存操作,将执行引擎操作后的值赋给工作内存中的变量。
  7. store:工作内存操作,将工作内存中的变量传递给主内存。
  8. write:主内存变量,将store得到的值写入主内存中的变量。

如果把一个变量从主内存复制到工作内存,必须顺序执行read和load操作;反之将一个变量从工作内存回写到主内存,store和write也要顺序执行。但虽然要顺序执行不代表到连续执行,也就是说两条语句之间可以插入其他执令。

Java内存模型还规定了执行上述8钟基本操作必须满足的规则:

  1. read和load,store和write必须成对出现,即工作内存或主内存必须将已经从另一方读取到的值写入自己所持有的变量,不允许拒绝。
  2. 工作内存最后一次assign必须同步回主内存,而工作内存同步回主内存的变量也必须执行过assign操作。
  3. 工作内存不可以使用未初始化的变量,即对一个变量实施use,store操作之前必须要有load和assign操作。
  4. 一个变量只能由一个线程lock,也只能由这个线程unlock,线程可以多次lock,对应的解锁需要同样次数的unlock。
  5. 不允许unlock未被lock过的变量。
  6. unlock操作必须在store和write之后。

对volatile型变量的特殊规则:

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。当一个变量被定义为volatile之后,他将具备两种特性:

  • 第一,保证此变量对所有线程的可见性;
  • 第二,禁止指令重排序优化;

下面分别讨论这两个特性:

保证变量对所有线程的可见性:

这里的“可见性”是指当一条线程修改了这个变量的值,新值对其他变量来说是可以立即得知的,而普通变量做不到这一点。普通变量的值均需要通过主内存来完成,例如线程A修改了一个变量的值,然后向主内存回写,线程B在线程A回写完之后再从主内存中读取值,新变量的值才对线程B可见。

但要注意,volatile变量在各个线程的工作内存中不存在一致性问题,但Java里面的运算并非原子操作,导致volatile变量的运算在并发情况下一样是不安全的。

例如:定义一个volatile类型的变量count,有一个方法是简单地count++。如果发起20个线程每个线程调用1w次自增方法,最后count结果是小于20w。 因为虽然每个线程取count的时候能够取到当前的正确的值,但由于++操作不是原子性的,在这个线程进行加操作的时候,其他线程可能已经把count的值加大了,而这个线程操作栈顶的值已经成了过期数据,然后回写地时候就可能把较小的值回写到主内存中。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景下,仍然需要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性:

  1. 运算结果并不依赖变量的当前值,或者能保证只有单一线程修改变量的值;
  2. 变量不需要与其他状态变量共同参与不变约束。

禁止指令重排优化:

分析下面的代码:

volatile boolean initizlized = false;//下面代码在线程A中运行
configOption = new HashMap();
...
initizlized = true;//下面代码在线程B中运行
while(!initizlized){sleep();
}

如果定义initialized的时候没有使用volatile修饰,就有可能由于指令重排序优化,导致位于A线程的最后语句代码“initialized=true”被提前执行(所指的指令重排序是机器级的优化操作,提前执行是指这条语句对应的汇编语言提前执行),这样会让B线程提前执行相关操作,很容易出问题,而使用volatile则可以避免这种情况的发生。

通过这8个基本操作和上述规定,再加上volatile特殊规则,可以确定Java程序中哪些内存访问操作在并发的情况下是安全的。但根据上述严谨的定义去判断,实践起来比较麻烦,所以通常可以根据一个和定义等效的判断原则----先行发生原则,来判断一个访问在并发情况下是否安全。

  相关解决方案