线程安全性

什么是线程安全性

线程是CPU执行的基本单位,进程是CPU分配的基本单位。

多线程访问某个类时,不管运行环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或者异步,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

示例:一个无状态的Servlet

1
2
3
4
5
6
//Servlet容器是一个单例多线程的容器对象
public class StatelessServlet implements Servlet {
public void service(String param) {
//多线程调度该方法
}
}

StatelessServlet是无状态的,它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态存储在线程栈帧的局部变量表中,并且只能由正在执行的线程访问。

访问StatelessServlet的线程不会影响另一个访问StatelessServlet的线程计算结果。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此,无状态对象一定是线程安全的。

原子性

当我们在无状态对象中增加一个状态时,会出现什么情况?假设我们希望增加一个 “命中计数器” 来统计所处理的请求数量。一种直观的方法是增加一个long类型的域,并且每次处理一个请求就将这个值加1。

1
2
3
4
5
6
7
8
@NotThreadSafe
public class UnsafeCountingServlet implements Servlet {
private long count = 0;
public void service(String param) {
//执行方法逻辑
++count;
}
}

不幸的是,UnsafeContingServlet并非线程安全的,尽管它在单线程环境中能够正确运行,这个类可能会丢失一些更新操作,++count并非原子的,它包含了三个独立的操作 :读取count值,将值加1,然后将计算结果写入count。这是一个 “读取-修改-写入” 的操作,并且其结果状态依赖于之前的状态。

上述例子给出了两个线程在没有同步的情况下,同时对一个计数器执行递增操作时发生的情况。如果计数器的初始值为9,那么在某些情况下,每个线程读到的值都为9,接着执行递增操作,并且都将计数器的值设置为10。命中计数器的值就讲偏差1。

竟态条件

在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竟态条件(Race Condition)

最常见的竟态条件类型就是 “先检查后执行(Check-then-Act)” 操作,即通过一个可能失效的观测结果来决定下一步的动作。

1
2
3
4
5
6
7
8
9
@NotThreadSafe
public class LazyInitRace {
private Object instance = null;
public Object getInstance() {
if(instance == null)
instance = new Object();
return instance;
}
}

LazyInitRace中包含一个竟态条件,它可能会破坏这个类的正确性。假定 线程A 和 线程B 同时执行getInstance,A看到instance为空,因而创建一个新的Object实例,B同样需要判断instance是否为空,此时的instance是否为空,取决于不可预测的时序,包括线程的调度方式,以及A需要花多长时间来初始化Object并设置instance,如果当B检查时,instance为空,那么两次调用getInstance时可能会得到不同的结果。

UnsafeCountingServlet的统计命中计数器中存在另一种竟态条件。在 “读取-修改-写入” 操作中,基于对象之前的状态来定义对象状态的转换,要递增一个计数器,你必须知道它之前的值,并确保在执行更新的过程中没有其他线程会修改或使用这个值。

1
2
3
4
5
6
7
8
@ThreadSafe
public class CountingServlet implements Servlet {
private final AtomicLong count = new AtomicLong(0);
public void service(String param) {
//执行方法逻辑
count.incrementAndGet();
}
}

java.util.concurrent.atomic包中包含一些原子变量类,通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问都是原子的。

内置锁

Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block),同步代码块包括两个部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。

以关键字synchronized来修饰方法就是一种横跨整个方法体的同步代码快,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。

1
2
3
synchronized (lock) {
//访问或者修改由锁保护的共享状态
}

每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁或监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。

Java的内置锁相当于一种互斥锁,最多只有一个线程能够持有这种锁,当 线程A 尝试获取一个由 线程B 持有的锁时,线程A 必须等待或者阻塞,直到 线程B 释放这个锁,如果B永远不释放锁,那么A也将永远地等下去。

重入

当某个线程请求一个由其他线程持有的锁时,发出的请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它持有的锁,那么这个请求就会成功。

“重入”获取锁的操作粒度是“线程”,重入的实现方法是,为每一个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有,当线程请求一个未被持有的锁时,JVM将几下锁的持有者,并且将获取计数值置为1,如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码快时,计数器会相应递减,当计数值为0时,这个锁将被释放。

1
2
3
4
5
6
7
8
9
10
11
public class Father {
public synchronized void method() {
//逻辑
}
}
public class Son extends Father {
public synchronized void method() {
System.out.println("calling");
super.method();
}
}

子类改写了父类的synchronized方法,然后调用父类中的方法,由于FatherSon都是synchronized方法,因此每个method方法在执行前都会获取Father上的锁,然而如果内置锁不是可重入的,那么在调用super.method()时将无法获得Father上的锁。

对象的共享

可见性

1
2
3
4
5
6
@NotThreadSafe
public class MutableInteger {
private int value;
public int get() { return value;}
public void set(int value) {this.value = value;}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) {
MutableInteger object = new MutableInteger();
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
int i = object.get();
System.out.println(i + "-" + new Date().toString());
if (i == 100) {
break;
}
}
}
});
thread.start();
}
object.set(100);
System.out.println(new Date().toString());
}

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
100-Thu Apr 16 16:17:04 CST 2020
0-Thu Apr 16 16:17:04 CST 2020
100-Thu Apr 16 16:17:04 CST 2020
100-Thu Apr 16 16:17:04 CST 2020
0-Thu Apr 16 16:17:04 CST 2020
100-Thu Apr 16 16:17:04 CST 2020
100-Thu Apr 16 16:17:04 CST 2020
100-Thu Apr 16 16:17:04 CST 2020
100-Thu Apr 16 16:17:04 CST 2020
设置Value时间:Thu Apr 16 16:17:04 CST 2020
100-Thu Apr 16 16:17:04 CST 2020
0-Thu Apr 16 16:17:04 CST 2020
100-Thu Apr 16 16:17:04 CST 2020
0-Thu Apr 16 16:17:04 CST 2020
100-Thu Apr 16 16:17:04 CST 2020

