从内存可见性看volatile、原子操作和CAS算法

简介: 从内存可见性看volatile、原子操作和CAS算法

关联博文:

多线程并发之volatile的底层实现原理

【1】内存可见性与内存可见性错误

内存可见性(Memory Visibility)是指当某个线程正在使用对象状态而另一个线程在同时修改该状态,需要确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。


那么如果一个线程修改了对象状态,其他线程看不到对象的状态变化怎么办?就会引起内存可见性错误!


可见性错误是指当读操作与写操作在不同的线程中执行时,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。实例如下:

public class TestVolatile {
    public static void main(String[] args){
        ThreadDemo td=new ThreadDemo();
        new Thread(td).start();
        while (true){
            if (td.isFlag()){
                System.out.println("----------------------");
                break;
            }
        }
    }
}
class ThreadDemo implements  Runnable{
    private boolean flag = false;
    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag=true;
        System.out.println("flag= "+flag);
    }
    public boolean isFlag(){
        return this.flag;
    }
    public void setFlag(boolean flag){
        this.flag=flag;
    }
}


有两个线程,main线程和新起的new Thread(td),当主线程while循环中判断td.flag为true时,则打印横线并跳出循环。

测试结果如下:




并不会打印横线并终止main的while循环!但是已经打印了flag= true说明td.flag状态已经改变。这就是内存可见性错误!!


流程分析



当程序运行时,JVM为会每一个线程分配独立的缓存提高效率。共享数据flag=false当然是存在JVM内存中(确切的说,是随对象存储在堆里面)。线程将会将数据读取到自己缓存进行利用,更改后同步更新JVM内存中的数据。

如下图所示:




由图可见,线程一new Thread(td)会去读取flag并进行修改然后再“反映”到JVM内存中的flag。那么为什么main里面的while循环一直拿不到更新后的true?

这里需要注意两点:



  • new Thread(td)的run方法里面先Thread.sleep(200);while (true)先执行了;
  • while (true){}调用了一系列的底层代码执行效率非常高,高到main没有机会获取内存中更新后的flag。

如何解决呢?


可能会说,使用同步代码块,如下:

 public static void main(String[] args){
        ThreadDemo td=new ThreadDemo();
        new Thread(td).start();
        while (true){
         //这里使用synchronized 关键字,锁为td
            synchronized (td){
                if (td.isFlag()){
                    System.out.println("----------------------");
                    break;
                }
            }
        }
    }



synchronized 能保证每次都刷新缓存,当然可以获取到对象的最新状态,结果如下:



但是需要注意的是,synchronized 会降低性能,如果有多个线程呢?当前线程拿到锁执行代码,其他线程过来肯定要阻塞等待CPU下次调度。

我们可以通过同步来保证对象被安全地发布。除此之外我们也可以使用一种更加轻量级的volatile 变量。

这里推荐使用volatile !

volatile能保证多个线程进行操作共享数据时,内存中的数据对彼此是可见的。

如果一个变量加了volatile关键字,就会告诉编译器和JVM的内存模型:这个变量是对所有线程共享的、可见的,每次jvm都会读取最新写入的值并使其最新值在所有CPU可见。

【2】Volatile与Synchronized

Java提供了一种稍弱的同步机制,即volatile 变量,用来确保将变量的更新操作通知到其他线程。你可以将volatile 看做一个轻量级的锁。


修改代码如下:

public class TestVolatile {
    public static void main(String[] args){
        ThreadDemo td=new ThreadDemo();
        new Thread(td).start();
        while (true){
//            synchronized (td){
                if (td.isFlag()){
                    System.out.println("----------------------");
                    break;
                }
//            }
        }
    }
}
class ThreadDemo implements  Runnable{
  // 这里,使用volatile修饰flag
    private volatile boolean flag = false;
    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag=true;
        System.out.println("flag= "+flag);
    }
    public boolean isFlag(){
        return this.flag;
    }
    public void setFlag(boolean flag){
        this.flag=flag;
    }
}



测试结果如下,可以看到完美符合我们的预期。



那么是否说明volatile比Synchronized更好,能替代Synchronized?


完全错误!!!


如上所说,volatile是一种轻量级的锁,synchronized是重量级锁(悲观锁),volatile与synchronized区别如下:


对于多线程,不是一种互斥关系;

synchronized是保证互斥的,即拿到锁的线程可以执行,其他线程过来只能等待;volatile不能保证这种关系。


不能保证变量状态的“原子性操作”。


不要将volatile用在getAndOperate场合(这种场合不原子,需要再加锁),仅仅set或者get的场景是适合volatile的。那么什么是“原子性操作”?volatile不能保证变量的“原子性”操作会出现什么问题?

【3】原子性操作

① 经典例子i++

代码如下:

@Test
public void test1(){
     int i=10;
     i=i++;
     System.out.println(i);//i=10
 }



