伪共享与volatile

volatile

先来说说volatie的作用

  • 禁止指令重排
  • 保证变量的可见性,但是不能保证互斥性

具体实现是采用了内存屏障

在《并发编程艺术》这本书中说到被volatile修饰的变量进行写操作的时候,会多出一行lock前缀的指令,触发两件事

  • 将当前处理器的缓存行数据写回到系统内存
  • 这个写回到内存的操作会使其他CPU里的缓存了该内存地址的数据无效

对象大小

我们知道java对象头的大小在32系统下面是8B,但是在64位系统下面就是16B,但是在java8里面,默认开启了指针压缩,所以是12B,但是我们都知道是以一个字宽为单位的,所以padding 4B,我们导入个小工具

        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-core</artifactId>
            <version>4.2.0</version>
        </dependency>

然后测试:

class SharingInt {
    volatile int value;
}
System.out.println("object size:"+RamUsageEstimator.sizeOf(new Object()));
System.out.println("sharingInt size:"+RamUsageEstimator.sizeOf(new SharingInt()));
结果:
object size:16
sharingInt size:16

可以看出没有实例域的Object是16B,有一个int实例域的SharingInt也是16B,是因为默认开启了指针压缩,int在java中是占用4B,所以12B+4B=16B,这借用一下R大的回复


伪共享

cpu高速缓存中的最小单位是缓存行,它的大小一般为32B,64B,128B,265B,现在电脑最常见的缓存行就64B的。当多个线程访问修改独立的变量的时候,恰好这些变量内存地址很接近,同在一条缓存行上面,由于MESI协议的原因,就会无意之间影响了性能

我们来看一个例子

class SharingInt {
    volatile int value;
//     long p1, p2, p3, p4, p5, p6;     
}
public class CacheLine extends Thread {

    private final SharingInt[] shares;
    private final int index;

    public CacheLine(SharingInt[] shares, int index) {
        this.shares = shares;
        this.index = index;
    }

    /**
     *      maven 导入小工具
     *      <dependency>
     *          <groupId>org.apache.lucene</groupId>
     *          <artifactId>lucene-core</artifactId>
     *          <version>4.2.0</version>
     *      </dependency>
     *
     */
    public static void main(String[] args) throws InterruptedException {
//        System.out.println(RamUsageEstimator.sizeOf(new SharingInt()));
        for (int i = 0; i < 10; i++) {
            test();
        }
    }

    private static void test() throws InterruptedException {
        //cpu 并行处理
        int size = Runtime.getRuntime().availableProcessors();
        SharingInt[] shares = new SharingInt[size];
        for (int i = 0; i < size; i++) {
            shares[i] = new SharingInt();
        }
        Thread[] threads = new Thread[size];
        for (int i = 0; i < size; i++) {
            threads[i] = new CacheLine(shares, i);
        }
        for (Thread t : threads) {
            t.start();
        }
        long start = System.currentTimeMillis();
        for (Thread t : threads) {
            t.join();
        }
        long end = System.currentTimeMillis();
        System.out.printf("用时: %dms\n", end - start);
    }


    @Override
    public void run() {
        for (int i = 0; i < 100000000; i++) {
            shares[index].value++;
        }
    }
}

代码很简单,N(与CPU核心相同)条线程共享同一个数组,让1~N条线程分别访问同一个数组的不同下标,互不干扰,每个线程循环1亿次读写操作(shares[index].v++)


我的电脑是4核8线程64位的系统,运行结果如下:

用时: 10531ms
用时: 9665ms
用时: 9668ms
用时: 9974ms
用时: 10364ms
用时: 10250ms
用时: 10342ms
用时: 10982ms
用时: 10604ms
用时: 10931ms

然后再去掉SharingInt里面的注释,再跑一遍

用时: 3735ms
用时: 4082ms
用时: 4007ms
用时: 1376ms
用时: 3860ms
用时: 3685ms
用时: 4366ms
用时: 1341ms
用时: 3039ms
用时: 3777ms

为什么会有那么大的差距呢?是因为伪共享的缘故当第一条线程返回index=0的时候


  1. 假设线程1,线程2分别在Core1,Core2中获取到时间令牌,然后都会加载Cache Line 1,这时候Cache Line 1的状态是S(共享)
  2. 然后可能线程1先修改了index=0的SharingInt.value,然后Cache Line 1 从 S变为M(修改),然后根据volatile的语义,然后立马把Cache Line 写回到主存,然后Cache Line 1 的状态置从M变为I(无效)
  3. 然后等到Core2 需要修改index=1的SharingInt.value时,发现Cache Line 1 的状态为I(无效),然后直又从主存读取Cache Line 1进来,然后把状态变为E独享,然后修改value之后,又将Cache Line 刷新回主存。

以上就是MESI缓存一致性协议的工作过程,可以看出一条一样数据被多读进一次CPU 的cache,所以这个操作就消耗了时间

避免伪共享

避免伪共享的两种方式:
1.增大对象的空间,使得需要访问的数据不在同一个Cache Line上面,典型的空间换时间的方法

  1. 在每个线程添加本地副本,等待完全修改完成后再写回主存

padding:

修改SharingInt

class SharingInt {
    volatile int value;
     long p1, p2, p3, p4, p5, p6;
}
System.out.println("sharingInt size:"+RamUsageEstimator.sizeOf(new SharingInt()));
sharingInt size:64

