线程安全与锁优化

1.前言

  之前的文章记录了一下Java的内存模型和线程的关系,其实已经由内存模型谈到了线程安全的问题。本文将对线程安全进行具体的描述,对锁的实现进行探究,要明白锁的原理是什么,才能更好的利用锁,排查相关问题。

2.线程安全

  《Java Concurrency In Practice》作者Brian Goetz对线程安全有一个比较恰当的定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的。这个定义要求:代码本身封装了所有必要的正确性保障手段,比如互斥同步等,令调用者无须多关心线程的问题,无须自己采取任何措施来保证多线程的正确调用。

2.1 Java中的线程安全

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

2.1.1不可变

  在Java语言中,不可变对象一定是线程安全的,不需要采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来(没有发生this引用逃逸的情况),那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程中处于不一致的状态。

  Java语言中,如果共享的数据是一个基本数据类型,只需要加final关键字即可,如果是一个对象,要保证对象的行为不会对其状态产生任何影响。比如String,不管调用什么修改方法都会产生一个新的String类,而原本的String的值保持不变。保证对象行为不影响的自身状态的方法很多,最简单的就是将会发生改变的变量声明为final。

  Java中符合这个定义的类型,除了String,还有就是一部分Number的子类,但是原子类AtomicInteger和AtomicLong并发不可变的。这是为什么呢?个人的理解是避免反复创建对象,其内部实现通过volatile保证了可见性,另外通过CAS操作保证了更新动作的线程安全,不需要设计成不可变类型。其本身的含义就是可变的,需要不断自增。

2.1.2 绝对线程安全

  绝对线程安全需要完全满足Brian Goetz给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行环境如何,调用者都不需要任何额外的同步措施”,这个需要付出很大的代价。在Java API中标注自己是线程安全的类,大多数不是绝对线程安全。比如Vector类,这是一个线程安全的容器,其add、get、size方法都被synchronized修饰,尽管效率不高。这并不意味着这是安全的:

  假设一个线程在不断的add,多个线程不断的remove,for(int i = 0; i < vector.size(); i++) { vector.remove(i)}。这可能造成判断通过了size方法后,刚要Remove操作,另一个线程已经把这个值删除了,最终这个线程的删除操作抛出了异常。

  这个例子可以看出必须将size()和remove()操作同时锁住才行,单个操作的原子性,并不能保证实际使用时组合操作是线程安全的。

2.1.3 相对线程安全

  相对线程安全就是通常意义上的线程安全了,需要保证的是对这个对象单独的操作是线程安全的。这样对单个操作是不需要额外的保障措施,但是对于一些顺序的连续调用,可能需要同步手段来保证安全。2.1.2中举的例子就是这个证明。

2.1.4 线程兼容

  线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说的一个类不是线程安全的,大部分情况指的就是这种。Java API中大部分类都属于线程兼容,比如ArrayList,HashMap。

2.1.5 线程对立

  线程对立指的是无论调用端是否采取同步措施,都无法在多线程环境中并发使用的代码。Java是天生具备多线程特性,线程对立的代码很少出现,通常有害,尽量避免。

  一个对立的例子就是Thread类中的suspend()和resume()方法。如果一个尝试中断,一个尝试恢复,并发执行,无论是否同步,目标线程都是存在死锁的风险。suspend中断的线程就是将要执行resume的线程,肯定要产生死锁了。正是这个原因,suspend和resume方法被JDK废弃了。常见的线程对立的操作还有System.setIn()、System.setOut()和System.runFinalizersOnExit();

3.线程安全的实现方法

 3.1 互斥同步

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

  在Java中,最基本的互斥同步手段就是synchronized关键字,这个关键字在编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个指令都需要一个reference类型的参数。如果synchronized(object),指明了对象,那么就是这个对象了,如果是修饰的实例方法或者类方法,会使用对应的实例或者Class对象作为锁对象。根据规范要求,在执行monitorenter指令时,首先要尝试获取锁,如果对象没有被锁定,或者当前有线程已经拥有了锁,把锁的计数器加1,相对的monitorexit进行-1,当计数器为0,锁释放。如果获取锁失败,需要阻塞等待,直到对象锁被另一个线程释放。

  有2个地方要注意。一是synchronized同步块对同一个线程可以重入,不会出现自己把自己锁死的情况。另外,同步块在已进入的线程执行完毕之前,会阻塞后面其他线程进入。另一篇文章说过,线程是映射到操作系统的原生线程上的,如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到内核态中,因此状态转换需要耗费很多的处理器时间。对于代码块简单的同步块,比如synchronized修饰的get或set方法,状态转换的时间可能比之下用户代码的时间还要长。所以synchronized是一个重量级的操作。只有必要的情况下才会使用这种操作。虚拟机会进行一些优化,比如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁切换到内核态。

  除了synchronized,还可以使用java.util.concurrent包中的重入锁ReentrantLock来实现同步,用法上和synchronized很相似,但是代码上有些不同,一个是API上的互斥锁,lock和unlock方法需要配合try/finally语句实现,另一个是在原生语法层的互斥锁。不过ReentrantLock比synchronized多了几个功能:等待可中断、可实现公平锁、以及锁可以绑定多个条件。

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

  公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来一次获得锁,非公平锁不保证这一点,在锁释放时,任何一个等待锁的线程都有机会获得锁。synchronized是非公平的,ReentrantLock默认也是非公平的,需要设置。

  锁绑定多个条件指的是一个ReentrantLock对象可以同时绑定多个Condition对象。

  JDK5中,synchronized的性能在多线程下下降的很快,但是并不是说ReentrantLock性能就好。在JDK6中加入了很多针对锁的优化措施,两者的性能差距就没有那么夸张了。