编译后class文件中对应代码如下:

 @Test
 public void test1() {
     int i = 10;
     //可以看到i++被拆分为了如下三步
     byte var10000 = i;
     int var2 = i + 1;
     i = var10000;
     System.out.println(i);
 }

使用javap -verbose Test.class查看对应指令如下:

public void test1();
   descriptor: ()V
   flags: ACC_PUBLIC
   Code:
     stack=2, locals=2, args_size=1
        0: bipush        10
        2: istore_1
        3: iload_1
        4: iinc          1, 1
        7: istore_1
        8: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       11: iload_1
       12: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
       15: return
     LineNumberTable:
       line 22: 0
       line 23: 3
       line 24: 8
       line 25: 15
     LocalVariableTable:
       Start  Length  Slot  Name   Signature
           0      16     0  this   Lcom/jane/TestRex;
           3      13     1     i   I
   RuntimeVisibleAnnotations:
     0: #17()


也就是说i++的操作实际上分为三个步骤“读-改-写”(对应指令中的 3 ,4 ,7)。原子性操作就是这三个步骤为一个"单元",在执行这个"单元"过程中,其他线程不能过来干涉。


② volatile不能保证变量的原子操作


示例如下:

public class TestAtomicDemo {
    public static void main(String[] args){
        AtomicDemo ad = new AtomicDemo();
        for (int i=0;i<10;i++){
            new  Thread(ad).start();
        }
    }
}
class AtomicDemo implements  Runnable{
  // 这里使用volatile修饰
    private volatile int serialNum = 0;
    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+" : "+getSerialNum());
    }
    public int getSerialNum() {
       // 这里 serialNum++
        return serialNum++;
    }
}


多次运行测试,会出现多线程问题,如下图:


由于volatile对于多线程不具有互斥性,不能保证变量的原子操作,故而如线程一读取并修改serialNum时,线程二、线程三也可能要修改!

那么如何在不使用synchronized的情况下实现变量的原子性操作?


这就涉及到了原子变量和CAS算法。

【4】原子变量

JDK1.5后提供了java.util.concurrent工具包。该包为类的小工具包,支持在单个变量上解除锁的线程安全编程。

事实上,此包中的类可将volatile 值、字段和数组元素的概念扩展到那些也提供原子条件更新操作的类。


java.util.concurrent.atomic 包下提供了一些原子操作的常用类:

类AtomicBoolean、AtomicInteger、AtomicLong 和AtomicReference 的实例各自提供对相应类型单个变量的访问和更新。每个类也为该类型提供适当的实用工具方法。


AtomicIntegerArray、AtomicLongArray 和AtomicReferenceArray 类进一步扩展了原子操作,对这些类型的数组提供了支持。这些类在为其数组元素提供volatile 访问语义方面也引人注目,这对于普通数组来说是不受支持的。


参考博文:JUC中原子类总结

修改代码如下:

public class TestAtomicDemo {
    public static void main(String[] args){
        AtomicDemo ad = new AtomicDemo();
        for (int i=0;i<10;i++){
            new  Thread(ad).start();
        }
    }
}
class AtomicDemo implements  Runnable{
//    private volatile int serialNum = 0;
    private AtomicInteger ai=new AtomicInteger();
    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+" : "+getSerialNum());
    }
    public int getSerialNum() {
//        return serialNum++;
        return ai.getAndIncrement();
    }

测试多次运行测试,不会出现多线程的错误!

为什么使用原子变量就可以避免如上所示的多线程错误?有两点需要注意:

  • 变量使用volatile修饰保证内存可见性;
  • CAS算法保证数据的原子性。


以AtomicInteger为例,除了final修饰的常量外,变量使用volatile修饰:

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

那么,什么是CAS算法?

【5】CAS算法

CAS (Compare-And-Swap) 是一种硬件对并发的支持,针对多处理器操作而设计的处理器中的一种特殊指令,用于管理对共享数据的并发访问。其实,CAS 是一种无锁的非阻塞算法的实现。Java中的CAS操作依赖于底层CPU的CAS指令。


CAS 包含了3 个操作数:

  • 需要读写的内存值V
  • 进行比较的值A
  • 拟写入的新值B


当且仅当V 的值等于A 时,CAS 通过原子方式用新值B 来更新V 的值,否则不会执行任何操作。


这也就是为什么【4】中的代码不会出现多线程的错误。


当某个线程尝试进行更新主存中的数据时,会再次读取数据–A,然后将V与A比较,V==A则执行更新,否则放弃。在同一个时刻,将会只有一个线程更新JVM中的数据成功!使用如下代码模拟计算机底层CAS操作:

public class TestCompareAndSwap {
    public static void main(String[] args){
        final CompareAndSwap compareAndSwap = new CompareAndSwap();
        for (int i=0;i<10;i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    // 获取主存值
                    int expectedValue = compareAndSwap.getValue();
                    boolean b = compareAndSwap.compareAndSet(expectedValue, (int) (Math.random() * 101));
                    System.out.println(b);
                }
            }).start();
        }
    }
}
class CompareAndSwap{
    private  int value;
    //获取主存值
    public int getValue() {
        return value;
    }
    // 比较并设置,返回主存值
    public synchronized  int compareAndSwap(int expectedValue,int newValue){
        int oldValue = value;
        //如果主存值==预估值,则进行更新
        if (oldValue==expectedValue){
            this.value=newValue;
        }
        return oldValue;
    }
    //调用设置值方法,并返回成功失败
    public synchronized boolean compareAndSet(int expectedValue,int newValue){
        return expectedValue==compareAndSwap(expectedValue,newValue);
    }
}

那为什么CAS比锁性能要高呢?


如果使用了锁,该次不成功的情况下将会阻塞,等待下一次CPU调度。但是CAS如果该次失败了,会立即尝试进行下一次!

它允许多线程非阻塞式地对共享资源进行修改,但同一时刻只有一个线程能够成功,其他线程被告知失败但并不会挂起,而是重新尝试。这是一种非阻塞式的同步方式。


【6】CAS指令

java中给我们提供了本地方法来获得和CAS指令一样的执行效果。比如Unsafe类中:

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);

因为这些方法会被编译成平台相关的CAS指令,故而这些CAS操作都具有原子性。AtomicInteger这些原子类其实就依赖于Unsafe。

在JDK并发包的底层实现中,还是可以处处看到它的身影。如下图所示:

【7】CAS结合失败重试机制进行并发控制

还记得【4】中的代码修改之后就不会出现多线程错误:

class AtomicDemo implements  Runnable{
//    private volatile int serialNum = 0;
    private AtomicInteger ai=new AtomicInteger();
    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+" : "+getSerialNum());
    }
    public int getSerialNum() {
//        return serialNum++;
        return ai.getAndIncrement();
    }

CAS指令只是提供了一个更新变量的原子操作,要使用它进行并发控制,还需要结合失败重试机制。

查看 ai.getAndIncrement();源码跟踪到AtomicInteger类中:

/**
 * Atomically increments by one the current value.
 *
 * @return the previous value
 */
public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

继续跟踪到Unsafe类中:

 public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
            //获取当前变量值
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
        //CAS失败则重新尝试直到成功为止
        return var5;
    }

可以看到步骤可以概括为

1.获得变量当前的值var5

2.使用CAS操作进行更新:若var5与当前的值不一样,说明1,2操作间有其他线程作了修改,此次更新失败,重新实行步骤1;否则用 var5+var4的值进行更新,并返回更新前的值。

多个线程同时使用CAS指令去更新变量,失败的线程将会不断重新尝试,直到更新成功。

【8】CAS优缺点

① 优点

没有线程阻塞唤醒带来的性能消耗问题。这也是为什么比synchronized性能高的原因!

② 缺点

ABA问题


在CAS操作时,我们以变量的当前值和预期值一致来判定变量未被其他线程修改。这样是不严谨的,因为变量可能被修改成其他值后又被改了回来。


大部分时候这是个可以忽略的小问题,如果要规避这个问题,可以使用AtomicStampedReference,它会额外使用一个时间戳来判断变量是否被修改过。


无法直接使用CAS来进行并发控制,相比同步锁的方式适用范围较窄。

【8】经典例子i++的几种情况

① 单独a++

如下所示:

//源码
@Test
public void contextLoads5() {
  int a=5;
  a++;
  System.out.println(a);
}
//编译后
@Test
  public void contextLoads5() {
       int a = 5;
       a = a + 1;
       System.out.println(a);
   }


此时毫无疑问,a==6。


② a++赋值给自身

如下所示:

//源码
@Test
public void contextLoads4() {
  int a=5;
  a=a++;
  System.out.println(a);
}
//编译后
@Test
 public void contextLoads4() {
     int a = 5;
     byte var10000 = a;
     int var2 = a + 1;
     a = var10000;
     System.out.println(a);
 }


此时 a仍旧等于5。


③ a++赋值给其他

如下所示:

//源码
@Test
public void contextLoads6() {
  int a=5;
  int b=a++;
  System.out.println(a);
  System.out.println(b);
}
//编译后
@Test
public void contextLoads6() {
      int a = 5;
      byte var10000 = a;
      int a = a + 1;
      int b = var10000;
      System.out.println(a);
      System.out.println(b);
  }


此时a==6,b==5


④ a++与其他运算符结合

如下所示:

//源码
@Test
public void contextLoads3() {
  int a=5;
  int b=a++;
  int c=b*a++;
  System.out.println(a);
  System.out.println(b);
  System.out.println(c);
}



