实战java虚拟机(三)——锁与并发

锁是多线程开发的必要工具之一,它的基本作用就是保护临界区资源不被多个线程同时访问而受到破坏,通过锁可以让多个线程排队一个个进入临界区访问,使得目标状态保持一致且可以受到稳定的控制。

如何使用更高的效率处理多线程竞争,是Java虚拟机的一项使命,如果将所有竞争都直接交给操作系统处理,那么效率会十分低下,因此虚拟机在操作系统层面挂起线程之前,会尽一切可能在虚拟机层面解决竞争关系,尽量避免真实的竞争发生。本文中将介绍一些方法,包括偏向锁,轻量级锁,自旋锁,锁消除,锁膨胀。

一、对象头和锁


在了解锁之前,需要先知道MarkWord的基本概念,Java虚拟机的实现中每个对象都有一个对象头,用于保存对象的系统信息,对象头有个Mark Word的部分,它是实现锁的关键。32位系统中,MarkWord占用32位,64位系统中为64位,存放对象的hash值,年龄,锁指针等信息。以32位系统为例,普通对象的对象头如下:

 

hash(25)|  age(4) |  biased_lock(1) | lock2

25位为hash值,4位年龄,1位偏向锁标识位(1表示是偏向锁) ,2位锁信息。

最后两位中,01表示可偏向/未锁定,00表示轻量级锁定,10表示重量级锁定

 

二、偏向锁


偏向锁是JDK1.6提出来的锁优化方式,其核心思想是,如果锁没有竞争,则取消已经取得锁的线程同步操作。也就是说,某一锁被线程获取后,就会进入偏向模式,当线程再次请求该锁的时候,无需再进行同步相关的操作,从而节省操作时间。如果在此期间有其他线程进行了锁请求,则锁退出偏向模式。JVM中使用-XX:+UseBiasedLocking可以设置启用偏向锁、

在竞争少的情况下,偏向锁对性能有一定帮助,但是锁竞争激烈时就没有了太强的优化效果,反而有可能降低系统性能,可以尝试使用-XX:-UseBiasedLocking禁用偏向锁

 

三、轻量级锁


如果偏向锁失败,Java虚拟机会让线程申请轻量级锁,轻量级锁在虚拟机内部使用一个BasicObjectLock的对象实现,这个对象内部由一个BasicLock对象和一个持有该锁的Java对象指针组成。BasicObjectLock对象放在java栈的栈帧当中,在BasicLock对象内部还维护着displaced_header字段,用于备份对象头部的Mark Word.

当一个线程持有一个对象的锁时,对象头部的Mark Word如下

 

[ptr          |00]locked

末尾两位为00,整个Mark Word为指向BasicLock对象的指针,由于BasicObjectLock对象在线程栈中,该指针必然指向持有该锁的线程栈空间。当需要判断某一线程是否持有该对象锁时,也只需简单判断对象头指针是否在当前线程栈地址范围内。同时,BasicLock对象的displaced_header字段备份了原对象的Mark Word内容,BasicObjectLock对象的obj字段则指向该对象。

虚拟机实现中,轻量级锁的核心代码如下

 

markOop mark = obj->mark();

lock->set_displaced_header(mark);

if(mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark )){

TEVENT (slow_enter: release stacklock);

return;

}

首先,BasicLock通过set_displaced_header()备份原对象的Mark Word。接着使用CAS操作,尝试将BasicLock的地址复制到对象头的Mark Word。如果复制成功则加锁成功,否则加锁失败,轻量级锁可能会膨胀为重量级锁。

 

、锁膨胀


当轻量级锁失败,虚拟机就会使用重量级锁,此时对象Mark Word如下

[ptr          |10]locked

末尾两位为10,整个Mark Word为指向monitor对象的指针,在轻量级锁处理失败后,虚拟机会执行以下操作

 

lock->set_displaced_header(markOopDesc::unused_mark);

ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD);

第一步是废弃前面BasicLock备份的对象头信息,第二步启用重量级锁,启用过程分为两步:首先通过inflate()方法膨胀,获得对象的ObjectMonitor;然后使用enter()方法尝试进入该锁。

在调用enter()方法时,线程很有可能在操作系统层面被挂起,此时线程间切换和调度的成本就会比较高。

 

自旋


