当前位置: 首页 > 科技观察

学妹问我,并发问题的根本原因是什么?

时间:2023-03-14 23:15:53 科技观察

并发编程是高级Java程序员必备的基本技能之一。但是写好并发程序并不容易。那么是什么原因导致很多“格子衫”的朋友写不出高质量稳定的并发程序呢?根本原因是大家对并发编程核心理论的模糊和不理解。想把技术用好。必须深入理解理论知识和核心概念。今天,我们就来看看并发编程的三个核心基础理论:原子性、可见性和顺序。1.原子性我们先来看看什么是原子性。可以进一步划分的最小粒子”,而原子操作是指“一个或一系列不能被打断的操作”。过程不被打断,或者全部不执行。(提供互斥访问,只有一个线程可以同时访问)原子在物理学中被定义为组成一个对象的不可分割的最小单元,在java并发编程中,我们可以理解为:一组要么成功要么失败的操作。1.1.产生原因原子性问题原子性问题的根本原因是什么?只有知道症状才能准确对症下药。这一节我们一起来探讨一下原子性问题的根源。我们都知道,当一个程序在执行的时候,必须以线程为单位执行,因为线程是CPU任务调度的基本单位,计算机的CPU会根据不同的任务调度算法执行线程调度,将时间划分为into切片并将它们分发给每个线程。当一个线程获得了CPU的时间片后,就获得了CPU的执行权,可以执行任务。当时间片用完后,它就失去了CPU的使用权。那么这个任务的执行就会暂时停止。在多线程场景下,由于时间片在线程之间轮换,会出现原子性问题。看了理论,好像原子性问题不能直观理解。接下来,我们将通过代码详细解释原子性问题的原因。1.2.案例分析我们以常见的i++为例。这是一个老式的原子性问题。我们先看代码rangeClosed(0,100).forEach(item->{newThread(()->{IntStream.rangeClosed(1,100)).forEach(i->{atomicDemo.add();});}).start();countDownLatch.countDown();});countDownLatch.await();System.out.println(atomicDemo.get());}}上面代码的作用是通过累计100次for来累计初始值为0的count变量每个线程100个线程。想得到一个结果为10000的值。但实际上结果很难达到10000。出现这个问题的原因:count++的执行实际上不是原子的,因为count++会拆分成下面三个步骤来执行(这样的步骤不是虚拟的,而是这样实际执行的)第一步:读取count的值;第二步:计算+1的结果;第三步:将+1的结果赋值给count变量那么问题又来了。那三步呢?让他完成行刑?理论上,每个人都非常友好。你执行完我执行,我执行完你继续。你可能会想象这样一个“乌托邦”的形象——20210430131612018,但实际上这些线程已经被“黑化”了。他们永远不能互相屈服。在CPU或程序的世界观中。每个人都在为自己所做的每一件事“争先恐后”。我们看下图:上图详细分析:第1步:线程A从主存中读取计数值0;第二步:线程A开始累加计数值;第三步:线程B读取主存中的Readthecountvalue0(PS:第三步从哪里开始并不重要,重点是:B线程在A线程写入之前开始读取count并执行将计数值写入主存,此时B线程读取的计数值仍是未被操作的原始值);第四步:(PS:这里不重要。因为不管A线程和B线程现在怎么操作。结果是不可逆的,已经错了)线程B开始累加计数值;step5:线程A将累加结果赋值给count,结果为1;第六步:线程B将累加的结果赋值给count,结果为1;第七步:线程A将结果count=1刷回主存;第八步:线程B将结果count=1刷回主存;相信此时大家已经清楚的分析出了原子性的根本原因。至于解决方案,可以通过lock或者CAS。具体方案在此不再赘述。2.可见性无论技术多么复杂,我们都需要从基本概念上来看:可见性:一个线程可以立即看到另一个线程对共享变量的修改,这称为可见性。2.1.可见性问题的原因很多年前,当嫁妆只需要一个手电筒的时候,你可能不会有这样的可见性问题,因为大家都是单核处理器,没有并发。而对于当下这个“视金钱如粪土”的时代。多核处理器已经是现代超级计算机的基础硬件。高速CPU处理器和慢速内存之前的数据通信就成了一对矛盾体。因此,为了解决和缓解这样的情况,每个CPU和线程都有自己的本地缓存。所谓本地缓存,就是缓存只对其所在的处理器可见。保证CPU缓存和内存中的数据一致并不容易。为了避免写入数据速度不一致造成的CPU性能浪费,处理器使用写缓冲区将要写入的数据暂时保存到主存中。writebuffer将多次写入同一个内存地址合并并分批刷新,也就是说writebuffer不会立即把数据刷到主存。缓存未能及时刷新到主存是可见性问题的根源。2.2.案例分析publicclassAtomicDemo{privateintcount=0;publicvoidadd(){count++;}publicintget(){returncount;}publicstaticvoidmain(String[]args)throwsInterruptedException{CountDownLatchcountDownLatch=newCountDownLatch(100);AtomicDemoatomicDemo=newAtomicDemo(ClosedAmseedDemo.range();IntString0,100).forEach(item->{newThread(()->{IntStream.rangeClosed(1,100)).forEach(i->{atomicDemo.add();});}).start();countDownLatch.countDown();});countDownLatch.await();System.out.println(atomicDemo.get());}}"what**",怎么和上面的代码一样。..结果没有截图,肯定不是10000。先看执行流程图(PS:先别纠结为什么和上面不一样,具体问题具体分析。在解释一种问题的时候,肯定会在一定程度上屏蔽另一种问题的干扰)假设线程A和线程B同时开始执行。首先,线程A和线程B会将主存中count的值加载/缓存到自己的本地内存中。然后它们会读取各自内存中的值进行运算,也就是说,此时线程A和线程B就像是两个世界的人,彼此之间不会有任何关系。运算完成后,A线程将结果写回自己的本地内存,B线程将结果写回自己的本地内存。然后在一定时间回来把结果刷回主存。最后一定是一方的数据被另一方覆盖了。这就是缓存可见性问题。3、条理相加不成千里。我们先来看一下有序性的概念:程序执行的顺序遵循代码执行的顺序。怎么了?程序是按照程序员写的代码老老实实执行的。有什么问题吗?3.1.顺序问题的原因是编译器实际上是想提高程序执行的性能。会改变我们代码的执行顺序。也就是你前面写的代码不一定先执行。例如:inta=1;整数b=4;从表面和常规的角度来看,程序的执行应该先初始化a,再初始化b。但是很有可能先初始化了b,再初始化了a。因为从编译器的角度来看,谁先初始化不会对这两个变量产生任何影响。也就是说,这两个变量之间没有数据依赖性。指令重排序分为三种类型,即:①编译器优化重排序。编译器可以在不改变单线程程序语义的情况下重新安排语句的执行顺序。②指令级并行重排序。现代处理器使用指令级并行(Instruction-LevelParallelism,ILP)来重叠执行多条指令。如果没有数据依赖,处理器可以改变语句对应机器指令的执行顺序。③记忆系统的重新排序。由于处理器使用高速缓存和读/写缓冲区,加载和存储操作可能看起来是乱序执行的。3.2.案例分析订单最常见的案例是DCL(doublechecklock),即单例模式下的doublechecklock功能。先看代码publicstaticSingletonDclDemogetInstance(){if(Objects.isNull(instance)){synchronized(SingletonDclDemo.class){if(Objects.isNull(instance)){instance=newDemoSingle;}}}returninstance;}publicstaticvoidmain(String[]args){IntStream.rangeClosed(0,100).forEach(item->{newThread(SingletonDclDemo::getInstance).start();});}}这段代码还是比较简单的。在获取对象实例的方法中,程序首先判断实例对象是否为空,如果为空则锁定SingletonDclDemo.class并再次检查实例是否为空,如果还是空则创建Singleton的实例。看起来很完美。它不仅保证了线程的完全初始化,而且在实例判断为null时,使用synchronized同步进行加锁。但是还是有问题!instance=newSingletonDclDemo();创建对象的代码分为三步:①分配内存空间;②初始化对象SingletonDclDemo;③将内存空间的地址分配给实例;但是这三个步骤重新排列后:①分配内存空间②分配内存空间的地址给实例③初始化对象SingletonDclDemo的结果会是什么?线程A先执行getInstance()方法,当指令②执行时,发生线程切换,切换到线程B;如果此时线程B也执行了getInstance()方法,则线程B会找到instance!=执行第一次判断时为null,所以直接返回instance,此时的instance还没有初始化。访问实例成员变量可能会触发空指针异常。继续上图更直观的理解:具体的执行过程上面已经分析过了。相信这张图会让你有一个透彻的了解。4.本文总结并发编程的学习和使用不是一日之功,也不是光靠几个理论就可以写出高质量的并发程序的。这需要长期的实践和总结。好的代码很少写出来,是迭代优化的。