volatile关键字

2023-12-13 08:39:12

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("--------主线中的程序读到flag为true了----------");
                break;
            }
//这里不能有语句,有语句循环之间就有间隙
        }
    }
}
class ThreadDemo implements Runnable {
    private boolean flag = false;
    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
        }
        flag = true;
        System.out.println("flag=" + isFlag());
    }
    public boolean isFlag() {
        return flag;
    }
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}

运行发现程序不会停止:

即使我们在子线程中将线程的共享变量flag的值修改成了false,但是主线程在while条件判断的时候读到的flag一直是false,这是什么原因导致的呢?

这就涉及到内存的可见性问题了,在讲怎么解决内存可见性问题之前。

什么是内存可见性 图片

内存可见性其实就是共享变量在线程间的可见性

共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。

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

volatile的作用
1)保证线程的可见性 (MESI,缓存一致性协议)

解释: 堆内存是所有线程共享里面的内存,除了共享内存之外,每个线程都有自己专属的区域,都有自己的工作内存,如果共享内存里面存在一个值,则当我们是要线程去访问这个值,首先会将这个值copy一份到自己的工作内存中进行修改,修改好了再返回回去,具体不知道什么时候修改完,什么时候放回去,这个线程里面的修改不能及时的反应到另一个线程这就是不可见性,volatile关键字能够保证及时通信,这个线程的修改会马上让其他线程知道这就是内存的可见性。

MESI:本质上使用了 cpu 的高速缓存一致性协议 ( jvm中 )

2)禁止指令重新排序

指令重排是和cpu有关系,每次都会被线程读到,加上volatile之后.cpu原来执行指令是一步一步顺序执行,但是现在cpu为了提高效率,它会把指令并发执行,第一个指令执行到一半的时候第二个指令可能就开始执行了,这叫做流水线式的执行,在这种新的架构设计基础上想充分利用这点优势,那么就要求你的编译器把你的源码编译完的之后之后可以进行下一个指令的重新排列。

3)DCL单例

单例就是保证你在jvm内存中永远只有某个类的一个实例,

a)饿汉式


packet com.lsj.demo
/**
  饿汉式
  类加载到内存当中,被实例化一个单例,jvm保障线程安全
  缺点: 不管用到与否,类 装载时就完成实例化
*/
public class Mgr01{
    private static final Mgr01 instance = new Mgr01();
    private Mgr01(){};
    public static Mgr01 getInstance(){
       return instance;
    }
    
    public static void main(String[] args){
        Mgr01 m1 = Mgr01.getInstance();
        Mgr01 m2 = Mgr01.getInstance();
        System.out.println(m1 == m2);
    }
}

b)静态方法初始化 (为了解决一开始就创建对象) —调用这个方法的时候就开始初始化对象

package com.lsj.demo;

public class Mgr02{
    private static final Mgr02 instance;
    
    static {
        instance = new Mgr02();
    }
    private Mgr01(){};
    public static Mgr01 getInstance(){
       return instance;
    }
    
    public static void main(String[] args){
        Mgr01 m1 = Mgr01.getInstance();
        Mgr01 m2 = Mgr01.getInstance();
        System.out.println(m1 == m2);
    }
    
}

c)懒汉式

/**
  lazy loading
  懒汉式
  虽然到达了按需初始化的目的,但却带来线程不安全的问题
  可以通过synchronized解决,但也带来效率下降
*/
public class Mgr03{
    private static Mgr04 instance;
    private Mgr04(){};
    public static synchronized Mgr03 getInstance(){
        if (instance == null){
            try{
                Thread.sleep(1);
            }catch(Exception e){
                e.printStace();
            }
            instance = new Mgr03();
        }
    }
    public static void main(String[] agrs) {
        for (int i=0;i<100;i++){
            new Thread(() -> {
                System.out.println(Mgr03.getInstance().hashCode());
            }).start();
        }
    }
}

d)双重检查

public class Demo1 {
    private static volatile Demo1 demo = null;
    private Demo1(){}

    public static Demo1 getInstance(){
        if (demo == null){
            synchronized (Demo1.class) {
                try {
                    Thread.sleep(1);
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
            if (demo == null) {
                demo = new Demo1();
            }
        }
        return demo;
    }
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                System.out.println(Demo1.getInstance().hashCode());
            }).start();
        }
    }
}

单例模式中的双重检查需不需要加 volatile ? 答案 : 需要

原因 : 不加volatile问题就会出现在指令重排上 编译器编译之后的指令分三步: ①给指令申请内存 ②给成员变量初始化 ③把这块内存的内容复制给instance,既然有这个值了你在另一个线程里头上来先去检查,你会发现这个值已经有了.加了volatile之后指令重排列就不允许存在,在这个时候一定是保证你初始化完了之后才会赋值给这个变量。

