并发编程那些事

对最近并发编程学习的总结

任何事物的产生都是有历史原因的,这同样也适用于计算机领域

进程、线程、协程的产生

  • 最开始的计算机只有一个 CPU, 操作系统也只能顺序的执行单任务,每次只能等前面的一个任务执行完后才能执行第二个任务,如果一开始执行一个比较耗时的任务那其它的任务还玩不玩?那怎么才能让大家都能玩呢?在单 CPU 硬件限制下只能根据一些算法策略分配执行时间片来给这些任务了,有些任务并没有执行完就被调度程序切换到别的任务了,那中间的状态怎么保持呢?于是就产生了进程,进程就是一个包含这段程序及其上下文所需资源及运行状态的一个抽象。

  • 其实这种调度CPU及保存恢复现场的过程本质上就是协程的核心流程,只不过不由我们程序员控制,由操作系统控制。

  • 后来出现了多CPU计算机,操作系统也就可以同时执行多任务了,实现了真正意义上的并行

  • 多任务用多了后就被人们用坏了,有一个新任务就开启一个新进程,即使两个任务其实所需要的系统资源相同。操作系统会频繁的切换进程的上下文,一切换上下文进程就得反复进入内核,置换相应的资源和状态,切换越频繁,进程数越高,系统资源及CPU切换占用就会越高,真正任务的执行时间及系统可用资源就越少,为了解决这个问题,于是就产生了线程,同一进程中的线程共享进程资源,这样就可以减少进程切换导致的性能瓶颈,线程是 CPU 执行任务的基本单元,线程自己基本不拥有系统资源,只拥有少量必不可少的资源:程序计数器、一组寄存器、栈。

  • 随着高并发的需求增长,特别是在IO高并发操作场景上,过多的线程以及线程间的切换产生了性能瓶颈,多线程的优势在于线程持续的执行,但是在高IO场景下,线程大多时间不是在执行而是在频繁上下文切换,线程的上下文切换也是需要耗一些性能的,而且线程创建和销毁也是耗系统资源和时间的,为了尽可能地利用线程的执行,减少上下文切换,于是产生了协程,协程就是用户态控制程序的跳转执行,可以复用线程,而不是让其一直在IO等待或者上下文切换。协程的产生还有另一部分原因:代码编写同步式直观。

Java世界中的并发编程

共享资源的竞争同步问题

在并发的场景中同一时间不同线程对同一内存资源产生了操作,如果操作依赖这个内存资源的当前状态就可能产生问题,试想就一张票,两个人同时去买那应该卖给谁呢?这就引出了并发编程的头等问题:共享资源的竞争同步问题。

我们在现实生活中遇到这种问题怎么解决?票由一个售票员来卖,不能任由别人来抢,同一时间只能有一个人获取售票员的交谈权限。在 Java 中就是加锁机制,有语言级别的 synchronized 关键字和 API 级别的 Lock 类,都可以实现加锁同步功能,

Java线程阻塞同步

synchronized

根据作用范围可以划为三种用法

1 同步代码块

同一时间只能有一个线程来访问有此 lock 的代码块

private Object lock = new Object()
public void func(String name) {
    synchronized(lock) {
        // ...
    }
}

2 同步方法

同一时间只能有一个线程来访问该对象中用synchronized定义的方法,因为本质上锁的是当前类的对象

public synchronized void func(String name) {
    // ...
}

3 同步静态方法

同一时间只能有一个线程来访问该类所有对象中用synchronized定义的方法,因为本质上锁的是当前类

public synchronized static void func(String name) {
    // ...
}

Lock

Lock是 API 级别的加锁方式,一个锁可以有多个条件,可以实现更细粒度的控制,由于不是系统控制锁的退出可能程序运行异常导致锁不会被释放所以一定要用try finally 来确保锁的释放:

private Lock lock;
public int func(int value) {
   try {
       lock.lock();
       // ...
   } finally {
      lock.unlock();
   }
}

Lock 还有一种使用场景是可以中断等候锁,使用 synchronized 会一直等待锁的释放,而使用Lock可以根据需要中断等待,转去做别的事。

Lock 有如下的子类:

synchronized 获取的互斥锁不仅互斥读写操作、写写操作,还互斥读读操作,而读读操作是一个幂等操作并不会带来数据竞争,因此对对读读操作也互斥的话,会降低性能。Java 5中提供了读写锁,它将读锁和写锁分离,使得读读操作不互斥,解决了这方面的性能影响。

锁优化

synchronized 会有不同情况下的性能问题,针对这些问题在 JDK1.6 中虚拟机团队做了一些优化策略

1 自旋锁和自适应锁

挂起线程和恢复线程的操作都需要转入内核态完成,如果加锁的这段代码执行很快,那么其他线程就没必要切换这个上下文了,可以空运行一会,等到前面的线程执行完直接接手,这就是自旋锁。

很明显自旋锁的适用场景决定了如果不满足这个场景(锁被占用的时间很长)也会存在性能问题(自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的)

针对这个问题的更一步智能化优化:在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如 100 个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越 “聪明” 了。

