1.概述
软件业发展初期,程序编写都是以算法为核心,程序员会把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据,这种思维方式直接站在计算机的角度区抽象问题和解决问题,称为面向过程的编程思想。
面向过程的编程思想极大提升了现代软件开放的生产效率和软件可以达到的规模,但现实世界与计算机之间不可能避免存在一些差异。例如,很难想象现实中的对象在一项工作进行期间,被不停的中断和切换,对象的数据可能在中断期间被修改。
对于高效并发来说,首先需要保证并发的正确性,在此基础上实现高效。
2.线程安全
《Java Concurrency In Practice》做的作者"Brian Goetz"对"线程安全"有一个比较恰当的定义:"当多个线程访问一个对象时,如果不考虑这些线程在运行环境下的调度和交替执行,也不需要进行额外的同步,或在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的"。
2.1Java中的线程安全
按照线程安全的"安全程度"由强至弱来排序,我们可以将Java中各个操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立。
1.不可变
Java中,不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不用再采取任何线程安全保障措施。
Java中,如果共享数据是基本数据类型,那么只要再定义时使用final修饰就可以保证它是不可变的。如果共享数据是一个对象,就需要保证对象的行为不会对其状态产生任何影响才行。保证行为不影响自己状态的方法有很多种,最简单的就是把对象中带有状态的变量都声明为final。
2.绝对线程安全
绝对线程安全满足Brian Goetz给出的线程安全定义"不管运行时环境如何,调用者都不需要任何额外的同步措施"通常要很大的代价。Java API中标注自己是线程安全的类,大多数不是绝对的线程安全。
下面看一个测试:
运行结果是:
很明显,即使它的方法都是同步的,但多线程环境下,不再方法调用时做额外的同步措施的话,使用这段代码仍是不安全的。如果要保证这段代码正确执行,需要修改为以下:
3.相对线程安全
相对线程安全就是通常意义上的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但对于一些特定顺序但连续调用,可能需要在调用时使用额外的同步手段保证正确性。
4.线程兼容
指对象本身并不是线程安全的,但是可以通过在调用时正确的使用同步手段来保证对象在并发环境下可以安全的使用,我们平常说一个类不是线程安全的,绝大多数指的是这种情况。
5.线程对立
指无论在调用时是否采取了同步措施,都无法在多线程环境中并发使用的代码。
2.2线程安全的实现方法
接下来了解一下代码编写如何实现线程安全和虚拟机如何实现同步与锁机制。
1.互斥同步
常见的并发正确性保障手段。同步指在多个线程并发访问数据时,保证共享数据在同一个时刻只被一个线程使用。互斥时实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此,互斥是方法,同步是目的。
Java中最基本的互斥同步手段是synchronized,synchronized经过编译后,会在同步块前后分别形成monitorenter和monitorexit两个字节码指令,这两个字节码都需要一个reference类型的参数指明要锁定和解锁的对象。
根据规范的要求,在monitorenter执行时,首先尝试获取对象的锁。如果这个对象没被锁定,或当前线程已经拥有了那个对象的锁,把锁的计数器加1;执行monitorexit指令将锁计数器减1,计数器为0时,锁被释放。如果获取对象的锁失败,当前线程被阻塞等待,直到对象锁被另外一个线程释放为止。
除了synchronized之外,我们还可以使用java.util.concurrent中的重入锁(ReentrantLock)实现同步,ReentrantLock和synchronized相似,他们都具备一样都线程重入特性,只是代码写法上有区别。
不过相比synchronized,ReentrantLock增加了一些高级功能,主要是以下3项:等待可中断、可实现公平锁、锁可以绑定多个条件。
等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。
公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序依次来获得锁;非公平锁:在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁时非公平的,ReentrantLock默认也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
锁可以绑定多个条件:一个ReentrantLock对象可以同时绑定多个Condition对象,而synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联,就不得不额外添加一个锁;ReentrantLock只需要多次调用newCondition()方法即可。
那么基于性能考虑呢?Brian Goetz对这两种锁分别在JDK1.5与单核处理器中、JDK1.5与双Xeon处理器环境做了一组吞吐量对比的实验,结果如下图所示:
可以看出ReentrantLock在多线程环境下更稳定,与其说ReentrantLock性能好,还不如说synchronized有非常大优化大空间,JDK1.6也印证了这一点,人们发现synchronized和ReentrantLock性能上基本持平了。
2.非阻塞同步
互斥同步最主要的问题是进行线程阻塞和唤醒带来的性能问题,因此这种同步也称为"阻塞同步",它是一种悲观的并发策略,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态和心态转换、维护锁计数器和检查是否有阻塞的线程需要唤醒等。
不过我们还有一种选择基于冲突检测的并发策略:先进行操作,如果没有其他线程争用共享数据,那操作就是成功了,如果共享数据有争用,产生冲突就采取其他的补偿措施。(最常见的是不断的重试,知道成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此被称为"非阻塞同步"。
为什么乐观并发策略需要"硬件指令集的发展"才能进行?因为我们需要操作和冲突检测两个步骤都具备原子性,如果使用互斥同步来保证它们原子性就失去意义了,所以只能靠硬件完成这件事。硬件保证需要多次操作的行为只通过一条处理器指令就能完成,常用指令有:
测试并设置(Test-and-Set)
获取并增加(Fetch-and-Increment)
交换(Swap)
比较并交换(Compare-and-Swap,CAS)
加载链接/条件存储(Load-Linked/Store-Conditional,LL/SC)
其中前面3条是早就有的处理器指令,后面2条是现代处理器新增的,这2条指令的目的和功能是类似的。
CAS指令需要3个操作数,分别是内存位置(Java中简单理解为变量的内存地址,用V表示)、旧的预期值(A表示)、新值(B表示)。CAS执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它不执行更新。无论是否更新了V的值,都会返回V的旧值,这个处理过程是一个原子操作。
JDK1.5后,Java才可以使用CAS操作。它们由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供,由于Unsafe不是提供用户程序调用的类,因此如果不采用反射,我们只能通过其他的Java API间接使用它。
我们再来看看12章中没有解决的代码问题使用CAS操作避免阻塞同步,我们曾通过20个线程自增的代码证明volatile变量不具备原子性,那么如何让他具备呢?把"race++"操作或increase()方法用同步包装当然是一个方法,但如果改成下面代码效率会提高很多:
使用AtomicInteger代替int后,程序输出了正确的结果。
incrementAndGet()方法在一个无限循环中,不断尝试将一个比当前值大1的新值赋值给自己。如果失败了,那说明"获取-设置"操作的时候值已经有了修改,于是再次循环进行下一次操作,直到设置成功。
3.无同步方案
要保证线程安全,并不一定要进行同步,同步只是保证共享数据争用时的正确性的手段,如果一个方法本来不涉及共享数据,那它自然无须同步措施。
因此有些代码天生就是线程安全的:
可重入代码(Reentrant Code):这种代码也叫纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另一段代码,在控制权返回后,原来的程序不会出现任何错误。
线程本地存储(Thread Local Storage):如果一段代码中的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果可以保证,我们就能把共享数据的可见范围限制在同一个线程内,这样也无须同步就能保证正确性。
3.锁优化
3.1 自旋锁与自适应自旋
互斥同步对性能最大的影响是阻塞的实现,挂起和恢复线程都需要转入内核态完成。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的线程"等一下",但不放弃处理器的执行时间,看看持有锁的线程是否很快会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环,这被称为自旋锁。
自旋锁本身虽然避免了线程切换的开销,但它是占用处理器时间的。如果锁被占用的时间很短,自旋锁等待的效果就非常好,如果锁占用时间很长,就会白白浪费处理器资源。因此,自旋锁等待必须有个限度。默认次数是10此,用户可以通过使用参数-XX:PreBlockSpin更改。
3.2锁消除
锁消除指虚拟机即时编译器运行的时候,对一些代码上要求同步,但是检测到不可能存在共享数据竞争的锁进行消除。
主要判定依据是逃逸分析的数据支持:如果判断一段代码中,堆上的所有数据都不会逃逸出去被其他线程访问到,就可以把它们当作栈上的数据对待,认为它们是线程私有的,同步加锁自然就没必要了。
许多同步措施并不是程序员自己加入的,同步的代码在Java程序中很普遍。例如下面的代码:
public String concatString(String s1, String s2, String s3){
return s1 + s2 + s3;
}
由于String是一个不可变的类,对字符串的操作总是通过生成新的String对象来进行,因此Javac编译器会其做了自动优化:
// JDK1.5之前
public String concatString (String s1, String s2, String s3){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
每个StringBuffer.append()方法都有一个同步块,锁就是sb对象。虚拟机会观察变量sb,发现它的动态作用域被限制在concatString(),其他线程无法访问到它,因此这里有锁,可以被安全的消除。
3.3 锁粗化
原则上,我们推荐将同步块的作用范围限制的尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能小,如果存在竞争,等待锁的线程也可以尽快拿到锁。
大部分情况,这样是没问题的。但如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中,即时没有线程竞争,频繁互斥同步操作也会导致不必要的性能损耗。
上面的StringBuffer.append()就是这类情况,如果虚拟机检测到这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。这样只需要加锁一次就可以。
3.4 轻量级锁
它是JDK1.6中加入的新型锁机制,"轻量级"是相对于使用操作系统互斥量来实现的传统锁而言的,传统锁就被称为"重量级"锁。
轻量级锁并不是来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能损耗。
HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希吗、GC分代年龄等,这部分数据的长度在32位和64位的虚拟机中分别是32bit和64bit,官方叫它"Mark Word"。它是实现轻量级锁和偏向锁的关键。第二部分用于存储指向方法去对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象状态复用自己的存储空间。
例如32位的HotSpot虚拟机中对象未锁定的状态下,Mark Word的32bit空间中的25bit用于存储对象哈希吗,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,其他状态下对象的存储内容如下表所示:
在代码进入到同步块的时候,如果此同步对象没有被锁定(标志位"01"),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)空间,用于存储锁对象目前的Mark Word拷贝(称为"Displaced Mark Word")。这时候线程堆栈与对象头的状态如下图所示:
然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标记位转变为"00",表示此对象处于轻量级锁定状态,如下图所示:
如果更新操作失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果指了说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这锁对象已经被其他线程占了。
如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,膨胀为重量级锁,锁标志的状态为"10",Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
它的解锁过程也是通过CAS操作的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Dispalced Mark Word替换回来。如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁能提升程序同步性能的依据是"对于绝大部分的锁,在整个同步周期内都是不存在竞争的"。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销;但如果存在锁竞争,除了互斥量的开销,还发生了额外的CAS操作,因此,竞争情况下,轻量级锁会更慢。
3.5偏向锁
它也是JDK1.6引入的,目的是消除数据在无竞争情况下的同步,进一步提高程序的运行性能。
如果轻量级锁是在无竞争条件下使用CAS操作消除同步使用的互斥量,那么偏向锁就是在无竞争情况下把整个同步都消除,CAS操作都不做了。
偏向锁的偏意思是这个锁会偏向于第一个获得它的线程,如果在接下来执行中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。
假设虚拟机启动了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为"01"。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
当另外一个线程尝试获取这个锁时,偏向模式宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位"01")或轻量级锁定(标志位"00")。后续的同步操作就是轻量级那样执行。
偏向锁、轻量级锁的状态转化及对象Mark Word的关系如下图:
偏向锁可以提高带有同步但无竞争的程序性能,并不一定总对程序有利。如果程序大多数锁总是被多个不同的线程访问,偏向锁就是多余的。