上文说到,锁膨胀后,进入ObjectMonitorenter()方法,线程很有可能在操作系统层面被挂起,这样线程上下文切换的性能损失就比较大,在锁膨胀之后,虚拟机会最后争取,希望线程可以尽快进入临界区而避免被操作系统挂起。一种有效的手段就是自旋锁。

自旋锁可以使线程在没有取得锁时不被挂起,执行一个空循环,在若干个空循环后,如果可以获得锁,则继续执行,否则线程才会被挂起。

使用自旋锁后,线程被挂起的概率相对减小,线程执行连贯性加强。因此对于锁竞争不是很激烈,占用时间短的并发线程具有积极意义,但对于竞争激烈,单线程占用锁时间长的并发程序,往往自旋后依然无法获得锁,浪费了CPU时间和系统资源。

JDK1.6中可通过-XX:+UseSpinning开启自旋锁,-XX:PreBlockSpin 设置锁的自旋次数

JDK1.7之后无法配置自旋锁,它总是开启,并且自旋次数由虚拟机自行调整

 

、锁消除


锁消除是Java虚拟机在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省毫无意义的请求锁时间。

该功能主要用于在开发过程中,开发人员使用一些JDK内置API,比如StringBufferVector等,这类工具类的内部同步方法是没有必要的,虚拟机在运行时,基于逃逸分析技术,捕获这些不可能存在竞争却申请了锁的代码段,消除这些不必要的锁,从而提高性能。

逃逸分析和锁消除分别使用参数(锁消除必须在-server模式下)

-XX:-DoEscapeAnalysis-XX:-EliminateLocks

 

、锁在应用层的优化思路


1.减少锁的持有时间。线程持有锁的时间越长,竞争就越激烈,在开发过程中应尽量减少锁的占用时间。

2.减小锁粒度,典型使用场景就是ConcurrentHashMap类的实现。对一个普通集合对象的多线程同步来说,最常用的方式就是对get()add()进行同步。每当集合进行这两个操作时,总能获得集合对象的锁。ConcurrentHashMap使用拆分锁对象的方式提高吞吐量,它将整个HashMap拆分成若干段(Segment),每一个字段都是一个HashMap。默认情况下,ConcurrentHashMap16个段,最好的情况下ConcurrentHashMap可以接受16个线程同时插入,从而大大提高吞吐量。但是减小粒度也会引发一个新的问题,当系统需要取得全局锁时,消耗的资源会比较多,以ConcurrentHashMap为例,当执行size()方法时,需要同时取得所有段的锁,才能获取有效表项的数量。

3.锁分离,最典型的操作就是读写分离,读写分别加锁。

4.锁粗化,通常情况下为了保证有效并发都要求持有锁的时间尽量短,但是如果对同一个锁不停请求和释放,本身也会消耗资源,反而不利于性能优化。为此,虚拟机在遇到一连串连续对同一个锁的请求和释放操作时,会把所有的锁操作整合成一个请求,从而减少对锁操作次数。开发中也应该有意识在合理场合中进行锁粗化。

5.无锁(CAS)高并发时,激烈竞争可能会成为系统瓶颈,使用锁不可能避免相互等待,为了避免这个问题,非阻塞同步的方式就被提出。最简单的实现就是ThreadLocal.CAS算法是一种更好的无锁并发控制算法,它的过程是这样的:它包含三个参数,形式为CASV,E,N),V表示要更新的变量,E表示预期值,N表示新值,仅当V=E时才会将V的值设为N,如果VE的值不同说明有其他线程做了更新,则当前线程什么都不做。最后,CAS返回当前V的真实值。CAS操作时抱着乐观的态度进行的,它总认为自己可以成功完成操作,当多个线程同时使用CAS操作一个变量时,只有一个会胜出并更新,失败的线程不会挂起,仅被告知失败,并且允许再次尝试,也可以放弃操作。在硬件层面,大部分现代处理器已经支持原子化的CAS算法。JDK1.5之后,虚拟机便可以使用这个指令来实现并发操作和并发数据结构。JDKjava.util.concurrent.atomic包下,有一组使用无锁算法实现的原子操作类,主要有AtomicInteger, AtoIntegerArray,AtomicLong, AtomicLongArray, AtoReference, 分别封装了整数,整数数组,长整型,长整型数组和普通对象的多线程安全操作。

 

猜你喜欢

转载自www.cnblogs.com/gtblog/p/11635549.html
今日推荐