Java多线程编程实战指南学习(三)

Java线程同步机制

  • 1.线程同步机制简介
  • 2.锁概述
    • 2.1锁的作用
    • 2.2与锁相关的几个概念
    • 2.3锁的开销及可能导致的问题
  • 3.内部锁:synchronized关键字
  • 4.显式锁:Lock接口
    • 4.1显式锁的调度
    • 4.2显式锁与内部锁的比较
    • 4.3锁的选用
    • 4.4改进型锁:读写锁
  • 5.锁的使用场景
  • 6.线程同步机制的底层助手:内存屏障
  • 7.锁与重排序
  • 8.轻量级同步机制:volatile关键字
    • 8.1volatile的作用
    • 8.2 volatile变量的开销
    • 8.3volatile的典型应用场景
  • 9.正确实现看似简单的单例模式
  • 10.CAS与原子变量
    • 10.1CAS
    • 10.2原子操作工具:原子变量类
  • 11.对象的发布与逸出
    • 11.1对象的初始安全:重访final和static
    • 11.2安全发布与逸出

1.线程同步机制简介

从应用程序的角度看,线程安全问题的产生是由于多线程应用程序缺乏线程同步机制。线程同步机制是用于协调线程间数据访问及活动的机制,该机制用于保障线程安全以及实现这些线程的共同目标。
Java平台的线程同步机制包括锁、volatile关键字、final关键字、static关键字以及一些相关的API,如Object.wait()/Object.notify()等。

2.锁概述

线程安全问题产生的前提是多高线程并发访问共享变量、共享资源等。很容易想到一个保障线程安全的方法——将多个线程对数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问,该线程访问结束后其他线程才能对其进行访问。锁就是利用这种思路以保障线程安全的同步机制。
一个线程在访问共享数据之前必须申请相应的锁,线程这个动作被称为锁的获得。一个线程获得某个锁,我们就称该线程为相应锁的持有线程,一个锁一次只能被一个线程持有。锁的持有线程可以对锁所保护的共享数据进行访问,访问结束后该线程必须释放相应的锁。锁的持有线程在其获得锁之后和释放锁之前的这段代码被称为临界区。因此共享数据只允许在临界区内进行访问,临界区一次只能被一个线程执行。
如果有多个线程访问同一个锁所保护的数据,我们就称这些线程同步在这个锁上。相应的,这些线程所执行的临界区就被称为这个锁所引导的临界区。
锁具有排他性,即一个锁一次只能被一个线程持有。因此,这种锁被称为排他锁或者互斥锁。Java平台中的锁包括内部锁和显式锁。内部锁是通过synchronized关键字实现的,显式锁是通过java.concurrent.locks.Lock接口的实现类实现的。

2.1锁的作用

锁能保护共享数据以实现线程安全,其作用包括保障原子性、保障可见性和保障有序性。
锁是通过互斥保障原子性的。所谓互斥,就是指一个锁一次只能被一个线程持有。一个线程持有锁时,其他线程无法获得该锁,只能等待该锁释放后再申请。一个线程执行临界区内期间没有其他线程能够访问相应的共享数据,这使得临界区代码所执行的操作有不可分割的特性,也即具有的原子性。
锁其实是将多个线程对共享数据的访问由本来的并发改为串行。
可见性的保障是通过写线程冲刷处理器缓存和读线程刷新处理器缓存的动作实现的。Java平台中,锁的获得隐含着刷新处理器缓存的动作。这使得程序在执行临界区代码前(获得锁之后)可以将写线程对共享变量所做的更新同步到该线程执行处理器的高速缓存中。而锁的释放隐含着冲刷处理器缓存的操作,这使得写线程对共享变量的更新能够及时被写入到该线程执行处理器的高速缓存中,从而对读线程可见。
锁的互斥性及其对可见性的保障合在一起,可以保证临界区内的代码能够读取到共享数据的最新值。
锁不但能保证临界区中的代码能够读到共享变量的最新值,而且对于引用型共享变量,锁还可以保证临界区中的代码读到该变量所引用对象的字段最新值。这点可以扩展到数组变量。
锁能够保证有序性。写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来完全像是按照源代码顺序执行的。锁对可见性的保障,使得写线程在临界区中对任何一个共享变量的更新都对读线程可见。并且因为临界区内的操作具有原子性,所以读线程无法看到写线程具体是按照什么顺序更新的共享变量,这意味着读线程可以认为写线程是按照源代码顺序更新的共享变量,从而保证有序性。
尽管能保证有序性,这并不意味着临界区内的代码无法被重排序。临界区内的代码可以被重排序,但是临界区内的操作不能被重排序到临界区之外。由于临界区内的操作具有原子性,写线程在临界区内对各个共享变量的更新同时对读线程可见。因此,重排序不会对其他线程产生影响。
锁对可见性。原子性和有序性的保障是有条件的,需要满足以下两点:

  • 这些线程在访问同一组共享数据的时候必须访问同一个锁
  • 这些线程中的任意线程,都需要在对共享变量进行操作的时候持有相应的锁。

2.2与锁相关的几个概念

  • 可重入性:
    可重入性描述这样一个问题:一个线程在持有一个锁的时候能否再次申请该锁。如果一个线程持有一个锁的时候还能够继续成功申请该锁,就称之为可重入的。否则称之为不可重入的。
  • 锁的争用与调度
    锁可以看做被多线程程序访问共享数据时所需要持有的一种排他性资源。因此,资源的争用、调度概念对锁来说也是存在的。Java中锁的调度策略也包括公平策略和非公平策略。相应的锁就被称为公平锁和非公平锁。内部锁属于非公平锁,显式锁既支持公平锁又支持非公平锁。
  • 锁的粒度
    一个锁实例可以保护一个或多个共享数据,一个锁实例锁保护的共享数据的数量大小就被称为该锁的粒度,一个锁实例保护的共享数据的数量大,就称该锁的粒度粗,否则称该锁的粒度细。

2.3锁的开销及可能导致的问题

