Java内存模型
Java内存模型的主要目标:定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
注:此处的变量是指实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数(线程私有),可参考Java内存区域划分时的方法区和虚拟机栈的区别。
对工作内存和主内存的理解:
主内存中存放了所有的变量,Java线程所对应的工作内存则保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,工作内存之间也不能相互访问调用,线程间变量的传递都需依靠主内存。
在工作内存与主内存的交互之间,需要制定协议来保证交互的正确进行
Java内存模定义了以下8种操作来实现,但需要保证这些操作都是原子的、不可再分的(long、double不保证)
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态
unlock(解锁):作用与主内存的变量,它把一个处于锁定状态的变量释放出来,释放出来的变量才能被其他线程锁定
read(读取):作用与主内存的变量,它把一个变量的值从主内存传输到线程的工作区,以便随后的load动作使用
load(载入):作用于工作内存的变量,它把read操作从主内存得到的变量值放到工作内存的变量副本中
use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用此变量的字节码指令时,该操作将会执行
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存中的变量,每当虚拟机遇到一个需要给变量赋值的字节码指令时,该操作将会执行
store(存储):作用与工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write动作使用
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放到主内存的变量中
在这8个操作中,read和load顺序执行,store和write顺序执行
注:是顺序执行而不是连续执行
Java内存模型还规定了这8种操作需满足以下规则
1、不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起了回写但主内存不接受的情况出现
2、不允许一个线程丢弃它的最近的assign操作,即变量如果在工作内存中改变了必须写回到主内存中,保证工作内存和主内存的同步
3、不允许一个线程无原因(没有发生任何assign操作)地把数据从线程的工作内存同步回主内存
4、一个新的变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化(load、assign)的变量,即在use之前先load,在sotor之前先assign
5、一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可被同一条线程重复执行多次,多次执行lock之后,只有执行相同次数的unlock操作,变量才会被解锁(可重入锁)
6、如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量之前,需要重新执行load或者assign操作初始化变量的值
7、如果一个变量事先没有被lock锁定,则不允许对其进行unlock操作,也不允许unlock一个被其他线程锁定住的变量
8、对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(store、write)
上述规则过于繁琐,可使用先行发生原则,来确定一个访问在并发环境下是否安全
先行发生原则:
1、程序次序规则:在一个线程内,按照程序代码控制流顺序,书写在前面的操作先行发生于书写在后面的操作
2、管程锁定规则:一个unlock操作在时间上先行发生于后面对同一个锁的lock操作
3、volatile变量规则:对一个volatile变量的写操作在时间上先行发生于后面对这个变量的读操作
4、线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
5、线程终止规则:线程中的所有操作都要先行发生于对此线程的终止检测
6、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
7、对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始
8、传递性:如果操作A先行发生于操作B,操作B又先行发生于操作C,那么操作A肯定会先行发生于操作C
这样看来,对于一个并发环境的约束条件是很繁琐的,实则不然,这些规则无需任何同步辅助手段就天然存在于Java内存模型。
Java与线程
实现线程主要有3种方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现
JVM具体使用哪种方式,取决于操作系统支持怎样的线程模型
附大佬博客:https://blog.youkuaiyun.com/gatieme/article/details/51892437
1、使用内核线程实现:
Thread Scheduler:操作调度器,内核通过这个对线程进行调度
KLT:内核线程
LWP:轻量级进程(通常意义上的线程)
每个轻量级进程都必须由一个内核线程来支持,关系为1:1
优点:每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程被阻塞,也不会影响整个进程继续工作
缺点:由于基于内核线程实现,所以各种线程操作,都需要系统调用,在用户态和内核态之间来回切换,代价较高;其次,每个轻量级进程都需要有一个内核线程支持,因此轻量级进程需要消耗一定的内核资源(入内核空间的栈空间),因此一个系统支持的轻量级进程数量是有限的。
2、使用用户线程来实现:
UT:用户线程,此处指完全建立在用户空间的线程库上,系统内核不能感知线程的存在
用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助
进程与用户线程的关系为1:N
优点:可以不需切换到内核态,并且支持更大的线程数量
缺点:线程操作需要用户程序自己考虑。且阻塞这类问题很难解决
3、使用用户线程加轻量级进程混合实现
在此情况下,用户线程依然存在与用户空间中,用户线程的建立、同步、销毁和调度依然高效,并且线程的调度依赖于内核线程,大大降低了整个进程被完全阻塞的风险
用户线程与轻量级进程的关系为M:N
线程调度:
1、协同式调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程。协同式调度实现简单,并且不存在线程同步的问题。但是协同式调度存在存在一个很致命的问题,若一个线程出现问题,将不会通知系统切换线程,会一直阻塞在那里,导致系统崩溃
2、抢占式调度:每个线程由系统分配执行时间,线程的切换由系统决定,不会发生一个线程出现问题,导致整个进程阻塞的问题。线程调度是由系统自动完成,但也可通过设置线程优先级进行干预(线程优先级并不不总是靠谱的,因为Java线程最终是会映射到系统的原生线程上来实现的,所以线程调度取决于操作系统,Java线程的优先级也取决于操作系统线程的优先级)
线程状态转换:
线程安全
定义:当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的
按照安全程度由强至弱排序:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立
1、不可变:不可变的对象一定是线程安全的。无论是对象的方法或是方法的调用者,都不需要再采取任何的线程安全保障措施
在Java语言中,若共享数据是基本数据类型,使用final关键字修饰,就可保证其不可变;若共享数据是一个对象,需要将这个对象的状态都使用final修饰
2、绝对线程安全:完全做到线程安全的定义,这个限制过于苛刻,很难实现
对于一些线程安全的容器来讲,虽然容器的方法是线程安全的,但在使用时,也需保证在调用端同步
3、相对线程安全:这就是通常意义上的线程安全,它需要保证对这个对象单独的操作是线程安全的,在调用时,不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用管=端使用额外的同步手段来保证调用的正确性
4、线程兼容:对象本身并不是线程安全的,但可以通过在调用端正确地使用同步手段来保证对象在并发环境下可以安全的使用
5、线程对立:无论调用端是否使用了同步手段,都无法在多线程环境下并发使用
线程安全的实现方法:
互斥同步、非阻塞同步、无同步方案
1、互斥同步
:使用互斥,达到同步
两个互斥同步手段:synchronized关键字、java.util.concurrent包中的重入锁(ReentrantLock)
synchronized:表现为原生语法层面的互斥锁。synchronized关键字经过编译之后,会在同步块的前后形成monitorenter和monitorexit这两个字节码指令。monitorenter在同步块的开始位置,monitorexit在同步块的结束位置。当执行到monitorenter会去获取这个对象(若synchronized修饰的是实例方法,锁对象为实例对象;若修饰的是类方法,锁对象为Class对象)的锁,若获取到了,或者当前线程已经拥有了这个对象的锁,就将锁的计数器加1,执行到monitorexit减1,当计数器为0时,释放该锁;若未获取到,当前线程就要阻塞等待,直到对象锁被另一个线程所释放。
由上述可知,synchronized是表现为原生语法层面的锁,即依赖JVM来实现的,也就是说synchronized所导致的线程切换,最终都会映射到操作系统的原生线程上,这样会频繁的在用户态和核心态之间切换,耗费时间。对于简单的代码块而言,状态切换消耗的时间可能比用户代码执行的时间还要长,这也是synchronized为什么是Java语言中的一个重量级锁的原因。
synchronized同时也是一个可重入锁,对于同一条线程是可重入的,不是同一条线程,会阻塞。
更多关于synchronized的细节,附上大佬博客:https://blog.youkuaiyun.com/javazejian/article/details/72828483
ReentrantLock:表现为API层面的互斥锁。由lock()和unlock()配合try/finally语句块来实现,同样具有可重入性。相对于synchronized而言,ReentrantLock多了3项功能
等待可中i断:当前持有锁的线程长时间不释放锁的时候,正在等待的线程可以选择放弃等待
公平锁:多个线程在等待同一个锁的时,必须按照申请锁的时间顺序来依次获得锁,默认的为非公平锁
锁可以绑定多个条件:是指一个ReentrantLock对象可以绑定多个Condition对象
在JDK1.6之后,对synchronized进行了优化,所以在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步
2、非阻塞同步
在对非阻塞同步讲解之前,需要对悲观锁和乐观锁做个了解
悲观锁:认为每次操作只要不做同步处理,就会出现并发问题,所以每次操作都会加锁
乐观锁:认为每次操作不会出现并发问题,所以不会上锁,但会在更新的时候判断在此期间有没有别的线程更新了这个数据,可采用版本号机制和CAS算法
互斥同步就属于一种悲观的并发策略,而非阻塞同步则是一种基于冲突检测的乐观并发策略
基于冲突检测的乐观并发策略:先操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,那就采取其他的补偿措施(不断地重试,直到成功为止)
非阻塞同步的主要是思想操作和冲突检测,为防止出现并发问题,需保证操作和冲突检测具备原子性(硬件保证),即CAS(Compare and Swap)
CAS:
V:变量的内存地址
A:旧的预期值
B:新值
CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值。否则它就不执行更新;
但是无论是否更新了V的值,都会返回V的旧值,上述处理过程是个原子操作。
CAS操作存在的问题:
1、ABA问题:如果一个变量V在初次读取时是A值,在准备赋值时,检查到仍为A值,这个并不能保证在此期间它的值没有发生修改。因为如果这个变量值被改成了B值,随后又改成了A值,这时CAS操作就会认为这个变量值没有改变过。可使用版本号机制来避免这个问题
2、循环时间长开销大
3、只能保证一个共享变量的原子操作
3、无同步方案
1、可重入代码:如果一个方法,它的返回结果是可预测的,只要输入了相同的数据,就能返回相同的结果,那它就满足可重入的要求
2、线程本地存储:ThreadLocal
锁优化
互斥同步对性能最大的影响是阻塞的实现,因为线程的挂起和恢复都需要在用户态和内核态中切换。
自旋锁:为了避免线程频繁的挂起和恢复,可以让后面那个请求锁的线程执行一个忙循环,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
自旋锁在jdk1.4.2中引入,但默认是关闭的。在jdk1.6之后,就已经默认开启了。自旋锁并不能代替阻塞,自旋本带本身虽然避免了线程切换的开销,但也占用了处理器的时间。如果锁被占用的时间很短,自选等待的时间同样也会很短,这样的话,自旋锁的效果就很好。反之,如果锁被占用的时间很长,自选等待会占用处理器的大量资源,反而会对性能造成浪费。因此,自选等待的时间必须有一个限度,如果自旋超过了限定的次数,依然没有获得锁,那么就使用传统的方式去挂起线程。自旋次数的默认值是10,用户可以使用参数
-XX:PreBlockSpin来更改。
在jdk1.6之后,引入了自适应的自旋锁。自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如100个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能忽略掉自旋过程,以避免浪费处理器资源。
锁消除:是值虚拟机即时编译器在运行时,对一些代码要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问,那就可以把它们当做栈上数据对待,认为他们是线程私有的,同步加锁自然无须进行。
例如:在一个方法内部声明了一个StringBuffer类型的对象,该对象的所有方法都是同步的,但同时该对象又是一个属于栈上的对象,不会发生同步问题,所以编译器会在调用该方法的StringBuffer对象时,消除其同步手段。
锁粗化:原则上,在编写代码上,总是将同步块的作用范围限制的尽量小,只有在共享数据的实际作用域才进行同步。但存在一个情况,需要对该对象进行连续操作,如果同步作用范围太小,将导致反复的加锁和释放锁,例如一个循环内的共享数据,这样将会导致不必要的性能消耗。因此,虚拟机如果探测到有一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部。即扩大同步作用范围,避免反复加锁解锁。
轻量级锁:要理解轻量级锁,首先要对对象头的内存布局有个认识。
HotSpot虚拟机的对象头分为两部分,第一部分存储对象自身的运行时数据,如哈希码、GC分代年龄等,官方称之为 Mark Word,这是实现轻量级锁和偏向锁的关键;另一部分用于存储指向方法区对象类型数据的指针,如果是一个数组对象的话,还会有一个额外的部分用于存储数组的长度。
HotSpot虚拟机对象头Mark Word
轻量级锁的执行过程:在代码进入同步块的时候,如果此对象没有被锁定(锁标志位为“01”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(Displaced Mark Word)。然后虚拟机将使用CAS操作尝试将锁对象的Mark Word更新为指向Lock Record的指针。如果这个更新操作成功了,那么这个线程就拥有了这个对象的锁,并且对象Mark Word的锁标志位将转变为“00”,即表示该对象处于轻量级锁定的状态;如果这个更新操作失败了,虚拟机首先会检查这个对象的Mark Word是否指向当前线程的栈帧,若有值说明当前线程已经拥有了这个对象的锁,那么就可以直接进行同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁不再有效,要膨胀为重量级锁,对象的锁标志位也将转变为“10”。Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
偏向锁:即这个锁偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
偏向锁是在jdk1.6之后引入的一项锁优化,它的目的是在消除数据在无竞争情况下的同步原语。轻量级锁则是在无竞争(几乎无竞争,存在着多条线程争用同一个锁的情况)的情况下使用CAS操作去消除同步使用的互斥量,而偏向锁则是在无竞争的情况下消除整个同步过程。
偏向锁、轻量级锁和重量级锁的切换机制:在无竞争的情况下,会使用偏向锁;在轻度竞争的情况下,会使用轻量级锁;在重度竞争的情况下,会使用重量级锁。
虚拟机启用偏向锁的参数:-XX:+UseBiasedLocking。
偏向锁的执行过程:对锁对象第一次被线程获取的时候,虚拟机会先判断该对象的可偏向标志位(“1”代表可偏向,“0”代表不可偏向)是否为“1”,若为“1”,则会将对象头中的标志位设为“01”代表已偏向。同时将会使用CAS操作把获取到的这个线程的ID放到Mark Word中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
当有另一个线程尝试去获取这个锁的时候,偏向锁将进程撤销操作,根据是否处于被锁定的状态,将选择恢复到未锁定状态还是轻量级锁状态。
此文摘自于《深入理解Java虚拟机》,仅供个人学习总结之用