Java多线程编程(4)--线程同步机制

时间:2024-03-28 15:06:02

一.锁

1.锁的概念

  线程安全问题的产生是因为多个线程并发访问共享数据造成的,如果能将多个线程对共享数据的并发访问改为串行访问,即一个共享数据同一时刻只能被一个线程访问,就可以避免线程安全问题。锁正是基于这种思路实现的一种线程同步机制。

  在对共享数据加锁后,每个线程在访问共享数据时必须先申请相应的锁。一旦获得锁后,就可以访问共享数据,并且一个锁同一时刻只能被一个线程持有,这意味着获得锁后不会有其他线程再访问共享数据。访问共享数据结束后线程必须释放锁。锁的持有线程在其获得锁之后和释放锁之前这段时间内所执行的代码被称为临界区。因此,共享数据只允许在临界区内进行访问,临界区一次只能被一个线程执行。

  这种同一时刻只能被一个线程持有的锁被称为排他锁或者互斥锁。这种锁的实现方式代表了锁的基本原理。本文后续还会提到另外一种锁——读写锁,它可以被看作排他锁的一种相对改进。

  Java平台中的锁包括内部锁和显式锁。内部锁是通过synchronized关键字实现的,显式锁则是通过Lock接口的实现类实现的。

2.可重入性

  如果一个线程在持有一个锁的同时还能继续成功申请该锁,那么就称这个锁是可重入的。可重入锁的一个优点是可以在一定程度上避免死锁。可重入性可以通过下面的伪代码理解:

void methodA() {
applyForLock(lock); //申请锁lock
methodB();
releaseLock(lock); //释放锁lock
}
void methodB() {
applyForLock(lock); //申请锁lock
...
releaseLock(lock); //释放锁lock
}

  那么可重入锁是怎么实现的呢?实际上,可重入锁内部维护了一个同步状态status来统计重入次数,其初始值为0。当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status为0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status不为0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status不为0的话会导致其获取锁失败,当前线程阻塞。

  释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1==0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。

3.公平性

  锁也可以被看作多线程访问共享数据时所持有的一种资源。因此,资源的调度策略对于锁来说也同样适用(参考《Java多线程编程(2)--多线程编程中的挑战》中“资源的调度”一节)。锁的调度策略也分为公平策略和非公平策略,相应的锁就称为公平锁和非公平锁。内部锁属于非公平锁,而显式锁则既支持公平锁又支持非公平锁。

4.锁的作用

  锁作为一种保障线程安全性的机制,它的作用自然就是保障线程安全的三个特性,即保障原子性、保障可见性和保障有序性。

  锁是通过互斥来保障原子性的。由于一个锁任意时刻只能被一个线程持有,因此一个线程在执行临界区内的代码时,其他线程无法访问临界区,这使得临界区的操作自然而然地具有不可分割的特性,即具备了原子性。

  上一篇文章中介绍过,可见性的保障是通过写线程冲刷处理器缓存和读线程刷新处理器缓存这两个动作实现的。在Java平台中,锁的获得隐含着刷新处理器缓存这个动作,这使得读线程在获得锁之后可以将其他线程对共享变量所做的更新同步到该线程的执行处理器的高速缓存中;而锁的释放隐含着冲刷处理器缓存这个动作,这使得写线程对共享变量所做的更新能够被推送到该线程的执行处理器的高速缓存中,从而对其他线程可同步。因此,锁能够保障可见性。

  锁能够保障有序性。由于锁能够保障原子性,一个线程在临界区中所执行的一系列操作,在其他线程看来要么尚未执行,要么已经执行完毕,即使在执行的过程中发生了重排序,但这个过程对于其他线程来说是不可见的,再加上锁可以保障可见性,所有对于共享变量的更新对于其他线程都是可见的,因此其他线程对这些操作的感知顺序与源代码顺序一致的。这就保障了有序性。

5.synchronized关键字

  每个对象都有唯一一个与之关联的锁。这种锁被称为监视器或者内部锁。内部锁是一种排他锁,它能够保障原子性、可见性和有序性。内部锁是通过synchronized关键字来使用的。synchronized关键字可以用来修饰代码块和方法。

  观察下面的例子:

