Java 多线程同步:volatile 关键字

多线程基础知识

Java 内存模型

Java 中的堆内存用来存储对象的实例,堆内存是被所有线程共享的运行时内存区域,因此,它存在可见性的问题。而局部变量、方法定义的参数则不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

Java 内存模型定义了线程和主存之间的抽象关系:线程之间的共享变量存储在主存中,每个线程都有了一个私有的本地内存,本地内存中存储了该线程共享变量的副本。
JMM
线程A 与线程B 之间若要通信的话,必须要经历:

  • 线程A 把本地(工作)内存中更新过的共享变量刷新到主内存中去。
  • 线程B 到主存中去读取线程A 之前已更新过的共享变量。

举例:

int i=3;

执行线程必须先在自己的工作线程中对变量i所在的缓存进行赋值操作,然后再写入主存当中,而不是直接将数值3写入主存当中。

高速缓存存储器

高速缓存结构

在这里插入图片描述

现代处理器的处理能力要远胜于主内存(DRAM)的访问速率,主内存执行一次内存读/写操作需要的时间,如果给处理器使用,处理器可以执行上百条指令。为了弥补处理器与主内存之间的差距,硬件设计者在主内存与处理器之间加入了高速缓存(Cache)。

处理器执行内存读写操作时,不是直接与主内存打交道,而是通过高速缓存进行的。高速缓存相当于是一个由硬件实现的容量极小的散列表,这个散列表的key是一个对象的内存地址,value 可以是内存数据的副本,也可以是准备写入内存的数据。

高速缓存内部结构

在这里插入图片描述
缓存条目划分为 TagData BlockFlag 三个部分:

  1. Tag

包含了与缓存行中数据对应的内存地址的部分信息(内存地址的高位部分比特)

  1. Data Block

Data Block 也叫缓存行(Cache Line),是高速缓存与主内存之间数据交换的最小单元,可以存储从内存中读取的数据,也可以存储准备写进内存的数据。

  1. Flag

Flag 用于表示对应缓存行的状态信息

Java 线程调度原理

在任意时刻,CPU 只能执行一条机器指令,每个线程只有获取到CPU的使用权后,才可以执行指令。也就是在任意时刻,只有一个线程占用CPU,处于运行的状态多线程并发运行实际上是指多个线程轮流获取CPU使用权,分别执行各自的任务。多线程并发运行实际上是指多个线程轮流获取 CPU 使用权,分别执行各自的任务。线程的调度由JVM负责,线程的调度是按照特定的机制为多个线程分配 CPU 的使用权。

线程调度模型分为两类:分时调度模型抢占式调度模型

  • 分时调度模型

分时调度模型是让所有线程轮流获取 CPU 使用权,并且平均分配每个线程占用 CPU 的时间片。

  • 抢占式调度模型

JVM 采用的是抢占式调度模型,也就是先让优先级高的线程占用CPU,如果线程的优先级都一样,那就随机选择一个线程,并让该线程占用CPU。也就是如果我们同时启动多个线程,并不能保证它们能轮流获取到均等的时间片。如果我们的程序想干预线程的调度过程,最简单的办法就是给每个线程设定一个优先级。

多线程安全特性

原子性

对基本数据类型变量的读取和赋值操作是原子性操作,即 对数据的操作是一个独立的、不可分割的整体,这些操作是不可被中断的,要么执行完毕,要么就不执行。

JMM(Java 虚拟机内存模型)来直接保证的原子性变量操作有 read、load、use、assign、store、write 六个操作。

  1. read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
  2. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
  3. use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
  4. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
  5. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作
  6. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入到主内存的变量中

所以大致认为基本数据类型访问、读写都是具备原子性的。

实例:

  x=3;  // 原子操作
  y=x;  // 不是原子操作
  x++;  // 不是原子操作

分析:

  1. y=x 包含两个操作,它先读取x的值,再将x的值写入工作内存。读取x的值以及将X的值写入工作内存这两个操作单拿出来都是原子性操作,但是合起来就不是原子性操作。
  2. x++ 包含3个操作:读取x的值、对x的值进行加1、向工作内存写入新值。

小结:

  • 一个语句含有多个操作时,就不是原子性操作,只有简单地读取和赋值(将数字赋值给某个变量)才是原子性操作。

  • 如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。

可见性
// 线程B已经将flag的值改变,但是线程A并没有及时的感知到,导致一直进行死循环
public class TestDemo{
    public static boolean flag=false;
    
    public static void main(String[] args){
        new Thread(new Runnable{
            public void run(){
                while(!flag){}
                System.out.println("threadA end");
            }
        }).start();
        
        new Thread(new Runnable{
            public void run(){
                flag=true;
                System.out.println("threadB end");
            }
        }).start()
    }
}

输出结果为:threadA一直在运行
threadB end


public class TestDemo{
    // flag 变量加上volatile 关键字修饰
    public static volatile boolean flag=false;
    
