并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类压榨计算机运算能力最有力的武器。

概述

在软件业发展的初期,程序编写都是以算法为核心的,程序员会把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据,这种思维方式是直接站在计算机的角度去抽象问题和解决问题,称为面向过程的编程思想。与此相对,面向对象的编程思想则站在现实世界的角度去抽象和解决问题,它把数据和行为都看作是对象的一部分,这样可以让程序员能以符合现实世界的思维方式来编写和组织程序。

人们很难想象现实中的对象在一项工作进行器件,会被不停地中断和切换,对象地属性可能会在中断期间被修改和变脏,而这些事件在计算机世界中是很正常地事情。良好地设计原则不得不向现实做出一些让步,我们必须让程序在计算机中正确无误地运行,然后再考虑如何将代码组织得更好,让程序运行得更快。

线程安全

“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下得调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协同操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。”

—— 《Java Concurrency In Practice》的作者Brian Goetz对“线程安全”的定义

这个定义很严谨,它要求了线程安全的代码必须都具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关系多线程的问题,更无须自己实现任何措施来保证多线程的正确调用。这点并不容易做到。

Java语言中的线程安全

我们讨论线程安全,就限定于多个线程之间存在共享数据访问这个前提,因为如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度上看,程序时串行执行还是多线程执行对它来说是完全没有区别的。

按照线程安全的“安全程度”由强至弱来排序,可以将Java语言中各种操作共享的数据分为五类:不可变、绝对线程安全、相对线程安全、县城兼容和线程对立。

不可变

在Java语言里面,不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何的线程安全保障措施,只要一个不可变的对象被正确的构建出来(没有发生this逃逸的情况),那其外部的可见状态永远不会改变,永远也不会看到它再多个线程之中处于不一致的狂态。“不可变”带来的安全性是最简单最纯粹的。

如果共享数据是一个一本数据类型,那么只要再定义时使用final关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行。java.lang.String类的对象,是一个典型的不可变对象,我们调用它的substring()replace()concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。

保证对象行为不影响自己状态的途径由很多种,其中最简单的就是把对象中带有状态的变量都声明为final,这样在构造函数结束之后, 它就是不可变的。

java.lang.Integer构造函数,它通过将内部状态变量value定义为final来保障状态不变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* The value of the {@code Integer}.
*
* @serial
*/
private final int value;

/**
* Constructs a newly allocated {@code Integer} object that
* represents the specified {@code int} value.
*
* @param value the value to be represented by the
* {@code Integer} object.
*/
public Integer(int value) {
this.value = value;
}

在Java API中符合不可变要求的类型,除了String之外,常用的还有枚举类型,以及java.lang.Number的部分子类。

绝对线程安全

绝对线程安全完全满足Brian Goetz给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的,甚至是不切实际的代价。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。

java.util.Vector是一个线程安全的容器,因为它的add()get()size()这类方法都是被synchronized修饰的,尽管这样效率很低,但确实是安全的。但是,即使它所有的方法都被修饰成同步,也不意味着调用它的时候永远都不再需要同步手段了。

代码清单,对Vector线程安全的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.cayzlh.jvmdemo;

import java.util.Vector;

/**
* @author Ant丶
*/
public class VectorSaveTest {

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) {

}
}
}

}

运行结果:

对Vector线程安全的测试
对Vector线程安全的测试

尽管这里使用到的Vectorget()remove()size()方法都是同步的,但是在多线程的环境中,如果不在方法调用端做额外的不同措施,使用这段代码仍然是不安全的,因为如果另一个线程切好在错误的时间里删除了一个元素,导致序号i已经不再可用的话,get()方法就会抛出一个ArrayIndexOutOfBoundsException

要保证这段代码能正确地执行下去,要对removeThreadprintThread做一些修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
}
});

Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
System.out.println(vector.get(i));
}
}
}
});

相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全, 它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

在Java语言中,大部分的线程安全类都属于这种类型,例如VectorHashTableCollectionssynchronizedCollection()方法包装的集合等。

线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中安全地使用,我们平常说一个类不是线程安全的,绝大多数指的都是这种情况。Java API中大部分的类都是线程兼容的,如VectorHashtable相对应的集合类ArrayListHashMap等。

线程对立

线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码很少出现,而且通常是有害的,应尽量避免。

一个线程对立的例子是Thread类的suspend()resume()方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,如果并发进行的话, 无论调用时是否进行了同步,目标线程都是存在死锁风险的,如果suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定产生死锁了。

