首页 > 基础资料 博客日记

Java 之 volatile 详解

2024-08-19 20:00:06基础资料围观166

Java资料网推荐Java 之 volatile 详解这篇文章给大家,欢迎收藏Java资料网享受知识的乐趣

目录

一. 前言

二. 可见性

2.1. 可见性概述

2.2. 内存屏障

2.3. 代码实例

三. 不保证原子性

3.1. 原子性概述

3.2. 如何解决 volatile 的原子性问题呢?

四. 禁止指令重排

4.1. volatile 的 happens-before 关系

4.2. 代码实例

五. volatile 应用场景

5.1. 状态标志

5.2. 一次性安全发布(one-time safe publication)

5.3. 独立观察(independent observation)

5.4. volatile bean 模式

5.5. 开销较低的读-写锁策略

5.6. 双重检查(double-checked)


一. 前言

    volatile 可以看做是轻量级的 synchronized,它只保证了共享变量的可见性,是Java虚拟机提供的轻量级的同步机制。在线程 A 修改被 volatile 修饰的共享变量之后,线程 B 能够读取到正确的值。Java 在多线程中操作共享变量的过程中,会存在指令重排序与共享变量工作内存缓存的问题。volatile 一共有三大特性,保证可见性不保证原子性禁止指令重排

二. 可见性

2.1. 可见性概述

    首先提一个JMM的概念,JMM是Java内存模型,它描述的是一组规范或规则,通过这组规范定义了程序中各个变量(实例字段,静态字段和构成数组对象的元素)的访问方式。JMM规定所有的变量都在主内存,主内存是公共的,所有线程都可以访问,线程对变量的操作必须是在自己的工作内存中。在这个过程中可能出现一个问题。

现在假设主物理内存中存在一个变量,他的值为7,现在线程A和线程B要操作这个变量,所以他们首先要将这个变量的值拷贝一份放到自己的工作内存中,如果A将这个值改为1,这时候线程B要使用这个变量但是B线程工作内存中的变量副本是7 不是新修改的1 这就会出现问题。

所以JMM规定线程解锁前一定要将自己工作内存的变量写回主物理内存中,线程加锁前一定要读取主物理内存的值。也就是说一旦线程A修改了变量值,线程B马上能知道并且能更新自己工作内存的值。这就是可见性。 

2.2. 内存屏障

volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现。

内存屏障又称内存栅栏,是一个 CPU 指令。在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止 + 特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。

2.3. 代码实例

设计思路:首先,我们新建一个类,里面有一个 number,然后写一个方法可以让他的值变成60,这时候在主线程中开启一个新的线程让他 3s 后将number值改为60,然后主线程写一个循环如果主线程能立刻监听到number值的改变则主线程输出改变后的值,此时说明有可见性。如果一直死循环,说明主线程没有监听到number值的更改,说明不具有可见性。

class MyData {
    public int number = 0;

    public void change() {
        number = 60;
    }
}

public class VolatileTest {
    public static void main(String[] args) {
        MyData myData = new MyData();
        new Thread(()->{
            System.out.println(Thread.currentThread().getName() + "number is :"+ myData.number);
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.change();
            System.out.println(Thread.currentThread().getName() + "has changed :" + myData.number);
        }, "A").start();
        while(myData.number == 0){

        }
        System.out.println(Thread.currentThread().getName() + "number is:" + myData.number);
    }
}

看一下结果:

结果是进入了死循环一直空转,说明不具有可见性。下面我们在number前面加上关键字volatile。 public volatile int number = 0;

证明能监控到number值已经修改,说明加上volatile具有可见性。

三. 不保证原子性

3.1. 原子性概述

    原子性指的是不可分割,完整性,也即某个线程正在做某个业务时不能被分割,要么同时成功,要么同时失败。

    为了证明 volatile 能不能保证原子性,我们可以通过一个案例来证明一下。首先我们在之前的MyData 类中加入一个方法 addplus() 能让number加1,然后我们创建20个线程,每个线程调用1000次 addplus()。看看结果,如果number是20000,那么他就能保证原子性,如果不是20000,那么就不能保证原子性。

class MyData{
    public static volatile int number = 0;
	
    public void change(){
        number = 60;
    }
	
    public void incre(){
        number++;
    }
}

public class VolatileDemo {
    public static void main(String[] args) {
        MyData data = new MyData();
		
        for (int i = 0; i < 20; i++) {
            new Thread(()-> {
                for (int j = 0; j < 1000; j++) {
                    data.incre();
                }
            }, String.valueOf(i)).start();
        }
		
        while(Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("number is: " + MyData.number);
    }
}

结果不是20000,说明不能保证原子性。 

不保证原子性的原因:number++ 这个操作一共有3步,第一步从主物理内存中拿到number的值,第二步 number + 1,第三步写回主物理内存。

假设一开始主物理内存的值为0,线程A、线程B分别读取主物理内存的值到自己的工作内存,然后执行加1操作。这时候按理说线程A 将1写回主物理内存,然后线程B 读取主物理内存的值然后加1变成2,但是在线程A写回的过程中突然被打断线程A挂起,线程B 将1写回主物理内存这时候线程A重新将1写回主物理内存最终主物理内存的值为1,两个线程加了两次最后值居然是1,出错了。 

3.2. 如何解决 volatile 的原子性问题呢?