锁的开销包括锁的申请和释放所产生的开销,以及锁可能导致的上下文切换的开销。这些开销主要是处理器时间。
锁的不正确使用也可能导致一些活性故障:

  • 锁泄漏
    锁泄漏是指一个线程获得锁之后,由于程序的错误、缺陷使该锁一直无法被释放而导致其他线程一直无法获得该锁的现象。因此,锁泄漏会导致同步在该线程上的所有线程都无法进展。
  • 锁的不正确使用可能导致死锁、锁死等活性故障。

3.内部锁:synchronized关键字

Java平台中的任何一个对象都有一个与之关联的锁。这种锁被称为监视器或者内部锁。内部锁是一种排他锁,它能够保证原子性、可见性以及有序性。
内部锁是通过synchronized关键字实现的。synchronized关键字可以用来秀死方法或者代码块(花括号"{}"包裹的代码)。
synchronized关键字修饰的方法就被称为同步方法。synchronized修饰的静态方法就被称为同步静态方法,synchronized修饰的实例方法就被称为同步实例方法。同步方法的整个方法体就是一个临界区。同步方法的一个示例如下:

public synchronized short nextSequence(){
        if (sequence >= 999)
        {
            sequence = 0;
        }
        else {
            sequence++;
        }
        return sequence;
    }

synchronized会确保该方法一次只会被一个线程执行,因此客户端线程在执行该方法的时候实际上是串行的。同时锁对可见性的保障使得执行该方法的当前线程对sequence变量的更新对该方法的下一个线程可见。因此我们保障了线程安全,避免序列号的重号和丢号。
synchronized关键字所修饰的代码块被称为同步块,其语法如下所示:

synchronized (锁句柄){
	//在代码块中访问共享数据
}

synchronize关键字所引导的代码块就是临界区。锁句柄是一个对象的引用。例如锁句柄可以填写为this关键字。习惯上我们将锁句柄称为锁,锁句柄对应的监视器就被称为相应同步块的引导锁。相应的,我们称呼相应的同步块为该锁引导的同步块。
同步实例方法相当于以"this"为引导锁的同步块。一个例子如下:

public short nextSequence(){
        synchronized(this)
        {
            if (sequence >= 999)
            {
                sequence = 0;
            }
            else {
                sequence++;
            }
        }
        return sequence;
    }

作为锁句柄的变量通常采用final修饰。因为锁句柄变量的值一旦改变,会导致执行同一个同步块的多个线程实际上使用的是不同的锁,从而导致竞态。
作为锁句柄的变量通常采用private final修饰。如:private final Object lock = new Object();
同步竞态方法相当于以当前的类对象为引导锁的同步块。例如:

public class SynchronizeMethodExample{
	public static synchronized void staticMethod(){
		//在此访问共享数据
	}
}

相当于

public class SynchronizeMethodExample{
	public static void staticMethod(){
		synchronized(SynchronizeMethodExample.class)
		{
			//在此访问共享数据
		} 
	}
}

线程在执行临界区代码的时候必须持有该临界区的引导锁。一个线程执行到同步块时必须先申请同步块的引导锁。只有申请成功获得该锁的线程才能执行相应的临界区。一个线程执行完临界区代码后引导该临界区的锁就会被自动释放。线程对内部锁的申请释放动作由Java虚拟机器负责代为实施,这也是synchronized实现的锁被称为内部锁的原因。内部锁的使用不会导致锁泄漏。
Java虚拟机会为每个内部锁分配一个入口集,用于记录等待获得该内部锁的线程。多个线程申请同一个内部锁的时候,只有一个申请者能够成为该锁的持有线程,其他申请者的申请操作会失败。这些申请失败的线程并不会跑出异常,而是会被暂停(生命周期变为BLOCK)并被存入相应锁的入口集等待再次申请锁的机会。入口集中的线程就被称为相应内部锁的等待线程。当这些线程申请的锁被其持有线程释放的时候,该锁的入口集中的任意一个线程会被Java虚拟机唤醒,从而得到再次申请锁的机会。由于Java虚拟机对内部锁的调度进支持非公平调度,被唤醒的等待线程占用处理器运行时可能还有其他新的活跃线程与该线程抢占这个被释放锁,因此被唤醒的线程不一定能成为该锁的持有线程。唤醒的顺序取决于虚拟机的具体实现。

4.显式锁:Lock接口

显式锁是JDK1.5开始引入的排他锁。作为一种线程同步机制,其作用与内部锁相同。它提供了一些内部锁并不具备的特性,但并不是内部锁的替代品。
显式锁是java.util.concurrent.locks.LOCK接口的实例。
Lock接口定义的方法如下:

  • void lock() 获取锁
  • void lockInterruptibly()如果当前线程未被中断,则获取锁
  • Condition newCondition()返回绑定到此Lock实例的新Condition实例
  • boolean tryLock()仅在调用时锁为空闲状态才获取该锁
  • boolean tryLock(long time, TimeUnit unit)如果锁在给定时间内空闲,且当前线程未被中断,则获取该锁
  • void unlock()释放锁

一个Lock实例接口技术一个显式锁对象。Lock接口定义的lock方法和unlock方法分别用于申请和释放相应Lock实例表示的锁。
显式锁的使用方法如下:

private final Lock lock = ……;
……
lock.lock();
try{
	//在此访问共享数据
	……
}
finally{
	//总是在finally块中释放锁,以避免锁泄漏
	lock.unlock();//释放锁lock
}

显式锁的使用包括以下几个方面:

  • 创建Lock接口的实例。如果没有特别的要求,我们可以就创建Lock接口的默认实现类ReentrantLock实例作为显式锁使用。从字面意思看,ReentrantLock是一个可重入锁。
  • 在访问共享数据前申请相应的显式锁。在这一步,我们调用相应的Lock.lock()即可。
  • 在临界区中访问共享数据。Lock.lock()调用与Lock.unlock()调用之间的代码区域为临界区。
  • 共享数据访问结束后释放锁。显然释放锁的操作通过调用Lock.unlock()即可。为了避免锁泄漏,释放锁必须在finally块中进行。循环递增序列号生成器使用显式锁改写的示例如下:
	private short sequence = -1;
    private final Lock lock = new ReentrantLock();
    public short nextSequence(){
        lock.lock();
        try {
            if (sequence >= 999)
            {
                sequence = 0;
            }
            else {
                sequence++;
            }
            return sequence;
        }
        finally {
            lock.unlock();
        }
    }

