初识volatile是在多线程下的懒汉式加双重锁的单例模式,用来防止指令重排,具体可看JAVA单例模式。那时候并没有把它放在脑中,也就一漂而过了,但再次遇到它时,是在一个飞行棋的小游戏中这段代码是点击筛子图片进行摇筛子的操作,但如果没有第一个注释了的那句输出语句是点击了筛子图片也毫无反应的,最后是在变量isClick前用了volatile关键字才可以。为什么会这样呢?volatile到底有什么神奇的功能?我当时也是一脸懵逼,别急,我们来慢慢分析...
一.内存模型
计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,一定会涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU跑的贼快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,但总不能你慢就让快的人一直在等你,这也太不合理了。那怎么办呢?我们都想到用缓冲了,所以在CPU里面就有了高速缓存。
也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这句代码:
i=i+1;
当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存。 比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。
最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。
那么如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),就可能存在缓存不一致的问题。
解决缓存不一致性问题的两种解决方法:
1)通过在总线加LOCK#锁的方式
这因为CPU和其他部件(内存)进行通信都是通过总线来进行的,那想一下我们在总线上加锁就意味着在整条路上设了通行杆,只能有一个CPU能使用这个变量的内存,把其他CPU都拦在了外面,阻塞了其他CPU对它的操作。也就是说只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样明显会降低效率。
2)通过缓存一致性协议
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
二.并发中的三个概念
先解释一下什么是并发吧。 并发是两个或多个事物在同一时间间隔内发生。是在同一个cpu上同时运行多个程序。(它在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行,从宏观外来看,好像是这些进程都在执行并行,是每个cpu运行一个程序。)比如单核电脑只有一个CPU在同一时刻只能做一件事情,但是电脑却同时做很多事情,一边放音乐,一边下电影等。这里整个电脑表现出来的就是并发性,很多事情同时在进行。
区别另一个概念,并行:两个或者多个事件在同一时刻发生,“并行”是指无论从微观还是宏观,二者都是一起执行的.
打个比方,并发,就像一个人(cpu)喂2个孩子(程序),轮换着每人喂一口,表面上两个孩子都在吃饭。并行,就是2个人同时喂2个孩子,两个孩子也同时在吃饭。
在并发编程中,我们需要知道的三个概念:原子性,可见性题,有序性。
1.原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
看下下面四条语句哪些是原子操作?是不是觉得它们都是原子操作,特别是语句1和2,但其实只有语句1是原子操作。
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
2.可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
举个简单的例子,看下面这段代码:
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
此时volatile就大显身手了。用volatile修饰一个共享变量时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去主内存中读取新值。
另外,通过synchronized和Lock也能够保证可见性,因为synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。其实volatile就是一种轻量级的synchronized方式。
3.有序性:即程序执行的顺序按照代码的先后顺序执行。
从上面代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
那什么是指令重排序呢?就是处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
那么再想一下,语句3和4会发生指令重排序吗?
很容易想到是不会的,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,上面也说了它会保证程序最终执行结果和代码顺序执行的结果是一致的,所以如果一个指令4必须用到3的结果,那么处理器会保证3会在4之前执行。
虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?
上面代码中,语句1和语句2可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此时线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
那么此时也就要volatile出手了,Java中volatile关键字用来保证一定的“有序性”。
Java内存模型具备的有序性,也就是happens-before原则(先行发生原则):
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
这8条原则摘自《深入理解Java虚拟机》。
三、深入理解volatile
用volatile修饰一个共享变量(类的成员变量、类的静态成员变量)后的效果是:
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
2)禁止进行指令重排序。 保证了有序性
但注意:volatile并不能保证对变量的操作是原子性的!
假如线程1先执行,线程2后执行。中断线程时我们可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。
为什么会造成死循环呢?
每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。
那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
volatile如何保证可见性的?
第一:使用volatile关键字会强制将修改的值立即写入主存;
第二:使用volatile关键字的话,当线程2进行修改时(这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会导致线程1的工作内存中缓存变量stop的缓存行无效;
第三:线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。
关于这个关键字还有很多可以深入理解的地方,暂且就先到这吧,不然感觉要晕菜了,最后附上一张主存与线程工作内存之间的关系图便于理解。