并发编程之JMM&Volatile(123)

并发编程之JMM&Volatile(一)

 

并发

很多程序员应该对并发一词并不陌生,并发如同一把双刃剑,如果使用得当,可以帮助我们更好的压榨硬件的性能,反之,也会产生一些难以排查的问题。这里,先简单介绍下并发的几个基本概念。

进程与线程

进程:进程是操作系统进行资源分配和调度的基本单位。

线程:线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。

上面是百度百科对进程和线程的解释,可能有点抽象,这里笔者再根据自己的理解解释下进程和线程的概念和区别:当我们打开QQ、微信、网易云音乐,这时我们启动了三个进程,操作系统会分别对这三个进程分配资源,操作系统会分配什么资源给这三个进程呢?首先是内存资源,这三个进程都有各自的内存进行数据的存取,QQ和微信分别有各自的内存资源来保存我们的用户数据、聊天数据。其次,当我们需要用QQ或者微信聊天时,操作系统只会把键盘资源分配给QQ或者微信其中一个进程,当我们输入文字,只会出现在QQ或者微信其中一个的聊天窗。下面我们再来说说线程,我们用网易云音乐,可以同时下载音乐和播放音乐,两者互不影响,这是因为在网易云音乐这个进程里,同时有两个线程,一个线程播放音乐,一个线程下载音乐,利用多线程,可以使一个进程在一段时间内同时执行两个任务。

并发与并行

并发:在单核单CPU架构中,只会出现并发,不会出现并行。比如在一个电商系统中,用户A正在下单,用户B正在改名,因此分别有线程A和线程B两个线程在CPU上交替执行,互相竞争CPU资源。假设下单操作需要执行100个指令,改名操作需要执行60个指令,单核单CPU的架构可能先在线程A中执行80个指令,然后将CPU时间片让给线程B,线程B在执行50个指令后,CPU重新把时间片让给线程A执行剩余的20个指令,再执行线程B剩余的10个指令,最后线程A和线程B都执行完毕。

并行:只要是多核CPU,不管是单CPU还是多CPU,都有可能出现并行。还是以上面的电商系统为例,用户A和用户B的线程可以同时跑在同CPU或者不同CPU的不同的处理器上,这时候就能做到线程A和线程B同时执行,互不竞争CPU处理器资源。

区别:从上面的例子,我们可以知道并发和并行的区别,并发是指在一段时间内,多个任务交替执行,并行是同一时间内,多个任务可以同时执行。

并发编程的本质

至此,我们已经了解了并发的几个基本概念。而并发的本质是要解决:可见性、原子性、有序性这三个问题。

可见性

当多个线程同时访问同一个变量,一个线程修改了这个变量的值,其他线程要能立刻看到修改的结果。

我们来看下面这段代码,首先我们声明了一个静态变量flag,默认为true,线程A只要检查到flag为true时,就循环下去,主线程启动线程A后休眠2000毫秒,再启动线程B修改flag的值为false。按理来说,在flag被线程B修改为false之后,线程A应该退出循环。然而,如果我们运行下面的代码,会发现程序并不会终止。程序之所以不会终止的原因,是因为线程A无法跳出循环,即便我们用线程B把flag改为false,但线程B修改的行为,对线程A是无感知的,即线程A并不知道此时flag已经被其他线程修改为false,线程A仍旧以为flag为true,所以无法跳出循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class VisibilityTest {
    private static boolean flag = true;//静态变量
 
 
    public static void main(String[] args) {
        new Thread(() -> {
            int i = 0;
            while (flag) {//如果静态变量为flag则循环下去
                i++;
            }
            System.out.println("i=" + i);
        }, "Thread-A").start();
        try {
            Thread.sleep(2000);
        catch (InterruptedException e) {
            e.printStackTrace();
        }
        new Thread(() -> flag = false"Thread-B").start();
 
    }
 
}

    

之所以线程A无法感知线程B修改flag变量的值,是因为在线程A启动的时候,会拷贝一份flag的副本,我们将副本命名为flag’,当线程A需要flag的值时,会去访问flag’,并不会去访问flag最新的值。那么,线程A又为什么要拷贝一份flag的值呢?为什么不直接去访问flag呢?这里就要谈到CPU缓存架构和JMM模型(Java线程内存模型)。

下图是一个双核双CPU的架构,Core是CPU内核,L1、L2、L3是CPU的高速缓存,当CPU需要对数值进行运算时,会先把内存的数据加载到高速缓存再进行运算。假设线程A跑在Core1,线程B跑在Core2,不管是读取flag还是修改flag,线程A和B都需要从主存将flag加载到高速缓存(L1、L2、L3)。因此,高速缓存有两份flag的拷贝:flag(A)和flag(B),分别用于线程A和线程B,要注意一点的是,即便flag(A)和flag(B)都是主存flag的拷贝,但线程A对flag(A)读取或者修改对线程B是不可见的,同理线程B对flag(B)的读取修改对线程A也是不可见的。在我们上面的代码中,线程B在修改缓存的flag(B)之后,会把flag(B)最新的值同步回主存的flag,但线程A并不知道主存的flag已更新,它仍旧用缓存中flag(A)的值,所以无法跳出循环。

CPU缓存结构

Java的线程内存模型则参考了CPU的结构,在Java中,每个线程都有自己单独的本地内存用来存储数据,主存的共享变量也会被拷贝到本地内存成为副本,线程如果要使用共享变量,不会从主存读取或者修改,而是读取修改本地内存的副本。这也是代码VisibilityTest中,线程B在修改flag变量后,线程A无法跳出循环的原因。

Java线程内存模型

那么,如果我们业务中存在多线程访问修改同一变量,而且要求其他线程能看到变量最新修改的值该怎么办呢?Java提供了volatile关键字,来保证变量的可见性:

1
private static volatile boolean flag = true;

  

如果我们给flag加上volatile,线程B在修改flag的值之后,线程A就能及时获取到flag最新的值,就会跳出循环。那么,除了volatile关键字,还有其他的办法来保证可见性吗?有三种方式:synchronized、休眠和缓存失效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class VisibilityTest2 {
    private static boolean flag = true;//静态变量
 
 
    public static void main(String[] args) {
        new Thread(() -> {
            int i = 0;
            while (flag) {//如果静态变量为flag则循环下去
                i++;
                //System.out.println("i=" + i);//<1>调用println()方法时会进入synchronized同步代码块,synchronized可以保证共享变量的可见性
//                try {
//                    Thread.sleep(100);//<2>休眠也可以保证贡献变量的可见性
//                } catch (InterruptedException e) {
//                    e.printStackTrace();
//                }
                //shortWait(100000);//<3>模拟休眠100000纳秒,缓存失效
            }
            System.out.println("i=" + i);
        }, "Thread-A").start();
        try {
            Thread.sleep(2000);
        catch (InterruptedException e) {
            e.printStackTrace();
        }
        new Thread(() -> flag = false"Thread-B").start();
 
    }
 
    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        while (start + interval >= end);
    }
}

  

VisibilityTest2中<1>、<2>、<3>处的代码都会可以让线程A跳出循环,但三者的原理是不一样的:

  • <1>调用标准输出流的println()方法,这个方法里有synchronized关键字,这个关键字可以保证本地内存对共享变量的可见性。
  • <2>Thread.sleep()和Thread.yield()会让出CPU时间片,当休眠结束或者重新得到CPU时间片时,线程会去加载主存最新的共享变量。
  • <3>我们调用shortWait(long interval)等待100000纳秒,由于本地内存的副本太久没有使用,线程判断副本过期,重新去主存加载,这里需要注意一点是,如果我们把等待时间设为10或者100纳秒,那么结束等待时线程又会去使用flag副本,由于等待时间不是很长,不会将副本设置为已过期,也就不会跳出循环。

至此,我们了解了线程可见性,以及保证可见性的方法。当然,在上面几种保证可见性的方法中,最优雅的还是使用volatile关键字,其他保证可见性的方式都不是那么优雅,或者说是不可控的。

原子性

即一个操作或者多个操作,要么全部执行并且执行的过程不被任何因素打断,要么就都不执行。原子性就像数据库里面的事务一样,要嘛全部执行成功,如果在执行过程中出现失败,则整体操作回滚。

我们来看下面的例子,在AtomicityTest中声明两个int类型的静态变量a和b,然后我们启动10个线程,每个线程对a和b循环1000次加1的操作,如果我们多次执行下面这段代码,会发现大部分情况下a和b最后的值都不是10000,甚至a和b的值也不相等,那么是为什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class AtomicityTest {
    private static volatile int a, b;
 
    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    a++;
                    b++;
                }
            });
        }
        for (Thread thread : threads) {
            thread.start();
        }
        for (Thread thread : threads) {
            try {
                thread.join();
            catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("a=" + a + " b=" + b);
    }
}

    

