浅谈Java并发编程
浅谈Java并发编程
最近看了看并发编程相关的一些书籍和文章,想谈一谈自己对于并发编程的一些理解和看法,一些浅薄之见,如有不对的地方还请大家批评指正。
想要讲清楚一件事,我认为最重要的是以下几点:
what?这个东西是啥?
why?为什么有了这个东西?这个东西是用来干啥的?解决什么问题?
how?这个东西为了解决问题,是怎么做的?
只要能把这几点讲清楚,整个事情就一目了然了。
今天,我们就用这个框架,来聊一聊并发编程。
并发编程
what?
简单理解,并发编程就是允许在一个线程让出CPU(可能在I/O)的情况下,允许另一个线程来使用CPU。
why?
如果不使用并发编程,可能一个线程中,10%的时间在使用CPU,但是90%的时间花费在I/O上,这样的话,CPU的利用率仅有10%,白白浪费了大量的CPU性能。如果允许并发编程,在一个线程进行I/O的时候,另一个线程就可以去使用CPU,这样就极大地提升了CPU的利用率,整体上提升了程序的性能。
how?
那么,想要实现并发编程,具体需要怎么做呢?
- 在计算机体系结构方面,CPU 增加了缓存,以均衡与内存的速度差异;
- 在操作系统方面,使用进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
- 在编译程序方面,优化指令执行次序,使得缓存能够得到更加合理地利用;
但是,由于这三种做法,又带来了【可见性】、【原子性】、【有序性】问题,只有将这几个问题解决,才能真正实现安全的并发编程。接下来,我们逐一进行解释。
可见性
what?
可见性问题指的是在一个线程中对共享变量的修改,其他线程可能看不到这些修改,因为这些修改可能没有及时同步到主内存中。
why?
为什么需要CPU缓存?
CPU增加缓存的主要目的是为了缓解CPU和内存之间速度差异的问题,这种差异被称为“内存墙”(Memory Wall)。以下是详细解释为什么需要缓存来均衡这种速度差异:
- 充分利用CPU的计算能力:CPU的运行速度远远快于内存的访问速度。如果CPU直接从内存中读取数据,它会花费大量时间等待数据,这会导致CPU的计算能力得不到充分利用。
- 局部性原理:计算机程序通常表现出良好的局部性,即它们倾向于重复访问最近或邻近访问过的数据。缓存利用这一原理,将频繁访问的数据保留在快速的缓存中,减少了对慢速内存的访问。
- 减少延迟:内存访问的延迟远高于CPU内部操作的延迟。缓存可以减少CPU访问内存的延迟,因为它比内存更接近CPU,且访问速度更快。
- 带宽限制:内存的带宽有限,无法同时满足多个CPU核心的数据请求。缓存可以减少对内存总线的争用,提高数据传输效率。
- 功耗考虑:访问内存比访问缓存消耗更多的能量。通过减少对内存的访问,可以降低功耗,延长电池寿命(对于移动设备尤其重要)。
- 成本效益:虽然缓存的成本高于内存,但是相比于提高内存速度所需的成本,使用缓存是一种更经济的解决方案。
- 多级缓存:现代CPU通常使用多级缓存(如L1、L2、L3缓存)来进一步优化性能。每一级缓存的速度和成本不同,形成一个速度-成本的平衡。
- 预取技术:缓存还可以使用预取技术,预测CPU即将需要的数据,并提前将其加载到缓存中,减少等待时间。
- 数据一致性:在多核处理器中,缓存还有助于维护数据一致性。通过缓存一致性协议,确保所有核心看到的是最新的数据。
- 系统性能:缓存可以显著提高系统的整体性能,因为它减少了CPU等待内存数据的时间,使得CPU可以更高效地执行指令。
CPU缓存为什么会带来可见性问题?
在没有缓存的情况下,如果线程1对变量a的值进行了修改,需要将其写入内存,线程2再去内容中读变量a,就可以读到线程1对变量a所做的修改;
但是有了缓存之后,尤其是在现代多核CPU处理器上,每个CPU有自己的一份缓存。
如果线程1访问的是CPU1,对变量a的值进行了修改,只修改了其缓存,线程2访问的是CPU2,读到的是CPU2的那份缓存,其中变量a的值并没有发生变化,就可能读不到线程1对变量a所做的修改;(这个问题基本上可以被缓存一致性协议解决,注意,是基本上)
JMM工作内存为什么会带来可见性问题?
在Java中,JMM定义了工作内存的概念,每个线程都有自己的工作内存,且对变量的操作都是在工作内存中进行的。
如果线程1访问的是CPU1,对变量a的值进行了修改,只修改了线程1的工作内存中变量a的值,线程2访问的也是CPU1,读到的是自己的工作内存中缓存的变量a的值,其中变量a的值并没有发生变化,就可能读不到线程1对变量a所做的修改;(这个问题可以被volatile关键字解决)
how?
缓存一致性协议
在多CPU的系统中,为了保证各个CPU的高速缓存中数据的一致性,会实现缓存一致性协议,每个CPU通过嗅探在总线上传播的数据来检查自己的高速缓存中的值是否过期,当CPU发现自己缓存行对应的主存地址被修改时,就会将当前CPU的缓存行设置成无效状态,当CPU对这个数据执行修改操作时,会重新从系统主存中把数据读到CPU的高速缓存中。
但是,如果缓存未命中,核心需要从更慢的内存层次(如L2缓存、L3缓存或主内存)中获取数据,这可能导致该核心暂时看不到其他核心已经做出的更新。又或者指令重排序导致了缓存优化,也有可能导致多CPU中的缓存不一致。所以说,缓存一致性协议只能“基本上”解决CPU层面的可见性问题。
但是,缓存一致性协议仅仅保障内存弱可见(高速缓存失效),没有保障共享变量的强可见,而且缓存一致性协议更不能禁止CPU重排序,也就是不能确保跨CPU指令的有序执行。
缓存一致性协议是硬件层面的协议,比如MESI协议,这里就不展开讲了。
volatile关键字
在Java中,volatile关键字来保证可见性。volatile是怎么做的呢?
如果共享变量var加了volatile关键字,那么在对应的汇编指令中,操作var之前多出一个lock前缀指令lock addl,该lock前缀指令有三个功能。
- 将当前CPU缓存行的数据立即写回系统内存;
- lock前缀指令会引起在其他CPU中缓存了该内存地址的数据无效;
- lock前缀指令禁止指令重排;作为内存屏障(Memory Barrier)使用,可以禁止指令重排序,从而避免多线程环境下程序出现乱序执行的现象。
这样,如果线程1对变量a的值进行了修改,会将a的值立即写回主存,且线程2中的工作内存中的a的缓存会立即失效,必须从主存中重新读取,并且由于禁止了指令重排序,这样就可以读取到最新的值。
原子性
what?
原子性指的是一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
原子性问题指的是一段代码中的操作,可能有一些执行成功了,但有一些并未达到预期的效果,原因是CPU的时分复用。
why?
举个简单的例子,看下面这段代码:
int i = 1;
// 线程1执行
i += 1;
// 线程2执行
i += 1;
这里需要注意的是:i += 1需要三条 CPU 指令:
- 将变量 i 从内存读取到 CPU寄存器;
- 在CPU寄存器中执行 i + 1 操作;
- 将最后的结果i写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
由于CPU分时复用(线程切换)的存在,线程1执行了第一条指令后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换会线程1执行后续两条指令,将造成最后写到内存中的i值是2而不是3。
how?
synchronized关键字和Lock
Java内存模型保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。
由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
有序性
what?
有序性即程序执行的顺序按照代码的先后顺序执行。
有序性问题即编译器和处理器常常会对指令做重排序,导致程序执行的顺序没有按照代码的先后顺序执行。
why?
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。
- 编译器重排序。编译器重排序指的是在代码编译阶段进行指令重排,不改变程序执行结果的情况下,为了提升效率,编译器对指令进行乱序(Out-of-Order)的编译。
- CPU重排序。流水线(Pipeline)和乱序执行(Out-of-Order Execution)是现代CPU基本都具有的特性。机器指令在流水线中经历取指令、译码、执行、访存、写回等操作。为了CPU的执行效率,流水线都是并行处理的,在不影响语义的情况下。处理次序(Process Ordering,机器指令在CPU实际执行时的顺序)和程序次序(Program Ordering,程序代码的逻辑执行顺序)是允许不一致的,只要满足As-if-Serial规则即可。显然,这里的不影响语义依旧只能保证指令间的显式因果关系,无法保证隐式因果关系,即无法保证语义上不相关但是在程序逻辑上相关的操作序列按序执行。CPU重排序包含以下两类:
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
how?
As-if-Serial规则
As-if-Serial规则的具体内容为:无论如何重排序,都必须保证代码在单线程下运行正确。
为了遵守As-if-Serial规则,编译器和CPU不会对存在数据依赖关系的操作进行重排序,因为这种重排序会改变执行结果。但是,如果指令之间不存在数据依赖关系,这些指令可能被编译器和CPU重排序。
虽然编译器和CPU遵守了As-if-Serial规则,无论如何,也只能在单CPU执行的情况下保证结果正确。在多核CPU并发执行的场景下,由于CPU的一个内核无法清晰分辨其他内核上指令序列中的数据依赖关系,因此可能出现乱序执行,从而导致程序运行结果错误。
内存屏障
内存屏障又称内存栅栏(Memory Fences),是一系列的CPU指令,它的作用主要是保证特定操作的执行顺序,保障并发执行的有序性。在编译器和CPU都进行指令的重排优化时,可以通过在指令间插入一个内存屏障指令,告诉编译器和CPU,禁止在内存屏障指令前(或后)执行指令重排序。
硬件层常用的内存屏障分为三种:读屏障(Load Barrier)、写屏障(Store Barrier)和全屏障(Full Barrier)。
-
读屏障
读屏障让高速缓存中相应的数据失效。在指令前插入读屏障,可以让高速缓存中的数据失效,强制重新从主存加载数据。并且,读屏障会告诉CPU和编译器,先于这个屏障的指令必须先执行。
-
写屏障
写屏障在指令后插入写屏障指令能让高速缓存中的最新数据更新到主存,让其他线程可见。并且,写屏障会告诉CPU和编译器,后于这个屏障的指令必须后执行。
-
全屏障
全屏障是一种全能型的屏障,具备读屏障和写屏障的能力。
硬件层的内存屏障的作用
-
阻止屏障两侧的指令重排序
编译器和CPU可能为了使性能得到优化而对指令重排序,但是插入一个硬件层的内存屏障相当于告诉CPU和编译器先于这个屏障的指令必须先执行,后于这个屏障的指令必须后执行。
-
强制让高速缓存的数据
失效硬件层的内存屏障强制把高速缓存中的最新数据写回主存,让高速缓存中相应的脏数据失效。一旦完成写入,任何访问这个变量的线程将会得到最新的值。
volatile关键字
volatile语义中的有序性是通过内存屏障指令来确保的。
为了实现volatile关键字语义的有序性,JVM编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。JMM建议JVM采取保守策略对重排序进行严格禁止。
下面是基于保守策略的volatile操作的内存屏障插入策略。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
原文地址:https://blog.csdn.net/itigoitie/article/details/144314449
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!