首页 > 编程语言 > 详细

【深入理解JAVA虚拟机】第5部分.高效并发.2.线程安全和锁优化

时间:2019-02-13 19:13:34      阅读:138      评论:0      收藏:0      [点我收藏+]

1 概述

对于这部分的主题“高效并发”来讲,首先需要保证并发的正确性,然后在此基础上实现高效。

2 线程安全

《Java Concurrency In Practice》 的作者Brian Goetz对“线程安全”有一个比较恰当的定义:“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的”。

这个定义比较严谨,它要求线程安全的代码都必须具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。

2.1 Java语言中的线程安全

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

1.不可变

基本数据类型:定义时使用final关键字修饰它就可以保证它是不可变的。

对象:把对象中带有状态的变量都声明为final。

符合不可变要求的类型:String,枚举,java.lang.Number的部分子类,Long和Double等数值包装类型,BigInteger和BigDecimal等大数据类型。但同为Number的子类型的原子类AtomicInteger和AtomicLong则并非不可变的。

2.绝对线程安全
3.相对线程安全

绝对的线程安全完全满足Brian Goetz给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的,甚至有时候是不切实际的代价。

相对的线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

我们常说的线程安全类型就是相对线程安全的,如Vector、 HashTable、Collections的synchronizedCollection()方法包装的集合等。

尽管这里使用到的Vector的get()、 remove()和size()方法都是同步的,但对vector遍历仍然是不安全的。

4.线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。

如:ArrayList和HashMap

5.线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。 由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

2.2 线程安全的实现方法

1.互斥同步(Mutual Exclusion&Synchronization)/阻塞同步(Blocking Synchronization)

同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。 而互斥是实现同步的一种手段,临界区(CriticalSection)、 互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。 因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题.

在Java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。 如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。

Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块(如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。 所以synchronized是Java语言中一个重量级(Heavyweight)的操作,有经验的程序员都会在确实必要的情况下才使用这种操作。 而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中。

相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:等待可中断、 可实现公平锁,以及锁可以绑定多个条件。

虚拟机在未来的性能改进中肯定也会更加偏向于原生的synchronized,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。

2.非阻塞同步

随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。

因为我们需要操作和冲突检测这两个步骤具备原子性,只能靠硬件来完成这件事情,这类指令常用的有:
测试并设置(Test-and-Set)。
获取并增加(Fetch-and-Increment)。
交换(Swap)。
比较并交换(Compare-and-Swap,下文称CAS)。
加载链接/条件存储(Load-Linked/Store-Conditional,下文称LL/SC)。

CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供. 由于Unsafe类不是提供给用户程序调用的类(Unsafe.getUnsafe()的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的Class才能访问它),因此,如果不采用反射手段,我们只能通过其他的Java API来间接使用它,如J.U.C包里面的整数原子类,其中的 compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作。

incrementAndGet()方法的JDK源码: for(;){ int current=get(); int next=current+1; if(compareAndSet(current,next)) return next; }}

这个漏洞称为CAS操作的“ABA”问题。 J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。

3.无同步方案

可重入代码(Reentrant Code):这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、 用到的状态量都由参数中传入、 不调用非可重入的方法等。 我们可以通过一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

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

其中最重要的一个应用实例就是经典Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,java.lang.ThreadLocal类.

3 锁优化

3.1 自旋锁与自适应自旋

前面我们讨论互斥同步的时候,提到了互斥同步对性能最大的影响是阻塞的实现,挂起 线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的 压力。 同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短 的一段时间,为了这段时间去挂起和恢复线程并不值得。 如果物理机器有一个以上的处理 器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一 下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。 为了让线程等 待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。 自旋锁在JDK 1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning 参数来开启,在JDK 1.6中就已经改为默认开启了。 自旋等待不能代替阻塞,且先不说对处 理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的, 因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间 很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性 能上的浪费。 因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然 没有成功获得锁,就应当使用传统的方式去挂起线程了。 自旋次数的默认值是10次,用户可 以使用参数-XX:PreBlockSpin来更改。 在JDK 1.6中引入了自适应的自旋锁。 自适应意味着自旋的时间不再固定了,而是由前 一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。 如果在同一个锁对象上,自旋等 待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有 可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。 另外,如果 对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避 免浪费处理器资源。 有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对 程序锁的状况预测就会越来越准确,虚拟机就会变得越来越“聪明”了

3.2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能 存在共享数据竞争的锁进行消除。

答案是 有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也许超过了 大部分读者的想象。

StringBuffer

3.3 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在 共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如 果存在锁竞争,那等待锁的线程也能尽快拿到锁。 大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反 复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥 同步操作也会导致不必要的性能损耗。

3.4 轻量级锁

它名字中的“轻量级”是相对于使用操作系统 互斥量来实现的传统锁而言的,因此传统的锁机制就称为“重量级”锁。 首先需要强调一点的 是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传 统的重量级锁使用操作系统互斥量产生的性能消耗。

简单地介绍了对象的内存布局后,我们把话题返回到轻量级锁的执行过程上。 在代码进 入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当 前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线 程堆栈与对象头的状态如图13-3所示。 然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标 志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线 程堆栈与对象头的状态如图13-4所示

如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈 帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否 则说明这个锁对象已经被其他线程抢占了。 如果有两条以上的线程争用同一个锁,那轻量级 锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指 向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 上面描述的是轻量级锁的加锁过程,它的解锁过程也是通过CAS操作来进行的,如果对 象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中 复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。 如果替换失 败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。 轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不 存在竞争的”,这是一个经验数据。 如果没有竞争,轻量级锁使用CAS操作避免了使用互斥 量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞 争的情况下,轻量级锁会比传统的重量级锁更慢

3.5 偏向锁

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

当锁对象第一次被线程获取的时候, 虚拟机将会把对象头中的标志位设为“01”,即偏向模式。 同时使用CAS操作把获取到这个锁 的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次 进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、 Unlocking 及对Mark Word的Update等)

偏向锁可以提高带有同步但无竞争的程序性能。

【深入理解JAVA虚拟机】第5部分.高效并发.2.线程安全和锁优化

原文:https://www.cnblogs.com/aoyihuashao/p/10371416.html

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!