并发中需要注意的问题很多,总结起来主要是三个方面:安全性、**活跃性以及性能**问题。

《JCIP》中后半本书中大部分篇幅都在讲这三个问题,但是我之前看到第八章就没有继续往后看了,这篇文章则是比较简单概括的描述了这三个问题,适合先建立一个印象,详细学习还是要去看 Java并发编程实战

安全性问题

所谓安全,经常会听到这个方法不是线程安全的,这个不是线程安全的等等。

线程安全的本质是 正确性正确性指的是程序按照我们期望的结果执行

多线程环境下很多 Bug 都是出乎我们预料的,程序没有按照我们期望的顺序和结果执行。

线程安全问题的三个源头:有序性可见性原子性

首先,会出问题的前提是在多线程下的共享变量中,所以如果你是单线程的,或者在多线程环境下该变量被封闭在类中,或是封闭在方法中的,外部无法访问,并且生命周期与方法或线程相同,那么就不必担心被其他线程修改的问题。

有很多方案都是基于不共享来解决可见性问题,例如:

  • 线程本地存储(Thread Local Storage TLS)
  • 不变模式

但是有时候 必须共享会发生变化的数据,这样的场景在现实生活中也很多。

当多个线程同时访问同一个数据,并且至少有一个线程会写入数据的时候,如果不采用同步措施,就会导致并发 Bug ,对此有一个专业术语:数据竞争(Data Race)

比如下面代码:


public class Test {
  private long count = 0;
  void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
}

这个代码是第一章中的示例代码

  • {% post_link 读书笔记/极客时间/Java并发编程实战/01|可见性、原子性和有序性问题:并发编程Bug的源头 01 | 可见性、原子性和有序性问题:并发编程Bug的源头 %}

多线程环境下调用这个方法的时候,如果不采用同步措施,则 count 一定不是我们预期中的值,因为 count+= 1 是一个非原子操作,存在竞态条件,其结果的正确性依赖执行顺序,所以无法保证 count 的值与 预期相同。

如果我们在访问数据的地方加锁,也并不能解决并发问题


public class Test {
  private long count = 0;
  // 加锁之后的 get 和 set 方法都是线程安全的了,但是将这 2 个线程安全的方法组合在一起使用仍然无法保证线程安全
  synchronized long get(){
    return count;
  }
  synchronized void set(long v){
    count = v;
  } 
  void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      set(get()+1)  // 虽然 get 和 set 都是原子操作,但是这里 set 依赖 get 获取的值,两个原子操作组合起来是仍然是一个非原子操作,存在 竞态条件     
    }
  }
}

这里我们增加了同步的 getset 方法来访问 count 变量,但是 add10K 仍然不是线程安全的。

假如此时 count = 0 ,有2个线程同时调用 get 方法,此时他们得到的值都是0,然后执行 get+1 操作,最终的结果是 1 ,而不是预期中的 2.

这时候这种问题叫做竞态条件(Race Condition)竞态条件指的是程序的执行结果依赖线程的执行顺序

比如上面的例子,如果两个线程完全同时执行,那结果就是错误的1,如果是先后执行,则结果是正确值2.

如果程序存在竞态条件,则该程序的结果不确定的,这属于严重的Bug。下面用一个转账的例子来说明竞态条件。

转账操作里有一个判断条件转出金额不能大于余额。但在并发环境中,如果不做控制,当多个线程同一个账号做转出操作时,就有可能出现超额转出问题。

假设 账户A 余额200线程1线程2 都要从 账户A 中转出 150,在下面的代码中,有可能 线程1线程2 同时执行到 if (this.balance > amt),这时 线程1线程2 都会发现 转出金额 150 小于 余额200 ,所以可以执行转出操作,从而发生超额转出问题。


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

也可以按照下面这样来理解竞态条件

并发场景中,程序的执行依赖某条件变量。

if(状态变量 满足 执行条件) {
	执行操作
}

当某个线程发现状态变量满足执行条件后,开始执行操作,就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致条件不满足了。

很多场景中这个条件不是显式的,比如 addOne 中,set(get()+1) 隐式依赖 get() 的结果。

对于 数据竞争竞态条件,都可以使用 互斥 来解决,保证同时只有1个线程做操作,这个线程做操作的时候其他任何线程都不能修改状态条件。

实现互斥的方案很多:

  • CPU 提供了相关的互斥指令
  • 操作系统、编程语言提供了相关的 API

从逻辑上来看,这些都可以归类为:。关于锁,之前已经有介绍 Java 的内置锁 synchronized ,后面还会介绍 JDK 类库中实现的锁。

