线程安全
当多个线程同时访问一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
按照线程安全的强弱排序,java语言中各种操作共享的数据分为以下五类:不可变,绝对线程安全,相对线程安全,线程兼容和线程对立。
1.java语言中的线程安全
1.不可变
如果共享数据是一个基本数据类型,定义时使用final关键字修饰可保证它不可变。如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行。其中最简单的是把对象中带有状态的变量都声明为final,这样在构造函数结束后,它就是不可变的。
Java API中符合不可变要求的类型:java.lang.String/java.lang.Number部分子类等,如Long和Double等数值包装类型。
1.2绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施,即绝对线程安全。在java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
package com.ryj.Thread; import java.util.Vector; public class VectorTest { private static Vector<Integer> vector = new Vector<>(); public static void main(String[] args) throws InterruptedException { 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++){ try { Thread.sleep(1000); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } System.out.println(vector.get(i)); } } }); removeThread.start(); printThread.start(); } } }
尽管Vector的get(),remove()等方法都是同步的,但是在多线程环境中,如果没有额外的同步措施,这段代码依然不是线程安全的。
如果需要保证这段代码能正确执行下去,可以做如下修改:
1.3相对线程安全
java语言中,大部分声称线程安全的类都属于相对线程安全。如Vector,HashTable,Collections的synchronizedCollection()方法包装的集合等
1.4线程兼容
指对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中可以安全的使用
1.5线程对立
线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。一个线程对立的例子是Thread类的suspend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险:假如suspend()中断的线程就是要即将要执行resume()的那个线程,那就肯定要产生死锁。
2.线程安全的实现方法
2.1互斥同步
同步是指多个线程并发访问共享数据时,保证共享数据在同一时刻只能被一条线程使用。而互斥是实习同步的一种手段。
java里面最基本的互斥同步手段就是synchronized。synchronized关键字经过javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来只能要锁定和解锁的对象。如果java源码中的synchronized明确指定了对象参数,那么就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象作为线程要持有的锁。
根据虚拟机规范的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
在虚拟机规范对monitorenter和monitoreexit的行为描述中,有两点是需要特别注意:
- synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题
- 同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。它属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。
2.2非阻塞同步
随之硬件指令集的发展,有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再进行其他的补偿措施,最常见的补偿措施就是不断地重试,直到试成功了为止。这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作被称为非阻塞同步(No-Blocking Synchronization)。
之所以是随着硬件指令集的发展,是因为必须要求操作和冲突检测这两个步骤具备原子性。
在JDK5之后,java类库中才开始使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()等方法包装提供。不过Unsafe类设计上是不提供给用户程序调用的,因为Unsafe.getUnsafe()的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的Class才能访问它,因此JDK9之前只有Java类库可以使用CAS。这一类方法是native方法,并且虚拟机对这类方法做了特殊处理,所以在编译期间将会编译成一条平台相关的CAS指令。
以AtomicInteger源码为例:
/** * Atomically increments by one the current value. * * @return the updated value */ public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) + 1; } /** * Atomically decrements by one the current value. * * @return the updated value */ public final int decrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, -1) - 1; }
继续查看Unsafe源码:
@CallerSensitive public static Unsafe getUnsafe() { Class localClass = Reflection.getCallerClass(); if (!VM.isSystemDomainLoader(localClass.getClassLoader())) { throw new SecurityException("Unsafe"); } return theUnsafe; } public final int getAndAddInt(Object paramObject, long paramLong, int paramInt) { int i; do { i = getIntVolatile(paramObject, paramLong); } while (!compareAndSwapInt(paramObject, paramLong, i, i + paramInt)); return i; }
public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);
尽管CAS看起来很美,但显然这种操作无法涵盖互斥同步的所有使用场景,并且CAS从语义上来说并不是完美的,它存在着一个CAS操作的‘ABA’问题:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那我们就能说它的值没有被其他线程改变过了吗?如果在这段期间它的值曾经被改成了B,后来又被改回了A,那CAS操作就会误认为它从来没有被改变过。
为了解决ABA问题,J.U.C包提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。不过大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
2.3无同步方案
要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保障共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。
- 可重入代码(Reentant Code):这种代码也叫纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。特征: 例如不依赖存储在堆上的数据公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。
- 线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题
3.锁优化
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程都需要转入内核态完成,这给java虚拟机的并发性能带来了很大的压力。
3.1自旋锁与自适应锁
自旋等待不能代替阻塞,自旋等待本身虽然避免了线程切换的开销,但是它要占用处理器时间。如果锁被占用的时间很长,那么自旋的线程会白白消耗处理器资源。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数(默认十次)依然没有成功获得锁,就应该使用传统的方式取挂起线程。
JDK6中对自旋锁进行了优化,引入了自适应的自旋。自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
3.2锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。
每个StringBuffer.append()方法中都有一个同步块,锁就是sb对象。虚拟机观察变量sb,很快就会发现它的动态作用域被限制在concatString()方法的内部。也就是sb的所有引用永远不会“逃逸”到concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
3.3锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小---只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快地拿到锁。
大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
3.4轻量级锁
轻量级锁是JDK1.6中加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重要级锁使用操作系统互斥量产生的性能消耗。
轻量级锁能提升程序同步性能的依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
3.5偏向锁
偏向锁也是JDK1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,边CAS操作都不做了。
作者:oneape15
链接:https://www.jianshu.com/p/a19c4e2ce65c
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。