Java多线程学习(二)- 详解Java中volatile关键字作用

Java内存模型中的三个特性

在了解volatile相关特性之前,先来了解一下Java内存模型中的原子性、可见性和有序性这三个特征。了解这三个特征是为了更好的理解后面所讲的volatile相关特性。

  • 原子性(Atomicity): 原子是世界上的最小单位,具有不可分割性。原子操作是指一个操作是不可中断的,要么全部执行成功要么全部执行失败。即使在多线程的情况下,一个操作一旦开始,就不会被其他线程所干扰。
    那么在上面提到的“操作”两字究竟是什么操作呢? 其实这个操作指的是虚拟机的字节码指令操作。
    具体虚拟机有哪些字节码指令可以去网上学习,这些指令操作是相当底层的,可以作为扩展知识面掌握下。
    我们大致可以认为基本数据类型的访问读写都是具备原子性的(例外就是lang和double的非原子性协定)。
    例如:num=0;(num非long和double类型)
    这个操作是不可分割的,那么我们可以说这个操作是原子操作。
    再例如:num++;
    看上去只有一行代码,但是这句代码在Class文件中却是由四条字节码指令构成的。他不是一个原子操作,至于为什么不是?将会在下文中的示例中去解释。
    非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。java的concurrent包下提供了一些原子类,我们可以通过阅读API来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。
  • 可见性(Visibility): 可见性是指当一个线程修改了共享变量的值,其他线程能够得知这个修改,也就是说其他线程对这个修改是可见的。
    可见性的实现方式:
    Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。无论普通变量还是volatile变量都是如此。
    普通变量与volatile变量的区别是:volatile的特殊规则保证了新值能够立即同步到主内存,以及每次使用前立即从主内存刷新。因此可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
    除了volatile之外,Java还有两个关键字可以实现可见性,即synchronized和final。
  • 有序性(Ordering): Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值的操作的顺序与程序代码中的执行顺序一致。这就是Java内存模型中描述的所谓的“线程内表现为串行的语义”。
    例如:a=1;b=2;
    这两句代码,变量a的赋值操作写在前面,变量b写在后面。但是在执行的时候,就有可能变量b先执行赋值操作。这就是指令重排序。
    使用volatile关键字是可以禁止指令中排序的。而synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock
    操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。

关键字Volatile的特殊规则

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是它并不容易完全被正确、完整的理解,以至于很多程序员都习惯不去用它。了解volatile变量的特性对了解多线程的操作很有意义。
Java内存模型对volatile专门定义了一下特殊的访问规则,当一个变量定义为volatile之后,它将具备两个特性,

保证该变量对所有线程的可见性。

关于volatile变量的可见性,经常被误解,认为一下描述成立:“volatile变量对所有线程时立即可见的,对volatile变量所有写操作都能立刻反应到其他线程之中,也就是说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是安全的”。这句话的论据部分没有错,但是结论有问题。
不要忘记了在Java里面的运算并非原子操作,这导致volatile变量的运算在并发的情况下一样是不安全的。通过下面的示例来说明原因

示例

/**
 * volatile变量自增运算测试
 */
public class VolatileTest {

    public static volatile int num = 0;

    /**
     * 自增1
     */
    public static void increase(){
        num++;
    }
    //  创建线程数量
    private static final int THREADS_COUNT = 30;

    public static void main(String[] args) {
    	// 先创建一个容量为 30 的 Thread 数组 
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
        	// 对每一个 Thread 进行实例化,并使用匿名内部类实现 run 方法
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                   // 每个线程调用1000 次自增方法
                    for (int j = 0; j < 1000; j++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
		
		//如果在IDEA里面进行调试的话,这里的判断条件改为2,因为IDEA会启动一个 Monitor Ctrl-Break 线程。
        while (Thread.activeCount() > 1){
            Thread.yield();
        }
        System.out.println(num);
    }
}

这段代码发起了30个线程,每个线程对num变量进行了1000次自增操作,如果这段代码能够正确并发的话,那么最后的输出结果应该是30000。但是输出结果并不是我们期望的这样,而且基本上每次运行都会得到不同的结果。
这是为什么呢?问题就出在 “num++” 之中,这句代码看似只有一行,但是在Class文件中是由4条指令构成的。分别是下面这四条:

  • getstatic
  • iconst_1
  • iadd
  • putstatic

通过查询虚拟机字节码指令表得知,当getstatic指令把num的值压入操作栈顶时,volatile保证了num的值是正确的,但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经把num的值加大了,而呆在操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的num值同步回主内存中。
所以这条代码不是一个原子操作。所以volatile变量的运算在并发的情况下一样可能是不安全的。

禁止指令重排序。

有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障

Volatile内存原理

在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
在这里插入图片描述
当对 volatile 变量进行读写的时候,每个线程先把变量从主内存中拷贝到自己的工作内存中。在修改完后,将新值立即推送到主内存中去。而其他线程想要使用这个变量时,必须先从主内存中更新这个变量。

Volatile性能

volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过大多数场景下volatile的总开销要比锁低。

拓展:使用AtomicInteger改善上面的变量自增运算测试程序

public class AtomicTest {

    public static AtomicInteger num = new AtomicInteger(0);
    
    /**
     * 自增1
     */
    public static void increase(){
        num.incrementAndGet();
    }
    
    //  创建线程数量
    private static final int THREAD_COUNT = 30;

    public static void main(String[] args) {
  	    // 先创建一个容量为 30 的 Thread 数组 
        Thread[] threads  = new Thread[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++) {
            // 对每一个 Thread 进行实例化,并使用匿名内部类实现 run 方法
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
             	    // 每个线程调用1000 次自增方法
                    for (int j = 0; j < 1000; j++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println(num);
    }

AtomicInteger位于java.util.concurrent包下,使用了AtomicInteger代替了int后,程序输出了正确的结果,这一切都要归功于incrementAndGet方法的原子性。关于java.util.concurrent包下更多的内容会在后面的文章逐步学习。


技 术 无 他, 唯 有 熟 尔。
知 其 然, 也 知 其 所 以 然。
踏 实 一 些, 不 要 着 急, 你 想 要 的 岁 月 都 会 给 你。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值