弄懂伪共享

1什么是伪共享 (false sharing)

要理解什么是伪共享,必须先了解一下CPU的缓存结构。

计算机系统中为了解决主内存与CPU运行速度的差距,在CPU与主内存之间添加了一级或者多级高速缓冲存储器(Cache),这个Cache一般是集成到CPU内部的,所以也叫 CPU Cache,如下图是三级cache结构:
在这里插入图片描述
在Cache内部是按行存储的,其中每一行称为一个Cache行。Cache行是Cache与主内存进行数据交换的基本单位,大小一般为2的幂次数字节。比如Cache行大小为64字节(通常为64字节),Cache要缓存主内存中一个int变量时,必须将其附近的其他数据凑足64字节一起缓存进来。
在这里插入图片描述
在下图中,变量同时被放到了 CPU一级和二级缓存中(假设只有一、二级缓存), 当线程使用 CPU1对变量x进行更新时,首先会修改 CPU1的一级缓存变量x所在的缓存行,这时候在缓存一致性协议下, CPU2中变量x对应的缓存行失效。如果CPU2要读取变量y,就必须去共享的二级缓存中查找。更坏情况是,如果CPU只有一级缓存,则会导致频繁地访问主内存。
在这里插入图片描述
到这里,我们也就明白了,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享

2如何避免、解决伪共享

1 对齐填充Padding

JDK8之前一般都是通过字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同个缓存行中,例如如下代码:

public final static class FilledLong {
	public volatile long value = 0L;
	public long p1,p2,p3,p4,p5,p6;
}

假如缓存行为64字节,那么我们在 FilledLong类里面填充 long 类型的变量,每个long 类型变量占用8字节, 加上value变量总共 56 字节。另外,这里FilledLong是一 个类对象, 而类对象的字节码的对象头占用8字节,所以一个 FilledLong对象实际会占用64节的内存,这正好可以放入缓存行。

2 @Contended注解

JDK 8开始原生支持避免伪共享,提供了@Contended注解,用来解决伪共享问题。

将上面代码修改为如下:

@Contended
public final static class FilledLong {
	public volatile long value = 0L;
}

@Contended在这里注解用来修饰类,当然也可以修饰变量。注解于字段上时,有一个可选的标签contention group,不同group中的字段字段是隔离的,不会被加载到同一缓存行,同一group中的字段可能不隔离。如果没有用group标记,默认使用空标记“”,则每一个字段都在自己的group中。

如下一段代码

public class Data{
    long modifyTime;
    boolean flag;
    long createTime;
    char key;
    int value;
}

当上面的对象需要由多个线程同时的访问时,从Cache角度来说,就会有一些有趣的问题。当我们没有加任何措施时,Data对象所有的变量极有可能被加载在L1缓存的一行Cache Line中。在高并发访问下,会出现这种问题:
在这里插入图片描述

// 类前加上代表整个类的每个变量都会在单独的cache line中
@sun.misc.Contended
@SuppressWarnings("restriction")
public class ContendedData {
    int value;
    long modifyTime;
    boolean flag;
    long createTime;
    char key;
}
或者这种:
// 属性前加上时需要加上组标签
@SuppressWarnings("restriction")
public class ContendedGroupData {
    @sun.misc.Contended("group1")
    int value;
    @sun.misc.Contended("group1")
    long modifyTime;
    @sun.misc.Contended("group2")
    boolean flag;
    @sun.misc.Contended("group3")
    long createTime;
    @sun.misc.Contended("group3")
    char key;
}

采取上述措施图示: 如果对value字段进行修改,只会影响到group1的缓存行,其他行不受影响。
在这里插入图片描述

3单线程下缓存行的加速作用

在多线程下访问同一缓存行的多个变量时才会出现伪共享,在单线程下访问缓存行里面的多个变量反而会对程序运行起到加速作用。

由于缓存行的作用,当数组被放入一个或多个缓存行时,会有利于程序的执行。

看如下例子:

public class CacheLineTest1 {
     static final int ROW = 1024;
     static final int COL = 1024;

    public static void main(String[] args) {
        long[][] array = new long[ROW][COL];

        long startTime = System.currentTimeMillis();
        for (int i = 0; i < ROW; i++)
            for (int j = 0; j < COL; j++)
                array[i][j] = i * 2 + j;
        long endTime = System.currentTimeMillis();
        System.out.println("cache time:" + (endTime - startTime));
        // 运行结果为 5-6
    }
}
public class CacheLineTest2 {
     static final int ROW = 1024;
     static final int COL = 1024;

    public static void main(String[] args) {
        long[][] array = new long[ROW][COL];

        long startTime = System.currentTimeMillis();
        for (int i = 0; i < ROW; i++)
            for (int j = 0; j < COL; j++)
                array[i][j] = i * 2 + j;
        long endTime = System.currentTimeMillis();
        System.out.println("cache time:" + (endTime - startTime));
        // 运行结果为 17 - 20
}

Test1Test2执行速度更快,这是因为数组内数组元素的内存地址是连续的, 当访问数组第一个元素时,会把第1个元素后的若干元素一块放入缓存行,这样顺序访问数组元素时会在缓存中直接命中,因而就不会去主内存读取了,后续访问也是这样。也就是说,当顺序访问数组里面元素时,如果当前元素在缓存没有命中,那么会从主内存一下子读取后续若干个元素到缓存,也就是一次内存访问可以让后面多次访问直接在缓存中命中。

Test2是跳跃式访问数组元素的,不是顺序的,这破坏了程序访问的局部性原则,并且缓存是有容量控制的,当缓存满了时会根据一定淘汰算法替换缓存行,这会导致从内存置换过来的缓存行的元素还没等到被读取就被替换掉了。

总结: 所以在单个线程下顺序修改一个缓存行中的多个变量,会充分利用程序运行的局部性原则,从而加速了程序的运行。而在多线程下并发修改一个缓存行中的多个变量时就会争缓存行,从而降低程序运行性

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值