并发中需要注意的问题很多,总结起来主要是三个方面:安全性
、**活跃性
以及性能
**问题。
《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 获取的值,两个原子操作组合起来是仍然是一个非原子操作,存在 竞态条件
}
}
}
这里我们增加了同步的 get
和 set
方法来访问 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
中就用到了它。
饥饿
:线程无法访问所需资源从而导致无法继续执行下去:
- 如果
线程
的优先级不一致
,则优先级低的线程得到的执行机会很小,就可能发生饥饿。 持有锁的线程执行时间过长
,导致很多线程等待获取锁也可能导致饥饿问题。
解决饥饿问题有三种方案:
- 保证资源充足
- 公平分配资源
- 避免持有锁的线程长时间执行
在并发编程中,公平分配资源主要通过使用 「公平锁
」来解决,所谓公平锁
就是先来后到
的方案,线程的等待
存在顺序
,排在等待队列前的面的线程会优先获得资源。
性能问题
使用锁要小心,如果使用过度,就会出现性能问题。 锁的过度使用可能导致串行化范围过大,从而使扩展性大幅度下降,无法发挥多线程的优势。而我们使用多线程的目的大多数时候也是为了提升性能。
所以要尽量减少串行,下面开始分析串行对性能的影响:
假设串行百分比是 5%,我们使用多核多线程相比单核单线程的提升是多少呢?
根据 阿姆达尔
(Amdahl
)定律,计算处理器并行运算之后效率提升的能力,具体公式如下:
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.
Comments | 0 条评论