Java并发7:安全性&活跃性&性能问题
安全性问题
理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。
要对存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据这种情况下的代码进行线程安全分析。
如果能不共享数据或者数据状态不发生变化,也是可以保证线程安全。
例如线程本地存储(Thread Local Storage,TLS)、不变模式等。
数据竞争(Data Race):多个线程同时访问同一数据,且至少有一个线程会写此数据时,如不采取防护措施,就会导致并发 Bug。
如下面这种情况,多个线程调用add10K()
方法,导致对count
存在数据竞争。
public class Test {
private long count = 0;
void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
}
竞态条件(Race Condition):程序的执行结果依赖线程执行的顺序。
并发场景中,程序执行依赖于某个状态变量:
if (状态变量 满足 执行条件) {
执行操作
}
当某个线程发现状态变量满足执行条件,开始执行操作;
但此时这个线程执行操作时,其他线程同时修改了状态变量,导致状态变量不满足执行条件。
很多场景下,这个条件不是显式的。
显式理解:
A&B两线程同时调用transfer()方法:A执行第5行发现余额大于转账金额,可以执行第6行;同时B也执行第5行发现余额大于转账金额,可以执行第6行;但此时余额可能因A&B的操作导致为负。
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
//第6行共享读了数据blance,没有写
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
隐式理解:
虽然get()
&set()
都加了锁,但多个线程执行第12行时,get()
会取到相同的count=0
,导致get()+1
均为1,导致set(get()+1)
均为1。
public class Test {
private long count = 0;
synchronized long get(){
return count;
}
synchronized void set(long v){
count = v;
}
void add10K() {
int idx = 0;
while(idx++ < 10000) {
//set(get()+1) 这个复合操作,隐式依赖 get() 的结果
//先计算完get()+1这个参数,才会去执行set()的方法体
set(get()+1);
}
}
}
活跃性问题
死锁/活锁/饥饿都是常见的活跃性问题,即操作无法执行下去。
解决死锁,见Java并发5-死锁;
解决活锁,随机等待一个时间,未获取到目标资源再释放当前持有资源;
解决饥饿:保证资源充足/公平分配资源/避免持有锁的线程长时间执行。
一般采用公平分配资源,公平锁:排在等待队列前的线程会优先获得资源。
性能问题
锁使用过度,可能导致大量操作变为串行,产生性能问题。
阿姆达尔(Amdahl)定律:处理器并行运算之后效率提升的能力。
$$
S=\frac{1}{(1-p)+\frac{p}{n}}
$$
n:CPU核数
p:并行百分比
1-p:串行百分比
S:性能加速比
假设串行百分比为5%,那么n即使无穷大,也只能提升20倍性能,所以关键在于如何减少串行百分比。
如何避免锁带来的性能问题
1、使用无锁的算法和数据结构。
例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好等等。
2、减少锁持有的时间。
互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。
例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术;还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
性能度量指标
吞吐量:单位时间内能处理的请求数量。吞吐量越高性能越好。
延迟:从发出请求到收到响应的时间。延迟越小性能越好。
并发量:能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。
一因此延迟一般是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。
思考
代码是否存在并发问题?
void addIfNotExist(Vector v, Object o){
if(!v.contains(o)) {
v.add(o);
}
}
会产生竞态条件问题,多个线程同时执行到第2行,都发现v
中无o
,都回去执行第3行,造成并发BUG。
将变量封装在内部,提供线程安全的方法去访问。
class SafeVector{
private Vector v;
// 所有公共方法增加同步控制
synchronized void addIfNotExist(Object o){
if(!v.contains(o)) {
v.add(o);
}
}
}
别人的总结
临界区都是串行的,非临界区都是并行的,用单线程执行临界区的时间/用单线程执行(临界区+非临界区)的时间就是串行百分比。
——作者
Vector
实现线程安全是通过给主要的写方法加了synchronized
,类似contains()
这样的读方法并没有synchronized
,该题的问题就出在不是线程安全的contains()
方法,两个线程如果同时执行到if(!v.contains(o))
是可以都通过的,这时就会执行两次add()
方法,重复添加。也就是竞态条件。——飘呀飘的小叶子
编写并发程序的初衷是为了提升性能,但在追求性能的同时由于多线程操作共享资源而出现了安全性问题,所以才用到了锁技术,一旦用到了锁技术就会出现了死锁,活锁等活跃性问题,而且不恰当地使用锁,导致了串行百分比的增加,由此又产生了性能问题,所以这就是并发程序与锁的因果关系。
——Nevermore
服务器上存了2000万个电话号码相关的数据,要做的是把这批号码从服务器上请求下来写入到本地的文件中,为了将数据打散到多个文件中,这里通过 电话号码%1024 得到的余数来确定这个号码需要存入到哪个文件中取,比如13888888888 % 1024 =56,那么这个号码会被存入到 56.txt的文件中,写入时是一行一个号码。
为了效率这里使用了多线程来请求数据并将请求下来的数据写入到文件,也就是每个线程包含向服务器请求数据,然后在将数据写入到电话号码对1024取余的那个文件中去,如果这么做目前会有一个隐患,多线程时如果 电话号码%1024 后定位的是同一个文件,那么就会出现多线程同时写这个文件的操作,一定程度上会造成最终结果错误。——0928
写一个文件只需要一个线程就够了。
你可以用生产者-消费者模式试一下。
可以创建64个线程,每个线程负责16个文件,
同时创建64个阻塞队列,64个线程消费这7664个阻塞队列,
电话号码%1024 % 64 进入目标阻塞队列。其余的就是优化一下写文件的效率了
note:余64固然可以确定队列,但如何确定目标文件。
——作者