Volatile和高速缓存的关系

简介: 把volatile当成一种锁机制,认为给变量加上了volatile,就好像是给函数加sychronized,不同的线程对于特定变量的访问会去加锁把volatile当成一种原子化的操作机制,认为加了volatile之后,对于一个变量的自增的操作就会变成原子性

“volatile关键字有什么用?”


1 常见理解错误

把volatile当成一种锁机制,认为给变量加上了volatile,就好像是给函数加sychronized,不同的线程对于特定变量的访问会去加锁

把volatile当成一种原子化的操作机制,认为加了volatile之后,对于一个变量的自增的操作就会变成原子性

// 一种错误的理解,是把volatile关键词,当成是一个锁,可以把long/double这样的数的操作自动加锁

private volatile long synchronizedValue = 0;

// 另一种错误的理解,是把volatile关键词,当成可以让整数自增的操作也变成原子性的

private volatile int atomicInt = 0;

amoticInt++;


很多工程师容易把volatile关键字,当成和锁或者数据数据原子性相关的知识点。volatile最核心要关系JMM。


JMM是JVM这个进程级虚拟机里的一个内存模型,但该内存模型和计算机组成里的CPU、高速缓存和主内存组合在一起的硬件体系类似。理解JMM,可更容易理解计算机组成里CPU、高速缓存和主内存之间的关系。


2 “隐身”的变量

dzone.com代码段,后续修改这段代码来进行各种小实验。


2.1 demo1

public class VolatileTest {

   private static volatile int COUNTER = 0;

   public static void main(String[] args) {

       new ChangeListener().start();

       new ChangeMaker().start();

   }

   static class ChangeListener extends Thread {

       @Override

       public void run() {

           int threadValue = COUNTER;

           while ( threadValue < 5){

               if( threadValue!= COUNTER){

                   System.out.println("Got Change for COUNTER : " + COUNTER + "");

                   threadValue= COUNTER;

               }

           }

       }

   }

   static class ChangeMaker extends Thread{

       @Override

       public void run() {

           int threadValue = COUNTER;

           while (COUNTER <5){

               System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");

               COUNTER = ++threadValue;

               try {

                   Thread.sleep(500);

               } catch (InterruptedException e) { e.printStackTrace(); }

           }

       }

   }

}



先定义了一个volatile的int类型的变量,COUNTER。


然后,分别启动两个独立线程:


ChangeListener

先取到COUNTER当前值,然后一直监听该COUNTER值。一旦COUNTER值变化,就把新值打印。直到COUNTER的值达到5。这监听过程,通过while死循环的忙等待实现

ChangeMaker

取到COUNTER的值,在COUNTER小于5的时候,每隔500毫秒,就让COUNTER自增1。在自增前,通过println方法把自增后的值打印

输出结果并不让人意外。ChangeMaker函数会一次一次将COUNTER从0增加到5。因为这个自增是每500毫秒一次,而ChangeListener去监听COUNTER是忙等待的,所以每一次自增都会被ChangeListener监听到,然后对应的结果就会被打印出来。


Incrementing COUNTER to : 1

Got Change for COUNTER : 1

Incrementing COUNTER to : 2

Got Change for COUNTER : 2

Incrementing COUNTER to : 3

Got Change for COUNTER : 3

Incrementing COUNTER to : 4

Got Change for COUNTER : 4

Incrementing COUNTER to : 5

Got Change for COUNTER : 5


2.2 demo2

把上面的程序小小地修改一行代码,把定义COUNTER变量时的volatile去掉,会咋样?


private static int COUNTER = 0;


ChangeMaker还是能正常工作,每隔500ms仍然能够对COUNTER自增1。但ChangeListener不再工作。在ChangeListener眼里,它似乎一直觉得COUNTER的值还是一开始的0。似乎COUNTER的变化对ChangeListener彻底“隐身”。


Incrementing COUNTER to : 1

Incrementing COUNTER to : 2

Incrementing COUNTER to : 3

Incrementing COUNTER to : 4

Incrementing COUNTER to : 5


2.3 demo3

不再让ChangeListener进行完全的忙等待,而是在while循环里小等5ms

static class ChangeListener extends Thread {

   @Override

   public void run() {

       int threadValue = COUNTER;

       while ( threadValue < 5){

           if( threadValue!= COUNTER){

               System.out.println("Sleep 5ms, Got Change for COUNTER : " + COUNTER + "");

               threadValue= COUNTER;

           }

           try {

               Thread.sleep(5);

           } catch (InterruptedException e) { e.printStackTrace(); }

       }

   }

}


