大家好,我是Java不惑(WX公众号同名)。这是专栏的第一篇文章,我将给大家介绍一下计算机体系中的高速缓存以及三大并发问题,在介绍三大并发问题时会介绍一下volatile修饰符在这三个问题中起到的作用,最后介绍了CAS的使用。希望这篇文章让你有所收获!
高速缓存
在计算机体系中,CPU内核处理数据的速度和在内存中读取数据的速度不匹配,为了解决它们之间存在的巨大差异,引入高速缓存。
内核读取数据时,会首先去高速缓存中读取,高速缓存中不存在数据则去内存中读取,读取后会将数据存入高速缓存中。
根据时间局部性原理,被缓存的数据在未来可能会被多次引用,所以使用高速缓存可以提高内核读取数据的速度。
时间局部性(temporal locality) :被引用过一次的存储器位置在未来会被多次引用。
空间局部性(spatial locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。
这里其实也用到了空间局部性原理,当内核读取数据时,不会只读需要的数据,而是会将周围的数据一同读到高速缓存中,而读取的这部分数据就叫做缓存行。
缓存行是高速缓存和内存进行数据交换的最小单位,大小是2的整数幂,Linux系统中缓存行的默认值是64byte。
我们可以通过代码证明一下缓存行的存在:
int[][] array = new int[64 * 1024][1024];
long startTime1=System.currentTimeMillis(); //获取开始时间
// 横向遍历
for(int i = 0; i < 64 * 1024; i ++)
for(int j = 0; j < 1024; j ++)
array[i][j] ++;
long endTime1=System.currentTimeMillis(); //获取结束时间
System.out.println("程序运行时间: "+(endTime1-startTime1)+"ms");
// 纵向遍历
long startTime2=System.currentTimeMillis(); //获取开始时间
for(int i = 0; i < 1024; i ++)
for(int j = 0; j < 64 * 1024; j ++)
array[j][i] ++;
long endTime2=System.currentTimeMillis(); //获取结束时间
System.out.println("程序运行时间: "+(endTime2-startTime2)+"ms");
因为有缓存行的存在,横向遍历比纵向遍历快的多,在我电脑上执行结果如下:
程序运行时间: 176ms
程序运行时间: 2513ms
三级缓存
缓存容量越大,存储数据会变多,但查找速度会变慢。
缓存容量越小,存储数据会变少,但查找速度会变快。
为了提高缓存的性能,在内核和内存之间引入三级缓存。距离内核近的缓存,容量小,速度快;距离内存近的缓存,容量大,速度慢。
从内核到内存分别是L1缓存、L2缓存和L3缓存。
L1缓存和L2缓存是内核专用缓存,一个CPU共享一个L3缓存。
以我现在所用电脑为例:L1缓存:256K,L2缓存1MB,L3缓存6MB。
从L1到L3,缓存越来越大,命中率也越来越高,但是读取缓存的延迟也会变高。
三大并发问题
多核CPU和高速缓存的存在,每个内核读取相同的数据到缓存中,当一个内核修改了缓存的数据,并不会直接同步到内存中,其他内核无法得知数据已经改变,就导致了并发问题。
下面我们看一下三大并发问题,并讨论volatile和三大并发问题的关系。
原子性
指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。
volatile修饰过的变量的读和写是原子性的。读出来再写进去这种复合操作不是原子性的,所以volatile不保证原子性。下面两段代码是等价的:
public class ThreadSafeInteger {
private int value;
public synchronized int get() {
return value;
}
public synchronized void set(int value) {
this.value = value;
}
}
public class ThreadSafeInteger {
private volatile int value;
public int get() {
return value;
}
public void set(int value) {
this.value = value;
}
}
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
实现如下代码,main线程和thread01线程分别读取了running的变量。main线程对running变量做了修改,但thread01线程不可见。
public class Visibleness {
private boolean running = true;
void func() {
System.out.println("thread start……");
while (running){}
System.out.println("thread end……");
}
public static void main(String[] args) throws InterruptedException {
System.out.println("main start……");
Visibleness object = new Visibleness();
new Thread(object::func,"thread01").start();
Thread.sleep(2 * 1000);
object.running = false;
System.out.println("main end……");
}
}
//输出:
main start……
thread start……
main end……
上面的代码,core0和core1读取running到缓存中,并使用了running变量。但后面core0修改running为false,这个修改仅影响了core0的缓存,并没有写入main memory,导致对core1不可见。
思考一下,为什么core1不直接写入main memory?最主要的原因还是性能问题,如果内核不断读取和写入main memory,那么高速缓存就没有存在的意义。
使用volatile修饰running变量,core0修改了running变量后,会告知core1变量已经修改,并写入到main memory中,此时代码中输出:
main start……
thread start……
main end……
thread end……
所以,volatile修饰的变量具有可见性。
有序性
处理器为了提高处理的效率,会对我们的代码进行重排序操作。例如如下语句,两条语句执行顺序并不会影响程序的正常运行。
boolean inited = true;
User user;
user = new User(); //语句1
inited = true; //语句2
但在多线程环境下,如果其他线程依赖这两条语句,就会导致一些问题。如下代码,处理器如果先执行了语句2,此时user还未初始化,就会导致代码异常。
while (!inited) {
Thread.sleep(2 * 1000);
}
user.getId();
volatile修饰的变量,通过插入内存屏障来禁止特定处理器的重排序操作。
活学活用
刚刚我们讲过,缓存和内存之间数据交换的最小单位是缓存行,当一个缓存行被多个内核缓存后,一个内核修改缓存行中volatile修饰的数据后,将会通知其他内核修改该缓存行。如果频繁修改缓存行中的数据,将导致性能急剧下降,这就是有名的伪共享问题。
JDK7的并发包里有一个队列集合类LinkedTransferQueue,在使用volatile变量时,采用追加字节的方式优化性能,这样保证一个缓存行中仅存在一个变量,大大提高了性能。
CAS
悲观锁(Pessimistic Lock),顾名思义,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持悲观态度。线程每次修改数据之前,都会认为别的线程会修改,所以拿数据之前会先上锁,别的线程要想使用这个数据必须等待线程释放掉锁。synchronized 就是悲观锁的一种,也被称为独占锁。
乐观锁(Optimistic Lock),顾名思义,它认为数据一般情况下不会造成冲突,所以在数据提交更新的时候,会对数据是否冲突进行检测,如果发现冲突则失败并重试,直到成功为止,可以称为自旋。
CAS过程
乐观锁用到的主要机制就是 CAS。CAS 即(Compare and swap),也就是比较并替换,CAS 有三个操作数分别为:内存值 V,旧的预期值 A,新的值 B。处理过程为:
- 1、首先获取内存中的值A
- 2、A经过自增或其他计算后变为B
- 3、对比当前内存中的V和A是否相同,相同则B替换A
AtomicLong 的自增就是使用这种方式实现:
public final long incrementAndGet() {
for (;;) {
long current = get();//(1)
long next = current + 1;//(2)
if (compareAndSet(current, next))//(3)
return next;
}
}
public final boolean compareAndSet(long expect, long update) {
return unsafe.compareAndSwapLong(this, valueOffset, expect, update);
}
假如当前值为 1,那么线程 A 和线程 B 同时执行到了(3)时候各自的 next 都是 2,current=1。
假如线程 A 先执行了(3),那么这个是原子性操作,会把档期值更新为 2 并且返回 1,if 判断 true 所以 incrementAndGet 返回 2。
这时候线程 B 执行 (3),因为 current=1 而当前变量实际值为 2,所以 if 判断为 false,继续循环,如果没有其他线程去自增变量的话,这次线程 B 就会更新变量为 3 然后退出。
这里使用了无限循环使用 CAS 进行轮询检查,虽然一定程度浪费了 CPU 资源,但是相比锁来说避免的线程上下文切换和调度。
ABA问题
线程1获取当前内存值为:A,其他线程将内存的值改为B,后又改为A。此时内存中的A已经不是线程1获取的A,这个问题叫做ABA问题。
1、ABA问题无影响的可以不做处理
2、有影响的可以加版本号,每次修改数据都修改版本号,对比值时也对比版本号即可。
volatile修饰变量,对共享变量做读取或者写入操作时,具有原子性;但一个变量赋值给另一个变量是非原子性的。因此volatile可以和CAS结合实现原子操作。
总结
看完文章你一定会好奇volatile怎么实现的可见性?volatile又是怎样实现的有序性?cas为什么是原子性的操作?也希望你能想一下,内核修改数据之后,是怎样向其他内核通信。在专栏的第二篇文章中,我将向大家简单介绍一下volatile和cas的原理。
如果我的文章对你有帮助,请帮我点赞转发,如果文章内容有问题,请在评论区告诉我,谢谢!