多线程与线程安全

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

AL3oVS.png

转载自姜函的:多线程与线程安全

多线程与线程安全

Java内存模型

线程不安全的源头

CPU、内存、I/O的恩怨情仇

这些年,我们的 CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。我们都知道就速度而言,CPU>>内存>>I/O设备,所以为了平衡这三者之间的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 增加了缓存,以均衡与内存的速度差异
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用

但是,是的,凡事都有但是,这些优化让我们享受的同时,也带来了线程不安全的问题。

源头之一:缓存导致的可见性

多核时代,每颗 CPU 都有自己的缓存,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。

img

源头之二:线程切换带来的原子性问题

操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片“。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如大家所熟悉的count++,这个语句需要3条指令才能执行:

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器

  • 指令 2:之后,在寄存器中执行 +1 操作

  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)

操作系统做任务切换,可以发生在任何一条CPU指令。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

img

源头之三:编译优化带来的有序性问题

有序性,顾名思义,有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。再次拿出这个臭名昭著的DCL单例模式来举栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

public class Singleton {

static Singleton instance;

static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}

}

这个创建单例的模式为什么在多线程的情况下会出问题呢?主要是在new Singleton()这一步上,我们凭直觉以为这个new操作是这样的:

  • 分配一块内存 M
  • 在内存 M 上初始化 Singleton对象
  • 然后 M 的地址赋值给 instance 变量

实际上优化后的执行顺序长这样:

  • 分配一块内存 M
  • 将 M 的地址赋值给 instance 变量
  • 最后在内存 M 上初始化 Singleton 对象

所以这种情况下当A线程执行这个new操作的时候,这个时候M的地址已经赋给instance 变量,但是还未初始化,这时另外一个线程B同时进入getInstance()方法,检测到instance不为null,直接返回,而返回的是一个无效值,会触发空指针异常。

Java内存模型的概念

Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节,此处的变量与Java编程中所说的变量略有区别,它包括了实例字段、静态字段和构成数组对象的元素,
但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不存在竟争问题。

Java内存模型规定了所有的变量都存储在主内存上,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递都需要通过主内存来传递,线程、主内存、工作内存的交互关系如下。

1553328940111

JDK在1.5之后对Java内存模型做了很大的修正,我们接下来的讨论也是基于JDK1.5之后的Java内存模型。

Happens-Before

happens—before规则是Java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,其意思就是说,在发生操作B之前,操作A产生的影响都能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等,它与时间上的先后发生基本没有太大关系。这个原则特别重要,它是判断数据是否存在竞争、线程是否安全的主要依据。具体包括:

  • 程序次序规则:在一个单独的线程中,按照程序代码的执行流顺序,(时间上)先执行的操作happen—before(时间上)后执行的操作
  • 管程锁定规则:一个unlock操作happen—before后面(时间上的先后顺序,下同)对同一个锁的lock操作
  • volatile变量规则:对一个volatile变量的写操作happen—before后面对该变量的读操作
  • 线程启动规则:Thread对象的start()方法happen—before此线程的每一个动作
  • 线程终止规则:线程的所有操作都happen—before对此线程的终止检测,可以通过Thread.join()方法结束或者Thread.isAlive()的返回值等手段检测到线程已经终止执行
  • 线程中断规则:对线程interrupt()方法的调用happen—before发生于被中断线程的代码检测到中断时事件的发生
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)happen—before它的finalize()方法的开始
  • 传递性:如果操作A happen—before操作B,操作B happen—before操作C,那么可以得出A happen—before操作C

这是Java中仅有的无须任何同步手段就可以保证先行发生规则,我们可以通过一个简单的getter/setter示例感受一下“先行发生”与”时间上的先后发生“有何区别:

1
2
3
4
5
6
7
8
9
private int value = 0;

public int getValue(){
return value;
}

public void setValue(int value){
this.value = value;
}

假设线程A先调用setValue(10),然后线程B再去调用同一个对象的getValue,那么线程B收到的值是10吗?我们可以根据上述的规则进行分析,没有一个规则适用,也就是说无法保证这两个操作的先行发生关系,所以线程B获得的结果是不确定的,换句话说这是线程不安全的。而让他变得线程安全也至少有两种简单的方案:

  • 给get/set方法加锁,这样就可以套用管程锁定规则
  • 让value定义为volatile变量,这样就能套用volatile变量规则

最轻量级的同步机制volatile