虽然COUNTER变量仍没设置volatile这个关键字,但ChangeListener似乎“睡醒了”。在通过Thread.sleep(5)在每个循环里“睡“5ms后,ChangeListener又能够正常取到COUNTER的值了。


Incrementing COUNTER to : 1

Sleep 5ms, Got Change for COUNTER : 1

Incrementing COUNTER to : 2

Sleep 5ms, Got Change for COUNTER : 2

Incrementing COUNTER to : 3

Sleep 5ms, Got Change for COUNTER : 3

Incrementing COUNTER to : 4

Sleep 5ms, Got Change for COUNTER : 4

Incrementing COUNTER to : 5

Sleep 5ms, Got Change for COUNTER : 5


这些现象就来自于 JMM 及关键字volatile的含义。volatile究竟代表什么?


它确保我们对该变量的读取和写入,一定同步到主内存,而非从Cache读取。


3 如何理解这句话?

3.1 有volatile

因所有数据的读、写都来自主内存。自然ChangeMaker和ChangeListener之间,看到的COUNTER值一样。

3.2 无volatile

这时,ChangeListener又是一个忙等待的循环,它尝试不停获取COUNTER值,这样就会从当前线程的“Cache”获取。于是,这线程就没有时间从主内存同步更新后的COUNTER值。这样,它就一直卡死在COUNTER=0的死循环。


3.3 虽无volatile,但短短5ms的Thead.Sleep给了这线程喘息之机

既然这个线程没有这么忙了,它就有机会把最新数据从主内存同步到自己的高速缓存。于是,ChangeListener在下一次查看COUNTER值的时候,就能看到ChangeMaker变化。


虽然JMM是个隔离了硬件实现的虚拟机内的抽象模型,但它给出“缓存同步”问题示例。若数据在不同线程或CPU核里更新,因不同线程或CPU核有各自缓存,很可能在A线程的更新,B线程看不见。


4 CPU高速缓存的写入

可将Java内存模型和计算机组成里的CPU结构对照。


Intel CPU多核。每个CPU核里都有独属的L1、L2 Cache,再有多个CPU核共用的L3 Cache、主内存。


因为CPU Cache访问速度>>主内存,而CPU Cache里,L1/L2 Cache也比L3 Cache快。所以,CPU始终尽可能从CPU Cache获取数据,而非每次都从主内存读数据:

6.png

这层级结构就像在JMM里,每个线程都有属于自己的线程栈。线程读取COUNTER时,其实是从本地的线程栈的Cache副本读,而非从主内存读。若对数据仅只是读,问题还好。Cache Line组成及如何从内存里把对应数据加载到Cache。


但不光要读,还要去写入修改数据。问题就来了:写入Cache的性能也比写主内存快,那写数据,到底写到Cache还是主内存?若直接写主内存,Cache里的数据是否会失效?


先看两种


5 写入策略

5.1 写直达(Write-Through)


最简单的写策略,每次数据都写主内存。

写入前,先判断数据是否已在Cache:

5.png

已在Cache

先把数据写入更新到Cache,再写主内存

数据不在Cache

只更新主内存

实现简单,但性能很慢。无论数据是否在Cache,都要把数据写主内存。这有点像volatile关键字,始终都要把数据同步到主内存。


5.2 写回(Write-Back)

4.png

既然读数据也默认从Cache加载,能否不用把所有写入都同步到主内存?只写入CPU Cache是不是就够?可以!这就是写回(Write-Back)策略,不再是每次都把数据写主内存,而只写到CPU Cache。只有当CPU Cache里的数据要被“替换”,才把数据写主内存。


过程

若发现要写入的数据,就在CPU Cache,就只更新CPU Cache的数据。同时标记CPU Cache里的这个Block是脏(Dirty)的:指此时CPU Cache里的这个Block的数据,和主内存不一致。


如发现要写入的数据所对应的Cache Block里,放的是别的内存地址的数据,就要看那个Cache Block里的数据是否被标记成脏:


如果是脏,先把这个Cache Block里面的数据,写入主内存。再把当前要写入的数据,写入Cache,同时把Cache Block标记成脏

如果Block里面的数据没有被标记成脏的,直接把数据写入Cache,然后再把Cache Block标记成脏

用写回策略后,在加载内存数据到Cache时,也要多出一步同步脏Cache的动作。若加载内存数据到Cache时,发现Cache Block里有脏标记,也要先把Cache Block里的数据写回主内存,才能加载数据覆盖Cache。


该策略里,若大量操作都能命中缓存,则大部分时间里,无需读写主内存,性能比写直达效果好太多!


但无论是写回or写直达,都没解决volatile程序问题:多个线程或多个CPU核的缓存一致性问题。

