并发编程进阶-03

举报
kwan的解忧杂货铺 发表于 2024/08/11 23:11:34 2024/08/11
【摘要】 1.说说重排序的分类?在执行程序时,为了提高性能,编译器(jvm 里的)和处理器(操作系统级别的)常常会对指令做重排序.重排序分 3 种类型。编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行.如果不存在数据依赖性...

1.说说重排序的分类?

在执行程序时,为了提高性能,编译器(jvm 里的)和处理器(操作系统级别的)常常会对指令做重排序.重排序分 3 种类型。

编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行.如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序.这些重排序可能会导致多线程程序出现内存可见性问题.对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止).

对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers, Intel 称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

在单线程程序中,对存在数据依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果,因此必须要通过一定的同步手段加以控制。

2.说说重排序?

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

jmm 在实现上在不改变结果的前提下,编译器和处理器可以进行优化性的重排序.

image-20240126151519236

3.volatile 实现原则

相关名词

  • TPS(Transactions Per Second):每秒事务处理数,衡量一个服务性能好坏的评判标准。
  • JMM(Java Memory Model):Java 内存模型。内存模型 JMM 控制多线程对共享变量的可⻅性

volatile 实现原则有 2 条:

  • lock 前缀指令会使处理器缓存写回内存.
  • 一个处理器的缓存写回内存,会导致其他处理器的缓存失效.

4.内存屏障的种类以及说明?

image-20220425175438754

StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他 3 个屏障的效果.现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持).执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush).

5.volatile 的实现原理

为了实现 volatile 内存语义,JMM 会分别禁止如下两种类型的重排序类型:

image-20231219111611706

从图中可看出:

  1. volatile 写写禁止重排序

  2. volatile 读写,读读禁止重排序; volatile 读和普通写禁止重排序

  3. volatile 写读,volatile 写写禁止重排序。

image-20220425183259393

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序.对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能.为此,JMM 采取保守策略.下面是基于保守策略的 JMM 内存屏障插入策略。

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

image-20220425183322017

public class Juc_book_fang_03_VolatileBarrierExample {
   int a;
   volatile int v1 = 1;
   volatile int v2 = 2;

   void readAndWrite(){
     int i = v1; //第一个volatile读
     int j = v2; //第二个volatile读
     a = i + j; //普通写
     v1 = i +1; //第一个volatile写
     v2 = j *2; //第二个 volatile写
    //其他方法
  }
}

屏障说明:

image-20220425183442291

6.说说 volatile 的内存语义?

从内存语义的角度来说,volatile 的写读与锁的释放-获取有相同的内存效果:

  • volatile 写和锁的释放有相同的内存语义;
  • volatile 读与锁的获取有相同的内存语义。

volatile 写的内存语义如下。

当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。

volatile 读的内存语义如下。

当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

image-20220425183129532

7.volatile 的特性?

volatile是 Java 中的一个关键字,用于修饰变量。volatile具有以下特性:

  1. 可见性:当一个变量被volatile修饰时,在一个线程中修改该变量后,其他线程可以立即看到修改后的值。这是因为volatile会强制将变量的值从线程的本地内存中刷新到主内存中,使得其他线程可以读取到最新的值。
  2. 有序性:当一个变量被volatile修饰时,对该变量的读写操作具有有序性。这是因为volatile会禁止指令重排序,保证指令执行的顺序与程序代码中的顺序一致。这个特性主要是为了解决多线程中的并发问题,保证线程之间的操作顺序与程序代码中的顺序一致。
  3. 不保证原子性:虽然volatile具有可见性和有序性,但是并不保证对变量的操作是原子性的,即不一定能够保证在多线程环境下操作变量时的安全性。例如,对于a++这样的操作,虽然是原子操作,但是由于包含了读取、加 1、写回三个步骤,因此在多线程环境下仍然可能出现线程安全问题。

因此,volatile主要用于保证变量的可见性和有序性,而不是用于解决线程安全问题。如果需要保证线程安全,需要结合其他机制,例如synchronized、Lock等。

多线程i++操作不保证原子性:

虽然使用了 volatile 关键字来修饰 count 变量,但是仍然无法保证输出结果为 50。这是因为 volatile 只能保证变量在多线程之间的可见性,但并不能保证对变量的操作是原子的。

在多线程环境下,即使使用 volatile 关键字修饰变量,当多个线程同时对这个变量进行自增操作时,仍然可能会出现竞争条件,导致结果不是预期的。

8.volatile 是如何保证可见性的?

