JMM与线程安全 | Java后端
JMM
概念
JMM 是 JVM 在计算机内存 RAM 中的工作方式,定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的工作内存,工作内存中存储了该线程以读/写共享变量的副本(工作内存是 JMM 的一个抽象概念,并不真实存在)
同时它规范了 JVM 如何提供按需禁用缓存和编译优化的方法,意在解决在并发编程可能出现的线程安全问题,确保了程序执行在多线程环境中的应有的原子性、可见性及有序性。具体来说,这些方法包括:- 外部可使用的同步手段:volatile、synchronized 和 final 三个关键字
- 内置解决方案:Happens-Before 规则
JMM 还屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的并发效果
JVM 描述的是 Java 虚拟机内部及各个结构间的关系
Java 内存模型
所有的共享变量都存储在主内存中,包括实例变量、静态变量,但是不包括局部变量和方法参数
每个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行。线程不能直接读写主内存中的变量特征
- 原子性
不可中断
除了 JVM 自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用 synchronized 关键字或者重入锁(ReentrantLock)保证程序执行的原子性 - 可见性
指线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值
- Java 提供 volatile 关键字来保证可见性:当变量被 volatile 修饰时,这个变量被修改后会立刻刷新到主内存。当其它线程需要读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点
- 通过 synchronized 也能够保证可见性:synchronized 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中,因此可以保证可见性
- 有序性
代码的执行按顺序依次执行
- 对于多线程环境,可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致
- Java中,可以使用 synchronized 或者 volatile 保证多线程之间操作的有序性
实现原理有些区别:volatile 是使用内存屏障达到禁止指令重排序;synchronized 的原理是,一个线程 lock 之后,必须 unlock 后,其他线程才可以重新 lock,使得被 synchronized 包住的代码块在多线程之间是串行执行的
内存交互操作
操作
- lock(锁定)
作用于主内存中的变量,把变量标识为线程独占的状态 - read(读取)
作用于主内存的变量,把变量的值从主内存传输到线程的工作内存中,以便下一步的 load 操作使用 - load(加载)
作用于工作内存的变量,把 read 操作主存的变量放入到工作内存的变量副本中 - use(使用)
作用于工作内存的变量,把工作内存中的变量传输到执行引擎
每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作 - assign(赋值)
作用于工作内存的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中
每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作 - store(存储)
作用于工作内存的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的 write 使用 - write(写入)
作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中 - unlock(解锁)
作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
规则
- read 后必须 load,store 后必须 write,不允许单独出现
- 不允许线程丢弃他最近的 assign 操作,即工作内存中的变量数据改变了之后,必须告知主存
- 不允许线程将没有 assign 的数据从工作内存同步到主内存
- 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量
就是对变量实施 use、store 操作之前,必须经过 load 和 assign 操作 - 一个变量同一时间只能有一个线程对其进行 lock 操作
多次 lock 之后,必须执行相同次数 unlock 才可以解锁 - 如果对一个变量进行 lock 操作,会清空所有工作内存中此变量的值
在执行引擎使用这个变量前,必须重新 load 或 assign 操作初始化变量的值 - 如果一个变量没有被 lock,就不能对其进行 unlock 操作,也不能 unlock 一个被其他线程 lock 的变量
- 一个线程对一个变量进行 unlock 操作之前,必须先把此变量同步回主内存
重排序
- 指令重排序可以提高性能,属于时间并行技术,提高处理器各部件的利用率
- 重排序可能发生在多个阶段
- 编译器优化重排
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序 - 指令并行重排
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。 - 内存系统重排
由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级存储系统的存在,导致内存与缓存的数据同步存在时间差
- 编译器优化重排
- 指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题
happens-before 原则
一个给程序员使用的规则,只要程序员在写代码的时候遵循 happens-before 规则,JVM就能保证指令在多线程之间的顺序性符合程序员的预期
在程序开发中仅靠 sychronized 和 volatile 来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦。因此在 Java 内存模型中还提供了 happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据
JMM 使用 happens-before 来规范两个操作之间的执行顺序
这两个操作可以在一个线程以内,也可以是不同的线程之间。因此,JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证happens-before 关系的定义
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序
程序顺序原则
一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作管程锁定规则
对一个锁的解锁,happens-before 于随后对这个锁的加锁
无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行 unlock 操作后面才能进行 lock 操作volatile 变量规则
对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读传递规则
如果 A happens-before B,且 B happens-before C,那么 A happens-before C线程启动规则
指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作(主调 start 方法调用先行发生于被调 run 方法的每一个动作)线程终结规则
假定线程 A 在执行的过程中,通过制定 ThreadB.join() 等待线程B终止,那么线程 B 在终止之前对共享变量的修改在线程A等待返回后可见线程中断规则
对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测线程是否中断对象终结规则
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始
volatile
内存语义(用途)
- 保证 volatile 变量在线程间的可见性
- 禁止 volatile 变量与普通变量重排序
内存语义:指在多线程或处理器中用来控制存取共享内存位置,或者说是在更高层次上共享变量的处理逻辑
特性
- 可见性
对一个 volatile 变量的读,总能看到(任意线程)对这个 volatile 变量最后的写入 - 原子性
对任意单个 volatile 变量的读/写具有原子性,但是类似于 i++ 这种复合操作不具有原子性 - 有序性
volatile 会通过禁止指令重排序来保证有序性
i++ 是一个复合操作,包括三步骤
- 读取i的值
- 对 i 加 1
- 将 i 的值写回内存
- 可见性
禁止重排序的实现原理
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序- 内存屏障(Memory Barrier)
内存屏障是一种 CPU 指令,它导致 CPU 或编译器对 barrier 指令前后发出的内存操作执行顺序约束。也就是说,在 barrier 之前的内存操作保证在 barrier 之后的内存操作之前执行
- LoadLoad 屏障:对于指令序列 Load1; LoadLoad; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
- StoreStore屏障:对于指令序列 Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见
- LoadStore 屏障:对于指令序列 Load1; LoadStore; Store2,在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕
- StoreLoad 屏障:对于指令序列 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见
- 内存屏障插入策略
- 在每个 volatile 读操作后插入一个 LoadLoad 屏障和一个 LoadStore 屏障
+ 在每个 volatile 写操作前插入一个 StoreStore 屏障,后面插入一个 StoreLoad 屏障
3. 内存屏障的另一个作用是强制刷出各种 CPU 的缓存数据,这意味着如果你对一个 volatile 字段进行写操作,你必须知道: + 一旦你完成写入,任何访问这个字段的线程将会得到最新的值 + 在你写入之前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存
可见性的实现原理
- volatile 写-读的内存语义
- volatile 写:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存
- volatile 读:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
- 底层原因:volatile 变量进行写操作时会多一行 lock 前缀的汇编代码,使得
- 当前 CPU 缓存行的数据写回到系统内存
- 通过总线让其他 CPU 里缓存了该内存地址的本地缓存无效
- 如何通过总线让其他缓存了该内存的 CPU 本地缓存无效?
使用缓存一致性协议 MESI,每个 CPU 会通过嗅探总线上的数据来查看本地缓存的数据是否过期,一旦 CPU 发现本地缓存对应的内存被修改,就会将本地缓存设为无效(I)状态,此后 CPU 要再想获取这个数据就必须重新填充本地缓存,彼时会将缓存行标记为共享(S)状态 - lock 前缀指令有三个作用
- 锁总线:其它 CPU 对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存
- lock 后的写操作会回写已修改的数据,同时让其它 CPU 相关缓存行失效,从而重新从主存中加载最新的数据
- 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两遍的指令重排序
线程安全
产生原因
- 多线程的运行环境
- 多线程访问共享数据
- 多线程访问共享数据非原子操作
异步与同步
- 概念
- 同步:进程在执行某个请求访问某个资源的时候,如果这个资源被占用,那么这个进程就会等待着资源的释放,按顺序的执行。同步是一种阻塞模式
- 异步:进程不需要等待,继续执行下面的操作,等到有资源的时候,就会再回来处理。异步是非阻塞模式
- 异步使用场景
- 不涉及共享资源,或对共享资源只读,即非互斥操作
- 没有时序上的严格关系
- 不需要原子操作,或可以通过其他方式控制原子性
- 常用于 IO 操作等耗时操作,因为比较影响客户体验和使用性能
- 不影响主线程逻辑
- 优缺点
- 同步:执行效率会比较低,耗费时间,但有利于我们对流程进行控制,避免很多不可掌控的意外情况
- 异步:执行效率高,节省时间,但是会占用更多的资源,也不利于我们对进程进行控制
线程安全的实现方法
- 互斥同步(悲观)
使用 synchronized 和 ReentrantLock - 非阻塞同步(乐观)
- CAS
- AtomicXXXX
基于冲突检测检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步
- 无同步方案
- 栈封闭
- 可重入代码(Reentrant Code)
- 线程本地存储(Thread Local):共享数据的代码保证在同一个线程中执行
不涉及共享数据就无须任何同步措施去保证正确性
- 互斥同步(悲观)
synchronized 关键字
使用
- 修饰普通方法
- 此方法成为同步方法
- 锁对象为当前对象 this
1
2synchronized methods() {
}- 修饰静态方法
锁对象为当前类(字节码文件 .class)
1
2static synchronized methods() {
}- 修饰代码块
锁对象任意。若设置为当前对象/类,则和前两种并没有什么区别
有时候并不需要将整个方法都加上 synchronized,而是可以采取 synchronized 代码块的方式,对会引起线程安全问题的那部分代码进行 synchronized 就可以了,从而提高执行效率
1
2
3synchronized(锁对象) {
需要同步的代码块
}对象锁/实例锁(锁对象为对象)
对类的当前实例(当前对象)进行加锁,防止其他线程同时访问该类的该对象的所有 synchronized 块(可以理解为使用同一个锁对象的所有代码块成为一个临界区)。注意这里是“类的当前实例”,类的两个不同实例就没有这种约束了- 锁对象可以是任意的 java 对象
- java 中的每个对象都有一个锁,且是唯一的
- 这里的锁,其实就是利用对象内部存在的一个标志位,通过设置这个标志位,进行所谓的加锁释放锁
任意对象可作为监视器来实现同步的功能带来的好处是,synchronized(非this对象)代码块中的程序与同步方法是异步的,不与其他同步方法争抢 this 锁(再扩展一下,一个对象里可以有多种锁)
类锁(锁对象为类)
该锁针对的是类,无论该类实例出多少个对象,运行在它们上面的线程依然共享该锁加锁
当某个线程执行到同步代码块时,会尝试在当前线程中对锁对象加锁- 若此时锁对象处于未加锁状态,JVM 就会设置锁对象的标志位(加锁),并在锁对象中记录是哪个线程加的锁,然后让加锁成功的当前线程,执行同步代码块中的代码
- 若此时锁对象已经被加锁,且加锁线程不是当前线程,系统会让当前线程处于阻塞状态,直到加锁线程释放锁
释放锁
当加锁线程执行完了同步代码块中的代码(对共享变量的一组操作),在退出同步代码块之前,JVM 自动清理锁对象的标志位,将锁对象变成未上锁状态
原理
JDK1.6 之前
synchronized 通过 Monitor 来实现线程同步。synchronized 编译后生成会在同步块的入口位置和退出位置 monitorenter 和 monitorexit 两个字节码指令JVM 中管程 Monitor 本质是依赖于底层操作系统的互斥锁(Mutex Lock)来实现的,但使用 Mutex Lock 需要将当前线程挂起并从用户态切换到内核态来执行,开销大
这种依赖于操作系统 Mutex Lock 所实现的锁称之为“重量级锁”- JDK1.6 开始对 synchronized 进行了优化,为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”
因此对象锁总共有四种状态,从低到高分别是“无锁”、“偏向锁”、“轻量级锁”、“重量级锁”
lock
- 语法
1
2
3lock.lock()
//需要同步的代码
lock.unlock()- 常用子类:ReentrantLock
开发当中推荐 synchronized 的方式去处理线程数据安全问题
synchronized 代码实现更加简单,而lock方式更加麻烦。早期的 jdk 中 Lock 方式效率确实高一些,但是现如今的 jdk 中两者在效率上相差无几等待唤醒机制
- 线程间通信,主要通过 Object 中的方法来实现:
wait()
阻止自己notify()
通知别人notifyall()
通知所有人
1
2
3
4
5
6
7Object o = new Object();
// 让在o对象上活动的线程进入等待状态,直到被唤醒,同时会释放o对象的锁
o.wait();
// 唤醒在o对象上等待的线程,只会通知不会释放锁
o.notify();
// 唤醒在o对象上等待的所有线程
o.notifyAll();wait()
用法wait()
总是在一个循环中被调用,挂起当前线程来等待一个条件的成立(该条件的成立必然是由其他线程来完成的)- 线程调用
wait()
后会一直等到其他线程调用notifyAll()
唤醒 - 当一个线程执行到 synchronized 代码时调用了
wait()
后,该线程会释放该对象的锁,然后该线程会被添加到该对象的等待队列中。只要该线程在等待队列中,就会一直处于闲置状态,不会被调度执行
不能用在 if , 因为存在一些特殊情况,会使得线程没有收到 notify 时也能退出等待状态
notify()
用法- 当一个线程调用一个对象的
notify()
方法时, 调度器会从所有处于该对象线程等待队列中任意取出一个,将其添加到入口队列中(唤醒),然后与入口队列中的多个线程竞争对象的锁,得到锁的线程就可以继续执行 - 如果等待队列中没有线程,
notify()
不会产生任何作用 notifyAll()
会将等待队列中所有的线程都添加到入口队列中notifyAll()
比notify()
更常用
因为notify()
方法只会唤起一个线程,且无法指定,一般只有在多个执行相同任务的线程在并发运行,我们不关心哪一个线程被唤醒时,才会使用
- 当一个线程调用一个对象的
为什么
wait()
和notify()
或notifyAll()
需要在 synchronized 内部使用wait()
会强迫线程先进行释放锁操作,所以在调用wait()
时,该线程必须已经获得锁,否则会 IllegalMonitorStateException;notify()
同样必须是要当前线程获得锁才可以实行的,否则会报异常- 线程访问临界区资源(同步代码块),需要持有锁并且锁要可用。但是线程自己只知道这个锁被占有,不知道被哪个线程占有。所以应该由共享资源来获取锁,而不是线程本身
Thread.sleep()
VSObject.wait()
- 所属不同
- sleep 定义在 Thread 类,静态方法
- wait 定义在 Object 类中,非静态方法
- 唤醒条件不同
- sleep:休眠时间到
- wait:其他线程在同一个锁对象上,调用了
notify()
或notifyAll()
- 使用条件不同
- sleep 没有任何前提条件
- wait 必须在当前线程持有锁对象时,即锁对象上调用
- 休眠时对锁对象的持有(核心区别
- 线程因为 sleep 方法而处于阻塞状态的时候,在阻塞的时候不会放弃对锁的持有
- wait 会在阻塞时放弃锁对象持有
生产者与消费者模型
略
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 ZERO!- JDK1.6 开始对 synchronized 进行了优化,为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”
- 内存屏障(Memory Barrier)