这也就是在写入修改缓存后,需要解决的第二个问题。


要解决这个问题,需引入MESI协议,维护缓存一致性的协议。不仅可用在CPU Cache之间,也可广泛用于各种需要使用缓存,同时缓存之间需要同步的场景下。


总结

volatile程序可以看到,在有缓存的情况下会遇到一致性问题。volatile这个关键字可以保障我们对于数据的读写都会到达主内存。


Java内存模型和CPU、CPU Cache以及主内存的组织结构非常相似。在CPU Cache里,对于数据的写入,我们也有写直达和写回这两种解决方案。写直达把所有的数据都直接写入到主内存里面,简单直观,但是性能就会受限于内存的访问速度。而写回则通常只更新缓存,只有在需要把缓存里面的脏数据交换出去的时候,才把数据同步到主内存里。在缓存经常会命中的情况下,性能更好。


但是,除了采用读写都直接访问主内存的办法之外,如何解决缓存一致性问题?

下文分解。


参考


Fixing Java Memory Model

《计算机组成与设计:硬件/软件接口》5.3.3

目录
相关文章
|
4月前
|
缓存 安全 算法
Java面试题:如何通过JVM参数调整GC行为以优化应用性能?如何使用synchronized和volatile关键字解决并发问题?如何使用ConcurrentHashMap实现线程安全的缓存?
Java面试题:如何通过JVM参数调整GC行为以优化应用性能?如何使用synchronized和volatile关键字解决并发问题?如何使用ConcurrentHashMap实现线程安全的缓存?
42 0
|
1月前
|
SQL 缓存 Java
JVM知识体系学习三:class文件初始化过程、硬件层数据一致性(硬件层)、缓存行、指令乱序执行问题、如何保证不乱序(volatile等)
这篇文章详细介绍了JVM中类文件的初始化过程、硬件层面的数据一致性问题、缓存行和伪共享、指令乱序执行问题,以及如何通过`volatile`关键字和`synchronized`关键字来保证数据的有序性和可见性。
26 3
|
存储 缓存 Java
高并发编程-通过volatile重新认识CPU缓存 和 Java内存模型(JMM)
高并发编程-通过volatile重新认识CPU缓存 和 Java内存模型(JMM)
278 0
|
缓存 Java 索引
面试完才知道MESI缓存一致性协议竟然和 volatile 没有半毛关系!
面试完才知道MESI缓存一致性协议竟然和 volatile 没有半毛关系!
294 0
面试完才知道MESI缓存一致性协议竟然和 volatile 没有半毛关系!
|
缓存 Java
【Java 并发编程】线程共享变量可见性 ( volatile 关键字使用场景分析 | MESI 缓存一致性协议 | 总线嗅探机制 )
【Java 并发编程】线程共享变量可见性 ( volatile 关键字使用场景分析 | MESI 缓存一致性协议 | 总线嗅探机制 )
371 0
|
1月前
|
存储 缓存 NoSQL
数据的存储--Redis缓存存储(一)
数据的存储--Redis缓存存储(一)
|
1月前
|
存储 缓存 NoSQL
数据的存储--Redis缓存存储(二)
数据的存储--Redis缓存存储(二)
数据的存储--Redis缓存存储(二)
|
1月前
|
消息中间件 缓存 NoSQL
Redis 是一个高性能的键值对存储系统,常用于缓存、消息队列和会话管理等场景。
【10月更文挑战第4天】Redis 是一个高性能的键值对存储系统,常用于缓存、消息队列和会话管理等场景。随着数据增长,有时需要将 Redis 数据导出以进行分析、备份或迁移。本文详细介绍几种导出方法:1)使用 Redis 命令与重定向;2)利用 Redis 的 RDB 和 AOF 持久化功能;3)借助第三方工具如 `redis-dump`。每种方法均附有示例代码,帮助你轻松完成数据导出任务。无论数据量大小,总有一款适合你。
70 6
|
4天前
|
缓存 NoSQL 关系型数据库
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
本文详解缓存雪崩、缓存穿透、缓存并发及缓存预热等问题,提供高可用解决方案,帮助你在大厂面试和实际工作中应对这些常见并发场景。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
|
5天前
|
存储 缓存 NoSQL
【赵渝强老师】基于Redis的旁路缓存架构
本文介绍了引入缓存后的系统架构,通过缓存可以提升访问性能、降低网络拥堵、减轻服务负载和增强可扩展性。文中提供了相关图片和视频讲解,并讨论了数据库读写分离、分库分表等方法来减轻数据库压力。同时,文章也指出了缓存可能带来的复杂度增加、成本提高和数据一致性问题。
【赵渝强老师】基于Redis的旁路缓存架构