并发编程万恶之源
我们都知道编写正确的并发程序是一件极其困难的事情,并发程序经常出现一些极其诡异的BUG,想要快速又精确的找到这些BUG,就需要追本溯源理解这些”万恶”的源头。
高速缓存模型带来可见性问题
计算机绝大多数运算任务,都不可能只靠一个CPU就能完成,往往还需要和内存,硬盘进行交互。我们知道 CPU 的运行速度远远快于内存的速度,因此会出现 CPU 等待内存读取数据的情况。
由于两者的速度差距实在太大,为了加快运行速度,于是计算机的设计者在 CPU 中加了一个CPU 高速缓存。这个 CPU 高速缓存的速度介于 CPU 与内存之间,每次需要读取数据的时候,先从内存读取到CPU缓存中,CPU再从CPU缓存中读取。这样虽然还是存在速度差异,但至少不像之前差距那么大了。
缓存一致性
高速缓存引入了一个新的问题:缓存一致性(Cache Coherence)。在多核CPU系统中,每个CPU都有自己的高速缓存,而它们又公用一块主内存(Main Memory)。当多个CPU的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存不一致。如果真发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?
CPU与内存操作变量
一般来说,有两种方式解决缓存一致性问题
•通过在总线加LOCK#锁的方式
•通过缓存一致性协议
这2种方式都是硬件层面上提供的方式。
在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。
但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。
由于总线加Lock锁的方式效率低下,后来便出现了缓存一致性协议。最出名的就是Intel 的MESI协议。
MESI协议
MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
CPU乱序执行优化带来有序性问题
除了增加高速缓存外,为了使得处理器内部的运算单元能够尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行是一致的,但不保证程序中各个语句执行的先后顺序和输入的顺序一致。Java虚拟机的即时编译器也有类似的指令重排序(Instrution Reorder)优化。
线程切换带来原子性问题
现代的操作系统都基于线程来调度,线程切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如count += 1
,至少需要三条 CPU 指令。
•首先,需要把变量 count 从内存加载到 CPU 的寄存器;
•之后,在寄存器中执行 +1 操作;
•最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条 CPU 指令执行完。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 B 执行到指令3后线程A再执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。
CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
总结
缓存导致的可见性问题,线程切换带来的原子性问题,CPU乱序执行优化带来的有序性问题。我们就能更好理解并发编程的特性了:
1. 原子性(Atomicity)
原子性指的是一个操作是不可中断的整体,要么全部执行,要么全部不执行。 在多线程环境下,原子性是指一个操作(或一组操作)在执行过程中不会被其他线程干扰。
2. 可见性(Visibility)
可见性指的是当一个线程修改了共享变量的值,其他线程能够立即看到修改后的值。 在多线程环境下,如果一个线程修改了共享变量的值但其他线程无法立即看到,就可能导致数据不一致的问题。
3. 有序性(Ordering)
有序性指的是程序执行的顺序按照代码的先后顺序执行。 在多线程环境下,由于指令重排序的存在,可能导致代码执行的顺序与编写的顺序不一致。
Java内存模型
什么是JMM
JMM 是 Java 内存模型,与JVM 内存模型是两回事,JMM 的主要目标是定义程序中变量的访问规则,如下图所示,所有的共享变量都存储在主内存中共享。每个线程有自己的工作内存,工作内存中保存的是主内存中变量的副本,线程对变量的读写等操作必须在自己的工作内存中进行,而不能直接读写主内存中的变量。
在多线程进行数据交互时,例如线程 A 给一个共享变量赋值后,由线程 B 来读取这个值,A 修改完变量是修改在自己的工作区内存中,B 是不可见的,只有从 A 的工作区写回主内存,B 再从主内存读取自己的工作区才能进行进一步的操作。由于指令重排序的存在,这个写—读的顺序有可能被打乱。因此 JMM 需要提供原子性、可见性、有序性的保证。
JMM如何保证
原子性(Atomicity)
JMM保证对除了long
和double
之外的基本数据类型的读取和写入是原子性的。这意味着在多线程环境下,一个线程执行的读写操作要么完全执行,要么没有执行,不会出现中间状态。此外,Java提供的关键字synchronized
也可以保证原子性。当使用synchronized
关键字修饰代码块或方法时,它会将代码块或方法标记为临界区,确保同一时间只有一个线程可以执行该临界区的代码,从而保证原子性。
可见性(Visibility)
JMM通过使用内存屏障和缓存一致性协议来保证可见性。当一个线程对共享变量进行写操作时,JMM会将该变量的最新值刷新到主内存中,并使其他线程的工作内存失效,强制它们从主内存中重新获取最新值。这样可以确保其他线程能够看到最新的变量值,从而保证了可见性。
使用volatile
关键字可以实现可见性。当一个变量被声明为volatile
时,对该变量的读写操作都会直接在主内存中进行,而不会使用线程的工作内存。这样可以确保对volatile
变量的修改对其他线程立即可见。
有序性(Ordering)
JMM通过禁止指令重排序(volatile)和使用happens-before原则来保证有序性。指令重排序是指处理器在执行指令时可能会对指令进行优化,改变其执行顺序,但不改变程序的语义。在多线程环境下,指令重排序可能导致线程之间观察到的执行顺序与程序中的顺序不一致,从而引发错误。
volatile
volatile 关键字通过使用内存屏障(memory barrier)和禁止指令重排序来保证可见性和有序性。
1.可见性:当一个变量被声明为volatile
时,在每次对该变量的写操作完成后,JVM会强制将写入操作立即刷新到主内存中,而不是只在线程的工作内存中保留副本。同时,对于其他线程来说,在读取该变量之前,JVM会强制要求从主内存中获取最新的值,而不是使用线程的工作内存中的副本。这样可以确保在一个线程修改了volatile
变量的值后,其他线程能够立即看到最新的值,从而保证了可见性。2.有序性:volatile
关键字还可以保证变量操作的有序性。在volatile
变量的写操作之后,JVM会插入一个内存屏障,这个屏障会阻止在写操作之后的指令重排序。类似地,在volatile
变量的读操作之前,JVM会插入另一个内存屏障,这个屏障会阻止在读操作之前的指令重排序。这样可以确保对volatile
变量的操作按照程序的顺序进行,避免了指令重排序带来的问题,从而保证了有序性。
内存屏障是一种硬件或软件层面的机制,用于控制指令的执行顺序和内存访问的顺序。它可以阻止指令重排序,确保内存操作按照预期顺序执行。内存屏障的插入和处理由JVM和硬件共同完成,以确保volatile
变量的可见性和有序性。
下面是通过volatile实现线程安全单例方法的例子。
在这个示例中,使用了双重检查锁定(double-checked locking)的方式来实现延迟加载的线程安全单例模式。关键点是将instance
声明为volatile
,以保证多线程环境下对instance
的可见性。
在getInstance()
方法中,首先检查instance
是否已经创建,如果尚未创建,才会进行同步块的操作。在同步块内部,再次检查instance
是否为null
,这是为了防止在多个线程通过第一次检查后,其中一个线程已经创建了实例,其他线程不需要再次创建。只有在第二次检查时,如果instance
仍然为null
,才创建实例。
通过使用volatile
关键字,可以确保在一个线程对instance
进行写操作后,其他线程能够立即看到这个修改,从而避免了多个线程同时创建实例的问题,保证了线程安全性。
happens-before
happens-before是Java内存模型(Java Memory Model,JMM)中的一个概念,它用于定义多线程程序中操作之间的偏序关系,确保操作按照预期顺序执行。
happens-before包括一系列规则:
1.程序顺序规则(Program Order Rule):在一个线程内,按照程序的顺序,前面的操作"happens-before"于后续的操作。也就是说,一个线程内的操作按照代码的顺序执行,后续的操作可以看到前面的操作的影响。2.volatile变量规则(Volatile Variable Rule):对一个volatile
变量的写操作"happens-before"于后续对该变量的读操作。这个规则确保了对volatile
变量的写操作对于后续的读操作是可见的。在这个示例中,通过将flag
声明为volatile
变量,保证了写操作的"happens-before"于后续的读操作。这意味着在reader()
方法中,如果flag
的值为true
,那么它一定能够看到writer()
方法设置的flag
的更新。
3.传递性规则(Transitive Rule):如果操作A"happens-before"于操作B,并且操作B"happens-before"于操作C,则操作A"happens-before"于操作C。这个规则保证了操作之间的传递性,即如果A先于B,B先于C,那么A必然先于C。在下面示例中,假设有两个线程,一个线程执行writer()
方法,另一个线程执行reader()
方法。 根据传递性规则,在示例代码中,当writer()
方法执行写操作A(x = 42
)之后,紧接着执行写操作B(flag = true
)。然后,当reader()
方法执行读操作C(if (flag)
)时,它能够看到在写操作B之前对flag
的修改。因此,根据传递性规则,reader()
方法执行读操作D(System.out.println("x = " + x)
)时,它也能够看到在写操作A之前对x
的修改,打印出更新后的值42。
4.监视器锁规则(Monitor Lock Rule):对一个锁的解锁操作"happens-before"于后续对该锁的加锁操作。这个规则确保了对监视器锁的解锁操作对于后续的加锁操作是可见的,即保证了线程之间的同步顺序。这个规则中说的锁其实就是Java里的 synchronized。5.线程启动规则(Thread Start Rule):一个线程的启动操作"happens-before"于其后续的所有操作。这个规则保证了一个线程启动后的操作对于其他线程是可见的。在下面示例中,startThread()
方法创建一个新的线程并启动它,而新线程中执行的代码对变量x
进行写操作。在主线程中,调用printValue()
方法执行对变量x
的读操作并打印其值。当调用thread.start()
启动新线程时,新线程中的写操作(x = 42
)在主线程的读操作之前发生。因此,根据线程启动规则,主线程中的读操作printValue()
能够看到新线程中对x
的修改,输出更新后的值42。
6.线程终止规则(Thread Termination Rule):一个线程的所有操作"happens-before"于其终止操作。这个规则保证了一个线程的所有操作对于其他线程是可见的。
本文使用 文章同步助手 同步