二、多线程:可见性、有序性、原子性
二、多线程:可见性、有序性、原子性
1.可见性
1.1什么可见性?
可见性讨论的就是:当一个线程对某个共享变量做了更新操作后,这个更新的操作对于其他想要读取该共享变量的线程来说是不是可见的问题。这对于未接触或者刚刚接触多线程编程的人来说,可能有些违反常理,对一个变量进行更新后为什么会读不到呢?下面的代码就是一个例子。
public class TestVisibility {
private static boolean flag = true;
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
i++;
}
System.out.printf("**********test 跳出成功, i=%d **********\n", i);
});
t1.start();
Thread.sleep(100);
flag = false;
System.out.printf("**********test main thread 结束, i=%d **********\n", i);
}
}
上面的代码在主线程中创建了一个子线程t1,并在该线程中循环输出i,直到条件flag变为false跳出循环。运行后便会发现虽然在主线程中将flag的值更新为false,但是并没有打印出跳出循环,并且程序还继续在运行。造成这种现象的原因就是在主线程中对flag变量的更新,对于子线程t1来说是不可见的。
1.2造成不可见的原因
1.2.1编译器优化
while (flag) {
i++;
}
由于并没有给与JIT足够的提示,flag这个变量是可能被多个线程访问的,所以为了避免重复读取变量的值JIT编译器可能会把上面的循环优化成下面的样子
if(flag){
while(true){
i++
}
}
可惜的是,这样的优化会造成死循环,所以我们发现该程序一直不会退出。
1.2.2写缓冲器
在如果你读了我的上一篇文章多线程:硬件基础,可能已经想到了,写缓冲器也会造成可见性问题。主内存RAM是多线程共享的,高速缓存也可以通过缓存一致性协议进行缓存同步,可是写缓冲器中的数据是每个处理器所独有的,每个处理器只能读取到自身写缓冲器中的数据而无法读取到其他处理器的。如果针对于变量flag的写操作结果存储在写缓冲器中,并且没有接收到其他所有处理器针对于flag内存地址所对应的缓存条目的invalidate acknowledge消息的话,该更新结果就不会冲刷到高速缓存或内存中,其他处理器就无法读取到变量flag的最新值。所以对于执行写操作之外的其他处理器来说,这次写操作对于他们是不可见的。
1.2.3如何保障可见性?
根据上面造成可见性问题的原因,如果想要保证可见性就需要以下几点:
- 避免JIT编译器进行一些可能会导致可见性问题的优化
- 对于共享变量的更新操作结果一定要写入该处理器的高速缓存中,或者主内存中,而不是停留在写缓冲器中。这个过程称为冲刷处理器缓存。
- 一个处理器如果在执行读出共享变量操作的时候,有其他处理器在此之前对该变量进行过更新。那么该处理器必须从其他处理器的高速缓存中或者主内存中对相应的变量进行缓存同步。这个过程称为刷新处理器缓存。
幸运的是java为我们提供了这种工具。在代码TestVisibility中,只需要稍作改动就可以保证对于共享变量flag更新的可见性,变量flag加上关键字volatile即可。
private static volatile boolean flag = true;
简单来说volatile关键字通过告知JIT编辑器该变量是有可能被多个线程访问,从而避免JIT编译器进行一些可能会导致可见性问题的优化。并且在对volatile变量进行写操作时会冲刷处理器缓存,对volatile变量进行读时会刷新处理器缓存。至于volatile关键字,还有其他保障可见性的手段,我会在之后的文章进一步进行讲解。
2.有序性
2.1重排序
我们知道执行一个java程序需要经过几个过程
-
编写java编代码
-
java静态编译器javac把我们的源代码编译成class字节码文件
-
class文件通过解释执行或者经过JIT编译器编译成机器码
-
处理器执行机器码
在保证程序运行正确性的前提下(单线程环境),为提升程序运行的性能而做出的优化会导致上面这四个过程每两个过程之间的都有可能是不同的。例如java源代码和class字节码中顺序不同(静态编译器重排序),class字节码和JIT编译成的机器码顺序不同(JIT编译器重排序),实际上处理器执行机器码的顺序与JIT编译成的机器码顺序不同(处理器重排序),还有以中比较特殊的就是给定处理器执行指令的顺序与其他处理器所感知到的顺序不同(储存子系统重排序)。
2.1.1指令重排序
在java平台中静态编译器javac基本是不会执行指令重排序的,而JIT编译器则有可能执行指令重排序。
//JIT编译器重排序
public class Person{
private String name;
private String sex;
private int age;
public Person(String name,String sex,int age){
this.name = name;
this.sex = sex;
this.age = age;
}
public static void main(String[] args){
Person person = new Person("张三","男","18"); //操作1
}
//在main方法中的操作1,其实包含三个子操作:
//子操作1:分配Person实例所需要的内存空间并获得一个指向该空间的引用
objRef = allocate(Person.class);
//子操作2:使用Person类的构造器初始化objRef引用指向的实例
invokeConstructor(objRef);
//子操作3:将Person实例的引用objRef赋值给person
person = objRef;
//这三个操作有可能并不会按照顺序执行,有可能将子操作3重排序到子操作2之前,这就会导致别的线程去看到变量person的引用不为空,但是实例并没有被初始 化完成。
}
处理器也可能执行指令重排序。主要是因为处理器是顺序读取(一条一条的读取指令),乱序执行(拿条指令就绪执行哪条)-顺序提交(各个指令执行的结果会存储到重排序缓冲器ROB中,最后根据对应指令被读取的顺序提交)。
处理器乱序执行往往会采用一种叫猜测执行(Speculation)的技术。该技术会在执行指令遇到 if 这种判断时可能不会等待判断结果而先执行 if 体中的代码,并将结果存入到重排序缓冲器ROB中,之后执行 if 判断如果为假的话就丢弃重排序缓冲器ROB中的结果,相反则会把ROB中的结果写到内存中。这种重排序在单线程环境中不会有任何问题,但是在多线程环境中可能会造成线程安全问题,例如下面代码。
public class TestSpeculation{
private boolean ready = false;
private int[] data = new int[] {1,2,3,4,5,6,7,8}
public void processData(){
int[] new newData = new int[8];
for(int i=0; i<newData.length; i++){
newData[i] = data[i] - i;
}
data = newData;
ready = truel
}
public void readData(){
if(ready){ //操作1
System.out.println(data); //操作2
}
}
}
在多线程环境下,如果一个线程t1执行processData方法,另一个线程t2执行readData方法,并且两个线程处于p1和p2处理器上。如果p2处理器采用猜测执行技术,先执行操作2并存储到重排序缓冲器ROB中,而此时p1处理器并未完成数据的数据操作ready值为false。当p2执行操作1时恰好p1处理器刚刚执行完数据处理操作将ready的值更新为true。此时p2读到的ready值为true,因此不会丢弃其ROB中读取到的data值,所以打印出的结果并不是我们想要的[1,1,1,1,1,1,1,1]而是未经处理的[1,2,3,4,5,6,7,8]。
2.1.2内存重排序(也称储存子系统重排序)
内存重排序相比较与指令重排序来说,并没有真正的对指令进行重排序,而是一种现象。例如:处理器p0严格按照源代码的顺序执行操作s1,s2,但是在其他处理器感知到的顺序可能与处理器p0的执行顺序不同。这种现象是在储存子系统的影响下做内存操作而导致的。
Store代表写内存,Load代表读内存。写缓冲器会造成StoreLoad重排序(其表示写内存操作S被重排序到读内存L操作之后了)和StoreStore重排序(其表示写内存操作S1被重排序到写内存操作S2之后了)。无效化队列会造成LoadLoad重排序(其表示读内存操作L1被重排序到读内存操作L2之后了)。
写缓冲器造成的StoreLoad重排序:
Processor0 | Processor1 |
---|---|
X=1 //S1 | Y=1 //S3 |
r1=Y //L2 | |
r2=X //L4 |
假设P0和P1按照上表的顺序执行所有操作,其中X,Y为共享变量,r1,r2为局部变量,其初始值均为0。当P0执行到L2时,虽然S3已经执行完成,但是由于其结果可能还存储在写缓冲器中,所以此时P0读到Y的值有可能依然时初始值0。同理当P1执行到L4时,其读取到的X的值也可能是初始值0,而不是1。因此从P1的角度来看,就像是L2已经执行完成,但是S1还没有执行,P1对操作S1,L2的感知顺序是L2->S1。此时我们就可以说操作S1被重排序到了操作L2之后。
写缓冲器造成的StoreStore重排序:
Processor0 | Processor1 |
---|---|
data = 1 //S1 | |
ready = true //S2 | |
if(ready) //L3 | |
pirnt(data) //L4 |
假设P0和P1按照上表的顺序执行所有操作,其中data ,ready为共享变量其初始值分别为0和false。并且假设P0的缓存中有data的副本,而没有ready的副本,因此执行S1时会把data数据直接写入到缓存中,而执行S2会把ready值存入写缓冲器。所以当执行到L3时,通过缓存同步可以读取到ready的值为true,继续执行L4,但此时data的更新结果可能还存在写缓冲器之中,所以L4读取到的值很可能是0,而不是1。这样从P1的角度来看,就好像S2执行完成,而S1还未执行,P1对S1,S2的感知顺序是S2->S1。此时我们就可以说写操作S1被重排序到写操作S2之后。
无效化队列造成的LoadLoad重排序:
P0和P1也按照上表执行data和ready的相关操作其初始值分别为0和false。此时我们假设P0和P1的缓存上都有data的副本,只有P0有ready的副本P0执行S1时由于缓存中data的副本并不是独占状态,会把更新data的操作结果写入写缓冲器并发送Invalidate消息,此时P1接收到Invalidate消息后将该消息存储到无效化队列就回复Invalidate Acknowledge消息,并没有立即将data所处的缓存条目的状态置为I也就是无效。P0接受到Invalidate Acknowledge消息后将data的更新结果写入高速缓存,然后执行S2。由于此时,只有ready副本的状态是独占的,所以直接把ready的值写入高速缓存。此时P1执行到L3可以通过缓存同步读取到ready的值为true,继续执行L4,由于P1的缓存上data的缓存条目的状态还没有置为I也就是无效,所以此时P1直接从缓存中读取到data的旧值0。由此可见,即使P0对data和ready的更新操作按顺序写到了缓存中,但是由于无效化队列的作用,就好像是P1在ready=false的情况下提前读取了data,因为从P0看来,如果P1上能执行到L4这一步,也就是ready=true的话,打印出的data值一定是1。所以从P0的角度来看,L4操作被重排序到了L3操作之前。
3.原子性
原子性操作指的是一组访问共享变量的操作,在除了执行线程外的线程看来,要么全部完成,要么全部没有执行,那么我们就可以称该操作具有原子性。
原子操作的概念是在多线程环境下才有意义的,讨论单线程环境下的操作无所谓是否具有原子性是没有意义的,或者我们可以把单线程环境下的操作都视为原子操作。
原子操作是针对访问共享变量来说的,仅仅访问局部便变量的操作是无所谓的,因为局部变量的访问是单线程的。
Java语言中针对任何变量的读操作都是原子的,对于除了double/long的基本类型的写操作也是原子的。
Java中保证原子性可以使用锁:synchronized或者ReentrantLock;还有就是处理器提供的CAS指令。锁与CAS指令保证原子性的方式本质上是一致的,只不过锁是在代码层次实现的,而CAS指令是在硬件(处理器内存)层次实现的。