Java学习笔记(多线程):volatile关键字

本文参考以下资料:
https://www.cnblogs.com/dolphin0520/p/3920373.html
https://time.geekbang.org/column/article/10772


1、导言

这篇文章对Java的volatile关键字进行较详尽的探讨,讨论其实现机制,使用的注意事项等。

volatile与多线程编程有关,用来修饰一变量,使其简单达到synchronized修饰的效果。这样简单一句话似乎能理解volatile,但是volatile和synchronized等还是有些区别,实际使用过程中如果没有全面的了解,在多线程的环境下可能会引起可怕的灾难。下面就进行详细讲解volatile的实现原理。



2、计算机的写与读,CPU的高速缓存

2.1、CPU的高速缓存机制为什么多线程下数据会不一致

当我们执行x = x + 1;时,有没有想过计算机内部发生了些什么?数据是如何流转的?

这么一个简单的赋值语句实际会经历以下过程。

  1. 从内存中读取x的值到CPU的高速缓存中。
  2. cpu将高速缓存中的x进行+1操作,然后将结果写入高速缓存中。
  3. 高速缓存将结果返回到内存中。

也就是说,内存和CPU之间还有一个高速缓存。以上过程在单线程中不会有任何问题,但是在多线程环境下,每个线程都有自己的一个CPU,都有自己的一个高速缓存,这时候就有数据不一致的情况出现。

比如,一开始,x = 0。线程1执行x = x + 1,当执行完第二步,计算结果存储在CPU 1的高速缓存中,还没有将计算结果存入内存中。线程2突然插进来,也执行这条语句,并且一次性执行完三步,主存中x = 1。最后终于轮到线程1把最后一步执行完,结果最终结果是x = 1。我们预期x经过两次自加操作后应该等于2,但这种情况就与预期不符。

2.2、解决方法

早期的CPU是在总线上加Lock锁来解决这个问题。但是这种方法很影响效率。因为主存和其它CPU交互也要通过总线。加锁就意味着那段时间只有这个CPU能访问主存,其它的CPU都得等着。

之后又出现了缓存一致性协议,最出名的就是Intel 的MESI协议。该协议可以保证在所有CPU的高速缓中,对某个变量的缓存总是一致的。该协议的做法是:当有CPU将据写入主存时,会发送一个信号给其它的CPU,告诉这些CPU你们的高速缓存中关于该变量值应该更新了。然后其它CPU就会从主存中取最新值,刷新自己的高速缓存。



3、并发编程中的三个概念

并发编程中有三个概念,原子性,有序性,可见性。如果系统完全符合这三条性质,那系统就不会有并发危险。只要有一条不符合,那就可能会有并发危险。

3.1、原子性

即多个操作必须被当做一个操作来执行。这些操作要么一次性执行完,要么就全部不执行。

3.2、有序性与指令重排序

有序性是指代码执行的顺序与写代码的先后顺序是一致的。

比如下面的语句,

int a = 0; //语句1
a = a + 1; //语句2
int b = 1; //语句3
b = a + b; //语句4

如果系统对上面语句的执行顺序每次都是

语句1
语句2
语句3
语句4

那代码的执行就是具有有效性。

但实际上,系统为了优化代码的执行(流水线),往往会对代码进行重排序。只要保证最终结果一致即可。

系统保证重排序后的计算结果和原来一致的手段一般是依靠数据之间的依赖。若语句a需要用到语句b的计算结果,那语句a不可以被移到语句b之前执行。

比如上述例子中的语句2和语句3互不依赖,那执行顺序完全可以变成

语句1
语句3
语句2
语句4

但是语句2依赖语句1的计算结果,所以语句1必须在语句2之前执行。


3.2.1、多线程下指令重排序可能的问题

指令重排序在单线程下不会出现问题,但是在多线程环境下就有可能出错。比如下面的代码,其中context和init是共享变量,线程1负责初始化前的准备,线程2负责初始化。

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

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

因为context和inited不关联,有可能线程1中inited = true;先执行,然后线程2发现inited = true,以为初始化准备已经好了,于是跳出循环开始执行doSomethingwithconfig(context);,但context还没初始化,程序报错。


3.3、可见性

