JVM:什么是伪共享问题,如何解决伪共享问题(2)

151 阅读4分钟

1. 为什么会有伪共享问题

为什么会有伪共享问题就要从CPU多级缓存说起,计算机中每个CPU Core都有自己的多级缓存,CPU操作数据的时候先从自己的Cache中查找数据,如果没有找到,再从内存中读取,然后将读取的数据放到自己的Cache中。

Cache LineCPU从内存读取数据到自己的Cache的单位,一般Linux系统中Cache Line的大小是64字节。也就意味着CPU一次载入的数据大小是64字节。

CPU在取数据到自己的Cache中,除了取目标数据还会取该数据相邻的数据到Cache中,主要是依据时间局部性和空间局部性。

多级缓存又会导致缓存一致性问题,为了解决这个问题,又提出了缓存一致性协议MESIMESI是为了解决缓存一致性的问题,但是也引入了另外一个问题就是伪共享问题。

关于MESI可参考:JVM:Java内存模型(1)

2. 伪共享产生的流程

假设现在有两个CPU Core,分别是Core1Core2,这两个Core在运算过程中,Core1需要变量xCore2需要变量yxy都是long类型的变量,在内存中的地址是连续的。

2.1 缓存数据

1.由于CPU每次读取数据都会读取Cache Line大小,也就是64个字节根据空间局部性原理。

2.Core1在缓存x的同时也会缓存y,并且xy在同一个Cache Line当中。

3.同理Core2在缓存y的时候,也要缓存xxy也在同一个Cache Line当中。

4.这个时候两个Cache Line的为Shared状态。

wgx1.png

2.2 Core1修改数据

1.现在Core1要修改x变量的值,发现x所在的Cache LineShared状态

2.Core1需要先广播,然后Core2收到这个广播之后,把自己的Cache Line设置为Invalid状态。

3.然后Core1修改x的值,然后把Cache Line设置为Modified状态。

wgx2.png

2.3 Core2修改数据

1.现在Core2要修改y的值,发现Cache LineInvalid的状态。

2.然后Core1缓存了相同数据的,并且是Modified,需要先把Core1里面修改了的值同步到内存。

3然后Core2再从内存读取修改后的值读取到自己的缓存中。

4.Core2修改y的值,修改之后把Cache Line标记为Modified的。

5.Core1Cache Line标记为Invalid

wgx3.png 上述流程,虽然x只有Core1进行读写操作,y只有Core2,进行读写操作,按理说直接在缓存中操作即可,但是现在需要不停的从缓存和内存交换数据,如果这是一个循环的话,会很浪费性能。核心原因就在于Cache Line中同时缓存了xy,而xy被不同的CPU Core使用,导致了缓存失效,这就是伪共享问题。

3. 伪共享的代码示例

  1. 现在有一个对象,有三个变量,三个线程他同时对这个三个变量进行修改。
public class ObjDemo {
    volatile long a;
    volatile long b;
    volatile long c;
}
  1. 对这三个变量进行修改,注意join方法是阻塞主线程,防止主线程提前结束了,同时保证t1t2,和t3都能完成。

public class Demo {
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        ObjDemo objDemo = new ObjDemo();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                objDemo.a++;
            }
        });
​
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                objDemo.b++;
            }
        });
​
        Thread t3 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                objDemo.c++;
            }
        });
​
        t1.start();
        t2.start();
        t3.start();
        t1.join();
        t2.join();
        t3.join();
​
        System.out.println("总耗时:" + (System.currentTimeMillis() - start));
    }
}
​
​

上述代码总共耗时大概2000ms以上,三个线程,在修改对应long类型的变量的时候,需要把变量缓存到自己的Cache Line中,由于Cache Line一次缓存64个字节,而一个long类型的变量是8字节,由于空间局部性,CPU缓存的时候会把相邻的数据拿到自己的缓存中,所以虽然线程t1不使用bc也会把这两个变量放到自己的缓存中,其它两个一样。导致在修改的时候,会通知另外两个线程,并且修改了之后需要同步到内存当中,虽然这个变量没有其它核使用。

4. 解决方法1

第一种办法就是把这个三个变量分开,让它们不会缓存在同一个Cache Line中,最简单的做法就是把变量后面填满,刚好是一个Cache Line,这样另外的变量只能在下一个Cache Line当中了。

package com.lee.study.basic;
public class ObjDemo {
    volatile long a;
    long p1, p2, p3, p4, p5, p6, p7;
  
    volatile long b;
    long q1, q2, q3, q4, q5, q6, q7;
​
    volatile long c;
}

5. 解决方法2

Java8中提供了一个注解@Contented,使用了此注解的类或者变量会在前后加128字节,同时需要添加虚拟机参数-XX:-RestrictContended才会生效

import sun.misc.Contended;
public class ObjDemo {
    @Contended
    volatile long a;
    
    @Contended
    volatile long b;
​
    volatile long c;
}

使用上述两种方法可以,把代码运行时间降低到几百毫秒

参考文档:

什么是伪共享?又该怎么避免伪共享的问题

彻底搞清楚什么是伪共享