您的位置:首页 > 其它

二、聊聊并发 — 深刻理解并发三问题

2020-05-11 04:14 1746 查看

文章目录

前言

上篇文章我们已经聊了线程安全,大概了解了对线程安全产生影响的重要因素是什么,我们还聊到了多线程的消息传递方式和内存交互方式,正因为这种交互方式使得共享变量在多线程之前存在可见性问题,除此之外还有处理器为了指令优化导致的重排序以及原子操作问题,那这一篇我们就来细聊一下并发的这三个问题,让大家对并发程序中的重排序、内存可见性以及原子性有一定的了解

指令重排序

重排序通常是编译器或内存系统或者是处理器为了优化程序性能而采取的对指令进行重新排序执行的一种手段。按照程序运行在不同阶段,大致可以将重排序分为三种:编译器优化重排序指令级并行重排序内存系统重排序

  1. 编译器优化重排序就是通过调整指令顺序,在不改变程序语义的前提下,尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值。

  2. 指令级并行重排序是现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存系统是不会进行重排序的,由于使用缓存和读/写缓冲区,这使得操作看上去可能是在乱序执行。

下面我们通过例子分析一下重排序对程序的影响。

public class ConcurrentTest{
private static int x, y;
private static int a , b ;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; )   {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
CountDownLatch latch = new CountDownLatch(1);
Thread one = new Thread(() -> {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;

});
Thread other = new Thread(() -> {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
});
one.start();
other.start();
latch.countDown();
one.join();
other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
运行结果:
...
...
第445次 (1,0)
第446次 (0,1)
第447次 (1,0)
第448次 (0,1)
第449次 (0,0)

上述的代码我们模拟了两个线程并发的场景,如果按照正常的代码逻辑是不可能出现xy0,但在某一时刻出现了 x == y == 0的情况下,那我们就来分析一下。

在循环开始的时候我们就设置了x、y、a、b的值,让它们一直为0,那我们就默认为他们的初始状态为0,按照我们上面所说,如果两个指令之间不存在数据依赖关系,编译器、处理器可能对指令进行优化,调整他们的执行顺序,假如这两个线程的执行的指令都被优化,进行了上图中的重排序,那么某一时刻线程A看到 a 的值为 0,所以y = 0;同理,线程B也是同样的道理,就有可能导致 y == x == 0。

我们前面也提到内存系统不会进行重排序,主要是因为线程A操作完变量以后,B线程不能及时看到A线程对变量修改后的结果,导致操作看上去是乱序的,那我们从内存可见性的角度再来分析一下。

如上图所示,线程A、B操作共享变量,需要将共享变量拷贝一份副本到自己的工作内存中,操作结束以后,将修改后的值同步到主内存中,但这个过程中有很多没办法预料的结果发生(在不能控制线程的执行顺序情况下),假如线程A先执行,已经修改了变量b的值,但还没有同步到主内存中,此时线程B从主内存中读取的变量b的值还是0,最后xy0。假如A、B线程同时执行,都从主内存读取到变量的值,操作完成以后,将值同步到的主内存中,此时同步以后主内存中还是 x == y == 0。所以说,当没有任何其他措施的情况下,多线程去操作共享变量时,线程的执行顺序完全由处理器进行调度,产生的结果也是不可预知的。

内存可见性

在我看来内存可见性可以换一种说法,用数据一致性这个词来代替可能会比较好理解一些。作为软件开发人员,我们可能对数据一致性这个词比较敏感一点,数据一致性问题也是我们经常会遇到的。例如Redis作为缓存和数据库之间的数据一致性,计算机硬件架构中高速缓冲区域和系统主内存之间的数据一致性。感兴趣的朋友可以去看看高速缓冲区是什么 CPU高速缓冲区,其中提到了一个概念:缓存一致协议,也就是我们所说的数据一致性。想对缓存一致协议了解一下的同学可以看这里:缓存一致协议。等到我们了解了Java内存模型,我们会感觉到其实缓存一致协议和Java内存模型有很多相像之处。

看完上述所说的,你心里可能也许大概有一点明白了。Java作为高级语义,能够跨平台运行在不同的操作系统上,其实是通过虚拟机屏蔽了这些底层细节,定义了一套自己读写内存数据的规范,让开发人员不再需要关心硬件或操作系统中的缓存、内存交互问题。但是也抽象了主内存和本地内存的概念:所有的共享变量存在于主内存中,每个线程有自己的本地内存,线程读写共享数据也是通过本地内存交换的,所以可见性问题依然是存在的。我们这里说的本地内存并不是真的是一块给每个线程分配的内存,而是 JMM 的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象。但是内存模型也给出了解决这些问问题的办法,我们后面再详细的聊一聊。

原子性

原子操作即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

在Java并发编程中,如果对一个共享变量的操作不是原子操作,那有可能得不到你想要的结果。当多个线程访问同一个共享变量,且对共享变量的操作不是原子操作,那可能存在一个线程执行这个操作执行一半,另一个线程也执行这个非原子的操作,这样就会导致两个线程执行结果有误。我们通过例子来看一下。

public class ConcurrencyTest {
public static void main(String[] args) {
MyThread myThread = new MyThread();
Thread thread = new Thread(myThread, "A");
Thread thread1 = new Thread(myThread, "B");
Thread thread2 = new Thread(myThread, "C");
Thread thread3 = new Thread(myThread, "D");
Thread thread4 = new Thread(myThread, "E");
thread.start();
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}

class MyThread implements Runnable {
private volatile int count = 0;
public void run() {
//synchronized (this) {
count++;
System.out.println("线程" + Thread.currentThread().getName() + "计算,count=" + count);
//}
}
}
运行结果:
线程A计算,count=2
线程E计算,count=5
线程D计算,count=4
线程C计算,count=3
线程B计算,count=2

对此我们就不在这里对原子性展开探讨了,我会在后面的文章中详细的来说一下Java中的原子操作问题。

总结

这里主要聊了一下并发编程中重排序、原子性、可见性对其带来的影响,也让我们对此并发编程线程安全问题有了进一步认识。那下一篇文章我会来详细的聊一聊如何解决这些线程安全的问题,以及内存模型对此的解决方案。

lisnail1 原创文章 7获赞 8访问量 1014 关注 私信
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