4.1显式锁的调度

ReentrantLock既支持非公平锁也支持公平锁。ReentrantLock的一个构造器的签名如下:
ReentrantLock(boolean fair)
该构造器可以显式指定相应的锁是公平锁还是非公平锁(fair参数为true表示公平锁)。
公平锁保障锁调度的公平性往往是增加了线程的暂停和唤醒的可能性,即增加了上下文切换为代价的。公平锁适合于锁被持有的时间相对长或者线程申请锁的平均间隔时间相对长的情形。总体而论公平锁比非公平锁开销大,因此显式锁默认使用的是非公平调度策略。

4.2显式锁与内部锁的比较

内部锁是基于代码块的锁,其使用毫无灵活性可言。显式锁是基于对象的锁,可以充分发挥面向对象的灵活性。内部锁的优势是简单易用,不会出现锁泄漏的问题。显式锁容易被错用而导致锁泄漏,因此使用显式锁的时候必须注意将锁的释放操作放入finally块中。但显式锁也支持了一些内部锁不支持的特性。
如果一个内部锁的持有线程一直不释放锁,那么同步在该锁上的所有线程就会一直被暂停而使其任务无法进展。而显式锁则可以避免这样的问题。Lock接口定义了一个tryLock方法。该方法的作用是尝试申请相应的Lock实例锁表示的锁,如果相应的锁未被其他线程拥有,则会返回true并表示其获得了相应的锁。否则,该方法不会导致执行线程被暂停而是会返回false表示未获得相应的锁。其使用的代码模板如下:

Lock lock = ……;
if(lock.tryLock())
{
	try{
		//在此访问共享数据
	}
	finally{
		lock.unlock();
	}
}
else
{
	//执行其他操作
}

tryLock是个多载的方法,它还有另外一个签名版本:
boolean tryLock(long time,Timeout unit)
这个版本的tryLock方法使得我们可以指定一个时间。如果当前线程没有在指定的时间内申请到相应的锁,那么tryLock方法就直接返回false。
在锁的调度方面,内部锁仅支持非公平锁,显式锁既支持公平锁又支持非公平锁。
显式锁与内部锁的主要性能差异包括:

  • Java1.6和Java1.7对内部锁做了一些优化,这些优化在特定情况下可以减少锁的开销。
  • 在Java1.5中,高争用的情况下,内部锁性能急剧下降,而显式锁性能下降少得多。但到了JDK1.6,随着JDK对内部锁做的一系列改进,显式锁和内部锁之间的可伸缩性差异变得很小了。

4.3锁的选用

内部锁的优点是简单易用,显式锁的优点是功能强大。新开发的代码中可以采用显式锁,但显式锁的不正确使用会造成锁泄漏这样的严重问题。线程转储可能无法包含显式锁的相关信息导致问题定位困难。我们也可以采用保守的策略,默认情况下使用内部锁,只有在需要采用显式锁的时候才采用显式锁。

4.4改进型锁:读写锁

锁的排他性使得多个线程无法以线程安全的方式在同一时刻对共享变量进行读取,这不利于提高系统的并发性。
对于同步在同一个锁之上的线程而言,对共享变量仅进行读取而没有进行更新的线程被称为只读线程,简称读线程。对共享变量进行更新的线程被称为写线程。
读写锁是一种改进的排他锁,也被称为共享/排他锁。读写锁允许多个线程可以同时读取共享变量,但一次只允许一个线程对共享变量进行更新。任何线程读取共享变量的时候,其他线程无法更新这些变量。一个线程更新共享变量的时候,其他任何线程都无法访问这个变量。
读写锁的功能是通过其扮演的两个角色——读锁和写锁实现的。该线程在访问共享变量的时候必须持有相应读写锁的读锁。读锁是可以同时被多个线程持有的,即读锁是共享的。写锁是排他的,即一个线程拥有写锁的时候其他线程无法获得相应锁的写锁或读锁。因此,写锁保障了写线程对共享变量的访问是独占的。锁仅在读线程中共享,任何一个线程持有读锁的时候,其他任何线程都无法获得相应的写锁。这就保障了读线程一定会读取到共享变量的最新值。
读写锁的两种角色:

获得条件 排他性 作用
读锁 相应的写锁未被任何线程持有 对读线程是共享的,对写线程是排他的 允许多个读线程共同读取共享变量,并保证读线程读取共享变量期间没有任何线程可以更新这些共享变量
写锁 该写锁未被任何其他线程持有并且相应的读锁未被其他任何线程持有 对写线程和读线程都是排他的 使得写线程能够以独占的方式访问共享变量

java.util.concurrent.locks.ReadWriteLock接口是对读写锁的抽象,其默认实现类为java.util.concurrent.locks.ReentrantReadWriteLock。ReadWriteLock定义了两个方法readLock()和writeLock(),分别用于返回读写锁的读锁和写锁。两个方法的返回值类型都是Lock,这并不意味着一个ReadWriteLock接口实例对应两个锁,而是代表一个ReadWriteLock接口实例可以充当两种角色。
读写锁的使用方法与显式锁相似,也要注意锁泄漏问题。使用读写锁的一个示例如下:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockUsage {
    private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private final Lock readLock = readWriteLock.readLock();
    private final Lock writeLock = readWriteLock.writeLock();
    public void reader()
    {
        readLock.lock();
        try {
            //在此区域读取共享变量
        }
        finally {
            readLock.unlock();//总是在finally块中释放锁,避免锁泄漏
        }
    }
    public void writer()
    {
        writeLock.lock();
        try {
            //在此区域访问共享变量
        }
        finally {
            writeLock.unlock();//总是在finally块中释放锁,避免锁泄漏
        }
    }
}

