JUC学习笔记-volatile的基本使用
创建线程的三种方法
- 实现Runnable接口
- 继承Thread类
- 通过callable和Future创建线程
Volatile的作用
volatile提供了一种轻量的同步机制,其最为重要的特点为:
- 保证可见性
- 不保证原子性
- 禁止指令重排
可见性
在多线程程序中,每个线程都会拥有一个属于自己的工作空间(栈空间)。对操作数的操作在该空间进行。但是每个线程所操作的操作数可能是同一个操作数,这个时候就需要用一种同步机制,让任何线程改变操作数的时候,都可以保证其他线程可以知道操作数的最新值。
这里的基本设计就是,每次线程改变操作数的时候,就将操作数写回到多个线程共有的主内存,然后其他线程通过主内存操作数的变化得知操作数已经发生了改变。
为了实现上述的可见性,提出了许多协议。例如MESI协议,要求CPU写公共操作数的时候,会通知其他CPU将其缓存设置为无效;而其他CPU在读的时候,如果发现CPU缓存无效,就读取主内存的值。
那么,上述的通知其他CPU将缓存设置为无效是怎么实现的呢?这里用到了总线嗅探的技术。就是CPU会监控总线传播的数据,如果发现自己缓存对应的内存地址的值发生变化,那么就读取内存地址的值。
不保证原子性
考虑下面的程序:
1 | class myData{ |
上面的程序在执行的时候,两个线程同时对同一个操作数进行操作。由于将变量复制到工作内存了,因此在每次增加变量的值的时候,可能会出现多个线程同时写的情况,比如线程1已经写了,改变了主内存;此时,应该会通知其他线程的缓存无效,但是其他线程已经根据缓存算出了新值,在进行写操作了,然后将值写入内存,这样就出现了数据的丢失。
禁止指令重排
什么是指令重排
指令的顺序可能和程序设定的并不一样。
比如上面的命令就是指令重排的一个例子。
因为指令重排,所以程序不一定可以按照我们的预期执行。比如下面的一个例子:
1 | public class ThreadTest { |
上面的例子中,我们希望在线程A执行完loadConfigFromFile之后,线程B再执行后续工作。程序中通过configLoaded传递信息。但是这里由于在线程A内部,loadConfigFromFile方法和configLoaded并无逻辑关系,因此可能会将configLoaded排在loadConfigFromFIle之前。
我们的解决办法是为configLoaded添加上volatile修饰,该修饰可以禁止将configLoaded赋值语句之后的语句放到configLoaded赋值之前,之前的语句放在configLoaded赋值之后。
禁止指令重排的原理
volatile通过内存屏障实现了禁止指令重排和内存可见性。
内存屏障是一个cpu指令。其第一个作用是告诉编译器和CPU,无论如何都不可重排该条指令。第二个作用是刷新出CPU的缓存。
关于方法中不能定义volatile的解释
1 | public class volatileCount { |
上面可以看到,如果直接在方法中定义volatile变量是会报错的。这是因为local variable都是定义在stack中的,而volatile只对定义在heap中的变量起作用。因此在方法内部定义volatile无效,因此就禁止这么做了。
这里有一个有趣的现象。如果我们定义一个类,然后类的内部有一个volatile变量,比如下面的程序:
1 | class myData{ |
我们将volatile变量放到了一个类的内部,然后我们就会发现我们竟然可以在方法中使用这个类了。
我们上面说到了,不能使用volatile int的原因是volatile int 对应的变量是定义在栈区的,但java类的实例可是定义在堆区的。而本地栈实际上只存着 基本类型和对象的引用指针,因此上述的代码不会产生错误。
这里还有一个有趣的测试,就是static变量。在java7 以上的版本中,静态域存储于定义类型的Class对象中,Class对象如同堆中其他对象一样,存在于GC堆中。参考。因此,如果将count定义为static变量,程序应该也不会出错。