《Java并发编程实战》-1-理论基础篇

Posted by 瞿广 on Thursday, July 25, 2019
Last Modified on Saturday, July 27, 2019

TOC

这些年,我们的CPU、内存、I/O设备都在不断迭代,不断朝着更快的方向努力。但是在这个快速发展的过程中,又一个核心矛盾一直存在,就是这三者的速度差异

01| 可见性、原子性和有序性问题:并发编程Bug的源头

为了合理利用CPU的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

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

源头之一:缓存导致的可见性问题

共享内存和工作内存

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

源头之二:线程切换带来的原子性问题

一个 count+=1 操作,至少需要三条CPU指令。

我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。CPU能保证的原子操作是CPU指令级别的,而不是高级语言的操作符。因此,很多时候我们需要在高级语言层面来保证操作的原子性。

源头之一:编译优化带来的有序性问题

有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时会改变程序中语句的先后顺序。

双重检查锁定

当 instance 为 null 时,两个线程可以并发地进入 if 语句内部。然后,一个线程进入 synchronized 块来初始化 singleton ,而另一个线程则被阻断。当第一个线程退出 synchronized 块时,等待着的线程进入并创建另一个 Singleton 对象。注意:当第二个线程进入 synchronized 块时,它并没有检查 instance 是否非 null。

为处理上面的问题,我们需要对 singleton 进行第二次检查。这就是“双重检查锁定”名称的由来

//懒汉式单例
public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

最后,需要加 volatile 关键字 防止指令重排序。

02|java 内存模型:看Java如何解决可见性和有序性(重点学习)

什么是Java内存模型

导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是按需禁用缓存以及编译优化

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

使用volatile的困惑

volatile 关键字并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。

例如,我们声明一个 volatile 变量 volatile int x = 0,它表达的是:告诉编译器, 对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入

Happens-Before 规则

真正要表达的是:前面一个操作的结果对后续操作是可见的

比较正式的说法是:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。

  1. 程序的顺序性规则

    这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意 操作。

  2. volatile 变量规则

    这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

  3. 传递性

    这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

  4. 管程中锁的规则

    这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

    管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。 管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁, 而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

      synchronized (this) { // 此处自动加锁 // x 是共享变量, 初始值 =10
      if (this.x < 12) {
        this.x = 12; 
      }
    
    }//此处自动解锁 
    
  5. 线程 start()规则

    这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

  6. 线程 join()规则

    这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能 够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

被忽视的final

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

03|互斥锁(上):解决原子性问题

java语言提供的锁技术:synchronized

04|互斥锁(下):如何用一把锁保护多个资源?

用不用的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁

原子性的本质是什么?其实是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。所以解决原子性问题,是要保证中间状态对外不可见。

05|一不小心就死锁了,怎么办?

使用细粒度锁可以提高并行度,是性能优化的一个重要手段。

但是,使用细粒度锁是有代价的,这个代价就是可能会导致死锁。

死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致永久阻塞的现象

如何预防死锁

要避免死锁就需要分析死锁发生的条件,有个叫Coffman的牛人早就总结过了,只有一下四个条件都发生时才会出现:

  1. 互斥,共享资源X和Y只能被一个线程占用;
  2. 占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
  3. 不可抢占,其他线程不能强行抢占线程T1占有的资源;
  4. 循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待。

反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生

第一个条件互斥,我们没法破坏,我们用锁为的就是互斥。

  1. 对于占有且等待,我们可以一次性申请所有的资源,这样就不存在等待了;
  2. 对于不可抢占,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了;
  3. 对于循环等待,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源需要大的,这样线性化后自然就不存在循环了。

具体如何实现:

1.破坏占用且等待时间

2.破坏不可抢占条件

核心是要能够主动释放它所占有的资源,这一点synchronized是做不到的。原因是 synchronized 申请资源的时候如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。

不过 java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题的。

3.破坏循环等待条件

破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有不同的属性id,这个id可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,1-6出的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。

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;
        }
      }
    }
  } 
}

06|用“等待-通知”机制优化循环等待

一个完整的等待-通知机制:线程首先获得互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。

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

尽量使用notifyAll()

07|安全性、活跃性以及性能问题

安全性问题

数据竞争

竞态条件,指的是程序的执行结果依赖线程执行的顺序

活跃性问题

除了死锁外,还有两种情况,分别是“活锁”和“饥饿”

有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”

互相谦让,结果又相撞了,如果发生在编程世界,有可能会一直谦让下去。成为没有发生阻塞但依然执行不下去的活锁。

解决活锁的方案很简单,谦让时,尝试等待一个随机的时间就可以了。“等待一个随机时间”的方案虽然很简单,但非常有效,Raft 这样知名的分布式一致性算法中也用到了它。

饥饿,所谓饥饿指的是线程因无法访问所需资源而无法执行下去的情况。在cpu繁忙的时候,优先级低的线程得到执行的机会很小,就可能发生线程饥饿;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。

解决“饥饿”问题的方案很简单,有三种方案:

  1. 保证资源充足
  2. 公平地分配资源
  3. 避免持有锁的线程长时间执行。

性能问题

第一,既然使用锁会带来性能问题,那最好的方案自然是使用无锁的算法和数据结构了。例如线程本地存储(thread local storage,TLS)、写入时复制(Copy on write)、乐观锁;Java并发包里面的原子类也是一种无锁的数据结构;Disruptor则是一个无锁的内存队列,性能都非常好。。。

第二,减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少锁持有的时间。如锁分段技术,读写锁等等。

性能方面三个指标,吞吐量、延迟和并发量:

  1. 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
  2. 延迟: 指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
  3. 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会基于并发量来说的。

