古老的DOS操作系统是一个单进程系统。系统一次只能做一件事,完成一个任务后才能继续下一个任务。一次只能做一件事,例如不能边听歌边打开网页。所有任务操作都以串行方式顺序执行。这种服务器的缺点也很明显。操作等待时间太长,无法同时操作多个任务,执行效率很差。现在的操作系统都是多任务操作系统。比如可以一边听音乐一边打开网页,打开微信和朋友聊天。这几个任务可以同时进行,大大提高了执行效率。并发提高效率一个完整的服务器有CPU、内存和IO。三者在运行速度上存在明显差异:与CPU相关的操作、执行指令、读取CPU缓存等基本都在纳秒级。CPU读取内存,比CPU相关操作耗时千倍,基本在微秒级别。CPU和内存之间的速度差异。IO操作基本在毫秒级,比内存操作快千倍。内存和IO之间存在速度差异。CPU->Memory->SSD->Disk->NetworkNanosecond->Microsecond->Millisecond->Millisecond->Second程序中的大部分语句都需要访问内存,有的还需要访问IO读写。为了合理利用CPU的高性能,有效平衡三者的速度差异,操作系统和编译器主要做了以下改进:CPU增加了CPU缓存,以平衡CPU之间的速度差异和记忆。操作系统为CPU的分时复用增加了多进程和多线程,从而平衡了CPU和IO设备之间的差异。编译并优化程序的执行顺序,充分利用缓存。做了以上操作后,CPU读取或修改数据后,将数据缓存在CPU缓存中。CPU不需要每次都从内存中获取数据,大大提高了CPU的运行速度。多线程就是把时间段切割成小段。多个线程在上下文切换期间执行任务,而无需等待前面的线程完成执行。例如,CPU做一次计算需要1纳秒,但从内存中读取数据需要1微秒。如果没有多线程,N个线程耗时N微秒。这时候CPU的效率就体现不出来了。使用多线程,操作系统将CPU时间切割成小段,多线程上下文切换,线程执行计算操作,无需等待内存读取操作。虽然并发可以提高程序的运行效率,但是凡事有利有弊。并发程序中也有很多奇怪的bug。根本原因如下。缓存会导致可见性问题。一个线程修改了一个共享变量,另一个线程可以立即看到,这叫做可见性。单核时代,所有线程都运行在同一个CPU上,所有线程都操作同一个线程的CPU缓存。如果一个线程修改了缓存,它必须对另一个线程可见。比如下图中,线程A和线程B都操作同一个CPU缓存,所以线程A更新变量V的值,线程B访问变量V的值,必须获取V的最新值。所以变量V对所有线程都是可见的。在多核CPU下,每个CPU都有自己的缓存。当多个线程在不同的CPU上执行时,这些线程的操作也是在对应的CPU缓存上进行的。这时候就会出现问题。下图中,线程A运行在CPU_1上。首先,它从CPU_1缓存中获取变量V。如果获取不到,则获取内存的值,然后操作变量V。线程B以同样的方式获取CPU_2缓存中的变量V。线程A操作CPU_1的缓存,线程B操作CPU_2的缓存。此时线程A对变量V的操作对于线程B是不可见的,多核CPU一方面提高了运行速度,但另一方面也可能造成线程不安全的问题。下面用一段代码来测试多核场景下的可见性。先创建一个累加方法add10k方法,循环10000次count+=1操作。然后在test方法中创建两个线程,每个线程调用add10k方法,结果如何?公共类VisibilityTest{privatestaticintcount=0;privatevoidadd10k(){intindex=0;while(index++<10000){count+=1;}}@Testpublicvoidtest()throwsInterruptedException{VisibilityTesttest=newVisibilityTest();线程thread1=newThread(()->test.add10k());线程thread2=newThread(()->test.add10k());//启动两个线程thread1.start();thread2.start();//等待两个线程完成执行thread1.join();thread2.join();System.out.println(计数);}}直观上结果是20000,因为在每个线程中Accumulate10000,两个线程都是20000。但是实际结果是在10000到20000之间,每次执行的结果都是这个范围内的随机数。因为线程A和线程B同时开始执行,所以第一次会在自己的CPU缓存中缓存count=0,执行完count+=1后,写入到自己对应的CPU缓存中,并写入内存中同一时间。内存中的数字是1,而不是预期的2。之后,CPU取自己的CPU缓存进行计算。计算出来的计数值都小于20000。这就是缓存可见性问题。线程切换带来的原子性问题上文提到,由于CPU、内存、IO在速度上的巨大差异,在单进程系统中,需要等待最慢的IO操作完成后再进行下一个一。任务,无法体现CPU的高性能。但是操作系统有多个进程后,操作系统将CPU切分成小段,在不同的时间段执行不同的进程,而不用等待慢IO操作。在单核或多核CPU上您可以一边聊天一边听音乐。操作系统将时间切成小片,比例为20毫秒,前20毫秒执行一个进程,后20毫秒切换另一个线程执行,20毫秒成为一个时间片,如图下图:线程A和线程B来回切换Task。如果进行了IO操作,比如读取文件,此时进程会把自己标记为休眠状态,放弃CPU的使用权。完成IO操作后,需要使用CPU时会唤醒休眠的进程。进程可以等待CPU的调用。放弃CPU的使用权后,CPU可以对其他进程进行操作,这样CPU的使用率就提高了,整个系统的运行速度也快很多。大多数并发程序都是基于多线程的,也会涉及到线程上下文的切换,线程切换是在很短的时间内完成的。比如上面代码中的count+=1虽然有一行语句,但是里面有3条CPU指令。指令1:将变量V从内存加载到CPU寄存器。指令2:对寄存器执行+1操作。指令3:将结果写入内存(也可能写入CPU缓存)。任何CPU指令都可能发生线程切换。如果线程A执行完指令1后进行线程切换,线程A和线程B按照下图的顺序执行,那么我们会发现两个线程都执行了count+=1的操作,但是最后的结果是1而不是2。编译优化带来的有序问题有序是指程序按照代码的顺序执行。为了优化性能,编译器会在不影响程序最终结果的情况下调整语句的顺序。比如程序Middle:a=2;b=5;经过编译器优化后,可能变成:b=5;一=2;虽然不影响程序的最终结果,但也会造成一些意想不到的bug。Java中一个常见的例子是使用双重检查来创建单例对象,例如下面的代码:publicclassSingleton{staticSingletoninstance;staticSingletongetInstance(){if(instance==null){synchronized(Singleton.class){if(instance==null)instance=newSingleton();}}返回实例;}}在获取实例的getInstance方法中,首先判断实例是否为空,如果为空,则锁定Singleton.class并检查实例是否为Empty,如果仍然为空,则创建一个Singleton实例。假设有两个线程,线程A和线程B同时调用了getInstance方法。此时instance==null,同时加锁Singleton.class,JVM保证只有一个线程加锁成功,假设线程A加锁成功,另外一个线程会处于等待状态,线程A会创建一个instance,然后释放锁,唤醒线程B,它再次尝试加锁。这时就成功加锁了,而此时instance!=null,实例已经创建,所以线程B不会创建实例。看似没有问题,但实际上new操作也可能出现问题。本来new操作应该是:1.分配一块内存。2.初始化内存中的对象。3.将内存地址分配给实例变量。但实际优化后的执行顺序如下:1.分配一块内存。2.将内存地址分配给实例变量。3.初始化内存中的对象。优化后会发生什么?首先假设线程A先执行getInstance方法,即先执行new操作。指令2执行时,发生线程切换,切换到线程B,此时线程B执行getInstance方法。执行判断的时候会发现instance!=null,所以返回了instance,此时并没有初始化instance。如果此时访问该实例,可能会触发空指针异常。综上所述,操作系统已经进入多核、多进程、多线程时代。这些升级会大大提高程序的执行效率,但同时也会带来可见性、原子性和顺序性的问题。对于多核CPU,每个CPU都有自己的CPU缓存。每个线程更新的变量会先同步到CPU缓存中。此时其他线程无法获取到最新的CPU缓存值。这是隐形。count+=1包含多个CPU指令。当发生线程切换时,会导致原子问题。编译器优化器会调整程序的执行顺序,导致多线程环境下线程切换导致的有序问题。在开始学习并发的时候,经常会看到volatile、synchronized等并发关键字。如果了解并发编程的有序性、原子性和可见性,就能更好地理解并发场景的原理。请参阅可见性、原子性和排序问题:并发编程错误的来源