2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判定在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把他们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

也许读者会有疑问,变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?答案是有许多同步措施并不是程序员自己加入的。同步的代码在 Java 程序中的普遍程度也许超过了大部分读者的想象。下面段非常简单的代码仅仅是输出 3 个字符串相加的结果,无论是源码字面上还是程序语义上都没有同步。

一段看起来没有同步的代码

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

我们也知道,由于 String 是一个不可变的类,对字符串的连接操作总是通过生成新的 String 对象来进行的,因此 Javac 编译器会对 String 连接做自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作,在 JDK 1.5 及以后的版本中,会转化为 StringBuilder 对象的连续 append() 操作,即上面的代码可能会变成下面的样子:
Javac 转化后的字符串连接操作

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

每个 StringBuffer.append() 方法中都有一个同步块,锁就是 sb 对象。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会 “逃逸” 到 concatString() 方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了

3 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

上一个代码例子中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以上上述代码为例,就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。

4 轻量级锁

synchronized 还有一种损耗在于加锁和解锁的性能损耗,如果大多数执行情况下并没有发生资源竞争,那么这种多余的加锁和解锁操作就是一种无效的损耗了,轻量级锁底层采用CAS操作实现,并不会阻塞线程,但是有资源竞争的情况下就会有类似于自旋锁的损耗了。

5 偏向锁

偏向锁也是 JDK 1.6 中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不做了。

偏向锁的 “偏”,就是偏心的 “偏”、偏袒的 “偏”,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

轻量级锁和偏向锁的原理

线程通信

假如有这样一种场景:有两个线程,一个不停产生一个食物,一个不停吃掉这个食物,而由于餐盘(容量)有限只能同时承载5个食物,那么当第一个线程产生食物数量到达5个的时候应该终止,而另一个线程当食物为0的时候也应该终止,因为没有食物可吃了,当然也不可能同时这两个线程对餐盘这个容器进行操作,因为会有并发同步问题,那么我们就在这个餐盘上加个锁,同时只能有一个线程操作,这就会产生一个问题了,这两个线程一直是在运行的,先获得锁的是生产者线程,它会一直循环产生食物,直到产生了5个食物,那么它会在当前阻塞并一直持有该餐盘(容量)的操作权限,这个时候第二个线程不知道已经产生了5个食物,且没有该餐盘的访问权限,所谓也在一直阻塞等待该锁的释放。

怎么解决加锁机制中的这个问题呢?

当然你可以通过 Lock 来手动控制,也可以通过条件 synchronized 来控制,但有另外一种方式就是 线程间通信。当生产者线程产生了5个食物的时候释放掉锁并通知消费者线程执行,当消费者线程消费掉所有食物后通知生产者线程继续执行。

Java 中提供了这种机制,即 Object 类中的 wait() notify() notifyAll(),wait() 代表等待挂起,并释放掉锁,notify表示随机唤醒一个等待挂起中的线程,并拥有该锁。notifyAll 则是通知所有等待中的线程。

private boolean flag = false;

public synchronized void after() {
    while(flag == false) {
        wait();
        // ...
    }
}

public synchronized void before() {
    flag = true;
    notifyAll();
}

Java 中内存模型导致的并发同步问题

现代计算机中 CPU 的执行相对于内存的操作是非常快的,所以为了提高 CPU 运行效率,加入了高速内存缓存这一层级,但是有缓存就会有数据同步问题,表现在 Java 中就是主内存和工作内存的一致性问题。

Java 内存模型

Java 虚拟机规范中试图定义一种 Java 内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

主内存与工作内存

Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图所示。

内存间的交互操作

Java 内存模型定义了 8 种操作来完成工作内存与主内存之间的交互:一个变量从主内存拷贝到工作内存、从工作内存同步回主内存。虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中

volatile

volatile 关键字修饰的变量 会保持内存可见性,也就是说每次操作对所有线程都是可见的,访问这个变量时会强制从主内存读取,修改这个变量时会强制写会到主内存,所以在并发中一些场景是可以使用 volatile 来解决同步问题的,但前提是不依赖于当前变量的状态的操作,如 i++ 的操作,因为它是依赖于当前状态,是由三个原子操作组成的:读、自增、写,如果在一个线程执行读后自增的操作时,另外线程在执行读,那么就会出现数据同步的问题了。

同时 volatile 会禁止指令重排,指令重排(数据没有依赖的语句)可以优化cpu的执行,但在多线程中指令重排就可能导致意外的结果:

在线程A中

context=initContext()
inited=true;

在线程B中

while(inited){
   doSomething(context); 
}

假如线程A中发生了指令重排

inited=true;
context=initContext()

那么B中很可能就会拿到一个尚未初始化或尚未初始化完成的 context,从而引发程序错误。所以这个时候inited在并发编程中就应该用 volatile 修饰。

volatile 使用条件:

  • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  • 该变量没有包含在具有其他变量的不变式中。

