并发编程进阶-05

举报
kwan的解忧杂货铺 发表于 2024/08/12 22:49:49 2024/08/12
【摘要】 1.对于同步方法,如何实现原子操作?处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。总线锁定:如果多个处理器同时对非同步共享变量进行读改写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致.原因可能是多个处理器同时从各自的缓存中读取变量 i,分别进行加 1 操作,然后分别写入系统内...

1.对于同步方法,如何实现原子操作?

处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

总线锁定:如果多个处理器同时对非同步共享变量进行读改写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致.原因可能是多个处理器同时从各自的缓存中读取变量 i,分别进行加 1 操作,然后分别写入系统内存中。

对于同步方法操作 i++时,部分处理器使用总线锁就是来解决这个问题的.所谓总线锁就是使用处理器提供的一个 LOCK#信号(参见 93 题的 Lock 汇编指令),当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存,只不过总线锁定开销很大。

缓存锁定:所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在 Lock 操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言 LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效.

2.为什么不淘汰总线锁定?

缓存锁定性能优于总线锁定,为什么不淘汰总线锁定?

有两种情况下处理器不会使用缓存锁定。

  • 第一种情况是:当操作的数据不能被缓存在处理器内部(比如外部磁盘数据),或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。
  • 第二种情况是:有些处理器不支持缓存锁定.对于 Intel 486 和 Pentium 处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

3.什么是原子操作?说说 i++操作

原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。

i++是读改写系列操作,操作中包括如下三个:

  • 读操作:读 i 的当前值;
  • 改操作:在 i 的当前值上做+1 操作;
  • 写:将修改后的值写回内存。

4.java 如何保证原子操作的?

在 Java 中可以通过锁和循环 CAS 的方式来实现原子操作

CAS:从 Java 1.5 开始,JDK 的并发包里提供了一些类来支持原子操作,如 AtomicBoolean (用原子方式更新 boolean 值)、 AtomicInteger (用原子方式更新 int 值)和 AtomicLong (用原子方式更新的 long 值),其中就是依靠 CAS 操作来完成的。

锁:如 synchronized 以及 Lock 锁,线程获取对象锁之后,会完成系列操作后释放锁,运行期间,其他线程会处于阻塞状态,因此是原子性的操作.

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域.JVM 内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁.有意思的是除了偏向锁,JVM 实现锁的方式都用了循环 CAS,即当一个线程想进入同步块的时候使用循环 CAS 的方式来获取锁,当它退出同步块的时候使用循环 CAS 释放锁

5.锁的获取和释放内存语义?

对比锁释放-获取的内存语义与 volatile 写一读的内存语义可以看出:

  • 锁释放与 volatile 写有相同的内存语义;

  • 锁获取与 volatile 读有相同的内存语义。

下面对锁释放和锁获取的内存语义做个总结。

  • 线程 A 释放一个锁,实质上是线程 A 向接下来将要获取这个锁的某个线程发出了(线程 A 对共享变量所做修改的)消息。

  • 线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的(在释放这个锁之前对共享变变量所做修改的)消息。

  • 线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。

image-20220425184008576

从对 ReentrantLock 的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式。

  • 利用 volatile 变量的写-读所具有的内存语义。
  • 利用 CAS 所附带的 volatile 读和 volatile 写的内存语义。

6.CAS 操作的原理?

JDK 文档对该方法的说明如下:如果当前状态值(内存值)等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有 volatile 读和写的内存语义

所谓的 CAS,其实是个简称,全称是 Compare And Swap,对比之后交换数据.内存值–预期值–新值

//原子类Atomic中的cas
public final boolean compareAndSet(boolean expect, boolean update){
   int e = expect ? 1 : 0;
   int u = update ? 1 : 0;
   return unsafe.compareAndSwapInt(this, valueOffset, e, u);
}
//底层实现是用的Unsafe的cas,包含3个方法
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

上面的方法,有几个重要的参数:

  • this: Unsafe 对象本身,需要通过这个类来获取 value 的内存偏移地址。
  • valueOffset:value 变量的内存偏移地址。
  • expect:期望更新的值。
  • update:要更新的最新值。
