Java多线程高并发系列:(一)Java内存模型

2024-10-17 20:41
419
0

 

初学者可以先不去关注CPU、内存、JVM部分。可以先看多线程系列的其他文章,知其然,然后再去知其所以然。

一、CPU缓存和内存相关概念介绍(了解)

1.1、多级缓存

因为电脑硬件中遇到的并发问题和JVM中的情况有很多相似之处,JVM中的很多处理方案也是参考的硬件方案。所以可以先了解一下硬件的相关知识。

由于CPU与存储设备的运算速度有几个数量级的差距,所以现代计算机系统中都会加入一层读写速度接近处理器运算速度的高速缓存。

  • L1 Cache是CPU第一层高速缓存,它的容量非常小,一般为32-256KB,提高容量所带来的技术难度增加和成本增加非常大。
  • L2 由于L1高速缓存的容量限制,为了再次提高CPU的运算速度,在CPU外部放置一高速存储器,即二级缓存,现在家庭用CPU容量最大的是4MB,而服务器和工作站上用CPU的L2高速缓存普遍大于4MB,有的高达8MB或者19MB。
  • L3 现在一般都是内置的。在拥有三级缓存的CPU中,只有约5%的数据需要从内存中调用,进一步提升了CPU效率,同时提升大数据量计算时处理器的性能。具有较大L3缓存的处理器提供更有效的文件系统缓存行为及较短消息和处理器队列长度。一般是多核共享一个L3缓存!

CPU在读取数据时,先在L1中寻找,再从L2寻找,再从L3寻找,然后是内存,再后是外存储器。

1.2、缓存同步协议

高速缓存解决了CPU与内存之间的速度矛盾,但也带来了更高的复杂度:缓存一致性的问题。多CPU的的系统中,每个CPU都有自己的高速缓存,当不同CPU读取同样的数据进行缓存,然后进行不同的运算,最终写入主内存以哪个CPU为准?

在这种高速缓存回写的场景下,要遵循一定的协议,如:缓存一致性协议(MESI协议)多数CPU厂商对它进行了实现。

多处理器时,单个CPU对缓存中数据进行了改动,需要通知给其他CPU。也就是意味着,CPU处理要控制自己的读写操作,还要监听其他CPU发出的通知,从而保证最终一致。

 

1.3、CPU性能优化手段-运行时指令重排

指令重排的场景:当CPU写缓存时发现缓存区块正被其他CPU占用,为了提高CPU处理性能,可能将后面的读缓存命令优先执行

as-if-serial语义:指令重排时,不管怎么重排序,单个线程的执行结果不能被改变

换句话说,编译器和处理器不会对存在数据依赖关系的操作做重排序

两个问题:

1、CPU高速缓存下有一个问题:

缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步。在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。

2、CPU执行指令重排优化下有一个问题:

虽然遵守了as-if-serial语义,但仅在单CPU自己执行的情况下能保证结果正确。

多核多线程中,指令逻辑无法分辨因果关联,可能出现乱序执行,导致程序运行结果错误。

1.4、内存屏障

内存屏障(Memory Barrier) 是一个非常重要的概念,它是实现可见性、有序性的关键机制。Java 内存模型通过内存屏障来禁止特定类型的指令重排序,并确保线程间操作的可见性,从而保证多线程环境下的内存一致性。

处理器提供了两个内存屏障指令(Memory Barrier)用于解决上述两个问题:

写内存屏障(Store Memory Barrier):在写指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

强制写入主内存,这种显示调用,CPU就不会因为性能考虑而去对指令重排。

读内存屏障(Load Memory Barrier):在读指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据。

强制读取主内存内容,让CPU缓存与主内存保持一致,避免了缓存导致的一致性问题。

二、Java内存模型(JMM)

TIP:

Java内存模型是Java语言规范中提出的概念(官方文档)。

JVM运行时数据区是Java虚拟机规范中提出的概念(官方文档)。

学习内存模型为了让我们了解JVM做了哪些措施去保证共享变量在多线程下的原子性、可见性和有序性。

2.1、内存模型概念

硬件内存模型:对特定的内存或高速缓存进行读写访问的过程抽象。

Java内存模型:是对多线程程序的语义描述,不规定如何执行多线程程序。但描述了多线程程序的合法行为(规则)

