volatile关键字了解吗?

3 篇文章 0 订阅

volatile关键字了解吗?

我们知道Java支持多个线程同时访问一个对象或对象的成员变量。每个线程都可以拥有这个变量的成员拷贝(虽然对象以及成员变量分配的内存是共享内存中的,但每个执行的线程还是会拥有一份拷贝。这样可以加速程序的执行,多核处理器就拥有这样的特性)。

Java语言规范第三版中对volatile的定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致性的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,如果一个字段被声明成volatile,Java线程的内存模型确保所有的线程看到这个变量的值是一致的。

volatile关键字修饰成员变量,任何对这个变量的访问都要从共享内存中获取,改变这个变量时,也要同步刷新回共享内存。这样任何线程对这个变量的操作对其他线程都是可见的。

volatile关键字是线程同步的轻量级实现,它有三个特性:

  1. 可以保证可见性。
  2. 不保证原子性。
  3. 禁止指令重排。
volatile是如何保证可见性的?

​ 由于JVM运行程序的实体是线程。每个线程的创建,JVM都会为其创建一个工作内存(也可以称为栈空间)。工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存中,主内存是共享内存区域,所有线程都可以访问。但是线程对变量的操作必须在工作内存中进行。

​ 首先将变量拷贝到自己的工作内存中,然后对变量进行操作,操作完成后再将变量写回主内存中。线程不能直接操作主内存中的变量,各个线程的工作内存中都存储着变量的副本拷贝,线程之间不能访问其他线程的工作内存,因此线程间的通信需要通过主内存来完成。

在这里插入图片描述
​ 图1-1 共享变量的状态示意图

  1. 当写一个volatile变量时,JMM(Java内存模型)会把线程对应的本地内存中的共享变量的值刷新回主内存。

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

    图中线程A首先执行写操作,将变量的值修改后并刷新回了主内存,接着线程B执行读操作,由于线程B的本地内存中变量的值是初始状态的值,而主内存中的值已经发生变化,此时,线程B就需要从主内存中获取变量的值。

当一个线程修改了主内存中的值,对于其他线程来说可以第一时间知道,并再接下来读取共享变量时会直接从主内存中获取,而不读取自己本地内存中的变量的值。

为什么volatile不保证原子性?

什么是原子性?完整性,不可分割,即某个线程在执行某个具体的业务时,中间不可以被打断,需要整体完整。

代码演示:

package com.lany;

public class volatileTest {
    public static void main(String[] args) {
        data data = new data();
        for (int j = 0; j < 10; j++) {
            new Thread(() -> {
                for (int i = 1; i <= 1000; i++) {
                    data.addNumber();
                }
            }, String.valueOf(j)).start();

        }
        /*
            判断当前活动的线程数是否大于2
         */
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "\t finally data:" + data.number);
    }
}

class data {
    volatile int number = 0;

    void addNumber() {
        this.number++;
    }
}

在这里插入图片描述

​ 图1-2 volatile不保证原子性代码执行结果

volatile无法保证原子性:上面代码中十个线程每个线程执行1000次i++操作,结果本应该是10000,但是最终的结果却是9211。这就说明

volatile不能保证原子性。为什么会这样呢?

假设此时number的值为100,线程1对变量进行i++操作,线程1读取到number的值后,准备进行操作时,发生了阻塞。这时线程2也对number进行i++操作,因为线程1只是读取到了number的值,并没有进行操作,所以线程2读取到的值还是100,并对number执行了+1操作,number变为101,然后写入到了工作内存中,这时线程1又开始进行+1操作,然而此时线程1读取到的值已经和主内存中的值不一致了,但是最后线程1也对number进行了+1操作,变为101,也写入到了主内存中,这就导致,两次的+1操作最终number的值只增加了1。

解决办法:

  1. 使用synchronized关键字。
  2. 使用lock。
  3. 可以使用java.util.concurrent.atomic包下的AtomicInteger类。

使用AtomicInteger类解决不保证原子性的问题的代码如下:

package com.lany;

