对于java面试中的volatile的最佳解答

163 阅读11分钟

java并发采用的是共享内存模型,线程之间的通信对程序员来说是透明的。

1、Java内存模型

JMM即Java Memory Model,它定义了主存、工作内存抽象概念、底层对应着cpu寄存器、缓存、硬件内存、cpu指令优化等。

JMM体现在以下几个方面

原子性:保证指令不会受到线程上下文切换的影响
可见性:保证指令不会受cpu缓存的影响
有序性:保证指令不会受到cpu指令并行优化的影响

2、可见性

当我们运行以下代码时,按理说1秒后线程会停下来。但是实际却没有停下来,为什么?

public class VisibilityTest {
    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t =new Thread(() -> {
            while (run) {
                System.out.println("run...");
            }
        });

        t.start();

        Thread.sleep(1000);
        System.out.println("停止");
        run = false;
    }
}

1.初始状态,a线程刚开始从主内存读取了run的值到工作内存。 2.因为a线程要频繁的从主内存中读取run值,JIT编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率 3.1秒之后,main线程修改了run的值,并同步至主存,而a是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。 解决方案: 加上volatile
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存
通过加synchronized也可以解决

class VisibilityTest2 {
    volatile static boolean run = true;
    final static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t =new Thread(() -> {
            while (true) {
                synchronized (lock) {
                    if(!run) {
                        break;
                    }
                }
            }
        });

        t.start();

        Thread.sleep(1000);
        System.out.println("停止");
        synchronized (lock) {
            run = false;
        }
    }
}

3、可见性 VS 原子性 volatile:保证的是在多个线程之间,一个volatile变量的修改对另一个线程可变,不能保证原子性,仅用在一个写线程,多个读线程的情况。

synchronized:既可以保证代码的原子性,也同时保证代码块内变量的可见性。但是缺点是synchronized是属于重量级操作,性能相对更低

4、终止模式之两线程终止模式 在一个线程T1中如何优雅的终止线程T2?这里的【优雅】指的是给T2一个料理后事的机会

4.1 错误思路 使用线程对象的stop方法停止线程

stop方法会真正杀死线程,如果这是线程锁住了共享资源,那么当它被杀死后,就再也没有机会释放锁,其他线程将永远无法获取锁

使用System.exit(int) 方法停止线程

目的仅是停止一个线程,但这种做法会让整个程序都停止

4.2 正确思路

package com.sharing_model.visibility;

/**
 * 两阶段终止模式 (volatile实现)
 */
public class TwoPhaseTermination {
    private Thread monitor;

    private volatile boolean stop = false;

    //启动监控线程
    public void start() {
        monitor = new Thread(() -> {
            while (true) {
                Thread current = Thread.currentThread();
                if (stop) {
                    System.out.println("料理后事");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    System.out.println("执行监控");
                } catch (InterruptedException e) {
                }
            }
        });

        monitor.start();

    }

    public void stop() {
        stop = true;
    }
}

class Test {
    public static void main(String[] args) throws InterruptedException {
        TwoPhaseTermination t = new TwoPhaseTermination();
        t.start();

        Thread.sleep(3500);
        t.stop();
    }
}