当多个线程同时访问一个变量时,当其中一个线程对这个修改了这个变量的值时,其他的所有线程必须立刻知道这个改变。

实现的方案有多个,比如要求每次对共享变量的读取都直接从内存中读取,而不是从高速缓存中。或者对共享变量改变值以后必须将结果立刻刷新到内存中,然后以某些方式通知到使用到这个共享变量的线程。




4、Java内存模型

Java内存模型规定,所有的模型都应当存的主存中(前面的内存),每个线程都拥有一个自己的工作内存(并非前面的高速缓存)。线程对变量的所有计算都必须在工作内存中完成最后才能将计算结果放到主存中。并且线程不能直接访问另一个线程的工作区中的内容。

在这里插入图片描述

比如x = 10;必须在线程中的工作内存中赋值,然后再将工作内存中的值赋值到主存中,而不是直接赋值给主存。

综上所述,Java内存模型可以与上面提到的并发编程具有同样的存储结构,下面我们就来探讨Java内存模型对原子性,可
见性和有序性有着怎样的支持。

4.1、Java内存模型对原子性的支持

在Java中,对基本数据类型的变量的读取和赋值操作是具有原子性的。这句话不是那么容易理解的,下面举例子分析,哪些语句是具有原子性的?

x = 1;
x = x + 1;
x++;
x = y;

除了1以外,其他的语句都不具有原子性。

x = x + 1;实际上是先从主存中读取x到工作内存中,然后对x + 1后将结果赋值到工作内存中,然后将工作内存中的数据刷新到主存中。这一过程并没有直接对x赋值或读取。x++;x = y;也是同理。

这里的原子性其实是指指CPU内部的原子性。JVM读取数据计算,要经过线程的工作区,然后到CPU,但是在CPU内部也要经过多级缓存,再到寄存器,最后才到CPU进行运算。x = 1;具有原子性也只是保证多级缓存->寄存器->CPU->寄存器->多级缓存在CPU中能一次性执行完。但是到JVM内某个线程中,工作区->主存这一过程不能保证是一次性完成,不能保证中途其他线程不会插进来(这部分其实是可见性需要做的事)。

在这里插入图片描述

上述情况在Java中支持原子性,如果想要获得更大范围的原子性则要考虑synchronized和Lock来实现。

4.2、Java内存模型对可见性的支持

可见性的理解主要要和原子性对比着看,明白两者是对那些过程做出保障。

Java提供volatile关键字来保证可见性。具体做法是当volatile修饰的变量值改变后,会被立刻更新到主存中,而其他线程需要读取这个变量的时候,必须到主存中读取。

普通变量的值被修改后会放到线程工作内存中,不能保证数据立刻会送到主存中。

另外,synchronized和Lock也可以保证可见性。具体做法是保证共享变量在同一时间一定只有一个线程在使用,并且当线程释放锁之前,共享变量的最新值会被刷新到主存中。

4.3、Java内存模型对有序性的支持

Java 内存模型保证一下八种情况具有有序性。这里要强调一点,有序性只是保证系统重排序的时候需要遵守一定的原则,不能乱排序。

对于单个CPU的计算机来说,同一时间只能执行一条代码。这条代码执行之后,有一个缓冲区会存储接下来需要执行的代码,系统只能对这个缓存区中的代码进行重排序,所以说如果是多个CPU,有着各自的代码缓存区,这些缓冲区之间不具有有序性。

下面所说的Java内存模型具有的有序性也是只单个CPU中的,已在这个CPU的代码缓冲区中的代码具有有序性。感觉是这样,不然第3条和第6条都明显不成立。

Java内存模型对支持下列原则的有序性。

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
这里我不太理解,按道理即使是一个线程内,重排序还是有可能发生,这个原则应该是说保证数据依赖的代码顺序执行。
  1. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作。
这里说的应该是线程要对一个已锁的锁进行lock操作时,lock语句必须在这个锁的unLock之后执行。
  1. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。
不是很理解,这里指的是重排序指令缓存区中的指令中,volatile变量的写操作一定放在读操作之后吗?
  1. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
  2. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。
  3. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
  4. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
  5. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始



5、 volatile关键字保证三大特性的那些特性