线程安全的实现方法

如何实现线程安全与代码的编写有很大的关系,但虚拟机提供的同步和锁机制也起到了非常重要的作用。

互斥同步

互斥同步是最常见的一种并发正确性保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。互斥是因,同步是果,互斥是方法,同步是目的。

在Java里面,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorentermonitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是对这个参数的reference;如果没有明确指定,那就根据synchronized修饰的实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。

在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应地,在执行monitorexit指令时会讲锁计数器减1,当计数器为0时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

首先synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。Java的线程是映射到操作系统的原声线程上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块,状态转换消耗的时间可能比用户代码执行的时间还要常。所以synchronized是Java语言中的一个重量级的操作,在必要的情况下才使用这种操作。虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中。

非阻塞同步

互斥同步最要的问题是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也被称为阻塞同步。另外,它属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出问题,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。

随着硬件指令集的发展,有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再进行其他的补偿措施(不断地重试,直到成功为止),这种乐观的并发策略的许多首先都不需要把线程挂起,因此这种同步操作被称为非阻塞同步。

无同步方案

要保证线程安全,并不是以顶要进行同步,两者没有因果关系。同步只是保障共享数据竞争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的:

可重入代码

这种代码也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序出现任何错误。相对线程安全来说, 可重入性是更基本的特性,它可以是线程安全,即所有的可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。

可重入代码有一些共同的特征:例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。判断代码是否具备可重入:如果一个方法,它返回的结果是可以预测的,只要输入了相同的数据,就能返回相同的结果,那它满足可重入性的要求,当然也就是线程安全的。

线程本地存储

一段代码中所需要的数据必须与其他代码共享,那就看看能否否正这些共享数据在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。最常见的应用就是消费队列的架构模式(生产者-消费者模式),都会讲产品的消费过程尽量在一个线程中消费完,其中最重要的一个应用实例就是Web交互模型中的“一个请求对应一个服务器线程的处理方式,这种处理方式的广泛应用使得Web服务端的很多应用都可以使用线程本地存储来解决线程安全问题。”

Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字声明它为“易变的”;如果一个变量要被某个线程独享,可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

锁优化

虚拟机开发团队实现各种锁优化技术,如适应性自旋、锁消除、锁粗化、轻量级锁、偏向锁等,这些技术都是为了线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。

自旋锁与自适应自旋

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转内核态中完成,这些操作给系统的并发性能带来了很大的压力。同时,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时执行,就可以让后面请求锁的那个线程“稍等一会儿”,但不放弃处理器的执行时间,看啊可能持有锁是否很快就会释放锁。为了让线程等待,只须让线程执行一个忙循环(自旋),这项技术就是所谓自旋锁。

自旋等待不能代替阻塞,先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会拜拜消耗处理器资源,而不会做任何游泳的工作,反而会带来性能的浪费。因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。

自适应自旋锁,意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。另外一方面,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,一面浪费处理器资源。

有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越“聪明”。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

锁粗化

原则上,在编写代码的时候,推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

如果一系列连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程的竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

如果虚拟机探测到有这样的一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,在第一个操作之前直至最后一个操作之后,这样只需要加锁一次就可以了。

轻量级锁

轻量级是相对于操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为重量级锁。

轻量级锁并不是用来代替重量级锁的,它本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

在代码进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的Mark Word的拷贝。然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为“00”,即表示此对象出于轻量级锁的状态。

如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。

轻量级锁能替身程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS避免了使用互斥量的开销,但如果存在锁竞争,出了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁回避传统的重量级锁更慢。

偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除,连CAS操作都不做了。

偏向锁的“偏”,意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程的获取,则持有偏向锁的线程将永远不需要再进行同步。

当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定的状态,后续的同步操作就如轻量级锁那样执行。

偏向锁、轻量级锁的状态转化及对象Mark Word的关系
偏向锁、轻量级锁的状态转化及对象Mark Word的关系

偏向锁可以提高带有不同但无竞争的程序性能。它同样是一个带有利益权衡性质的优化,也就是说它不一定总是对程序运行有利,如果程序中大多数锁都总是被多个不同的线程访问,那偏向模式就是多余的。

小结

介绍了线程安全涉及的概念和分类、同步实现的方式及虚拟机的底层运作原理,并且介绍了虚拟机实现高效并发所作的一系列锁优化措施。

参考

  • 周志明,深入理解Java虚拟机:JVM高级特性与最佳实践,机械工业出版社