JAVA并发算法中伪共享导致性能崩溃的问题复现与优化

JAVA并发算法中伪共享导致性能崩溃的问题复现与优化

大家好,今天我们来聊聊一个在并发编程中经常被忽视,但威力巨大的性能杀手:伪共享。它就像隐藏在代码深处的幽灵,悄无声息地吞噬着程序的性能,尤其是在高并发、多核 CPU 的环境下。

什么是伪共享?

要理解伪共享,首先要了解 CPU 的缓存体系。现代 CPU 为了提高数据访问速度,引入了多级缓存(L1、L2、L3 Cache)。这些缓存以缓存行(Cache Line)为单位进行管理,通常大小是 64 字节(这个值会因 CPU 架构而异,可以通过 sysconf(_SC_LEVEL1_DCACHE_LINESIZE) 获取)。

伪共享指的是多个线程修改不同的变量,但这些变量却位于同一个缓存行中,导致缓存一致性协议(例如 MESI 协议)频繁生效,使得缓存行在多个 CPU 核心之间来回传递,从而降低性能。

为了更好地理解,我们先看一个简单的例子:

public class FalseSharing {

    private static final int NUM_THREADS = 4;
    private static final long ITERATIONS = 500_000_000L;

    private static class VolatileLong {
        public volatile long value = 0L;
    }

    private static VolatileLong[] longs;

    public static void main(final String[] args) throws Exception {
        longs = new VolatileLong[NUM_THREADS];
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new VolatileLong();
        }

        final long start = System.nanoTime();
        Thread[] threads = new Thread[NUM_THREADS];

