JVM学习笔记(三):Java内存模型

引子

在上一篇文章《JVM学习笔记(二):JVM GC机制与垃圾收集器》中,我总结了一下JVM的GC机制,并且结合着自己写的实例,分析了一下 标记-清除算法 中的标记过程;同时,我还总结了一下 垃圾收集器 相关的知识点。接下来,在这篇文章中,我就总结一下 Java的内存模型。

缓存与缓存一致性

计算机在处理任务的时候,与内存交互的I/O操作是不可避免的。而计算机的存储效率与处理器的运算速度有着几个数量级的差距,所以,现代计算机系统都会加上一层 高速缓存 来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行;当运算结束后,再将数据从缓存同步回内存之中。这样,处理器就无需等待缓慢的内存读写了。

高速缓存解决了处理器与内存读写效率之间的矛盾,但是又引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一个主内存(Main Memory)。当多个处理器的运算任务都涉及到同一块主内存区域时,可能各自的缓存并不一致,那么,同步回主内存时该以谁的缓存数据为准呢?

为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议。如下图:

Java内存模型

Java内存模型主要是用于定义程序中各种变量的访问规则,也就是 虚拟机把变量值存储到内存、从内存中取出变量值 这样的底层细节。这里所谓的变量,和Java编程中的变量有些不同,这里的的变量包括了实例字段、静态字段和构成数组对象的元素,不包含 局部变量和方法参数(此二者是线程私有的,不会被共享,也就不存在竞争)。

Java线程与内存的交互

Java内存模型规定了 所有的变量都存储在 主内存 中,每条线程还有自己的工作内存。线程的工作内存中保存了被该线程使用的变量在主内存的副本,线程对变量的所有操作都必须在 工作内存 中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作线程中的变量,线程间变量值的传递均需要通过主内存来完成。

如上图,Java线程、工作内存以及主内存之间的交互关系如图所示。

原子性、可见性和有序性

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征展开的。

原子性

原子性指一个操作是不可中断的。即使多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

简单来说,我们大致可以认为,基本类型数据的访问、读写都是具备原子性的。(long 和 double 的非原子性协定,后文讲)

此外,在 synchronized块 内的操作也具有原子性。

long和double的非原子协定

对于64位的数据类型(long 和 double),Java内存模型中特别定义了一条宽松的规定:允许虚拟机将没有被 volatile 修饰的64位数据的读写操作划分为 两次32位的操作来进行。即 允许虚拟机实现自行选择是否要保证64位数据类型的读写操作的原子性。

也就是说,对于32位的系统来说,long类型的数据读写就不是原子性的。而针对 double类型,现代中央处理器中,一般都包含专门用于处理浮点数据的浮点运算器(Floating Point Unit,FPU),用来专门处理单、双精度的浮点数据。所以,哪怕是32位的虚拟机中通常也不会出现非原子性访问的问题。

可见性

可见性是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。显然,对串行执行的程序来说,不存在可见性问题。

Java内存模型是通过 在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值 这种依赖主内存作为传递媒介的方式来实现可见性的,无论是 普通变量还是 volatile 变量都是如此。

除了 volatile 之外,synchronized 和 final 关键字也可以实现可见性,就不展开讲了。

有序性

有序性可以被总结为:如果从本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排”现象和“工作内存与主内存同步延迟”现象。

Java提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性。

volatile 关键字

前文也有简单的描述了,volatile 可以是说是Java虚拟机提供的最轻量级的同步机制。

当一个变量被定义成 volatile 后,它将具备下面两条特性:

1、保证此变量对所有线程的可见性。

当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量的值在线程间传递时均需要通过 主内存 来完成。

从物理角度看,各个线程的工作内存中 volatile 变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为 volatile 变量在各个线程的工作内存中是不存在一致性问题的。但是,Java中的运算操作符并非原子操作,这将导致volatile 变量的运算在并发下一样是不安全的。

由于 volatile 只能保证可见性,在一些场合我们仍然需要通过加锁来保证原子性。

2、禁止指令重排序优化

指令重排对于提高CPU处理性能是十分必要的,这里不展开讲。

Happens-Before原则

Java虚拟机和执行系统会对指令进行一定的重排,但是重排是有原则的。下面就是一些必须遵循的规则:

  • 程序次序原则:一个线程内保证语义的串行性。
  • 管城锁定原则:unlock 操作必然先发生于后面对同一个锁的 lock 操作。
  • volatile变量规则:对同一个 volatile 变量的写操作必然先发生于后面对这个变量的读操作。
  • 线程启动原则:Thread.start()方法先行发生于此线程的每一个动作。
  • 线程终止原则:线程中所有的操作都先行发生于对线程的终止检测。
  • 线程中断原则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
  • 对象终结原则:一个对象的初始化完成(构造方法执行结束)先行发生于它的 finalize() 方法的开始。
  • 传递性:如果操作A先于操作B,而操作B先于操作C,则操作A先于操作C。

CPU缓存优化-伪共享问题解决

前文我们提到过,为了解决处理器与内存读写 效率之间的矛盾,CPU有一个高速缓存。在这个缓存中,读写数据的最小单位为 缓存行(Cache Line),它是从主内存复制到缓存的最小单位。

当两个变量被放在同一个缓存行时,在多线程环境下,可能影响两个变量的读写。

如上图,假设 变量X 和 变量Y 被放在了同一个缓存行。当CPU1更新了X值后,X值被同步回主内存。此时如果CPU2需要锁定并读取Y的值,那在这个操作之前,CPU2中X和Y所在的缓存行将被会清空,并且重新冲主内存同步。也就是说,X的更新导致了在同一个缓存行的Y缓存失效了。之后如果CPU2又更新了 Y,那又会导致CPU1上的 X缓存 也会失效。

为了避免这种情况,一种可行的做法是在变量 X 的前后空间都加入一些填充(padding)。这样,当数据被从内存读入缓存时,这个缓存行中就只有 X 这一个有效的变量。

如上图,当CPU1更新X后,X值被同步回主内存。若CPU2需要访问Y,则直接可以通过缓存读取;如果CPU2需要访问X,则CPU2中X所在缓存行会失效,并重新从主内存同步。整个过程CPU2中的Y的缓存行并不受影响。

需要补充说明的是,在JDK8中,Java并不采用这种加 padding 的方式来解决伪共享问题(例如:LongAdder中的伪共享问题解决),而是引入了一个全新的注解 @sun.misc.Contended

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
    String value() default "";
}

类或者属性标注了这个注解的后,JVM将自动为标注了这个注解的元素解决伪共享问题。

在我们自己写的代码中也可以使用这个注解,但是需要额外的虚拟机启动参数:-XX:-RestrictContended。否则,这个注解将被忽略。

总结

文中这些东西,书上都有。但是,自己学习、并总结一下,也是一种学习的好方法。加油吧,剑已配妥,转身杀入江湖。

参考文档

1、《深入理解Java虚拟机》第三版,周志明·著,第十二章。

2、《实战·Java高并发程序设计》第二版,葛一鸣 郭超 著,第一章、第二章、第五章、第六章。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值