JAVA并发编程基础理论

说明

这里只讲述java并发编程的问题根源以及解决方案。另外,java的并发包里面有很多并发工具,这里不讲述,追本溯源,这些无非是在基础之上提供更方便的封装应用以及场景使用,避免重复造车。

问题根源

由于CPU、内存、IO之间速度的问题(CPU远远大于内存,内存与IO之间的速度差距就更大了),而程序很多时候都需要访问内存,有时候还需要访问IO,至此由于木桶原理,很多时候性能就受限于最慢的那块(IO),为了平衡之间的差异,各方面都做了贡献,但同时也带来些问题,主要体现在如下:

  • CPU引入多级缓存,来平衡与内存之间的速度差距;但带来了可见性问题;
  • 线程之间以分时服用CPU,来平衡CPU和IO之间的速度差异;但由于线程之间的切换带来了原子性问题;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用;但带来有序性问题。

在并发编程中,需要考虑3大特性:原子性、可见性、有序性。这3个问题是JAVA并发编程带来问题的源头。

并发问题解决

只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以解决,接下来看看JAVA中是怎样解决这3个问题的。

可见性、有序性

如上所知,由于CPU缓存和编译器顺序优化从而引起可见性和有序性问题,那就通过禁用CPU缓存和编译器优化来解决,当然不能全局禁用,这样性能会受影响,应该按实际情况,需要的时候禁用。

Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized(lock) 和 final 三个关键字,以及六项 Happens-Before 规则。

例如:volatile,申明变量 volatile int x = 1;则告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入;同时通过内存屏障来禁止指令重排。
还有就是final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。

Happens-Before 规则

Happens-Before 表达的是:前面一个操作的结果对后续操作是可见的。Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。
happens-before原则的目的:为了在不改变程序执行结果的前提下,尽可能提高程序执行的并行度。

happens-before关系本质上和as-if-serial语义是一回事; 下面来比较一下as-if-serial和happens-before

  1. as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  2. as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
  3. as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

具体规则:

  1. 程序的顺序性规则
    这条规则是指一个线程中,按程序顺序,前面的操作happens-before于后续任何操作。
  2. volatile 变量规则
    这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。
  3. 传递性
    这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
    class Demo {
      int x = 0;
      volatile boolean f = false;
      public void writer(){
        x = 1;
        f = true;
      }
      public void reader(){
          if( f == true ){
              System.out.println(x);
          }
       }
    }
    

    根据规则1:x = 1 happens-before 写变量 f = true ,
    根据规则2:写变量 f = true happens-before 读变量 f = true
    根据规则3 传递性:x = 1 happens 读变量 f = true 因此会输出 1。
    ~
    也就是说,如果线程 B 读到了“f=true”,那么线程 A 设置的“x=1”对线程 B 是可见的。也就是说,线程 B 能看到 “x = 1”

  4. 监视器锁规则
    这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
    例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。
    synchronized (this) { //此处自动加锁
      // x是共享变量,初始值=9
      if (this.x < 10) {
        this.x = 10; 
      }  
    } //此处自动解锁
    
    也就是说:假设 x 的初始值是 9,线程 A 执行完代码块后 x 的值会变成 10(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x=10。
  5. 线程 start() 规则
    这条规则是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
    Thread B = new Thread(()->{
      // 主线程调用B.start()之前
      // 所有对共享变量的修改,此处皆可见
      // 此例中,x=10
    });
    // 此处对共享变量x修改
    x = 10;
    // 主线程启动子线程
    B.start();
    
  6. 线程 join() 规则
    这条规则是指如果线程A执行线程B.join()并成功返回,那么线程B的任意操作happens-before 于 线程A从线程B.join()操作成功返回。
    Thread B = new Thread(()->{
      // 此处对共享变量x修改
      x = 50;
    });
    // 例如此处对共享变量修改,
    // 则这个修改结果对线程B可见
    // 主线程启动子线程
    B.start();
    B.join()
    // 子线程所有对共享变量的修改
    // 在主线程调用B.join()之后皆可见
    // 此例中,x=50
    

总的来说:Happens-before的语义本质上是一种可见性,A happens-before B 意味着A事件对B事件来说是可见的,无论A事件和B事件是否发生在同一线程中。

原子性

如上所知,原子性是由于线程切换引起的。可以换个角度,那就是只要保证在同一时刻只有一个线程能处理,这样就可以避免。这里是通过锁来实现。如synchronized、lock。

/**
*很多时候我们看到的代码以为是原子性的,
*其实底层是多条指令执行的,这样如果在过程中产生线程切换就可能出现问题
*/

//例如
i+=1;//这里其实涉及到多条指令操作:读取、操作、存

//又比如long 型变量是 64 位,在 32 位 CPU 上执行写操作会被拆分成两次写操作
long i=100;

经典案例

/**
*获取单例
*利用双重判断来获取对象
*/
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 操作应该是:

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

但是实际上优化后的执行路径却是这样的:

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

这样会产生问题:假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

解决方案

将变量声明为volatile;或者也可以用其他方法实现单例(如采用静态内部类的方式)

static volatile Singleton instance;//加上volatile来禁止指令重排

存疑

我们所知道synchronized 是能保证可见性、原子性和有序性的,为啥之前的代码存在问题,主要还是因为synchronized 前面的判断(不在锁里面),引起这个问题,如果去掉这个判断代码也不会有问题,但性能就有损

总结

并发编程微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。我们在设计并发程序的时候,要重点关注它的安全性、活跃性以及性能。

  • 安全性方面要注意数据竞争和竞态条件;
  • 活跃性方面需要注意死锁、活锁、饥饿等问题;
  • 性能方面,具体问题具体分析。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值