这样一个SharingInt对象就填充了一个缓存行了,在java中一个long就是8B,加多6个刚刚好64B,这样子各个线程对相对应的对象修改就不会在不同的缓存行


上面追加之后的结果之后虽然快了很多,但是你会发现有一些1秒多有一些需要3秒多甚至4秒。这就是这种方式的不好之处,因为个人的操作系统或者CPU架构都可能不一样,

java7会优化这种字节追加方式而导致失效,但是查看java8编译的字节码来看,并没有优化掉,但是没有办法稳定下来

以继承的方式避免优化

我们修改一下SharingInt

class Temp{
    long p1,p2,p3,p4,p5,p6;
}
class SharingInt extends Temp{
    volatile int value;
}
System.out.println("sharingInt size:"+RamUsageEstimator.sizeOf(new SharingInt()));
sharingInt size:72

有人可能会问为什么多了8B,因为是继承关系,子类会多一个Reference类型,Reference类型在java中占4B,然后padding 4B就刚刚好72B
运行结果

用时: 1623ms
用时: 1305ms
用时: 1295ms
用时: 1307ms
用时: 1279ms
用时: 1286ms
用时: 1277ms
用时: 1269ms
用时: 1279ms
用时: 1312ms

虽然稳定了优化但是这样某一天java又进行了一系列的优化也许也不行了,但是在在java8给出了官方的实现

@Contended

在2012年openjdk的JEP-142说到使用这个注解可以自动追加合适的大小padding

这个注解需要是用在用户代码上面(非bootstrap class loader或者extension class loader所加载的类),并且需要添加-XX:-RestrictContended启动参数

我们修改SharingInt

class SharingInt {
    @Contended
    volatile int value;
}
System.out.println("sharingInt size:"+RamUsageEstimator.sizeOf(new SharingInt()));
sharingInt size:144

我们看到SharingInt被追加128B的padding,在JEP-142中提及

Note that we use 128 bytes, twice the cache line size on most hardware
to adjust for adjacent sector prefetchers extending the false sharing
collisions to two cache lines.

padding的大小定义为目前大多数CPU的Cache Line 大小的2倍,就是128B

分组功能:

There are cases where you want to separate the group of fields that
are experiencing contention with everything else but not pairwise. This
is the usual thing for some of the code updating two fields at once.
While marking both with @Contended would be sufficient, we can optimize
the memory footprint by not applying padding between them. In order to
demarcate these groups, we have the parameter in the annotation
describing the equivalence class for contention group.

意思就是如果两个字段a,b都被一个CPU修改,虽然各自追加padding就足够了,但是jvm可以将a,b字段优化在一个Cache Line上面
我们看一个例子:

class VolatileLong {
    @Contended("1")
    public volatile long value1 = 0L;
    @Contended("1")
    public volatile long value2 = 0L;

    @Contended("2")
    public volatile long value3 = 0L;
    @Contended("2")
    public volatile long value4 = 0L;
}

public final class ContendedTest implements Runnable {
    private final VolatileLong volatileLong;
    private final int id;

    public ContendedTest(int id,VolatileLong volatileLong) {
        this.id = id;
        this.volatileLong = volatileLong;
    }
  //-XX:-RestrictContended
    public static void main(final String[] args) throws Exception {
        runTest();
    }

    private static void runTest() throws InterruptedException {
        VolatileLong volatileLong = new VolatileLong();
        Thread t0 = new Thread(new ContendedTest(1,volatileLong));
        Thread t1 = new Thread(new ContendedTest(2,volatileLong));
        final long start = System.currentTimeMillis();
        t0.start();
        t1.start();
        t0.join();
        t1.join();
        System.out.println("用时:" + (System.currentTimeMillis() - start)+"ms");
    }
    @Override
    public void run() {
        long i = 500000000;
        if (1 == id) {
            while (0 != i--) {
                volatileLong.value1 = i;
                volatileLong.value2 = i;
            }
        } else if (2 == id) {
            while (0 != i--) {
                volatileLong.value3 = i;
                volatileLong.value4 = i;
            }
        }
    }
}

运行结果:用时:6151ms
代码很简单,两个线程分别对两个long变量赋值,重复5亿次使用了6s的时间
我们将run()方法替换如下再跑一遍

    @Override
    public void run() {
        long i = 500000000;
        if (1 == id) {
            while (0 != i--) {
                volatileLong.value1 = i;
                volatileLong.value3 = i;
            }
        } else if (2 == id) {
            while (0 != i--) {
                volatileLong.value2 = i;
                volatileLong.value4 = i;
            }
        }
    }

运行结果:用时:23963ms
那是使用了@contended注解分组

  • value1,value2被分配到了一条Cache Line
  • value3 value4被分配到了一条Cache Line

两条线程相互修改对方的Cache Line,又要从主存里面重新读取最新的数据,所以这件花费了大量的时间

本地变量副本

在JMM(java Memory Model)中,每一个线程都会有一个线程副本,每一次修改完之后不会立马刷新回主存,而是等处理完之后才刷新会主存


我们改一下上面的VolatileLong

class VolatileLong {
    @Contended("1")
    public long value1 = 0L;
    @Contended("1")
    public long value2 = 0L;

    @Contended("2")
    public long value3 = 0L;
    @Contended("2")
    public long value4 = 0L;
}

我们分别使用两种run方法去执行,两个方法的耗时
第一中run方法

用时:398ms

第二种run方法

用时:2871ms

虽然有差距,但是也没有之前那么严重了,所以使用volatile需要谨慎

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容