概述

并发一直以来是面试的重难点,从事java开发也有几年的经验了,但是并发这一块一直都是软肋,有幸同事建议看一下王宝令的极客时间,收获颇丰。我们从并发编程中存在的问题入手,并来一步一步的分析解决这些问题所用到的方法,最后引出并发编程真正的执行者:线程。

并发编程可以从三个方面来划分:

  • 分工:宏观上的分工,即如何给线程安排任务
  • 同步:分配给线程的任务之间有依赖的关系,如何保障线程按照编排的顺序来执行
  • 互斥:线程在执行任务的时候可能会涉及到多个线程访问共享变量的情况,如何保障线程执行的正确性就需要对共享变量的互斥访问,其中互斥访问又可以分成两种方案
    • 有锁互斥
    • 无锁互斥

整体划分如下:

个人理解,同步的一种特例就是互斥:通过互斥使得线程按照特定的顺序执行。

详解

并发问题

并发编程出现问题的根源在于:

  • 原子性:由于计算机硬件之间运行的速度不匹配,因此计算机在操作系统软件的层面形成了分时的系统,由此而引入的问题是在高级语言层面上看起来是一步执行完的操作,在cpu指令上则拆分了多个操作,一个真正的操作可能是会在切换了多个线程之后才执行完。如下图所示:

  • 可见性:由于计算机硬件之间运行的速度不匹配,除了在操作系统的层面上通过分时充分保障cpu被充分使用外,还引入了cpu对应的缓存来平衡硬件运行速度的差异。线程在执行的时候会先将主存数据加载到cpu对应的缓存,对于具备多核的cpu则会针对主存数据形成多份拷贝,如果不同的线程对数据进行了修改,其修改操作并不是直接修改主存中的数据,而是修改的cpu缓存中的数据,这样对于其他线程来说该修改操作就是不可见的,如下图所示:

  • 有序性:编译器、处理器为了优化性能,在保证单线程正确执行的前提下,有时候会调整程序的先后顺序,调整的目的无非是为了充分使用缓存,但是这些调整仅仅保障了单线程下执行正确,并无法保障多线程下操作的正确。比较经典的一个例子是双重检测来创建单例:

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

我们通常见到的这种单例的写法看起来很完美了,不过很遗憾,这里还是存在一些问题:指令重排带来的问题,假定一个线程进入到instance = new Singleton();这一步了,但是创建对象的操作并非原子操作,因此可能会出现先开辟一块内存,指向instance,此时instance就满足部位null的情况,然而对象却还没有来的及初始化,因此这种情况下我们直接返回的对象是存在问题的对象。

综上:缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题,java引入了内存模型来解决这几个问题,内存模型解决了可见性、有序性的问题,而原子性的问题则是由互斥来解决的

java内存模型(非jvm)

java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法,具体点就是volatile、synchronized、final关键字以及happens-before规则。

  • volatile:volatile通过禁用缓存解决了可见性的问题,这样volatile的读写都是操作的主存(但是没有解决原子性的问题,也就是当针对volatile执行非原子操作的时候要特别注意),另外volatile是针对引用的可见性,并不是引用内部字段的可见性,因此对于一般的对象,即使使用了volatile来修饰也没有办法保证其所包含字段的可见性的。除了解决可见性问题之外,在jdk1.5之后volatile对其做了功能性的增强,具体参见happens-before原则
  • final:代表对应的字段是不可变的,对于基本类型来说就是值,普通对象就是引用的值。final通常是用在构造函数中对值进行初始化,在使用final关键字的时候需要注意的就是指针逃逸的情况,虽然我们知道final代表的字段是不变的,不过如果我们在构造函数中将当前对象赋值给全局的静态变量则可能会由于指令重排导致对象还没有来得及真正的初始化就直接暴露出来了。因此严禁在构造函数中将对象的引用暴露出来,这样很可能能会造成指针逃逸,具体代码演示如下:
1
2
3
4
5
6
7
final int x;
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲this逸出,
global.obj = this;
}
    • 成员变量为final时的初始化
      • 定义变量时候赋值
      • 构造函数中赋值
      • 构造代码块中赋值
    • 类变量为final时的初始化
      • 定义变量的时候赋值
      • 静态代码块中初始化
  • happens-before规则:通过约束编译器优化的方式实现了前面操作的结果相对于后续的操作是可见的(个人感觉有判断条件的规则才是真正的规则)

    • 程序顺序性规则:从单线程的角度来看程序前面对某个变量的修改操作一定对后续操作可见
    • 对一个volatile变量的写操作,happen-before于对这个变量的读操作。这一个规则怎么看都像是禁用缓存的意思,好像是在强调volatile读到的总是最新的值,不过其真实的意思是在强调对volatile变量的操作禁用指令重排,这样前面双重检测的对象如果使用了volatile修饰了,那么就能够保障对象在写入内存的时候一定不会再是还没有来得及初始化的对象
    • 传递性规则:A happen before B、B happen before C,则 A happen before C
    • 管程中锁的规则:对一个锁的解锁happen before于后续对这个锁的加锁(java中对应的就是synchronized),java中的加锁是通过编译器帮忙实现的
    • 线程start规则:线程A启动线程B,则子线程B可以看到父线程A在启动子线程B之前的操作(感觉这个和顺序性有点像,不过顺序性是单线程,这里是多个线程)
    • 线程join规则:父线程A调用了子线程B的join方法,则子线程的操作happen before于该join方法,父线程会等待子线程执行完。一种通用的模式,经常会看到Thread.currentThread().join()出现在源代码中,这种是让主线程不退出,通过子线程持续对外提供服务的一种方式。

原子性问题解决方案:

前面的java内存模型解决了可见性和有序性的问题,不过并没有解决原子性的问题,在java中原子性问题的解决方案是采用管程的模型。管程对应的英文是Monitor,也可以叫做监视器,所谓管程是指管理共享变量以及对共享变量的操作过程,让他们支持并发,对应到java中就是管理类的成员变量以及成员方法,让其成为线程安全的类,这也是在任何语言中解决并发问题的关键。

管程模型

如下图所示为管程对应的模型:

被封装的共享变量或者方法对外提供的统一的入口,入口处有一个等待队列,这个队列代表了管程的互斥的作用,其保证每次只有一个线程进入到该入口。管程同时引入了条件变量,每个条件变量对应了一个等待队列(区别于入口等待队列),这个等待队列用于实现管程的同步的作用,当线程在执行的时候发现某个条件不满足,就会进入该条件变量对应的等待队列,这个时候是允许其他的线程进入管程的(因为进入等待队列会释放其持有的锁)。当某个条件满足的时候,我们可以通知该条件变量对应的等待队列中的线程重新进入入口等待队列,发起抢锁的操作,抢锁失败之后对应的线程会重新进入条件变量的等待队列(这一点很重要)

管程实现范式

java中选择的管程的模型是MESA,线程B在通知(是通知)完线程A条件满足的时候,线程B并不是立即释放锁并退出,而是会继续执行完接下来的操作,这就会引入一个问题,线程A在执行的时候发现条件又不满足了(或许其他线程又改变了这个条件使得之前满足的条件不再满足了),这样就引入了java中管程实现的特定的范式:

1
2
3
while(条件不满足) { 
wait();
}

对于管程的实现,在jdk1.5之前提供的默认的管程的实现是synchronized,之后在并发编程包中提供了AQS框架实现的管程,其本质上统统是锁,接下来具体看一下相关的细节。

synchronized

synchronized是jdk提供的默认的管程的实现,默认只有一个条件变量,因此也只对应了一个等待队列。前面在介绍happens before的时候已经介绍了管程中锁的规则,加锁happen before于解锁之前,这是可见性和有序性的体现,除此之外其可以保证操作的原子性。synchronized可以用来修饰方法、代码块、对象。synchronized的加锁、解锁的过程自动进行的,javap反编译一下能够看到synchronized修饰的block前后分别会加上monitor enter; monitor exit,另外需要知道的是synchronized修饰静态方法的时候锁定的是当前的类,修饰的是非静态方法的时候,所定的对象是this。synchronized进行了锁膨胀的优化,有时间可以研究一下

