引言
这篇文章主要讨论JVM中的内存模型(JMM),以及实现Java线程安全的底层的各种锁机制,会介绍JVM如何实现多线程,多线程之间由于共享和数据竞争导致的一系列问题和解决方案。通过本篇文章的学习,将使的你能写出更好的Java线程安全的代码。
Java内存模型与线程
概述
多任务处理在现代计算机操作系统是必备的功能,原因是计算机各个部件运算速度的差异,导致资源空闲,为了充分提高处理器的利用率,就需要进行并发处理。
硬件效率和数据一致性
为了缓和CPU和主存的速度差异,现代操作系统都引入了高速缓存(Cache)作为缓冲层。高速缓存解决了处理器和主存速度的矛盾,但是也引入了缓存一致性(Cache Coherence)问题。
缓存一致性问题
共享内存多核系统(SMPS)中每个处理器都有自己的Cache,他们又共享统一主存。当多个处理器的运算任务都涉及到同一块主存区域,可能导致各自的缓存数据不一致。
指令重排序
处理器内部会对输入代码进行乱序执行优化,处理器保证乱序执行和顺序执行的结果是一致的。在JVM中,JIT也会对指令进行重排序优化。这样做的好处是为了让内部的运算单元能充分的利用。
JMM(Java Memory Model)
JMM是由Java虚拟机规范定义的,用来屏蔽硬件和操作系统的内存访问差异。直到JDK5(JSR133)发布后,JMM内存模型才真正成熟完善。
JSR133:Java Memory Model and Thread Specification Revision(Java 内存模型和线程规范修订)
Java内存模型主要目的是定义程序中各种变量的访问规则。JMM规定了所有的变了存储在主存(类比物理机的主存)中,每条线程都有自己的工作内存(类比物理机的Cache)
JMM的内存交互操作
JMM定义了如下8种内存交互操作,JVM保证下面的每一种操作都是原子的,但是对于double和long来说可能会有一些例外,但一般不需要考虑这个例外。
- lock:锁定一个变量,把变量标识为一条线程独占
- unlock:把一个变量解锁
- read:读取主内存的变量值,然后进行load操作
- load:把主存中得到变量值,存入工作内存
- use:把工作内存的变量值,传递给执行引擎
- assign:把执行引擎接收到的值,赋值给工作内存变量
- store:读取工作内存变量的值,然后进行write操作
- write:把工作内存变量的值,写入主内存
volatile
当一个变量被定义为volatile后,具备两种特性:
- 对所有线程的可见性
- 禁止指令重排序优化
然而在下面两种场景下,volatile是不能保证原子性的,需要加锁来实现:
- 运算结果不依赖变量当前值,或者能够确保只有单一的线程修改变量值
- 变量不需要与其他的状态变量共同参与不变约束
正确的使用案例:
long和double的非原子性协定
JMM要求了之前讲的8种内存交互操作都具备原子性,然而64位的数据类型(long、double)允许虚拟机自行实现原子性。但是实际开发中,由于现代处理器包含专门的浮点运算器、64位JVM也不会出现非原子性访问行为,因此一般不需要针对long和double把变量声明成volatile。
原子性、可见性、有序性
JMM是围绕着并发过程中,如何处理原子性、可见性、有序性三个特征来建立的,下面介绍哪些操作实现了这三个特性
原子性
- 对基本数据类型的访问、读写都是具备原子性的
- 通过字节码指令monitorenter/monitorexit来实现
- 通过lock/unlock实现
可见性
- volatile:通过修改后把新值写回主内存,读之前从主内存刷新变量,实现可见性
- synchronized:执行unlock之前,必须把变量写回主内存中
- final:一旦初始化完成,只要没有this引用逃逸,就能实现可见性
有序性
- volatile禁止指令重排序,实现有序性
- synchronized在同一时刻允许一个线程对其进行lock,实现有序性
先行发生原则(happens-before)
Java的happens-before原则,用于判断数据是否存在竞争,线程是否安全。/JMM有一些天然的happens-before模型,无需任何同步器协助,可以直接使用。如果两个操作不在下面的规则里面,而且不能推导出来,那么就没有顺序性保证,JVM可以对他们进行指令重排序优化。
先行发生:是JMM定义的偏序关系,如果A happens before B,那么A操作产生的影响(如共享变量修改、消息发送、方法调用)能够被B看到。
- 程序次序原则
- 管程锁定原则
- volatile变量原则
- 线程启动规则
- 线程终止规则
- 线程中断规则
- 对象终结规则
- 传递性
时间先后顺序与先行发生原则之间基本没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准
Java与线程
Java的并发是依赖于线程的,如操作系统课程中知识,线程有多种实现方式:
- 内核线程(1:1实现)
- 用户线程(1:N实现)
- 用户线程+轻量级进程(N:M实现)
线程调度
调度方式有两种:
- 协同式调度(Cooperative):线程执行时间自己控制,主动通知系统切换另一个线程
- 抢占式调度(Preemptive):线程由系统分配执行时间,切换也是系统负责
我们可以给Java线程分配优先级(Java设置了10个优先级),但是线程优先级不是稳定的调节手段,原因如下:
- 某些操作系统上,不同的Java线程优先级会映射到相同的OS线程优先级
- 优先级可能会被OS给改变(Priority Boosting功能)
线程状态模型
Java与协程
Project Loom恢复了有栈协程(Fiber、Coroutine),也就是现在的虚拟线程(Virtual Thread)。协程也就是恢复了用户级线程,减少了内核上下文切换的开销(中断响应、现场保护、现场恢复),提升了性能,代价就是实现复杂。
线程安全与锁优化
线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
线程安全程度
按照线程安全的安全程度从强到弱,Java把操作共享数据分为如下5种线程安全程度:
不可变
对于不可变的对象,一定是线程安全的。对于基础数据类型,final关键字可以保证,变量只要被正确的初始化,并且没有this逃逸,一定就是不可变的。对于复合类型,要确保每个方法,不去影响原来的值,都返回新的对象,那这个对象也是不可变的。
绝对线程安全
不管运行时环境如何,即使是组合操作,也能够实现线程安全。实现这种级别的安全,需要额外的同步措施,比如维护一组一致性的快照,每次对元素改动需要产生新的快照。
相对线程安全
通常意义上的线程安全,保证对这个对象的单次操作是线程安全的。但是对于一些特定 顺序的连续组合调用,需要调用方进行额外的同步。
Java的Vector、HashTable、synchrnizedCollection都是相对线程安全
线程兼容
对象本身不是线程安全的,通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。
Java的ArrayList和HashMap就是线程兼容的
线程对立
不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。这种代码是编码问题造成的,尽量不要使用。
线程安全实现方法
互斥同步
互斥是方法,同步是目的。临界区、互斥量、信号量都是实现互斥的方式。同步是为了在多个线程并发访问共享数据时,保证同一时刻只被一个线程使用。Java有两种实现方法:
- synchronzed实现
- Lock接口实现
ReentrantLock对比synchronized
- 等待可中断
- 公平锁参数
- 锁绑定多个Condition条件对象
非阻塞同步(无锁编程)
使用乐观并发策略来进行冲突检测。就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。
乐观并发策略需要依赖于处理器指令:
- Test and Set
- Swap
- Compare and Swap
- Fetch and Increment
CAS可能造成ABA问题:
- Java提供了AtomicStampedReference,通过控制变量值的版本来保证 CAS 的正确性
- 大部分情况下ABA不会影响并发正确性,而且AtomicStampedReference性能差,不如使用互斥同步
无同步方案
在Web服务端中,每个请求都对应一个服务器线程,这种模式下,可以使用ThreadLocal来解决线程安全问题。
ThreadLocal是可重入代码,是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。
可重入是线程安全的,线程安全的代码不一定是可重入的
锁优化
JDK5升级到JDK6后,引入了很多种锁优化技术
自旋锁
- JDK6默认开启
- 多核处理器系统中,能让多个线程并行执行,让请求锁的线程执行一个忙循环(自旋),看看持久锁的线程是否很快被释放
- 自适应自旋,对自旋进行优化。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持
锁粗化
虚拟机探测到有这样许多操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部(比如循环内部加锁,粗化到循环外部)
HotSpot对象头(Object Header)
要理解轻量级锁和偏向锁,需要对对象头有一个简单的了解。HotSpot虚拟机对象头分为3部分(可选的一部分):
- Mark Word: 存储对象自身运行数据的
- 存储指向方法区对象类型数据的指针
- 可选的存储数组长度如果是数组对象(可选部分)
Mark Word 被设计成一个非固定的动态数据结构,它会根据对象的状态复用自己的存储空间。Mark Word结构如下:
轻量级锁
一项锁优化措施,轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量。在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。即将进入同步块的时候,先尝试轻量级锁,而不是直接上重量级锁。
Lock Record(锁记录): JVM在实现轻量级锁时使用的一种数据结构,主要作用是存储锁对象的相关信息,用于实现轻量级锁的获取和释放。Lock Record存储在栈帧中,包括以下两个字段:
- Displaced Mark Word:保存锁对象原始的Mark Word副本
- Object Reference:指向锁对象的引用
轻量级锁上锁过程:
- 保存Mark Word:如果对象没有被锁定(标志01),先在栈帧中创建一个Lock Record,存储当前锁对象(Lock)Mark Work的拷贝,命名为Displaced Mark Word。
- 更新对象Mark Word:JVM使用CAS尝试把对象的Mark Word指向Lock Record
- 更新成功(无竞争),锁标志变为00->轻量级锁上锁成功
- 更新失败(有竞争),检查对象Mark Word是否指向的是当前线程的Displaced Mark Word。如果是,表明已经上锁;如果不是,膨胀(升级)为重量级锁(锁标志10)->重量级锁上锁成功
轻量级锁解锁过程:
- 恢复Mark Word:使用CAS来把Mark Word恢复为Displaced Mark Word的值
- CAS成功->解锁完成
- CAS失败(有竞争),说明已经升级成重量级锁,执行重量级锁解锁操作
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。
偏向锁
偏向锁是一项锁优化措施,偏向锁就是在无竞争的情况下把整个同步都消除掉,目的是消除数据在无竞争下的同步原语。
偏向锁的”偏“,就是偏向第一个获得他的线程。接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
- 当锁对象第一次被线程获取,如果偏向锁可用,进入偏向模式
- Mark Word标志位设置为“01”,偏向模式设置为“1”
- 使用CAS把获取到这个锁的线程的ID记录在对象的Mark Word之中
- 持有偏向锁的线程进入同步块,不需要进行任何同步操作
- 当另一个线程尝试获取这个锁,偏向模式结束,恢复为无锁或者轻量级锁状态
无锁、偏向锁、轻量级锁、重量级锁的状态转化如下:
注意⚠️:在JDK 15及以后,偏向锁已被默认关闭
重量级锁
重量级锁是基于操作系统互斥量(Mutex)实现。当一个线程尝试获取重量级锁时,如果锁已经被其他线程持有,则该线程会被阻塞,并加入到等待队列中。当锁被释放时,操作系统会从等待队列中唤醒一个线程。 Mutex包括下面的字段:
- 锁状态:表示锁是否被持有
- 等待队列:储等待该锁的线程
重量级锁的上锁流程:
- 线程尝试获取锁
- 如果锁没有被持有,线程获取锁
- 如果锁被持有,线程被阻塞,加入等待队列
重量级锁的解锁流程:
- 线程释放锁
- 操作系统从等待队列中唤醒一个线程
- 被唤醒的线程尝试获取锁
参考
- 深入理解Java虚拟机(第三版)
- Java并发编程的艺术
- Java并发编程实战(Brian Goetz)