Java Concurrency - Basic Concept

这篇博文是Java Concurrency系列博文的第一篇,主要是来介绍多线程编程需要了解的一些基础,希望能够帮助你更好的理解多线程编程。

多线程

多线程在很多应用中都有使用。比如在GUI系统中,通常会单独为UI创建一个线程,以便提供更好的人机交互,在界面上给用户及时的操作反馈,而在服务器程序中,为了提高资源的利用率和吞吐量,也会利用到多线程。

线程,有的时候,也被称为轻量级的进程。现在的操作系,很多都是将线程,而不是进程,作为调度的基本单元。正是因为以线程作为基本调度单元,在同一时间,单线程程序至多只能在一个处理器上运行。

多线程可以让一个进程中同时存在多个控制流,这些控制流相互共享资源,比如内存和文件。每一个线程都拥有自己的程序指针(program counter),栈和本地变量。通过适当的分解,线程可以在多处理器系统环境下更好的利用硬件并行能力:同个程序的多个线程可以在多个CPU上并行运行。

我们总是习惯于编写顺序式的程序,因为它非常符合我们日常的工作习惯:我们总是习惯每次只做一件事,然后一件一件往下做。但是多线程无处不在,就算你的程序没有显式地创建线程,你所使用到的框架也很有可能会帮你创建线程,而跟这些框架交互的代码是需要由你来保证线程安全的。因此作为程序员,我们是必须要了解多线程。

PS:本文主要参考了Java Concurrentcy in Pratice一书,如有纰漏,请谅解。


风险

首先,我们必须先来了解下多线程可能带来的风险,以便更好的利用多线程。

1.安全的风险(不好的事情会发生),提高错误出现的几率。

线程在运行的时候,往往是交替运行的,如果没有合适的同步机制,各个线程的操作顺序是无法预料的,甚至是出乎意料的。比如说,下面的UnsafeSequence,本来是用来产生一系列的不重复的整数。在单线程环境下,这是完全没问题的。但是在多线程环境下,大部分情况下却都是失败的。value++看起来虽然只是一条语句,实际上却是有三个步骤:读取value,将value的值加一,然后将新值写入value。很有可能两个线程同时读取value值,并同时将value的值加一,因此这两个线程都将会获得相同的值。

java
1
2
3
4
5
6
7
8
9

@NotThreadSafe
public class UnsafeSequence {
private int value;
/** Returns a unique value. */
public int getNext() {
return value++;
}
}

2.活性的风险(好的事情不会发生),如某些代码不会执行,出现死锁、活锁以及饥饿。

比如,如果线程A在等待一个被线程B持有的资源,而线程B却也在等待一个被线程A持有的资源,则这两个线程都会一直等待下去,永远都不会结束。

3.性能的风险,不好的多线程编程,不但不能提高性能,反而可能会危害性能

使用多线程,能够让我们的程序更好的利用资源和提高吞吐率,但是如果一个应用有太多的线程,进程间的切换也会带来很大的开销。每次进程切换,调度器都需要暂停当前运行的线程,保存当前的上下文,然后恢复另一个线程的上下文,以便让这个线程可以运行。所以上下文切换太过频繁的话,CPU就需要花费大量的时间来切换线程,而不是执行它们。


线程安全

所以怎样才是线程安全呢??

总的来说,当多个线程访问一个类时,如果1)不用考虑这些线程在运行时环境下的调度和交替执行;2)不需要额外的同步机制,或在调用方代码不必做其他的协调,这个类的行为仍然是正确的话,则称这个类是线程安全的。

任何程序,如果在单线程环境下都不能确保操作的正确性,在多线程环境下也不可能是线程安全的。

有一种情况,你可以完全无需考虑线程安全,那就是无状态类。如果一个类既没有自己的状态域,也没有引用其他类的域,那这个类就是无状态类。无状态对象永远是线程安全的。除此之外,你必须好好考虑考虑了。

