1 线程安全
- 当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作。
调用这个对象的行为都可以获得安全的结果
,就称这个对象是线程安全的。 - 代码本身封装了所有必要的正确保障性操作(如互斥同步等),令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证多线程环境下的正确调用。
1.1 Java语言中的线程安全
- 这里讨论的线程安全,都是以多个线程之间存在共享数据访问为前提。 按照线程安全的"安全程度"由强至弱来排序,我们可以将Java语言中各种操作共享的数据分为以下几大类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
1.1.1 不可变
- 不可变的对象一定是线程安全的,无论是谁都不需要进行任何线程安全保障措施。
- 如果是一个基本数据类型,那么只需要使用final关键字来修饰,他就是不可变的。如果共享数据是一个对象,那么就需要对象保证其行为不会对其状态产生任何的影响才行。例如java.lang.String类,它是不可变的,就算用户调用它的substring(),replace()等方法,都只会返回一个新构建的字符串对象。
- 类似的不可变的还有 枚举类型(及java.lang.Number的部分子类),如Long和Double等数值包装类型,BigInteger和BigDecimal等大数据类型。
1.1.2 绝对线程安全
- 绝对线程安全能够完全满足我们前面提到的那个复杂的定义。 一个类要达到"不管运行环境如何,调用者都不需要任何额外的同步措施"可能需要付出非常高昂的代价。
- JavaAPI中标注自己是线程安全的类,绝大多数不是绝对的线程安全。
- 例如java.util.Vector是一个线程安全的容器,它的add(),get()和size()等方法都是被synchronized修饰的。虽然这样子效率不高,但是保证了它具备了原子性、可见性和有序性。
package specialTest;
import java.util.Vector;
public class VectorThreadSafeDemo {
private static Vector<Integer> vector = new Vector<>();
public static void main(String[] args) {
while (true) {
for (int i = 0; i < 10; i++) {
vector.add(i);
}
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
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);
}
}
}
- 如果一个线程恰好在某一个时间点删除了一个元素,导致序号i不可用了,这个时候再访问i可能就会抛出异常。
- 假设Vector一定要做到绝对的线程安全,就必须在内部维护一组一致性的快照访问才行,每次对其中的元素进行改动都要产生新的快照,这样付出的时间成本和空间成本是巨大的。
1.1.3 相对线程安全
- 相对线程安全就是我们通常意义上讲的线程安全,他需要保证这个对象的单次操作是线程安全的。
- 我们调用的时候不需要进行额外的保障操作,
但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
- 在Java中,大部分声称线程安全的类都属于这个类型。
1.1.4 线程兼容
- 线程兼容是指对象本身并不是线程安全的,但是可以在调用端正确地使用同步手段来保证对象在并发环境下可以安全使用。 Java中的大部分类都是线程兼容的,例如ArrayList和HashMap等。
1.1.5 线程对立
- 线程对立是指不管调用端是否采用了同步措施,都无法在多线程环境下并发使用代码。这种情况在Java中是比较少见的。
- 典型案例就是,一个Thread类的suspend()和resume()方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发的情况下,无论调用时是否执行了同步,目标线程都存在死锁的风险。
1.2 线程安全的实现方式
1.2.1 互斥同步
-
是最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,
保证共享数据在同一个时刻只能被一条线程使用
。 -
互斥是实现同步的一种手段。临界区、互斥量和信号量都是常见的互斥实现方式。
-
Java中,最基本的互斥手段就是
synchronized
关键字,这是一种块状结构的同步语法。被synchronized修饰的同步块对于同一条线程来说是可重入的,所以同一线程反复进入同步块也不会出现把自己锁死的情况。 被synchronized修饰的同步块在持有锁的线程执行完毕释放锁之前,将会阻塞后面其他线程的进入。 -
持有锁是一个重量级的操作。
-
重入锁是Lock接口(全新的互斥同步手段)最常见的一种实现形式,它与synchronized一样是可以重入的。ReentrantLock与synchronized相比增加了一些高级功能。
- 等待可中断:如果持有锁的线程长期不释放锁,其他等待线程可以选择放弃等待,改为处理其他事情。
- 公平锁:多个线程在等待同一个锁的时候,必须按照申请锁的时间顺序来依次获得锁。(非公平锁在锁释放的时候,任何线程都可能获得锁)
- 锁绑定多个条件:一个ReentrantLock对象可以绑定多个Condition对象。在synchronized中,锁对象的wait方法跟他的notify()或者notifyAll()方法配合可以实现一个隐含条件,但是要和多个条件关联,就得添加一个额外的锁。
1.2.2 非阻塞同步
- 互斥同步面临的问题是进行线程阻塞和唤醒所带来的性能开销,因此也叫作阻塞同步。
- 从解决问题角度来看,互斥同步属于一种悲观的并发策略,认为只要不去做同步措施,那肯定会出现问题。无论共享的数据是否会发生竞争,都会进行加锁。
- 乐观并发策略:不管风险,先进行操作,如果没有其他线程争用共享数据,那么操作就直接成功。如果共享的数据被争用,那么就进行补偿措施。(不断地重试,直到出现没有竞争的共享数据为止)
- 这种策略的实现不需要把线程阻塞挂起,因此这种同步操作称为非阻塞同步。
- 使用CAS(乐观锁)进行操作
1.2.3 无同步方案
- 不一定一定要进行阻塞或非阻塞同步,同步与线程安全没有必要的联系,同步只是保障在共性数据争用时的正确手段。因此会有一些代码天生就是安全的。
- 可重入代码:指可以在代码执行的任何时刻中断它、转而去执行另外一段代码,而在控制返回后,原本的程序不会出现任何错误,也不会对结果有所影响。
- 共同特征:不依赖全局变量、不依赖存储在堆上的数据和公用的资源、用到的状态量都由参数中传入、不调用非可重入的方法
- 如果一个方法的返回结果是可以预测的,即输入了相同的数据,返回的结果都相同。他就满足可重入性的要求,当然也是线程安全的。
- 线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否保证在同一个线程中执行。
- 例如生产者-消费者模式中的消费过程都限制在一个线程中消费完。
- ThreadLocal:使用ThreadLocal可以实现线程本地存储的功能。
2 锁优化
- 为了在线程之间高效地共享数据及解决竞争问题,HotSpot花费了大量的资源去实现各种锁优化技术。如适应性自旋、锁消除、锁粗化、轻量级锁、偏向锁等。
2.1 自旋锁与自适应自旋
- 互斥同步对性能最大的影响就是阻塞的实现,挂起线程和恢复线程的操作都要转入内核态中实现。共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。
- 如果一个物理机器上 有一个以上的处理器或者处理器核心 , 能让
两个线程同时并行执行
,我们就可以然后面请求锁的线程"稍等一会",但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放。 为了让线程等待,我们让线程执行一个忙循环,也就是自旋
。这项技术叫做自旋锁。
不过自旋会消耗处理器资源,如果锁被占用的时间长,这些资源的消耗是浪费的
- JDK6对自旋锁进行了优化,引入了
自适应的自旋
。 - 自适应意味着自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间和锁的拥有者的状态来决定。
- 如果虚拟机认为这次自旋很可能会成功,那么就允许自旋等待持续相对更长的时间。如果自旋很少成功获得过锁,甚至可能直接省略自旋的过程。
2.2 锁消除
- 锁消除指 虚拟机即时编译器在运行时检测 到某段需要同步的代码根本不可能存在共享数据竞争, 那么就对锁进行消除的优化策略。
- 锁消除的主要判断来自于逃逸分析的数据支持,如果判断一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,就可以把它们当作栈上的数据对待,认为是线程私有的。
可是变量是否逃逸,程序员自己是清楚的,为什么会在这种情况下还要求同步呢
那是因为Java中同步措施不是程序员加的,同步代码出现的次数很频繁。
public String concatString(String s1 , String s2 , String s3){
return s1 + s2 + s3;
}
- 这里的String是一个不可变的类,对字符串的连接操作是通过生成新的String对象来进行的。
- 但是Javac编译器会对String连接进行自动优化,会将字符串加法转化为StringBuffer对象的连续append操作。
而StringBuffer.append()方法中就有一个同步块,锁的就是sb对象。
- 而逃逸分析后发现,它的动态作用域被限制在concatString()内部,sb的所有引用不会逃逸到外部,其他线程无法访问,所以可以安全地消除掉。
2.3 锁粗化
- 在编写代码时,我们希望同步块的作用范围限制的小一点,在共享数据的实际作用域中进行同步。
- 一系列的连续操作都对同一个对象进行加锁和解锁,会带来不必要的损耗。
public String concatString(String s1 , String s2 , String s3){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
- 如果虚拟机检测到有一串零碎的操作都对同一个对象加锁,会将加锁同步的范围扩展(粗化)到整个操作序列的外部。
2.4 轻量级锁
- 轻量级锁是相对于操作系统互斥量来实现的传统锁而言,因此传统的锁机制被称为"重量级锁"。轻量级锁不是用来代替重量级锁的,而是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
- 对于HotSpot虚拟机的内存布局来说(尤其是对象头部分)
- HotSpot虚拟机的的对象头分为两部分,第一部分用于存储对象的自身运行时数据。(如哈希码、GC分代年龄)
- 另外一部分存储的是指向方法区对象类型数据的指针。
- 对象头的第一部分,官方称为"Mark Word",是实现轻量级锁的关键。
轻量级锁的工作过程
- 在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为"01")。那么虚拟机首先将在当前的线程的栈帧中创建一个名为锁记录(Lock Record)的空间。
- 此空间用于存储锁对象目前的Mark Word的拷贝。
- 虚拟机使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。获得锁
- 成功
- 代表该线程拥有了这个对象的锁,并且对象MarkWord的锁标志位变为"00",表示此对象属于轻量级锁状态。
- 失败
- 意味着至少存在一条线程与当前线程竞争获取该对象的锁
- 虚拟机会检查当前对象是否指向当前线程的栈帧
- 如果是,说明当前线程已经拥有这个对象的锁,那么直接进入同步块继续执行就可以
- 不是的话,说明已经被其他线程抢占,那轻量级锁(两条线程竞争)就不再有效,需要改为重量级锁,标志位变为"10"
再有竞争的情况下,轻量级锁反而比重量级锁更慢。
2.5 偏向锁
-
所谓偏向锁,就是从这个锁偏心,永远只给一个线程加锁。
-
也就是说,有一个线程访问了这个锁资源,那么就认为只有一个线程访问,完全没有其他线程访问。
所以完全忽略了同步问题
,让这个线程一直持有这个锁,哪怕退出了锁住的代码块也不解锁,减少了每次加锁、解锁带来的开销。
-
但是如果一旦有一个别的线程与它竞争这个锁,那么就会升级成轻量级锁。
-
工作过程
- 当一个线程访问同步代码块并获取锁时,虚拟机会把对象头中的标志位设置为"01",把偏向模式设置为"1",表示进入偏行模式。
- 同时通过CAS操作把当前线程的ID存储在Mark Word中。
- 在线程进入和退出同步代码块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里面是否存储着指向当前线程的偏向锁
- 一旦出现第二个线程来尝试获取这个锁的情况,偏行模式马上宣告结束。
- 它首先会暂停拥有偏向锁的线程,然后根据锁对象目前是否处于被锁定的状态来决定是否撤销偏向。