volatilesynchronized的比较

  • volatile 并没有实现加锁机制,不会阻塞其它线程,所以比 synchronized 轻量级
  • volatile 不能解决依赖于当前状态的并发场景
  • 加锁机制既可以确保可见性又可以确保原子性,而 volatile 变量只能确保可见性

非阻塞同步

加锁机制是一种悲观策略,它是假设执行这段代码不加锁一定会有问题,无论数据是否竞争都一般会进行加锁(有些虚拟机会优化掉不必要的锁),而加锁会有一些性能问题(用户态内核态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作)如果并没有发生数据竞争或者竞争的几率很小时,每次加锁就得不偿失,

随着计算机指令集的发展,我们有了另外一种策略:基于冲突检查的乐观并发策略,通俗来说就是先进行操作,如果没有冲突(即没有其他线程竞争)则操作成功,如果有冲突就采取其他措施(最常见的补偿措施就是不断地重试,直到成功为止)。

上文所讲 volatile 在 i++ 的情况下不能确保同步,是因为有三个原子操作:读取、自增、写入,如果有一种指令能完成这三个操作不就能保持同步了吗?,现在就有类似于这种指令(虽然从语义上来看是多种操作,但是其实是一条处理器指令):

  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(Compare-and-Swap,下文称 CAS)
  • 加载链接/条件存储(Load-Linked/Store-Conditional,下文称 LL/SC)

其中 CAS 指令就能实现这种效果

CAS

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论V值是否等于A值,都将返回V的原值。CAS 有效地说明了:我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

当多个线程尝试使用CAS同时更新一个变量,最终只有一个线程会成功,其他线程都会失败。但和使用锁不同,失败的线程不会被阻塞,而是被告之本次更新操作失败了,可以再试一次。此时,线程可以根据实际情况,继续重试或者跳过操作,大大减少因为阻塞而损失的性能。所以,CAS是一种乐观的操作,它希望每次都能成功地执行更新操作。

Atom原子类

JDK1.5 后提供了这种操作,该操作由 sun.misc.Unsafe 类里面的 compareAndSwapInt()compareAndSwapLong() 等几个方法包装提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器 CAS 指令,没有方法调用的过程,或者可以认为是无条件内联进去了。

由于 Unsafe 类不是提供给用户程序调用的类(Unsafe.getUnsafe()的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的 Class 才能访问它),因此,如果不采用反射手段,我们只能通过其他的 Java API 来间接使用它,如 J.U.C 包里面的整数原子类( Atomic 原子类),其中的 compareAndSet()getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。

java.util.concurrent.atomic包下的类结构

  • 标量类:AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference
  • 数组类:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
  • 更新器类:AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater
  • 复合变量类:AtomicMarkableReference,AtomicStampedReference

CAS的优点

  • CAS由于是在硬件层面保证的原子性,不会锁住当前线程,它的效率是很高的。

CAS的问题点

  • ABA问题。CAS在操作值的时候检查值是否已经变化,没有变化的情况下才会进行更新。但是如果一个值原来是A,变成B,又变成A,那么CAS进行检查时会认为这个值没有变化,但是实际上却变化了。ABA问题的解决方法是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就变成1A-2B-3A。从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。

  • 并发越高,失败的次数会越多,CAS如果长时间不成功,会极大的增加CPU的开销。因此CAS不适合竞争十分频繁的场景。

  • 只能保证一个共享变量的原子操作。当对多个共享变量操作时,CAS就无法保证操作的原子性,这时就可以用锁,或者把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

对竞争资源的操作受限于同一线程

如果把每次对竞争资源的操作受限于单一线程操作,就不会有发生竞争冲突的问题了,事实上在图形应用程序中,对UI资源的更新操作会受限于UI线程,以此来解决UI资源的多线程并发问题,如在 Android开发中其它线程想要有更新UI的操作,会借助主线程的Handler将 UI 更新操作发送到主线程消息队列中等待主线程调用来完成对应的UI更新。

并发编程的最佳实践总结

synchronized、lock、volatile、Atomic类:

  • synchronized 悲观锁,高并发下性能堪忧,性能瓶颈:加解锁,阻塞线程,线程上下文切换
  • lock 粒度更细的锁,高并发下性能较好(原因在于底层使用CAS机制),性能瓶颈同synchronized
  • volatile 在满足其使用场景的情况下性能较好,因为不会阻塞线程
  • Atomic 乐观锁,如果使用场景竞争小的概率大的情况下,性能较好,因为采用CAS机制,竞争较大的情况下会有 CAS 重复的 CPU 损耗,同volatile样也有不适用的场景,如被定义的变量与其它的变量在操作上有依赖。

上面的并发同步方案都有自己的适用场景,但是对于我们开发者来说,如果在不是很熟悉大多数方案的情况下直接使用 synchronized 即可,synchronized 已经作了很多优化,简单高效不易出错,如果有进一步的性能需求再考虑其他的方案。

最后

谢谢阅读!文中如有错误欢迎交流沟通

Thanks:
《深入理解 Java 虚拟机》
进程、线程与处理器的调度
为什么觉得协程是趋势
协程的好处有哪些
正确使用Volatile
Java并发