线程安全与锁优化
线程安全
当多个线程访问一个对象时,如果不用考虑这些线程在运行环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象时线程安全的。
Java语言中的线程安全
按照线程安全的“安全程度”由强至弱来排序,我们可以将Java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程建荣和线程对立。
不可变
不可变的对象一定是线程安全的。
保证对象行为不影响自己状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为final。
每次对对象的操作都会返回一个新的对象,而不会去改变旧的对象,这样可以保证对象的线程安全。
Java API中符合不可变要求的类型:String,java.lang.Number的部分子类(如Long和Double的数值包装类,BigInteger和BigDecimal等大数据类型但AtomicInteger
和AtomicLong
则并非不可变的)
绝对线程安全
Java API中标注自己是线程安全的类,大多数都不是绝对线程安全的。
相对线程安全
相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
Java语言中,大部分的线程安全都属于这种类型,例如Vector,HashTable,Collections的synchronizedCollection()方法包装的集合等。
线程兼容
指对象本身并不是线程安全的,但是通过使用同步手段来保证对象在并发环境中可以安全的使用。Java API中大部分的类都是属于线程兼容的,如ArrayList和HashMap。
线程对立
指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。
一个线程对立的例子就是Thread类的suspend()和resumn()方法(已被JDK声明废弃了)。
常见的线程对立操作还有System.setIn(), System.setOut(), System.runFinalizersOnExit()等等。
线程安全的实现方法
虚拟机提供的同步和锁机制起到了非常重要的作用。
互斥同步
互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此在这4个字里面,互斥是因,同步是果:互斥是方法,同步是目的。
Java中,最基本的互斥同步手段就是synchronized关键字。通过锁计数器+-1,实现对锁的加锁和释放。
synchronized是一个重量级的操作,因为:Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要消耗很多的处理器时间。对于代码简单的同步块(如synchronized修饰的getter()和setter方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。而虚拟机本身也会进行一些优化,比如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中。
还可以使用java.util.concurrent包中的ReentrantLock(重入锁)
来实现同步:JDK1.5多线程环境下synchronized的吞吐量下降的很严重,而ReentrantLock则基本保持在同一个比较稳定的水平上。JDK 1.6之后两者性能基本持平。
虚拟机在未来的性能改进中还会更偏向于原生的synchronize的,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。
非阻塞同步
非阻塞同步:从处理问题的方式上说,互斥同步属于一种悲观的并发策略。随着硬件指令集的发展,我们可以采用基于冲突检查的乐观并发策略,通俗地说,就是先行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现偶读不需要把线程挂起,因此这话总同步操作称为非阻塞同步。
非阻塞同步算法实战(二)-BoundlessCyclicBarrier
非阻塞同步算法实战(三)-LatestResultsProvider
无同步方案
如果一个方法本来就不设计共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。这类代码包括:可重入代码和线程本地存储。
可重入代码(Reentry code)也叫纯代码(Pure code)是一种允许多个进程同时访问的代码。为了使各进程所执行的代码完全相同,故不允许任何进程对其进行修改。程序在运行过程中可以被打断,并由开始处再次执行,并且在合理的范围内(多次重入,而不造成堆栈溢出等其他问题),程序可以在被打断处继续执行,且执行结果不受影响。
可重入代码有一些共同的特征。例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。我们可以通过一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
线程本地存储:TLS--线程局部存储 这个可以用ThreadLocal来设置。
锁优化
为了进一步改进高效并发,HotSpot虚拟机开发团队在JDK1.6版本上花费了大量精力实现各种锁优化。如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。
自旋锁与自适应自旋
为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。引入自旋锁的原因是互斥同步对性能最大的影响是阻塞的实现,管钱线程和恢复线程的操作都需要转入内核态中完成,给并发带来很大压力。自旋锁让物理机器有一个以上的处理器的时候,能让两个或以上的线程同时并行执行。我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋锁的自旋次数默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。
JDK1.6引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。(这个应该属于试探性的算法)。
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行清除。锁清除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步枷锁自然就无需进行。
锁粗化
另一种线程优化方式是锁粗化(或合并,merging)。当多个彼此靠近的同步块可以合并到一起,形成一个同步块的时候,就会进行锁粗化。该方法还有一种变体,可以把多个同步方法合并为一个方法。如果所有方法都用一个锁对象,就可以尝试这种方法。
轻量级锁
Java 轻量级锁原理详解(Lightweight Locking)
偏向锁
大多数锁,在它们的生命周期中,从来不会被多于一个线程所访问。即使在极少数情况下,多个线程真的共享数据了,锁也不会发生竞争。为了理解偏向锁的优势,我们首先需要回顾一下如何获取锁(监视器)。
获取锁的过程分为两部分。首先,你需要获得一份契约.一旦你获得了这份契约,就可以自由地拿到锁了。为了获得这份契约,线程必须执行一个代价昂贵的原子指令。释放锁同时就要释放契约。根据我们的观察,我们似乎需要对一些锁的访问进行优化,比如线程执行的同步块代码在一个循环体中。优化的方法之一就是将锁粗化,以包含整个循环。这样,线程只访问一次锁,而不必每次进入循环时都进行访问了。但是,这并非一个很好的解决方案,因为它可能会妨碍其他线程合法的访问。还有一个更合理的方案,即将锁偏向给执行循环的线程。
将锁偏向于一个线程,意味着该线程不需要释放锁的契约。因此,随后获取锁的时候可以不那么昂贵。如果另一个线程在尝试获取锁,那么循环线程只需要释放契约就可以了。Java 6的HotSpot/JIT默认情况下实现了偏向锁的优化。