第13章 线程安全与锁优化
13.2 线程安全
什么是线程安全?
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。
线程安全的代码必须具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),使调用者无需关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。
13.2.1 Java语言中的线程安全
前提:多个线程之间存在共享数据访问
(Brian Goetz 论文中提出)
按照线程安全的“安全程度”由强到弱来排序,分为以下5类:
不可变、绝对线程安全、相对线程安全、线程兼容和线程对立
1.不可变
不可变对象一定是线程安全的。因不可变对象创建出来后,对于外部来说,其可见性永远不会改变。
1)共享数据是基本数据类型。定义时使用final关键字修饰即可保证不可变。
2)共享数据是对象,保证对象的行为不会对其状态产生任何改变才行。
java.lang.String类的对象是不可变对象,substring()、replace()等方法不会改变原来的值,只会返回一个新构造的字符串对象。
保证对象行为不影响自己状态的最简单方法,把对象中带有状态的变量都声明为final。
Java中的不可变类,String、枚举类型、Long、Double、BigInteger、BigDecimal。
2.绝对线程安全
“不管运行时环境如何,调用者都不需要进行任何额外的同步措施”。
Java API中标注线程安全的类,大多都不是绝对线程安全。
private static Vector<Integer> vector = new Vector<Integer>(); public static void main(String[] args) { while (true) { for (int i = 0; i < 10; i++) { vector.add(i); } Thread removeThread = new Thread(new Runnable() { public void run() { for (int i = 0; i < vector.size(); i++) { vector.remove(i); } } }; Thread printThread = new Thread(new Runnable() { public void run() { for (int i = 0; i < vector.size(); i++) { System.out.println(vector.get(i)) } } }; removeThread.start(); printThread.start(); while (Thread.activeCount() > 20); } }
Vector的get(),remove(),size()都是同步的,但是不采取适当的同步措施的话,代码仍是不安全的。
3.相对线程安全
通常意义上的线程安全,需要保证对这个对象单独的操作是线程安全的,无需额外保证措施,但是对于特定顺序的连续调用,需要在调用时使用额外的同步手段保证调用的正确性。
Java中,大多数的线程安全类都属于这种类型,如Vector、HashTable、Collections.synchronizedCollection()方法包装过的集合。
Thread removeThread = new Thread(new Runnable() { public void run() { synchronized(vector) { for (int i = 0; i < vector.size(); i++) { vector.remove(i); } } } }; Thread printThread = new Thread(new Runnable() { public void run() { synchronized(vector) { for (int i = 0; i < vector.size(); i++) { System.out.println(vector.get(i)) } } } };
4.线程兼容
指对象本身不是线程安全的,可以在调用时使用同步等手段保证对象在并发环境下可以安全被被使用。
我们平时说一个类不是线程安全的,指的就是这种情况。如ArrayList和HashMap。
5.线程对立
指无论调用端是否采用了同步措施,都无法保证对象在多线程环境中被安全使用。
例子:Thread类的suspend()方法和resume()方法。两个线程同时对用一个线程对象,一个调用suspend(),一个调用resume(),可能导致死锁。正是这个原因,这两个方法被
JDK声明为@Deprecated。
13.2.2 线程安全的实现方法
如何实现线程安全?
同步是指多个线程并发访问共享数据的时候,保证共享数据仅被一个线程(或一些,使用信号量时)使用。
1.互斥同步
互斥是实现同步的一种手段,实现方式有
1)synchronized关键字
编译后,会在同步块的前后形成monitorenter和monitorexit两条字节码指令。
在JVM规范中,在执行monitorenter指令时,首先尝试获取对象的锁。如果这个对象还没被锁定,或者当前线程只有对象锁,锁的计数器加1。
在执行monitorexit指令时,将锁的计数器减1,当计数器变为0时,锁被释放。如果获取对象锁失败,当前线程会阻塞等待。
2)java.util.concurrent包中的重入锁ReentrantLock
相比synchronized,ReentrantLock有一些高级功能:等待可中断、公平锁、锁可绑定多个条件。
等待可中断:持有锁的线程长时间不释放锁,等待锁的线程可放弃等待,去做其他事情
公平锁:synchronized锁是非公平的,ReentrantLock默认也是非公平的,但ReentrantLock可实现公平锁
锁可绑定多个条件:一个ReentrantLock可绑定多个Condition对象
2.非阻塞同步
互斥同步最主要的问题是会进行线程阻塞和线程唤醒带来的性能问题,这种同步为阻塞同步。
互斥同步为悲观锁策略,任务不做正确的同步措施,肯定会出问题,无论是否出现了共享数据的竞争,都要加锁。
随着硬件指令集的发展,可使用乐观锁策略,即先执行操作,如果没有线程竞争,操作就成功了;如果有线程竞争,不断重试,直到成功为止。
CAS
什么是CAS指令?
CAS全拼为Compare and Swap,比较并交换。
CAS为cpu指令(硬件相关),其有3个操作数,分别是内存地址、旧值A、新值N。
CAS指令执行时,当且仅当内存地址中的值为旧值A时,更新为新值N。无论是否更新成功,都会返回旧值。
CAS指令包含了冲突检测+值更新,从语义上来说需要多次操作,因此需要从硬件上通过一条处理器指令来保证原子性。
Java中的CAS。
底层都是sun.misc.Unsafe类里面的compareAndSwapInt、compareAndSwapLong等几个方法,
虚拟机在做处理时,编译出来的是一条平台相关的CAS指令。
CAS的逻辑漏洞:ABA问题
内存中的旧值由A被改为B,后又改为了A,但是CAS误认为内存中的值没有被修改过。
java.util.concurrent中提供了个AtomicStampedReference来解决此问题,原理为变量携带了版本号(A1-->B2-->A3)。
这个类比较鸡肋,大部分情况下ABA问题都不影响并发的正确性。
3.无同步方案
方法不涉及共享数据,自然不需要任何同步措施保证正确性,这样的代码天生就是线程安全的
1)可重入代码
代码可重入的判断原则:如果一个方法,输入相同的数据,总是返回相同的结果。
2)线程本地存储
共享数据的代码在同一个线程中执行,将共享数据的可见范围限制在同一线程内
Java中的ThreadLocal类实现了线程本地存储。
例子:经典的web交互模型,一个请求对应一个服务器线程,服务端可以利用线程本地存储解决线程安全问题
13.3 锁优化
为了线程之间更高效共享数据、及解决竞争问题,JVM实现了各种锁优化技术。
包括自旋锁、锁消除、锁粗化、轻量级锁、偏向锁等。
1.自旋锁与自适应自旋
自旋锁
背景:
1)互斥同步对性能影响最大的是阻塞的实现(挂起线程和恢复线程都需要转入内核态完成)。
2)同时,在很多应用中,访问共享数据时,线程持有锁只会持续很短的一段时间。为了这段很短的时间去挂起和恢复线程很不值得。
原理:多核处理器,两个或多个线程能够并行执行,可以让请求锁的线程执行一个忙循环(自旋)稍等一下,不放弃处理器的执行时间,看看持有锁的线程是否会很快释放锁。
优点:由于自旋等待锁的过程线程并不会引起上下文切换,因此比较高效;
缺点:自旋等待过程线程一直占用CPU执行权但不处理任何任务,因此若该过程过长,那就会造成CPU资源的浪费。
自适应自旋锁
原理:自适应自旋可以根据以往自旋等待时间的经验,计算出一个较为合理的本次自旋等待时间。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,且持有锁的线程正在运行中,虚拟机会认为这次自旋很可能再次成功,会允许自旋等待更长时间,如100个循环。
如果对于某个锁,自旋很少获得成功过,以后可能获取锁时省略掉自旋过程,避免浪费资源。
2.锁消除
锁消除指即时编译期在运行时,检测到一些代码虽然要求同步,但却不可能存在共享数据竞争,会对这些代码进行锁消除。
锁消除主要判断依据是基于逃逸分析,如果判断在一段代码中,堆上的数据不会逃逸出去被其他线程访问到,认为它们是线程私有的,无需进行同步加锁。
3.锁粗化
锁粗化指虚拟机检测到一连串的操作都对同一个对象反复加锁、解锁(即使没有线程竞争,频繁互斥同步导致不必要的性能损耗),会把加锁同步的范围扩展到整个操作序列的外部。
4.锁升级
JDK1.6以后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。
而锁信息是存在Java对象头里的。
Java对象头的形式有以下两种(以32位JVM为例)
对象为非数组类型。
其中,Mark Word存储对象的hashcode或锁信息,Klass Word存储了对象类型数据的指针。
对象为数组类型。
对于数组类型,array length存储数组的长度。
在运行期间,Mark word里存储的数据会随着锁标志位的变化为变化。
1)偏向锁
当一个线程访问同步块时,在对象头Mark Word里记录线程id。
以后再次进入和退出同步块时,无需使用CAS进行加锁和解锁,仅需测试对象头Mark word里是否为偏向锁并且线程id是自己的id。
1)如果测试成功(为偏向锁且线程id是自己),表明线程已经获得了锁。
2)如果测试失败
如果Markd word中偏向锁标识设为了1(即当前是偏向锁),尝试使用CAS将Mark word中的线程id设为自己。
如果当前不是偏向锁,使用CAS竞争锁。
2)轻量级锁
轻量级锁加锁:
执行同步块前,JVM会在当前线程栈帧中创建由于存储锁记录的空间,并将对象头Mark word复制到锁记录中。
然后,尝试使用CAS将对象头中Mark word替换为指向锁记录的指针。
如果成功,当前线程获得锁;如果失败,表示其他线程在竞争锁,当前线程尝试使用自旋来获取锁。
轻量级锁解锁:
解锁时,会尝试使用CAS将锁记录中信息替换回Mark word。
如果成功,则表示没有竞争发生;如果失败,表示当前存在锁竞争,锁会膨胀为重量级锁(重量级锁就是传统的锁,互斥量)。
偏向锁、轻量级锁、重量级锁的比较
偏向锁
作用:偏向锁是为了消除无竞争情况下的同步原语,进一步提升程序性能。
优点:偏向锁可以提高有同步但没有竞争的程序性能。但是如果锁对象时常被多条线程竞争,那偏向锁就是多余的。
适用场景:适用于只有一个线程访问同步块的场景。
偏向锁与轻量级锁的比较
1)与轻量级锁的区别:轻量级锁是在无竞争的情况下使用CAS操作来代替互斥量的使用,从而实现同步;而偏向锁是在无竞争的情况下完全取消同步。
2)与轻量级锁的相同点:它们都是乐观锁,都认为同步期间不会有其他线程竞争锁。
轻量级锁
背景:『轻量级锁』是相对于『重量级锁』而言的,而重量级锁就是传统的锁。
本质:使用CAS取代互斥同步。
优点:竞争的线程不会阻塞,提高了程序的响应速度。
缺点:竞争的线程始终得不到锁,使用自旋会消耗CPU
轻量级锁与重量级锁的比较:
1)重量级锁是一种悲观锁,它认为总是有多条线程要竞争锁,所以它每次处理共享数据时,不管当前系统中是否真的有线程在竞争锁,它都会使用互斥同步来保证线程的安全;
2)而轻量级锁是一种乐观锁,它认为锁存在竞争的概率比较小,所以它不使用互斥同步,而是使用CAS操作来获得锁,这样能减少互斥同步所使用的『互斥量』带来的性能开销。
重量级锁
优点:线程竞争不使用自旋,不会消耗CPU
缺点:线程阻塞,响应时间缓慢