栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

java内存模型的角度分析volatile关键字

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

java内存模型的角度分析volatile关键字

多任务处理

多任务处理的背景:

  1. 计算机的运算能力增强。
  2. 计算机的运算速度与它的存储和通信子系统的速度差距太大,大量的时间都花费在磁盘I/O、网络通信或者数据库访问中上。

我们不希望处理器在大部分时间里都处于等待其他资源的空闲状态,计算机多任务处理应运而生。

TPS

每秒事务处理数(Transactions Per Second,TPS)是重要的指标之一,它代表着一秒内服务端平均能响应的请求总数。

TPS与程序的并发能力有着非常密切的关系。对于计算量相同的任务,程序线程并发协调得越有条不紊,效率自然就会越高;反之,线程之间频繁争用数据,互相阻塞甚至死锁,将会大大降低程序的并发能力。

硬件的效率与一致性
  1. 让计算机并发执行若干个运算任务。
  2. 更充分地利用计算机处理器的效能。

以上两条的复杂性来源于绝大多数的运算任务都不可能只靠处理器“计算”就能完成。

处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作就是很难消除的。(无法依靠寄存器来完成所有的运算任务)。

高速缓存和乱序执行优化(类似于java虚拟机的即时编译器中的指令重排序)是处理器优化的两种方式:

高速缓存

计算机的存储设备与处理器的运算速度有着几个数量级的差距,计算机系统都会加入一层或多层读写速度尽可能接近处理器运算速度的高度缓存(Cache)来作为内存与处理器之间的缓冲。

将运算需要的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

处理器、高速缓存、主内存之间的交互关系
缓存一致性

高速缓存的引入给计算机系统带来了更高的复杂度,并引入一个新的问题:缓存一致性(Cache Coherence)。

多个处理器的运算任务都涉及同一块主内存区域时,可能导致各自的缓存数据不一致。同步回到主内存时该以谁的缓存数据为准呢?

各处理器访问缓存时都需要遵循一些协议,按照协议来操作,这类协议有:MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly、Dragon Protocol等。

内存模型

在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象

乱序执行优化
  1. 处理器内部的运算单元尽量被充分利用。
  2. 代码乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证结果与顺序执行的结果是一致的。
  3. 顺序性不能靠代码的先后顺序来保证。
  4. 该处理器的优化策略类似于java虚拟机的即时编译器中的指令重排序优化。
Java内存模型 主内存和工作内存

java内存模型的主要目的是定义程序中各种变量的访问规则,即关注把变量值存储到内存和从内存中取出变量值这样的细节。

此处的变量与java编程中的变量不同,它包括了实例字段、静态字段、构成数组对象的元素;不包括局部变量和方法参数

局部变量与方法参数为线程私有,不存在竞争。

线程、工作内存、主内存之间的交互关系

java内存模型的内存划分与java内存区域中的java堆、栈、方法区并不是同一个层次的划分,两者没有任何关系。可以类比:

java内存模型java内存区域物理硬件
主内存java堆中的对象实例数据部分物理内存
工作内存虚拟机栈中的部分区域寄存器和高速缓存
内存间交互操作

内存间交互操作

read和load sotre和write是两个要求按顺序执行,但不要求连续执行的操作。

对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。

java内存模型规定了在执行上述8种基本操作必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存中读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
volatile型变量的特殊规则

关键字volatile可以说是java虚拟机提供的最轻量级的同步机制。

当一个变量被定义成volatile之后,它将具备两项特性:

  1. 保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
    • 普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。
    • 比如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对主内存进行读取操作,新变量值才会对线程B可见。
  2. 禁止指令重排序优化
    • 普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
    • 因为在同一个线程的方法执行过程中无法感知到这点,这就是java内存模型中描述的所谓“线程内表现为串行的语义(Within-Thread As-If-Serial Semantics)”
线程可见性

请判断以下说法是否正确?

volatile变量对所有线程是立即可见的,对volatile变量的所有写操作都能立刻反映到其他线程之中。换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是线程安全的。

论据部分没错,但是不能得出线程安全的结论,volatile变量在各个线程的工作内存中是不存在一致性问题的,但是java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的,以下代码可以解释原因:

先来看一个Thread.activeCount()的用法:

Thread.activeCount()方法的作用是返回当前线程的线程组中活动线程的数目。

public class ThreadActiveCountTest {
    public static void main(String[] args) {
        System.out.println(Thread.activeCount());
        //打印当前线程组的线程
        Thread.currentThread().getThreadGroup().list();
    }
}
output:
2
java.lang.ThreadGroup[name=main,maxpri=10]
    Thread[main,5,main]
    Thread[Monitor Ctrl-Break,5,main]

可以得出结论,在idea中执行Thread.activeCount()的默认值为2

public class VolatileTest {
    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREAD_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        //在idea中Thread.activeCount() = 2代表当前线程线程组中的其他线程已经执行完毕
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("多线程累加结果:"+race);
    }
}
//output:
多线程累加结果:199475

使用javap -v -c VolatileTest.class 反编译后increase方法如下图所示,一共包含4个指令:

累加结果199475小于200000的预期结果,原因就出在race++;这句代码中:

  1. getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的。
  2. 但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经把race的值改变了,而操作栈顶的值就变成了过期的数据。
  3. 所以当putstatic指令执行后就可能把较小的race值同步回主内存中。

即使编译出来的只有一条字节码,也并不意味着执行这条指令就是一个原子操作。如果是编译执行,一条字节码指令也可能转化成若干条本地机器码指令。

由于volatile变量只能保证可见性,在不符合以下两条规则的场景中,仍然需要通过加锁来保证原子性:

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  2. 变量不需要与其他的状态变量共同参与不变约束。
volatile可见性的应用场景
volatile boolean shutdownRequested;

public void shutdown(){
    shutdownRequested = true;
}

public void doWrik(){
    while(!shutdownRequested){
        //代码业务逻辑
    }
}

该场景很适合使用volatile变量来控制并发,当shutdown()方法被调用时,能保证所有线程中执行的doWork()方法都立即停下来。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/921653.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号