锁对应的方法之间是具备可见性规则的,因为block的访问涉及到happen before的规则,而对于无锁的方法,有锁的方法对共享变量的操作对其不一定可见,要保障可见则需要对无锁的方法加锁,或者将共享资源禁用缓存,也就是使用volatile进行修饰,不过很遗憾,volatile修饰的变量只可以是基本类型,修饰的是普通对象的话还是有可能无法访问到普通对象的最新的值(当然加锁的话需要是同一把锁才可以)

锁保障了原子性,并通过happens-before规则解决了可见性、有序性的问题,但是锁的使用也带来了死锁的问题:锁和其对应的资源之间的关系为1:N,其中N >= 1,当锁保护的资源不相关的时候,可以用不同的锁保护不同的资源,这种锁叫做细粒度锁,细粒度锁可以提高并行度,是性能优化的一个重要手段。不过细粒度锁是可能会由于相互等待导致死锁出现,例如当一个业务依赖于两个不同的加锁操作的时候可能就会出现相互等待锁的释放而进入死锁的状态

产生条件(从资源的角度来看)
  • 互斥,共享资源X和Y只能被一个线程占用(锁的排他性)
  • 占有且等待,线程A已经获得共享资源X,在等待共享资源Y的时候,不释放共享资源X
  • 不可抢占,其他线程不能强行抢占已经其他线程占有的资源
  • 循环等待,线程A等待线程B占有的资源,反之亦然

只有在上面四个条件都发生的情况下,才会出现死锁;因此只需要破坏其中一个条件就可以避免死锁发生。

解决方案:

  • 破坏占用且(循环)等待条件,一次申请所有的资源,如果申请不到,快速失败即可
  • 破坏不可抢占条件,java语言层面提供的synchronized无法破坏这个条件,Lock可以解决这个问题(TODO 如何解决的)
  • 破坏循环等待条件,对资源进行排序,获取资源失败就快速失败即可

破坏占用且等待的条件中一次申请所有的资源,通常采用的模式是忙等带,在并发量很低的情况下,这种做法没有什么问题,一旦并发量上来之后,这种循环等待的方案就不合适了,比较好的方案是采用等待-通知的机制,如果线程要求的条件不满足,则线程阻塞自身,进入等待状态,当条件满足的时候,通知等待的线程重新执行即可,这样做的好处是可以避免cpu的空转。(说明:即便上面是等待通知机制,可以将其理解为不是线程中的同步,而是互斥,也就是为了解决互斥带来的死锁问题)

占用且循环等待实施方案:

  • 忙等待:最常用的就是原子类中通过cas的操作来更新变量,这种方式的缺点是会消耗大量的cpu的计算资源
  • 等待-通知:线程首先获取互斥锁,当线程要求的条件不满足的时候,释放互斥锁,进入等待状态(等待状态是释放了锁的状态,阻塞是还没有获取到锁的状态),当要求的 条件满足的时候,通知等待的线程,重新获取互斥锁即可

java中内置的synchronized配合wait、notify、notifyAll可以轻松实现等待通知的机制,其为管程的一种实现方式,如下图所示:

当线程进入synchronized保护的临界区的时候,如果线程执行的条件不满足,那么是可以通过调用wait方法释放当前获取到的锁资源而进入等待的状态,在条件满足的时候可以通过notify或者notifyAll来唤醒等待队列中的线程,notify只能保证在唤醒的时刻条件是满足的,并且推荐使用notifyAll来实现线程的通知机制,这样是可以尽量保证线程的公平性的,因为notify是随机挑选一个线程进行唤醒,这很可能会导致有些线程永远不会被通知到。需要明确一点,这里的两个队列分别是阻塞队列和互斥锁的等待队列,被唤醒的线程只会是互斥锁的等待队列,而线程在不满足条件而进入阻塞队列之后,jvm线程调度操作只会从阻塞队列中选择线程。另外既然synchronized是管程的一种实现方式,那么其实现等待通知的机制必然也是上面所提到的范式。