运行结果:

1
a=9835 b=9999

    

我们来思考下,为什么a和b都不等于10000呢?静态变量a和b我们都用关键字volatile标记,所以一定能保证如果a和b的值被一个线程修改,其他线程能马上感知到。之所以出现a和b的结果都不是10000,是因为a++这个操作,并不是原子性,在一个线程执行a++这个操作时,可能被其他线程干扰。

我们可以来拆解下a++这个操作分哪几个步骤:

1
2
3
1.读取a的值
2.对a加1
3.将+1的结果赋值给a

  

我们假设线程1在执行a++操作的时,读取到a的数值为100,线程1执行完a++的第二个步骤,得出+1的结果是101,还未执行第三个步骤进行复制,此时线程2抢占了CPU时间片,线程1休眠,线程2读取到a的数值也是100,并且线程2完整的执行两次a++的所有步骤,此时a的数值为102,之后线程2休眠,线程1抢占到CPU时间片,便将之前+1的结果101赋值给a。这就是笔者所说,a++这个操作并非原子性,且被其他线程干扰,同理我们也就知道为何b的结果不是10000,而且a和b的结果还不相等。

要解决原子性问题也有很多种方式,针对AtomicityTest的代码,最简单的方式就是用synchronized加上一把同步锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
    Thread[] threads = new Thread[10];
    Object lock = new Object();
    for (int i = 0; i < threads.length; i++) {
        threads[i] = new Thread(() -> {
            synchronized (lock) {
                for (int j = 0; j < 1000; j++) {
                    a++;
                    b++;
                }
            }
        });
    }
    ……
}

  

运行上面的代码,a和b的结果都是10000。利用synchronized (lock)可以保证同一个时刻,最多只有一个线程访问同步代码块,其他线程如果要访问时只能陷入阻塞。这样也就能保证a++和b++的原子性。

Java每个对象的底层维护着一个锁记录,当一个对象时某个同步代码块的锁时,如果有线程进入同步代码块,对象的锁记录+1,线程离开同步代码块,则锁记录-1。如果锁记录>1,则代表当前线程重入锁,比如下面的代码,即方法A和方法B都有lock对象的同步代码块,当线程进入methodA的lock同步代码块,锁记录+1,调用methodB时执行到lock的同步代码块时,锁记录再次+1为2,当执行完methodB的同步代码块,lock的锁记录-1为1,最后执行完methodA的lock同步代码块,锁记录-1变为0,其他线程则可以竞争lock的锁权限,执行methodA或者methodB的同步代码块。

1
2
3
4
5
6
7
8
9
10
11
12
public void methodA() {
    synchronized (lock) {
        //...
        methodB();
    }
}
 
public void methodB() {
    synchronized (lock) {
        //...
    }
}

  

我们来看看下面四个操作哪几个是原子性哪几个不是:

1
2
3
4
i = 0;       //1
j = i ;      //2
i++;         //3
i = j + 1;   //4

  

  1. i=0:是原子性,在Java中对基本数据类型变量的赋值操作是原子性操作。
  2. j=i:不是原子性,首先要读取i的值,再将i的值赋值给变量j。
  3. i++:不是原子性,操作步骤见上。
  4. i=j+1:不是原子性,原因同i++一样。

有序性

为了提高执行程序的性能,编译器和处理器可能会对我们编写的程序做一些优化,执行程序的顺序不一定是按照我们代码编写的顺序,即指令重排序。编译器和处理器只要保证程序在单线程情况下,指令重排序的执行结果和按照我们代码顺序所执行出来的结果一样即可。

我们看下面的两行代码,思考一下如果对调这两行代码会不会有什么问题?这两行代码那一行需要执行的指令更少?

1
2
int j = a;//<1>
int i = 1;//<2>

  

首先我们来解决第一个问题,<1>和<2>这两行代码即便我们程序对调也不会有问题,毕竟代码<1>用到的变量和代码<2>没有交集,所以这两行代码是可以互换位置的。其次,我们来考虑<1>和<2>哪一行执行的指令更少,通过之前的学习,我们知道<2>是一个原子操作,而<1>需要读值再赋值,不是原子操作,执行代码<2>所需指令比<1>更少,所以编译器就可以做一个优化,把代码<2>和代码<1>的位置互换,优先执行指令少且变动顺序不会影响结果的代码,再执行指令多的代码。

下面的代码[1]和代码[2]是两个独立的代码块,但这两个独立的代码块最终结果又都是一样,即:i=2,j=3,那么哪一个代码块执行效率更高?

1
2
3
4
5
6
7
8
9
//[1]
int i = 1;//<1>
int j = 3;//<2>
int i = i+1;//<3>
 
//[2]
int i = 1;//<4>
int i = i+1;//<5>
int j = 3;//<6>

  