实际上,线程安全的类的核心,都是将状态封装起来。线程安全代码表面上是关于代码,但它实际上是针对状态的。为了达到线程安全,你必须确保封装状态的全部代码都是线程安全的,这些代码可能是一个对象,或者甚至整个程序。好的封装措施可以更简单的使我们的程序线程安全,同时有助于维护。封装后,外面的代码无法直接访问状态变量,我们只需要保存该对象本身时线程安全的就行。

编写线程安全的代码,实质是管理对状态的访问,尤其是那些共享、可变的状态。对象的状态包括任何能影响它外部可见行为的数据。

一个共享变量,是指可以被多个线程访问;而可变,则是意味着变量的值在它的生命周期中,可以被修改。

一个对象是否需要是线程安全,取决于这个变量会不会被多个线程访问。在多线程编程中,我们更关注这个对象在程序中怎么被使用,而不是它用来做什么。

如果有多个线程能够访问状态变量,而且他们当中能对变量进行修改,那么就需要对他们进行同步管理。

如果程序中的多个线程可以访问同一个状态可变的变量,但是没有使用正确的同步策略,那么这个程序肯定不能够完全正常工作的。(因为如果你运气非常好的话,你的程序也可以正常的工作。)

总的来说,我们有三种处理方法:

  • 不要让这个变量可以被多个线程访问,即变成非共享;
  • 让这个变量变成常量;
  • 使用合适同步策略来管理对这个变量的访问

原子性

在多线程环境下,线程的非原子的两个操作之间,都有可能被另一个线程中断,就像之前提到的UnsafeSequence

在多线程环境中,可能会出现下面两种情况:

  • 竞争条件:当计算的正确性依赖于“幸运”的时序,会产生竞争条件,也就是说正确性依赖于事件发生的相对时间。
  • 数据竞争:访问共享数据时没有采用同步措施,也就是多个线程会“不受控制”的使用数据。

当两个没有使用同步策略的线程,无论何时,只要一个线程修改某个接下来可能会被另一个线程读取的变量,或者一个线程读取某个刚刚被另一个线程修改的变量,这时候就会形成数据竞争。并不是所有的竞争条件都属于数据竞争,也不是所有的数据竞争都属于竞争条件。

最常见的竞争条件有以下两种情况:

1.检查-然后-操作,指的是基于检查的结果进行操作。由于检查和操作并非是原子操作,进行操作时检查的结果可能已经无效,那么基于检查所进行的操作就可能带来问题。

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

如上所示的,如果线程A和线程B同时执行了LazyInitRace的方法getInstance。当A检查instance的时候,发现instancenull,则会初始化一个新的ExpensiveObject。但是,如果创建和设置ExpensiveObject需要花费很多时间,则可能在A设置instance之前,B检查的时候,也发现instancenull,则就会初始化另一个新的ExpensiveObject。那么,方法getInstance就会返回两个不同的对象,而这跟这个方法的初衷是不一致的。

2.读取‐修改‐写入,指的是读取某个变量的值,修改后写回。这显然不是一个原子操作,如果B线程在A线程读取之后写入之前修改了的变量值,那么A线程读取的结果就失效了,基于读取所做的修改就可能带来问题。UnsafeSequence就是一个典型的例子。

竞争条件并不会一定使得代码发生错误,这取决与线程间的执行次序。如果你运气好的话,可能会一直都不会出现故障。但是一旦出现故障,是很难发现问题所在的。

前面所提到的UnsafeSequence,如果增量操作是原子操作,那么就不会出现竞争条件。为了确保线程安全,检查-然后-操作和读取-修改-写入这些操作都需要是原子操作。借助java.util.concurrent.atomic包中的AtomicInteger,我们可以将UnsafeSequence修改成线程安全的类:

java
1
2
3
4
5
6
7
8
9
10

@ThreadSafe
public class SafeSequence {
private final AtomicInteger value = new AtomicInteger(0);

/** Returns a unique value. */
public int getNext() {
return value.incrementAndGet();
}
}

