导语:把握关键的6秒时差
并发的学习不是简单的一两天能解决的,需要长期的学习,运用,总结。加油!
大纲 :

简单介绍:分工,同步,互斥
分工
任务分解:多线程实现。Java SDK中Executor,Fork/Join ,Future。模式:生产-消费者,Worker-Thread(spark集群),Thread-Per-Message。(合理的结合生活场景。)
同步
一个线程完成了一个任务,如何通知后续的任务线程开工。Future,CountDownLatch、CyclicBarrier、Phaser、Exchanger。核心是管程。
互斥
分工,同步解决的是性能问题。互斥讲的是线程安全问题--同一时刻只允许一个线程访问共享变量。JAVA:synchronized,lock;
Java SDK 里提供的 ReadWriteLock、StampedLock 就可以优化读多写少场景下锁的性能,CAS更好!
不共享变量或者只允许读:ThreadLocal,final,Copy-on-Write模式。
可见性,原子性,有序性就是并发的源头
并发(concurrency):1:在同一时间间隔内,一个cpu,轮流执行多个任务(线程)。
并行(parallellism):1:讲究同时性--多个cpu同时执行多个任务(线程)。
并发程序之幕后
CPU,内存,I/O三者的速度差异->核心矛盾
CPU>>内存>>I/O(天->年->1000年)
程序中大部分代码会涉及访问操作系统内存+I/O,根据木桶理论(最短的那块板子,决定了一 只木桶能装多少水!),所以整个程序的性能取决于I/O读写操作。仅单方面提高CPU性能是无效的。
目前合理利用CPU性能,平衡这三者的速度差异,计算机体系机构,操作系统,编译程序分别作了:
1.CPU增加了缓存->均衡了与内存的速度差异!
2.操作系统增加了进程、线程分时复用CPU,为了均衡CPU和I/O的速度差异。
3.编译程序优化了指令执行次序,使得缓存能够得到更加合理的使用。
核心矛盾就是并发程序的诡异问题的根源!
一、CPU缓存导致的可见性问题
单核CPU缓存和内存的数据一致性问题很好解决!下图CPU和内存的关系图!

可见性:一个线程对共享变量的修改,另外一个线程能立刻看到!
单核CPU上多个线程操作同一个CPU的缓存,一个线程对缓存的写,对另一个线程来说一定是可见的。上图:线程A和B同时操作同一个CPU的缓存,所以A更新了V的值,B之后再访问一定是拿到的最新的值。
多核CPU,每个cpu都有自己的缓存!

此时A线程操作的是CPU-1上的缓存,B线程操作的是CPU-2上面的缓存,A线程对变量V的操作对于B线程来讲就是隐藏的。
public class Test {
private long count = 0;
private void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
public static long calc() {
final Test test = new Test();
// 创建两个线程,执行 add() 操作
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束
th1.join();
th2.join();
return count;
}
}
执行calc()方法会发现,打印结果小于20000,因为A,B两个线程启动的时候会被放到不同的CPU中执行,而此时只有一个内存,A从内存中把count读出来放到cpu缓存中+1,B也是这样,完了再把count放到内存中。当A,B线程同时发生的时候,会发现内存中的值为1,而不是我们期望的2。
二、线程切换-带来原子性问题
1.例如单核CPU电脑上可以边听歌,边写bug,因为多进程存在的原因。
单核操作系统允许某个进程A执行一段时间-50ms,过了50ms会重新选择一个进程B来执行(线程切换),此时A线程不占有CPU,这个50ms叫做“时间片”。
2.Unix解决了?支持多进程分时复用–IO操作释放CPU,让CPU去做其他的任务!
前面提到CPU的速度>>I/O,当在一个时间片中,一个进程进行一个I/O操作的时候(读取一个文件),这个时候CPU发出一个指令就可以,但是I/O操作还会持续把文件读进内存,如果这个时候这个进程还在占有该CPU那么对于此时来讲,这个CPU是浪费的。可以在此时,该进程把自己标记为“休眠状态”并且让出CPU占有权,直到文件被读进内存后,操作系统唤起该进程,该进程可以继续获取到CPU的使用权。
那么CPU发出一个指令直到文件被读进内存期间,CPU使用权被释放,那么CPU可以做别的事情了,CPU的使用率会提高;此外,如果这个时候有另外一个进程也在进行读取文件,那么这个读取文件的进程就会排队,磁盘驱动在完成上一个读操作后,发现有新的任务,则会立即启动下一个读进程,此时IO的使用率也会提高。
早期的操作系统都是基于进程来调度CPU的,不同进程之间是不共享内存空间的,进程之间的任务切换就是切换内存映射地址。而一个进程所创建的所有的线程都是共享同一个内存空间的,SO,Thread之间进行任务切换就很低的成本了,现在的操作系统基本都是基于线程切换。
JAVA并发程序都是基于多线程的,存在线程切换。java属于高级语言,代码中的一条语句往往需要多条CPU指令执行。count+=1;则需要3条CPU指令。
1.把count从内存中加载到CPU寄存器。
2.在寄存器中执行+1操作
3.把此时count的结果写到内存中(也有可能写到CPU缓存)
此时的任务切换可能发生在任意CPU指令执行后,而不是java中一句代码后。

如图:线程切换会造成count+=1原子性问题。结果是1,不是期望的2.
原子性:一个或者多个操作在CPU执行的过程中不被中断的特性。
大头?如何在高级语言层面保证操作的原子性?
三、编译优化带来的有序性问题。
编译器为了优化性能,会改变程序中语句的先后顺序。“a=6;b=7”会变成"b=7;a=6"这个不会影响程序的最终结果,但是下面:会有问题
双重检查创建单例对象:
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
1:首先判断instance==null?是,则锁住Singleton.class并且再次检查instance==null?是,则创建Singleton的一个实例。
A,B两个线程同时调用getInstance方法,他们同时发现instance==null,于是同时对Singleton.class加上锁,但此时JVM保证只会有一个线程加锁成功(假设是A),B则会等待。A会创建一个Singleton实例,之后释放锁,B会被唤醒,B会尝试加锁,此时会成功加锁,线程B检查instance==null会发现已经存在了Singleton实例则不会再创建。
但是new操作的时候会出现问题!
我们期望:
1:分配一块内存M
2:在内存M上初始化Singleton对象
3:然后M的地址值赋给instance变量
实际上:经过编译优化后!
1:分配一块内存M
2:把M的地址值赋值给instance对象
3:最后在M上初始化Singleton
A线程在完成“把M的地址值赋值给instance对象”这一指令之后就直接线程切换给了B线程,然后B线程判断instance!=null(此时都没有进入synchronized里面)但是,此时instance会有空指针异常!
问题
long类型在32位操作系统上不安全?
答案:long64位,所以在32位机器上对long类型的数据操作是由多条CPU指令组合来的,无法保持原子性。