前面
并发编程可以总结为三个核心问题:
- 分工指的是如何高效地拆解任务并分配给线程
- 同步指的是线程之间如何协作
- 互斥则是保证同一时刻只允许一个线程访问共享资源
并发相关理论
可见性、原子性和有序性
核心矛盾
CPU、内存、I/O 设备的速度差异
cpu >>> 内存 >>> I/O 设备
CPU 增加了缓存,以均衡与内存的速度差异
操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异
编译程序优化指令执行次序,使得缓存能够得到更加合理地利用
缓存导致的可见性问题
可见性: 一个线程对共享变量的修改,另外一个线程能够立刻看到
多核 CPU 的缓存与内存关系图
多核时代,每颗 CPU 都有自己的缓存
当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存
这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了
线程切换带来的原子性问题
线程切换示意图
多进程: 单核的 CPU可以同时执行多个任务
时间片: 某个进程执行的一小段时间, 之后进行任务切换
一个时间片内, 某个进程IO可以先休眠, 让出CPU使用权, 读入内存后再由OS唤醒该进程, 可以同时提高CPU和IO的使用率
一个进程创建的所有线程共享一个内存空间的,不需要切换内存映射地址
count += 1, 三条CPU指令:
指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条 CPU 指令
执行完
假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1
原子性: 一个或者多个操作在 CPU 执行的过程中不被中断的特性
编译优化带来的有序性问题
应该这样写
class Single{
private static volatile Single s = null; //禁止重排序
private Single(){}
public static Single getInstance(){
if(null==s){
synchronized(Single.class){
if(null==s)
s = new Single();
}
}
return s;
}
}
双重检查创建单例对象
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);
线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例
getInstance 还是存在问题 – 重排序 – volatile!!!
new 操作:
- 分配一块内存 M;
- 在内存 M 上初始化 Singleton 对象;
- 然后 M 的地址赋值给 instance 变量。
优化后的执行顺序:
- 分配一块内存 M;
- 将 M 的地址赋值给 instance 变量;
- 最后在内存 M 上初始化 Singleton 对象。
产生问题: 空指针异常
假设线程 A 先执行 getInstance() 方法,当执行完指令 2
时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常
。
双重检查创建单例的异常执行路径
思考
在 32 位的机器上对 long 型变量进行加减操作存在并发隐患
long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原子性
,所以并发的时候会出问题
java内存模型, 按需禁用缓存以及编译优化
通过volatile, synchronized, final关键字和happens-before规则
volatile
禁用 CPU 缓存
volatile int x = 0:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入
Happens-Before 规则
Happens-Before 规则: 前面一个操作的结果对后续操作是可见的, 约束了编译器的优化行为
- 程序的顺序性规则
前面的操作 Happens-Before 于后续的任意操作
- volatile 变量规则
对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作
- 传递性
如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C
- 管程synchronized中锁的规则
对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
前一个线程的解锁操作对后一个线程的加锁操作可见
管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现
管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的
synchronized (this) { //此处自动加锁
// x是共享变量,初始值=10
if (this.x < 12) {
this.x = 12;
}
} //此处自动解锁
- 线程 start() 规则
主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现)
当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。
当然所谓的“看到”,指的是对共享变量的操作
eg: 如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作
- 线程 join() 规则
如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回
Thread B = new Thread(()->{
// 此处对共享变量var修改
var = 66;
});
// 例如此处对共享变量修改,则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改, 在主线程调用B.join()之后皆可见
// 此例中,var==66
final
volatile 为的是禁用缓存以及编译优化
final 关键字: 这个变量生而不变
利用双重检查方法创建单例
,构造函数的错误重排导致线程可能看到 final 变量的值会变化
final int x;
// 错误的构造函数
// 在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲this逸出,
global.obj = this;
}
思考
有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,你思考一下,有哪些办法可以让其他线程能够看到abc==3?
1.声明共享变量abc,并使用volatile关键字修饰abc
2.声明共享变量abc,在synchronized关键字对abc的赋值代码块加锁,由于Happen-before管程锁的规则,可以使得后续的线程可以看到abc的值。
3.A线程启动后,使用A.JOIN()方法来完成运行,后续线程再启动,则一定可以看到abc==3
总结
- 为什么定义Java内存模型?现代计算机体系大部是采用的
对称多处理器
的体系架构。每个处理器均有独立的寄存器组和缓存,多个处理器可同时执行同一进程中的不同线程,这里称为处理器的乱序执行
。在Java中,不同的线程可能访问同一个共享或共享变量。如果任由编译器或处理器对这些访问进行优化的话,很有可能出现无法想象的问题,这里称为编译器的重排序
。除了处理器的乱序执行、编译器的重排序,还有内存系统的重排序
。因此Java语言规范引入了Java内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性。
- 三个基本原则:原子性、可见性、有序性。
- Java内存模型涉及的几个关键词:锁、volatile字段、final修饰符与对象的安全发布。其中:第一是锁,锁操作是具备happens-before关系的,解锁操作happens-before之后对同一把锁的加锁操作。实际上,在解锁的时候,JVM需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。第二是volatile字段,volatile字段可以看成是一种不保证原子性的同步但保证可见性的特性,其性能往往是优于锁操作的。但是,频繁地访问 volatile字段也会出现因为不断地强制刷新缓存而影响程序的性能的问题。第三是final修饰符,final修饰的实例字段则是涉及到新建对象的发布问题。当一个对象包含final修饰的实例字段时,其他线程能够看到已经初始化的final实例字段,这是安全的。
- Happens-Before的7个规则:
(1).程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
(2).管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序。
(3).volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后顺序。
(4).线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
(5).线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
(6).线程中断规则
:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
(7).对象终结规则
:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
- Happens-Before的1个特性:传递性。
- Java内存模型底层怎么实现的?主要是通过
内存屏障(memory barrier)
禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于volatile,编译器将在volatile字段的读写操作前后各插入一些内存屏障。
互斥锁, 解决原子性问题
原子性问题: 线程切换
操作系统做线程切换是依赖 CPU 中断
的,所以禁止 CPU 发生中断就能够禁止线程切换
eg: 32 位 CPU 上执行 long 型变量的写操作
明明已经把变量成功写入内存,重新读出来却不是自己写入的
在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断
,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性
但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断
,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行
,如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现诡异 Bug : 明明已经把变量成功写入内存,重新读出来却不是自己写入的
同一时刻只有一个线程执行
保证对共享变量的修改是互斥
简易锁模型
临界区: 一段需要互斥执行的代码
改进后的锁模型
受保护的资源 R
要保护资源 R 就得为它创建一把锁 LR
针对这把锁 LR,在进出临界区时添上加锁操作和解锁操作
锁 LR 和受保护资源之间,保护自家的资源
!!
Java 语言提供的锁技术:synchronized 管程
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)
当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
当修饰非静态方法的时候,锁定的是当前实例对象 this。
synchronized 修饰静态方法相当于:
class X {
// 修饰静态方法
synchronized(X.class) static void bar() {
// 临界区
}
}
修饰非静态方法,相当于:
class X {
// 修饰非静态方法
synchronized(this) void foo() {
// 临界区
}
}
用 synchronized 解决 count+=1 问题
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
addOne方法:
1. 原子性
addOne() 方法,被 synchronized 修饰后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行 addOne() 方法,
所以一定能保证原子操作
2. 可见性
2.1 synchronized 修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码
2.2 管程(synchronized)中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
前一个线程的解锁操作对后一个线程的加锁操作可见
2.3 综合 Happens-Before 的传递性原则
前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的
问题: 执行 addOne() 方法后,value 的值对 get() 方法是可见的吗?这个可见性是没法保证的
get() 方法并没有加锁操作,所以可见性没法保证
问题的解决: get() 方法也 synchronized 一下
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
锁模型示意图
保护临界区 get() 和 addOne() 的示意图
锁和受保护资源的关系
合理的关系应该是: 受保护资源和锁之间的关联关系是 N:1 的关系
使用一把
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)