08|管程:并发编程的万能钥匙(重点学习)

管程,对应的英文是Monitor,直译是监视器。

所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为Java领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。

MESA模型

管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。管程 X将共享变量queue这个队列和相关的操作 enq()、出队deq()都封装起来了; 线程A和线程B如果想queue,只能通过调用管程提供的enq()和deq()方法来实现;enq()、deq()保证互斥性,只允许一个线程进入管程。前面章节的互斥锁用法,其背后的模型其实就是它。

geektime-java-conncurrent-monitor.png

在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意 思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入 管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似就医流程 的分诊,只允许一个患者就诊,其他患者都在门口等待。

管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。

/img/geektime-java-conncurrent-monitor-MESA.png

条件变量和等待队列的作用是什么呢?其实就是解决线程同步问题。你也可以结合上面提到的 入队出队例子加深一下理解。

假设有个线程 T1 执行出队操作,不过需要注意的是执行出队操作,有个前提条件,就是队列不 能是空的,而队列不空这个前提条件就是管程里的条件变量。

如果线程 T1 进入管程后恰好发现 队列是空的,那怎么办呢?等待啊,去哪里等呢?就去条件变量对应的等待队列里面等。此时线 程 T1 就去“队列不空”这个条件变量的等待队列中等待。这个过程类似于大夫发现你要去验个 血,于是给你开了个验血的单子,你呢就去验血的队伍里排队。线程 T1 进入条件变量的等待队 列后,是允许其他线程进入管程的。这和你去验血的时候,医生可以给其他患者诊治,道理都是 一样的。

再假设之后另外一个线程 T2 执行入队操作,入队操作执行成功之后,“队列不空”这个条件对 于线程 T1 来说已经满足了,此时线程 T2 要通知 T1,告诉它需要的条件已经满足了。当线程 T1 得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队 列里面。这个过程类似你验血完,回来找大夫,需要重新分诊。

条件变量及其等待队列我们讲清楚了,下面再说说 wait()、notify()、notifyAll() 这三个操作。前 面提到线程 T1 发现“队列不空”这个条件不满足,需要进到对应的等待队列里等待。这个过程 就是通过调用 wait() 来实现的。如果我们用对象 A 代表“队列不空”这个条件,那么线程 T1 需 要调用 A.wait()。同理当“队列不空”这个条件满足时,线程 T2 需要调用 A.notify() 来通知 A 等待队列中的一个线程,此时这个队列里面只有线程 T1。至于 notifyAll() 这个方法,它可以通 知等待队列中的所有线程。

09|Java线程(上):Java线程的生命周期

对于有生命周期的事物,要学好它,思路非常简单,只要能搞懂生命周期中各个节点的状态转换机制就可以了

通用的线程生命周期

通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态

Java中线程的生命周期

Java语言中的线程共有六种状态,分别的:

  1. NEW(初始化状态)
  2. RUNNABLE(可运行/运行状态)
  3. BLOCKED(阻塞状态)
  4. WAITING(无时限等待)
  5. TIMED_WAITING(有时限等待)
  6. TERMINATED(终止状态)

Java线程中的BLOCKED、WAITING、TIMED_WAITING是一种状态,即前面我们提到的休眠状态。也就是说只要Java 线程处于这三种状态之一,那么这个线程就永远没有CPU的使用权

10|Java线程(中):创建多少线程才是合适的?

为什么要使用多线程

两个度量性能的核心指标,它们就是延迟和吞吐量

多线程的应用场景

降低延迟,提高吞吐量。基本上有两个方向,一个方向是优化算法,另一个方向是将硬件的性能发挥到极致。前者是算法范畴,后者则是和并发编程息息相关了.这里主要是两类:一个是I/O,一个是CPU。简言之,在并发编程领域,提升性能本质上就是通过提升I/O的利用率和CPU的利用率。

创建多少线程合适

最佳线程数 = CPU核数*[1+(I/O耗时比上CPU耗时)]

11|Java线程(下):为什么局部变量是线程安全的?

CPU去哪里找到调用方法的参数和返回地址?

局部变量是保存在方法的调用栈里,每个线程都有自己独立的调用栈的。这也叫做线程封闭。

12|如何用面向对象思想写好并发程序?

一、封装共享变量

将共享变量作为对象属性封装在内部, 对所有公共方法制定并发访问策略。

二、识别共享变量间的约束条件

在设计阶 段,我们一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定 的并发访问策略南辕北辙。

这些约束条件,决定了并发访问策略

三、制定并发访问策略

制定并发访问策略,是一个非常复杂的事情。应该说整个专栏都是在尝试搞定它。不过从方案上 来看,无外乎就是以下“三件事”。

  1. 避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
  2. 不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模 式、CSP 模式以及函数式编程的基础都是不变模式。
  3. 管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。

接下来在咱们专栏的第二模块我会仔细讲解 Java 并发工具类以及他们的应用场景,在第三模块 我还会讲解并发编程的设计模式,这些都是和制定并发访问策略有关的。 除了这些方案之外,还有一些宏观的原则需要你了解。这些宏观原则,有助于你写出“健壮”的 并发程序。这些原则主要有以下三条。

  1. 优先使用成熟的工具类:Java SDK 并发包里提供了丰富的工具类,基本上能满足你日常的需 要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便 就能发明成功的。
  2. 迫不得已时才使用低级的同步原语:低级的同步原语主要指的是 synchronized、Lock、 Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
  3. 避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开 发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能 瓶颈不是你想预估就能预估的。

13|理论基础模块热点问题答疑