Java内存模型屏蔽掉各种硬件和操作系统的内存访问差异,防止不同平台上的内存模型差异。

Java内存模型主要目标是定义程序中各个变量的访问规则。

其围绕着如何处理原子性、可见性和有序性这3个特征来建立的。

在JDK1.5(JSR-133)发布后,Java内存模型已经成熟完善。

提出内存模型是因为多线程中的一些问题:

  1. 所见非所得
  2. 无法肉眼去检测程序的准确性
  3. 不同的运行平台有不同的表现
  4. 错误很难重现

2.1.1、主内存和工作内存

如上图所示:

  • Java内存模型规定,所有变量都存储在主内存中。  
  • 每条线程还有自己的工作内存,其中保存了被该线程使用到的变量的主内存副本拷贝(类比CPU高速缓存的功能)。
  • 线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
  • 线程间变量值的传递都要通过主内存来完成,线程之间无法直接访问对方工作内存中的变量。
  • 可以在线程之间共享的内存称为共享内存或堆内存(勉强可以把主内存对应于Java堆中对象实例数据部分,工作内存则对应于虚拟机栈中的部分区域)。
  • 所有实例字段、静态字段和数组元素都存储在堆内存中,这些字段和数组都是共享变量。

2.1.2、主内存与工作内存之间的交互操作

Java内存模型定义了一下8种操作来完成主内存和工作内存之间的交互。虚拟机实现时必须保证下面提到的每一种操作都是原子的,不可再分的。

  • 锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
  • 解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
  • use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

除了这种同步操作之外,还规定了一些同步规则来保证这些同步操作的正确执行,了解就行,就没一一列举了。

扩展,JIT即时编译

  • 执行前编译器把java文件编译成字节码,然后字节码在JVM中进行解释执行(执行前编译器不做任何性能优化,不会导致指令重排)。
  • 但JVM有还有个策略:当一个方法被调用多次,或者方法中循环体循环了多次,就进入编译执行,由JIT编译器编译缓存入方法区。JIT编译时还会进行编译优化(指令重排),其JIT的缓存和编译优化可能会导致一些所见非所得的问题。
  • 方法区除了存储类信息、静态字段,还存储了JIT编译之后的代码。

如何解决指令重排的问题?

通过volatile实现。

2.2、volatile关键字

可见性:让一个线程对共享变量的修改,能够及时的被其他线程看到。

Java内存模型中对volatile规定:

  • 对某个volatile字段的写操作,happens-before每个后续对该volatile字段的读操作。
  • volatile变量v的写入,与所有其他线程后续对v的读同步。

volatile如何实现它的语义:

  1. volatile变量的访问控制符会加个ACC_VOLATILEJava虚拟机规范中规定ACC_VOLATILE禁止缓存(不会放入CPU缓存中);官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.5
  2. volatile变量相关的指令不做重排。

volatile的读操作性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过总开销依然比锁要低。

但是注意,volatile的可见性不代表其申明的变量就是线程安全的,举例:

多个线程对一个volatile申明的静态变量进行循环自增多次,得到的结果可能比预计的要少。

那volatile应该使用在什么场景下合适?

  • 一个变量被多个线程访问,更改这个变量新值不依赖于旧值。
  • 多个线程读,一个线程写的场景。
  • 双重检查锁定模式,防止指令重排。
  • 读写锁,读方法上不加锁增加效率,写方法上配合synchronized保证线程安全。

2.3、原子性、可见性和有序性

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

2.3.1、原子性

主内存和工作内存之间的8种操作保证原子性变量的操作,因此我们大致可以认为基本数据类型的访问读写是具备原子性的。

long和double的非原子协定

Java语言规范》对于64位的数据类型(long和double)有一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,每次操作其中 32 位,这可能导致第一次写入后,读取的值是脏数据,第二次写完成后,才能读到正确值。即允许虚拟机实现时可以选择不保证64位数据类型的load、store、read和write这4个操作的原子性。 

  • 商业JVM不会存在该问题,虽然规范没要求实现原子性,但是考虑到实际应用,大部分都实现了原子性。因此在编码时一般也不需要把long和double变量专门申明为volatile。
  • Java语言规范》中说道:建议程序员将共享的64位值(longdouble)用volatile修饰或正确同步其程序以避免可能的复杂的情况(即:volatile修饰的longdouble是原子性的)。

 