示例代码中, instance 被 volatile 修饰。

volatile instance = new instance();

上边的 new 操作,转化成汇编代码如下:
image-20240118164210547
有 volatile 变量修饰的共享变量进行写操作的时候会多出第二行 Lock 汇编代码, Lock 前缀的指令在多核处理器下会引发了两件事情:

  • 将当前处理器缓存行的数据写回到系统内存.(volatile 写的内存语义)
  • 这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效.(volatile 读的内存语义)

如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里(volatile 读的内存语义)

9.synchronized 三种使用

java 中的每一个对象都可以作为锁。具体表现为以下 3 种形式。

  • 对于普通同步方法,锁是当前实例对象。ACC_SYNCHRONIZED
  • 对于静态同步方法,锁是当前类的 Class 对象。ACC_SYNCHRONIZED 和 ACC_STATIC
  • 对于同步方法块,锁是 synchronized 括号里配置的对象。monitorenter 和 monitorexit,其中 monitorexit 至少有 2 个出口,一个正常出口,一个异常出口

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁

当出现读少写多时,volatile 并不合适,提高吞吐量还得靠重量级锁.

10.synchronized 的实现原理

在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 MutexLock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,主要是锁升级的过程. owner 表示 Monitor 锁的持有者,而且同一个时刻只能有一个 owner.

JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但两者的实现细节不一样。

代码块同步是使用 monitorenter 和 monitorexit 指令实现的,而方法同步是使用另外一种方式实现的。

monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处,JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。

任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态.线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

修饰方法:

public class SynchonizedTest1 {
  private static int a = 0;
  public  synchronized void add(){
    a++;
  }
}

可以看到在 add 方法的 flags 里面多了一个 ACC_SYNCHRONIZED 标志,这标志用来告诉 JVM 这是一个同步方法

┌─[qinyingjie@qinyingjiedeMacBook-Pro]-[~/Documents/idea-workspace/ant/ant-juc/target/classes/com/xiaofei/antjuc/方腾飞]-[Thu Apr 14,18:51]
└─[$]<git:(master*)> javap -v SynchonizedTest1.class
Classfile /Users/qinyingjie/Documents/idea-workspace/ant/ant-juc/target/classes/com/xiaofei/antjuc/方腾飞/SynchonizedTest1.class
 Last modified 2022-4-14; size 486 bytes
 MD5 checksum 1a0bdb0e66832a2980bb5b8c0a58eff7
 Compiled from "SynchonizedTest1.java"
public class com.xiaofei.antjuc.方腾飞.SynchonizedTest1
 minor version: 0
 major version: 52
 flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#18// java/lang/Object."<init>":()V
#2 = Fieldref #3.#19// com/xiaofei/antjuc/方腾飞/SynchonizedTest1.a:I
#3 = Class #20// com/xiaofei/antjuc/方腾飞/SynchonizedTest1
#4 = Class #21// java/lang/Object
#5 = Utf8  a
#6 = Utf8  I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8  Code
#10 = Utf8  LineNumberTable
#11 = Utf8  LocalVariableTable
#12 = Utf8  this
#13 = Utf8  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest1;
#14 = Utf8  add
#15 = Utf8 <clinit>
#16 = Utf8  SourceFile
#17 = Utf8  SynchonizedTest1.java
#18 = NameAndType #7:#8 //"<init>":()V
#19 = NameAndType #5:#6 // a:I
#20 = Utf8  com/xiaofei/antjuc/方腾飞/SynchonizedTest1
#21 = Utf8  java/lang/Object
{
 public com.xiaofei.antjuc.方腾飞.SynchonizedTest1();
 descriptor: ()V
 flags: ACC_PUBLIC
 Code:
 stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
 LineNumberTable:
 line 3: 0
 LocalVariableTable:
 Start  Length  Slot  Name  Signature
0 5 0  this  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest1;

 public synchronized void add();
 descriptor: ()V
 flags: ACC_PUBLIC, ACC_SYNCHRONIZED
 Code:
 stack=2, locals=1, args_size=1
0: getstatic #2 // Field a:I
3: iconst_1
4: iadd
5: putstatic #2 // Field a:I
8: return
 LineNumberTable:
 line 6: 0
 line 7: 8
 LocalVariableTable:
 Start  Length  Slot  Name  Signature
0 9 0  this  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest1;

 static {};
 descriptor: ()V
 flags: ACC_STATIC
 Code:
 stack=1, locals=0, args_size=0
0: iconst_0
1: putstatic #2 // Field a:I
4: return
 LineNumberTable:
 line 4: 0
}
SourceFile: "SynchonizedTest1.java"