此时 a==7,b==5,c==30


⑤ ++a与其他运算符结合

如下所示:

@Test
public void contextLoads3() {
  int a=5;
  int b=++a;
  int c=b*++a;
  System.out.println(a);
  System.out.println(b);
  System.out.println(c);
}



此时 a==7,b==6,c==42


⑥ a++与++a和其他运算符结合

如下所示:

@Test
public void contextLoads3() {
  int a=10>>1;
  int b=a++;
  int c=++a;
  int d=b*a++;
  System.out.println(a);
  System.out.println(b);
  System.out.println(c);
  System.out.println(d);
}


此时 a==8,b==5,c==7,d==35。


本质也就是a++或者++a换行后,a一定会自增1。如果与其他变量赋值,那么a++是先赋值给其他变量,然后a+1。++a则是a先+1,后赋值给其他变量。

目录
相关文章
|
2月前
|
算法 Java 数据库
理解CAS算法原理
CAS(Compare and Swap,比较并交换)是一种无锁算法,用于实现多线程环境下的原子操作。它通过比较内存中的值与预期值是否相同来决定是否进行更新。JDK 5引入了基于CAS的乐观锁机制,替代了传统的synchronized独占锁,提升了并发性能。然而,CAS存在ABA问题、循环时间长开销大和只能保证单个共享变量原子性等缺点。为解决这些问题,可以使用版本号机制、合并多个变量或引入pause指令优化CPU执行效率。CAS广泛应用于JDK的原子类中,如AtomicInteger.incrementAndGet(),利用底层Unsafe库实现高效的无锁自增操作。
理解CAS算法原理
|
2月前
|
机器学习/深度学习 人工智能 算法
【AI系统】内存分配算法
本文探讨了AI编译器前端优化中的内存分配问题,涵盖模型与硬件内存的发展、内存划分及其优化算法。文章首先分析了神经网络模型对NPU内存需求的增长趋势,随后详细介绍了静态与动态内存的概念及其实现方式,最后重点讨论了几种节省内存的算法,如空间换内存、计算换内存、模型压缩和内存复用等,旨在提高内存使用效率,减少碎片化,提升模型训练和推理的性能。
109 1
|
2月前
|
存储 缓存 Java
【JavaEE】——内存可见性问题
volatile,一个线程读,一个线程写,两个线程互相读,多个线程多把锁
|
6月前
|
存储 SQL 缓存
揭秘Java并发核心:深度剖析Java内存模型(JMM)与Volatile关键字的魔法底层,让你的多线程应用无懈可击
【8月更文挑战第4天】Java内存模型(JMM)是Java并发的核心,定义了多线程环境中变量的访问规则,确保原子性、可见性和有序性。JMM区分了主内存与工作内存,以提高性能但可能引入可见性问题。Volatile关键字确保变量的可见性和有序性,其作用于读写操作中插入内存屏障,避免缓存一致性问题。例如,在DCL单例模式中使用Volatile确保实例化过程的可见性。Volatile依赖内存屏障和缓存一致性协议,但不保证原子性,需与其他同步机制配合使用以构建安全的并发程序。
84 0
|
3月前
|
算法
虚拟内存的页面置换算法有哪些?
【10月更文挑战第25天】不同的页面置换算法各有优缺点,在实际应用中,操作系统会根据不同的应用场景和系统需求选择合适的页面置换算法,或者对算法进行适当的改进和优化,以平衡系统的性能、开销和资源利用率等因素。
81 5
|
4月前
|
算法 Java
介绍一下CAS算法的实现原理
【10月更文挑战第20天】介绍一下CAS算法的实现原理
78 0
|
4月前
|
缓存 Java 编译器
【多线程-从零开始-伍】volatile关键字和内存可见性问题
【多线程-从零开始-伍】volatile关键字和内存可见性问题
75 0
|
4月前
|
存储 算法 C语言
MacOS环境-手写操作系统-17-内存管理算法实现
MacOS环境-手写操作系统-17-内存管理算法实现
56 0
|
6月前
|
存储 算法 Java
JVM自动内存管理之垃圾收集算法
文章概述了JVM内存管理和垃圾收集的基本概念,提供一个关于JVM内存管理和垃圾收集的基础理解框架。
JVM自动内存管理之垃圾收集算法
|
6月前
|
存储 算法 大数据
小米教你:2GB内存搞定20亿数据的高效算法
你好,我是小米。本文介绍如何在2GB内存中找出20亿个整数里出现次数最多的数。通过将数据用哈希函数分至16个小文件,每份独立计数后选出频次最高的数,最终比对得出结果。这种方法有效解决大数据下的内存限制问题,并可应用于更广泛的场景。欢迎关注我的公众号“软件求生”,获取更多技术分享!
215 12