另外需要强调的是尽量使用notifyAll而不是notify,这是为了防止信号的丢失:假设我们有资源 A、B、C、D,线程 1 申请到了 AB,线程 2 申请到了 CD,此时线程 3 申请 AB,会进入等待队列(AB 分配给线程 1,线程 3 要求的条件不满足),线程 4 申请 CD 也会进入等待队列。我们再假设之后线程 1 归还了资源 AB,如果使用 notify() 来通知等待队列中的线程,有可能被通知的是线程 4,但线程 4 申请的是 CD,所以此时线程 4 还是会继续等待,而真正该唤醒的线程 3 就再也没有机会被唤醒了。

上面提到了wait的使用,因此顺便提一下wait和sleep的异同:

  • 不同点
    • wait会释放锁而sleep不会释放锁资源,也正因为如此wait只能在同步方法和代码块中使用,而sleep可以在任何场景中使用
    • wait无需捕捉异常,而sleep需要捕捉。
  • 共同点:
    • 都会让渡cpu执行时间,等待再次被调度

demo

接下来通过等待-通知的机制来实现一个同步阻塞队列://TODO

线程

上面介绍了并发编程中存在的问题,以及解决这些问题而引入的一些概念、方案,并衍生出新的问题以及新问题的解决方案,接下来我们看一下并发编程的真正的执行者:线程吧。需要说明一下,线程其实包含了两种层次的线程,一种是编程语言级别的线程,一种是操作系统级别的线程

通用线程生命周期

通用的线程生命周期包含了:

  • 初始状态:编程语言级别的线程被创建、未被分配cpu
  • 可运行状态:操作系统级别的线程被创建,可以分配cpu
  • 运行状态:有空闲的cpu,并且被分配给可运行状态的线程
  • 阻塞状态:当线程等待某个事件的时候(管程对应的条件变量),线程就会转换到休眠的状态,同时会释放cpu,等到等待的事件满足,该线程被唤醒之后就会从阻塞状态转变成可运行状态
  • 终止状态: 线程正常执行完或者执行的过程中异常结束,终止状态的线程将不会再转换成其他的状态

如下图所示:

java线程生命周期

区别于通用线程生命周期,java中线程的生命周期对部分状态进行了合并及拆分,最终如下:

  • 初始状态:同上,编程语言级别的线程被创建,操作系统级别的线程并没有创建,对应到语言就是new一个thread
  • 运行状态:运行状态是上面的可运行与运行状态的合并,只要调用线程对象的start方法就可以了
  • 阻塞状态:线程在使用synchronized修饰的方法的时候,由于互斥的原因,进入入口队列中等待的状态
  • 等待状态
    • 无限等待状态:
      • 在synchronized修饰的方法中调用了wait的方法会导致线程进入到无限的等待状态中
      • Thread.join方法的调用,主调线程将会等待被调线程执行完之后才可以继续执行,这时候主调线程就会进入无限的等待状态中
      • LockSupport.park方法的调用会使得调用这个方法的线程陷入无限的等待状态中,当调用unpark方法之后又可以将线程唤醒
    • 限时等待状态:
      • 无限等待状态中的加入时间参数
      • 调用Thread.sleep方法
  • 终止状态:线程正常执行完或者执行过程中抛出异常的时候就会导致线程终止,当我们希望在执行的过程中直接终止线程,可以使用interrupt方法,不推荐使用stop等方法,这些方法会导致线程持有的锁无法释放。

前面我们提到的线程终止方案的时候有提到interrupt,现就对其简要介绍: interrupt会给线程发送一个通知,不过线程在接收到该通知的时候,可以选择处理也可以选择忽略,当线程处于运行状态的时候,线程可以直接通过isInterrupted() 方法来检测是否收到中断的通知,并进行相关的处理处理该通知,不过当线程处于等待状态的时候,不管是等待状态的哪一种状态,都会使得线程抛出异常,并且需要强调的是在抛出异常之后该中断标记位会复位,因此我们如果希望正常的处理这个通知,是需要在捕获异常之后进行重新的置位,通用的模式如下:

1
2
3
4
5
6
7
8
9
10
11
12
Thread th = Thread.currentThread();
while(true) {
if(th.isInterrupted()) {
break;
}
// 省略业务代码无数
try {
Thread.sleep(100);
}catch (InterruptedException e){
e.printStackTrace();
}
}

小结

本节主要是介绍了并发编程中的锁、线程相关的知识,接下来将会介绍并发编程常见的工具类