为了思考代码块[1]和代码块[2]哪一个执行效率更高,我们模拟下CPU的执行逻辑。首先是代码块[1]:CPU在执行完<1>和<2>两个赋值操作后,即将执行i=i+1,这时候i的值可能已经不在CPU的高速缓存里,CPU需要去主存加载i的值进行运算和赋值。再来是代码块[2]:CPU执行完<4>的赋值操作,此时i还在高速缓存,CPU直接从高速缓存读取i的值加1再赋值给i,最后再执行代码<6>的赋值操作。

到这里,我想大家应该都明白哪个代码块效率更高,显而易见,代码块[2]的效率会更高,因为它不用面临变量i从高速缓存中淘汰,后续对i进行+1操作时又需要去主存加载变量i。而代码块[1]在执行完i的赋值操作后,又执行了其他指令,这时候可能出现高速缓存无法容纳变量i而将i淘汰,后续需要对i进行操作需要去主存加载i。

根据上面我们所了解的,指令重排序确实会提高程序的性能,但指令重排序只保证单线程情况下,重排序的执行结果和未排序的执行结果是一样的,如果是多线程的情况下,指令重排序会给我们带来意想不到的结果。

在下面的代码中,我们声明4个int类型的静态变量:a,b,x,y,主方法有一个循环,每次循环都会将这四个静态变量赋值为0,之后开启两个线程,在线程1中奖a赋值为1,b的值赋值给x,线程2中将b赋值为1,a的值赋值给y。等到两个线程执行完毕后,如果x和y都为0,则跳出循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class ReOrderTest {
    private static int x = 0, y = 0;
 
    private static int a = 0, b = 0;
 
    public static void main(String[] args) {
        int i = 0;
        while (true) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread thread1 = new Thread(() -> {
                a = 1;//<1>
                x = b;//<2>
            });
            Thread thread2 = new Thread(() -> {
                b = 1;//<3>
                y = a;//<4>
            });
            thread1.start();
            thread2.start();
            try {
                thread1.join();
                thread2.join();
            catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("第" + i + "次:x=" + x + " y=" + y + "");
            if (x == 0 && y == 0) {
                break;
            }
        }
    }
}

  

运行结果:

1
2
3
4
第1次:x=0 y=1
……
第86586次:x=0 y=1
第86587次:x=0 y=0

    

运行上面的程序,我们会发现程序终究会跳出循环,按理来说,我们在线程1给a赋值,在线程2将a的值赋予给y,线程2又对b赋值,在线程1将b的值赋值给x,两个线程执行结束后,x和y本来应该都不为0,那为什么会出现x和y同时为0跳出循环的情况?可能有人想到线程的可见性,诚然有可能出现:线程1和线程2同时将这四个静态变量的值拷贝到本地内存,即便线程1对a赋值,线程2对b赋值,但线程1看不到线程2对b的修改,将b在本地内存的拷贝赋值给x,同理线程2将a在本地内存的拷贝赋值给y,因此x和y同时为0,跳出循环。但这里还要考虑到一个重排序的情况,线程1的<1>、<2>代码是可以互换位置的,同理还有线程2的<3>、<4>。考虑下线程1执行重排序后,执行顺序是<2>、<1>,而线程2执行顺序是<4>、<3>,即代码顺序变为:

1
2
3
4
5
6
7
//线程1
x = b;//<2>
a = 1;//<1>
 
//线程2
y = a;//<4>
b = 1;//<3>

  

线程1执行<2>之后,线程2又执行了<4>,之后两个线程即便对a和b赋值,但对x和y来说为时已晚,x和y已经具备跳出循环的条件了。那么,有没有办法解决这个问题呢?这里又要请出我们的关键字volatile了,volatile除了保证可见性,还能保证有序性。只要将ReOrderTest 的四个静态变量标记上volatile,就可以禁止指令重排序。

1
2
3
private static volatile int x = 0, y = 0;
 
private static volatile int a = 0, b = 0;

  

volatile之所以可以防止指令重排序,是因为它会在使用倒volatile变量的地方生成一道“栅栏”,“栅栏”的前后指令都不能更换顺序,比如上述四个静态变量标记上volatile关键字后,线程1执行代码的顺序如下:

1
2
3
4
a = 1;
//---栅栏---
x = b;
//---栅栏---

  

变量a的后面会生成一道“栅栏”,编译器和处理器会检测到这道“栅栏”,即便我们的指令在单线程下有优化空间,volatile也能保证处理器执行指令的顺序是按照我们代码所编写的顺序。

另外,笔者之前有提过,执行a=1的执行比x=b的指令更少,处理器应该要优先执行a=1再执行x=b,但实际上Java虚拟机在执行指令的时候情况是不一定的,也有可能优先执行x=b再执行a=1,也就是说JVM虚拟机执行指令的顺序,可能会按照我们编写代码的顺序,也可能会将我们的代码调整顺序后再执行,即便是同一段代码循环执行两次,前后两次的指令顺序,有可能是按我们代码所编写的顺序,也有可能不是。

下面的代码是用于获取单例对象的代码,通过SingleFactory.getInstance()方法我们可以获取到singleFactory对象,在这个方法中,如果singleFactory不为空,则直接返回,如果为空,则进入if分支,在if分支中还有个同步代码块,同步代码块里会再判断一次singleFactory是否为null,避免多线程调用SingleFactory.getInstance(),由于可见性原因,生成多个SingleFactory对象,所以synchronized已经保证了我们的可见性,第一个进入synchronized代码块中的线程,singleFactory一定为null,所以会去初始化对象,而其他同样需要singleFactory对象的线程,会先阻塞在同步代码块之外,等到第一个线程初始化好singleFactory后离开同步代码块,其他线程进入时singleFactory已经不为null了。但我们注意到一点,为什么synchronized已经保证了可见性,singleFactory这个静态变量还要用volatile关键字来标记呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SingleFactory {
    private static volatile SingleFactory singleFactory;
 
    private SingleFactory() {
    }
 
    public static SingleFactory getInstance() {
        if (singleFactory == null) {
            synchronized (SingleFactory.class) {
                if (singleFactory == null) {
                    singleFactory = new SingleFactory();
                }
            }
        }
        return singleFactory;
    }
}

    

诚然,volatile和synchronized都能保证可见性,但这里的volatile不是用来保证可见性的,而是禁止指令重排序的。我们来思考一个问题:JVM会如何执行singleFactory = new SingleFactory()这段代码?正常应该会先在堆上分配一块内存,在内存上创建一个SingleFactory对象,最后把singleFactory这个引用指向堆上的SingleFactory对象是不是?但如果一个对象的构建及其复杂,JVM可能会把创建对象的指令优化成先开辟一块内存,将singleFactory的引用指向这块内存,然后再创建这个对象。如果执行的顺序是先开辟内存,再指向内存,最后在内存上创建对象,那么其他线程在调用SingleFactory.getInstance()时,即便对象还没创建好,但singleFactory引用已经不为null了,这个时候如果将singleFactory引用返回并调用其堆上的方法是非常危险的,所以这里需要用volatile禁止指令重排序,并不是为了volatile的可见性,而是让volatile禁止指令重排序,按部就班的分配内存,创建对象,再将引用指向对象。

 