活跃性问题

活跃性问题:某个操作无法执行下去,导致程序卡死。死锁就是一个典型的活跃性问题,除了死锁外,还有两种情况:"活锁"、"饥饿"。

死锁发生后线程会陷入永久互相等待,从程序上来看,就是永久的阻塞了,无法继续执行。

活锁:**线程虽然没有阻塞,但是仍然存在无法继续执行的情况。**例如:有两个路人,甲从左手出门,乙从右手进门,(同一个门)两人为了不相撞,互相谦让,甲让路走右边,乙让路走左边,这导致两人有发生了碰撞。但是人是会交流的,所以这种问题不会永远持续下去。

在编程中,可能存在这种永久持续下去的问题,成为没有阻塞,但是依然无法继续执行的活锁

活锁的解决方案很简单:谦让时,尝试等待一个随机时间。 例如上面的例子,甲在谦让时不时立刻换到另一边,而是等待一个随机时间再切换。由于两人的等待时间是随机的,所以同时相撞后再次相撞的概率就很低了。

等待一个随机时间」虽然很简单,但是很有效,知名的分布式一致性算法 Raft 中就用到了它。

饥饿:线程无法访问所需资源从而导致无法继续执行下去:

  • 如果线程优先级不一致,则优先级低的线程得到的执行机会很小,就可能发生饥饿。
  • 持有锁的线程执行时间过长,导致很多线程等待获取锁也可能导致饥饿问题。

解决饥饿问题有三种方案:

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

在并发编程中,公平分配资源主要通过使用 「公平锁」来解决,所谓公平锁就是先来后到的方案,线程的等待存在顺序排在等待队列前的面的线程会优先获得资源

性能问题

使用锁要小心,如果使用过度,就会出现性能问题。 锁的过度使用可能导致串行化范围过大,从而使扩展性大幅度下降,无法发挥多线程的优势。而我们使用多线程的目的大多数时候也是为了提升性能。

所以要尽量减少串行,下面开始分析串行对性能的影响:

假设串行百分比是 5%,我们使用多核多线程相比单核单线程的提升是多少呢?

根据 阿姆达尔Amdahl)定律,计算处理器并行运算之后效率提升的能力,具体公式如下:

image-20200727143620764

  • n : cpu 核数
  • p 并行百分比(1-p) 则为 串行百分比

假设串行百分比 5%,CPU 核数 n 无穷大,则 加速比 S 的极限是 20,也就是说当串行率 5% 的时候,无论采用什么技术,最高提高 20倍的性能

所以使用锁的时候就要避免锁带来的性能问题,这个问题很复杂,JDK 并发包中之所以有那么多东西,很大一部分原因就是要提升某个特定领域性能

从高一点的解决方案层面来说,有两个对于性能问题的解决方案:

1、使用锁带来性能问题,那就使用无锁的算法和数据结构。这方面 Java 中的技术包括:

  • 线程本地存储(TLS)
  • 写入时复制(Copy-on-write)
  • 乐观锁
  • 原子类
  • Disruptor 无锁内存队列

2、减少锁持有的时间。互斥锁的本质是将并行程序串行化,所以要增加并行度就必须减少持有锁的时间:

  • 使用细粒度锁【典型例子就是 ConcurrentHashMap,它使用了分段所技术】
  • 读写锁,读是无锁的,只有些的时候才会互斥。

性能衡量指标吞吐量延迟并发量

  • 吞吐量:单位时间能处理的请求,越高越好。
  • 延迟:发出请求到收到响应的时间,越低越好。
  • 并发量:可同时处理的请求数量,随着并发量的增加,延迟也会增加。所以延迟也会基于``并发量来说。

总结:

并发编程是一个复杂的技术领域,微观上涉及到原子性,可见性、有序性问题,宏观上则变现为安全性、活跃性、以及性能问题。

设计并发程序时,主要从宏观出发,重点关注安全性、活跃性、性能。安全性方面需要注意数据竞争和竞态条件,活跃性方面要注意死锁、活锁。饥饿,性能需要具体案例具体分析,根据特定的场景选择合适的数据结构和算法。

要解决问题首先就要将问题分析清楚,要写好并发程序首先要了解并发程序可能遇到的相关问题。

课后留言:

Q:怎样计算串行百分比

A:可以将临界区都理解为是串行的非临界区是并行的,用 单线程执行临界区的时间 / 单线程执行 (临界区+非临界区)的时间 就是 串行百分比。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。