并发编程不仅仅是Java语言的难题,其他语言也同样存在(一般都是中高级开发需要面临的问题)。问题的根源在于硬件设备CPU、内存、I/O在不断的迭代发展过程中产生了差异。CPU和内存的差异可以理解为天上一天地下一年,内存与I/O设备的差异就更大了,可以理解为天上一天地上十年【之前看到过各设备的速度比的图例,但是没有找到】。I/O不仅指磁盘的IO,随着现在微服务的发展,网络IO的延迟问题也越来越凸显。
根据木桶理论,程序整体的运行速度取决于短板,即当Cpu需要运行一条指令时,需要等待其他更慢的内存,磁盘,网络IO返回数据,所有单单增加CPU的性能是无效的。并且随着摩尔理论的失效,从提升单核计算能力发展为提升计算机的核数,对软件开发也发生着巨大的变化。
为了合理利用CPU的高性能,平衡三者的速度差异,计算机体系结构,操作系统,编译程序都作出了巨大的贡献。当然,效果也是非常的明细(并发程序写的好,与单线程程序执行速度,性能提升数倍),但是也引出了下面的三个并发编程的问题。
1、CPU增加了缓存,以均衡与内存之间的差异
2、操作系统加载了进程和线程,以分时复用CPU,进而均衡了CPU和I/O设备的速度差异
3、编译程序优化程序执行,使得缓存能够更加合理的利用
一、可见性(增加并行度)
对于多核心CPU(可能一核对应多线程,比如我现在的笔记本就是4核8线程)在同一时刻并行度最大为核心线程数,由于上面所述引入了多核心的问题,那么底层的物理结构大致如:
那么一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。当然对于并发时,怎么保证两个CPU或核心线程可见,有共享变量和消息传递两种方式,而很明显Java选择了共享变量。
可见性引起的问题示例比较常见的就是叠加器,线程A操作了一个共享变量叠加后,线程B没有拿到最新的更新后的值就进行叠加,最后将原值进行了覆盖。示例代码如:
/**
* 【缓存】可见性问题
*
* 打印结果: 14786、20000、15444
*
* @author kevin
* @date 2020/7/26 15:10
* @since 1.0.0
*/
public class superpositionDemo {
/**
* 起始值
*/
private long count = 0;
private long getCount() {
return count;
}
/**
* 加加一万次
*/
private void add() {
int index = 0;
int loop = 10000;
while (index++ < loop) {
++count;
}
}
/**
* 计算多线程下的总值
* @return 叠加的结果
* @throws InterruptedException 线程中断异常
*/
public Long calc() throws InterruptedException {
final superpositionDemo superpositionDemo = new superpositionDemo();
Thread thread = new Thread(() -> superpositionDemo.add());
Thread thread1 = new Thread(() -> superpositionDemo.add());
// 启动线程,等待CPU时间切片
thread.start();
thread1.start();
// 等到两个线程执行结束
thread.join();
thread1.join();
return superpositionDemo.getCount();
}
public static void main(String[] args) throws InterruptedException {
Long calc = new superpositionDemo().calc();
System.out.println(calc);
}
}
二、原子性(增加并发)
即使是电脑只有一个CPU【1核心】的情况下,我们即可以一边听着音乐一边执行Java程序,但是同一时刻只能做一件事只是时间切片发生太快我们并不能感知到。能实现这一切的前提这就是上面说的操作系统增加了进程和线程以分时调用CPU时间切片。那么在多核的情况下,同一时刻就可以做核心线程度件事。
操作系统控制着每个线程或进程内的CPU指令,但是对于Java等高级语言的一条语句可能底层由多条CPU指令组成,那么当多CPU(多核心)的情况下可能在中途发生了时间切片,那么就可能存在原子性的问题。我们把一个或者多个操作在CPU执行过程中被中断的特性称为原子性。比如 上面的count++操作,对应底层CPU指令:
1、首先需要将count从内存加载到CPU的寄存器
2、对寄存器中的count +1操作
3、将结果写入内存,但是可能写入的是CPU的高速缓存而不是真正的内存
在多核、高速缓存的情况下,只有上面的三个操作执行过程不能被打断,才能保证程序最后的正确性,否则就得不到我们想要的结果,如下:
三、有序性
上面提到编译优化,可以提高程序的性能。指令重排续优化大致可分为编译器优化,指令级并行的重排续,内存系统的重排续,并且能大幅度提供程序的性能。现在的CPU一般采用流水线来执行指令,一条指令又被分成多个阶段,并且可以同时执行,对指令重拍续可以防止瀑布流的中断大幅提高性能。
在单核线程下,对指令重拍续后不会对我们需要的结果发生变化,但是如果是在多核情况下却不能保证,那么可以认为这是硬件工程师给软件工程师挖的一个坑。解决的办法就是在并发程序的时候,不能进行指令重排续,【后续分析Java的解决方案】。指令重拍续的问题更好理解了,比如上面的一条Java语句对应着多条CPU指令,那么对语句的执行顺序可能发生调换,但是只保证在单线程情况下执行结果不变。类似的例子就是volatile + 双重锁检查机制实现懒加载的单利模式,具体可以参见前面写的博客:并发编程模式 - Balking模式。
由于自己的非科班出身,所以理解这些个人觉得非常的重要,并且也理解了很久,看了很多的并发书,看的云里雾里,走了很多弯路。。。。。。