这个问题就涉及到了编译原理,所谓编译,就是把源代码“翻译”成目标代码——大多数是指机器代码——的过程。针对Java,它的目标代码不是本地机器代码,而是虚拟机代码。编译原理里面有一个很重要的内容是编译器优化。所谓编译器优化是指,在不改变原来语义的情况下,通过调整语句顺序,来让程序运行的更快。这个过程成为reorder。
JVM实现可以自由的进行编译器优化。而我们创建变量的步骤:
1、申请一块内存,调用构造方法进行初始化。
2、分配一个指针指向这块内存。
而这两个操作,JVM并没有规定谁在前谁在后,那么就存在这种情况:线程A开始创建SingletonClass的实例,此时线程B调用了getInstance()方法,首先判断instance是否为null。按照我们上面所说的内存模型,A已经把instance指向了那块内存,只是还没有调用构造方法,因此B检测到instance不为null,于是直接把instance返回了——问题出现了,尽管instance不为null,但它并没有构造完成,就像一套房子已经给了你钥匙,但你并不能住进去,因为里面还没有收拾。此时,如果BA将instance构造完成之前就是用了这个实例,程序就会出现错误了。
在JDK 5之后,Java使用了新的内存模型。volatile关键字有了明确的语义——在JDK1.5之前,volatile是个关键字,但是并没有明确的规定其用途——被volatile修饰的写变量不能和之前的读写代码调整,读变量不能和之后的读写代码调整!因此,只要我们简单的把instance加上volatile关键字就可以了。

4)使用jvm的内存模型保证线程安全而且为单例

 public class SingletClass{
      private SingletClass(){}
       private static class Singlet{
           private final  static SingletClass instance = new SingletClass();
       }
       public static SingletClass getInstance(){
          return Singlet.instance;   
       }

5)枚举

 public class SingletObject{
         private singletObject(){}
         private enum Singleton{
             INSTANCE;
             private final SingletObject instance;
             Singleton{
                 instance = new SingletObject();
             }
             public SingletObject getInstance(){
                 return instance;
             }
   }
         public static SingletObject getInstance(){
      return Singleton.INSTANCE.getInstance();
         }
   }

JVM关于synchronized的两条规定:

1)线程解锁前,必须把共享变量的最新值刷新到主内存中。

2)线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。

缺点:程序效率低,对计算机硬件资源是高开销动作。

synchronized和volatile比较图片

volatile不需要加锁,比synchronized更轻量级,不会阻塞线程,效率更高。

从内存可见性角度讲,volatile读相当于加锁,volatile写相当于解锁

volatile不具备“互斥性”,synchronized就具备“互斥性”。

何为互斥性图片

比方说当我们用synchronize修饰方法,当一个线程抢到锁执行该方法后另一个线程无法再抢到锁执行该方法,synchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,不能保证原子性。

强调:如果能用volatile解决问题,还是应尽量使用volatile,因为它的效率更高。

volatile不能保证原子性 那么什么是原子性 图片 什么又是原子性操作呢图片
A想要从自己的帐户中转1000块钱到B的帐户里。那个从A开始转帐,到转帐结束的这一个过程,称之为一个事务。在这个事务里,要做如下操作:

第一步:从A的帐户中减去1000块钱。如果A的帐户原来有3000块钱,现在就变成2000块钱了。

第二步:在B的帐户里加1000块钱。如果B的帐户如果原来有2000块钱,现在则变成3000块钱了。

如果在A的帐户已经减去了1000块钱的时候,忽然发生了意外,比如停电什么的,导致转帐事务意外终止了,而此时B的帐户里还没有增加1000块钱。那么,我们称这个操作失败了,要进行回滚。回滚就是回到事务开始之前的状态,也就是回到A的帐户还没减1000块的状态,B的帐户的原来的状态。此时A的帐户仍然有3000块,B的帐户仍然有2000块。

我们把这种要么一起成功(A帐户成功减少1000,同时B帐户成功增加1000),要么一起失败(A帐户回到原来状态,B帐户也回到原来状态)的操作叫原子性操作。


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 serialNumber = 0; //线程共享变量
    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
        }
        System.out.println(getSerialNumber());
    }
    public int getSerialNumber(){
        return serialNumber++;
    }
}
运行结果会发现可能会在不同的线程中,看到相同的数值,
疑问来啦,不对啊,上面是对变量serialNumber 进行自增操作,
由于volatile保证了内存可见性,
那么在每个线程中对serialNumber 自增完之后,
在其他线程中都能看到修改后的值啊,所以有10个线程,
获取的都应该是不同的值的呀。

这里面就有一个误区了,
volatile关键字能保证内存可见性没有错,但是上面的程序错在没能保证原子性。
可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。
那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

假如某个时刻变量serialNumber的值为10,
线程1对变量进行自增操作,线程1先读取了变量serialNumber的原始值,然后线程1被阻塞了;
然后线程2对变量进行自增操作,线程2也去读取变量serialNumber的原始值,
线程2会直接去主存读取serialNumber的值(volatile就是要直接从内存读取),
发现serialNumber的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

回到线程1接着进行加1操作,由于已经读取了serialNumber的值,
注意此时在线程1的工作内存中serialNumber的值仍然为10,
所以线程1对serialNumber进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。
    
那么两个线程分别进行了一次自增操作后,serialNumber只增加了1。
根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

文章来源:https://blog.csdn.net/weixin_45817985/article/details/134954562
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。