当一个变量为定义为volatile之后,将具备两个特性:

  • 可见性:这里的可见是指当一个线程修改了变量的值,其他线程可以立即看见。但是请注意,volatile可以保证并发情况下变量在各个线程中保证一致性,但是并不能说在并发条件下是线程安全的,因为volatile并不能保证原子性,我们可以用一个示例的来说明:
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
public class VolatileTest {

public static volatile int race = 0;

public static void increase(){
race++;
}

private static final int THREAD_COUNT = 20;

public static void main(String[] args){
for (int i = 0;i<THREAD_COUNT;i++){
new Thread(
() -> {
for (int j = 0;j<10000;j++){
increase();
}
}
).start();

}
//等待所有累加线程都结束
while (Thread.activeCount()>1){
Thread.yield();
}

System.out.println(race);
}
}

代码发起了20个线程,每个线程对race变量进行10000次更新,如果正确并发结果应该是200000。但是实际上每次的输出结果都不一样,都是一个小于200000的数。这是由于incerese()方法实际上由4条字节码组成,当getstatic指令将操作数取到栈顶时,race的值上正确的,但是执行icont_1,iadd指令的时候其他线程可能已经改变了race的值,在执行putstatic的时候就不再正确了。

1
2
3
4
5
6
7
8
9
10
public static void increase(){
Code:
Stack = 2,Local = 0,Args_size = 0;
0: getstatic
3:iconst_1
4:add
5:putstatic
8:return

}

volatile非常适合用作某个操作完成、发生中断或者状态的标志,如下图,调用shutdown方法后,所有线程会停止工作:

1
2
3
4
5
6
7
8
9
10
11
12
volatile boolean requested;

public void shutdown(){
requested = true;
}

public viod doWork(){
while(!requested){
// do something

}
}
  • volatile的第二个作用是特性是禁止指令重排序,volatile变量赋值后,会多执行一个“lock add1 $0X0”操作,这个操作相当于一个内存屏障,指令重排时不能把后面的指令重排序到内存屏障之前的位置。还是拿出我们上面那个DCL单例的模式来,这次我们使用volatile变量后,就可以正确的实现单例模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {

static volatile Singleton instance;

static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
// 由于是volatile变量,此处不会进行重排序
instance = new Singleton();
}
}
return instance;

}

}

通过上述的情况我们大概了解了volatile的基本特性,也知道了volatile是一个轻量级的同步机制,在某些情况下,volatile的性能会优于锁的性能,但是经过虚拟机对锁的优化和消除后,并不能量化的认为volatile就比synchronized快多少,所以我们选择volatile的唯一依据应该是它能否满足我们的应用需求,总结一下,当且仅当以下条件满足时,应该使用volatile变量:

  • 对变量的写入不依赖变量的当前值,或者能确保只有单个线程更新变量的值;
  • 改变量不会与其他状态变量一起纳入不变性条件中;
  • 访问变量时不需要加锁

Java与线程

线程的实现

实现线程主要有三种方式:使用内核线程实现,使用用户线程实现,使用用户线程加轻量级进程混合实现。

1.使用内核线程实现