3.2 非阻塞同步

  互斥同步主要的问题在于进行线程阻塞和唤醒所带来的性能问题,这种同步也就是阻塞同步。从处理问题上说,互斥同步是一种悲观的并发策略,总是认为只要不去做正确的同步措施,就会出问题。随着硬件指令集的发展,我们有了另一个选择:基于冲突检测的乐观并发策略。简单的说就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了,如果发生冲突,再采取其他措施。这种策略不需要把线程挂起,称为非阻塞同步。

  这个需要硬件指令集的发展原因在于需要操作和冲突检测这两个步骤具备原子性,如果使用互斥同步保证,那么就没有意义了,所以需要硬件支持。硬件需要保证看起来需要多条指令的操作,只需要一条指令就能完成,如:

    测试并设置(Test-and-Set)、

    获取并增加(Fetch-and-Increment)、

    交换(swap)、

    比较交换(Compare-and-Swap,CAS)、

    加载链接/条件存储(Load-Linked/Store-Conditional,LL/SC)。

  前面3个是在20世纪就存在大多数指令集之中了,后面2个是现代处理器新增的。IA64、x86指令集中有cmpxchg指令完成CAS功能,sparc-TSO也有casa指令实现,在ARM和PowerPC架构下,则需要使用一对Idrex/strex指令来完成LL/SC功能。

  CAS指令需要3个操作数,分别是内存位置,旧的预期值和新值,如果旧的预期值符合预期,就会替换成新值,不管是否成功,都会返回旧值。

  JDK5中提供了CAS操作,在sun.misc.Unsafe类中的几个方法报提供,虚拟机对其内部做了特殊处理,即时编译出来的结果就是一条平台相关的CAS指令,没有方法调用过程,或者可以认为是无条件内联进去了。Unsafe类不是提供给用户程序调用的,代码中限制了只有启动类加载器加载的Class才能访问它。所以不采用反射手段,只能通过API间接使用。

  CAS看起来很好,但是还是有些问题无法解决,比如ABA问题。如果一个线程读取到A,下次又读取的是A,能说这个字段没有改变过吗?肯定不行,就像刚刚说的ABA,另一个线程改成B后又改回来了,就无法判断了。解决方法就是添加一个版本号,不过这样做使用传统的互斥同步可能更高效。

3.3 无同步方案

  要保证线程安全,不一定需要进行同步。同步只是保证共享数据在竞争时正确的手段,如果一个方法不涉及共享数据,就不需要同步了。有一些代码是天生线程安全的,下面介绍其中两类。

  可重入代码:这种代码也叫纯代码,在代码执行的任何时刻中断它,转去执行另外一段代码(包括递归调用本身),而在控制权返回后,原程序不会出现错误。相对于线程安全,可重入性是更基本的特性,可以保证线程安全。可重入代码有一些共同的特征:例如不依赖存储在堆上的数据和公用的资源系统、用到的状态量都是由参数传入,不调用非可重入方法等。简单判断是否可重入的方法是:有一个方法,其返回结果是可以预测的,只要输入了相同的数据,就能返回相同的结果,那它就满足可重入性的要求,当然就是线程安全的。

  线程本地存储:如果一段代码的数据必须与其他代码共享,就要看这些共享数据的代码是否在同一个线程中执行?如果可以保证,那么共享数据在一个线程中使用,自然是线程安全的。比如web服务的,一个请求对应一个线程。Java中还可以通过ThreadLocalMap对象实现数据绑定线程,只有这个线程可以访问这个数据。

4.锁优化

  之前说了JDK5的synchronized的性能与Reentrant相比差距明显,但是在JDK6中就不会太糟糕了,这就是实现了各种锁优化技术。比如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。这些名词常常听到,但是不甚了解,下面对其进行介绍。

