线程安全
有一个比较恰当的线程安全的定义:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对 象的行为都可以获得正确的结果,那就称这个对象是线程安全的
Java语言中的线程安全
线程安全是以多个线程之间存在共享数据为前提的。我们可以将java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立
不可变
不可变的对象一定是线程安全的,只要一个不可变的对象被正确的构造出来(即没有发生this引用逃逸的情况),那其外部的可见状态永远都不会改变。以java.lang.String类的对象为例,它是一个典型的不可变对象,除了String外常用的还有枚举类型及java.lang.Number的部分子类,如Long和Double等数值包装类型、BigInteger和BigDecimal等大数据类型。
绝对线程安全
绝对的线程安全要求一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”。在JavaAPI中标注自己是线程安全的类,大多数都不是绝对的线程安全。
例如Vector是一个线程安全的容器,他的add()、get()和size()等方法都是被synchronized修饰的,尽管这样效率不高,但保证了具备原子性、可见性和有序性。但是虽然它的每个方法在调用的时候都能保证原子性,在方法组合使用的时候还是无法保证线程的安全性,同样是需要额外的同步处理的,所以不能算是绝对的线程安全。
相对线程安全
相对线程安全就是我们通常意义上所讲的线程安全,他需要保证对这个对象单次的操作是线程安全的(即每个方法都是线程安全的),但是对于一些特定顺序的连续调用,仍需要额外的同步手段来保证调用的正确性。
线程兼容
线程兼容是指对象对象本身不是线程安全的,但是可以通过调用段正确的同步手段来保证对象在并发的环境中可以安全的使用。我们平常说的一个类是不是线程安全的,通常就是指这种情况。Java类库中大部分类都是线程兼容二,如前面的Vector和HashTable像对应的集合类ArrayList和HashMap等
线程对立
线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境下并发使用代码。由于Java天生就支持多线程的特性,线程对立这种排斥多线程的代码时很少出现的。
一个线程对立的例子就是Thread类的suspend()和resume()方法,如果有两个线程同时持有一个线程对象,一个尝试中断线程,一个尝试恢复线程,在并发进行的条件下,无论调用时是否进行了同步,目标线程都存在死锁的风险。
线程安全的实现方法
互斥同步(阻塞同步)
互斥同步是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据的时候,保证共享数据在同一时刻只能被一条线程使用。而互斥是实现同步的手段,临界区、互斥量和信号量都是常见的互斥实现方式。
在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构的同步语法。synchronized的经过JavaC编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明锁定和解锁的对象。
在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对像没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值加一,而在执行monitorexit指令会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。
由此可以得出两个结论
- synchronized修饰的同步块是可重入的
- synchronized修饰的同步块在持有锁的线程执行完毕释放锁之前,会无条件的阻塞后面其他线程的进入
这种重量级锁导致的状态转换非常耗费处理器时间,尤其是对于代码简单的同步块,状态转换的时间甚至会比用户代码本身执行的时间还长。因此虚拟机本身会进行一些优化,比如在通知操作系统阻塞线程之前加入一段自选等待过程,以避免频繁切换入核心态中
除了sychronized关键字外,自JDK5起,Java的java.util.concurrent.locks.Lock接口成了另一种全新的互斥同步手段。基于Lock接口,用户可以以非块结构来实现互斥同步
重入锁(ReentrantLock)是Lock接口最常见的一种实现,ReentrantLock与synchronized相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁即锁可以绑定多个条件。
- 等待可中断:体现在tryLock()方法,是指当持有锁的线程长期不释放锁的时候,正待等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。
- 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。
- 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。当需要实现一个隐含条件的时候,只需要多次调用newCondition()方法就能配合await、signal/signalALL实现了
两个锁的区别:
- sychronized实在Java语法层面的同步,足够清晰、简单
- Lock应该确保finally块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远都不释放持有的锁
非阻塞同步
互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,也被称为阻塞同步。从解决问题的方式上看,互斥同步属于一种悲观的并发策略,无论共享的数据是否真的会出现问题,它都会进行加锁。
CAS锁则属于乐观锁,不管共享数据出错的风险先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享数据被争用了,那会进行其他的补偿措施,最常用的补偿措施就是不断地重试,直到没有竞争的共享数据为止。
CAS指令需要三个操作数,分别是内存位置(变量的内存地址),旧的预期值和准备设置的新值
在JDK5之后,Java类库中才开始使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个包装方法提供。不过Unsafe类在设计上就不是给用户程序调用的类(只能通过反射获取类对象),因此JDk9之前只有Java类库可以使用CAS,譬如J.U.C包里的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作来实现。
尽管CAS看起来很美好,即简单又高效,但显然这种操作无法涵盖互斥同步使用的所有场景。类似于CAS操作就无法处理ABA问题。由此J.U.C包为了解决这个问题,提供了一个带有标记的原子引用原子类AtomicStampedReference。不过这个类处于相当鸡肋的位置,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更为高效。
无同步方案
线程安全其实和阻塞或非阻塞同步没有必然的联系,同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的。列举其中的两类
可重入代码
这种代码又称纯代码,是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有任何印象。在多线程的上下文语境里,我们可以认为可重入代码是线程安全代码的一个真子集,即所有可重入的代码都是线程安全的,但并非所有线程安全的代码都是可重入的
可重入代码有一些共同的特征,例如,不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数传入,不调用非可重入方法等。
我们可以通过一个简单的原则判断代码是否有可重入性:如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就能返回相同的结果,那么它就满足可重入性的要求,当然也是线程安全的。
线程本地存储
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无需同步也能保证线程之间不出现数据争用问题
我们可以通过java.lang.threadLocal类来实现线程本地存储的功能。
锁优化
高效并发是从JDK5升级到JDK6后一项重要的改进项,HotSpot开发团队也实现了各种锁优化技术,如适应性自旋、锁消除、锁膨胀、轻量级锁、偏向锁等
自旋锁与自适应自旋
为了解决阻塞时线程状态的转换对cpu核的开销。虚拟机开发团队注意到在很多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果一个物理机上有一个以上的处理器或处理器核心,我们可以让后面那个请求锁的线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋锁)
在JDK6中引入了对自旋锁的优化,引入了自适应的自旋,自适应就意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有着的状态来决定的。如果对于某个锁,自选很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程。
锁消除
锁消除时JIT(即时编译器)的优化,对检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据时逃逸分析的数据支持。如果判断到一段代码中,即堆上的所有数据都不会逃逸出去被其他线程访问到,那么,就可以把它们当作栈上数据对待,认为他们是线程私有的,同步加锁自然就无须再进行。
轻量级锁
轻量级锁是JDK6,轻量级是相对于使用操作系统互斥量的重量级锁而言的
HotSpot虚拟机的对象头分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄等,官方称它为“Mark Word",这部分是实现轻量级锁核偏向锁的关键
在代码进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先将在当前线程的栈中建立一个名为锁记录的空间,用于存储锁对象的Mark Word的拷贝。然后使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个操作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位将转变为”00“,表示此对象处于轻量级锁定状态。
如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为”10“。
偏向锁
如果说轻量级锁是在无竞争的情况下使用CAS操作去除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步给消除掉,连CAS操作都不去做了。
当虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”,把偏向模式设置为“1”,进入偏向模式,同时使用CAS操作把获取到这个锁的线程的ID记录在对象的MarkWord中。
一旦出现另外一个线程去尝试获取这个这个锁的情况,偏向模式就马上宣告结束。
有个问题如果使用了偏向锁,那么Mark Word大部分空间都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置,以至于只要一个对象已经计算过一致性哈希吗后,他就再也无法进入偏向锁状态了。当一个对象当前正处于偏向锁状态,又收到需要计算一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁
如果该类重写了hashCode方法的话,那么线程就无法进行偏向锁状态,对象的对象头占8个字节,hashcode占4个字节,如果类没有重写hashCode方法的话,对象的hashcode不会存储在对象头中,这时如果有一个线程想要对该对象加偏向锁,线程使用CAS操作把获取到这个锁的线程的ID记录在对象的MarkWord中,而这个ID也长大概4个字节,这时如果重写了hashcode方法或者本身就重写过了hashcode方法,对象头没有足够的空间存储线程ID的信息,就无法进入偏向锁状态