内核线程(Kernel Thread, KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程都可以看做是内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫多线程内核(Multi-Threads Kernel)。程序一般不会直接去使用内核线程,而是去使用内核线程的种高级接口–轻量级进程(Light Weight Process, LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。如图所示:

1553339177594

由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作,但是轻量级进程具有它的局限性:首先,由于是基于内核线程实现的,所以各种进程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode )和内核态(Kernel Mode)中来回切换其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。

2.使用用户线程实现

广义上来讲,一个线程只要不是内核线程,那就可以认为是用户线程(UserThread, UT ) ,因此从这个定义上来讲轻最级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制。 而狭义上的用户线程指的是完全建众在用户空间的线程库上,系统内核不能感知到线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型,如图所示。

img

使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理,而线程的创建、切换和调度都是需要考虑问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至不可能完成。因而使用用户线程实现的程序一般都比较复杂,除了以前在不支持多线程的操作系统中(如Dos)的多线程程序与少数有特殊需求的程序外,现在使用用户线程的程序越来越少了,Java, Ruby等语言都曾经使用过用户线程,最终又都放弃了
使用它。

3.混合实现

线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式。在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射。井且用户线程的系统调用要通过轻量级线程来完成,大大降低了进程被阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不的,是M:N的关系,如图所示,这种就是多对多的线程模型。

img

4.Java线程的实现

Java线程在JDK1.2之前,是基于名为“绿色线程" (Green Threads)的用户线程实现的,而在JDK l .2中,线程模型被替换为基于操作系统原生线程模型来实现。因此在目前的JDIC版本中,操作系统支持怎样的线程模型,在很大程度上就决定了Java虚拟机的线程是怎样映射的,这点在不同的平台上没有办法达成一致,虚拟机规范中也并未限定Java线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是透明的。对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型来实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的。

线程的调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种。分别是协程式(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。

协程式线程调度

如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。协同式多线程的最大好处是实现简单,而且由于线程要把白己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题。它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。很久以前的Windows 3.x系统就是使用协同式来实现多进程多任务的,那是相当的不稳定,一个进程坚持不让出CPU执行时间就会导致整个系统的崩溃。

抢占式线程调度

如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。

虽然说Java线程调度是系统自动完成的,但是我们还是可以“建议”系统给某些线程多分配一点执行时间,另外的此线程则可以少分配一点。这项操作可以通过设置线程优先级来完成。Java语言一共设置了10个级别的线程优先级,在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。但是这种方法并不靠谱,因为Java的线程上被映射到系统的原生线程上实现的,所以最终线程调度还是由操作系统说了算,而不同操作系统的线程优先级不能保证与Java中的线程优先级一 一对应,同时有些操作系统如“Windows”存在一个“线程推进器”,它的大致作用是发现线程运行的特别“勤奋”,会越过线程优先级为线程分配时间。

线程状态

Java中的线程一共有六种状态:

  • NEW(初始化状态)
  • RUNNABLE(可运行 / 运行状态)
  • BLOCKED(阻塞状态)
  • WAITING(无时限等待)
  • TIMED_WAITING(有时限等待)
  • TERMINATED(终止状态)

线程可以在这六种状态之间相互转换,如图所示:

1553349267664

从 NEW 到 RUNNABLE 状态
  • 每个线程刚被创建的时候都是出于new状态,而要转换到RUNNABEL状态很简单,调用线程的start方法即可。
RUNNABLE 与 BLOCKED 的状态转换

只有一种场景会触发这种转换:

  • 就是线程等待 synchronized 的隐式锁。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。
RUNNABLE 与 WAITING 的状态转换

总的来说,有三种场景会触发这种改变

  • 第一种场景,获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。
  • 第二种场景,调用无参数的 Thread.join() 方法。其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
  • 第三种场景,调用 LockSupport.park() 方法。其中的 LockSupport 对象,也许大家有点陌生,其实 Java 并发包中的锁,都是基于它实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。
RUNNABLE 与 TIMED_WAITING 的状态转换

有五种场景会触发这种转换:

  • 调用带超时参数的 Thread.sleep(long millis) 方法;
  • 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
  • 调用带超时参数的 Thread.join(long millis) 方法;
  • 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
  • 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。

这里你会发现 TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了带超时参数.

从 RUNNABLE 到 TERMINATED 状态
  • 线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断 run() 方法的执行,例如 run() 方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?Java 的 Thread 类里面倒是有个 stop() 方法,不过已经标记为 @Deprecated,所以不建议使用了。正确的姿势其实是调用 interrupt() 方法。

线程安全

Java语言的线程安全

什么是线程安全?《Java Concurrency In Practice》的作者Brian Goetz对“线程安全”有一个比较恰当的定义:

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

已经有了线程安全的一个抽象定义,那接下来我们就讨论一下在Java语言中,线程安全具体是如何体现的以及有哪些操作是线程安全的。为了更深入理解线程安全,我们可以不把线程安全当做一个非真既假的二元排他概念,按照线程安全的“安全程度”由强到弱,我们把Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1.不可变

在Java语言里不可变( Immutalaie)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何的线程安全保障措施,只要一个不可变的对象被正确地构建出来,那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性是最简单最纯粹的。比如String类型就是一个经典的不可变对象,我们调用substring(),replace()等方法时都会返回一个新的字符串对象,而不会影响原来的值。保证对象行为不影响自己状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为final。这样在构造函数结束之后,它就是不可变的。

2.绝对线程安全

绝对的线程安全完全满足Brian Goetz给出的线程安全的定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的,甚至是不切实际的代价。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。我们可以通过Java API中一个不是“绝对线程安全”的线程安全类来看看这里的“绝对”是什么意思。比如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
public class AbsoluteThreadSafeTest {


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);
}

new Thread(()->{
for (int i=0;i<vector.size();i++){
vector.remove(i);
}
}).start();

new Thread(()->{
for (int i=0;i<vector.size();i++){
vector.get(i);
}
}).start();

//避免创建过多线程
while (Thread.activeCount()>20);
}
}
}

虽然vector的get(),size()以及get()方法都是同步的,但是上面的代码仍然会抛出ArrayIndexOutOfBoundsException异常,我们仍然需要通过加锁的方式来保证这段代码的线程安全性。

3.相对线程安全

相对的线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。上面的栗子就是相对线程安全的一个很明显的案例。在Java语言中,大部分的线程安全类都属于这种类型,例如Vector, HashTable,
Collections的synchronicedCollectiont()方法包装的集合等。

4.线程兼容

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

5.线程对立

