Java 谈谈什么是 volatile

随着互联的飞速发展,互联网公司也越来越高,并发多线程,内存管理,JVM调优等成为面试必问题。

1. 什么是 volatile?

volatile 是 Java 虚拟机提供的轻量级同步机制

他的三大特性:

  1. 保证可见性
  2. 不保证原子性
  3. 禁止指令重排

1.1 可见性

在解释可见性之前需要先看一下什么是 JMM?

JMM(Java 内存模型 Java Memory Model,简称 JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(实例字段,静态字段和构成数组对象的元素)的访问方式。

JMM 关于同步的规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存
  3. 加锁解锁是同一把锁

JMM 保证:可见性、原子性、有序性

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方成为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完成再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存储存着主内存中的变量副本拷贝,因此不同的线程无法访问对方的工作内存,此案成间的通讯(传值) 必须通过主内存来完成,其简要访问过程如下图:

可见性:当一个线程修改主物理内存的值再写回主内存时,其他线程能都收到修改数据的通知。

如果看不懂上面的一段话,那么看看代码,然后再会过头看上面的文字。代码演示:

class MyData{
	//第一次不加 ,第二次运行加 volatile 
	int n = 0;
	public void change() {
		this.n=60;
	}
}

/**
 * 1.验证 volatile 的可见性
 * 2.假设 int n = 0; n 变量再之前根本没有添加volatile 关键字修饰,没有可见性
 * @author taotao
 *
 */
public class VolatileDemo {
	public static void main(String[] args) {
		MyData myData = new MyData();
		// 生成线程修改 n 
		new Thread(()-> {
			System.out.println(Thread.currentThread().getName()+" 进入程序");
			try {
				Thread.sleep(3000);//休眠3s
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			myData.change();
			System.out.println(Thread.currentThread().getName()+" 修改数据完成 "+myData.n);
		},"线程1").start();
		
		 while(myData.n==0) {
			 //主线程等待
		 }
		 
		 System.out.println(Thread.currentThread().getName()+" 完成工作,n 最新值" + myData.n);
	}
}

第一次运行结果:

第二次运行结果:

1.2 原子性

操作不可分割,又叫完整性,既某个线程正在做某个具体业务,中间不可以被加塞或者被分割,需要整体完整。要不同时成功,要不同时失败。volatile 是不保证原子性的。

先来代码验证:

class MyData{
	volatile int n = 0;
	// 此时 n 已经加 volatile 关键字,但是 volatile 不保证原子性。
	public void addPulsPuls() {
		n++;
	}
}

/* 验证 volatile 不保证原子性*/
public class VolatileDemo2 {
	public static void main(String[] args) {
		MyData myData = new MyData();
		// 生成 20 个线程 ,同时修改 n 值
		for (int i = 0; i < 20; i++) {
			new Thread(()-> {
				for (int j = 0; j < 1000; j++) {
					myData.addPulsPuls();
				}
			},"线程"+i).start();
		}
		// Java 默认两个线程:main线程和 GC线程
		while(Thread.activeCount()>2) {
			Thread.yield();  //当前线程停止,让出资源
		}
		
		System.out.println(Thread.currentThread().getName()+" 完成工作,n 最新值" + myData.n);
	}
}

第一次运行结果:

第二次运行结果:

根据代码大家可以知道正确的答案应该是1000*20=20000,其原因就是因为 volatile 不保证原子性。

虽然addPlusPuls执行的一行代码,但是使用 javap -c对class文件进行汇编的时候会发现,他的底层执行的是三行代码

附:JVM指令手册

执行 getfield 拿到原始 n
执行 iadd 进行加 1 操作
执行 putfield 写把累加后的值写回

那么,怎么解决这个问题?

  1. 加 synchronized 关键字(有一种杀鸡用牛刀的感觉)
  2. atomic 原子类(小问题,推荐使用)
  3. Lock 锁机制

这次我们使用 atomic 原子包下的 AtomicInteger 来解决这个问题

import java.util.concurrent.atomic.AtomicInteger;

class MyData{
	//使用 AtomicInteger 的原子类
	AtomicInteger ai = new AtomicInteger();
	
	public void addAtomic() {
		ai.getAndIncrement(); // 自增1
	}
}

/* 解决 volatile 原子性的方法 */
public class VolatileDemo2 {
	public static void main(String[] args) {
		MyData myData = new MyData();
		// 生成 20 个线程 ,同时修改 n 值
		for (int i = 0; i < 20; i++) {
			new Thread(()-> {
				for (int j = 0; j < 1000; j++) {
					myData.addAtomic();
				}
			},"线程"+i).start();
		}
		// Java 默认两个线程:main线程和 GC线程
		while(Thread.activeCount()>2) {
			Thread.yield();  //当前线程停止,让出资源
		}
		System.out.println(Thread.currentThread().getName()+" 完成工作,n 最新值" + myData.ai);
	}
}

运行结果:

进阶:为什么 Atomic 能解决原子性的问题?

CAS 

1.3 有序性

volatile 实现禁止指令重排序的优化,从而避免了多线程环境下程序出现乱序的现象

先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个 CPU 指令,他的作用有两个:

  • 保证特定操作的执行顺序
  • 保证某些变量的内存可见性(利用该特性实现 volatile 的内存可见性)

由于编译器个处理器都能执行指令重排序优化,如果在指令间插入一条 Memory Barrier 则会告诉编译器和 CPU,不管什么指令都不能个这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后执行重排序优化。内存屏障另一个作用是强制刷出各种 CPU 缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本。

下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图:

0e75180bf35c40e2921493d0bf6bd684_th

下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图:

21ebc7e8190c4966948c4ef4424088be_th

线程安全性保证

  • 工作内存与主内存同步延迟现象导致可见性问题
    • 可以使用 synchronzied 或 volatile 关键字解决,它们可以使用一个线程修改后的变量立即对其他线程可见
  • 对于指令重排导致可见性问题和有序性问题
    • 可以利用 volatile 关键字解决,因为 volatile 的另一个作用就是禁止指令重排序优化

代码举例:

public void example(){
    int x = 5;     //语句1
    int y = 10;    //语句2
    x = x + 5;     //语句3
    y = x + x;     //语句4
}

观察 example 方法,方法体有四个语句,在多线程环境下,编译器会对这个四条语句执行的顺序进行调整,所以可能的执行顺序是:1->2->3->4;1->3->2>4;2->1->3->4 等。但是不会出现 语句4 在第一位,因为他的执行有依赖条件。正是因为在这种情况下,编译器会自动对代码执行的顺序进行一个优化调整,但是在多线程的情况下我们是希望他根据我们写好的顺序(1-2-3-4)依次执行,换句话说就是禁止编译器进行语句优化调整,也就是 volatile 的第三个特性“禁止指令重排”。

发布了39 篇原创文章 · 获赞 20 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/cong____cong/article/details/104323727