java.util.concurrent.atomic包,包含了atomic变量类,可以用来确保数字或者对象引用的原子状态转换。对于java.util.concurrent.atomic的详细分析,请参看Java Concurrency - Utility


使用java.util.concurrent.atomic包提供的类,我们可以很容易的确保单个变量操作的原子性,但是假如多个变量需要同时进行更新,则这是远远不够的。

例如UnsafeCachingFactorizer,我们是不能够同时更新lastNumberlastFactors,即使每次对setget操作都是原子操作。为了保持状态的一致性,我们必将将相关状态变量的更新操作都在一个原子操作中执行。

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber.get()))
encodeIntoResponse(resp, lastFactors.get());
else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}
}

Java提供了锁机制来强制保持操作的原子性:synchronized代码块。synchronized代码块包括两部分:一个对象的引用,用来充当的是锁的角色;受该锁保护的代码段。

获取一个内部锁的唯一方法,就是进入这个synchronized块或者由这个锁控制的方法。

Java的synchronized机制使用的锁是可重入锁,即同一个线程可以多次申请持有同一把锁而不会引起死锁。假设A线程持有lock锁,那么如果B线程申请lock锁,B线程就会被阻塞。但是如果A线程再次申请已经持有的锁,该申请将获得通过,这就是所谓的同一线程可多次获取同一把锁。可重入锁的请求是基于“每个线程”,而不是“每次调用”。可重入锁的实现是通过将每一个锁关联一个引用计数和占有这个锁的线程。当某个线程去请求一个释放的锁的时候,JVM首先会记录下这个线程,并且将引用计数设为1。如果所有者线程刚刚申请到锁,则计数器的值为1,每重新获取一次,计数器的值加1,每退出一个同步代码块,计数器的值就会减1。当计数器的值减为0时,所有者线程才释放锁。可重入锁的设计是为了防止因申请已持有的锁而造成死锁。

java
1
2
3
4
5
6
7
8
9
10
11
public class Widget {
public synchronized void doSomething() {
...
}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}

上面的例子中,子类LoggingWidget重写了父类Widgetsynchronized方法doSomething,并在新的实现中调用了父类的doSomethind方法。如果没有可重入锁,这种情况下就会形成死锁。因为父类Widget和子类LoggginWidgetdoSomething方法都是synchronized,在执行之前都需要获得父类Widget的锁。但假如这个锁不是可重入的,那么调用super.doSomething就永远都不会获得父类的锁,因为它已经调用的线程所持有了。

对于每一个涉及多个变量的不变约束,需要由同一个锁来保护其所有的变量。

需要格外注意的是,获取一个对象的锁,不会阻止其他线程访问这个对象,只会阻止其他线程获取这个锁。

一种常见的锁规则是:在对象内部封装所有的可变状态,通过对象的内部锁来同步任何访问可变状态的代码路径,保护它在并发访问中的安全。


共享对象

可见性

我们不仅希望能够避免一个线程修改其他线程正在使用的对象的状态,而且希望确保当一个线程修改了对象的状态后,其他线程能够真正看到改变。
为了确保跨线程写入的内存可见性,我们必须使用同步机制。

在没有同步的情况下,编译器、处理器,在运行时安排操作的执行顺序可能完全出人意料(指令重排序)。在没有进行适当同步的多线程程序中,尝试推断那些“必然”发生在内存中的动作时,你总是会判断错误。

现代CPU一般都使用读写速度很快的高速缓存来作为内存和CPU之间的缓冲,高速缓存的引入可以有效的解决CPU和内存的速度矛盾,但是也带来了新的问题:缓存一致性。在多CPU的系统中,每个处理器都有自己的高速缓存,而高速缓存又共享同一内存,为了解决缓存一致性问题,需要各个处理器访问缓存时都遵循一定的协议。另外,为了获得更好的执行效率,处理器可能会对代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果进行重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的顺序与输入代码的顺序一致。java虚拟机在即时编译器中也有类似的指令重排序优化。