public class IdGenerator {
private int id = -1;
public int nextId() {
if (++id >= 1000) {
id = 0;
}
return id;
}
}

  如果多个线程共用同一个IdGenerator对象,那么可能会得到重复的id。可以在访问id的代码块前面加上synchronized关键字,就像下面这样:

public int nextId() {
synchronized(this) {
if (++id >= 1000) {
id = 0;
}
return id;
}
}

  synchronized关键字可以确保被其修饰的代码块在任意时刻只能被一个线程执行,这就排除了对id变量访问操作的交错的可能性。

  synchronized关键字修饰代码块时,需要提供一个锁句柄。锁句柄是一个对象的引用,它表示在执行临界区内的代码时需要使用内部锁的对象。上面的程序中使用了this作为锁句柄,这就意味着,在执行临界区内的代码时,当前对象是被加锁的,其他线程无法访问对象的属性,也无法调用对象的方法。当然,也可以使用别的对象来作为锁句柄,这完全取决于程序的逻辑。

  synchronized关键字也可以修饰方法,例如:

public synchronized int nextId() {
if (++id >= 1000) {
id = 0;
}
return id;
}

  当synchronized关键字修饰实例方法时,锁句柄是当前对象,这类似于上面synchronized(this)的写法。当synchronized关键字修饰静态方法时,锁句柄是当前类对象。例如,下面的静态方法:

public class SynchronizedStaticMethodDemo {
public synchronized static staticMethod() {
// 临界区
}
// ...
}

  相当于:

public class SynchronizedStaticMethodDemo {
public static staticMethod() {
synchronized(SynchronizedStaticMethodDemo.class) {
// 临界区
}
}
// ...
}

  当synchronized关键字修饰实例方法时,要调用该方法必须先申请该对象的内部锁。当持有该对象的内部锁后,其他线程无法执行该方法和其他被synchronized关键字修饰的实例方法,而被synchronized关键字修饰的静态方法则可以正常调用,这是因为静态方法是属于类而不是某个实例的。当synchronized关键字修饰静态方法时,要调用该方法必须先申请类对象的内部锁。在学习了与反射有关的内容之后我们知道,Java中的每个类也有与之相关联的对象,它存储了关于该类的域、方法、构造器等一系列信息。当进入被synchronized修饰的静态方法后,线程就获取了该类的锁,这意味着当前方法和类中其他所有被synchronized修饰的静态方法都无法被其他线程调用,而被synchronized修饰的实例方法则不受限制,因为锁是加在类对象上的,而类的实例并没有加锁,因此不会产生互斥。

  此外,需要说明的是,内部锁是可重入锁。可以通过下面的例子来证明:

public class SynchronizedDemo {
public static void main(String[] args) {
SynchronizedDemo demo = new SynchronizedDemo();
demo.method1();
} private synchronized void method1() {
System.out.println("Entered method1.");
method2();
System.out.println("Left method1.");
} private synchronized void method2() {
System.out.println("Entered method2.");
System.out.println("Left method2.");
}
}

  该程序输出如下:

Entered method1.
Entered method2.
Left method2.
Left method1.

  可以看到,当主线程进入method1时,它已经获得了demo对象的锁,此时主线程仍然可以进入method2,这就说明了当前的锁,也就是内部锁是可重入的。

  最后,在公平性方面,正如上文提到的,Java虚拟机对内部锁的调度仅支持非公平调度策略。

内部锁的实现机制

Java虚拟机会为每个对象维护一个入口集(Entry Set),用于记录等待获得相应内部锁的线程。多个线程申请同一个内部锁的时候,只有一个申请者能够申请成功,而其他申请者则会申请失败。这些申请失败的线程并不会抛出异常,而是会被暂停(生命周期状态变为BLOCKED)并被存入入口集中等待再次申请锁的机会。当这些线程申请的锁被释放的时候,该锁的入口集中的一个线程会被JVM唤醒,从而得到再次申请锁的机会。如何从入口集中选择一个线程作为下一个可以参与再次申请相应锁的线程取决于JVM的实现方式,例如,这个线程有可能是等待时间最长的线程,也有可能是等待时间最短的线程,或者是完全随机的一个线程。此外,由于JVM对内部锁的调度仅支持非公平调度,可能存在其他新的活跃线程(处于RUNNABLE状态,且未进入过入口集)与被唤醒的线程抢占这个锁,因此被唤醒的线程不一定就能成为该锁的持有线程。

