美文网首页
Android多线程编程 - volatile关键字

Android多线程编程 - volatile关键字

作者: BlueSocks | 来源:发表于2023-07-24 17:50 被阅读0次

前言

有时仅仅为了读/写一个或者两个实例就使用同步的话,显得开销过大;而volatile关键字为实例域的同步访问提供了免锁的机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域可能是被另一个线程并非更新的。在说到volatile关键字之前,我们需要先了解下内存模型的相关概念以及兵法编程中的3个特性:原子性、可见性和有序性。

1. Java内存模型

Java中的堆内存用来存储对象实例,堆内存是被所有线程共享的运行时内存区域,因此它存在内存可见性的问题。而局部变量、方法定义的参数则不会再线程之间共享,它们不会有内存可见性问题,也不受内存模型影响。Java内存模型定义了线程和主存之间的抽象关系:线程之间的共享变量存储在主存中,每个线程都有一个私有的本地内存,本地内存中存储了该共享变量的副本。需要注意的是本地内存是Java内存模型的一个抽象概念,其实并不真实存在,它涵盖了缓存、写缓冲区、寄存器等区域。Java内存模型控制线程之间的通信,它决定一个线程对主存共享变量的写入何时对另一个线程可见。

2. 原子性、可见性和有序性

2.1. 原子性

对基本数据类型变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行完毕,要么就不执行。现在看看下面的代码:

x = 3;//语句1
y = x;//语句2
x++;//语句3

在上面3个语句中,只有语句1是原子性操作,其它两个都不是原子性操作。语句2虽说很短,但它包含了两个操作,它先读取x的值,再将x的值写入工作内存。读取x的值以及将x的值写入工作内存这两个操作单拿出来都是原子性操作,但是合起来就不是原子性操作了。语句3包括3个操作:读取x的值、对x的值进行加1、向工作内存写入新值。通过这3个语句我们得知,一个语句含有多个操作时,就不是原子性操作,只有简单的读取和赋值操作才是原子性操作。

2.2. 可见性

这里的可见性指的是线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上能看到。当一个共享变量被volatile修饰时,它会保证修改的值立即被更新到主存,所以对其它线程时可见的。当有其它线程需要读取该值时,其它线程会去主存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,并不会立即被写入主存,何时被写入主存也是不确定的。当其他线程去读取该值时,此时主存中可能还是原来的旧值,这样就无法保证可见性。

2.3. 有序性

Java内存模型中允许编译器和处理器对指令进行重排序,虽然重排序的过程不会影响线程执行的正确性,但是会影响到多线程并发执行的正确性。我们知道,synchronized和Lock保证每个时刻只有一个线程执行同步代码,这相当于让线程顺序执行同步代码,从而保证了有序性。

3. volatile关键字

3.1. 保证可见性,禁止进行指令重排序

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  • 禁止进行指令重排序。

先看一段代码,假如线程1先执行,线程2后执行:

     //线程1
    boolean stop = false;
    while(!stop){
        doSomething();
    }
     
    //线程2
    stop = true;

很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。 为何有可能导致无法中断线程?每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。 但是用volatile修饰之后就变得不一样了:

  • 使用volatile关键字会强制将修改的值立即写入主存;
  • 使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效;
  • 由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

3.2. volatile不保证原子性

public class VolatileTest {
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final VolatileTest test = new VolatileTest();
        for (int i = 0; i < 10; i++) {
            new Thread() {
                @Override
                public void run() {
                    for (int j = 0; j < 100; j++) {
                    test.increase();
                }
                }
            }.start();
        }
        //如果有子线程就让出资源,保证所有子线程都执行完
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(test.inc);
    }
}

这段代码每次运行,结果都不一致。在前面已经提过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1、写入工作内存。也就是说,自增操作的这3个子操作可能会分割开执行。假如某个时刻变量inc的值为9,线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了。之后线程2对变量进行自增操作,也去读取变量inc的原始值,然后进行加1操作,并把10写入工作内存,最后写入主存。随后线程1接着进行加1操作,因为线程1在此前已经读取了inc的值为9,所以不会再去主存读取最新的数值,线程1对inc进行加1操作后inc的值为10,然后将10写入工作内存,最后写入主存。两个线程分别对inc进行了一次自增操作后,inc的值只增加了1,因此自增操作不是原子性操作,volatile也无法保证对变量的操作是原子性的。

4. 正确使用volatile关键字

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

  • 对变量的写操作不依赖于当前值
  • 该变量没有包含在具有其他变量的不变式中

第一个条件就是不能是自增自减等操作,上文已经提到volatile不保证原子性。 第二个条件我们来举个例子它包含了一个不变式 :下界总是小于或等于上界。

public class NumberRange {
    private volatile int lower, upper;
    public int getLower(){
        return lower;
    }
    public int getUpper(){
        return upper;
    }
    public void setLower(int value){
        if (value > upper){
            throw new IllegalArgumentException("...");
        }
        lower = value;
    }
    public void setUpper(int value){
        if (value < lower){
            throw new IllegalArgumentException("...");
        }
        upper = value;
    }
}

这种方式限制了范围的状态变量,因此将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全,从而仍然需要使用同步。否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3),这显然是不对的。 其实就是要保证操作的原子性就可以使用volatile,使用volatile主要有两个场景:

4.1. 状态标志

public class Shutdown {
    volatile boolean shutdownRequested;
    public void shutdown(){
        shutdownRequested = true;
    }
    
    private void doWork(){
        while (!shutdownRequested){
            //do something
        }
    }
}

4.2. 双重检查模式

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

在这里使用volatile会或多或少的影响性能,但考虑到程序的正确性,牺牲这点性能还是值得的。 DCL优点是资源利用率高,第一次执行getInstance时单例对象才被实例化,效率高。缺点是第一次加载时反应稍慢一些,在高并发环境下也有一定的缺陷,虽然发生的概率很小。 DCL虽然在一定程度解决了资源的消耗和多余的同步,线程安全等问题,但是他还是在某些情况会出现失效的问题,也就是DCL失效,在《java并发编程实践》一书建议用以下的代码(静态内部类单例模式)来替代DCL:

public class Singleton {

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

总结

与锁相比,Volatile 变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。如果严格遵循 volatile 的使用条件即变量真正独立于其他变量和自己以前的值 ,在某些情况下可以使用 volatile 代替 synchronized 来简化代码。然而,使用 volatile 的代码往往比使用锁的代码更加容易出错。本文介绍了可以使用 volatile 代替 synchronized 的最常见的两种用例,其他的情况我们最好还是去使用synchronized 。

相关文章

网友评论

      本文标题:Android多线程编程 - volatile关键字

      本文链接:https://www.haomeiwen.com/subject/wzhcpdtx.html