与普通的排他锁相比,读写锁在排他性方面较弱。在原子性。可见性和有序性的保障方面,它起到的作用与普通的排他锁是一致的。写线程释放写锁所起到的作用相当于一个线程释放一个普通排他锁。读线程获得读锁所起到的作用相当于一个线程获得一个普通的排他锁,由于读写锁内部实现比内部锁和其他显式锁复杂的多,因此读写锁适合于在以下条件同时得以满足的场景中使用:

  • 只读操作比写操作频繁很多
  • 读线程持有锁的时间更长
    ReentrantReadWriteLock所实现的读写锁是个可重入锁。ReentrantReadWriteLock支持锁的降级,即一个线程拥有写锁的时候再申请相应的读锁。
    一个示例如下:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockDownrade {
    private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private final Lock readLock = readWriteLock.readLock();
    private final Lock writeLock = readWriteLock.writeLock();
    public void operationWithLockDowngrade()
    {
        boolean readLockAcquired = false;
        writeLock.lock();
        try {
            //对共享变量进行更新
            readLock.lock();
            readLockAcquired = true;
        }
        finally {
            writeLock.unlock();
        }
        if (readLockAcquired)
        {
            try {
                //对共享变量进行读取
            }
            finally {
                readLock.unlock();
            }
        }
        else {
            //执行其他操作
        }
    }
}

锁降级的反面是锁的升级,即一个线程在持有读写锁的读锁的情况下申请相应的写锁。ReentrantReadWriteLock不支持锁的升级,必须先释放读锁,再申请写锁。

5.锁的使用场景

锁是Java同步功能中最强大、适用范围最广泛,也是开销最大、可能导致最多问题的同步机制。多个线程共享同一组数据的时候,如果也线程涉及如下操作,就可以考虑使用锁:
check-then-act操作:一个线程读取共享变量并根据其决定下一个操作。
read-modify-write操作:一个线程读取共享变量并更新数据。
多个线程对多个共享变量进行更新。

6.线程同步机制的底层助手:内存屏障

对于同步在一个锁之上的多个线程,我们称对共享变量进行更新的线程为写线程,对共享变量进行读取的线程为读线程。因此,一个线程可以既是写线程又是读线程。读线程、写线程在访问共享变量的时候必须持有相应的锁。
Java虚拟机的底层实际上是借助内存屏障来实现刷新处理器缓存和冲刷处理器缓存的。内存屏障是对一类仅针对内存读、写操作指令的跨处理器架构的比较底层的抽象。内存屏障是被插入到两个指令之间进行使用的,其作用是禁止编译器、处理器重排序从而保证有序性。但是,为了实现禁止重排序的功能,这些指令往往也有一个副作用——刷新处理器缓存、冲刷处理器缓存,从而保证可见性。
按照内存屏障所起的作用划分,可以分为以下几种:

  • 按照可见性保证的划分,内存屏障可以分为加载屏障和存储屏障。加载屏障的作用是刷新处理器缓存,存储屏障的作用是刷新处理器缓存。Java虚拟机在释放锁对应的机器码之后插入一个存储屏障,保证写线程在释放锁之前在临界区对共享变量所做的更新对读线程的执行处理器来说是可同步的。相应的,Java虚拟机会在申请锁对应的机器码指令之后临界区开始之前的地方插入一个加载屏障,这使得读线程的执行处理器能够将写线程对相应的共享变量所做的更新从其他处理器同步到该处理器的高速缓存中。
  • 按照有序性保障划分,内存屏障可以分为获取屏障和释放屏障。获取屏障的使用方式是在一个读操作之后插入该内存屏障,其作用是禁止该读操作与后面的任何读写操作进行重排序。释放屏障的使用方式是在写操作之前加入该屏障,其作用是禁止该写操作与前面的任何读写操作进行重排序。Java虚拟机把申请锁(其包含了读操作)对应的机器码指令后临界区开始的地方加入了一个获取屏障,并在临界区结束之后的释放锁(它包含了写操作)开始之前的地方插入了一个释放屏障。
    由于获取屏障禁止了临界区中的任何读写操作被重排序到临界区之前,而释放屏障禁止了临界区中的任何读写操作被重排序到临界区之后,所以临界区内的操作无法重排序到临界区之外。在锁的排他性的作用下,这使得临界区中的操作具有原子性。释放屏障和加载屏障则保证了临界区代码的可见性。原子性和可见性的保证使读线程对代码的感知顺序与源代码一致。

7.锁与重排序

总的来说,与锁有关的重排序规则可以理解为语句相对于临界区的“许进不许出”。临界区外的语句可以被重排序到临界区之内,临界区之内的语句不能被重排序到临界区之外。
具体来说,处理器和编译器需要遵守以下规则。
规则1——临界区内的操作不允许被重排序到临界区之外。
规则2——临界区内的操作之间允许被重排序。
规则3——临界区外的操作之间允许被重排序。但是临界区后的操作不能被重排序到临界区之前,反之亦然。
这三个规则较容易理解。
在有多个临界区的情形下,以下规则也需要被满足:
规则4——锁申请与锁释放操作不能被重排序。
规则5——两个锁申请操作不能被重排序。
规则6——两个锁释放操作不能被重排序。
规则4确保锁申请与锁释放操作总是配对的。这三个规则确保了Java语义支持了嵌套锁的使用,从而不会导致死锁。
规则7——临界区外的操作可以被重排序到临界区之内。
规则7是指在编译的时候,编译器可能将临界区前、临界区后的语句重排序到临界区之内,然后在临界区开始之前和结束之后相应的插入获取屏障和释放屏障。处理器则不会把这种被排序到临界区内的语句重排序到临界区之外。不过对于已经动态编译过的目标代码中临界区之外的指令,由于编译器插入的内存屏障的作用无法被重排序到临界区之内。

8.轻量级同步机制:volatile关键字

volatile关键字用于修饰共享可变变量,即没有使用final关键字修饰的实例变量或静态变量,相应的变量就被称为volatile变量。
private volatile int logLevel;

8.1volatile的作用