import java.util.concurrent.atomic.AtomicInteger;

public class volatileTest {
    public static void main(String[] args) {
        data data = new data();
        for (int j = 0; j < 10; j++) {
            new Thread(() -> {
                for (int i = 1; i <= 1000; i++) {
                    data.addAtomicInteger();
                }
            }, String.valueOf(j)).start();

        }
        /*
            判断当前活动的线程数是否大于2
         */
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "\t finally data:" + data.atomicInteger);
    }
}

class data {
    int number = 0;

    AtomicInteger atomicInteger = new AtomicInteger(0);

    void addNumber() {
        number++;
    }

    void addAtomicInteger() {
        atomicInteger.getAndIncrement();
    }
}

指令重排:

计算机在执行程序的时候,为了提高性能,编译器和处理器常常会对指令进行重排序。

源代码–>编译器优化的重排–>指令并行的重排–>内存系统的重排–>最终执行的指令。

单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。

处理器在进行重排时必须要考虑指令之间的数据依赖性。多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预判。

为了实现Java的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

volatile实现禁止指令重排序优化的原理:

​ 由于编译器和处理器都能对指令进行重排序。在指令间插入一条内存屏障告诉编译器和CPU,不管什么指令都不能和这条内存屏障指令重排。也就是说插入内存屏障,就能禁止在内存屏障前后的指令重排优化。

JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的前面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。
    在这里插入图片描述
    ​ 图1-3 指令序列示意图
什么地方使用volatile?

多线程下的单例模式

单例模式在单线程情况下没有问题,但是在多线程的情况下会出现安全问题。一个线程在初始化对象还未完成时,另一个对象开始初始化对象,最终导致初始化多个对象。

代码演示:

package com.lany;

public class newObjectVolatileTest {
    static MyInstance myInstance;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                getData();
                System.out.println(Thread.currentThread().getName() + "\t " + myInstance);
            }, String.valueOf(i)).start();
        }
    }

    private static MyInstance getData() {
        if (myInstance == null) {
            myInstance = new MyInstance();
        }
        return myInstance;
    }
}

class MyInstance {
    int id;
    String name;

    public MyInstance() {
        this.id = 0;
        this.name = "zs";
    }
}

在这里插入图片描述
​ 图1-4 多线程下的单例模式执行结果

这时候为了避免这种情况,可以使DCL(Double Check Lock)双端检索机制,通过首先的第一个对象是否被初始化然后进入锁的代码块,后再检查一次是否已经初始化单例,这样可以有效的避免创建多个对象的问题。

代码如下:

package com.lany;

public class newObjectVolatileTest {
    static MyInstance myInstance;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                getData();
                System.out.println(Thread.currentThread().getName() + "\t " + myInstance);
            }, String.valueOf(i)).start();
        }
    }

    private static MyInstance getData() {
        /*
        使用DCL机制两次判断对象是否已经初始化。
        */
        if (myInstance == null) {
            synchronized (MyInstance.class) {
                if (myInstance == null) {
                    myInstance = new MyInstance();
                }
            }
        }
        
        return myInstance;
    }
}

class MyInstance {
    int id;
    String name;

    public MyInstance() {
        this.id = 0;
        this.name = "zs";
    }
}

在这里插入图片描述
​ 图1-5 使用DCL后的执行结果

使用DCL虽然实现了同步,但是在多线程的情况下还是会有线程安全问题。

我们都知道JVM在初始化对象的时候至少做了一下3步:

  1. 为对象分配内存空间
  2. 初始化对象
  3. 将对象指向分配的内存空间(此时对象已不为null)

以为存在指令重排的优化,因此最终的执行顺序可能不为1,2,3而变成了1,3,2,当先执行了第三步时,对象已不为null,但是对象却未被初始化,此时另一个线程开始判断发现对象已不为null,直接return,但是对象却还未被初始化,这就会发生错误。

所以为了防止这种很小的概率才会发生的问题,可以为对象添加一个volatile关键字,禁止指令重排,这样就解决了这种问题。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值