修饰类:

主要是使用 monitorenter 和 monitorexit 实现

有两个 monitorexit 呢?

  • 第一个:正常退出

  • 第二个:异常退出

public class SynchonizedTest2 {
   private static int a = 0;

   public void add(){
     synchronized (SynchonizedTest2.class){
    		 a++;
     }
   }
}
方腾飞|master⚡⇒ jjavap -v SynchonizedTest2
警告: 二进制文件SynchonizedTest2包含com.xiaofei.antjuc.方腾飞.SynchonizedTest2
Classfile /Users/qinyingjie/Documents/idea-workspace/ant/ant-juc/target/classes/com/xiaofei/antjuc/方腾飞/SynchonizedTest2.class
 Last modified 2022-4-14; size 599 bytes
 MD5 checksum e4ad4e62082f26cefee3bb1715e94295
 Compiled from "SynchonizedTest2.java"
public class com.xiaofei.antjuc.方腾飞.SynchonizedTest2
 minor version: 0
 major version: 52
 flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#22// java/lang/Object."<init>":()V
#2 = Class #23// com/xiaofei/antjuc/方腾飞/SynchonizedTest2
#3 = Fieldref #2.#24// com/xiaofei/antjuc/方腾飞/SynchonizedTest2.a:I
#4 = Class #25// java/lang/Object
#5 = Utf8  a
#6 = Utf8  I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8  Code
#10 = Utf8  LineNumberTable
#11 = Utf8  LocalVariableTable
#12 = Utf8  this
#13 = Utf8  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest2;
#14 = Utf8  add
#15 = Utf8  StackMapTable
#16 = Class #23// com/xiaofei/antjuc/方腾飞/SynchonizedTest2
#17 = Class #25// java/lang/Object
#18 = Class #26// java/lang/Throwable
#19 = Utf8 <clinit>
#20 = Utf8  SourceFile
#21 = Utf8  SynchonizedTest2.java
#22 = NameAndType #7:#8 //"<init>":()V
#23 = Utf8  com/xiaofei/antjuc/方腾飞/SynchonizedTest2
#24 = NameAndType #5:#6 // a:I
#25 = Utf8  java/lang/Object
#26 = Utf8  java/lang/Throwable
{
 public com.xiaofei.antjuc.方腾飞.SynchonizedTest2();
 descriptor: ()V
 flags: ACC_PUBLIC
 Code:
 stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
 LineNumberTable:
 line 3: 0
 LocalVariableTable:
 Start  Length  Slot  Name  Signature
0 5 0  this  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest2;

 public void add();
 descriptor: ()V
 flags: ACC_PUBLIC
 Code:
 stack=2, locals=3, args_size=1
0: ldc #2 // class com/xiaofei/antjuc/方腾飞/SynchonizedTest2
2: dup
3: astore_1
4: monitorenter
5: getstatic #3 // Field a:I
8: iconst_1
9: iadd
10: putstatic #3 // Field a:I
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
 Exception table:
from  to  target type
5 15 18  any
18 21 18  any
 LineNumberTable:
 line 7: 0
 line 8: 5
 line 9: 13
 line 10: 23
 LocalVariableTable:
 Start  Length  Slot  Name  Signature
0 24 0  this  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest2;
 StackMapTable: number_of_entries = 2
 frame_type = 255 /* full_frame */
 offset_delta = 18
 locals = [ class com/xiaofei/antjuc/方腾飞/SynchonizedTest2, class java/lang/Object ]
 stack = [ class java/lang/Throwable ]
 frame_type = 250 /* chop */
 offset_delta = 4

 static {};
 descriptor: ()V
 flags: ACC_STATIC
 Code:
 stack=1, locals=0, args_size=0
0: iconst_0
1: putstatic #3 // Field a:I
4: return
 LineNumberTable:
 line 4: 0
}
SourceFile: "SynchonizedTest2.java"

11.synchronized 同步队列与等待队列?

synchronized是 Java 中用于实现同步的一种机制。在synchronized中,同步队列和等待队列是两个重要的概念:

  1. 同步队列:同步队列是一个双向队列,用于存放已经获得锁的线程。当一个线程成功地获得锁时,它会被加入到同步队列中。同步队列中的线程按照获取锁的先后顺序排队,先获取锁的线程排在队列的前面。
  2. 等待队列:等待队列是一个单向队列,用于存放等待锁的线程。当一个线程尝试获取锁时,如果锁已经被其他线程持有,那么它就会被加入到等待队列中。等待队列中的线程被阻塞,直到获取锁的线程释放锁后,它们才有机会重新竞争锁。