volatile关键字的作用包括:保障可见性、保障有序性和保障long/double型变量读写操作的原子性。
访问同一个volatile变量的线程被称为同步在这个变量之上的线程,其中读取这个变量的线程被称为读线程,更新这个变量的线程被称为写线程。一个线程可以既是读线程又是写线程。
volatile关键字能够保障对long/double型变量的写操作具有原子性。在Java语言中,对所有long/double以外的基本类型的写操作都是原子操作,但对long/double型变量的写操作并不一定是原子操作。Java语言规范规定对long/double型volatile变量的写操作和读操作也具有原子性。
volatile仅仅保障对其修饰的变量的写操作(和读操作)本身的原子性,而这并不表示对volatile变量的赋值操作一定具有原子性。如下对volatile变量count1的赋值操作并不是原子操作:

count1 = count2+1;

如果变量count2也是一个共享变量,那么该赋值操作实际上是一个read-modify-write操作,不是原子操作。
一般而言,对volatile变量的赋值操作,其右边表达式中只要涉及共享变量(包括该变量本身),那么这个赋值操作就不是原子操作。要保障这样操作的原子性仍然需要借助锁。
又或者这样的赋值操作:

volatile Map map = new HashMap();

其可以分解为如下伪代码所示的几个子操作:

objRef = allocate(HashMap.class);//子操作1:分配对象所需的存储空间
invokeConstructor(objRef);//子操作2:初始化objRef引用的对象
aMap = objRef;//子操作3:将对象引用写入map

虽然volatile关键字仅保障子操作3是一个原子操作,但是由于子操作1和子操作2仅仅涉及局部变量而未涉及共享变量,因此对aMap的赋值操作仍然是一个原子操作。
volatile关键字在原子性方面仅保障对被修饰变量的读操作、写操作本身是原子操作。如果要保障volatile变量的赋值操作的原子性,那么这个赋值操作不能涉及任何共享变量(包括被赋值的volatile变量本身)的访问。
写线程对volatile变量的写操作会产生类似于释放锁的效果。读线程对volatile变量的读操作会产生类似于获得锁的效果。对volatile变量的写操作,Java虚拟机会在其前面插入一个释放屏障,后面插入一个存储屏障。
volatile虽然能够保障有序性,但是它不像锁那样具备排他性,所以并不能保障其他操作的原子性,而只能保障对被修饰变量的写操作的原子性。因此,volatile变量的写操作之前的操作如果涉及共享可变变量,那么竞态仍然可能产生。这是因为共享变量被赋值给volatile变量的时候其他线程可能已经更新了该共享变量的值。
存储屏障具有冲刷处理器缓存的作用,因此在volatile变量写操作之后插入的一个存储屏障就使得该存储屏障前所有操作的结果对其他处理器来说都是可同步的。
对于volatile变量读操作,Java虚拟机会在该操作之前插入一个加载屏障,并在该操作之后插入一个获取屏障。
另外,volatile关键字也可以看做对JIT编译器的一个提示,它相当于告诉JIT编译器相应变量的值可能被其他处理器更改,从而使JIT编译器不会对相应代码做出一些导致可见性问题的优化。
volatile在有序性的方面的保障也可以从禁止重排序的方面理解,即volatile禁止了如下重排序:

  • 写volatile变量操作与该操作之前的任何读、写操作不会被重排序
  • 读volatile变量操作与该操作之后的任何读、写操作不会被重排序
    我们知道volatile关键字的作用体现在对其所修饰变量的读写操作上。如果被修饰变量是个数组,那么volatile关键字只能对数组引用本身的操作起作用(读取数组引用和更新数组引用),而无法对数组元素的操作(读取、更新数组元素)起作用。
    对数组的操作可以分为读取数组元素、写数组元素和读取数组引用这几种类型:
int i = anArray[0];//操作类型1:读取数组元素
anArray[1] = 1;//操作类型2:写数组元素
volatile int[] anotherArray = anArray;//操作类型3:读取数组引用

上述操作中,类型1可以分为两个子步骤,先读取数组引用anArray,再读取数组中第0个元素。在类型1操作中,volatile关键字起到的作用是保障当前线程能够读到的数组引用的相对新值,这个值仅仅代表相应数组的内存地址而已。其并不能保证读到数组元素的相对新值。类型2的操作中,volatile关键字起到的作用只是保障读取到的数组引用将是一个相对新值,而对相应的数组元素的写操作则没有可见性保障。类型3的操作是将一个数组引用写入另外一个数组,这相当于更新另外一个数组的引用(内存地址),这里的赋值操作是能够触发volatile关键字所有作用的。
对于引用型volatile变量,volatile关键字只是保障读线程能够读取到一个指向对象的相对新的内存地址,而这个内存地址指向的对象的实例/静态变量值是否是相对新值则是没有保障的。

8.2 volatile变量的开销

volatile变量的开销包括读变量和写变量两个方面。volatile的读写操作都不会导致上下文切换,其开销均低于使用锁的读写操作的开销之间。写一个volatile变量会使该操作以及该操作之前的结果对其他处理器是可同步的,而读一个变量则每次都需要从高速缓存或者主内存中读取,无法从寄存器中读取。故volatile变量的读写开销高于普通的读写操作。

8.3volatile的典型应用场景

  • 使用volatile作为状态标志。应用程序的某个状态由一个线程设置,其他线程会读取该状态并以该状态作为其计算的依据(或者仅仅读取并输出这个状态值)。
  • 使用volatile保障可见性。
  • 使用volatile替代锁。volatile关键字并非锁的替代品,但是在一定情况下它比锁更加合适。多个线程共享一组可变状态变量的时候,通常我们需要使用锁保障这些变量更新操作的原子性以及避免数据不一致的问题。但是利用volatile变量写操作的原子性,我们可以把这一组可变状态变量封装成一个对象,那么对这些状态变量的更新操作就可以通过创建一个新对象并将该对象引用赋值给相应的引用型变量来实现。
  • 使用volatile变量实现简易版读写锁。读写锁是通过混合使用锁和volatile变量来实现,其中锁用于保障共享变量写操作的原子性,volatile变量用于保障共享变量的可见性。这种简易版读写锁仅涉及一个共享变量并允许一个线程读取这个共享变量的同时其他线程可以更新该变量。示例如下:
public class Counter{
	private volatile long count;
	public long value(){
		return count;
	}
	public void increment(){
		synchronized(this)
		{
			count++;
		}
	}
}

9.正确实现看似简单的单例模式

单例模式是设计模式中毕竟容易理解、运用也非常广泛的一个模式。单例模式所要实现的目标非常简单:保持一个类有且只有一个实例。出于性能的考虑,不少单例模式的实现会采用延迟加载的方式,即仅在需要用到相应实例的时候才创建实例。
单线程版单例模式实现:

public class SingleThreadedSingleton {
    private static SingleThreadedSingleton instance = null;
    private SingleThreadedSingleton()
    {
    }
    public static SingleThreadedSingleton getInstance()
    {
        if (null == instance)
        {
            instance = new SingleThreadedSingleton();
        }
        return instance;
    }
}

在多线程的环境下,getInstance()中的if语句形成了一个check-then-act操作,它不是一个原子操作。因此该程序的运行可能出现线程交错的情形:线程T1和线程T2同时执行到"null == instance"语句,接着在T1创建了SingleThreadedSingleton实例之后T2语句创建了SingleThreadedSingleton实例,尽管instance实际上已经不为null,但是T1还会再创建一个实例,导致了多个实例的创建,违背了初衷。可以通过加锁解决这个问题:

public class SimpleMultithreadedSingleton {

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

这种方法实现的实例单例模式固然是线程安全的,但是这意味着执行getInstance()的任何一个执行线程都需要申请锁,为了避免锁的开销,人们想到一个"聪明的方法":在执行上述代码的时候,先检查instance是否为null,如果不为null则直接返回,否则才执行临界区。这种方法实现的getInstance()会检查两次instance的值是否为null,因此它被称为双重检查锁定。
需要注意双重检查锁定的实现,一个错误的实现如下所示:

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

但是上述代码是错误的。"instance = new IncorrectDCLSingletion();"可以分解为一下伪代码所示的几个独立子操作:

objRef = allocate(IncorrectDCLSingletion.class);//子操作1:分配对象所需的存储空间
invokeConstructor(objRef);//子操作2:初始化objRef引用的对象
instance = objRef;//子操作3:将对象引用写入共享变量

根据锁的重排序规则,临界区内的操作可以在临界区内被重排序。因此子操作2可能被重排序到子操作3之后,在这种情况下,可能会导致线程T1执行完子操作3但还未执行子操作2,此时线程T2执行"null == instance"的判断,此判断不成立,则T2会直接返回尚未初始化完毕的instance实例,这可能导致程序出错。
分析清楚问题原因后,可以发现只需要instance变量采用volatile修饰即可。
正确的双重锁示例如下:

public class DCLSingletion {
    private static volatile DCLSingletion instance = null;
    private DCLSingletion()
    {
    }
    public static DCLSingletion getInstance()
    {
        if (null == instance) {
            synchronized (DCLSingletion.class) {
                if (null == instance) {
                    instance = new DCLSingletion();
                }
            }
        }
        return instance;
    }
}

考虑到双重锁检定容易出错,可以采用另外一种同样可以实现延迟加载效果比较简单的一种方法,如下所示:

public class StaticHolderSingleton {
    private StaticHolderSingleton()
    {
        System.out.println("StaticHolderSingleton inited.");
    }
    private static class InstanceHolder
    {
        final static StaticHolderSingleton INSTANCE = new StaticHolderSingleton();
    }
    public static StaticHolderSingleton getInstance()
    {
        System.out.println("getInstance Invoked.");
        return InstanceHolder.INSTANCE;
    }
    public void someService()
    {
        System.out.println("someService invoked.");
    }
    public static void main(String[] args)
    {
        StaticHolderSingleton.getInstance().someService();
    }
}

我们知道类的静态变量被初次访问触发Java虚拟机对该类进行初始化,即该类的静态变量的值会变为初始值而不是默认值。静态方法getInstance()被调用的时候Java虚拟机会初始化这个方法所访问的静态内部类InstanceHolder。这使得InstanceHolder的静态变量INSTANCE被初始化每次部分使StaticHolderSingleton 类的唯一实例得以创建。因为类的静态变量只会创建一次,因此StaticHolderSingleton只会被创建一次。
正确实现延迟加载的单例模式还有一种方法,那就是利用枚举类型。示例代码如下:

public class EnumBasedSingletonExample {
    public static void main(String[] args)
    {
        Thread t = new Thread(){
            @Override
            public void run()
            {
                System.out.println(Singleton.class.getName());
                Singleton.INSTANCE.someService();
            }
        };
        t.start();
    }
    public static enum Singleton{
        INSTANCE;
        Singleton()
        {
            System.out.println("Singleton inited.");
        }
        public void someService()
        {
            System.out.println("someService invoked.");
        }
    }
}

枚举类型Singleton相当于一个单例类,其字段INSTANCE值相当于该类的单一实例。这个实例是在其初次被引用的时候才被初始化的。仅仅访问Singleton本身不会导致该实例被初始化。

10.CAS与原子变量

10.1CAS

保障向自增这样比较简单的操作的原子性,可以选择CAS。CAS能够将read-modify-write和check-and-act之类的操作转换为原子操作。
CAS好比一个代理人,共享同一个变量V的多个线程就是它的客户。当客户需要更新变量V的值的时候,它们需要请求(即调用)代理人代为修改,为此客户要告诉代理人其看到的共享变量的当前值A及其期望的新值B。CAS作为代理人,相当于如下伪代码所示的函数:

boolean compareAndSwap(Variable V,Object A,Object B){
	f(A == V.get()){//check:检查变量值是否被其他线程修改过
		V.set(B);//act:更新变量值
		return true;//更新成功
	}
	return false;//变量值以及被其他线程修改过,更新失败
}

CAS是一个原子的if-then-act操作。其假设是:当一个线程执行CAS操作的时候,如果变量V的当前值和客户请求(即调用)CAS时所提供的变量A(即旧值)是相等的,那么就说明没有线程修改过变量V的值,否则说明有线程修改过变量V的值。执行CAS时如果没有其他线程修改过变量V的值,那么下手最快的线程就会把变量V的值更新为新值,其他线程的更新请求则会失败。这些失败的线程通常可以再次尝试直到成功。
有了CAS之后,线程安全的计数器实现可以如下所示:

import sun.misc.Unsafe;
public class CASbasedCounter {
    private volatile long count;
    public long value()
    {
        return count;
    }
    public void increment()
    {
        long oldValue;
        long newValue;
        do {
            oldValue = count;
            newValue = oldValue+1;
        }while (!Unsafe.getUnsafe().compareAndSwap(oldValue,newValue));
    }
    private boolean compareAndSwap(long oldValue,long newValue)
    {
        //此处进行具体实现
    }
}

需要注意的是,CAS只是保障了这个共享变量更新这个操作的原子性,它并不保障可见性。因此,在上述代码中我们仍然采用volatile修饰共享变量count。CAS仅保障共享变量更新操作的原子性,它并不保障可见性。

10.2原子操作工具:原子变量类

原子变量类是基于CAS实现的能够保障对共享变量进行read-modify-write更新操作的原子性和可见性的一组工具类。原子变量类如下表所示:

分组
基础数据类型 AtomicInteger,AtomicLong,AtomicBoolean
数组型 AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
字段更新器 AtomicIntegerFieldUpdater,AtomicLongFieldUpdater,AtomicReferenceFieldUpdater
引用型 AtomicReference,AtomicStampedReference,AtomicMarkableReference

AtomicLong继承自number类,它内部维护了一个long型的volatile变量。AtomicLong类对外暴露了相关方法用于实现针对该volatile变量的自增(自减)操作,这些操作是基于CAS实现的原子性操作。
AtomicLong类的常用方法如下表格:

方法声明 功能
public final long get() 获取当前实例的当前数值(获取的是相对新值,未必是最新值)
public final long getAndIncrement() 使当前实例的数字以原子方式自增1。该方法的返回值为自增前的数值。
public final long getAndDecrement() 使当前实例的数字以原子方式自减1。该方法的返回值为自减前的数值。
public final long incrementAndGet() 使当前实例的数字以原子方式自增1。该方法的返回值为自增后的数值。
public final long decrementAndGet() 使当前实例的数字以原子方式自减1。该方法的返回值为自减后的数值。
public final void set(long newValue) 设置当前实例的数值为指定的值

AtomicInteger的使用方法类似于AtomicLong。
AtomicBoolean类乍看有些多余,因为对布尔类型变量的写操作本身就是原子操作。这里需要注意更新操作不是简单的赋值。AtomicBoolean类如同其他原子类一样,它们是要实现以read-modify-write操作的原子性。

private final AtomicBoolean initializating = new AtomicBoolean(false);
if(initializating.compareAndSet(false,true)){
	//一些操作
}

我们使用了AtomicBoolean 的compareAndSet方法来保证上述check-then-cat的原子性,从而避免的锁的开销。
AtomicReference类和AtomicBoolean类比较类似,其可以理解为对引用型变量的有条件更新:更新引用变量时确保该变量的确是我们要修改的那个,即该变量没有被其他变量修改过。
当然,CAS实现原子操作的背后一个假设是:共享变量的当前值与当前线程所提供的旧值相同,我们就认为这个变量没有被其他线程修改过。这个假设不一定成立。例如,对于变量V,当前线程看到它的值为A那一刻,其他线程已经将其更新为B,接着在当前线程执行CAS的时候该变量的值又被其他线程更新为A,那么此时会认为变量V的值未被其他线程更新过。这就是ABA问题。规避ABA问题需要为共享变量的更新引入一个修订号(也称时间戳)。每次更新共享变量时相应的修订号的值就会被增加1。对于初始实际值为A的共享变量V,它可能经历过这样的更新:[A,0]到[B,1]到[A,2],这种情况下我们能准确的判断究竟变量的值是否被其他线程修改过。

11.对象的发布与逸出

我们知道线程安全问题产生的前提条件是多个线程共享变量。即使是private变量,它也可能被多个线程共享。例如以下代码:

public class Example{
	private Map<String, Integer> registry = new HashMap<String, Integer>();
	public void someService(Stirng in){
		//访问registry
	}
}

多个共享变量还有其他途径,它们被统称为对象发布,对象发布是指使对象能够被其作用域之外的线程访问,常见的发布形除了上述的private变量之外,还包括以下几种。