MutableInteger不是线程安全的,因为 get 和 set 都是在没同步的情况下访问 value 的,如果某个线程调用了set,那么另一个正在调用 get 的线程可能会看到更新后的 value 值,也可能看不到。

1
2
3
4
5
6
@ThreadSafe
public class SynchronizedInteger {
private int value;
public synchronized int get() { return value;}
public synchronized void set(int value) {this.value = value;}
}

Volatile变量

volatile变量用来确保将变量的更新操作通知到其他线程,当把变量申明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序,因此在读取volatile类型的变量时总会返回最新写入的值。

1
2
3
4
5
6
@ThreadSafe
public class VolatileInteger {
private volatile int value;
public int get() { return value;}
public void set(int value) {this.value = value;}
}

然而在访问volatile时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。

从内存的可见性角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块,但并不建议过度依赖volatile变量提供的可见性,如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,更难以理解。

尽管volatile只能确保可见性,在复合操作情况下,无法确保其正确性,如count++,具备了”读取-修改-写入”的复合操作

加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

当且仅当满足以下所有条件时,才应该使用volatile变量:

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

逸出

提供一个对象的引用给作用域之外的代码,我们称做发布该对象。如果在对象构造完成之前就发布该对象,就会破坏线程安全性,当某个不应该发布的对象被发布时,这种情况被称为逸出。

1
2
3
4
5
6
7
8
9
10
11
public class ThisEscape {
public ThisEscape() {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
//多线程代码
}
});
thread.start();
}
}

ThisEscape中给出了一个this引用在构造函数中逸出的示例。this引用逸出的一个常见错误是,在构造函数中启动一个线程,当对象在其构造函数中创建一个线程时,this引用都会被新创建的线程共享,在对象未完全构造之前,新的线程就可以看见它。在构造函数创建线程并没有错误,但最好不要立即启动它。

当且仅当对象的构造函数返回时,对象才处于可预测和一致的状态。当从构造函数中发布对象时,只是发布了一个尚未构造完成的对象,即使发布语句位于构造函数的最后一行,那么这种对象被认为是不正确构造。

不要再构造过程中使this引用逸出。

线程封闭

当访问共享的可变数据时,通常需要使用同步,一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭(Thread Confinement),它是实现线程安全性的最简单方式之一。

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

1
2
3
4
public int execute() {
List<Object> list = new ArrayList<>();
return 0;
}

此时只有一个引用指向集合list,这个引用被封闭在局部变量中,因此也被封闭在执行线程中,然而如果发布了对集合list(或者该对象中的任何内部数据)的引用,那么封闭性呗破坏,并导致对象list逸出。

不变性

如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,他们的不变性条件是由构造函数创建的,不可变对象一定是线程安全的。

不可变性不等于将对象所有域都声明为final类型,即使对象中所有域都是final类型的,这个对象也仍然是可变的,因为final类型的域中可以保存对可变对象的引用。

1
2
3
4
5
6
public final class MutableClass {
private final List<String> lists = new ArrayList<>();
public void setValue(String value) {
lists.add(value);
}
}

当满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态就不能修改。
  • 对象的所有域都是final类型。
  • 对象是正确创建的(在对象的创建期间,this引用没有逸出)
1
2
3
4
5
6
7
8
9
10
public final class ImmutableClass {
private final List<String> lists = new ArrayList<>();
public ImmutableClass() {
lists.add("apple");
lists.add("banana");
}
public boolean isContains(String value) {
return lists.contains(value);
}
}

即使对象是可变的,通过将对象的某些域声明为final类型,仍然可以简化对状态的判断,相当于限制了该对象可能的状态变化。

除非需要更高的可见性,否则应将所有域都声明为私有域是一个良好的变成习惯,除非需要某个域是可变的,否则应将其声明为final域,也是一个良好的变成习惯。

要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见,一个正确构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到valatile类型的域或者AtomicReferance对象中
  • 将对象的引用保存到某个正确的构造对象的final类型域中。
  • 将对象的引用保存到一个由锁保护的域中

在线程安全容器内部同步意味着,将对象放到某个容器,例如Vector或者synchronizedList时将满足上述最后一条需求。

线程安全库中的容器提供了以下的安全发布保证:

  • 通过将一个键或者值放入HashtablesynchronizedMap或者ConcurrentMap中,可以安全将它发布给任何从这些容器中访问它的线程。
  • 通过将某个元素放入VectorCopyOnWriteArrayListCopyOnWriteArraySetSynchronizedList或者synchronizedSet中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。

事实不可变对象

如果对象从技术上来说事可变的,但其状态在发布后不会再改变,那么把这种对象称之为事实不可变对象。这些对象不需要满足不可变性的严格定义,在这些对象发布后,程序只需将它们视为不可变对象即可。

在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。

可变对象

如果对象在构造后可以修改,那么安全发布只能确保 “发布当时” 状态的可见性,对于可变对象,不仅在发布对象时需要使用同步,而且每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者由某个锁保护起来。

对象发布需求取决于它的可变性:

  • 不可变对象可以通过任意机制来发布。
  • 事实不可变对象必须通过安全方式来发布。
  • 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来的。

“本篇文章主要摘自《JAVA 并发编程实战》”

最后更新: 2020年09月24日 18:37

原始链接: https://midkuro.gitee.io/2020/05/21/thread-safety/

× 请我吃糖~
打赏二维码