6.Lock接口

  显式锁是指Lock接口的实例,它位于java.util.concurrent.locks包中。该接口对显式锁进行了抽象,定义了以下方法:

  • void lock()

    获取锁。如果锁不可用,则线程会持续等待直到获取到锁。
  • void lockInterruptibly()

    获取锁。与lock()的区别在于lock()在获取锁的时候不会响应中断,而lockInterruptibly()则可以响应中断。
  • void unlock()

    释放锁。
  • boolean tryLock()

    尝试性地获取锁,不会一直等待。若获取到则直接返回true,否则直接返回false。
  • boolean tryLock(long time, TimeUnit unit) throws InterruptedException

    在指定的时间内获取锁。发生以下情况时该方法结束:

      1.在给定的时间内获取到锁,此时返回true;

      2.在指定的时间内没有获取到锁,此时返回false;

      3.该线程被中断,抛出InterruptedException异常。
  • Condition newCondition()

    返回一个绑定到当前锁的Condition对象。

  上面提到的线程中断机制和与Condition有关的内容会在后续的文章中进行介绍,这里只是做一个简单的说明。

  显式锁的使用方法如下所示:

private final Lock lock = ...;  //创建Lock接口的实例
lock.lock(); //申请锁
try {
... //访问共享数据
} finally {
lock.unlock(); //释放锁
}

    JDK提供了一个Lock接口的实现类ReentrantLock,如果没有特别的要求,我们就可以创建这个类的实例来作为显式锁使用。从字面意思上可以看出ReentrantLock是一个可重入锁。ReentrantLock既支持公平锁也支持非公平锁。ReentrantLock提供了两个构造器:

  • ReentrantLock()

    创建一个ReentrantLock的实例,默认使用非公平锁。
  • ReentrantLock​(boolean fair)

    根据指定的公平策略来创建ReentrantLock的实例,若fair是true则为公平锁,是false则为非公平锁。

7.读写锁

  锁的排他性使得多个线程无法在同一时刻访问共享变量,即使这些线程对于共享变量而言只有读取操作,这显然不利于提高系统的并发性。

  读写锁是一种改进型的排他锁。读写锁允许多个线程同时读取共享变量,但是一次只允许一个线程对共享变量进行更新。任何线程读取共享变量的时候,其他线程无法对该变量进行更新;一个线程更新共享变量的时候,其他任何线程都无法访问该变量。

  读线程在访问共享变量的时候必须持有相应读写锁的读锁。读锁是可以同时被多个线程持有的,即读锁是共享的。但是当任何一个线程持有一个读锁的时候,其他任何线程都无法获得相应锁的写锁。这就保障了读线程在读取共享变量期间没有其他线程能够对这些变量进行更新,从而使读线程能够读取到相应变量的最新值。写线程在访问共享变量的时候必须持有相应读写锁的写锁。写锁是排他的,即一个线程持有写锁的时候其他线程无法获得相应锁的写锁或读锁。总的来说,读锁对于读线程来说起到保护其访问的共享变量在其访问期间不被修改的作用,并使多个读线程可以同时读取这些变量从而提高了并发性;而写锁保障了写线程能够以独占的方式安全地更新共享变量。写线程对共享变量的更新对读线程是可见的。

  ReadWriteLock是对读写锁的抽象。和Lock接口一样,ReadWriteLock接口也有一个默认的实现类ReentrantReadWriteLock。下面是ReadWriteLock接口的两个方法:

  • Lock readLock()

      返回用于读操作的锁。
  • Lock writeLock()

      返回用于写操作的锁。

  ReentrantReadWriteLock支持锁的重入和降级,即一个线程持有写锁的情况下可以继续获得相应的读锁。这是由于,如果某个线程在获取读锁之前释放了写锁,那么写锁可能会被别的线程抢占;当该线程获取到读锁的时候,共享变量可能已经发生了变化,这就有可能破坏了该线程操作的原子性;此外,上述情况还会导致线程的上下文切换,增加了额外的开销。因此,既然ReentrantReadWriteLock支持重入和降级,那么我们就可以在获取到读锁之后再释放写锁。

  和锁的降级对应的是锁的升级,即一个线程在持有读锁的情况下,申请相应的写锁。ReentrantReadWriteLock不支持锁的升级,持有读锁的线程如果要申请写锁,必须先释放读锁,然后申请相应的写锁。