//CAS-c++源码:
inline jint Atomic::cmpxchg (jint exchange value, volatile jint*dest,
                             jint compare value){
  // alternative for InterlockedCompareExchange
  int mp=os::isMP();//是否为多核心处理器
  _asm {
    mov edx, dest //要修改的地址
      mov ecx, exchange_value //新值值
      mov eax,compare_value //期待值
      LOCK_IF_MP(mp) //如果是多处理器,在下面指令前加上LOCK前缀
      cmpxchg dword ptr [edx],ecx//[edx]与eax对比,相同则[edx]=ecx,否则不操作
  }
}

这里看到有一个 LOCK_IF_MP,作用是如果是多处理器,在指令前加上 LOCK 前缀,因为在单处理器中,是不会存在缓存不一致的问题的,所有线程都在一个 CPU 上跑,使用同一个缓存区,也就不存在本地内存与主内存不一致的问题,不会造成可见性问题.然而在多核处理器中,共享内存需要从写缓存中刷新到主内存中去,并遵循缓存一致性协议通知其他处理器更新缓存.

Lock 在这里的作用:

  • 在 cmpxchg 执行期间,锁住内存地址[edx],其他处理器不能访问该内存,保证原子性.即使是在 32 位机器上修改 64 位的内存也可以保证原子性。
  • 将本处理器上写缓存全部强制写回主存中去,保证每个线程的本地内存与主存一致。
  • 禁止 cmpxchg 与前后任何指令重排序,防止指令重排序。

7.CAS 存在的问题?

CAS 主要有 3 个问题:

  • ABA 问题
  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作

ABA 问题.因为 CAS 需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了.ABA 问题的解决思路就是使用版本号.在变量前面追加上版本号,每次变量更新的时候把版本号加 1,那么 A→ B→A 就会变成 1A→2B→3A。

解决一:从 Java 1.5 开始,JDK 的 Atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题.这个类的 compareAndSet 方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

解决二:使用 AtomicMarkableReference 可以通过 Boolean 类型进行判断

CAS 循环时间太长,会有什么问题:

自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。

使用 CAS 自旋,需要考虑业务场景是否是多任务快速处理的场景,如果单个任务处理够快且任务量大,使用 CAS 会带来很好地效果.轻量级锁的设计原理底层就是使用了 CAS 的操作原理。

8.AtomicMarkableReference

AtomicMarkableReference 与 AtomicStampedReference 一样也可以解决 ABA 的问题,两者唯一的区别是,AtomicStampedReference 是通过 int 类型的版本号,而 AtomicMarkableReference 是通过 boolean 型的标识来判断数据是否有更改过。

既然有了 AtomicStampedReference 为啥还需要再提供 AtomicMarkableReference 呢,在现实业务场景中,不关心引用变量被修改了几次,只是单纯的关心是否更改过

AtomicMarkableReference详解:

// 静态内部类,封装了 变量引用 和 版本号
private static class Pair<T> {
        final T reference;   // 变量引用
        final boolean mark;  // 修改标识
        private Pair(T reference, boolean mark) {
            this.reference = reference;
            this.mark = mark;
        }
        static <T> Pair<T> of(T reference, boolean mark) {
            return new Pair<T>(reference, mark);
        }
    }

    private volatile Pair<V> pair;

    /**
     *
       初始化,构造成一个 Pair 对象,由于 pair 是用 volatile 修饰的所以在构造是线程安全的
     * @param initialRef 初始化变量引用
     * @param initialMark 修改标识
     */
    public AtomicMarkableReference(V initialRef, boolean initialMark) {
        pair = Pair.of(initialRef, initialMark);
    }

常用方法:

// 构造函数,初始化引用和标记值
public AtomicMarkableReference(V initialRef, boolean initialMark)

// 以原子方式获取当前引用值
public V getReference()

// 以原子方式获取当前标记值
public int isMarked()

// 以原子方式获取当前引用值和标记值
public V get(boolean[] markHolder)

// 以原子的方式同时更新引用值和标记值
// 当期望引用值不等于当前引用值时,操作失败,返回false
// 当期望标记值不等于当前标记值时,操作失败,返回false
// 在期望引用值和期望标记值同时等于当前值的前提下
// 当新的引用值和新的标记值同时等于当前值时,不更新,直接返回true
// 当新的引用值和新的标记值不同时等于当前值时,同时设置新的引用值和新的标记值,返回true
public boolean weakCompareAndSet(V  expectedReference,
                                 V  newReference,
                                 boolean expectedMark,
                                 boolean newMark)