并发的优势与风险

优势

速度:同时处理多个请求,响应更快;复杂的操作可以同时分成多个进程或者线程同时进行。

设计:程序设计在某些情况下变得更简单。

资源利用:CPU可以在等待IO的时候做其他的事情。

风险

安全性:多个线程同时读写数据可能会产生于期望不相符的结果。

活跃性:某个操作无法进行下去时,就会发生活跃性问题。比如:死锁、饥饿、活锁等问题。

  • 死锁:两个或多个线程在执行时互相持有对方所需要的资源,导致线程处于阻塞状态,无法执行。
  • 活锁:有若干线程彼此之间都会互相影响,当一个线程需要某个资源,如果他检测到其他线程也需要这个资源,就退出竞争,将资源让给其他线程,这种情况下容易发生活锁,每个线程都占用CPU时间,但每个线程都检测到自己所需要的资源也被其他线程需要,于是谦让给其他资源,导致没有一个线程真正占用资源并完成执行,浪费宝贵的CPU时间。

  • 饥饿:如果一个线程因为处理器时间全部被其他线程抢走而得不到处理器运行时间,这种状态被称之为饥饿,一般是由高优先级线程吞噬所有的低优先级线程的处理器时间引起的。

性能:线程过多时会使得:CPU频繁切换,调度时间增多;同步机制;消耗过多内存。

