前言
Java 的并发时一般都会 synchronizedvolatile 和 Lock 来实现同步, 或者使用 Java 提供的一些辅助类, 例如 atomic 和 concurrent 包下 AtomicXXXX,ConcurrentXXX 等, 但是我们是否想过为什么会有这些关键字和类呢? 这些关键字和类解决了什么问题? 又是如何实现的呢? 可能有的人会说不就是因为多线程操作吗; 解决的就是并发时数据错误的问题; 是通过唤醒机制实现的; 但是真的就这么简单吗? 显然不是的, 这些涉及了大量的知识点, 接下来就来梳理一下并将它们串下来
首先我们知道多线程并发操作的目前是让程序可以更快速更高效, 但并不是启动更多的线程就会执行的更快, 其实为了程序可以更高效和快速, 编译器和处理器也会做一些底层的操作, 让我们程序在有效的时间可以更高效的执行, 例如重排序, 但是不管是重排序还是多线程操作都存在一些其他的问题, 例如多线程之间内存可见性的问题重排序后导致程序执行结果的错误, 那么为了解决这些出现的问题, 则有了这些并发的关键字和工具, 但是具体是因为什么, 又是如何解决的, 还需要我们注意些什么, 我们慢慢来分析, 下面的内容是环环相扣的, 希望可以按顺序看
上下文切换
刚才上面提到了在有效的时间内可以更高效执行, 其实就是上下文切换多线程执行的时候 CPU 会给每个线程分配 CPU 时间片, 时间片就是分配给各个线程的执行时间, 因为时间片非常的短, 所以 CPU 可以通过不停的切换线程, 让我们错觉的认为线程是同时执行的每次在切换之前都需要保持当前的线程状态, 以便下次切换回来的时候快速的读取状态, 每一次切换就叫做一次上下文切换那么如果过多的切换上下文自然也就会影响到程序的执行效率既然线程执行会受到时间片的限制, 所以为让了让线程在有效的时间里可以执行更多的操作, 编译器和处理器就会给我们的代码进行重排序, 因为我们的代码在执行的时候会转变成汇编语言, 所以重排序也分成编译器重排序和指令重排序, 但是不管是哪种重排序都是为了更充分的利用 CPU 分配的时间片
减少上下文切换的方法有如下方法:
无锁并发编程: 多线程竞争锁的时候会引发上下文的切换, 可以使用一些其他的办法来避免使用锁
CAS 算法: 又称为自旋锁, Java 的 Atomic 包中就使用的 CAS 算法来更新数据
尽量减少线程的数量: 线程的创建和切换都是需要的时间的, 线程多了来回切换的次数自然也就就更多, 所以减少线程的数量也就能减少上下文的切换了
重排序
上面说为了可以让线程更充分的利用 CPU 分配的时间片, 就有了重排序为什么重排序就可以让程序更效率呢? 那是因为 CPU 执行的速度太快, 而内存操作的时间没有 CPU 执行的速度快, 举个例子, 假如有一个内存操作相当的耗时, 如果不进行重排序的话, 可能直到时间片结束都没有处理完这一步操作, 但是如果虚拟机碰到这样的操作时先暂时绕过这一步操作, 先执行下一步操作这样就可以更好的利用 CPU 分配的时间片了, 而站在硬件层面来说为了可以快速的执行, 也会按照一定的规则将一系列的指令进行重排序执行重排序的原理非常复杂, 这也是为了更好的理解举得一个例子而已再看一下如下两行代码, 在真正执行的时候不一定操作 1 就执行在操作 2 的前面既然编译器和处理器会对代码进行重排序, 那么就一定会因为重排序的缘故而导致代码逻辑错误, 最终的执行结果错误, 所以这时就需要有一些规则来约束因为重排序而导致的错误
- byte[] bytes = new byte[1024];// 操作 1
- int a = 0;// 操作 2
规则一: 数据依赖性
上面说编译器和处理器会为了更高效的执行会进行重排序, 那么重排序后又肯定会影响代码的最终逻辑, 导致结果错误所以虚拟机就有了一些规则, 第一个规则就是数据依赖性什么是数据依赖性呢, 就是说如果有两个操作同时访问一个变量, 并且两个操作中有一个操作为写操作, 那么就说这两个操作之间存在数据依赖性, 所以就产生了三种数据依赖性的情况, 如下:
写后读: 写一个变量后, 在读取这个变量, 例子如下
- int a = 1;// 先写 a
- int b = a;// 再读取 a
写后写: 写一个变量之后, 再写这个变量, 例如如下
- int a = 1;// 先写 a
- a = 2;// 再写 a
读后写: 先读取一个变量, 再写这个变量, 例子如下
- int a = b; // 先读取变量 b
- b = 1;// 再写 b
如上三种情况假如进行重排序的话, 那么这个代码的逻辑和结果就会产生错误了, 所以编译器和处理器不会对有存在数据依赖性的两个操作进行重排序, 但是这个数据依赖性仅仅针对单线程和单个处理器有效, 不同的处理器和不同的线程之间的数据依赖性就不被编译器和处理器考虑了
as-if-serial 语义
as-if-serial 语义的意思是: 不管怎么重排序, 单线程的执行结果不能被改变, 编译器 Runtime 和处理器都必须遵守这个语义规则重排序是为了更高效的执行, 但是重排序又必须要遵守 as-if-serial 语义和数据依赖性的规则, 例如如下代码, 它们在一定程度上可以进行重排序, 但是又遵守了数据依赖性和不影响执行结果的语义
- double pi = 3.14; //A
- double r = 1.0; //B
- double area = pi * r * r; //C
A 和 C 之间存在数据依赖性, B 和 C 之间也存在数据依赖性, 所以按照数据依赖性的规则 C 是不能被重排序到 A 和 B 的前面的, 但是 A 和 B 却不存在数据依赖性, 那么编译器和处理器是可以将 A 和 B 进行重排序的, 而且不会影响最终的执行结果
假设没有重排序的执行时: A - B - C = 3.14;
假设编译器按照规则重排序后: B - A - C = 3.14;
在单线程中因为编译器 Runtime 和处理器都必须遵守 as-if-serial 语义, 所以给了我们一种假象就是 java 程序是按照代码的顺序执行的, 但是其实并不是, 而是因为遵循了 as-if-serial 语义不能改变最终的执行结果, 所以让我们误认为是按照代码顺序执行的, 通过上面的例子我们知道, 单线程也其实也存在一些重排序的
Java 的内存模型
上面两个规则全部都是针对单线程来说的, 但是在多线程操作的时候重排序依然会存在, 并且还会夹杂着数据可见性的问题, 所以在多线程的情况下也存在着一些规则来防止这些问题的出现, 但是在说这些规则之前必须要先来了解一下 Java 的内存模型
Java 虚拟机为了屏蔽硬件和操作系统之间的内存访问差异和提高程序的运行速度, 定义一种 Java 抽象的内存模型(Java Memory Mode,JMM), 即主内存和工作内存, 这个内存模型主要用来定义程序中变量的访问规则包括对象的实例字段静态字段和数组元素, 不包括局部变量和方法参数, 因为局部变量和方法参数是存储在栈中的, 属于线程私有, 并不是线程共享的这里说的内存模型和 Java 的内存区域不是一个概念, 不要混淆, 内存模型只是虚拟机的一种抽象表现如果硬要对照的话, 那么主内存可以简单理解成主要对标堆内存和方法区, 因为这两者是线程共享的区域而工作内存可以简单理解为 Java 栈内存, 因为栈内存属于线程私有, 但是还是建议不要这样理解为什么说不是一个概念, 其实因为主内存是直接对应硬件物理内存的, 而为了让程序运行的更快速, 所以虚拟机会将一些数据缓存在工作内存中, 简单说线程在运行的时候如果操作了一些共享变量, 其实这些变量只是主内存的副本, 然后会在某个特定的时间回写到主内存中, 例如下图
多线程操作存在的问题
这里有两个存在的问题:
第一是重排序导致的问题, 如果代码被重排序了, 那么就可能导致这个程序的最终结果错误, 例如下面的代码, 假设有两个线程 A 和 B,A 线程访问 writer 方法, 同时 B 线程访问 reader 方法, 因为 writer 方法中两步操作没有数据依赖性, 所以可以被重排序, 那么假设被重排序了, 如果代码先走了第 2 步, 这时线程被切换到 B, 那么这时 B 线程看到 flag=true, 但是 A 线程中还没有开始 a=1 的写入, 那么这短代码最终的结果就是错误:
- class Test {
- int a = 0;
- boolean flag = false;
- public void writer() {
- a = 1; // 1
- flag = true;//2
- }
- Public void reader() {
- if (flag) { //3
- int i = a * a;//4
- }
- }
- }
第二是数据可见性的问题, 在介绍 Java 内存模型的时候, 说到多线程操作的时候如果操作了共享变量会在工作内存中存储主内存的数据副本, 并且在某个特定的时间将数据刷新到主内存中那么这里就有问题了, 在某个特定时间到底是什么时间, 说白了就是我们没法把控这个时间点
那么上面分析出了多线程情况下出现的两个问题, 所以为了解决这两个问题就引出了 Java 的另一个规则: happens-before
happens-before(先行发生原则)
在 Java 的内存模型中, 会使用 happens-before 的概念来描述内存可见性的问题如果一个操作执行的结果需要对另一个操作可见, 那么这两个操作之间就必须存在 happens-before 关系, 这里说的两个操作可以是单线程, 也可以是不同的多线程之间简单点说就是如果操作 A happens-before B, 其意思就是说, 在发生操作 B 之前, 操作 A 产生的影响都能被操作 B 观察到, 影响包括修改了内存中共享变量的值发送了消息调用了方法等, 它与时间上的先后发生基本没有太大关系 happens-before 观念是 Java 非常核心概念, 因为它是判断数据是否存在竞争线程是否安全的主要依据
举个例子:
假设有三个线程分别执行如下三行代码
- int i = 1; //A 线程执行
- int j = i; //B 线程执行
- i = 2; //C 线程执行
现在假设 A 线程 happens-before B 线程, 那么就可以保证 B 线程执行后 j 的值一定等于 1, 意思就是上面说的 B 线程观察到了 A 线程中操作产生的影响
再假设现在同样还是 A 线程 happens-before B 线程, 但是这时 C 线程发生在 A 线程和 B 线程之间运行, 而且 B 线程和 C 线程之间不存在 happens-before, 那么当 B 线程最后执行后的值就不能确定了, 这时 j 的值有可能是 1, 也有可能是 2, 那么也就可以说他们不具备线程安全性
以上的例子就是 happens-before 的意思 Java 内存模型中规定了 8 条 happens-before 的规则, 这 8 条规则是 Java 本身就存在的, 无需任何其他的同步操作, 可以在编码中直接使用但是如果两个操作之间的关系不在如下 8 条规则中, 或者无法从如下 8 条中推倒出来的话, 那么 Java 就不能保证他们的顺序性, 也就是说不能保证最后结果的正确性
程序顺序规则: 一个单独线程中的每个操作, happens-before 与 该线程中的任意后续操作, 或者说写在前面的代码 happens-before 写在后面的代码, 准确地说, 应该是控制流顺序而不是程序代码顺序, 因为要考虑分支循环等结构
监视器锁规则: 对一个锁的解锁, happens-before 与随后对这个锁的加锁
volatile 变量规则: 对一个 volatile 变量的写操作, happens-before 与后续任意对这个 volatile 变量的读操作
线程启动规则: Threadd 对象的 start()方法, happens-before 与该线程中的任意操作
线程终止规则: 线程中的所有操作都 happens-before 于对此线程的终止检测, 我们可以通过 Thread.join()方法结束 Thread.isAlive()的返回值等手段检测 到线程已经终止执行
线程中断规则: 对线程 interrupt()方法的调用 happenbefore 于被中断线程的代码检测到中断时事件的发生
对象终结规则: 一个对象的初始化完成 (构造函数执行结束)happens-before 于它的 finalize() 方法的开始
传递性: 如果操作 A 先行发生于操作 B, 操作 B 先行发生于操作 C, 那就 可以得出操作 A 先行发生于操作 C 的结论
下面再深入的理解一下 happens-before 原则 和 时间顺序的区别, 看一下如下的例子
- private int value = 0;
- public int get(){
- return value;
- }
- public void set(int value){
- this.value = value;
假设有两个线程 A 和 B 分别执行 get 和 set 方法, 在时间上 B 线先执行 set(2), 然后 A 线程再执行 get 方法, 那么 A 线程最终的执行结果一定是 2 吗? 答案是不一定, 因为我们对照上面 8 条规则, 发现没有一条符合我们 value 变量的操作, 所以我们说 A 线程和 B 线程之间就不存在 happens-before 关系, 所以 Java 内存模型也不能保证 B 线程对 value 的操作可以对 A 线程可见
看了上面的几个例子可能朦胧的有点认为 A happens-before B 意思就是 A 会在 B 之前执行, 但是其实并不是的, 来看一个例子
- int a = 1; //A
- int b = 2; //B
假设只有一个线程执行上面两行代码, 那么根据 happens-before 规则的第 1 条规则那么 A happens-before B, 但是根据数据依赖关系来看 A 和 B 不存在数据依赖, 那么编译器和处理器就可能会对 A 和 B 进行重排序, 并且重排序后不会影响最终的执行结果, 重排序后那么 B 就会先在 A 之前执行, 所以说不能单纯认为 A happens-before B 就是 A 会先执行 happens-before 规则中说到 A happens-before B 并不要求 A 一定要在 B 之前执行, 只要求前一个操作对后一个操作可见, 并要求前一个操作按顺序在第二操作之前可能最后一句有点绕嘴, 但是细细的想想就是上面给出的例子一个意思
来源: https://blog.csdn.net/yulong0809/article/details/79728009