在多线程并发执行下,多个线程修改共享的成员变量,会出现一个线程修改了共享变量的值后,另一个线程不能直接看到修改后的变量的最新值。
public class VolatileExample {
public static void main(String[] args) {
Threads th = new Threads();
th.start();
while(true) {
// 无法读取子线程修改后的值
if (th.getFlag()) {
System.out.println("主线程访问到 flag 变量");
}
}
}
}
class Threads extends Thread {
private boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 在线程内部修改 flag 的值
flag = true;
}
public boolean getFlag() {
return flag;
}
}
概述:在介绍多线程并发修改变量不可见现象的原因之前,需要了解回顾一下 Java 的内存模型(和 Java 并发编程有关的模型):JMM(Java Memory Model) JMM:Java 内存模型,是 Java 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不用计算机的区别。 Java 内存模型描述了 Java 程序中各种变量(线程共享变量)的访问规则,以及在 JVM 中将变量存储到内存和从内存中读取变量这样的底层细节。
JMM 具有以下规定:
本地内存和主存的关系:
问题分析:
while(true)
调用的是系统中更底层的代码,速度快,快到没有时间再去读取主内存中的值所以 while(true)
读取到的值一直是 false。(如果有一个时刻 main 线程从主内存中读取到了主内存中 flag 的最新值,那么 if 语句就可以执行,main 线程何时从主内存中读取最新的值是无法控制的)
概述:如何实现在多线程下访问共享变量的可见性:也就是实现一个线程修改变量后,对其他线程可见?
volatile 总体概览:volatile 可以实现并发下共享变量的可见性,除了 volatile 可以保证可见性外,volatile 还具备如下一些突出的特性:
原子性的定义:所谓的原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。
public class VolatileAtomicThread {
public static void main(String[] args) {
Runnable th = new TestThread();
for (int i = 1; i <= 100; i++) {
new Thread(th, "第"+i+"个线程").start();
}
}
}
class TestThread implements Runnable {
private int count = 0;
@Override
public void run() {
// 对变量进行 100 次自增操作
for (int i = 1; i <= 10000; i++) {
count++;
System.out.println(Thread.currentThread().getName() + " count >>> " + count);
}
}
}
该段代码并不能保证结果一定是 1000000 可能是 999988 也可能是 999991 被 volatile 修饰的变量 count 为什么不能保障线程安全?
对多线程操作一个变量的过程进行剖析为什么会导致 2 次操作结果只有 1 次:
小结,volatile 在多线程中不具有原子性,但是在单线程单个读写操作下:
所以在多线程环境下,如果想要保证数据的安全性,最好的解决方案就是加锁或使用原子类对象:
synchronized (TestThread.class) {
count++;
System.out.println(...);
}
此时就可以将 volatile 修饰去掉,因为这里的 synchronized 既保证了可见性有保证了原子性。
private AtomicInteger count = new AtomicInteger(0);
... for ...
count.incrementAndGet(); // 通过原子操作来自增
...
重排序:为了提高性能,编译器和处理器尝尝会对既定的代码的执行顺序进行指令重排序。 为什么要重排序:一个好的内存模型实际上会放松对处理器和编译器规则的舒服,也就是说软件技术和硬件技术都为同一个目标而进行奋斗 -> 在不改变程序执行结果的前提下,尽可能条执行效率。JMM 对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器尝尝会对指令进行重排序。一般重排序分为以下三种:
重排序的好处:重排序可以提高处理的速度。
a = 3; b = 2; a = a + 1;
/** 不进行重排序 **/
1. -> Load a -> Set to 3 -> Store a
2. -> Load b -> Set to 2 -> Store b
3. -> Load a -> Set to 4 -> Store a
a = 3; a = a + 1; b = 2;
/** 重排序 **/
1. -> Load a -> Set to 3 -> Set to 4 -> Store a
2. -> Load b -> Set to 2 -> Store b
重排序虽然可以提高执行的效率,但是在并发执行下,JVM 虚拟机底层并不能保证重排序下带来的安全性等问题:
public class VolatileAtomicThread {
public static int i = 0, j = 0;
public static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int count = 0;
while (true) {
count++;
i = 0; j = 0; a = 0; b = 0;
Thread one = new Thread(() -> {
a = 1; i = b;
});
Thread two = new Thread(() -> {
b = 1; j = a;
});
one.start(); two.start();
one.join(); two.join();
String result = "第" + count + "次 ( i=" + i + ", j=" + j + " )";
System.out.println(result);
if (i == 0 && j == 0) {
break;
}
}
}
}
可能出现的结果及其重排序后的情况:
i=0, j=1
:a=1 i=b(0) b=1 j=a(1)i=1, j=0
:b=1 j=a(0) a=1 i=b(1)i=1, j=1
:b=1 a=1 i=b(1) j=a(1)i=0, j=0
:i=b(0) j=a(0) a=1 b=1第1次 ( i=0, j=1 )
第2次 ( i=0, j=1 )
...
第2751次 ( i=0, j=1 )
第2752次 ( i=1, j=0 )
...
第2761次 ( i=0, j=1 )
第2762次 ( i=0, j=0 )
Process finished with exit code 0
按照以前的观点:代码执行的顺序是不会改变的,也就是第一个线程是 a=1 是在 i=b 之前执行的,第二个线程 b=1 是在 j=a 之前执行的。 发生了重排序:在线程1和线程2内部的两行代码的实际执行顺序和代码在 Java 文件中的顺序是不一致的,代码指令并不是严格按照代码顺序执行的,他们的顺序改变了,即发生了重排序。 但是使用 volatile 来修饰 i j a b 之后可以禁止重排序,从而实现业务的安全性保障线程安全。其中 volatile 又是通过 内存屏障(内存栅栏) 方法来实现了禁用重排序的功能。
为了提高速度,JVM 会对代码进行编译优化,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患,如:指令重排序导致的多个线程之间的不可见性。 从 JDK1.5 开始,提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
所以为了解决多线程的可见性问题,就推出了 happens-before 原则,让线程之间遵守这些原则。编译器还会优化我们的语句,所以等于是给了编译器优化的约束。不能让它优化的过分了。 简单的说:happens-before 应该翻译成 -> 前一个操作的结果可以被后续的操作获取。将白点就是前面一个操作变量 a 赋值为 1,那么后面的一个操作肯定能知道 a 已经变成了 1。
具体的一共有 6 大规则和性质:
ThreadB.start()
,那么A线程的 ThreadB.start()
操作 happens-before 于线程B中的任意操作。 ThreadB.start()
来启东线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。主意:线程B启动之后,线程A再对变量进行修改的操作对线程B未必可见。ThreadB.join()
并成功返回,那么线程B中的任意操作都 happens-before 于线程A从 ThreadB.join()
操作成功返回。 A.join()
,或者 A.isAlive()
成功返回后,都对 B 可见。happens-before 有一个原则是:如果A是对 volatile 变量的写操作,B 是对同一个变量的读操作,则有 hb(A,B):
public class HappensBefore {
int a = 1, b = 2;
protected void write() {
a = 3;
b = a;
}
protected void read() {
System.out.println("b="+b+";a="+a);
}
public static void main(String[] args) {
while (true) {
HappensBefore hb = new HappensBefore();
new Thread(hb::write).start();
new Thread(hb::read).start();
}
}
}
对可能会出现的情况进行分析:
b=3;a=3
:write() 方法正常串行b=2;a=1
:先执行了第二个读线程再执行了第一个写线程b=2;a=3
:先执行了一半的读线程再执行了写线程最后再执行剩下的读线程
读操作先执行线程延迟了一会儿,b=2进了常量池读进了println里面,写线程的操作太快,a=3在读a的引用之前又进了常量池所以读到了a=3b=3;a=1
:没有 volatile 修饰的 b 导致了 a=3 操作不可见无法感知 a 的变化针对第四种情况,依据 happens-before 原则,只需要给 b 加上 volatile 修饰符,那么 b 之前的写入操作将对读取 b 之后的代码可见,也就是说即使 a 不加 volatile,只要 b 读取到 3,那么 b 之前的操作就一定是可见的,此时就绝对不会出现 b=3 a=1 的情况了。
volatile 发生重排序的情况:
操作1(下) 操作2(右) | 普通读/写 | volatile读 | volatile写 |
---|---|---|---|
普通读/写 | 不允许 | ||
volatile读 | 不允许 | 不允许 | 不允许 |
volatile写 | 不允许 | 不允许 |
概述:在 Java 中,long 和 double 都是8个字节共64位,那么如果是一个32位的系统,读写 long 或 double 的变量时会涉及到原子性问题,因为32位系统要读完一个64位的变量需要分两步执行,每次读取32位,这样对 double 和 long 变量的赋值操作就会出现问题:如果两个线程同时写一个变量内存,一个进程写入低32位、而另一个线程写入高32位,这样就会导致最终的64位数据是无效的。
结论:如果是在64位的系统中,那么对64位的 long 和 double 的读写都是原子操作,即可以以一次性读写 long 或 double 的整个64bit。如果在32位的 JVM 上,long 和 double 就不是原子性操作。解决方案:需要使用 volatile 关键字来防止此类现象。
单例是需要在内存中永远只能创建一个类的实例。单例对象的作用是:节约内存和保证共享计算的结果正确,以及方便管理。 单例模式的适用场景:
8 种单例模式:
public class Singeton01 {
private static final Singleton01 INSTANCE = new Singleton01();
private Singeton01() {}
public static Singeton01 getInstance() {
return INSTANCE;
}
}
public class Singeton02 {
private static final Singleton02 INSTANCE;
static {
INSTANCE = new Singeton02();
}
private Singeton02() {}
public static Singeton02 getInstance() {
return INSTANCE;
}
}
public class Singleton03 {
private static Singleton03 INSTANCE;
private Singleton03() {}
public static Singleton03 getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton03();
}
return INSTANCE;
}
}
public class Singleton04 {
private static Singleton04 INSTANCE;
private Singleton04() {}
public synchronized static Singleton04 getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton04();
}
return INSTANCE;
}
}
缺陷分析:对整个方法使用 synchronized 加锁来保障线程安全会降低性能,将线程阻塞在方法外部,增加内部判断的内存开销。并发情况下有且只能有一个线程正在进入获取单例对象方法。
public class Singleton05 {
private static Singleton05 INSTANCE;
private Singleton05() {}
public static Singleton05 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton05.class) {
INSTANCE = new Singleton05();
}
}
return INSTANCE;
}
}
为什么线程不安全:若线程A、线程B同时进入判断方法内部,则等待上一个线程解锁后 INSTANCE 会再被重新构造,使得这期间对单例的设置全部失效。
其他隐患:构造对象方法的底层原子操作被重排序了,原本是先构造再把构造的对象的地址赋给引用,重排序后变为先赋空指针地址再构造对象并把对象的地址填入空指针中。
线程2拿到的 INSTANCE 对象为一个空指针地址,在线程2中对 INSTANCE 的后续操作会抛出 java.lang.NullPointerException
异常。
public class Singleton06 {
// 防止构造重排序拿到空指针对象
private volatile static Singleton06 INSTANCE = null;
private Singleton06() {}
public static Singleton06 getInstance() {
// 第一次检查单例对象是否已被构建
if (INSTANCE == null) {
synchronized (Singleton06.class) {
// 第二次检查防止二次构造覆盖
if (INSTANCE == null) {
// 非原子操作
INSTANCE = new Singleton06();
}
}
}
return INSTANCE;
}
}
双重检查的优点:线程安全、延迟加载、效率较高。
public class Singleton07 {
private Singleton07 {}
private static class SingletonInstance {
private static final Singleton07 INSTANCE = new Singleton07();
}
// 闭包暴露
public static Singleton07 getInstance() {
return SingletonInstance.INSTANCE;
}
}
public enum Singleton08 {
INSTANCE;
public void whatever() {}
}
概述:volatile 不适合做 a++ ++a 等操作,但适合做纯赋值操作,如:boolean flag = true; 具体案例如下:
import java.util.concurrent.atomic.AtomicInteger;
public class UserVolatile01 implements Runnable {
// 定义一个 volatile 修饰的boolean 变量
volatile boolean flag = false;
// 定义一个原子类记录总的赋值次数
AtomicInteger atomicInteger = new AtomicInteger();
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
switchFlag();
atomicInteger.incrementAndGet();
}
}
public void switchFlag() {
// flag = true; // 纯赋值操作是符合预期的
flag = !flag; // 这样做不符合预期 可能 true 可能 false
}
}
class Test {
public static void main(String[] args) throws Exception {
UserVolatile01 u = new UserVolatile01();
// 创建两个线程执行赋值操作
Thread t1 = new Thread(u);
Thread t2 = new Thread(u);
t1.start();
t2.start();
t1.join();
t2.join();
// 等两个线程执行结束后再获取结果
System.out.println(u.flag);
System.out.println(u.atomicInteger);
}
}
小结:volatile 可以适合做多线程中的纯赋值操作:如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用 volatile 来替代 synchronized 或者替代原子变量,因为赋值操作本身具有原子性,而 volatile 又保证了可见性,所以线程安全。而 flag = !flag
不保障原子性 只保障了可见性。
概念:按照 volatile 的可见性和禁止重排序以及 happens-before 规则,volatile 可以作为刷新之前变量的触发器。我们可以将某个变量设置为 volatile 修饰,其他线程一旦发现该变量修改的值后,触发获取到的该变量之前的操作都将是最新的且可见。
public class UserVolatile02 {
int a = 1, b = 2, c = 3;
volatile boolean flag = false;
public void write() {
a = 100;
b = 200;
c = 300;
flag = true;
}
public void read() {
// flag 被 volatile 修饰 充当了触发器 一旦值为 true 此处立即对变量之前的操作可见
// 即 a b c 修改后的值具有可见性
while (flag) {
System.out.println("a=" + a + " , b=" + b + " , c=" + c);
}
}
public static void main(String[] args) {
UserVolatile02 u = new UserVolatile02();
new Thread(u::write).start();
new Thread(u::read).start();
}
}
小结:volatile 可以作为刷新之前变量的触发器。我们可以将某个变量设置为 volatile 修饰,其他线程一旦发现该变量修改的值后,触发获取到的该变量之前的操作都将是最新的且可见的。
案例:在 IoC 中的容器A中的某个 volatile 变量发生改变后可以简介触发其他操作的可见性,使得在另一个线程中的容器B能够嗅探到来自容器A的变化。
// 错误的写法 synchronized 禁止用于修饰变量
synchronized int a = 1;
// 错误的写法 volatile 禁止用于修饰方法
public volatile write() {
a = 100;
}