    public static void main(String[] args){
        new Thread(new Runnable{
            public void run(){
                while(!flag){}
                System.out.println("threadA end");
            }
        }).start();
        
        new Thread(new Runnable{
            public void run(){
                flag=true;
                System.out.println("threadB end");
            }
        }).start()
    }
}

输出结果为:
threadA end
threadB end

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

通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中,因此可以保证可见性。

有序性

程序执行的顺序按照代码的先后顺序执行

先了解一下概念:

指令重排序 : 处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

  1. 单线程情况下
int i = 0; 
boolean flag = false;
i = 1;       // 语句1 
flag = true; //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序。

比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。

但是有依赖关系的语句不会进行重排序,如下面求圆面积的代码:

举例:

double pi = 4.14   //语句1
double r = 1.0     //语句2
double area = pi * r * r   //语句3

程序的执行顺序只有下面这2个形式:语句1->语句2->语句3和语句2->语句1->语句3,因为语句1和语句3之间存在依赖关系,同时/语句2和语句3之间也存在依赖关系。因此最终执行的指令序列中语句3不能被重排序到语句1和语句2前面。

  1. 多线程情况下
/线程1:
context = loadContext();   //语句1
inited = true;             //语句2

//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

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

volatile 关键字

当一个共享变量被volatile修饰之后,具备两个含义:
一个是 线程修改了变量的值时,变量的新值对其他线程是立即可见的。就是不同线程对这个变量进行操作时具有可见性。
另一个是 禁止使用指令重排序(重排序是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分为两类:编译器重排序和运行期重排序,分别对应编译时运行时环境)。

volatile 保证可见性

当共享变量被 volatile修饰后,在多线程环境下,当一个线程对它进行修改值后,会立即写入到内存中,然后让其他所有持有该共享变量的线程的工作内存中的值过期,这样其他线程就必须去内存中重新获取最新的值,从而做到共享变量及时可见性。

volatile 保证有序性

volatile 关键字能禁止指令重排序,因此volatile 能保证有序性。volatile关键字禁止指令重排序有两个含义:一个是当程序执行到volatile变量的操作时,在其前面的操作已经全部执行完毕,并且结果会对后面的操作可见,在其后面的操作还没有进行;在进行指令优化时,在volatile变量之前的语句不能在volatile变量后面执行;同样,在volatile变量之后的语句也不能在volatile变量前面执行。

volatile 不保证原子性

下面通过一段代码来验证下:

public class TestDemo{
    public static volatile int count = 0;
    
    public static void increase() {
        count++;
    }
    
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                    System.out.println(Thread.currentThread().getName() + " count = " + count);
                }
            }).start();
        }
    }
}

运行结果:
Thread-1 count = 13202
Thread-2 count = 24664
Thread-0 count = 24990
Thread-3 count = 42709
Thread-4 count = 44707
Thread-6 count = 60874
Thread-7 count = 62080
Thread-5 count = 65813
Thread-8 count = 85714
Thread-9 count = 80792

上面结果运行与我们期望结果(count=100000)不一致,代码中 count 变量加上volatile 修饰,在多线程运行情况下,能保证变量可见性。但是increase()方法中count++ 这个自增操作不是原子性。所以造成结果与期望不一致。

解决方法: 给 increase()方法 加上Synchronized 关键字

public static synchronized void increase() {
    count++;
}

运行结果:
Thread-0 count = 24499
Thread-1 count = 29007
Thread-2 count = 39748
Thread-3 count = 40668
Thread-4 count = 59913
Thread-5 count = 60381
Thread-6 count = 89032
Thread-8 count = 91704
Thread-7 count = 97795
Thread-9 count = 100000

最终结果 count 最后会增加到100000,达到了预期效果。

正确使用volatile 关键字

volatile 关键字在某些情况下的性能要优于synchronized,注意volatile 关键字是无法替代synchronized关键字的,因为volatile 关键字无法保证操作的原子性。

具备以下两个条件:

  • 对变量的写操作不会依赖于当前值

不能是自增、自减等操作

  • 该变量没有包含在具有其他变量的不变式中

它包含了一个不变式:下界总是小于或等于上界

使用场景
  • 双重检查模式(DCL)
public class Singleton{
    private volatile static Singleton instance=null;
    public static Singleton getInstance(){
        if(instance==null){
            synchronized(this){
                if(instance==null){
                    instance=new Singleton();
                }
            }
        }
        return instance;
    }
}

getInstance 方法中对Singleton进行了两次判空,第一次是为了不必要的同步,第二次是只有在Singleton等于null的情况下才创建实例。

小结:
与锁相比,volatile变量是一种简单同时是非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。如果严格遵循volatile使用条件,即变量真正独立于其他变量和自己以前的值,在某些情况下可以使用volatile 代替synchronized 来简化代码。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值