二.内存屏障

  在上一篇文章中讲解锁是如何保证可见性的时候提到了线程获得和释放锁时所分别执行的两个动作:刷新处理器缓存和冲刷处理器缓存。前一个动作保证了该锁的当前持有线程能够读取到前一个持有线程对这些数据所做的更新,后一个动作保证了该锁的持有线程对这些数据所做的更新对该锁的后续持有线程可见。那么,这两个动作是如何实现的呢?弄清楚这个问题有助于我们学习和掌握Java中的线程同步机制。

  JVM实际上是借助内存屏障(Memory Barrier,也称Fence)来实现上述两个动作的。内存屏障是被插入到两个指令之间进行使用的,其作用是禁止编译器、处理器重排序从而保障有序性。它在指令序列中就像是一堵墙一样使其两侧的指令无法穿过它。此外,内存屏障还有另外一个作用,那就是刷新处理器缓存或冲刷处理器缓存,从而保证可见性。不过,内存屏障是由JVM自身直接使用的,我们不需要也不能在代码中直接使用内存屏障。

  根据读操作(Load)和写操作(Store)的先后顺序,操作系统提供的内存屏障可以分为:

  • LoadLoad:对于“Load1,LoadLoad,Load2,...”这样的语句,在Load2及后续读操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • LoadStore:对于“Load1,LoadStore,Store1,...”这样的语句,在Store1及后续的写操作执行前,保证Load1要读取的数据被读取完毕。
  • StoreStore:对于“Store1,StoreStore,Store2,...”这样的语句,在Store2及后续的写操作执行前,保证Store1的写入操作对其它处理器可见。
  • StoreLoad:对于“Store1,StoreLoad,Load1,...”这样的语句,在Load1及后续读操作要读取的数据被访问前,保证Store1的写入对所有处理器可见。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能,但它的开销也是四种屏障中最大的。

  不同微架构的处理器所提供的这样的内存屏蔽指令是不同的,例如x86的处理器就根本没有提供StoreStore屏障,因为x86处理器不会对写-写操作进行重排序。

  上面是操作系统提供的内存屏障,JVM将这几种内存屏障指令进行组合,得到了以下四种屏障:

  • 加载屏障(Load Barrier):加载屏障的作用是刷新处理器缓存,StoreLoad可以作为加载屏障。JVM会在monitorenter指令(JVM中获取内部锁的字节码指令)之后、临界区之前插入一个加载屏障,这使得当前线程的执行处理器能够将其他线程对共享变量所做的更新同步到该处理器的高速缓存中。
  • 存储屏障(Store Barrier):存储屏障的作用是冲刷处理器缓存,StoreLoad可以作为存储缓存。JVM会在monitorexit指令(JVM中释放内部锁的字节码指令)之后插入一个存储屏障,这就保障了当前线程在释放锁之前在临界区中对共享变量所做的更新对于其他线程的执行处理器来说是可见的。
  • 获取屏障(Acquire Barrier):获取屏障的使用方式是在一个读操作之后插入该内存屏障,其作用是禁止该读操作与其后的任何读写操作之间进行重排序,相当于LoadLoad屏障与LoadStore屏障的组合。
  • 释放屏障(Release Barrier):释放屏障的使用方式是在一个写操作之前插入该内存屏障,其作用是禁止该写操作与其前面的任何读写操作之间进行重排序,相当于LoadStore屏障与StoreStore屏障的组合。

  加载屏障和存储屏障可以保障可见性,这是因为它们分别对应了刷新处理器缓存和冲刷处理器缓存的动作;获取屏障和释放屏障可以保障有序性,这是因为获取屏障禁止了临界区中的任何读、写操作被重排序到临界区之前的可能性,而释放屏障又禁止了临界区中的任何读、写操作被重排序到临界区之后的可能性,因此临界区内的任何读、写操作都无法被重排序到临界区之外,也就保障了有序性。

  在JVM中,有很多地方都用到了内存屏障,例如内部锁、volatile、final等,下面以一张图来演示在内部锁中JVM是如何使用内存屏障的,后文还会继续介绍volatile及final与内存屏障的关系。