最后开始讲解volatile关键字。volatile与并发编程相关,我们只要知道了volatile修饰的变量具有并发三大特性的哪些特性,就能很好的理解volatile关键字。

结论:volatile只支持可见性,不支持原子性。当然有序性也支持,不过有序性指的是系统层面的事,Java的都支持有序性,当然也包括volatile修饰的变量的读写操作。

volatile的可见性具体是这样的。前面提到,普通的变量在读取和写入的时候不是直接在主存中操作,CPU与主存之间还有一个工作区。

当线程1对变量a写入时,计算值到工作区,还未到主存。这时线程2来主存读取的就是旧值(我们期望读到最新值)。这种情况就会有误差,不具有可见性。

被volatile修饰的变量在读取与写入的时候会被强制要求立刻写入到主存,而读取的时候也是强制要求必须从主存中读取,不可从工作区中读取。这样就保证了可见性,volatile修饰的变量的修改总是能被其他线程知道。

但volatile的可见性是以性能为代价的,因为所谓的工作区实际上是高速缓存,在高速缓存上读写肯定是比在内存中读写要快很多。所以不是很清楚volatile的特性和自己的需求,不要轻易使用volatile。

5.1、原子性可能产生的错误

volatile不能保证原子性,即一行代码在计算机中执行的步骤不能保证一次性完成。比如i++,系统不能保证读取i,i+1,最新值送回主存这样三步能一次性完成。

为了更好地理解这个,下面用一段代码来分析。

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 < 100; i++){
            new Thread(() -> {
                for(int j = 0; j < 10000; j++) {
                    test.increase();
                    System.out.println(test.inc);
                }
            }, "线程" + i).start();
        }
    }
}


上面的代码,最终输出结果是多少?

初学者会以为最终输出结果应该是1000000。但是答案是错误的,最终结果有可能是1000000,但大多数情况下都是小于1000000的某个值(执行的时候一直都是输出1000000而不是更小值,那可以适当增加线程数和计算次数。两者值越大效果越明显)。

虽然volatile保证了可见性,保证每个线程取值都是最新的,但是不能保证原子性。所以就可能出现下面的情况。

线程1读取i,还没来得及计算,线程2插进来读取i并将+1后的值刷新到主存,此时主存i的值是i+1。之后线程1拿着旧的i,将计算结果i+1刷新到主存中,此时主存i的值还是i+1。经历了两次自加运算,预期结果应该是i+2才对,但实际结果却是i+1。正是因为有这种情况存在,最终结果会小于等于1000000。

我们可以使用synchronized或者Lock来保证同一时间只有一个线程在操作i,这样就不会出现上面的情况,保证最终输出结果一定是1000000。

public class VolatileTest {
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final VolatileTest test = new VolatileTest();
        Lock lock = new ReentrantLock();
        for(int i = 0; i < 100; i++){
            new Thread(() -> {
                for(int j = 0; j < 10000; j++) {
                	lock.lock();
                    test.increase();
                    System.out.println(test.inc);
                    lock.unlock();
                }
            }, "线程" + i).start();
        }
    }
}


或者使用java.util.concurrent.atomic下的包来进行自增操作。

public class VolatileTest {
    public volatile int inc;

    public AtomicInteger atomicinc;

    public void increase() {
        inc++;
    }

    public VolatileTest() {
        inc = 0;
        atomicinc = new AtomicInteger(0);
    }

    public static void main(String[] args) {
        VolatileTest test = new VolatileTest();
//        test.safeIncWithLock(test);
        test.safeIncWithAtomic(test);
    }

    public void safeIncWithLock(VolatileTest test) {
        Lock lock = new ReentrantLock();
        for(int i = 0; i < 100; i++){
            new Thread(() -> {
                for(int j = 0; j < 10000; j++) {
                    lock.lock();
                    test.increase();
                    System.out.println(test.inc);
                    lock.unlock();
                }
            }, "线程" + i).start();
        }
    }

    public void safeIncWithAtomic(VolatileTest test) {
        for(int i = 0; i < 100; i++){
            new Thread(() -> {
                for(int j = 0; j < 10000; j++) {
                    atomicinc.incrementAndGet();
                    System.out.println(atomicinc);
                }
            }, "线程" + i).start();
        }
    }
}



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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值