// 以原子的方式同时更新引用值和标记值
// 当期望引用值不等于当前引用值时,操作失败,返回false
// 当期望标记值不等于当前标记值时,操作失败,返回false
// 在期望引用值和期望标记值同时等于当前值的前提下
// 当新的引用值和新的标记值同时等于当前值时,不更新,直接返回true
// 当新的引用值和新的标记值不同时等于当前值时,同时设置新的引用值和新的标记值,返回true
public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             boolean expectedMark,
                             boolean newMark)

// 以原子方式设置引用的当前值为新值newReference
// 同时,以原子方式设置标记值的当前值为新值newMark
// 新引用值和新标记值只要有一个跟当前值不一样,就进行更新
public void set(V newReference, boolean newMark)

// 以原子方式设置标记值为新的值
// 前提:引用值保持不变
// 当期望的引用值与当前引用值不相同时,操作失败,返回fasle
// 当期望的引用值与当前引用值相同时,操作成功,返回true
public boolean attemptMark(V expectedReference, boolean newMark)

// 使用`sun.misc.Unsafe`类原子地交换两个对象
private boolean casPair(Pair<V> cmp, Pair<V> val)

9.final 域的内存语义?

final域的内存语义是指一个被final修饰的域在构造函数执行完成后,其值对于其他线程是可见的,因此其他线程可以安全地访问该域的值,而无需进行同步控制。

具体来说,当一个线程在构造函数中完成对一个final域的赋值后,该线程会释放所有已经初始化的final域的内存屏障(Memory Barrier),这会导致所有后续的读操作都可以看到该域的值,而不会看到该域的默认值或初始值。同时,由于内存屏障的作用,其他线程也可以看到该域的最新值,而不需要进行同步控制。

需要注意的是,final域的内存语义仅适用于被final修饰的域,而不适用于在构造函数中赋值但未被final修饰的域。对于非final域,其他线程在访问该域的值时,可能会看到该域的默认值或初始值,而不是构造函数中赋予的值。因此,在多线程环境下,应该尽可能地使用final域来保证线程安全性。

public class Juc_book_fang_12_FinalExample {
    int i; // 普通变量
    final int j; // final变量
    static Juc_book_fang_12_FinalExample obj;

    public Juc_book_fang_12_FinalExample() { // 构造函数
        i = 1; // 写普通域
        j = 2; // 写final域
    }

    public static void writer() { // 写线程A执行
        obj = new Juc_book_fang_12_FinalExample();
    }

    public static void reader() { // 读线程B执行
        Juc_book_fang_12_FinalExample object = obj;// 读对象引用
        int a = object.i; // 读普通域
        int b = object.j; // 读final域
    }
}

final规则:

  1. 一个被final修饰的域必须在声明时或构造函数中进行初始化。
  2. 对于基本类型和不可变对象,可以将其声明为public static final,表示常量。
  3. 对于可变对象,应该避免将其声明为public static final,因为这样会使对象的引用被固定下来,而无法被替换。
  4. 在多线程环境下,final域可以用于保证线程安全性,因为final域的内存语义保证了它在构造函数中赋值后对于其他线程是可见的。

final重排序规则:

在 Java 中,读final域的重排序规则是比较宽松的,即在读操作之前可以进行一定的重排序,但是不能影响到读操作的正确性。

具体来说,当一个线程在读取一个final域的值时,可能会出现以下情况:

  1. 读操作可以在构造函数中的写操作之前执行,但是读操作不能看到构造函数中未初始化的值。
  2. 读操作可以在构造函数中的写操作之后执行,但是读操作必须看到构造函数中初始化的值。

这种规则保证了在多线程环境下,对于被final修饰的域的读操作是安全的,不会看到未初始化的值或者重复的值。需要注意的是,这种规则仅适用于读操作,对于写操作,final域的内存语义要求必须在构造函数中完成初始化,不能进行重排序。

10.happens-before 的理解?

与程序员密切相关的 happens-before 规则如下。

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。

  • 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。

  • volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。

  • 传递性:如果 Ahappens-beforeB,且 Bhappens-beforeC,那么 Ahappens-beforeC

注意两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the frst is visible to and ordered before the second)。happens-before 的定义很微妙,后文会具体说明 happens-before 为什么要这么定义。

image-20220425180332637

11.什么是 as-if-serial 语义?

不管怎么重排序,单线程执行结果不变

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

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

评论(0

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

全部回复

上滑加载中

设置昵称

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

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

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