基本概念
先补充一下概念,java内存模型中的可见性、原子性和有序性。
可见性
百度百科的讲解,是指对象间的可见性,含义是一个对象能够看到或者引用另一个对象的能力。
可见性,是指线程之间的可见性,一个线程修改的值对另外一个线程是可见的。可以将可见性理解为一种通知机制也就是A线程修改了值,其他线程立马就知道修改的结果。比如:用volatile修饰的变量,就会具有可见性。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。比如 volatile int a = 0;之后有一个操作 a++;这个变量a具有可见性,但是a++ 依然是一个非原子操作,也就是这个操作同样存在线程安全问题。
在 Java 中 volatile、synchronized 和 final 实现可见性。
原子性
原子是组成世界万物的最小单位,具有不可分割性。
由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write。大致可以认为基本数据类型的操作是原子性的。同时 lock 和 unlock 可以保证更大范围操作的原子性。而 synchronize 同步块操作的原子性是用更高层次的字节码指令 monitorenter 和 monitorexit 来隐式操作的。
一个操作是原子操作,那么我们称它具有原子性。java的concurrent包下提供了一些原子类,我们可以通过阅读API来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。
在 Java 中 synchronized 和在 lock、unlock 中操作保证原子性。
有序性
如果在线程内被观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句指线程内表现为串行的语义,后半句是指“指令重排”现象和“工作内存与主内存同步延迟”现象。
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。
Volatile原理
java提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
当对非volatile变量进行读写的时候,每个线程先从内存中拷贝变量到线程对应的CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个变量可以拷贝到不同的CPU 缓存中。
而声明变量是volatile的,JVM保证了每次读取变量都是从内存中读,跳过了CPU catche这一步。
当一个变量定义为 volatile 之后,将具备两种特性:
保证此变量对所有的线程的可见性,这里的“可见性”,如本文开头所述,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存(详见:Java内存模型)来完成。
禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。
接下来会去分别验证volatile的特性。
volatile 可见性
1.num变量未使用volatile前,虽然主内存中的num值变了,但是没有通知到线程num值已更改,导致线程一直在死循环。
package com.jp.test002; import java.util.concurrent.TimeUnit; public class VolatileDemo1 { // volatile 读取的时候去主内存中读取在最新值! private static int num = 0; public static void main(String[] args) throws InterruptedException { // Main线程 new Thread(()->{ /** * @description: * 由于num添加了volatile,所以线程每次读取都会去主内存中读取 * @author yangxj * @date 2020/2/26 17:54 */ while (num==0){ } }).start(); TimeUnit.SECONDS.sleep(1); num = 1; System.out.println(num); } }
2.添加volutile保证可见性
package com.jp.test002; import java.util.concurrent.TimeUnit; public class VolatileDemo1 { private volatile static int num=0; public static void main(String[] args) { //1.线程 new Thread(()->{ //从主内存中不断读取num值 while (num==0){ } }).start(); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } //改变主线程内存中的num值 num=1; System.out.println(num); } }
volatile不保证原子性
原子性的特点 : 不可分割,要么同时成功,要么同时失败。
//验证不保证原子性 public class VolatileDemo2 { private volatile static int num=0; public static void add(){ num++; } public static void main(String[] args) { //理论上,计算num的结果应该为20*1000=20000 for (int i=1;i<=20;i++){ new Thread(()->{ for (int j = 0; j < 1000; j++) { add(); } },String.valueOf(i)).start(); } //main线程,判断上面 所有线程执行完成,只剩下主线程、和gc线程 while (Thread.activeCount()>2){ //线程放弃当前分得的 CPU 时间,但是不使线程阻塞 Thread.yield(); } System.out.println(Thread.currentThread().getName() + " " + num); } }
上面代码,for循环结算理论上num的计算结果应该为20000,但是每次计算的结果却都不相同
这是什么原因呢,因为numm++ 不是原子操作,而volatile也不保证原子性操作。我们可以通过javap命令查看一下,字节码执行过程。
首先找到,VolatileDemo2生成的字节码文件
然后在当前目录使用CMD命令,输入命令
javap -c VolatileDemo2.class
可以看到如下:
volatile 不保证原子性解决方案
1.使用同步关键字synchronized,保证每一次只有一个线程能操作值
public class VolatileDemo2 { private volatile static int num=0; public synchronized static void add(){ num++; } public static void main(String[] args) { //理论上,计算num的结果应该为20*1000=20000 for (int i=1;i<=20;i++){ new Thread(()->{ for (int j = 0; j < 1000; j++) { add(); } },String.valueOf(i)).start(); } //main线程,判断上面 所有线程执行完成,只剩下主线程、和gc线程 while (Thread.activeCount()>2){ //线程放弃当前分得的 CPU 时间,但是不使线程阻塞 Thread.yield(); } System.out.println(Thread.currentThread().getName() + " " + num); } }
2.使用lock锁
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class VolatileDemo2 { private volatile static int num=0; private static Lock lock=new ReentrantLock(); public static void add(){ //2.加锁的方式 lock.lock(); num++; lock.unlock(); } public static void main(String[] args) { //理论上,计算num的结果应该为20*1000=20000 for (int i=1;i<=20;i++){ new Thread(()->{ for (int j = 0; j < 1000; j++) { add(); } },String.valueOf(i)).start(); } //main线程,判断上面 所有线程执行完成,只剩下主线程、和gc线程 while (Thread.activeCount()>2){ //线程放弃当前分得的 CPU 时间,但是不使线程阻塞 Thread.yield(); } System.out.println(Thread.currentThread().getName() + " " + num); } }
3.使用原子性类工具java.util.concurrent.atomic
import java.util.concurrent.atomic.AtomicInteger; public class VolatileDemo2 { private static AtomicInteger num=new AtomicInteger(); public static void add(){ num.getAndIncrement(); // 等价于 num++ } public static void main(String[] args) { //理论上,计算num的结果应该为20*1000=20000 for (int i=1;i<=20;i++){ new Thread(()->{ for (int j = 0; j < 1000; j++) { add(); } },String.valueOf(i)).start(); } //main线程,判断上面 所有线程执行完成,只剩下主线程、和gc线程 while (Thread.activeCount()>2){ //线程放弃当前分得的 CPU 时间,但是不使线程阻塞 Thread.yield(); } System.out.println(Thread.currentThread().getName() + " " + num); } }
volatile 禁止指令重排
指令重排: 计算机在执行程序之后,为了提高性能,编译器和处理器会进行指令重排!
指令重排:程序最终执行的代码,不一定是按照你写的顺序来的!
int x = 11; // 语句1 int y = 12; // 语句2 x = y + 5; // 语句3 y = x*x ; // 语句4 我们所期望的:1234 但是可能执行的时候回变成 2134 1324
可不可能是4123?不能,因为4要依赖于1.
处理器在进行指令重排的时候,需要考虑:数据之间的依赖性。
a,b,x,y初始值都是0.
线程A | 线程B |
x=a | y=b |
b=1 | a=2 |
正常的结果是,x=0,y=0。如果出现指令重排,就会出现如下效果
线程A | 线程B |
b=1 | a=2 |
x=a | y=b |
指令重排得到了诡异结果: x=2,y=1
volatile实现禁止指令重排原理:内存屏障。
内存屏障: 作用于CPU的指令,主要作用两个:
- 保证特定的操作执行顺序;
- 保证某些变量的内存可见性(利用volatile实现了可见性)。
内存屏障使用最多的地方是在单例模式中。
总结
volatile是可以保持可见性,不可以保证原子性的,由于内存屏障,可以避免指令重排的现象发生。