下面,我们来看下死锁的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class DeadLockTest {
    private static String a = "a";
    private static String b = "b";
 
    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(() -> {
            synchronized (a) {
                System.out.println("threadA进入a同步块,执行中...");
                try {
                    Thread.sleep(2000);
                    synchronized (b) {
                        System.out.println("threadA进入b同步块,执行中...");
                    }
                catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "threadA");
 
        Thread threadB = new Thread(() -> {
            synchronized (b) {
                System.out.println("threadB进入b同步块,执行中...");
                synchronized (a) {
                    System.out.println("threadB进入a同步块,执行中...");
                }
            }
        }, "threadB");
        threadA.start();
        Thread.sleep(1000);
        threadB.start();
    }
}

    

运行结果:

1
2
threadA进入a同步块,执行中...
threadB进入b同步块,执行中...

  

上面是一段很传统的死锁代码,线程A在获得对象a的同步锁后,休眠2000ms,确保线程B已获得对象b的同步锁。线程B还要获取对象a的同步锁时,对象a的同步锁已经被线程A持有,线程B只能等待。等到线程A被唤醒,要获取对象b的同步锁,此时线程B持有对象b的同步锁,且线程B还等待线程A释放对象a的同步锁,两个线程都持有对方完成任务所需的锁,但又不能释放,从而造成死锁。

我们再来看下活锁的案例:假设我们有若干工人(Worker)需要工具(tool)来完成工作,工人最开始被创建出来,会加入到工人集合(workers),只有工人使用工具完成工作后,会从工人集合移除该工人。工具一开始会分配一个工人使用,工人在工作时,如果发现工具所分配的主人不是自己,则通知其他工人来争夺工具,而自己陷入等待,工具的主人抢占到工具后会检查工人集合中是否有其他等待工具的工人,如果有,则随机找一个,将工具转让给那个工人。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import java.util.*;
 
public class LiveLockTest {
    private static Set<Worker> workers = new HashSet<>();//<1>工人集合
    private static Tool tool = new Tool();//<2>工具
 
    static class Tool {
        private volatile Worker owner;
 
        public Worker getOwner() {
            return owner;
        }
 
        public void setOwner(Worker owner) {
            this.owner = owner;
        }
 
 
    }
 
    //判断其他处于等待的工人
    public static Worker getOtherWaitingWorker(Worker except) {
        //将工人集合转换成list
        List<Worker> list = new ArrayList<>(workers);
        //如果list在移除except之后,长度为0,则代表工人集合里没有处于等待的工人
        list.remove(except);
        if (list.size() == 0) {
            return null;
        }
        //如果有处于等待的工人,则随机选择一个返回
        Random random = new Random();
        int index = random.nextInt(list.size());
        return list.get(index);
    }
 
    static class Worker extends Thread {
 
        public Worker(String name) {
            super(name);
        }
 
 
        @Override
        public void run() {
            try {
                synchronized (tool) {
                    while (true) {
                        //如果当前工人线程抢到工具锁,则判断自己是否是工具的主人,不是则陷入等待并释放锁
                        while (tool.getOwner().getName() != this.getName()) {
                            tool.wait();
                        }
                        //如果当前工人线程为工具的主人,则获取其他处于等待的工人
                        Worker other = getOtherWaitingWorker(this);
                        //如果处于等待的工人不为空,则重新设置工具的主人为等待的工人,并调用工具的notifyAll()方法,通知其他工人线程竞争工具锁
                        if (other != null) {
                            System.out.println("我是" + getName() + ",我把工具让给" + other.getName());
                            tool.setOwner(other);
                            tool.notifyAll();
                        else {
                            //如果没有处于等待的工人,则使用工具完成工作,并将自己从工人集合中移除
                            System.out.println(getName() + "使用完工具,从workers集合中删除" + getName());
                            workers.remove(this);
                        }
                        Thread.sleep(500);
                    }
                }
            catch (InterruptedException e) {
                System.out.println(getName() + "中断");
            }
        }
    }
 
 
    public static void main(String[] args) {
        for (char c = 'A'; c <= 'C'; c++) {
            Worker w = new Worker("工人" + c);
            if (tool.getOwner() == null) {
                tool.setOwner(w);//如果工具没有主人,则设置一个工人为工具的主人
            }
            workers.add(w);//将工人依次加入工人集合
        }
        for (Worker worker : workers) {
            worker.start();//工人开始工作
        }
        try {
            Thread.sleep(10000);
            //中断每个工人,并等待工人线程执行完成
            for (Worker worker : workers) {
                worker.interrupt();
                worker.join();
            }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
 
    }
}

  

运行结果:

1
2
3
4
5
6
7
8
9
10
11
我是工人A,我把工具让给工人C
我是工人C,我把工具让给工人B
我是工人B,我把工具让给工人A
我是工人A,我把工具让给工人C
我是工人C,我把工具让给工人A
……
我是工人C,我把工具让给工人B
工人C中断
我是工人B,我把工具让给工人C
工人A中断
工人B中断

  

如我们上面所看到,每个工人都抢占到这个工具,但每个人又因为谦让给别的工人,也无法使用工具完成工作,浪费CPU时间。

缓存一致性

我们知道,一个CPU有多少个处理器,就可以处理多少个线程,同时这些处理器还会维护自己的缓存。这样看来,可见性问题不单单是Java的烦恼,只要运行在多核架构的程序,不管程序本身是否由Java编写,都会面临可见性的问题,那么当我们有多个线程需要同时修改内存某块数据,操作系统是否有提供额外的手段来保证线程安全和数据的可见性呢?答案是有的,操作系统提供了:总线锁缓存一致性

从下面这张图可以看到:总线是计算机各种功能部件之间传送信息的公共通信干线,它是cpu、内存、输入、输出设备传递信息的公用通道。当处理器需要对内存里的一块数据做运算,总线会把数据从内存传输到CPU缓存,等处理器计算完毕后回写缓存,再从缓存传输回内存。

那么,如果多个处理器都要计算内存里同一块数据,我们能否在总线那里加把锁?只允许一个处理器计算完数据后,别的处理器才可以通过总线计算内存中的数据。事实上,总线锁就是这么做的,处理器提供的一个LOCK#信号,当一个处理器向总线传输此信号时,其他处理器的请求将陷入阻塞,于是该处理器就可以独占内存了。但这么做有个弊端,就是其他处理器无法处理别的任务,可能不同的处理器正分别处理不同的程序,因为一个程序的某一资源可能出现的并发问题而把总线锁住,导致其他程序无法执行,这似乎有些得不偿失。因此,CPU又提供了缓存一致性:当内存中某块数据被多个处理器缓存,其中一个处理器要修改缓存中的数据时会先通知其他处理器放弃缓存中的副本,得到其他缓存的回应确定其他缓存已将副本置位失效状态,处理器才会修改数据并在未来的某个时刻将缓存中的数据同步到内存。

缓存一致性协议有多种实现,比较为人熟知的一种实现为MESI协议,MESI协议设定CPU缓存中64个字节为一个缓存行,通过给缓存行定义状态:M(Modify,修改)、E(Exclusive,独占)、S(Share,共享)和I(Invalid,无效)用来描述该缓存行是否被多处理器共享、是否修改。

状态 描述
M(Modify,修改) 代表该缓存行中的内容被当前处理器修改了,这个状态下的缓存行与对应内存中的数据不一致,在未来的某个时刻它会被写入到内存中,比如当其他处理器需要读取或修改同一份内存数据的时候。
E(Exclusive,独占) 代表该缓存行对应内存中的内容只被当前CPU缓存,其他CPU没有缓存该缓存行对应内存的数据。这个状态下的缓存行和对应内存中的数据一致。缓存行可以在其他CPU读取同一份内存中的数据时变成S状态、或者本地处理器修改缓存行内容时会变成状态M。
S(Share,共享) 代表该缓存行所对应的内存中的数据数据不止存在当前缓存中,还被其他CPU的缓存所持有,这个状态下缓存行的数据和内存中的数据是一致的,当有一个CPU修改该缓存行对应的内容时会使其他CPU中对应的该缓存行变成状态I 。
I(Invalid,无效) 代表该缓存行中的内容无效,CPU要读取或者修改缓存行,需要重新去内存同步。

我们已经知道MESI协议的概念,下图是模拟当多个多核CPU在修改内存同一变量时,如何按照MESI协议来交互:

  

按照MESI协议,当变量i存在CPU1和CPU2中,如果CPU1要修改缓存中的变量i前,首先要通知CPU2,等到CPU2返回消息告诉CPU1已经将自己缓存中的变量i状态置为I(失效)后,CPU1修改缓存中的变量i,状态改为M。之后CPU1会在一定时间内将缓存中副本i的最新数据同步到内存,比如:当其他CPU需要从内存中同步变量i的数据时,CPU1将缓存中的变量i同步到内存,然后CPU2读取内存中的变量i,CPU1和CPU2缓存中的变量i状态变为S。

我们用local read和local write分别代表本地CPU读写,remote read和remote write分别代表其他CPU读写,针对变量i,再次归纳下MESI状态的变化:

  • M(Modify)
    • local read:不影响当前缓存行状态。
    • local write:不影响当前缓存行状态。
    • remote read:先把缓存行数据同步到内存,当其他CPU读取内存中的变量i时,持有最新数据的缓存行状态都变为S。
    • remote write:首先经历M状态下remote read的步骤,所有持有变量i最新数据的缓存行状态都为S。假设当前CPU为CPU1,CPU1、CPU2和CPU3都持有最新的变量i的拷贝,状态都为S。当CPU2要修改变量i,先通知CPU1和CPU3,得到回应确定变量i在其他缓存的拷贝已经置位失效(I)后,CPU2修改变量i,并把状态置位M,当前CPU(CPU1)和CPU3对变量i的拷贝的状态都为I。
  • E(Exclusive)
    • local read:不影响当前缓存行状态。
    • local write:当前缓存行状态变为M。
    • remote read:缓存行和其他CPU的缓存行状态变为S。
    • remote write:先经历E状态下,remote read步骤,和其他CPU的缓存行状态都为S。假设当前CPU为CPU1,CPU1、CPU2和CPU3缓存行中的i状态都为S,CPU2要修改缓存中的变量i,先通知CPU1和CPU3将变量i状态置为失效(I),之后CPU2修改缓存行,CPU2缓存行状态变为M。
  • S(Share)
    • local read:不影响当前缓存行状态。
    • local write:当前CPU通知其他CPU将各自缓存行i置为失效,得到其他CPU回应后,修改当前缓存行i,状态变为M。
    • remote read:不影响当前缓存行状态。
    • remote write:其他CPU通知当前缓存行将变量i置为失效(I)。
  • I(Invalid)
    • local read:
      1. 如果其他处理器中没有变量i的拷贝,当前缓存从内存中取变量i后状态变为E。
      2. 如果其他处理器中有变量i的拷贝,且缓存行状态为M,则先把缓存行中的数据同步到内存。当前缓存再从内存读取数据,这时两个缓存行的状态都变为S。 
      3. 如果其他缓存行中有变量i的拷贝,其他缓存行的拷贝状态为S或E,当前缓存从内存中取数据,并且这些缓存行状态变为S。
    • local write:
      1. 先从内存中读取变量i,如果其他缓存中有变量i的拷贝且状态为M,则先让持有变量i且状态为M的缓存更新最新值到内存后,当前缓存再读取内存中的变量i。之后两个缓存的变量i状态S,当前处理器要修改i之前,先通知其他处理器放弃缓存中的变量i,得到其他处理器回应后,确定变量i在别的缓存中状态为I后,当前处理器修改i的值,状态改为M。 
      2. 如果其他缓存有变量i,且状态为E或S,那么其他缓存行的状态变为I。 
    • remote read:不影响当前缓存行状态。
    • remote write:不影响当前缓存行状态。

现在我们来思考一个问题,MESI协议确实可以保证内存的可见性,但处理器发现缓存数据失效,要去内存加载最新的数据,无论加载得再如何快也会存在性能的损耗,那有没有办法既不需要处理器去内存加载最新数据,又可以让别的处理器获取到最新数据呢?这里就引出了MOESI协议,这个协议相比MESI协议,MOESI引入状态Owned,并且重新定义了S状态,而E、M状态保持不变。

当CPU1修改完缓存的副本i,会把当前数据的状态改为O,这个状态代表当前缓存的副本i与内存的变量i的值不同,且变量i被多个CPU共享,在O这个状态下其他持有副本i的CPU会从CPU1的缓存将最新的副本i的值同步到自己的缓存,之后其他CPU原先副本i的状态为I变为S,如果有的CPU的缓存没有变量i,需要从内存同步,这时CPU1就可以借助状态O,将副本i的值同步到内存。

这里需要要注意一点,按照MEOSI协议,如果缓存中的副本i状态为S,并不代表副本的值与内存的值一致,如果存在副本i状态为O的缓存,则此时缓存中的副本i与内存的变量i的值不一致,如果所有缓存的副本i状态都为S,则代表缓存中的副本i与内存的变量i的值是一致的。

另外,缓存一致性协议只能保证数据的可见性,但不能保证原子性,假设内存里变量i的值为0,CPU1和CPU2同时对变量i做+1的操作,计算结果也可能不是2。

Store Buffer

当一份数据在多个CPU缓存中存在拷贝,其中一个CPU要修改数据,需要先通知其他CPU放弃该数据的拷贝,等到回应后才能修改,这无疑是个同步操作,而CPU时间是非常宝贵的,不应该让CPU陷入等待状态,要解决这个问题也很简单,采用异步即可。原先CPU要等到其他CPU的回应后才可以修改缓存中的数据,现在在CPU和缓存之间增加一个Store Buffer,CPU要修改数据时,一边把最新的数据写进Store Buffer,一边向其他CPU发送消息,然后继续执行别的指令,等到其他CPU发来确认数据失效的回应后,当前CPU在把Store Buffer的数据同步到缓存。

Store Buffer确实解决了CPU陷入等待的问题,但又引入一个新的问题,我们来看下面的代码:

1
2
3
4
5
6
7
a = 0
b = 0
 
func execToCPU1(){
    a = 1
    b = a + 1
}

    

假设变量a同时存在CPU1和CPU2的缓存,缓存行a和内存的值一样都为0。当CPU1要执行上execToCPU1(),它一边将a=1写入到Store Buffer,一边向CPU2发送消息,当CPU1要执行b=a+1时,由于a最新的值存在Store Buffer,CPU1读取a的值依旧从缓存读,缓存中的a的值依旧是0,所以b的值为1,但按照逻辑b的值应该为2,程序的执行顺序遭到破坏,变成下面这样:

1
2
3
4
func execToCPU1(){
    b = a + 1
    a = 1
}

  

Store Forwarding

Store Buffer可能导致程序顺序遭到破坏,因此在Store Buffer的基础上又引入了Store Forwarding技术,CPU可以从Store Buffer中读取最新的数据,将传递给之后的指令,不再完全从缓存中读取数据。

Store Forwarding解决了单CPU下读写的问题,但如果是多CPU读写数据时还是有问题,我们看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
a = 0
b = 0
 
func execToCPU1(){
    a = 1
    b = 1
}
 
func execToCPU2(){
    while(b == 0) continue
    if(a == 1){
        //do something...
    }
}

  

假设CPU1执行execToCPU1()方法,CPU2执行execToCPU2()方法。初始状态下,CPU1的缓存持有变量b的拷贝,CPU2的缓存持有变量a的拷贝。

  1. CPU2要执行while(b == 0),由于CPU2的缓存中没有b,发送read b消息。
  2. CPU1要执行a=1,由于CPU1的缓存中没有a,它将a=1写到自己的Store Buffer中,并发送read invalid a。
  3. CPU1执行b=1,由于b的拷贝已在CPU1的缓存中且为独占(E)状态,因此直接将缓存中的b改为1并把状态修改为M。
  4. CPU1接收到CPU2 read b的消息,将缓存中的b返回给CPU2,再将b同步到内存,并将缓存行的状态改为共享(S)。
  5. CPU2接收到b的值,结束了while(b == 0)的循环。
  6. CPU2要判断a是否为1,如果判断为true,则要执行if分支里的逻辑。此时CPU2缓存的a仍旧未失效,值依旧为0,所以就不执行if分支里的逻辑。
  7. CPU2接收到CPU1发送的read invalid a,将本地缓存行置为失效(I),但为时已晚。
  8. CPU1接收到CPU2的invalid a失效回应,将Store Buffer里的a同步到缓存。

出现这个问题是因为CPU之间不知道数据之间的依赖关系,CPU1可以在最开始的时候就修改变量a为1并发送消息,CPU2跳出while(b == 0)时本应执行if(a == 1)分支里的代码,但由于发送消息是异步的,此时CPU2还没收到缓存行a已失效的通知,不执行if分支的代码,等收到的时候为时已晚。

写屏障指令

现在看来,要确保CPU修改在修改缓存中的拷贝后,其值对其他CPU是立即可见的,单靠硬件是无法做到的,需要在软件层面支持。于是,CPU提供了写屏障指令(write memory barrier),Linux系统将写屏障指令封装成smp_wmb()函数,而CPU执行smp_wmb()函数的有两种思路是:

  1. 修改数据同时发送消息,等到返回后,将Store Buffer的数据同步到缓存。
  2. 对Store Buffer中所有条目打上标记,写屏障之后的写入操作也放到Store Buffer中,CPU继续执行别的指令,当其他CPU返回确认数据失效的返回后,将标记条目和之后的写入操作刷新到缓存。

第一种思路不需要讲解,我们重点讲解第二种思路,来看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
func execToCPU1(){
    a = 1
    smp_wmb()
    b = 1
}
 
func execToCPU2(){
    while(b == 0) continue
    if(a == 1){
        //do something...
    }
}

  

我们依旧设定CPU1持有变量b的拷贝,CPU2持有变量a的拷贝:

  1. CPU2要执行while(b == 0),由于CPU2的缓存中没有b,发送read b消息。
  2. CPU1要执行a=1,由于CPU1的缓存中没有a,它将a=1写到自己的Store Buffer中,并发送read invalid a。
  3. CPU1遇到smp_wmb(),会对Store Buffer里的所有条目,即a=1被标记。
  4. CPU1执行b=1,尽管b在缓存中的状态为独占(E),但Store Buffer还存在被标记的条目,所以b=1也会写到Store Buffer中。
  5. CPU1收到CPU2发送的read b消息,将缓存中b的值(b为0)返回给CPU1,并将状态改为S。
  6. CPU2收到read b的回应,b的值为0,继续while(b ==0)的循环。
  7. CPU2收到CPU1的read invalid a消息,将本地变量a的拷贝置为失效(I),并发送确认a失效回应。
  8. CPU1收到CPU2的确认失效回应后,将Store Buffer中的a(值为1)刷新到缓存,并将缓存行状态置为M。
  9. CPU1所有被标记的条目已被刷回缓存,开始尝试将b=1同步回缓存,由于缓存中b的状态从原先的独占(E)改为共享(S),因此CPU1要先发送invalid b消息。
  10. CPU2收到CPU1发送的invalid b消息,将本地缓存中的b置为失效后,再发送确认b失效回应。
  11. CPU2继续执行while(b == 0),由于本地缓存中的b已经失效,CPU2发送read b消息。
  12. CPU1收到CPU1发送的invalid b返回后,将Store Buffer中b=1写到缓存。
  13. CPU1收到CPU2的read b消息,将缓存中的b(b为1)返回给CPU2,修改状态为S,并同步回内存。
  14. CPU2收到CPU1发送的read b返回后,更新本地缓存中的b为S,执行while(b == 0)跳出循环。
  15. CPU2执行if(a == 1),由于变量a在CPU2的缓存中状态为失效,CPU2发送read a消息。
  16. CPU1收到CPU2的read a消息后,将a的值返回给CPU2后,修改a的状态为S,并同步回内存。
  17. CPU2收到CPU1发送的read a的返回后,修改本地缓存中的变量a状态为S,判断if(a == 1)条件为true,执行if分支里的代码。

Invalid Queue

在Store Buffer、Store Forwarding的基础上,我们再在软件层面引入写屏障,确实是解决了多个CPU不知道变量之间的依赖关系。但我们要知道,Store Buffer是有大小限制的,如果CPU遇到一个写屏障,后续的写入操作都会堆积在Store Buffer,直到Store Buffer中屏障之前的条目都处理完才能同步到缓存,这非常容易造成Store Buffer被写满,当Store Buffer被写满之后,CPU还是要等待其他CPU返回的invalid回应以处理Store Buffer中被标记的条目,而invalid回应的主要耗时原因是CPU把invalid消息对应的缓存行状态置为失效后再发送invalid回应。如果一个CPU特别的繁忙,那么会导致别的CPU一直在等待它的invalid回应。而解决方案还是化同步为异步,CPU收到invalid消息后不必立即将对应的缓存行置为失效,可以把invalid消息放到Invalid Queue就立即发送invalid回应,当CPU要处理某个缓存行的MESI状态前,先检查Invliad Queue是否有对应缓存行的消息。

但引入Invliad Queue又会出现新的问题,来看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
func execToCPU1(){
    a = 1
    smp_wmb()
    b = 1
}
 
func execToCPU2(){
    while(b == 0) continue
    if(a == 1){
        //do something...
    }
}

 

假设a和b在内存的初始值都为0,变量a的拷贝同时存在CPU1和CPU2的缓存中,状态为S,变量b的拷贝存在于CPU1的缓存中,状态为E。现在,我们开始模拟CPU1执行execToCPU1()方法,CPU2执行execToCPU2()方法:

  1. CPU1执行a=1,由于CPU1缓存已经存在变量a对应的缓存行了,将a=1写入Store Buffer,同时发送invalid a消息。
  2. CPU2执行while(b==0),由于CPU2缓存中没有变量b对应的缓存行,发出read b消息。
  3. CPU2收到CPU1发送来的invalid a消息,存放到Invalid Queue并发送确认a失效回应。
  4. CPU1收到CPU2返回的确认a失效回应,将Store Buffer中的a=1同步到缓存并修改状态为M。
  5. CPU1看到smp_wmb()写屏障语句,由于Store Buffer为空,因此它跳过该语句。
  6. CPU1执行b=1,因为CPU1独占b,所以直接将变量b对应的缓存行状态由E改为M。
  7. CPU1接收到CPU2的read b消息,将缓存行b的值返回给CPU2后,同步回内存,并修改状态为S。
  8. CPU2收到read b的响应后,执行while(b==0)跳出循环。
  9. CPU2执行if(a==1),由于缓存行a存的是旧值,所以if条件判断失败,无法执行分支里的逻辑。
  10. CPU2处理Invalid Queue中的消息,将缓存行a的状态置为失效。

问题出在第9步,CPU2应该先处理Invalid Queue中的消息,将本地缓存行a置为失效后,重新读取a的值,之后再执行if(a==1)分支判断。对此,CPU提供了读屏障指令,Linux将其封装为smp_rmb()函数,只要执行读屏障指令,就能确保CPU会把当前Invalid Queue中的消息处理掉。于是,我们可以把execToCPU2()方法改成如下:

1
2
3
4
5
6
7
func execToCPU2(){
    while(b == 0) continue
    smp_rmb()
    if(a == 1){
        //do something...
    }
}

  

这样就能确保在CPU2在第8步跳出while循环时,执行读屏障指令处理完Invalid Queue消息后,将本地缓存行a置为失效,当要执行if(a==1)重新发起对变量a的读取消息。

内存屏障

迄今为止,我们已经介绍了写屏障和读屏障,写屏障可以标记Store Buffer中的条目,将写屏障之后的写入操作都写进Store Buffer,等到接收到标记条目的失效回应,再将Store Buffer的内容同步回缓存。而读屏障可以让CPU先处理Invalid Queue里的消息,更新本地缓存行的最新状态。除了这两种屏障,还有一种全屏障,它具有读、写屏障的功能。另外,内存屏障还能保证屏障两边的指令不会发生重排序。

 

volatile原理

Java虚拟机规范中定义了Java内存模型(Java Memory Model,即JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现Java程序在各个平台下都能达到一致的并发效果。

Java内存模型中规定所有的变量都存储在主内存,每个线程都有自己独立的工作内存,线程的工作内存中保存了该线程所需要用到的本地变量,以及从主内存中拷贝到工作内存的副本。线程对于变量的读、写都必须在工作内存中进行,线程不能直接读、写主内存的变量。同时,线程的工作内存的变量也无法被别的线程访问,必须通过主内存完成。

下图展示了JMM模型和多核CPU架构模型,对比这两个模型,我们可以发现是十分相似的。假设Java没有提供volatile关键字,如果线程A和线程B在各自的内存里共享主存的同一变量,当线程A修改变量值,线程B将无法感知到变量的值已改变。而在多核CPU模型中,如果没有缓存一致性协议,CPU1和CPU2在各自的缓存拷贝了主存中某一变量的副本,当CPU1修改了副本的值,CPU2也是无法感知此时自己缓存中的副本已失效。于是,我们不禁开始思考:volatile是怎样做到与缓存一致性协议相似的功能?当被volatile标记的变量被一个线程修改时,通知其他线程放弃该变量的副本。

现在让我们看下,一个变量是否被volatile标记,在汇编指令中会有怎样的表现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package org.example.ch1;
 
public class TestVolatile {
    private static volatile int a = 1;
    private static int b = 1;
 
    public static void test() {
        a = 2;
        b = 3;
    }
 
    public static void main(String[] args) {
        TestVolatile.test();
    }
}

      

我们添加JVM参数:-Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*TestVolatile.test 运行TestVolatile的代码,可以看到TestVolatile.test()的汇编输出:

1
2
3
4
5
6
7
……
  0x000000000319a6ad: lock add dword ptr [rsp],0h  ;*putstatic a
                                                ; - org.example.ch1.TestVolatile::test@1 (line 8)
 
  0x000000000319a6b2: mov     dword ptr [rsi+6ch],3h  ;*putstatic b
                                                ; - org.example.ch1.TestVolatile::test@5 (line 9)
……

    

由于输出结果篇幅的原因,这里只节选了部分,TestVolatile.test()方法很简单,有两个静态变量a和b,初始值都为1,在这个方法中分别对两个字段赋值为2和3,如果对Java字节码熟悉的人也可以看到后面的putstatic a和putstatic b,putstatic在Java字节码的作用就是为静态变量赋值,而a和b就是我们定义的静态变量。我们关键来看字节码前面的汇编指令,其中add dword ptr [rsp],0h和mov dword ptr [rsi+6ch],3h都是常见的汇编指令,一般堆栈里的数据是以双字(dword,8bit)存放的,add dword ptr [rsp],0h意思是:将寄存器rsp栈顶指针+0。mov dword ptr [rsi+6ch],3h意思是:从将3h(3的十六进制表示)存放到寄存器rsi+6ch(288的十六进制表示)的位置,即在rsi+6ch和rsi+6dh存入0000 0000 0000 0011b(3的二进制表示)。除此之外,我们还注意到用volatile标记的变量a还有个lock指令,而正是lock指令,保证了静态变量a的可见性。

lock指令可以对总线或者缓存加锁,然后执行之后的指令,释放锁的时候会把缓存中的数据刷新回主内存,并让其他CPU对回写数据的拷贝失效,重新去主存加载最新的数据,从而保证了当一个线程修改volatile变量的值,其他线程能放弃工作内存的拷贝,重新去主存加载。lock指令不是内存屏障,却实现了类似内存屏障的功能,阻止屏障两边的指令重排序,因此volatile关键字还有禁止两边指令重排序的功能。lock指令后面还可以跟着ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

到这里,我们应该了解为何volatile关键字能保证可见性,又能阻止重排序。

现在,我们再来了解下volatile重排序规则,对于什么样的操作,volatile允许指令重排序,什么样的操作,volatile不允许指令重排序:

是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写     NO
volatile读 NO NO NO
volatile写   NO NO

根据上面的表格,我们可以做出如下归纳:

  1. 第二个操作是volatile写,不管第一个操作是什么都不会重排序。
  2. 第一个操作是volatile读,不管第二个操作是什么都不会重排序。
  3. 第一个操作是volatile写,第二个操作是volatile读,也不会发生重排序。

现在我们来分析下上面的规则,为什么volatile要做出这样的限制?首先我们要明确volatile读/写的内存语义:

  • 当读一个volatile变量时,JMM会把当前线程用到的所有共享变量(不单单被volatile修饰的变量)从主存重新加载。
  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的所有共享变量值刷回到主内存。

所以,无论是volatile读/写,都会刷新本地内存的所有共享变量,不单单是volatile变量。

我们假设execToThead1()被线程1执行,execToThread2()被线程2执行,从下面的代码我们可以看出线程1对变量a和b写数据,线程2读取变量a和b的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
volatile int a = 0;
int b = 1;
 
public void execToThead1() {
    b = 2;//1 普通写
    a = 1;//2 volatile写
}
 
public void execToThread2() {
    while (a == 0) {
        continue;
    }
    if (b == 2) {
        //do something...
    }
}

  

按照之前的规则,第二个操作是volatile写时,不管第一个操作是什么都不允许指令重排序,所以我们知道当线程1执行步骤2volatile写时,变量a连同共享变量b的数据会刷回到主存。因此线程2检查到变量a不为0时跳出循环,而且共享变量b为2,执行if分支里的逻辑。

如果volatile写不影响指令的排序,步骤1和步骤2没有依赖关系,那么步骤2可能先步骤1执行,变量a在主存的值从0变为1。线程2检查到变量a不再为0后跳出循环,但此时主存里的b仍然为1,线程2即便每次读取a时都会刷新本地内存,但依旧无法执行if分支里的判断,这是因为允许volatile写时重排序,导致把变量值刷回到主存的时机提前了,漏掉volatile写前的对共享变量的修改。

因此,这里就引出我们第一条规则:第二个操作是volatile写时,不管第一个操作是什么都不允许指令重排序。

下面,我们再来解释第二条规则,execToThead1()被线程1执行,对变量a和b赋值,execToThread2()较之前有些许变化,但依旧是读取变量a和b的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
volatile int a = 0;
int b = 1;
 
public void execToThead1() {
    b = 2;//1 普通写
    a = 1;//2 volatile写
}
 
public void execToThread2() {
    while (a == 0) {//3 volatile读
        continue;
    }
    int c = b;//4 普通写
}

  

按照第二条规则,第一个操作是volatile读,不管第二个操作是什么都不能重排序,所以线程1执行完毕,线程2的步骤3就会跳出循环,此时共享变量b的值为2,将b的值赋值给本地变量c,所以c的值也为2。

假设允许volatile读和之后的指令重排序,那么步骤3和步骤4实际上没有依赖性关系,那么指令序列可能优化成,先读取b的值(此时b为1)将其放入缓冲区,然后执行while循环,知道线程1执行完毕,volatile变量a的值为1,线程2跳出while循环,将缓冲区中b的值取出赋值给变量c,但此时主存中的变量b为2,这明显不是我们想要的结果。

由此引出第二条规则:第一个操作是volatile读,不管第二个操作是什么都不能重排序。

而volatile变量间的读写不能重排就不再举例了。

JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

屏障类型 指令示例 说明
LoadLoadBarrier Load1;LoadLoad;Load2 确保Load1数据的装载先于Load2及所有后续装载指令的装载。
StoreStoreBarrier Store1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储。
LoadStoreBarrier Load1;LoadStore;Store2 确保Load1数据的装载先于Store2及所有后续的存储指令刷新到内存。
StoreLoadBarrier Store1;StoreLoad;Load2 确保Store1数据对其他处理器变得可见(指刷新到内存)先于Load2及所有后序装载指令的装载。StoreLoadBarrier会使该屏障之前的所有内存访问指令(存储和装载指令)完成后,才执行屏障之后的内存访问指令。
 
原文地址:https://www.cnblogs.com/xinxihua/p/14461348.html