learn-tech/专栏/Java并发编程78讲-完/40AtomicInteger在高并发下性能不好,如何解决?为什么?.md
2024-10-16 00:20:59 +08:00

198 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
40 AtomicInteger 在高并发下性能不好,如何解决?为什么?
本课时我们主要讲解 AtomicInteger 在高并发下性能不好,如何解决?以及为什么会出现这种情况?
我们知道在 JDK1.5 中新增了并发情况下使用的 Integer/Long 所对应的原子类 AtomicInteger 和 AtomicLong。
在并发的场景下,如果我们需要实现计数器,可以利用 AtomicInteger 和 AtomicLong这样一来就可以避免加锁和复杂的代码逻辑有了它们之后我们只需要执行对应的封装好的方法例如对这两个变量进行原子的增操作或原子的减操作就可以满足大部分业务场景的需求。
不过,虽然它们很好用,但是如果你的业务场景是并发量很大的,那么你也会发现,这两个原子类实际上会有较大的性能问题,这是为什么呢?就让我们从一个例子看起。
AtomicLong 存在的问题
首先我们来看一段代码:
/**
* 描述: 在16个线程下使用AtomicLong
*/
public class AtomicLongDemo {
public static void main(String[] args) throws InterruptedException {
AtomicLong counter = new AtomicLong(0);
ExecutorService service = Executors.newFixedThreadPool(16);
for (int i = 0; i < 100; i++) {
service.submit(new Task(counter));
}
Thread.sleep(2000);
System.out.println(counter.get());
}
static class Task implements Runnable {
private final AtomicLong counter;
public Task(AtomicLong counter) {
this.counter = counter;
}
@Override
public void run() {
counter.incrementAndGet();
}
}
}
在这段代码中可以看出我们新建了一个原始值为 0 AtomicLong然后有一个线程数为 16 的线程池并且往这个线程池中添加了 100 次相同的一个任务
那我们往下看这个任务是什么在下面的 Task 类中可以看到这个任务实际上就是每一次去调用 AtomicLong incrementAndGet 方法相当于一次自加操作这样一来整个类的作用就是把这个原子类从 0 开始添加 100 个任务每个任务自加一次
这段代码的运行结果毫无疑问是 100虽然是多线程并发访问但是 AtomicLong 依然可以保证 incrementAndGet 操作的原子性所以不会发生线程安全问题
不过如果我们深入一步去看内部情景的话你可能会感到意外我们把模型简化成只有两个线程在同时工作的并发场景因为两个线程和更多个线程本质上是一样的如图所示
我们可以看到在这个图中每一个线程是运行在自己的 core 中的并且它们都有一个本地内存是自己独用的在本地内存下方有两个 CPU 核心共用的共享内存
对于 AtomicLong 内部的 value 属性而言也就是保存当前 AtomicLong 数值的属性它是被 volatile 修饰的所以它需要保证自身可见性
这样一来每一次它的数值有变化的时候它都需要进行 flush refresh比如说如果开始时ctr 的数值为 0 的话那么如图所示一旦 core 1 把它改成 1 的话它首先会在左侧把这个 1 的最新结果给 flush 到下方的共享内存然后再到右侧去往上 refresh 到核心 2 的本地内存这样一来对于核心 2 而言它才能感知到这次变化
由于竞争很激烈这样的 flush refresh 操作耗费了很多资源而且 CAS 也会经常失败
LongAdder 带来的改进和原理
JDK 8 中又新增了 LongAdder 这个类这是一个针对 Long 类型的操作工具类那么既然已经有了 AtomicLong为何又要新增 LongAdder 这么一个类呢
我们同样是用一个例子来说明下面这个例子和刚才的例子很相似只不过我们把工具类从 AtomicLong 变成了 LongAdder其他的不同之处还在于最终打印结果的时候调用的方法从原来的 get 变成了现在的 sum 方法而其他的逻辑都一样
我们来看一下使用 LongAdder 的代码示例
/**
* 描述 在16个线程下使用LongAdder
*/
public class LongAdderDemo {
public static void main(String[] args) throws InterruptedException {
LongAdder counter = new LongAdder();
ExecutorService service = Executors.newFixedThreadPool(16);
for (int i = 0; i < 100; i++) {
service.submit(new Task(counter));
}
Thread.sleep(2000);
System.out.println(counter.sum());
}
static class Task implements Runnable {
private final LongAdder counter;
public Task(LongAdder counter) {
this.counter = counter;
}
@Override
public void run() {
counter.increment();
}
}
}
代码的运行结果同样是 100但是运行速度比刚才 AtomicLong 的实现要快下面我们解释一下为什么高并发下 LongAdder AtomicLong 效率更高
因为 LongAdder 引入了分段累加的概念内部一共有两个参数参与计数第一个叫作 base它是一个变量第二个是 Cell[] 是一个数组
其中的 base 是用在竞争不激烈的情况下的可以直接把累加结果改到 base 变量上
那么当竞争激烈的时候就要用到我们的 Cell[] 数组了一旦竞争激烈各个线程会分散累加到自己所对应的那个 Cell[] 数组的某一个对象中而不会大家共用同一个
这样一来LongAdder 会把不同线程对应到不同的 Cell 上进行修改降低了冲突的概率这是一种分段的理念提高了并发性这就和 Java 7 ConcurrentHashMap 16 Segment 的思想类似
竞争激烈的时候LongAdder 会通过计算出每个线程的 hash 值来给线程分配到不同的 Cell 上去每个 Cell 相当于是一个独立的计数器这样一来就不会和其他的计数器干扰Cell 之间并不存在竞争关系所以在自加的过程中就大大减少了刚才的 flush refresh以及降低了冲突的概率这就是为什么 LongAdder 的吞吐量比 AtomicLong 大的原因本质是空间换时间因为它有多个计数器同时在工作所以占用的内存也要相对更大一些
那么 LongAdder 最终是如何实现多线程计数的呢答案就在最后一步的求和 sum 方法执行 LongAdder.sum() 的时候会把各个线程里的 Cell 累计求和并加上 base形成最终的总和代码如下
public long sum() {
Cell[] as = cells; Cell a;
long sum = base;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
在这个 sum 方法中可以看到思路非常清晰先取 base 的值然后遍历所有 Cell把每个 Cell 的值都加上去形成最终的总和由于在统计的时候并没有进行加锁操作所以这里得出的 sum 不一定是完全准确的因为有可能在计算 sum 的过程中 Cell 的值被修改了
那么我们已经了解了为什么 AtomicLong 或者说 AtomicInteger 它在高并发下性能不好也同时看到了性能更好的 LongAdder下面我们就分析一下对它们应该如何选择
如何选择
在低竞争的情况下AtomicLong LongAdder 这两个类具有相似的特征吞吐量也是相似的因为竞争不高但是在竞争激烈的情况下LongAdder 的预期吞吐量要高得多经过试验LongAdder 的吞吐量大约是 AtomicLong 的十倍不过凡事总要付出代价LongAdder 在保证高效的同时也需要消耗更多的空间
AtomicLong 可否被 LongAdder 替代
那么我们就要考虑了有了更高效的 LongAdder AtomicLong 可否不使用了呢是否凡是用到 AtomicLong 的地方都可以用 LongAdder 替换掉呢答案是不是的这需要区分场景
LongAdder 只提供了 addincrement 等简单的方法适合的是统计求和计数的场景场景比较单一 AtomicLong 还具有 compareAndSet 等高级方法可以应对除了加减之外的更复杂的需要 CAS 的场景
结论如果我们的场景仅仅是需要用到加和减操作的话那么可以直接使用更高效的 LongAdder但如果我们需要利用 CAS 比如 compareAndSet 等操作的话就需要使用 AtomicLong 来完成