线程对立是指无论使用何种同步手段,代码都无法再多线程情况下并发使用的代码。由于Java语言的代码天生具有多线程性,所以这种情况极少出现。而且线程对立的代码是有害的,应该避免。

一个线程对立的例子是Thread类的suspend()和resume()方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都是存在死锁风险的,如果suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。也正是由于这个原因,这两个方法已经被JDK声明废弃(@Deprecated )了。常见的线程对立的操作还有System.setIn(). Sytem.setout()和System. runFinalizersOnExit()等。

实现线程安全的方法

在了解了什么是线程安全后,我们接下来了解一些实现线程安全的方法。

1.互斥同步

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

根据Java虚拟机的规范,在执行monitorenter指令时,首先会去尝试获取对象的锁。如果这个对象没被锁定,或者当前程序已经获取了该对象的锁,则锁的计数器加1。相应的,在执行monitorexit指令时,如果计数器减1,当计数器为0,则代表锁被释放。

在虚拟机规范对monitorenter和monitnrexit的行为描述中,有两点是需要特别注意的:

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

2.非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也被称为阻塞同步(Blocking Synchronization),另外,它属于一种悲观的并发策略。总是认为只要不去做正确的同步措施(加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁(这里说的是概念模型。实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。随着硬件指令集的发展,我们有了另外一个选择。基于冲突检测的乐观并发策略,通俗地说就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再进行共他的补偿措施(最常见的补偿措施就是不断地重试,直到试成功为止〕,这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization)。

能够帮助我们实现非阻塞同步的指令包括:

  • 测试并设置(Test-And-Set)
  • 获取并增加(Fetch-and-Increament)
  • 交换(Swap)
  • 比较并交换(Compare-and-Swap,CAS)
  • 加载链接/条件存储(Load-Linked/Store-Conditional,LL/SC)

接下来我们重点讨论一下CAS指令。CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执
行时,当且仅当V符合旧预期值A时,处理器用新值S更新V的值,否则它就不执行更新,但是不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。

我们可以通过Java API来间接使用Unsafe类中提供的CAS操作,比如AtomicInteger类中的incrementAndGet(),getAndAdd()等方法,我们可以用volatile无法保证原子操作的示例来演示一下CAS用来避免阻塞同步:

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
public class AtomicTest {

public static AtomicInteger race = new AtomicInteger(0);

public static void increase(){
race.incrementAndGet();
}

private static final int THREAD_COUNT = 20;

public static void main(String[] args){
for (int i = 0;i<THREAD_COUNT;i++){
new Thread(
() -> {
for (int j = 0;j<10000;j++){
increase();
}
}
).start();

}
while (Thread.activeCount()>1){
Thread.yield();
}

System.out.println(race);
}
}

3.无同步手段

要保证线程安全不一定需要同步,在Java中有些代码天生就是线程安全的,我们简单的来讨论一下常见的两类。

可重入性的代码

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

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

线程封闭

当访问共享的可变数据是,如果能限制在仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭。Java元语言没有强制规定某个变量必须由锁保护,同样也无法强制将对象封闭在某个线程中。Java语言及其核心库提供了一些机制帮助维持线程封闭性,比如局部变量和ThreadLocal类。

栈封闭

栈封闭上线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中,它们位于执行线程的栈中,其他线程无法访问。

对于基本类型的局部变量,例如下面代码中loadTheArk方法中的numPairs,由于任何方法都不能获得对基本类型的引用,因此Java语言的这种语义保证了基本类型的局部变量始终封闭在线程内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public int loadTheArk(Collection<Animal> candidates){

SortedSet<Animal> animals;

int numPairs = 0;

Animal candidate = null;

animals = new TreeSet<Animal>();
animals.addAll(candidates);
for (Animal a: animals){
//do something
numPairs++;
}

return numPairs;

}

而在维持对象引用的栈封闭性时,我们需要使用一些手段保证被引用的对象不会逸出。比如我们在上面代码中实例化了一TreeSet对象,并将指向它的一个引用保存在animals中。此时,只有一个引用指向animals,这个引用被封闭在局部变量中,因此也封闭在执行线程中。然而,如果我们发布了animals的引用或者改对象中的任何数据的引用,这都会导致封闭性被破坏。

ThreadLocal类

Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字声明它为“易变的”:如果一个变量要被某个线程独享,就可以通过ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程ThreadLocalMap的访问入口,每个ThreadLocal对象都包含一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。需要注意的是,ThreadLocal变量类似于全局变量,会降低代码的可重入性,因此使用时要格外小心。

参考资料

  • 《深入理解Java虚拟机》第四版
  • 《Java并发编程实战》
  • 极客时间《Java并发编程实战》
0%