4.1 自旋锁与自适应自旋

  前面提到互斥同步最大的问题在于阻塞的实现,挂起和恢复线程的操作需要转入内核态中完成,这给操作系统的并发带来了很大压力。同时,开发团队注意到很多应用上,共享数据的锁定状态只会持续很短一段时间,为了这段时间去挂起和恢复线程不值得。所以我们可以让线程稍微等一下,而不放弃处理器的执行时间,看看持有锁的线程是否很快就释放了锁。这样的实现就是让线程执行一个循环(自旋),这就是自旋锁了。

  自旋锁在JDK1.4中就有了,不过需要使用-XX:+UseSpinning参数开启,JDK6则是默认开启的。自旋等待不能代替阻塞,自旋本身虽然避免了线程切换的开销,但是其需要占用处理器的时间,白白浪费资源。所以自旋的等待时间上需要限制,如果超过了限定的次数就需要使用传统的方式去挂起线程了。自旋的次数默认值是10次,可以通过-XX:PreBlockSpin来更改。

  JDK6中引入了自适应的自旋锁,意味着自旋的时间不再固定,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋可能再次成功,等待的时间持续更长,比如100个循环。如果自旋成功次数很少,那么之后可能会省略掉自旋的步骤,直接阻塞,避免处理器资源浪费。

4.2 锁消除

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

  这里有个问题,开发人员应该很清楚变量是否逃逸,对不会逃逸的变量,怎么会去加同步措施呢?答案是很多同步措施不是程序员添加的,而且同步代码在Java中普遍程度超过大部分人的想象。比如s1+s2+s3三个字符串相加,我们都知道String是一个不可能变的量,JDK5之前,会转换成StringBuffer进行连续的append操作。JDK5之后会转化成StringBuilder的append操作。对于StringBuffer而言,每次append都会上锁,但是这个对象并没有发生逃逸,所以这个锁会被消除掉。

4.3 锁粗化

  原则上,我们编写同步代码时候,推荐同步块的作用范围限制得尽量小,这样占用锁的可能性就小了。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中,那么没有线程竞争频繁的同步会导致不必要的性能损耗。比如之前说的StringBuffer的连续append方法,锁粗化就可以在第一个append操作之前加锁,在最后一次append解锁。

4.4 轻量级锁

  JDK6添加了新的锁机制,轻量级是相对于使用操作系统互斥量实现的传统锁而言。轻量级锁不是用来代替重量级锁的,本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

  要理解轻量级锁,以及后面的偏向锁的原理,首先要明白虚拟机对象的内存布局——主要是对象头Object Header部分的内容。对象头分为两部分信息,第一部分用于存储对象自身的运行时数据,比如hashcode,GC年龄等,这串数据被称为Mark Word,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象Class数据结构的指针,如果是数组对象,还有一个额外的部分用户存储数组长度。

  Mark Word被设计成了一个非固定的数据结构,会根据对象的状态复用自己的存储空间。比如:32位的虚拟机对象中未锁定的状态下,Mark Word的32bit空间中25位用于存放hashCode,4位存储分代年龄,2位用于存储锁标志位,1位固定为0。锁标志的状态含义和具体值如下:

  进入同步块的时候,如果同步对象没有被锁定(01状态),虚拟机先在当前线程的栈帧中创建一个名为锁记录的空间,用于存储锁对象当前的Mark Word的拷贝,称为Displaced Mark Word。之后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果更新成功了,这个线程就拥有了对象的锁,并且对象的Mark Word的锁标志位00,表明对象锁处于轻量级锁定状态。具体步骤如下图:

  如果这个更新失败了,就会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明本线程已经获取了对象的锁,可以直接进入同步代码块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两个以上的线程争用一个锁,轻量级锁不再有效,会膨胀成重量级锁,锁标志状态变成10,Mark Word存储的就是执行重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

  解锁过程同样要将当前的Mark Word替换回来,替换成功就同步完成,失败就说明有线程尝试过获取该锁,在释放的同时唤醒被挂起的线程。

  轻量级锁能提升程序同步性能的依据是“对于大部分锁,在整个同步周期内都是不存在竞争的”这个经验数据。如果没有竞争,轻量级锁使用了CAS操作,避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,所以竞争情况下,轻量级锁会比重量级锁还要慢。

4.5 偏向锁

  这个也是JDK6引入的一项锁优化,目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。

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

  偏向锁的意思就是这个锁会偏向于第一个获取它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程永远不需要同步。JDK6中使用-XX:+UseBiasedLocking启用偏向锁,这个是默认值。那么当锁对象第一次被线程获取的时候,虚拟机就会把对象头中的标志设为01,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中,成功,每次进入该锁的同步块时,不需要进行任何同步操作(Locking、Unlocking及对Mark Word的Update)。

  当另外一个线程尝试获取这个锁时,偏向模式结束。根据对象目前是否处于被锁定的状态,撤销偏向恢复到未锁定或轻量级锁定的状态,后面的同步操作和轻量级锁那样执行。

 

  偏向锁可以提高带有同步但无竞争的程序性能,不一定总对程序运行有利,如果程序中大多数的锁总是被多个线程访问,偏向模式就是多余的,禁用可能会提升性能。

posted @ 2018-07-23 22:22  dark_saber  阅读(375)  评论(1编辑  收藏  举报