线程安全与锁优化(JVM)

来源:互联网 发布:永利国际中心 商业数据 编辑:程序博客网 时间:2024/06/05 05:33

指令重排序:

为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order-Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码的顺序一致,因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。

并不是说指令任意重排,CPU需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。


volatile

Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量,Java语言提供了volatile。

关键字volatile是Java虚拟机提供的最轻量级的同步机制。

voliatile具备两种特性

保证此变量对所有线程的可见性

这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。普通变量与volatile的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。可以说volatile保证了多线程操作时变量的可见性。

synchronized和final两个关键字也能实现可见性。


“volatile”变量对所有线程是立即可见的,对volatile变量所有的写操作都能立即反应到其他线程之中

volatile变量在各个线程的工作内存中不存在一致性问题,在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题。

volatile变量的运算在并发下是不安全的,因为Java里面的运算并非原子操作。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(synchronized和java.util.concurrent中的原子类)来保证原子性:

1、运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值;

2、变量不需要与其他的状态变量共同参与不变约束。

禁止指令重排序优化

普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。


volatile是轻量级的synchronized。

why

它比synchronized的使用和执行成本会更低,因为它不会引起线程上下文的切换和调度。

线程安全

如果一个对象可以安全地被多个线程同时使用,那它就是线程安全的。

Java语言中各种操作共享的数据

不可变

不可变的对象一定是线程安全的。比如final修饰、String类、枚举类等

绝对线程安全

在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。

相对线程安全

相对的线程安全就是我们通常意义上所讲的线程安全

线程兼容

对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境下可以安全适用。

线程对立

无论调用端是否采取了同步措施,都无法再多线程环境中并发使用的代码。


线程安全的实现方法

互斥同步、阻塞同步

互斥同步是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。

最基本的互斥同步手段就是synchronized关键字

synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit两个字节码指令,在执行monitorenter指令时,首先尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,在执行monitorexit指令时,会将锁计数器减1,当计数器为0时,锁就被释放。

synchroized同步块对同一条线程来说是可重入的。同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到和心态,因此状态转换需要耗费很多的处理器时间。,对于简单的同步块,状态转换消耗的时间有可能比用户代码执行的时间还要长,所以synchronized是Java语言中一个重量级操作

重入锁(ReentrantLock,java.util.concurrent包下)

增加了3项高级功能

等待可中断

当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。

可实现公平锁

公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁,而非公平锁不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁

锁可绑定多个条件

一个ReentrantLock对象可以同时绑定多个Conditional对象

非阻塞同步

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁。

基于冲突检测的乐观并发策略,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施(最常用的就是不断地重试,直到成功),这种乐观的并发策略的许多实现都不需要把线程挂起。

需要指令硬件集。比如

CAS指令(比较并交换 Compare-and-Swap)

比较是否和给定的数值一致,如果一致则修改,不一致则不修改。

CAS指令需要有3个操作数,分别是内存位置(V)、旧的预期值(A)和新值(B)。在CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。

无同步方案

如果一个方法不涉及共享数据,那么它自然就无须任何同步措施区保证正确性,因此会有一些代码天生就是线程安全的:

可重入代码(Reentrant Code)

也叫纯代码(Pure Code),可以在代码执行的任何时刻去中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何问题。所有可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。

可重入代码有一些共同的特征:例如不依赖存储在堆上的数据和共用的系统资源,用到的状态量都由参数中传入、不调用非可重入的方法等。

判断可重入:如果一个方法,它的返回结果是可预测的,只要是输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也是线程安全的。

线程本地存储(Thread Local Storage)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。


锁优化

如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题。

自旋锁和自适应旋

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,对系统的并发性能带来了很大的压力。

如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的消耗。因此自旋等待的时间必须有一定的限度。


自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略到自旋过程,以避免浪费处理器资源。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,会把加锁同步的范围扩展到整个操作序列的外部,只加锁一次就可以了。


锁的升级

为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

锁一共有四种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁和重量级锁。

偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。

如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。

整个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

why

Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程

偏向锁的关闭

偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。


轻量级锁

“轻量级”相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就称为“重量级”锁。

轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

加锁和解锁过程都是通过CAS操作来进行的。

轻量级锁加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁解锁

轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

对于绝大部分锁,在整个同步周期内都是不存竞争的。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。


锁的优缺点

优点

缺点

适用场景

偏向锁

加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。

如果线程间存在锁竞争,会带来额外的锁撤销的消耗。

适用于只有一个线程访问同步块场景。

轻量级锁

竞争的线程不会阻塞,提高了程序的响应速度。

如果始终得不到锁竞争的线程使用自旋会消耗CPU。

追求响应时间。

同步块执行速度非常快。

重量级锁

线程竞争不使用自旋,不会消耗CPU。

线程阻塞,响应时间缓慢。

追求吞吐量。

同步块执行速度较长。



原创粉丝点击