从并发的源头谈起

不管什么语言,并发的编程都是在高级的部分,可见并发有多难,因为并发的涉及的知识太广,不单单是操作系统的知识,还有计算机的组成的知识等等。说到底,这些年硬件的不断的发展,但是一直有一个核心的矛盾在:CPU、内存、I/O设备的三者的速度的差异。这就是所有的并发的源头。

  1. 解决办法

    • CPU增加了缓存,以均衡与内存的差异;
    • 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
    • 编译程序优化指令执行次序,使得缓存能够得到更加合理的利用。

    以上的解决办法都会导致相应的问题。

  2. 三大源头

    1. 缓存导致可见性问题

      • 单核:所有的线程都是在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是同一个CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。

        在这里插入图片描述

        多核:每颗CPU都有自己的缓存,这时CPU缓存与内存的数据一致性就没有那么容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存(CPU的解决的方案:MESI协议

        在这里插入图片描述

        测试一下可见性,书写以下的代码:

        public class Test {
            private static long count = 0;
        
            private void add10K() {
                int idx = 0;
                while (idx++ < 10000) {
                    count += 1;
                }
            }
        
            public static long calc() throws InterruptedException {
                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;
            }
        
            public static void main(String[] args) throws InterruptedException {
                System.out.println(calc());
            }
        }
        

        运行结果如下:可以见得不是20000的值。

        在这里插入图片描述

        我们假设线程A和线程B同时开始执行,那么第一次都会将count=0读到各自的CPU缓存里,执行完count+=1之后,各自CPU缓存里面的值都是1,同时写入内存后,我们后发现现在内存中是1,而不是我们期望的2。之后由于各自的CPU缓存里都有了count的值,两个线程都是基于CPU缓存里的count值来计算,所以导致最终count的值都是小于20000的。这就是缓存的可见性的问题。

      • 总结:一个线程对共享变量的修改,另外一个线程能够立即看到,我们称为可见性

    2. 线程切换带来的原子性问题

      • 时间片:操作系统允许某个进程执行一小段时间。

        在这里插入图片描述

        为了提供CPU的利用率,当一个进程执行IO操作时候,这个时候这个进程将自己标记为休眠的状态,并且让出CPU使用权,如果不让出,这个时候CPU会一直等待这个进程的IO操作结束,这个时候CPU的使用率就会下降。所以刚才那个进行IO操作的进程要让出CPU,这样CPU的使用率就会上来。如果这个时候有一个进程要进行IO操作,这个时候会发现已经有进程在IO操作,这个时候新来的进程就会等待。当上个IO进程结束的时候,会再进行这个新来的IO进程,这样IO的使用率也上去了。

      • 回到本质上来:所有的高级的语言一条语句往往需要CPU中的多条指令。例如count+=1,至少需要三条CPU指令

        • 指令1:首先,需要把变量count从内存加载到CPU的寄存器;
        • 指令2:之后,在寄存器中执行+1操作;
        • 指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)
      • 操作系统任务切换,可以发生在任何一条CPU指令执行完。是的,是CPU指令,而不是高级语言里的一条语言。

        在这里插入图片描述

      • 总结:我们把一个或者多个操作在CPU执行过程中不被中断的特性称为原子性。

    3. 编译优化带来的有序性问题

      • 经典案例:双重检查创建单例对象

        public class Singleton {
            private Singleton(){}
            private static Singleton instance;
            public static Singleton getInstance() {
                if (instance == null) {
                    synchronized (Singleton.class) {
                        if (instance == null)
                            instance = new Singleton();
                    }
                }
                return instance;
            }
        }
        
      • 我们认为的new操作:

        1. 分配一块内存M;
        2. 在内存M上初始化Singleton对象;
        3. 然后M的地址赋值给instance变量。
      • 但是实际优化后的执行路径:

        1. 分布一块内存M;
        2. 将M的地址赋值给instance变量;
        3. 最后在内存M上初始化Singleton对象。

        在这里插入图片描述

  3. Java中如何解决可见性和有序性问题?(Java内存模型)

    • 根本的原因:缓存导致可见性,编译优化导致有序性。

    • 解决办法:禁用缓存和编译优化。如果全部的禁用缓存和编译优化,那我们的程序的性能会很差。我们需要的是按需禁用缓存以及编译优化。

    • Java内存模型解决上面的问题主要是volatile、synchronized和final三个关键字,以及八项Happens-Before规则。

      • volatile:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取和写入

        public class VolatileExample {
            int x = 0;
            volatile boolean v = false;
        
            public void writer() {
                x = 42;
                v = true;
            }
        
            public void reader() {
                if (v == true) {
                    //x为多少?
                    System.out.println(x);
                }
            }
        }
        

        在JDK1.5之前可能会出现x=0的情况,因为变量x可能被CPU缓存而导致可见性问题。1.5之后对volatile语义进行了增强,就是利用Happens-Before规则

      • Happens-Before规则(前面一个操作的结果对后续操作是可见的

        • 程序次序规则

          • 在一个线程中,按照程序顺序,前面的操作Happens-Before于后续的任何操作。
        • volatile变量规则

          • 对一个volatile编程的写操作,Happens-Before于后续对这个volatile变量的读操作。
        • 传递规则

          • 如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。

            在这里插入图片描述

        • 锁定规则

          • 对一个锁的解锁Happens-Before与后续对这个锁的加锁。
        • 线程启动规则

          • 如果线程A调用线程B的start()方法(即在线程A中启动线程B),那么该start()操作Happens-Before线程B中的任意操作。
        • 线程终结规则

          • 如果在线程A中,调用线程B的join()并成功返回,那么线程B中的任意操作Happens-Before于该join()操作的返回。
        • 线程中断规则

          • 对线程interrupt()方法的调用Happens-Before被中断线程的代码检测到中断事件的发生
        • 对象终结规则

          • 一个对象的初始化完成Happens-Before他的finalize()方法的开始。
      • final关键字

        • final修饰变量是,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。

        • 在1.5以后Java内存模型对final类型变量的重排进行了约束。现在只要我们能够提供正确函数没有‘逸出’,就不会出问题了。

        • 逸出

          final int x;
          // 错误的构造函数
          public FinalFieldExample() {
          	x = 3;
          	y = 4;
          	// 此处就是讲 this 逸出,
          	global.obj = this;
          }
          
  4. Java中如何解决原子性问题(互斥锁

    • 概念:一个或者多个操作在CPU执行过程不被中断的特性,称为原子性

    • 导致的原因:线程切换。

    • 操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断能够禁止线程切换。

      • 当在32位CPU上执行long型变量的写操作会被拆分成两次操作。写高32位和写低32位。
      • 单核:在单核CPU下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作不会重新调度线程,也就是禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
      • 多核:同一时刻,有可能有两个线程同时执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行。
    • 解决办法:互斥锁

    • 简易的锁模型

      在这里插入图片描述

      线程进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;否则就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁unlock()

    • 改进后的锁模型

      在这里插入图片描述

      我们为其增加了受保护的资源R;其次,我们要保护资源R就得为它创建一把锁LR;最后,针对这把锁LR,我们还需在进出临界区时添上加锁和解锁操作。

    • synchronized

      • 使用方式

        public class X {
            //修饰非静态方法
            synchronized void foo() {
                //临界区
            }
        
            //修饰静态方法
            synchronized static void bar() {
                //临界区
            }
        
            //修饰代码块
            Object obj = new Object();
        
            void baz() {
                synchronized (obj) {
                    //临界区
                }
            }
        }
        
      • synchronized修饰静态方法相当于

        class X {
           	//修饰静态方法
        	synchronized(X.class) static void bar() {
                //临界区
        	}
        }
        
      • synchronized修饰非静态方法相当于

        class X {
           	//修饰静态方法
        	synchronized(this) void bar() {
                //临界区
        	}
        }
        
      • 用synchronized解决count+1问题

        class SafeCalc {
            long value = 0L;
        
            synchronized long get() {
                return value;
            }
        
            synchronized void addOne() {
                value += 1;
            }
         }
        
        • 大体的模型如下:

      在这里插入图片描述

    • 锁和受保护资源的关系

      • 受保护资源和锁之前的关联关系是N:1的关系。

      • 重写修改上面的代码

        class SafeCalc {
            long value = 0L;
        
            synchronized long get() {
                return value;
            }
        
            synchronized static void addOne() {
                value += 1;
            }
        }
        

        这个时候的问题就是两个锁保护一个资源。因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性保证,这就导致并发问题 在这里插入图片描述

    • 如何用一把锁保护多个资源

      • 保护没有关联关系的多个资源

        • 账号余额和账号密码没有关联关系

          class Account {
              // 锁:保护账户余额
              private final Object balLock = new Object();
              // 账户余额
              private Integer balance;
              // 锁:保护账户密码
              private final Object pwLock = new Object();
              // 账户密码
              private String password;
          
              // 取款
              void withdraw(Integer amt) {
                  synchronized (balLock) {
                      if (this.balance > amt) {
                          this.balance -= amt;
                      }
                  }
              }
          
              // 查看余额
              Integer getBalance() {
                  synchronized (balLock) {
                      return balance;
                  }
              }
          
              // 更改密码
              void updatePassword(String pw) {
                  synchronized (pwLock) {
                      this.password = pw;
                  }
              }
          
              // 查看密码
              String getPassword() {
                  synchronized (pwLock) {
                      return password;
                  }
              }
          }
          
      • 保护有关联关系的多个资源

        • 银行转账的问题

          class Account {
              private int balance;
          
              // 转账
              synchronized void transfer(Account target, int amt) {
                  if (this.balance > amt) {
                      this.balance -= amt;
                      target.balance += amt;
                  }
              }
          }
          
        • 上面的代码的问题出在this这把锁上,this这把锁可以保护自己余额this.balance,却保护不了别人的余额target.balance,就像你不能用自家的锁来保护别人家的资产,也不能用自己的票保护别人的座位一样。

          在这里插入图片描述

        • 使用锁的正确姿势(锁能覆盖所有受保护的资源)

          • 再次修改上面的代码(下面的代码存在串行化的操作)

            class Account {
                private int balance;
            
                // 转账
                synchronized void transfer(Account target, int amt) {
                    synchronized(Account.class){
                         if (this.balance > amt) {
                        this.balance -= amt;
                        target.balance += amt;
                   		}
                    }
                }
            }
            

            总结:首先要分析多个资源之间的关系。如果资源资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。原子性的本质:操作的中间状态对外不可见。

      • 解决上面的代码的存在的串行化的问题

        • 从现实的世界中找答案

          • 假设银行在给我能做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。会出现三种情况

            • 文件架上恰好有转出账本和转入账本,那就同时拿走;

            • 如果文件加上只有转出和转入账本之一,那这个柜员就先把文件架上有账本拿到手,同时等着其他柜员把另一个账本送回来;

            • 转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。

              在这里插入图片描述

              • 于是我们将代码改成如下:

                class Account {
                    private int balance;
                
                    // 转账
                    synchronized void transfer(Account target, int amt) {
                        //锁定转出账户
                        synchronized(this){
                            //锁定转入账户
                            synchronized(target){
                                 if (this.balance > amt) {
                            		this.balance -= amt;
                            		target.balance += amt;
                       			}
                            }
                        }
                    }
                }
                
              • 这样的代码会发生新的问题,就是死锁的问题

        • 死锁

          • 概念:一组互相竞争资源的线程互相等待,导致“永久”阻塞的现象。

            在这里插入图片描述

            class Account {
                private int balance;
            
                // 转账
                synchronized void transfer(Account target, int amt) {
                    //锁定转出账户
                    synchronized(this){//①
                        //锁定转入账户
                        synchronized(target){//②
                             if (this.balance > amt) {
                        		this.balance -= amt;
                        		target.balance += amt;
                   			}
                        }
                    }
                }
            }
            
          • 如何发生死锁(上面的代码)

            • 假设线程T1执行账户A转账账户B的操作,账号A.transfer(账户B);
            • 同时线程T2执行账户B转账账户A的操作,账户B.transfer(账户A);
            • 当T1和T2同时执行完①处的代码时,T1获得了账户A的锁(对于T1,this是账户A)而T2获得了账户B的锁(对于T2,this是账户B)
            • 之后T1和T2在执行完②处的代码时,T1试图获取账户B的锁时,发现账户B已经被锁定(被T2锁定),所以T1开始等待
            • T2则试图获取账户A的锁时,发现账户A已经被锁定(被T1锁定),所以T2也开始等待
          • 死锁的产生条件

            • 互斥,共享资源X和Y只能被一个线程占用;
            • 占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X
            • 不可抢占,其他线程不能强行抢占线程T1占有的资源
            • 循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待。
          • 解决办法(破坏四个条件的其中之一)

            • 破坏占有且等待条件

              • 一次性申请所有资源

                在这里插入图片描述

                • 于是有了下面的代码

                  class Allocator {
                  	private List<Object> als = new ArrayList<>();
                  	// 一次性申请所有资源
                  	synchronized boolean apply(Object from, Object to){
                  		if(als.contains(from) || als.contains(to)){
                  			return false;
                  		} else {
                  			als.add(from);
                  			als.add(to);
                      	}
                  		return true;
                      }
                  	// 归还资源
                  	synchronized void free(Object from, Object to){
                  		als.remove(from);
                  		als.remove(to);
                  	}
                  }
                  class Account {
                  	// actr 应该为单例
                  	private Allocator actr;
                  	private int balance;
                  	// 转账
                  	void transfer(Account target, int amt){
                  		// 一次性申请转出账户和转入账户,直到成功
                  		while(!actr.apply(this, target));
                  		try{
                  			// 锁定转出账户
                  			synchronized(this){
                  				// 锁定转入账户
                  				synchronized(target){
                  					if (this.balance > amt){
                  						this.balance -= amt;
                  						target.balance += amt;
                  					}
                  				}
                  			}
                  		} finally {
                  			actr.free(this, target)
                  		}
                  	}
                  }
                  

                同时申请资源apply()和同时释放资源free()。账户Account类里面持有一个Alloctor的单例(必须是单例,只能由一个人分配资源)。当账户Account在执行转账的时候,首先向Allocator同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源,当转账执行完,释放锁之后,我们需要通知Allocator同时释放转出账户和转入账户这两个资源。

              • 破坏不可抢占条件

                • 能够主动释放它占有的资源。synchronized不支持,后面会说JUC下面的包会支持
              • 破坏循环等待条件

                • 破坏这个条件,需要对资源进行排序,然后按序申请资源

                  class Account {
                  	private int id;
                  	private int balance;
                  	// 转账
                  	void transfer(Account target, int amt){
                  		Account left = this; //①
                  		Account right = target; //②
                  		if (this.id > target.id) { //③
                  			left = target; //④
                  			right = this; //⑤
                  		} //⑥
                  		// 锁定序号小的账户
                  		synchronized(left){
                  			// 锁定序号大的账户
                  			synchronized(right){
                  				if (this.balance > amt){
                  					this.balance -= amt;
                  					target.balance += amt;
                  				}
                  			}
                  		}
                  	}
                  }
                  
            • 再谈破坏占有且等待条件

              // 一次性申请转出账户和转入账户,直到成功
              while(!actr.apply(this, target));
              

              如果上面的代码的执行时间非常短,这个方案是可行的,如果操作耗时长,这样CPU的空转的时间会大大的提升。

              • 解决办法(等待-通知机制)
                • 如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;当线程要求的条件(转出账本和转入账本同时在文件架上)满足后,通知等待的线程重新执行。
              • 一个完整的等待-通知机制:
              • 线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
            • 再谈wait()、notify()、notifyAll()

              在这里插入图片描述

              注:这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。

              当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态。当调用wait()方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。线程在进入等待队列的同时,会释放持有的互斥锁,线程释放后,其他线程就会有机会获得锁,并进入临界区。

              当条件满足时调用notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。

              在这里插入图片描述

              因为notify()只能保证在通知时间点,条件是满足的。而被通知线程的执行时间和通知的时间点基本不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。被通知的线程要重新执行,仍然需要获取到互斥锁

            • 重写刚才的例子

              class Allocator {
              	private List<Object> als;
              	// 一次性申请所有资源
              	synchronized void apply(Object from, Object to){
              		// 经典写法
              		while(als.contains(from) ||als.contains(to)){
              			try{
              				wait();
              			}catch(Exception e){
              			}
              		}
              		als.add(from);
              		als.add(to);
              	}
              	// 归还资源
              	synchronized void free(Object from, Object to){
              		als.remove(from);
              		als.remove(to);
              		notifyAll();
              	}
              }
              
  5. 总结(宏观的角度)

    • 存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程同时读写同一个数据。

    • 并发的问题主要是三个方面:安全性问题、活跃性问题、性能问题

    • 安全性问题

      • 本质的问题:程序按照我们期望的执行。
      • 数据竞争:当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发的Bug。
      • 竞态条件:程序的执行结果依赖线程执行的顺序。
      • 上面的两种问题,都可以通过互斥这个技术方案,而实现互斥的方案有很多,CPU提供了相关的互斥指令,操作系统、编程语言也会提供相关的API。
    • 活跃性问题

      • 死锁:线程会互相等待,而且会一直等待下去,在技术上表现就是线程永久的阻塞
      • 活锁:线程没有阻塞,但仍然会存在执行不下去的情况。(尝试等待一个随机时间就可以了)
      • 饥饿:线程因无法访问所需资源而无法执行下去的情况。(不患寡,而患不均)(解决办法:保证资源充足,公平地分配资源,避免持有锁的线程长时间执行。)
    • 性能问题:

      • 尽量使用无锁 算法和数据结构
      • 减少锁持有的时间
      • 衡量标准
        • 吞吐量:单位时间内能处理的请求数量
        • 延迟:从发出请求到收到响应的时间
        • 并发量:同时处理的请求数量,随着并发量的增加、延迟也会增加。
    • 存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程同时读写同一个数据。

    • 并发的问题主要是三个方面:安全性问题、活跃性问题、性能问题

    • 安全性问题

      • 本质的问题:程序按照我们期望的执行。
      • 数据竞争:当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发的Bug。
      • 竞态条件:程序的执行结果依赖线程执行的顺序。
      • 上面的两种问题,都可以通过互斥这个技术方案,而实现互斥的方案有很多,CPU提供了相关的互斥指令,操作系统、编程语言也会提供相关的API。
    • 活跃性问题

      • 死锁:线程会互相等待,而且会一直等待下去,在技术上表现就是线程永久的阻塞
      • 活锁:线程没有阻塞,但仍然会存在执行不下去的情况。(尝试等待一个随机时间就可以了)
      • 饥饿:线程因无法访问所需资源而无法执行下去的情况。(不患寡,而患不均)(解决办法:保证资源充足,公平地分配资源,避免持有锁的线程长时间执行。)
    • 性能问题:

      • 尽量使用无锁 算法和数据结构
      • 减少锁持有的时间
      • 衡量标准
        • 吞吐量:单位时间内能处理的请求数量
        • 延迟:从发出请求到收到响应的时间
        • 并发量:同时处理的请求数量,随着并发量的增加、延迟也会增加。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值