    我们需要使用原子类,原子类是保证原子性的。加入一个 AtomicInteger 类的成员,然后调用他的getAndIncrement() 方法(就是把这个数加1,底层用CAS保证原子性)。原子类的具体讲解请参见《JUC之Atomic原子类》

运行结果:

这就解决了不保证原子性的问题。 

四. 禁止指令重排

    禁止指令重排又叫保证有序性。计算机编译器在执行代码的时候不一定非得按照你写代码的顺序执行。他会经历编译器优化的重排,指令并行的重排,内存系统的重排,最终才会执行指令,多线程环境更是如此,可能每个线程代码的执行顺序都不一样,这就是指令重排。

4.1. volatile 的 happens-before 关系

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

// 假设线程A执行writer方法,线程B执行reader方法
class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
    
    public void writer() {
        a = 1;              // 1 线程A修改共享变量
        flag = true;        // 2 线程A写volatile变量
    } 
    
    public void reader() {
        if (flag) {         // 3 线程B读同一个volatile变量
        int i = a;          // 4 线程B读共享变量
        ……
        }
    }
}

根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。
1. 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。
2. 根据 volatile 规则:2 happens-before 3。
3. 根据 happens-before 的传递性规则:1 happens-before 4。

因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。 

4.2. 代码实例

public class VolatileReSort {
    int a = 0;
    boolean flag = false;

    public void methodA() { // 线程A
        a = 1;
        flag = true;
    }

    public void methodB() { // 线程B
        if(flag) {
            a = a + 5;
            System.out.println("**********retValue:" + a);
        }
    }
}

假设现在线程A、线程B 分别执行上面两个方法,由于指令的重排序,可能线程A中的两条语句发生了指令重排,flag先变为true,这时候线程B突然进来判断flag为true,然后执行下面的最后输出结果为a = 5,但是也有可能先执行a = 1,那这样结果就是a = 6,所以,由于指令重排可能导致结果出现多种情况。现在加上volatile关键字,他会在指令间插入一条Memory Barrier,来保证指令按照顺序执行不被重排。

五. volatile 应用场景

5.1. 状态标志

    也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。

volatile boolean shutdownRequested;
......
public void shutdown() { shutdownRequested = true; }

public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

5.2. 一次性安全发布(one-time safe publication)

    缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。这就是造成著名的双重检查锁定【double-checked-locking】问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。

public class BackgroundFloobleLoader {
    public volatile Flooble theFlooble;
 
    public void initInBackground() {
        // do lots of stuff
        theFlooble = new Flooble();  // this is the only write to theFlooble
    }
}
 
public class SomeOtherClass {
    public void doWork() {
        while (true) { 
            // do some stuff...
            // use the Flooble, but only if it is ready
            if (floobleLoader.theFlooble != null) 
                doSomething(floobleLoader.theFlooble);
        }
    }
}

5.3. 独立观察(independent observation)

    安全使用 volatile 的另一种简单模式是定期发布观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

public class UserManager {
    public volatile String lastUser;
 
    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
}

5.4. volatile bean 模式

在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。

@ThreadSafe
public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;
 
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
 
    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }
 
    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }
 
    public void setAge(int age) { 
        this.age = age;
    }
}

5.5. 开销较低的读-写锁策略

    volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。 安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;
 
    public int getValue() { return value; }
 
    public synchronized int increment() {
        return value++;
    }
}

5.6. 双重检查(double-checked)

传统的单例模式,在单线程下其实是没有什么问题的,多线程条件下就不行了。

public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + " :构造方法被执行");
    }

    public static SingletonDemo getInstance() {
        if(instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }

    public static void main(String[] args) {
        for(int i = 0; i < 10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }
}

可以看出多线程下单例模式将会失效。我们通过DCL双重检查锁可以解决上述问题。 

public static SingletonDemo getInstance() {
	if(instance == null) {
		synchronized (SingletonDemo.class) {
			if(instance == null) {
				instance = new SingletonDemo();
			}
		}
	}
	return instance;
}

但是这种方式也有一定的风险。原因在于某一个线程执行到第一次检测,读取到的 instance 不为null 时,instance的引用对象可能没有完成初始化。instance = new SingletonDemo(); 可以分为以下3步完成:1. 分配对象内存空间;2. 初始化对象;3. 设置instance指向刚分配的内存地址,此时instance!=null。但是由于编译器优化可能会对2、3两步进行指令重排也就是先设置instance指向刚分配的内存地址,但是这时候对象还没有初始化,如果这时候新来的线程调用了这个方法就会发现instance != null 然后就返回 instance,实际上 instance 没被初始化,也就造成了线程安全的问题。为了避免这个问题我们可以使用 volatile 对其进行优化,禁止他的指令重排就不会发生上述问题了,给变量加上 volatile,如 private static volatile SingletonDemo instance = null;。 


文章来源:https://blog.csdn.net/mrluo735/article/details/134577489
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!

标签:

相关文章

本站推荐

标签云