Java多线程编程(4)--线程同步机制

三.volatile关键字

  单词“volatile”的字面意思是“易变的、不稳定的”。作为Java中的关键字,它表示被修饰的变量的值容易变化(被其他线程修改),因此不稳定。volatile变量的不稳定性意味着对这种变量的读和写操作都必须从高速缓存或者主内存中读取,以读取变量的相对新值,这样就保障了可见性。

  对于volatile变量的写操作,Java虚拟机会在该操作之前插入一个释放屏障,并在该操作之后插入一个存储屏障,如下图所示:

Java多线程编程(4)--线程同步机制

  volatile关键字的作用包括:保障可见性、保障有序性和保障long/double型变量读写操作的原子性。

1.保障可见性

  由于JVM会在volatile变量的写操作之后插入一个存储屏障,而存储屏障会执行冲刷处理器缓存的操作,这就保障了当前线程对volatile变量所做的更新对于其他线程的执行处理器来说是可见的,即保障了可见性。

2.保障有序性

  由于JVM会在volatile变量的写操作之前插入一个释放屏障,释放屏障禁止了volatile变量写操作与该操作之前的任何读、写操作进行重排序,从而保证了volatile写操作之前的任何读、写操作都会先于volatile写操作被提交,即其他线程看到写线程对volatile变量的更新时,写线程在更新volatile变量之前所执行的内存操作的结果对于其他线程必然也是可见的。这就保障了读线程对写线程在更新volatile变量前对共享变量所执行的更新操作的感知顺序与相应的源代码顺序一致,即保障了有序性。

3.保障long/double变量读写操作的原子性

  在Java语言中,对long型和double型以外的任何类型的变量的写操作都是原子操作。考虑到某些32位Java虚拟机上对long/double型变量进行的写操作可能不具有原子性(比如先写低32位,再写高32位),Java语言规范特别地规定对long/double型volatile变量的写操作和读操作也具有原子性。因此,如果要在多线程编程中使用long/double型的共享变量,务必记得加上volatile关键字。

  不过,volatile仅仅保障对其修饰的变量的读写操作本身的原子性,这并不表示对volatile变量的赋值操作一定具有原子性。例如.如下对volatile变量count1的赋值操作并不是原子操作:

count1 = count2 + 1;

  如果变量count2也是一个共享变量,由于上面的代码在执行过程中其他线程可能会更新co unt2的值,因此该操作不是一个原子操作。

四.CAS和原子变量类

  CAS(Compare and Swap)是对一种处理器指令(例如x86处理器中的cmpxchg指令)的称呼。不少多线程相关的Java标准库类的实现最终都会借助CAS。虽然在实际工作中多数情况下我们并不需要直接使用CAS, 但是理解CAS有助于更好地理解相关标准类库,以便恰当地使用它们。

1.CAS

  来观察一个我们在上面介绍synchronized关键字时用到的例子:

public int nextId() {
synchronized(this) {
if (++id >= 1000) {
id = 0;
}
return id;
}
}

  实际上,这里用内部锁来保障原子性显得有点大材小用的感觉。锁固然是功能最强大、适用范围也很广泛的同步机制,但是毕竟它的开销也是最大的。保障像自增这种比较简单的操作的原子性我们有更好的选择——CAS,它能将这类操作转换为原子操作。

  下面是关于CAS的一段定义:

CAS操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值(在CAS的一些特殊情况下将仅返回CAS是否成功,而不提取当前值)。CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

  CAS操作可以防止内存*享变量出现脏读脏写问题,多核的CPU在多线程的情况下经常出现的问题,通常我们采用锁来避免这个问题,但是CAS操作避免了多线程的竞争锁,上下文切换和进程调度。

  再次强调,真实的CAS操作是由CPU完成的,CPU会确保这个操作的原子性。CAS远非JAVA代码能实现的功能,Java中的一些类只是借助了这种操作来保证一些操作的原子性。

