第十三章 线程安全与锁优化 《深入理解java虚拟机》

面向过程变成(数据结构和算法)-->面向对象编程

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

线程安全限定于多个线程之间存在共享数据访问这个前提,按照线程安全的程序由强到若排序:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立。

不可变

不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要采取任何的线程安全保障措施(前提是没有发生this引用逃逸的情况)

java中,如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的,如果共享数据是一个对象,就需要保证对象的行为不会对其状态产生任何影响,例如将对象的所有属性声明为final。

绝对线程安全

不管运行环境如何,调用者都不需要任何额外的同步措施。

相对线程安全

相对的线程安全就是我们通常意义上的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。java中大部分线程安全的类都属于这种类型,如vector、HashTable、Collections.synchronizedCollection()

线程兼容

线程兼容是指对象本身不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中可以安全的使用,我们常说的线程不安全的类是指这种。

线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。(极少)

虚拟机线程安全的运作手段

互斥同步

同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或一些)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。

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

根据虚拟机规范要求,在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

在虚拟机规范对monitorenter和monitorexit行为描述中,两点需要注意:首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,就需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块,状态转换消耗时间有可能比用户代码执行的时间还要长。所以synchronized是java语言中的一个重量级操作。

除了synchronized之外,还可以使用java.unit.concurrent包中的重入锁(ReentrantLock)来实现同步,在基本用法上,ReentrantLock与synchronized相似,都具备一样的线程重复特性,只是一个表现为API层面的互斥锁(lock()和unlock()方法配合try/finallly语句块来完成),另一个表现为原生语法层面的互斥锁。相对于synchronized,ReentrantLock有三项高级功能:等待可中断、可实现公平锁、锁可以绑定多个条件。

1.等待可中断是指当持有锁的线程长时间不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。

2.公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都由机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,可以构造函数中参数设置成公平锁。

3.锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notiry()或notiryAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁,而ReentrantLock则无需这样做,只需要多次调用newCondition()方法即可。

jdk1.5版本及以前在多线程环境下,synchronized的吞吐量下降的非常严重,而ReentrantLock则基本保持在同一个比较稳定的水平上。

第十三章 线程安全与锁优化 《深入理解java虚拟机》

jdk1.6之后增加了很多针对锁的优化措施,synchronized和ReentrantLock的性能基本完全持平,因此在jdk1.6之后,性能因素就不再是选择ReentrantLock的理由,虚拟机在未来的性能改进中肯定更偏向于原生的synchronized,所以提倡在synchronized能实现需求的情况下,优先考虑synchronized来进行同步。

非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的的性能问题,因此这种同步也称为阻塞同步,是一种悲观的同步策略。

乐观并发策略:基于冲突检测,也就是先行操作,如果没有其他线程争用共享数据,那操作就成功;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施,这种乐观的并发策略的许多实现不需要把线程挂起,被称为非阻塞同步。

乐观并发策略需要硬件指令集的支持,因为操作和冲突检测这两个步骤需要具备原子性。

无同步方案

同步只是保证共享数据争用时的正确性手段,如果一个方法本来就不涉及到共享数据,那它自然就无需任何同步措施去保证正确性,以下两种代码天然是线程安全的:

1.可重入代码:也称纯代码,不依赖存储在堆上的数据和共用的系统资源,用到的状态量都由参数中传入、不调用非可重入的方法等。

2.线程本地存储:java中,如果一个变量要被多线程访问,可以使用volatile关键字声明它为易变的;或者使用java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

锁优化

jdk1.5到jdk1.6实现多种锁优化技术:适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。

自旋锁

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁状态只会持续很短一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程稍等一下,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这就是自旋锁。

jdk1.6默认开启,自选等待不能代替阻塞,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,带来性能上的浪费。因此,自旋等待的时间需要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就使用传统的方式挂起线程。默认自旋次数是10次。

jdk1.6引入自适应自旋锁,意味着自旋的时间不固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。

锁消除

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

锁粗化

如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩大(粗化)到整个操作序列的外部。

轻量级锁

偏向锁