小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
初步认识volatile
java语言规范第3版对volatile的定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排它锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
下面这段代码,演示了一个使用了volatile和没有使用volatile关键字对变量更新的影响。
public class VolatileTest {
public static void main(String[] args) throws InterruptedException {
VolatileTest test = new VolatileTest();
test.start();
for (;;) {
if (test.isFlag()) {
System.out.println("hi");
}
}
}
}
class VolatileTest extends Thread {
private /*volatile*/ boolean flag = false;
public boolean isFlag() {
return flag;
}
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (Exception ex) {
ex.printStackTrace();
}
flag = true;
System.out.println("flag = " + flag);
}
}
运行之后会发现,如果没加volatile关键字就不会输出hi这个结果,但是线程中明明改了flag变量的值啊,这里就是volatile在这段代码中所起的作用了。
volatile的特性一:保证可见性
volatile可以使得在多处理器环境下保证了共享变量的可见性,那么到底什么是可见性? 解决内存可见性问题方式的一种是加锁,但是使用锁太笨重,因为它会带来线程上下文的切换开销。Java提供了一种弱形式的同步,也就是volatile关键字。该关键字确保对一个变量的更新对其他线程马上可见。
当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。
当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
理解volatile保证可见性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。
查看上述代码的汇编指令的时候发现,在修改带有volatile修饰的成员变量时,会多出一个lock指令。lock指令是一种控制指令,在多线程环境下,lock汇编指令可以基于总线锁或者缓存锁的机制来达到可见性的效果。
volatile的特性二:禁止指令重排
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
重排序的类型
一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标,而进行奋斗:在不改变程序执行结果的前提下,尽可能提高执行效率。JMM对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。 这里还得提一个概念,as-if-serial。
不管怎么重排序,单线程下的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
volatile如何保证不会被执行重排序
java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:
但是volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。
volatile写读如下:
volatile的特性三:不保证原子性
所谓原子性就是:不可分割,也即某个线程在做某个具体业务时,中间不可以被加塞或者分割,需要整体完整,要么同时成功 要么同时失败。 看下如下代码:
public class VolatileAtomic {
public static void main(String[] args) {
MyTest myTest= new MyTest();
for(int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
myTest.addNum();
}
}, String.valueOf(i)).start();
}
while(Thread.activeCount()>2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\t number= "+myTest.num);
}
}
class MyTest {
public volatile int num = 0;
public void addNum() {
num++;
}
}
添加了volatile,最终结果应该为20000,实际输出小于等于20000,说明volatile不保证原子性。 volatile不保证原子性是因为num++在多线程下是非线程安全的。num++方法编译成字节码后,分为以下三步运行的: 1.从主存中复制 i 的值并复制到 CPU 的工作内存中。
2.CPU 取工作内存中的值,然后执行 i++操作,完成后刷新到工作内存。
3.将工作内存中的值更新到主存。
原本线程1在自己的工作空间中将num改为1,写回主内存,主内存由于内存可见性,通知线程2 3,num=1;线程2通过变量的副本拷贝,将num拷贝并++,num=2;再次写入主内存通知线程3,num=2,线程3通过变量的副本拷贝,将num拷贝并++,num=3; 然而 多线程竞争调度的原因,1号线程刚刚要写1的时候被挂起,2号线程将1写入主内存,此时应该通知其他线程,主内存的值更改为1,由于线程操作极快,还没有通知到其他线程,刚才被挂起的线程1 将num=1 又再次写入了主内存,主内存的值被覆盖,出现了丢失写值;
这种问题可以使用synchronized 或者使用原子变量 来解决。原子变量通过调用unsafe类的cas方法实现了原子操作,由于CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许中断,也即是说CAS是一条原子指令,不会造成所谓的数据不一致的问题。
总结
volatile是java虚拟机提供的轻量级的同步机制,主要有以下几点:保证可见性,禁止指令重排,不保证原子性,volatile只能作用于属性,用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。 volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。