【01】Java并发编程学习笔记之——(内存模型)

可见性、原子性和有序性问题
我们的 CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。但是, 在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。CPU 和内 存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年(假设 CPU 执行一 条普通指令需要一天,那么 CPU 读写内存得等待一年的时间)。内存和 I/O 设备的速度 差异就更大了,内存是天上一天,I/O 设备是地上十年。
程序里大部分语句都要访问内存,有些还要访问 I/O,根据木桶理论(一只水桶能装多少 水取决于它最短的那块木板),程序整体的性能取决于最慢的操作——读写 I/O 设备,也 就是说单方面提高 CPU 性能是无效的。
为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译 程序都做出了贡献,主要体现为:
1. CPU 增加了缓存,以均衡与内存的速度差异;
2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差 异;
3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

缓存导致的可见性问题
在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解 决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来 说一定是可见的。例如在下面的图中,线程 A 和线程 B 都是操作同一个 CPU 里面的缓 存,所以线程 A 更新了变量 V 的值,那么线程 B 之后再访问变量 V,得到的一定是 V 的 最新值(线程 A 写过的值)。
在这里插入图片描述
多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易 解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如 下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明 显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。这个就属于硬 件程序员给软件程序员挖的“坑”。
在这里插入图片描述
线程切换带来的原子性问题
我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性
由于 IO 太慢,早期的操作系统就发明了多进程,即便在单核的 CPU 上我们也可以一边听 着歌,一边写 Bug,这个就是多进程的功劳。

如何解决其中的可见性和有序性导致的问题,这也就引出来了今天的主 角——Java 内存模型
操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选 择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。

Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟 然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候, 我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例 如上面代码中的count += 1,至少需要三条 CPU 指令。

指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

在这里插入图片描述

编译优化带来的有序性问题
在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实 例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实 例。

public class Singleton { 
 static Singleton instance;  
 static Singleton getInstance(){    
               if (instance == null) {      
               synchronized(Singleton.class) {    
                   if (instance == null)        
                     instance = new Singleton();   
                          }  
                            }    return instance;  } 

这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出 在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

  1 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量; 
  3.  最后在内存 M 上初始化 Singleton 对象

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功 (假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一 个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此 时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创 建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方 法,那么线程 B 会发现instance != null,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空 指针异常。

什么是 Java 内存模型?

导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、 有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性 能可就堪忧了。
合理的方案应该是按需禁用缓存以及编译优化
本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的 方法

具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六 项 Happens-Before 规则,这也正是本期的重点内容。
使用volatile的困惑
volatile 关键字并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁 用 CPU 缓存。
例如,我们声明一个 volatile 变量 volatile int x = 0,它表达的是:告诉编译器, 对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。这个语义看上去相 当明确,但是在实际使用的时候却会带来困惑。
例如下面的示例代码,假设线程 A 执行 writer() 方法,按照 volatile 语义,会把变量 “v=true” 写入内存;假设线程 B 执行 reader() 方法,同样按照 volatile 语义,线程 B 会从内存中读取变量 v,如果线程 B 看到 “v == true” 时,那么线程 B 看到的变量 x 是 多少呢?
直觉上看,应该是 42,那实际应该是多少呢?这个要看 Java 的版本,如果在低于 1.5 版 本上运行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上运行,x 就是等于 42。

在这里插入图片描述
为什么 1.5 以前的版本会出现 x = 0 的情况呢?我相信你一定想到了,变量 x 可能被 CPU 缓存而导致可见性问题。这个问题在 1.5 版本已经被圆满解决了。Java 内存 模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢?答案是一项 HappensBefore 规则。

如何理解 Happens-Before 呢?如果望文生义(很多网文也都爱按字面意思翻译成“先行 发生”),那就南辕北辙了,Happens-Before 并不是说前面一个操作发生在后续操作的 前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。就像有心灵感应的 两个人,虽然远隔千里,一个人心之所想,另一个人都看得到。Happens-Before 规则就 是要保证线程之间的这种“心灵感应”。所以比较正式的说法是:Happens-Before 约束 了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 HappensBefore 规则。
1. 程序的顺序性规则
这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意 操作。这还是比较容易理解的,比如刚才那段示例代码,按照程序的顺序,第 6 行代码 “x = 42;” Happens-Before 于第 7 行代码 “v = true;”,这就是规则 1 的内容,也比 较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。

class VolatileExample {  
int x = 0; 
 volatile boolean v = false;  
 public void writer() {    
 x = 42;    v = true; 
  } 
   public void reader() {
       if (v == true) {     
        // 这里 x 会是多少呢?
    }  } }

2. volatile 变量规则
这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。
这个就有点费解了,对一个 volatile 变量的写操作相对于后续对这个 volatile 变量的读操 作可见,这怎么看都是禁用缓存的意思啊,貌似和 1.5 版本以前的语义没有变化啊?如果 单看这个规则,的确是这样,但是如果我们关联一下规则 3,就有点不一样的感觉了。
3. 传递性
这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A HappensBefore C。
我们将规则 3 的传递性应用到我们的例子中,会发生什么呢?可以看下面这幅图:
在这里插入图片描述

  1. “x=42” Happens-Before 写变量 “v=true” ,这是规则 1 的内容;

  2. 写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 2 的内容 。
    再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变 量“v=true”。这意味着什么呢?
    如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是 说,线程 B 能看到 “x == 42” ,有没有一种恍然大悟的感觉?这就是 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent) 就是靠 volatile 语义来搞定可见性的
    4. 管程中锁的规则

    这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
    要理解这个规则,就首先要了解“管程指的是什么”。管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。
    管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁, 而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

5. 线程 start() 规则
这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在 启动子线程 B 前的操作。
换句话说就是,如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那 么该 start() 操作 Happens-Before 于线程 B 中的任意操作
6. 线程 join() 规则
这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

final字段
前面我们讲 volatile 为的是禁用缓存以及编译优化,我们再从另外一个方面来看,有没有 办法告诉编译器优化得更好一点呢?这个可以有,就是final 关键字。
final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。Java 编译 器在 1.5 以前的版本的确优化得很努力,以至于都优化错了。
问题类似于上一期提到的利用双重检查方法创建单例,构造函数的错误重排导致线程可能 看到 final 变量的值会变化

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值