java内存模型规定了所有的变量都存储在主内存中,除此之外每个线程都有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

由上可知, 一个线程修改了变量的值, 另一个线程并非总是能够及时获知最新的值, 这就是可见性问题的根源.

例如下面的例子:

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class NoVisibility {   
private static boolean ready;
private static int number;

private static class ReaderThread extends Thread {
public void run() {
while (!ready)
Thread.yield();
System.out.println(number);
}
}

public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}

由于指令重排序,主线程中将ready赋值为true的操作可能发生在对number的赋值之前, 因此ReaderThread的输出结果可能为0。 另外,因为这个变量没有声明为volatile,并且这个变量在while循环中并没有发生改变,所以这个变量很有可能在while循环开始之前就会缓存起来。这是jitter优化代码的一种方式。因此,可能在主线程将ready设置为true之前,ReaderThread线程就会启动,并且读取ready的最开始的值,即false,然后将这个值缓存起来,并且永远不会再重新读取它。所以ReaderThread线程可能无法获知主线程对ready的修改, 那么ReaderThread的循环将不会停止。

Synchronized与可见性

Synchronized除了可以保护临界区,还可以保证内存的可见性。一个线程在同步块之中或之前所做的每一件事,当其他线程处于同步块时都是可见的,因此某些操作不一定要放到同步块中,之前也是可以的。

Visibility Guarantees for Synchronization.
当线程A进入synchronized代码块后,线程B也进入由同一个锁保护synchronized代码块,那线程A在释放这个锁之前所修改的那些变量的值,都会确保被线程B看到。也就是说,线程A在synchronized代码块中或之前做的所有事情,对线程B都是可见的。

为了确保所有的线程都可以看到那些共享的可变变量的最新值,读线程和写线程必须用同一个锁来进行同步。

Volatile

Java提供了另外一种替代的方式,一种比synchronized宽松的方式,volatile变量。

JVM规范规定了任何一个线程修改了volatile变量的值都需要立即将新值更新到主内存中,任何线程任何时候使用到volatile变量时都需要重新获取主内存的变量值,而且volatile关键字隐含禁止进行指令重排序优化的语义。这些规范保证了volatile变量的线程可见性。

volatile是一种轻量级的同步机制,不同于synchronizedvolatile无法保证操作的原子性,只能保证变量的可见性。因此volatile关键字的使用是受限的,volatile关键字的正确使用必须同时满足以下条件:

  1. 更改不依赖于当前值,或者能够确保只会在单一线程中修改变量的值。如果对变量的更改依赖于现有值,就是一个竞争条件操作,需要使用其他同步手段如synchronized将竞争条件操作转换为原子操作,而volatile对原子性是无能为力的。但是如果能够确保只会在单一线程中修改变量的值,那么除了当前线程外,其他线程不能更改变量的值, 此时竞争条件就不可能发生.
  2. 变量不需要与其他状态变量共同参与不变约束。

一旦将某个属性标记成volatile,就意味着:

  • 这个属性的值将永远不会缓存起来:所有的读取和写入都是直接操作于主内存;
  • 对这个属性的访问就像由以自身为锁的synchronizer代码块包含一样。

synchronizedvolatile的主要区别是:

  • synchronized可以保证原子性和可见性,而volatile只能保证可见性;
  • 一个基本变量可以声明为volatile,是不能是synchronized
  • volatile变量的访问是不会被阻塞的,是不会像synchronized代码块那样获得任何的锁;
  • 正式因为缺乏锁机制,所以volatile不适用于读取-更新-写入等这类原子操作;
  • 一个volatile变量可以使一个指向null的对象引用(因为你是同步对这个引用的访问,而不是所指向的那个真正的对象)

发布 & 逸出