        for (int i = 0; i < NUM_THREADS; i++) {
            final int j = i;
            threads[i] = new Thread(() -> {
                long value = longs[j].value;
                for (long k = 0; k < ITERATIONS; k++) {
                    value = longs[j].value;  // 读操作,尝试优化掉
                    value++;
                    longs[j].value = value; // 写操作
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        final long duration = System.nanoTime() - start;
        System.out.println("Duration = " + duration);
    }
}

在这个例子中,我们创建了一个 VolatileLong 数组,每个线程负责修改数组中一个元素的值。看似每个线程都在操作不同的内存地址,互不干扰。但是,如果 VolatileLong 对象在内存中是紧凑排列的,那么很可能多个 VolatileLong 对象的 value 属性位于同一个缓存行中。

当一个线程修改了 longs[0].value 的值时,会导致包含这个值的缓存行失效。如果另一个线程正在访问 longs[1].value(假设它和 longs[0].value 在同一个缓存行中),那么它需要从主内存或者其他 CPU 核心的缓存中重新获取这个缓存行,这会带来显著的性能损耗。

如何复现伪共享?

运行上面的代码,你会发现执行时间比你预期的要长。具体长多少,取决于你的 CPU 核心数、缓存行大小等因素。为了更清晰地观察伪共享的影响,我们可以增加迭代次数,并多次运行取平均值。

为了更方便地分析,我们可以使用一些工具来辅助观察,例如 Linux 下的 perf 工具。

  1. 编译代码: javac FalseSharing.java
  2. 运行代码并记录性能事件: perf stat -e cache-misses,cache-references java FalseSharing

运行结果会显示大量的 cache-misses(缓存未命中)事件,说明缓存利用率很低,这正是伪共享的典型特征。

伪共享的危害有多大?

伪共享带来的性能损失非常可观。在某些情况下,它可以导致程序性能下降几个数量级。尤其是在高并发场景下,大量的线程争夺同一个缓存行,会导致系统频繁地进行缓存同步,从而使得 CPU 资源被浪费在维护缓存一致性上,而不是执行实际的计算任务。

如何避免伪共享?

避免伪共享的核心思想是:确保被并发访问的变量位于不同的缓存行中。

以下是一些常用的避免伪共享的方法:

  1. Padding(填充): 在变量前后填充额外的字节,使其位于不同的缓存行中。

    public class PaddedVolatileLong {
       public volatile long value = 0L;
       public long p1, p2, p3, p4, p5, p6, p7; // Padding
    }

    或者使用更简洁的注解:

    import sun.misc.Contended;
    
    @Contended
    public class ContendedVolatileLong {
       public volatile long value = 0L;
    }

    注意: @Contended 注解在 JDK 8 中默认是禁用的,需要通过 JVM 参数 -XX:-RestrictContended 启用。在 JDK 9 及以后,这个注解的行为可能会有所变化,需要查阅相关文档。

  2. 对象数组: 将共享的变量封装成对象,然后使用对象数组。由于每个对象都有自己的对象头,因此可以增加变量之间的距离。

    public class ObjectVolatileLong {
       public volatile long value = 0L;
    }
    
    ObjectVolatileLong[] longs = new ObjectVolatileLong[NUM_THREADS];
    for (int i = 0; i < NUM_THREADS; i++) {
       longs[i] = new ObjectVolatileLong();
    }
  3. 使用 Unsafe 类: Unsafe 类提供了直接操作内存的能力,可以精确地控制变量的内存布局。 这种方法比较底层,需要对 JVM 的内存模型有深入的了解。

    import sun.misc.Unsafe;
    import java.lang.reflect.Field;
    
    public class UnsafePaddedLong {
       private static final Unsafe UNSAFE;
       private static final long VALUE_OFFSET;
    
       static {
           try {
               Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
               theUnsafe.setAccessible(true);
               UNSAFE = (Unsafe) theUnsafe.get(null);
               VALUE_OFFSET = UNSAFE.objectFieldOffset(UnsafePaddedLong.class.getDeclaredField("value"));
           } catch (NoSuchFieldException | IllegalAccessException e) {
               throw new Error(e);
           }
       }
    
       private long p1, p2, p3, p4, p5, p6, p7; // Padding
       private volatile long value = 0L;
       private long q1, q2, q3, q4, q5, q6, q7; // Padding
    
       public long getValue() {
           return UNSAFE.getLongVolatile(this, VALUE_OFFSET);
       }
    
       public void setValue(long newValue) {
           UNSAFE.putLongVolatile(this, VALUE_OFFSET, newValue);
       }
    }
  4. Java 8 的 LongAdderStriped64 LongAdder 是 Java 8 中提供的一个线程安全的累加器,它内部使用了多个 Cell 对象,每个 Cell 对象都包含一个 long 类型的 value 属性,这些 Cell 对象分散在不同的缓存行中,从而避免了伪共享。Striped64LongAdder 的父类,它提供了更通用的机制来避免伪共享。

    import java.util.concurrent.atomic.LongAdder;
    
    public class LongAdderExample {
       private static final int NUM_THREADS = 4;
       private static final long ITERATIONS = 500_000_000L;
    
       private static LongAdder[] adders;
    
       public static void main(final String[] args) throws Exception {
           adders = new LongAdder[NUM_THREADS];
           for (int i = 0; i < adders.length; i++) {
               adders[i] = new LongAdder();
           }
    
           final long start = System.nanoTime();
           Thread[] threads = new Thread[NUM_THREADS];
    
           for (int i = 0; i < NUM_THREADS; i++) {
               final int j = i;
               threads[i] = new Thread(() -> {
                   for (long k = 0; k < ITERATIONS; k++) {
                       adders[j].increment();
                   }
               });
               threads[i].start();
           }
    
           for (Thread thread : threads) {
               thread.join();
           }
    
           final long duration = System.nanoTime() - start;
           System.out.println("Duration = " + duration);
       }
    }

    LongAdder 的内部实现使用了分段锁(Striped64),每个线程操作不同的段,从而避免了竞争。

代码示例与性能对比

我们修改最初的 FalseSharing 代码,使用 Padding 的方式来避免伪共享,并进行性能对比。

FalseSharing.java (原始代码)

public class FalseSharing {

    private static final int NUM_THREADS = 4;
    private static final long ITERATIONS = 500_000_000L;

    private static class VolatileLong {
        public volatile long value = 0L;
    }

    private static VolatileLong[] longs;

    public static void main(final String[] args) throws Exception {
        longs = new VolatileLong[NUM_THREADS];
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new VolatileLong();
        }

        final long start = System.nanoTime();
        Thread[] threads = new Thread[NUM_THREADS];

        for (int i = 0; i < NUM_THREADS; i++) {
            final int j = i;
            threads[i] = new Thread(() -> {
                long value = longs[j].value;
                for (long k = 0; k < ITERATIONS; k++) {
                    value = longs[j].value;
                    value++;
                    longs[j].value = value;
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        final long duration = System.nanoTime() - start;
        System.out.println("Duration = " + duration);
    }
}

PaddedFalseSharing.java (使用 Padding 避免伪共享)

public class PaddedFalseSharing {

    private static final int NUM_THREADS = 4;
    private static final long ITERATIONS = 500_000_000L;

    private static class PaddedVolatileLong {
        public volatile long value = 0L;
        public long p1, p2, p3, p4, p5, p6, p7; // Padding
    }

    private static PaddedVolatileLong[] longs;

    public static void main(final String[] args) throws Exception {
        longs = new PaddedVolatileLong[NUM_THREADS];
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new PaddedVolatileLong();
        }

        final long start = System.nanoTime();
        Thread[] threads = new Thread[NUM_THREADS];

        for (int i = 0; i < NUM_THREADS; i++) {
            final int j = i;
            threads[i] = new Thread(() -> {
                long value = longs[j].value;
                for (long k = 0; k < ITERATIONS; k++) {
                    value = longs[j].value;
                    value++;
                    longs[j].value = value;
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        final long duration = System.nanoTime() - start;
        System.out.println("Duration = " + duration);
    }
}

在我的测试环境中(Intel i7-8700K, 6 cores, 12 threads),运行结果如下:

代码 执行时间 (纳秒)
FalseSharing.java ~25,000,000,000
PaddedFalseSharing.java ~5,000,000,000

可以看到,使用 Padding 避免伪共享后,性能提升了大约 5 倍。

重要提示: 实际性能提升幅度取决于你的硬件配置、JVM 版本、以及代码的其他因素。

总结与启示

伪共享是一个隐蔽但严重的性能问题,尤其是在并发编程中。通过填充、对象数组、Unsafe 类或使用 LongAdder 等方式,我们可以有效地避免伪共享,从而提升程序的性能。

  • 理解缓存一致性协议至关重要: 了解 CPU 的缓存机制和缓存一致性协议是避免伪共享的前提。
  • 性能测试是关键: 避免过度优化,只有通过实际的性能测试才能确定是否存在伪共享,以及优化是否有效。
  • 选择合适的解决方案: 根据具体的场景选择合适的解决方案,例如,对于简单的计数器可以使用 LongAdder,对于更复杂的数据结构可以使用填充或对象数组。

希望今天的分享能帮助大家更好地理解和解决伪共享问题,写出更高效的并发程序。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注