前些日子,同事发来一个Main函数,问:怎么保证高并发场景下计算结果的正确性。
我们来举个例子:
/**
* @author admin
* @version $Id: Counter.java, v 0.1
*/
public class Counter {
/** 共享变量 */
public volatile static int count = 0;
/**
* @param args args
* @throws InterruptedException InterruptedException
*/
public static void main(String[] args) throws InterruptedException {
// TODO Auto-generated method stub
for (int i = 0; i < 100000; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Tid:" + Thread.currentThread().getName()
+ ",结果:Counter.count=" + Counter.count);
count++;
}
});
thread.start();
}
System.out.println("运行结果:Counter.count=" + Counter.count);
}
}
很明显这不会得到正确的结果,因为volatile修饰符并不保证操作的原子性,经管它申明了count字段不保留线程副本。对于 count++; 这样的表达式,它其实包含了三个步骤:读取count值,count加1,对count赋值。volatile关键字只能保证我读到的count值是最新的。
我们对上面的程序修改一下,加入并发判断:
int tmp;
while (true) {
tmp = count;
if (tmp == count) {
tmp = count + 1;
if (tmp - 1 == count) {
count = tmp;
return;
}
}
}
局部变量是线程安全的,两个if判断来防止当前读到的count改变,否则重试。但我们惊讶的发现,结果仍是不正确的,细想之后才顿悟:其实在瞬时拉起10W个线程这样的并发量下,两重并发判断能起到的作用微乎其微。
如果在第二重if后加个锁会怎么样呢?而且,if (tmp - 1 == count) 这样的if判断效率太低,这么高的并发量,哪怕是慢一点点结果也会有些许差异吧。怀着这样的想法,我们再对上面的程序修改一下:
final Object lock = new Object();
for (int i = 0; i < 100000; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Tid:" + Thread.currentThread().getName()
+ ",结果:Counter.count=" + Counter.count);
int tmp, total;
while (true) {
tmp = count;
if (tmp == count) { // (1)
total = count + 1;
if (tmp == count) { // (2)
synchronized (lock) {
count = total;
}
return;
}
}
}
}
});
thread.start();
}
结果仍旧是不正确的,而且效率更低、结果值偏差的更大。为什么呢?第一,当发现count改变后,我们让它自旋尝试重新计算,换言之,线程任务并没有少一个,并发量并没有下来;第二,加了同步锁意味着,如果到当前线程竞争不到锁资源则阻塞。假设现在100个线程刚好走到(2)号if并且不等式通过了,这个时候它们的total变量值都是一样的,然后它们之中某个线程竞争到锁、执行 count = total; 成功、释放锁,其它线程在继续竞争锁资源时,它们的total值还是一样的,但此时count值已经改变。
※ 通常的fail-fast思想要有一定的补偿机制,要么用户发起要么程序消化,本篇为了简单暂时不考虑这种方案。
※ AtomicInteger或Unsafe能解决这类问题,它们的底层是内存屏障+CAS算法,但本篇是想讨论一个纯粹的模拟并发的问题,暂时先不考虑这种方式。而且Unsafe太危险了,线上环境我们很少使用到它。
好吧,加锁是错误的,我们尝试改变重试策略,以时间换空间,并且删除打印语句(因为它其实很慢),将并发量提至20W。程序修改如下:
for (int i = 0; i < 200000; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int tmp, total;
while (true) {
tmp = count;
if (tmp == count) {
total = count + 1;
if (tmp == count) {
count = total;
return;
} else {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
// ~什么都不做
}
}
} else {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
// ~什么都不做
}
}
}
}
});
thread.start();
}
多次运行后,结果值虽然仍是错的,但偏差值已经明显缩小。
现在我们思考,这个问题的本质是什么?我们发现,这个例子其实模拟了一种高并发场景(因为for循环很快),在某个时间线内瞬间拉起20W个线程来统计count值,但它其实是错误的。我们在任务体内做的是加1和赋值,而实际上我们真正的需求,是异步计算、同步赋值!所以我们无论怎么做,在无法异步转同步的前提下,如果不减少并发量,最后的结果是难以保证的。而且你会发现,异步计算的效率比同步更低、开销更大、代价更高,所以它是一个错误的场景,又是一个很好的场景。在这个例子里,如果我只是for循环20W次来执行 count++;,只需要16毫秒,而拉起20W个线程异步计算则至少16秒以上,就是说前后差了一千倍,但如果把count看做一种分布在运算网络上的数据呢?
我想说明的是:很多时候,并发计算是有瓶颈的,多个线程争夺一份资源,无论再怎么增加线程,效率是不会提高的,因为资源只有一份。我们可以将一份资源分成多份,以减低单个资源上的并发度,这也是我们通常的做法:以空间换时间。
import java.util.Objects;
/**
* @author admin
* @version $Id: Counter.java, v 0.1
*/
public class Counter {
/** 资源数组 */
public volatile static int count[] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
/**
* @param args args
* @throws InterruptedException InterruptedException
*/
public static void main(String[] args) throws InterruptedException {
long begin = System.currentTimeMillis();
System.out.println("开始执行");
// 加volatile避免副本拷贝
// 引进安全的局部变量
// 加预期值比较
// 同步阻塞,不仅更慢,结果偏差的更大
// 自旋并没有降低并发度
// final Object lock = new Object();
final int factor = count.length - 1;
for (Integer i = 0; i < 200000; i++) {
// 相比于取模运算,&运算更加高效
// 当数组长度为2的n次幂时,碰撞的几率越小,数据在数组上分布就越均匀。上面我们将数组长度设为16,
final int index = Objects.hashCode(i) & factor;
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// System.out.println("Tid:" + Thread.currentThread().getName()
// + ",结果:Counter.count=" + Counter.count);
int tmp, total;
while (true) {
tmp = count[index];
if (tmp == count[index]) {
total = tmp + 1;
if (tmp == count[index]) {
// synchronized(lock){
// 将元素均匀的分布在16等分的数组中,这样单个位置上的并发度变为原先的16分之1
count[index] = total;
// }
return;
} else {
// try {
// Thread.sleep(10);
// } catch (InterruptedException e) {
// // ~什么都不做
// }
}
} else {
// try {
// Thread.sleep(10);
// } catch (InterruptedException e) {
// // ~什么都不做
// }
}
}
}
});
thread.start();
// thread.join(); // 异步转同步
}
Thread.sleep(3000); // 主线程延时3秒,避免子线程未执行完时,主线程回收
// 汇总
int result = 0;
for (int j : count) {
result += j;
}
System.out.println("执行耗时:" + (System.currentTimeMillis() - begin));
System.out.println("运行结果:" + result);
}
}
如果我们再将并发量调至100W,甚至1000W呢?除了需要增加资源数组的长度,可以把fail-fast+补偿机制思想用起来,因为单个位置的并发度已经达到上限。当然,这种情况的模拟已经和实际相差甚远,百万级、千万级的并发量考量的不是单机的处理能力,而是应用的整体架构、服务器环境和业务是否具有超并发的合理性。