编码场景需要一个更大范围的原子性保证时,lock和unlock操作在代码中体现就是synchronized同步块关键字。因此在synchronized申明内的操作也具有原子性。

2.3.2、可见性

上文提到过的volatile保证了多线程操作时变量的可见性。

除了volatile之外,synchronized和final关键字也能实现可见性。

synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这条规则获得。

final的可见性是指:当一个对象的构造函数完成时,它被认为是完全初始化的。如果一个线程只能在一个对象被完全初始化后才能看到该对象的引用,那么它就可以保证看到该对象的final字段的正确初始化值。(可防止this引用逃逸的问题) 官方文档

2.3.3、有序性

volatile的有序性:通过禁止指令重排来获得有序性。

synchronized的有序性:一个变量在同一时刻只允许一条线程对其进行lock操作,这条规则获得。

除了这2个关键字外,Java语言中还定义了一些其他的执行顺序规则:

程序次序规则,官方文档

同步次序规则,官方文档

先行发生的原则,如果一个操作先行发生于另一个操作,则第一个操作被第二个操作可见。这个原则是判断数据是否存在竞争,线程是否安全的主要依据。官方文档

下面是Java内存模型下的一些天然的先行发生规则,这些规则无需任何同步器就已经存在。如果两个操作之间不在此列中并且无法推到出下列规则,就表示他们没有顺序性保障,虚拟机可以对这操作进行重排。

  • 程序次序规则:某个线程中的每个动作都先行发生于该线程中该动作后面的动作。
  • 管程锁定规则:一个unlock动作先行发生于后面对同一个锁的lock动作。
  • volatile变量规则:对某个volatile字段的写操作先行发生每个后续对该volatile字段的读操作
  • 线程启动规则:在某个线程对象上调用start()方法先行发生被启动线程中的任意动作
  • 线程终止规则:线程中的所有操作都先行发生于此线程的终止检测。一般线程的终止可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到。
  • 线程中断规则:对线程interrupt()方法调用先行发生于Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行完成),先行发生于它的finalize()方法的开始。
  • 传递性:如果某个动作 a happens-before动作 b,b happens-before动作 c,则有 a happens-before c

这些规则的建立不是一定禁止指令重排,只是禁止会影响程序的指令重排。

2.4、final在JMM中的处理

JMM中对final的描述:

  • final在该对象的构造函数中设置对象的字段,当线程看到该对象时,将始终看到该对象的final字段的正确构造版本。(解释:设置的成员变量,如果通过构造函数赋值,那么设置了final的成员变量在其他地方调用会满足可见性。而没设置final的字段可能在多线程高并发的情况下,会出现拿到的是默认的值,而不是构造函数里赋的值。构造版本:即通过构造函数初始化时变量的值。)
  • 如果在构造函数中设置字段后发生读取,则会看到该final字段分配的值,否则它将看到默认值。(解释:如果xfinal修饰y没有,然后在构造函数中,y=x;那么y的构造函数版本也是可见的,即x的值,而不会出现默认的值。)
  • 读取该共享对象的final成员变量之前,先要读取共享对象。(解释:即你要拿到类的成员变量肯定得先拿到类的对象,也就是获取类对象和获取final成员变量这两个操作不能重排)
  • 通常被static final 修饰的字段,不能被修改。然后System.inSyste.outSystem.errstatic fianl修饰却可以修改,这是遗留问题,其必须允许通过set方法改变,我们将这些字段称为写保护,以区别普通的final字段。

2.5、WordTearing字节处理

有些处理器(尤其是早期的Alphas处理器)没有提供写单个字节的功能。在这样的处理器上更新byte数组,若只是简单地读取整个内容,更新对应的字节,然后将整个内容再写回内存,将是不合法的。

这个问题有时候被称为“字分裂(word tearing)”,更新单个字节有难度的处理器,就需要寻求其他方式来解决问题。

因此JMM中建议:编程人员需要注意,尽量不要对 byte[] 中的元素进行重新赋值,更不要在多线程程序中这样做。

三、参考文献

《深入理解Java虚拟机》
Java虚拟机规范官方文档: https://docs.oracle.com/javase/specs/jls/se23/html/index.html

 

全部评论