5、设计模式——犹豫模式 Balking(犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回。

例如:


package com.sharing_model.visibility;

import java.util.concurrent.TimeUnit;

/**
 * 设计模式: 犹豫模式
 * 当发现想要做的事被做了就不做了
 */
public class HesitateMode {
    public static void main(String[] args) {
        TwoPhaseTermination1 tp1 = new TwoPhaseTermination1();
        tp1.start();
        tp1.start();
    }
}

class TwoPhaseTermination1 {
    //监控线程
    private Thread monitorThread;

    //停止标记
    private boolean stop = false;

    //判断是否执行过start方法
    private boolean staring = false;

    //自动监控线程
    public void start() {
        synchronized (this) {
            if (staring) {
                return;
            }
        }
        staring = true;
        monitorThread = new Thread(() -> {
            while (true) {
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("执行监控");
            }
        },"monitor");
        monitorThread.start();
    }
}

6、有序性 JVM在不影响正确性的前提下,可以调整语句的执行顺序,思考下面的一段代码:

static int i;
static int j;

//在某个线程内执行如下的赋值操作
i = ...;
j = ...;

可以看到,至于先执行i还是先执行j,对最终的结果不会产生什么影响,所以上面代码最终执行的时候,即可以是

i = ...; j = ...; 1 2 也可以是

j = ...; i = ...; 1 2 这种特性称为指令重排,多线程下的【指令重排】会影响正确性。为什么要有指令重排这项优化?下面从CPU执行指令的原理来理解一下。

在不改变程序的运行结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令集并行,这一技术在80中叶到90中叶占据了计算架构的重要地位。

分阶段,分工是提升效率的关键!

7、volatile原理
1、如何保证可见性
写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存中
读屏障保证在该屏障之后,对共享变量的读取,加载是主存中最新的数据
2、如何保证有序性
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
8、DCL 单例模式

/**
 * 双重校验锁
 */
public class DoubleCheckedLocking {
    private DoubleCheckedLocking() {}
    private static volatile DoubleCheckedLocking INSTANCE = null;
    public static  DoubleCheckedLocking getInstance() {
    //加if的母的是,每次获取实例的时候都要加速,效率太低了,加if后只有第一次会加锁,这样大大的提高了运行效率
        if (INSTANCE == null) {
        //加synchronized的目的时为了防止多个线程在操作的时候,虽然第一个线程判断实例为空,但是在前一个线程还没有执行实例的创建的时候,第二个线程也认为实例为空
            synchronized (DoubleCheckedLocking.class) {
                if (INSTANCE == null) {
                    INSTANCE = new DoubleCheckedLocking();
                }
            }
        }
        return INSTANCE;
    }
}

happens-before规定了对共享变量写操作对其它线程的读操作可见,可见可见性和有序性的一套规则总结,抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

线程解锁m之前对变量的写,对于接下来对m加锁的其它线程对该变量的读可见

static int x;
static Object m = new Object();

new Thread(() -> {
	synchronized(m) {
			x = 10;
	}
},"t1").start();

new Thread(() -> {
	synchronized(m) {
		System.out.println(x);
	}
},"t2").start();

线程对volatile变量的写,对接下里其它线程对该变量的读可见

volatile static int x;

new Thread(() -> {
	x = 10;
},"t1").start();

new Thread(() -> {
	Sysytem.out.println(x);
},"t2").start();

线程start前对变量的写,对该线程开始后对该变量的读可见

static int x;

x = 10;

new Thread(() -> {
	System.out.println(x);
},"t2").start();

线程结束前对变量的写,对其它线程得知它结束后的读可见

static int x;

Thread t1 = new Thread(() -> {
	x = 10;
}, "t1");
t1.start();

t1.join();
System.out.println(x);

线程t1打断它t2前对变量的写,对于其它线程得知t2被打断后对变量的读可见

static int x;

public static void main(String[] args) {
	Thread t2 = new Thread(() -> {
	while(true) {
			if(Thread.currentThread().isInterrupted()) {
				System.out.println(x);
				break;
			}
		}
	},"t2");
	t2.start();
	
	new Thread(() -> {
		sleep(1);
		x=10;
		t2.interrupt();
	},"t1").start();
	
	while(!t2.isInterrupted()) {
		Thread.yield();
	}
	System.out.println(x);

对变量默认值(0, false, null) 的写,对其它线程对改变量的读可见 具有可见性,如果 x hb -> y 并且 y hb -> z 那么有x hb ->z ,配合volatile的防止指令重排

volatile static int x;
static int y;

new Thread(() -> {
	y = 10;
	x = 20;
},"t1").start();

new Thread(() -> {
	//x = 20 对 t2 可见, 同时 y= 10也对 t2 可见
	System.out.println(x);
},"t2").start();

3.原理及简答

在揭开面纱之前,我们需要认识几个基础概念:内存屏障(memory Barriers),指令重排序,happens-before规则,as-if-serial语义。

什么是 Memory Barrier(内存屏障)?

内存屏障,又称内存栅栏,是一个CPU指令,基本上它是一条这样的指令:
1、保证特定操作的执行顺序。
2、影响某些数据(或则是某条指令的执行结果)的内存可见性。

编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。

Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个 Write-Barrier(写入屏障)将刷出所有在 Barrier 之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。


这和java有什么关系?volatile是基于Memory Barrier实现的。

如果一个变量是volatile修饰的,JMM会在写入这个字段之后插进一个Write-Barrier指令,并在读这个字段之前插入一个Read-Barrier指令。

这意味着,如果写入一个volatile变量a,可以保证:
1、一个线程写入变量a后,任何线程访问该变量都会拿到最新值。
2、在写入变量a之前的写入操作,其更新的数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。

happens-before
从jdk5开始,java使用新的JSR-133内存模型,基于happens-before的概念来阐述操作之间的内存可见性。

在JMM中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这个的两个操作既可以在同一个线程,也可以在不同的两个线程中。

与程序员密切相关的happens-before规则如下:
1、程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意的后续操作。
2、监视器锁规则:对一个锁的解锁操作,happens-before于随后对这个锁的加锁操作。
3、volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
4、传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C。

注意:两个操作之间具有happens-before关系,并不意味前一个操作必须要在后一个操作之前执行!仅仅要求前一个操作的执行结果,对于后一个操作是可见的,且前一个操作按顺序排在后一个操作之前。

指令重排序
在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。但是,JMM确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的Memory Barrier来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。

1、编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2、指令级并行的重排序:如果不存l在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3、内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

数据依赖性 如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。 编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。

as-if-serial
不管怎么重排序,单线程下的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。

抽象结构
java线程之间的通信由java内存模型(JMM)控制,JMM决定一个线程对共享变量(实例域、静态域和数组)的写入何时对其它线程可见。

从抽象的角度来看,JMM定义了线程和主内存Main Memory(堆内存)之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有自己的本地内存Local Memory(只是一个抽象概念,物理上不存在),存储了该线程的共享变量副本。

所以,线程A和线程B之前需要通信的话,必须经过一下两个步骤:
1、线程A把本地内存中更新过的共享变量刷新到主内存中。
2、线程B到主内存中读取线程A之前更新过的共享变量。