并发编程的可见性
可见性
看如下代码:
public class HelloVolatile {
private static volatile boolean running = true;
//private static /*volatile*/ boolean running = true;
private static void m() {
System.out.println("m start");
while (running) {
//System.out.println("hello");
}
System.out.println("m end!");
}
public static void main(String[] args) throws IOException {
//指定Hellovolatile里的静态方法m,线程名为t1
new Thread(HelloVolatile::m, "t1").start();
SleepHelper.sleepSeconds(1);
running = false;
System.in.read();
}
}
如上代码在main函数里面启动一个新的线程,并让主线程睡一秒,确保main函数里的新开线程完全启动,再将running设置为false,如果以上代码注释掉volatile关键字的话,那么这个线程是结束不了的,这里的原因就牵扯到了线程的可见性问题,当线程在运行的过程中,都会把数据从内存里面读一份到线程的本地缓存中,然后每次循环的时候都是从线程的本地缓存中去读,没有去内存中读取,这就导致当另一个线程修改了running的值之后,并不会被当前线程读取到,所以当前的线程无法停止。
但是当用volatile修饰了之后则能让当前线程停止,因为volatile所修饰的那块内存,对于它的任何修改另外一个线程立马可见,被volatile修饰的内存,线程每次读取都从内存里面读取一遍,这样就能获得最新的被volatile修饰的那块内存的值,所以一个线程改了以后另一个线程立马可见。
是否一定要加volatile才能让线程可见?
看如下代码:
public class T01_HelloVolatile {
private static /*volatile*/ boolean running = true;
private static void m() {
System.out.println("m start");
while (running) {
System.out.println("hello");
}
System.out.println("m end!");
}
public static void main(String[] args) throws IOException {
new Thread(HelloVolatile::m, "t1").start();
SleepHelper.sleepSeconds(1);
running = false;
}
}
但是如上代码让线程结束了,running这个值让线程之间保持可见了,原因是 System.out.println(“hello”)触发了线程可见性机制
进入println里面发现有一个synchronized这个关键字,通过这个关键字保证线程可见性的,在某些语句的情况下可能触发内存与线程的本地缓存之间数据的刷新与同步
volatile修饰的引用类型(包括数组)只能保证引用本身的可见性,不能保证内部字段的可见性
public class VolatileReference {
private static class A {
boolean running = true;
void m() {
System.out.println("m start");
while (running) {
}
System.out.println("m end!");
}
}
private volatile static A a = new A();
public static void main(String[] args) {
new Thread(a::m, "t1").start();
SleepHelper.sleepSeconds(1);
a.running = false;
}
}
类里面的成员变量并没有被volatile修饰,所以是线程不可见的,只是 a这个引用被修饰了,如果a指向了另外一个new出来的a对象,这时候这个a就是可见的。如果要让成员变量可见的话则用volatile修饰那个running。
理解线程中的缓存
如上图是一两个CPU,每个CPU里面有两个核心,L1,L2位于核的内部,L3则位于CPU的内部,两颗CPU共享同一个主存,如果寄存器(Registers)需要数据它会先去L1里面找,L1里面没有则再去L2里面找,L2里面没有则去L3里面找,L3里面没有则去内存里面找,找到了之后L3,L2,L1依次存一份读取到的数据,然后再读到寄存器里面,这是缓存的一个基本概念,我们上面所说的线程本地缓存一般是指的L1,L2。
缓存行基本概念理解
为了保证效率,我们计算机从内存里面读取变量是按块去读取的
背后涉及到程序局部性原理:
程序局部性原理,可以提高效率,充分发挥总线CPU针脚等一次性读取更多数据的能力。
空间局部性原理:当我读取到某一个数据的时候,由于它相邻的数据我们一般很快就会读取到,所以每次用一个数据的时候,我们计算机每次把他周边的值也读取到缓存里面。
时间局部性原理:当我用的某一条指令的时候,可能会马上用到与他相邻的指令,所以在读取指令的时候,一次性把很多的指令相关的数据也读到缓存或者内存里面。
那么读取的那一块数据有64字节(读取的专业名词叫缓存行)
通过程序认识缓存一致性:
public class CacheLinePadding {
public static long COUNT = 10_0000_0000L;
private static class T {
//private long p1, p2, p3, p4, p5, p6, p7;
public long x = 0L; //8bytes
//private long p9, p10, p11, p12, p13, p14, p15;
}
public static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws Exception {
CountDownLatch latch = new CountDownLatch(2);
Thread t1 = new Thread(() -> {
for (long i = 0; i < COUNT; i++) {
arr[0].x = i;
}
latch.countDown();
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < COUNT; i++) {
arr[1].x = i;
}
latch.countDown();
});
final long start = System.nanoTime();
t1.start();
t2.start();
latch.await();
System.out.println((System.nanoTime() - start) / 100_0000);
}
}
运行时间如下:
当把T中的注释给取消掉运行时间如下(他的效率竟然提升了):
原因如下:
如果不取消掉上面的注释的话arr[0]和arr[1]因为不大于64个字节可能位于同一个缓存行里面,这样两个线程的本地缓存都有了arr[0]和arr[1],但是线程一只操作arr[0],线程二只操作arr[1],那么必须采用缓存一致性协议来保证两个线程里面的arr[0]和arr[1]里面的数据一致,当第一个线程不断修改arr[0]里面的值的时候,那么必然会不断的去通知线程2的缓存行里面的arr[0]被修改了,需要从内存中去再获取(当多线程修改的东西位于同一行的情况下反而会互相干扰)
那么取消掉上面的注释为什么效率会提高呢?
在x的前面有7个long类型的变量占用56个字节,在x的后面有7个long类型的变量占用56个字节,这样就一定不会和另一个对象的x在同一个缓存行上面,因为一个缓存行最多64个字节,x往前数或者往后数都是64个字节,另一个x不可能进到同一个缓存行,这样就会让两个线程修改数据改的是不同的缓存行数据,就不用去通过缓存一致性协议去不断的通信了,因此效率提高了。(应用jdk7的LinkedBlockingQueue)
认识Disruptor(闪电)中缓存行对齐的写法(单机效率最高的MQ)
有一个环形的缓冲区(RingBuffer):
发现INITIAL_CURSOR_VALUE的后面有七个long类型的变量,但是这里万一他前面要和其他的数据混在一起呢?我们进入RingBufferFields里面发现它继承了RingBufferPad然后进入RingBufferPad发现也定义了七个long,这个和我们之前写的那个demo没什么区别只是我们的demo是直接写的,而这个RingBuffer则通过继承来实现的。
认识Contended
当然如果我们的缓存行的大小可能发生了变化不再是64个字节了,那么是否需要改变源程序?
Oracle在java8的时候提供了一个注解,这个注解保证被它标注的数据不会和其他数据位于同一行。
public class Contended {
public static long COUNT = 10_0000_0000L;
//@Contended //只有1.8起作用 , 保证x位于单独一行中
private static class T {
@Contended
public long x = 0L;
}
public static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws Exception {
CountDownLatch latch = new CountDownLatch(2);
Thread t1 = new Thread(() -> {
for (long i = 0; i < COUNT; i++) {
arr[0].x = i;
}
latch.countDown();
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < COUNT; i++) {
arr[1].x = i;
}
latch.countDown();
});
final long start = System.nanoTime();
t1.start();
t2.start();
latch.await();
System.out.println((System.nanoTime() - start) / 100_0000);
}
}
注意:运行时需要配置JVM参数:-XX:-RestrictContended
MESI Cache 一致性协议
MESI代表的是缓存行的四种状态
Modified:被修改状态
Exclusive:独享
Shared:共享
Invalid:失效
当两个线程读取到同一个缓存行的时候,当一个线程修改了缓存行里的数据使得缓存行变为Modified状态,那么另一个线程的那个缓存行进入到Invalid状态,当当前的线程时间片用完以后执行另一个线程的时候那么他会重新去内存加载这个缓存行对应的数据。
缓存行:
缓存行越大,局部性空间效率越高,但读取时间慢
缓存行越小,局部性空间效率越低,但读取时间块
取一个折中值,目前多用:
64字节
总结
通过Volatile保障线程的可见性,同时引出缓存的概念(一级缓存、二级缓存、三级缓存),而且读取的时候是一行一行的来读缓存行的内容,以及引出底层CPU多数都支持缓存一致性协议。