2.原子操作类

  原子操作类是基于CAS实现的能够保障对共享变量进行read-modify-write更新操作的原子性和可见性的一组工具类,这些类一共有12个,它们全部位于java.util.concurrent.atomic包下,大致可以分为以下四类。

Java多线程编程(4)--线程同步机制

  下面使用AtomicInteger来对上面的IdGenerator进行改造:

public class IdGenerator {
private AtomicInteger id = new AtomicInteger(-1);
public int nextId() {
if (id.incrementAndGet() >= 1000) {
id.set(0);
}
return id.get();
}
}

  通过AtomicInteger类,无需锁也可以保障操作的原子性,这是由操作系统提供的命令来保证的,即避免了锁的开销,又使代码变得更加简单。

  篇幅所限,这里不对所有的原子操作类进行详细的介绍,具体可以参考Java API文档中对这些类的介绍。

3.ABA问题

  前面我们讲到CAS实现原子操作背后的一个假设是:共享变量的当前值与当前线程所提供的旧值相同,我们就认为这个变量没有被其他线程修改过。实际上,这个假设不一定总是成立。例如,对于共享变量V,当前线程看到它的值为A的那一刻,其他线程已经将其值更新为B,接着在当前线程执行CAS的时候该变量的值又被其他线程更新为A,那么此时我们是否认为变量V的值没有被其他线程更新过呢?这就是ABA问题,即共享变量的值经历了A->B->A的更新。ABA问题是否出现以及是否需要规避与要实现的业务有关,因为有些情况下根本不会出现ABA问题,例如一个总是自增的变量不会变成之前出现过的任何一个值;有些情况下可能会出现ABA问题但我们可以接受。有些情形下我们无法容忍ABA问题,规避它也不难,那就是为共享变量的更新引入一个版本号。每次更新共享变量时相应的版本号的值就会发生变化。例如,对于初始实际值为A的共享变星V,它可能经历这样的更新过程:(A,0)->(B,1)->(A,2)。这里,虽然变量V的实际值仍然经历了A->B->A的更新,但是由于每次更新都导致了版本号的变化,我们依然能够准确地判断变量的值是否被修改过。上面提到的AtomicStampedReference和AtomicMarkableReference正是基于这种思想而设计的。

五.对象的发布和逸出

  “发布”一个对象的意思是指,是对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。在许多情况中,我们需要确保对象即其内部状态不被发布;而在某些情况下,我们又需要发布某个对象,但如果在发布时要确保线程安全性,则可能需要同步。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。例如,如果在对象构造完之前就发布该对象,就会破坏线程安全性。当某个不该发布的对象被发布时,这种情况就称为逸出。

  常见的对象发布形式包含以下几种:

  • 将对象引用存储到public变量中;
  • 在非private方法中返回一个对象;
  • 创建内部类,使得内部类中可以通过“外部类名.this”来访问外部类及其属性;
  • 通过方法调用将对象以参数的形式传递给外部方法。

      因此,当我们需要发布一个对象的时候就需要注意与之相关的线程安全问题。例如,我们可以酌情使用锁、volatile关键字来保障线程安全。

对象的安全初始化:static和final

  Java中类的初始化采取了延迟加载的技术,即一个类被Java虚拟机加载之后,该类的所有静态变量的值都仍然是默认值,直到有线程初次访问了该类的任意一个静态变量才使这个类被初始化,即类的静态初始化块被执行,类的所有静态变量被赋予初始值。

  下面的例子演示了延迟加载的现象:

public class ClassLazyInitDemo {
public static void main(String[] args) {
System.out.println(Foo.class.getSimpleName());
System.out.println(Foo.bar);
}
}
class Foo {
static {
System.out.println("Static code block executing...");
} public static int bar = 1;
}

  上面的程序会产生如下输出:

Foo
Static code block executing...
1

  可见,Foo类被JVM加载后并没有立即调用静态代码块,而是在第一次访问其静态变量时这个类才被初始化。

  static关键字在多线程环境下有其特殊的含义,它能够保证一个线程即使在未使用其他同步机制的情况下也总是可以读取到一个类的静态变量的初始值(而不是默认值)。但是,这种可见性保障仅限于初始值。如果这个静态变量在类初始化完毕之后被其他线程更新过,那么要读取该变量的相对新值仍然需要借助于其他同步机制。

  对于引用类型的静态变量,static关键字还可以保障一个线程读取到该变量的初始值时,这

个引用的对象已经初始化完毕。

  现在来看final关键字。由于重排序的作用,一个线程读取到一个对象引用时,该对象可能尚未构造完毕(只有在构造方法结束后才能认为一个对象已经构造完毕),即可能读取到字段的默认值而不是初始值。在Java语言中,用final修饰的字段被赋予了一些特殊的语义,它可以阻止某些重排序,具体的规则如下:

规则1 禁止将对final域的写操作重排序到构造方法之外。

  这个规则可以保障当一个对象构造完毕时,它的final域一定是初始值。这个规则是通过在final域的写操作之后、构造方法结束之前插入一个StoreStore屏障来实现的。而非final域则没有这个保障,即某个线程读取非final域时所读取到的值可能仍然是默认值而不是初始值。观察下面的例子:

public class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
} public static void writer() {
f = new FinalFieldExample();
} public static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}

  假设线程A运行writer()方法,线程B运行reader()方法,那么一种可能的执行时序图如下:

Java多线程编程(4)--线程同步机制

  在上图所示的这种情况中,对于普通域的写操作被重排序到了构造方法之外,因此线程B在读取变量y时读取到的值可能是0也可能是4;而由于禁止将final域的写操作重排序到构造方法之外,因此线程B在读取x时则一定会读取到3。

规则2 初次读一个包含final字段对象的引用,与随后初次读这个final字段,这两个操作不能重排序。

  是不是一脸懵逼?说实话笔者刚开始看到这条规则也是百思不得其解。正常情况下肯定要先读引用,再读引用的这个对象的字段,否则就可能会出现空指针。这两个操作之间存在依赖性,理论上不应该进行重排序。实际上,大部分处理器确实是不会对这两个操作进行重排序的。但是,有一些为了追求极限性能的处理器(例如alpha处理器)就会这么做。因此这条规则就是为了这类处理器而设计的,它是通过在对final域的读操作之前插入一个LoadLoad屏障来实现的。话不多说,看下面的例子:

public class FinalFieldDemo {

    final int x;
int y;
static FinalFieldExample f; public FinalReordering() {
x = 3;
y = 4;
} public static void writer() {
f = new FinalFieldDemo();
} public static void reader() {
FinalFieldDemo demo = f; //读对象引用
int j = demo.y; //读普通域
int i = demo.x; //读final域
}
}

  reader()方法一种可能的执行顺序如下:

Java多线程编程(4)--线程同步机制

  在上图所示的情况中,读普通域的操作被重排序到读对象引用之前,这显然是错误的操作。而由于在final域的读操作之前插入了一个LoadLoad屏障,因此该操作不会被重排序到读对象引用之前。

  还有一个需要注意的问题。上面的规则1可以确保我们在使用一个对象引用的时候该对象的final域已经在构造方法中被初始化过了。但是这里其实是有一个前提条件的,即在构造方法中,不能让这个被构造的对象对其他线程可见,也就是说该对象引用不能在构造方法中“逸出”。观察下面的例子:

public class FinalFieldExample {
final int x;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3; //1
f = this; //2
} public static void reader() {
if (f != null) {
int i = f.x;
}
}
}

  上面的程序看上去好像没什么问题,但实际上,由于操作1和操作2之间没有数据依赖性,因此它们是有可能被重排序的,即先执行操作2,再执行操作1。这并不违反规则1,因为这个重排序发生在构造方法内,对final域的写操作确实没有被重排序到构造方法之外。这样一来,在对象没有被完全构造完成之前就将其发布,这就造成了对象的“逸出”,甚至不能保证读取到的final域的值是初始值。因此,千万不要在构造方法中提前将对象发布,而是在构造完毕之后再通过安全的方式将其发布。