发布一个对象,我们通常是指使它能够被当前范围之外的代码所使用,比如将它的引用存储到其他代码可以访问的地方,或者在一个非私有的方法中返回这个对象,或者传递它到其它类的方法中。发布了一个不该发布的对象或没完全准备好的对象(的现象)称为逸出。

java
1
2
3
4
5
public static Set<Secret> knownSecrets;

public void initialize() {
knownSecrets = new HashSet<Secret>();
}

当你发布一个对象的时候,你可能间接地也发布了其他对象。如果你往knownSecret集合中加入Secret,那你同时也就发布了Secret,因为任何代码通过轮询这个集合都可以获得这个Secret的引用。同样的,在一个非私有的方法中返回一个对象引用,一样会发布这个返回的对象。

为了安全的发布对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确创建的对象可以通过下列条件安全地发布:

  • 通过静态初始化器初始化对象的引用;
  • 将它的引用存储到volatile域或AtomicReference;
  • 将它的引用存储到正确创建的对象的final域中;
  • 或者将它的引用存储到由锁正确保护的域中。

线程封闭

可以通过约定或者Java内置的ThreadLocal将对象的访问限制在单一的线程上,这样一来,即使对象不是线程安全的, 也不会出现错误。

例如在iOS中,对UIKit组件的操作都必须发生在主线程里,所以即便UIKit的组件不是线程安全,我们无需担心会引发并发错误,只需要确保我们所有访问UIKit的代码是在主线程执行便可。

线程封闭的三种实现方式:

  • Ad-hoc线程限制:是指维护线程限制性的任务全部落在实现上,而不需要经过设计。如使用volatile修饰单写多读的共享变量。但是这种方式并不非常可靠,是非常容易出错的。
  • 栈限制:在线程中定义本地变量(对象),此时必须保证该对象不能被其他线程访问。
  • threadLocal:把一个全局共享的变量设置为threadlocal,这样每个线程都会保存一个该变量的副本,而不会相互冲突。使用threalocal还可以频繁执行的操作每次都重新分配临时对象(相对于栈限制)。

不可变性

我们所说的不可变对象,是指对象在创建后,对象的状态不能被修改。就跟无状态对象一样,不可变对象永远是线程安全的。

一个对象只有满足如下条件,才是不可变的:

  • 它的状态不能在创建后再被修改;
  • 所有域都是final类型;并且(final域可能是可变的,因为它可以获得一个可变对象的引用)
  • 它被正确创建(创建期间没有发生this引用的逸出)。

因此,为了减少对象的复杂度,尽量将对象的所有域都声明为final类型,除非它们需要为可变的。(就跟最好将对象的方法都声明为private,除非别的对象需要调用这个方法。)

有效不可变对象

如果一个对象在技术上不是不可变的,但是它的状态不会在发布后被修改,那么这样的对象称作有效不可变对象。任何线程都可以在没有额外的同步下安全的使用一个安全发布的有效不可变对象。

可变对象的安全发布,仅仅可以保证“发布当时”状态的可见性。

发布对象的必要条件依赖与对象的可变性:

  • 不可变对象可以通过任意机制发布;
  • 有效不可变对象必须要安全的发布;
  • 可变对象必须要安全发布,同时不需要线程安全或者是被锁保护。

在并发程序中,使用和共享对象的一些最有效的策略如下:

  • 线程限制:一个线程限制的对象,通过限制在线程中,而被线程独占,且只能被堵占有它的线程修改。
  • 共享只读(share read-only):一个共享的只读对象,在没有额外同步的情况下,可以被多个线程并发的访问,但是任何线程都不能修改它,共享只读对象包括不可变对象和有效不可变对象。
  • 共享线程安全(shared thread-safe):一个线程安全的对象在内部进行同步,所以其他线程无须额外同步,就可以通过公共接口随意的访问它。
  • 被守护的(Guarded):一个被守护的对象只能通过特定的锁来访问。被守护的对象包括那些被线程安全对象封装的对象,和已知被特定的锁保护起来的已发布对象。