前言
硬件和软件的发展都是相互的,硬件的发展,多核CPU,缓存,进程,线程,我们享受CPU带来的高性能的同时,必定同时也伴随着风险。为了解决这些,则出现了一些理论和实践
问题
问题一 缓存导致的可见性问题
**可见性:**一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
**单核时代:**所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。
**多核时代:**每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。
如下图 多核的变量的变更两个线程之间时不可见的,都是独立的缓存。
问题二 线程切换带来的原子性问题
原子性:我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。
**线程切换:**指的是操作系统在多任务环境下,由于需要让不同的线程轮流执行,而对当前正在运行的线程进行暂停,并切换到另一个线程上运行的过程。在多线程环境中,操作系统会通过调度算法来决定哪个线程可以运行,并在必要时进行线程切换,以实现多个线程并发执行的目的。
线程切换通常涉及以下几个步骤:
-
保存当前线程状态: 当操作系统决定要切换到另一个线程时,首先会保存当前线程的上下文信息,包括程序计数器、寄存器状态、堆栈指针等,以便稍后能够恢复到该线程的执行状态。
-
选择下一个要运行的线程: 在多任务环境下,操作系统需要决定下一个要运行的线程是哪一个。这通常由调度算法来决定,调度算法的选择会影响到线程的调度策略,比如先来先服务、最短作业优先、时间片轮转等。
-
恢复下一个线程的状态: 一旦操作系统确定了下一个要运行的线程,它会从该线程保存的上下文信息中恢复线程的状态,包括程序计数器、寄存器状态、堆栈指针等,然后将处理器的控制权转移到该线程上,使其开始执行。
线程切换的频繁发生会带来一定的性能开销,因为切换过程涉及到保存和恢复线程状态的操作,以及调度算法的开销。因此,在设计多线程应用程序时,需要注意减少线程切换的次数,以提高程序的性能和效率。
实例分析:count += 1
问题三 编译优化带来的有序性问题
编译程序的有序性问题通常指的是编译器对代码的优化可能导致的指令执行顺序与源代码中的顺序不一致的情况。在多核处理器和乱序执行的情况下,这种优化可能会引发一些意想不到的问题,尤其是在并发编程中。
具体来说,编译器在进行代码优化时可能会对指令进行重新排序,以提高程序的性能。这种重新排序可能导致源代码中的指令顺序与实际执行的指令顺序不一致,从而引发一些问题,比如:
-
数据竞争: 如果编译器将某些操作提前到了原本不应该执行的位置,可能会导致多个线程之间的数据竞争问题,从而产生不确定的结果。
-
内存一致性问题: 在多核处理器上,由于缓存和乱序执行的影响,可能会导致内存访问的一致性问题,即不同线程或核心对同一内存位置的读写顺序不一致,从而导致错误的结果。
-
指令重排导致的逻辑错误: 如果编译器进行了过度的指令重排,可能会破坏程序的逻辑顺序,导致程序行为与预期不符。
为了解决编译程序的有序性问题,可以采取以下几种方法:
-
使用内存屏障(Memory Barriers): 内存屏障是一种指令,用于确保某些操作在其他操作之前或之后执行,从而保证了内存访问的顺序性和一致性。
-
使用同步原语: 使用锁、信号量等同步原语可以保证临界区内的指令按照顺序执行,从而避免了由于指令重排导致的逻辑错误和数据竞争问题。
-
禁用特定的编译器优化: 对于涉及到并发编程的代码,可以通过编译器提供的编译选项或者特定的编译器指令来禁用特定的优化,以确保编译程序的有序性。
-
使用原子操作: 原子操作可以确保某些操作的原子性和顺序性,从而避免了编译程序的有序性问题。
java
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
-
getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:分配一块内存 M;在内存 M 上初始化 Singleton 对象;然后 M 的地址赋值给 instance 变量。但是实际上优化后的执行路径却是这样的:分配一块内存 M;将 M 的地址赋值给 instance 变量;最后在内存 M 上初始化 Singleton 对象。优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
总的来说,编译程序的有序性问题在并发编程中是一个重要的考虑因素。通过合适的编程技术和编译器选项,可以有效地解决这些问题,确保程序的正确性和性能。
总结
在介绍可见性、原子性、有序性的时候,特意提到缓存导致的可见性问题 ,线程切换带来的原子性问题 ,编译优化带来的有序性问题 ,其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。