synchronized中的同步队列和等待队列是通过内置锁(也称为监视器锁)来实现的。当一个线程成功获取锁时,它会持有锁并进入同步队列;当一个线程无法获取锁时,它会进入等待队列并释放锁,在等待队列中等待被唤醒。当持有锁的线程释放锁时,它会从同步队列中唤醒下一个等待的线程,使其重新竞争锁。

需要注意的是,同步队列和等待队列是在 Java 虚拟机层面实现的,而不是由操作系统的线程调度器来管理。因此,在synchronized中的线程调度是由 Java 虚拟机来负责调度的,可能与操作系统的线程调度器有所不同。

12.synchronized 不同方法?

当一个线程进入一个对象的 synchronized 方法 A 之后,其它线程是否可进入此对象的 synchronized 方法 B?

如果一个线程进入一个对象的synchronized方法 A,那么其他线程在此期间是无法进入该对象的任何synchronized方法(包括方法 A 和方法 B)的。这是因为,当一个线程进入对象的synchronized方法时,它会获取该对象的锁,并将该对象的锁计数器加 1,直到该线程执行完该方法并释放锁后,其他线程才有机会获取该对象的锁。

因此,在同一个对象上,同一时间只能有一个线程执行该对象的synchronized方法,其他线程必须等待该线程执行完方法并释放锁后,才有机会获取该对象的锁并执行该对象的synchronized方法。如果多个线程需要同时访问该对象的不同synchronized方法,可以使用不同的锁对象,或者使用synchronized代码块,并使用不同的锁对象。

13.ObjectMonitor 的属性

ObjectMonitor是 Java 中的一个内部类,用于实现synchronized的同步机制。ObjectMonitor类中包含了以下一些重要的属性:

  1. _owner:表示当前获得锁的线程对象。
  2. _count:表示当前线程已经重入锁的次数。
  3. _waitSet:表示等待锁的线程队列,即等待队列。
  4. _waitSetLock:表示等待队列的锁对象,用于对等待队列进行同步操作。
  5. _recursions:表示当前线程重入锁的次数,与_count类似。
  6. _EntryList:表示同步队列,即已获得锁的线程队列。
  7. _WaitSetNext:表示等待队列中下一个等待锁的线程。

需要注意的是,这些属性是在 Java 虚拟机层面实现的,而不是 Java 语言层面的。因此,它们的具体实现可能与不同的 Java 虚拟机实现有所不同。

14.MESI 与 volatile

既然 CPU 有缓存一致性协议(MESI),JVM 为啥还需要 volatile 关键字?

CPU 的缓存一致性协议(如 MESI:修改、独占、共享、无效)确实可以帮助处理多个 CPU 核心之间的缓存一致性问题,但是 Java 中的volatile关键字在某些情况下仍然是必要的,因为它涉及到更高级的内存可见性问题,而不仅仅是底层的缓存一致性。

以下是为什么在某些情况下需要volatile关键字的原因:

  1. 禁止指令重排序:Java 中的volatile关键字不仅仅保证了对变量的写操作会刷新到主内存中,还保证了对volatile变量的读操作不会发生在写操作之前,即禁止了指令重排序。这对于确保线程安全非常重要,因为如果没有这个保证,其他线程可能会在写操作之前看到过时的值。

  2. 内存可见性:volatile关键字保证了一个线程对volatile变量的写操作对于其他线程来说是可见的。如果一个线程在一个volatile变量上进行了写操作,那么其他线程在读取该变量时会立即看到最新的值。这是因为volatile变量的读操作会从主内存中读取,而不是从线程的本地缓存中读取。

  3. 不适用于复合操作:缓存一致性协议通常只关注单个变量的读写操作,而不考虑复合操作的一致性。在 Java 中,许多操作都是复合操作,例如递增操作(i++),如果不使用volatile或其他同步机制,可能会导致不正确的结果。volatile关键字可以确保这些复合操作在多线程环境中的正确性。

虽然 CPU 的缓存一致性协议可以处理底层的缓存一致性问题,但volatile关键字在 Java 中提供了更高级别的内存可见性和线程安全保证,特别是在处理复合操作和避免指令重排序方面非常重要。所以,volatile关键字和 CPU 的缓存一致性协议并不冲突,而是在不同层面上处理多线程的问题。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。