  • 将对象引用存储到public变量中。例如:
public Map<String, Integer> registry = new HashMap<String, Integer>();

从面向对象的原则来看,这种发布形式不太提倡,因为它违反了信息封装原则。

  • 在非private方法(包括public、proteced、package方法)中返回一个对象,例如:
private Map<String, Integer> registry = new HashMap<String, Integer>();
public Map<String, Integer> getRegistry(){
	return this.registry ;
}
  • 创建内部类,使得当前对象(this)能够被这个内部类使用。例如:
public void startTask(final Object task){
	Thread t = new Thread(new Runnable(){
		@Override
		public void run(){
			//省略其他代码
		}
	});
}

上述代码中的"new Runnable()“所创建的匿名类可用访问其外层类的当前实例this(通过"外层类名.this这种语法访问”),也就是该匿名类的外层类发布了自身的当前实例。

  • 通过方法调用将对象传递给外部方法。
    外部方法是相对于某个类而言其他类的方法或者该类的可覆盖方法(即非private方法或者非final方法)。将一个对象传递给外部方法也会被视为对象发布。

11.1对象的初始安全:重访final和static

Java中的类初始化实际上也采取了延迟加载的技术,即一个类被Java虚拟机加载之后,该类的所有静态变量的值仍然是默认值(引用型变量的默认值为null,boolean变量的默认值为false),直到有个线程初次访问了该类的任意一个静态变量才使这个类被初始化——类的竞态初始化块(“static{}”)被执行。一个示例如下:

public class ClassLazyInitDemo {
    public static void main(String[] args){
        System.out.println(Collaborator.class.hashCode());
        System.out.println(Collaborator.number);
        System.out.println(Collaborator.flag);
    }
    static class Collaborator{
        static int number = 1;
        static boolean flag = true;
        static {
            System.out.println("Collaborator initializing");
        }
    }
}

上述demo的运行输出类似如下:
32379559
Collaborator initializing
1
true

可见,访问Collaborator类本身仅仅使该类被Java虚拟机加载,而并没有使其被初始化。从"Collaborator initializing"在number的初始值1之前被输出可以看出,当一个线程初次访问Collaborator的静态变量时这个类才被初始化。
static关键字在多线程环境下有其特殊的含义。它能够保证一个线程即使在未使用其他同步机制的情况下也总是可以读取到一个类的静态变量的初始值(而不是默认值)。但是,这种可见性保证仅限于线程初次读取该变量。如果这个静态变量在相应的类初始化完毕之后被其他线程更新过,那么一个线程要读取该变量的相对新值仍然需要借助锁、volatile关键字等同步机制。
一个static可见性保证的示例如下:

import java.util.HashMap;
import java.util.Map;

public class StaticVisibilityExample {
    private static Map<String,String> taskConfig;
    static {
        System.out.println("The class being initialized");
        taskConfig = new HashMap<String, String>();
        taskConfig.put("url","https://github.com");
        taskConfig.put("timeout","1000");
    }
    public static void changeConfig(String url, int timeout)
    {
        taskConfig = new HashMap<String, String>();
        taskConfig.put("url",url);
        taskConfig.put("timeout",String.valueOf(timeout));
    }
    public static void main(String[] args)
    {
        Thread t = new Thread(){
            @Override
            public void run()
            {
                String url = taskConfig.get("url");
                String timeout = taskConfig.get("timeout");
                System.out.println("url is "+url+" and timeout is "+timeout);
            }
        };
        t.start();
    }
}

上述代码中t线程肯定能够看到static块中的操作,但是能否看到changeConfig方法中的操作并无保证。
对于引用型变量,static关键字还能保证一个线程读取到该变量的初始值时,这个值所指向的对象已经初始化完毕。
stati仅仅保障线程能够读取到相应字段的初始值,而非相对新值。

在多线程环境下final关键字有其特殊作用:
当一个对象被发布到其他线程的时候,该对象的所有final字段(实例变量)都是初始化完毕的,即其他线程读取这些字段的时候锁读取到的值都是相应字段的初始值(而不是默认值),而非final字段则没有这种保障。
假设两个线程分别执行下列代码中的writer()和reader(),那么reader()的执行线程读取到的实例变量x值一定为1,而该线程读取到实例变量y的值可能是2也可能是0。

public class FinalFieldExample {
    final int x;
    int y;
    static FinalFieldExample instance;
    public FinalFieldExample()
    {
        x = 1;
        y = 2;
    }
    public static void writer()
    {
        instance = new FinalFieldExample();
    }
    public static void reader()
    {
        final FinalFieldExample theInstance = instance;
        if(theInstance != null)
        {
            int diff = theInstance.y - theInstance.x;
            System.out.println(diff);
        }
    }
}

在JIT编译器的优化作用下,FinalFieldExample方法中的语句会被“挪入”writer方法,因此writer方法对应的指令可能被编译为与如下伪代码等效的代码:

objRef = allocate(FinalFieldExample.class);//子操作1:分配对象所需的存储空间
objRef.x = 1;//子操作2:对象初始化
objRef.y = 2;//子操作3:对象初始化
instance = objRef;//子操作4:将对象引用写入共享变量

子操作3可能被重排序到子操作4之后,当其他线程通过共享变量instance看到对象引用objRef的时候,该对象的实例变量y可能还没有被初始化。而FinalFieldExample的字段x是采用final关键字修饰,因此Java虚拟机会将子操作2限定在子操作4前完成。
对于引用型变量,Java语言规范还会保障其他线程看到包含该字段的对象时,这个字段所引用的对象必然是初始化完毕的。
需要注意,final关键字只能保障有序性,即保障一个对象对外可见的时候该对象的final字段必然是初始化完毕的。final关键字并不保障对象引用本身对外的可见性。

11.2安全发布与逸出

安全发布及时指对象以一种线程安全的方式被发布。当一个对象的发布出现我们不期望的结果或者对象发布本身不是我们所期望的时候,我们就称该对象逸出。逸出是我们要尽量避免的,因为它不是一种安全发布。
上述的发布形式3是最容易导致对象逸出的一种发布,它具体包括以下几种形式:

  • 在构造器中将this赋值给一个共享变量
  • 在构造器中将this作为方法参数传递给其他方法。
  • 在构造器中启动基于匿名类的线程。
    由于构造器为执行结束意味着相应初始化未完成,因此在构造器中将this关键字代表的当前对象发布到其他线程会导致这些线程看到的可能是一个未初始化完毕的对象,从而可能导致程序运行结果错误。一个安全发布的示例如下:
import java.util.Map;

public class SafeObjPublishWhenStartingThread {
    private final Map<String,String> objectState;
    private SafeObjPublishWhenStartingThread(Map<String,String> objectState)
    {
        this.objectState = objectState;
        //不能在构造器中启动工作者线程,避免this逸出
    }
    private void init()
    {
        new Thread(){
            @Override
            public void run()
            {
                String value = objectState.get("someKey");
                System.out.println(value);
            }
        }.start();
    }
    public static SafeObjPublishWhenStartingThread newInstance(Map<String,String> objectState)
    {
        SafeObjPublishWhenStartingThread instance = new SafeObjPublishWhenStartingThread(objectState);
        instance.init();
        return instance;
    }
}

一个对象在其初始化的过程中没有出现this逸出,我们就称该对象为正确创建的对象。要安全发布一个正确创建的对象,我们可以根据情况从以下方式中选择:

  • 使用static关键字修饰引用该变量的对象
  • 使用final关键字修饰引用该变量的对象
  • 使用volatile关键字修饰引用该变量的对象
  • 使用AtomicReference来引用该对象
  • 对访问该对象的代码进行加锁

你可能感兴趣的:(Java多线程)