Java线程安全与锁优化

Java线程安全与锁优化

一、Java语言中的线程安全

按照线程安全的“安全程度”由强至弱来排序,我们可以将Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立

  • 不可变:Java语言中,不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。
  • 绝对线程安全:其含义是不管运行时环境如何,调用者都不需要任何额外的同步措施。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
  • 相对线程安全:相对线程安全是我们通常意义上所讲的线程安全,它需要保证这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
  • 线程兼容:线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中可以安全的使用,我们平常说一个类不是线程安全的,通常就是指这种情况。
  • 线程对立:线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。

二、线程安全的实现方法

1、互斥同步

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

量和信号量都是常见的互斥实现方式。互斥是因,同步是果;互斥是方法,同步是目的。

在Java里面最基本的互斥同步手段就是synchronized关键字,它在Javac编译后,会在同步块的前后分别形成monitorenter和monitorexit俩个字节码指令。这俩个字节码指令都需要一个reference类型的参数来指定要锁定和解锁的对象。

被synchronized修饰的同步块对同一个线程来说是可重入的,这意味着同一个线程反复进入同步块也不会出现自己把自己锁死的情况。
被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件的阻塞后面其他线程的进入。

重入锁(ReentrantLock)是Lock接口最常用的一种实现,它相比synchronized增加了一些高级功能:

  • 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序依次获得锁,ReentrantLock默认非公平,可以设置为使用公平锁,同时会影响性能和吞吐量。
  • 锁绑定多个条件:ReentrantLock对象可以同时绑定多个condition对象,在synchronized中,锁对象的wait跟它的notify或者notifyall方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁。

2、非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒锁带来的性能开销,因为加锁,这将会导致用户态到核心态的切换,维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。
随着指令集的发展(例如:原子操作级别的cas),我们已经有了另外一个选择:基于冲突检测的乐观并发策略;通俗来说,就是不管分险,先进行操作,如果没有其他线程争用共享数据,那操作直接成功,如果共享的数据的确被争用,产生了冲突,那么在进行其他的补救措施,最常用的补偿措施就是不断的重试,直到出现没有竞争的共享数据为止。

3、无同步方案

有些代码天生就是线程安全的,例如:

  • 可重入代码:一个简单的原则来看,如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那么它就满足可重入性的要求。
  • 线程本地存储:例如threadlocal对象。

三、锁优化

1、自旋锁与自适应自旋

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态去完成,这些操作给Java虚拟机的并发性能带来了很大的压力。同时共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。

现在大多数的个人电脑和服务器都是多路处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让俩个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就能释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋等待本身虽然避免了线程切换的开销,但是它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那自旋的线程只会白白消耗处理器资源,这就会带来性能的浪费。可以通过-xx:PreBolckSoin来设置自旋次数。默认是10次。

jdk6以后引入了自适应的自旋,自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上自旋时间及锁的拥有者的状态来决定。

2、锁清除

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

3、锁粗化

原则上,我们需要将同步块的作用范围限制的尽量小--只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数据尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快的拿到锁。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁是出现在循环体内,那么即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。例如stringbuffer多次连续的append就属于这列情况。如果虚拟机探测到由这样一串病零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部。

4、轻量级锁

轻量级锁是jdk6加入的新型锁机制,“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能消耗。

轻量锁会使用到对象头的数据,在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为01状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock record)的空间,用于存储对象目前的mark word的拷贝,然后虚拟机将使用cas操作尝试把对象的mark word更新为指向Lock record的指针。如果这个更新动作成功了,代表该线程拥有了这个对象的锁,并且对象mark word的锁标志位将转变为00,表示此对象处于轻量级锁状态。

 

如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的mark word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了对这个对象的锁,那么直接进入同步块继续执行就行了,否则就说明这个锁对象已经被其他线程抢占了。如果出现俩条以上的线程争用同一个锁的情况,那么轻量级锁就不再有效了,必须膨胀为重量级锁,锁标志变为10.

 

上述是轻量级锁加锁的过程,他的解锁过程同样也是通过cas操作来进行的,如果对象的mark word仍然指向线程的锁记录,那就用cas操作把对象当前的mark word的线程中复制的displaced mark word替换回来。假如能够成功替换,那么整个同步过程就顺利完成了,如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过cas操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了cas操作的开销。因此有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

5、偏向锁

偏向锁也是jdk6引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用cas操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连cas操作都不去做了。这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

当虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为01、把偏向模式设置为1,表示进入偏向模式。同时使用cas操作把获取到这个锁的线程的id记录在对象的mark word中。如果cas操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。如果出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为0),撤销后标志位恢复到未锁定或者轻量级锁定的状态,后续的同步操作就是按照上面介绍的轻量级锁那样去执行。

 

 用于存储对象哈希吗的位置被用于存储持有锁的线程ID了,在Java语言里面一个对象如果计算过哈希吗,就应该一直保持该值不变,否则很多依赖对象哈希码的API都可能存在出错的风险,这个值能强制保证不变,它通过在对象头中存储计算结果来保证第一次计算后,再次调用该方法取到的哈希码永远不会再发生改变。因此当一个对象已经计算过一致性哈希码之后,它就再也无法进入偏向锁状态了,而当一个对象当前处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。

偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。

posted @ 2022-01-22 15:35  刘姥爷观园子  阅读(71)  评论(0编辑  收藏  举报