Java多线程编程实战指南(随笔)

提示:下面讲解线程时,如果不特别声明,默认是一个线程运行在一个CPU上

1、概述

进程应用程序向操作系统申请计算机资源(文件句柄,内存等)的最小单位,而线程是进程中可独立执行的最小单位。一个进程中可以包含多个线程,这些线程共享这个进程的资源。线程所以完成的计算工作叫做任务,比如文件下载,解压缩等都属于一个任务。

2、线程:

1、线程在Java中作为Thread类的实例存在,创建线程的两种方式new Thread(),new Thread(new Runnable()) ,主要区别在于前者通过继承,耦合性较高,后者通过组合,耦合性较低。另外通过组合还可以实现多个线程共享Runnable实例的数据
2、线程的start方法只能被调用一次,再次调用会抛出IllegalThreadStateException异常
3、创建线程对象时,还会给线程的调用栈Call Stack分配内存,有些版本的虚拟机还会给线程关联一个内核线程,所以线程对象的创建比普通对象的成本高一些
4、Java中的线程可分为守护线程 / 非守护线程,应用线程 / 垃圾收集线程
5、线程是否是守护线程和父线程相同而且线程优先级相等,并且可以通过Thread.setDaemon(Boolean)方法更改线程类型,并且这个方法要在线程被启动之前调用
6、Java中线程基本状态:NEW(已创建但是未start),RUNNABLE(运行中或者就绪队列上等待运行),BLOCKED(阻塞,特指在申请某些互斥资源失败后的状态),WAITING(特指调用了Object.wait()或者LookSupport.park()后的状态),TIMED_WAITING(指可设置等待时间的WAITING状态),TERMINATED(线程执行结束的状态)
7、理解线程饥饿、死锁、锁死、活锁的概念

3、多线程编程的目标与挑战

1、理解串行,并发,并行在生活中的概念以及在编程中的概念
2、计算的正确性依赖于相对时间的顺序或者线程的交错,这种现象就叫做竞态
3、理解原子性,可见性和有序性的基本概念,理解数据访问的概念:通常读写操作叫做数据访问
4、原子性问题:32位JVM访问double、long变量,读-修改-写,先检验后执行。对于第一个解决方法可以使用volatile保证变量的访问原子性,可以使用锁保证一系列操作的原子性。
5、可见性问题:JIT将程序当做单线程应用编译时指令重排序优化导致线程压根没有按照代码编写顺序执行任务和访问数据、线程更新的数据其他线程无法及时读取。常见的是第二种,可简单将JVM内存模型抽象为四层:CPU,写缓冲器高速缓存主内存(其实不止,还有寄存器等其他计算机元件)。当一个线程在一个CPU上运行,更新数据先将数据更新到写缓冲器,并且根据一定策略同步到高速缓存和主内存。线程A所在的CPU是无法直接读取线程B所在CPU的的写缓冲器数据的,但是A-CPU可以通过缓存一致性协议同步B-CPU高速缓存中的内容(缓存同步)或者读取主存上的内容。将当前线程所在CPU的写缓冲器的内容同步到自己的高速缓存或者主内存的过程叫做冲刷处理器缓存,而将主内存或者其他CPU高速缓存中的内容更新到当前线程所在CPU的高速缓存过程叫做刷新处理器缓存。可以通过volatile提醒编译器变某些变量可能用于多线程环境下来取消其默认的指令重排序优化,而且能够使CPU在更新volatile变量后立刻冲刷处理器缓存,在读取volatile变量之前必定进行刷新处理器缓存。可见性得以保证只能让线程计算任务导致的变量更新结果第一时间让其他线程看到,但是依然存在原子性问题:比如两个线程在两个CPU上同时进行计算任务其中一个线程的计算结果会被覆盖。所以衍生出了相对新值最新值的概念,即可见性只能保证当前线程CPU读取到的是一个相对新值,如果想要读取最新值还需要原子性得以保证。而对于单处理器的计算机,通过时间片调度来实现多线程,CPU寄存器中的变量属于线程上下文,上下文切换时如果寄存器的内容没有刷新到高速缓存或者主存依然会造成线程间变量的不可见性。
6、有序性问题:重排序是编译器(普通编译器,JIT编译器)、处理器、存储子系统(写缓冲器,高速缓存)在不影响单线程程序正确性的前提下,对内存访问操作所做的一种优化。这种优化在多线程环境下可能会导致源代码顺序程序顺序执行顺序感知顺序出现不一致的情况,被称作有序性问题。常见的重排序类型及表现如下:

重排序类型 重排序来源 重排序表现
指令重排序 javac编译器(静态编译器) 程序顺序与源码顺序不一致
指令重排序 JIT编译器(动态编译器)、处理器 执行顺序与程序顺序不一致
存储子系统重排序(内存重排序) 高速缓存,写缓冲器 源代码顺序、程序顺序、执行顺序一致,感知顺序不同

实际编程中,静态编译器指令重排序的概率很低,比较经典的动态编译器指令重排序是在创建对象时
Object A = new Object();在多线程环境中,可能仅仅为对象分配了内存但是构造函数Object()还没有执行完,就已经将这个内存地址赋值给A,这就导致其他线程可以访问A,但是由于构造函数尚未执行完毕,在读取A的数据时抛出异常。
现代处理器在运行程序时会顺序的一条条读取编译好的指令,即顺序读取,但是指令执行所需的操作数是各不相同的,处理器对于操作数已经就绪的指令会优先执行,可能会导致执行的顺序(执行顺序)和指令读取的顺序(程序顺序)是不相同的,即乱序执行。但是处理器的计算结果会先存入到重排序缓冲区,等到相关的操作计算完成再按照读取顺序一次性提交到寄存器、高速缓存或者主存,即顺序提交。此外处理器还会进行猜测执行,也可能导致执行重排序。但是这些重排序在单线程环境下不会有任何问题,多线程环境下如果不采取某种措施则会超出我们的设想。
我们将寄存器、写缓冲器、高速缓存称为存储子系统,处理器在存储子系统和主存之间有两种内存访问操作。将数据从存储子系统写入到主存即Store操作,还有就是将数据从主内存加载到存储子系统,即Load操作。指令重排序是编译器和处理器实实在在的改变了指令的顺序,重排序的对象就是指令,是一种动作。而内存重排序则是一种现象,重排序的对象是指令执行的结果(内存访问操作),假设当前线程的处理器和编译器都按照源代码顺序进行工作,有两个指令M1和M2,经过执行后需要进行内存访问操作O1和O2将执行结果同步到主存上,处理器首先将操作结果写入到存储子系统,但是存储子系统比如高速缓冲区,为了提高效率,在于主存进行同步时采取了某种算法,打乱了O1和O2的顺序,这在其他线程(处理器)看来,可能是O2→O1,即感知顺序和源代码顺序不同,由于内存访问操作只分两种,所以内存重排序共有 2 * 2 = 4种:LoadLoad、LoadStore、StoreLoad、StoreStore。
所有的重排序指令之间都遵循特定的规则从而给单线程应用造成一种是按照源码顺序执行的假象,这种假象叫做貌似串行化语义。而多线程环境下则无法保证这种假象。
而这个特定的规则就是没有数据依赖。如果两个指令涉及同一变量,且至少有一个指令中包含对变量的写操作,则这两个指令存在数据依赖,即这两个指令一定不会被重排序,另外需要注意的是指令间只存在控制依赖关系的话是允许进行重排序的。
对于单处理多线程则比较特殊,只有静态处理器的指令重排序会影响多线程的正确性。而对于运行时期的指令重排序如处理器乱序执行,JIT编译器,存储子系统重排序都不会对其产生影响。因为单处理器多线程是依靠时间片和上下文切换来实现的,而上下文的切换是等到重排序的相关指令都执行完之后才进行,所以对于其他线程来说就像没有发生过重排序一样。
7、线程所执行的任务的进度信息(寄存器内容,程序计数器内容等)就是线程上下文。运行中的线程丢失处理器使用权被暂停,进度信息被暂存到内存,另一个线程获得处理器使用权并且从内存中载入自己的进度信息然后开始或者继续运行,这样的过程就是上下文切换。从Java应用的角度来说,一个线程从RUNNABLE变成非RUNNABLE暂停线程)的过程就叫做上下文切换,而一个线程从非RUNNABLE变成RUNNABLE唤醒线程)只能说其获得了可以获得CPU使用权的一个机会,当其获得了使用权从内存中恢复自己的进度信息,也是上下文切换。上下文切换分为自发性非自发性
8、同一时间内处于RUNNING状态的线程越多,则代表并发程度越高,简称高并发。某些线程申请访问一个正在被其他线程持有的排他性资源被称作争用,这些线程越多则代表争用程度越高,简称高争用。而多线程编程的目标在于高并发且低争用。
9、计算机如何为线程分配排他性资源就是资源的调度,在不同的场景下使用不同的调度策略,有利于降低争用。资源调度分为公平调度非公平调度。无论哪种方式,线程抢占资源失败都会被暂停,保存上下文,并且进入等待队列。当被允许抢占资源时才会被从等待队列上唤醒,等到获得CPU使用权,然后加载上下文并且申请资源使用权。公平调度在等待队列为空时才允许线程申请资源,对于可以申请资源的线程,成功则获得使用权,失败则被暂停然后进入等待队列,等待资源被当前持有线程释放时,调度器按照等待队列先进先出的策略唤醒等待队列上的线程去抢占CPU并且申请资源。非公平调度则允许当前的RUNNING线程直接申请资源,成功获得使用权,失败则被暂停然后进入等待队列,等待资源被当前持有线程释放时,调度器唤醒等待队列中一个线程(是不是第一个线程有待考证),等到获取CPU使用权加载上下文然后申请资源,但是在这个被唤醒的线程尚在加载上下文过程中,是允许在RUNNING的线程直接申请资源的,所以可能导致其开始运行时由于资源又被抢占了而被暂停重新回到等待队列。可以看出,公平调度线程抢到资源的时间都差不多,而非公平调度中,可能有些线程反复被暂停唤醒N次才能抢到资源,甚至造成饥饿。但是在争用较高的情况下,公平调度由于每次抢占资源都需要伴随一次上下文切换,开销较大,而非公平调度允许RUNNING线程直接抢占资源,如果线程持有资源的时间较少,甚至在等待被唤醒线程载入上下文过程中资源以及被抢占了好多次,这样就会大大减少上下文切换的开销增加吞吐率。但是在线程占用资源时间过长的情况下,非公平策略没有任何好处,还会带来线程饥饿的问题。非公平策略是一般情况下的默认策略。

4、线程同步机制——共享数据访问控制

1、默认情况下,多线程程序由于硬件(存储子系统、写缓冲器)和软件(编译器)对程序的优化会产生一些预想不到的行为即多线程问题。我们如果想获得正确的运行结果就需要在代码层面通过一些措施来协调线程间的数据访问和活动,这种措施就是线程同步机制。Java平台提供的线程同步机制包括:锁,volatile关键字、final关键字、static关键字以及一些API(wait() / notify())
2、锁可以控制线程一段代码的执行权限(控制哪个线程可以执行),而这段被锁控制的代码叫做临界区。当一个线程想执行临界区时,必须持有引导该临界区的锁。前面我们已经知道,并发编程的主要问题在于原子性、可见性、有序性。Java中的锁有几种实现,而合理的使用不同的锁则可以使得这三个性质得以保障。
原子性:如果想保证原子性,则需要将本来对于共享数据的并发访问变成逻辑上的串行访问。即我们将所有访问共享数据的操作都写在临界区内并且使用互斥锁(排它锁)就行。互斥锁只能被一个线程持有,也就是说同一时间只有一个线程可以执行临界区代码。
可见性:线程在获得锁时会隐式的刷新处理器缓存,线程在释放锁时会隐式的冲刷处理器缓存,使得临界区中的共享变量都是相对新值。不仅如此,锁还能保证引用类型的变量所指向的内容都是相对新值!比如数组,集合,其内部的对象和对象的字段都会是相对新值!如果当前锁是互斥锁,那么原子性和可见性都得到了保障,从而这些共享数据都会是最新值~而由于JIT重排序导致的可见性问题则待会再说。
有序性:如果原子性和可见性都得到了保障,那么从实际结果来看,相当于有序性得到了保障,即使发生了重排序,因为临界区的代码执行是串行的而且能够保障执行完的结果其他线程能够看到,而且由于锁的功能,临界区内的指令不会和临界区外的指令进行重排序。
3、理解锁粒度的概念。
4、锁的使用会增加处理器时间消耗:锁的申请和释放、锁导致的线程上下文切换。并且还会导致锁泄露,死锁等一系列线程活性问题。
5、内部锁(synchronized)是Java自带的一个互斥锁,被synchronized关键字修饰的方法或者代码块就是临界区。需要知道,Java中所有对象内部都有一个互斥资源(Monitor,这个才是锁的本体。synchronized可以修饰方法和代码块,当线程访问一个synchronized实例方法时,需要申请的锁就是这个实例的Monitor;线程访问一个synchronized静态方法时,需要申请的锁就是这个类的Class对象的Monitor;线程访问一个synchronized代码块时,需要申请我们指定的对象(锁句柄)的Monitor;由于锁句柄是我们指定的,为了保证其不会被改变,通常使用private final修饰。而之所以被称为内部锁是因为锁的申请和释放都是JVM进行的不用我们介入,而且由于JVM的控制所以不会出现锁泄露。
锁作为一个互斥资源,其等待队列是JVM给其分配的一个叫做EntrySet(入口集)的数据结构,就是一个普通队列。内部锁默认是非公平的,而且只能是非公平的。
6、显式锁:JUC.locks下的锁,典型的有Lock(接口),ReentrantLockReadWriteLock。由于这些锁是代码层面的,锁的申请和释放需要由开发人员显式控制,所以被叫做显式锁ReentrantLock可重入锁,并且其既支持公平锁也支持非公平锁。与内部锁不同的是,显式锁的使用更自由,不局限于一个方法或者代码块,而且显式锁提供了tryLock操作,当执行线程使用tryLock失败时,并不会被暂停。
7、Java1.6、1.7对内部锁进行了优化:锁消除锁粗化偏向锁适配性锁Java1.5中再高争用情况下,内部锁性能急剧下降而显式锁相对稳定,不过经过内部锁优化之后,两种锁就没有太大差距了,所以现在如果仅仅是使用非公平互斥锁的情况下还是直接使用内部锁。但是某些情况下我们可能需要一些具有某些特性的锁,比如读写锁。读写锁分为读锁写锁,这不是说ReadWriteLock是两个锁,而是其作为一个锁有两种角色。线程读取共享数据时必须持有其读锁,读锁不是互斥的,但是如果共享数据的写锁已经被持有,则无法获得其读锁。线程更新共享数据时必须持有写锁,写锁是互斥的,如果共享数据的读锁或者写锁已经被持有,则无法再获得写锁。读写锁的使用可以实现多线程程序并发的只读某些数据,当只读操作比更新操作频繁的多、读线程持有锁时间较长的情况下建议使用读写锁。
8、前面介绍了可见性和有序性的保障为冲刷处理器缓存和刷新处理器缓存和禁止重排序,而这几个动作则是依靠内存屏障来实现的,并且内存屏障只针对于对于主内存的访问操作指令(比如从主内存读取数据的Load / Read指令、将数据从写入主内存的Store / Write指令)。内存屏障其实就是一些特殊的指令,当这些指令能够起到限制重排序边界和提醒处理器操作缓存的作用,但是对于不同的处理器架构,这些指令是不同的,无需在意只要知道常见的一些就行。
按照可见性划分:加载屏障存储屏障。在锁获得指令之后添加加载屏障,可刷新处理器缓存。在锁释放之后添加存储屏障,可冲刷处理器缓存。
按照有序性划分:获取屏障释放屏障。在读操作之后插入一个获取屏障,可以禁止这个读操作之后的读写操作与其重排序。在写操作之前插入一个释放屏障可禁止前面的读写操作与其发生重排序。前面已经知道,临界区内的指令不会与临界区之外的指令发生重排序,并且能够保证原子性,可见性,有序性。现在应该清楚了。互斥资源Montor作为锁用来保证临界区的原子性,在获得锁指令EnterMonitor(是一个读操作)之后插入获取屏障,在释放锁指令ExitMonitor(是一个写操作)之前插入释放屏障用来保证临界区代码与外部的有序性,在EnterMonitor之后插入加载屏障,在ExitMonitor之后插入存储屏障用来保证临界区的可见性。
假设临界区内没有其他临界区:由于重排序是一种提高程序性能的动作,只是在多线程环境中会出现意想不到的结果,为了不使性能受到太大损伤,现代编译器(JIT)的一些优化是在生成代码屏障之前,可根据某些策略将临界区外的代码先移到临界区内,然后再插入内存屏障,一旦编译结束、屏障生成,则这些代码不允许被重排序到临界区外,而被移到临界区内的代码则可以在满足貌似串行化语义的情况下被重排序。
9、之前提到过volatile关键字可以保证64位JDK下double/long变量读取的原子性以及volatile修饰变量的可见性以及访问这个变量的有序性,由于其不会引起上下文切换且功能有限所以也被称为轻量级锁。编译器不会将volatile变量分配到寄存器中存储,处理器对于volatile变量的访问操作只会在高速缓存或者主内存上进行。处理器读volatile变量时的行为类似于获得锁,写volatile变量时的行为类似于释放锁。即在写volatile变量操作之前会插入释放屏障防止和其之前的读写该变量的操作重排序保证有序性,在其之后插入存储屏障用来保证对volatile的写入及其之前其他变量写入操作对其他线程的可见性。在读volatile变量操作之前会插入加载屏障,在其之后插入获取屏障来保证volatile变量的读操作和其之后的读写操作不会发生重排序,并且由于加载屏障的存在,不仅能保证其读取的是相对新值,也能保证其之后的其他变量的读写操作的数值是相对新值。
与synchronized不同的是,volatile修饰引用类型如数组,对象时只能保证对这个引用起作用,二队引用对象的内部数据不起作用。
由于volatile每次读取和写入时都需刷新/冲刷处理器缓存,而且不能存储在寄存器中,所以其访问开销介乎于普通变量的访问和临界区变量的访问之间。
volatile比较常见的使用场景是一个线程设立标志位,其他线程读取标志位时用来保证可见性和该变量的原子性有序性,即一写多读。还有就是如果一组共享变量的更新状态要保持一致,比如4个变量都被A线程修改,结果前两个变量更新被其他线程观察到,后两个没有,预期应该是要不都没被观察到,要不都被观察到。这个时候可以将四个共享变量塞入一个volatile的对象中,直接更换这个对象的地址是原子性的,可以实现其他线程查看这个对象中的四个共享变量的更新状态是一定保持一致的。
来看一个多线程入门必会的实例:双重检查锁定——单例模式

public class Singleton {
    private volatile static Singleton instance = null;

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

双重检查是为了不让线程在获取单例实例时每次都申请锁;synchronized修饰代码块是为了不让单例类被多次创建;volatile是为了不让分配内存指令、**对象初始化指令、重排序到地址赋值指令(写操作)之后,详解如下:

instance = new Singleton(); 实际上对应着三个不同的指令
1 objRef = allocate(Singleton.class) 给对象分配内存
2 invokeConstructor(objRef); 执行objRef引用对象的构造函数
3 instance = objRef 将构造完成的对象的地址赋值给instance

如果我们不用volatile修饰instance,则在临界区中2可能重排序到3之前,那样其他线程可能发现instance并不为null,但是构造函数还没有执行完全,这样在后续的代码执行时可能造成空指针异常。如果想实现多线程下的延迟加载单例模式而又不想像双重检查锁定一样复杂,则可以使用静态内部成员变量枚举的方式实现:

静态内部成员变量,类成员变量在被初次访问时才会触发该类的初始化,且JVM能保证静态变量初始化的多线程特性,
即默认多线程环境下,能且仅能保证静态变量的初始化值是可靠的
public class Singleton {
  private static final Singleton INSTANCE = new Singleton();

  public static Singleton getInstance() {
      return INSTANCE;
  }
}
枚举值,枚举实例在初次被调用时才会被初始化,同样由JVM保证其多线程特性
public enum Singleton{
  INSTANCE;
}
Singleton.INSTANCE就是单例类的实例

10、CAS与原子变量:count++在多线程环境下是不安全的,同步方法很简单,使用内部锁或者显式锁就行。只为了这样一个最简单的自增操作就使用开销较大的锁不够优雅,CAS则是我们更好的选择。CAS全称为compare and swap,这是一个由处理器提供的无需加锁的原子操作,包含了很多指令。把CAS抽象成函数:

boolean CAS(Variable V,int oldValue,int newValue) {
      if (V.getValue() == oldValue) {
            setValue(newValue);
      } else {
            return false;
      }
}

简单来说就是,我们使用CAS 指令时需要传给处理器三个值,需要更新的变量,更新线程当前看到的该变量的值,更新线程需要将该变量更新的值。而处理器会直接看这个地址上的值是否和更新线程看到的值相等,相等则说明在更新线程执行计算到位变量赋值的过程中没有其他线程更改过变量值,则可以执行更新,如果不相同则说明发生了竞态,放弃此次更新操作(但会发生ABA问题)。由于CAS只保证原子性,但是其内部有一个setValue操作,所以需要将这个value设置为volatile,保证其可见性
11、static和final:前面讲单例的时候说道static类型的变量在多个线程共享时,能够保证其在第一次调用时被初始化且仅被初始化一次,但是如果static变量是一个引用类型,由于重排序等作用,则不能保证这个static变量内部的成员变量也被初始化完毕,但是能保证其也被初始化。但是final类型变量则更厉害。在其修饰变量能够被其他线程看到,其被第一次读取的时候总是能读取到初始值,并且如果这个final变量是引用类型,其成员变量如果有初始值也一定会被初始化完毕(禁止了JIT重排序和JIT的内联优化)。
12、对象的发布与逸出:共享变量的引起多线程问题的原因,但是如果共享变量是private的就只能通过其他public等可见的方法或者内部类的方式来房屋共享变量,这种非直接访问的途径统称为对象发布,常见的对象发布有将private的共享变量注册到public的容器中,内部类的this指针,public方法中访问等。由于对象发布可能造成多线程问题,如果出现了问题就叫做对象逸出

5、线程间协作

前面了解了并发环境下多线程可能产生的问题。但是并发线程之间通常不是各自为战的,而是需要相互结合来完成一项复杂的计算任务,即线程间协作
1、暂停和唤醒:所有对象内部都含有一个由JVM控制的monitor互斥变量,即所有的对象都能用来引导临界区。Object类有两个原子方法wait() notify(),即所有对象都有这两个方法。当一个线程进入到某个对象obj所引导的临界区内,调用obj.wait()方法,则会导致当前线程暂停并且释放obj.monitor,然后进入obj的等待队列(也被叫做WaitSet),状态变为WAITING(注意,不是之前讲的EntrySet,下文中将用阻塞队列来替代EntrySet),等待队列中的线程只能被其他处于obj所引导的临界区内的线程调用obj.notify()可能被唤醒回到RUNNALBE状态等待CPU资源获得重新进入临界区继续执行之前线程上下文的机会。为什么说可能被唤醒?是因为等待队列上可能会有多个线程,而notify一次只能唤醒一个随机线程,如果想唤醒所有线程则可以调用obj.notifyAll(),除此之外Object类还有wait(long time)方法,即线程在等待队列中等待了time毫秒依然没被其他线程唤醒则由JVM进行唤醒。暂停/唤醒是线程间协作最基本的方法。
wait/notify的问题:notify唤醒随机线程,notifyAll唤醒所有线程,这也就导致了一个最显而易见的问题,无法唤醒确切的线程,如果要想让我们想唤醒的线程一定能被唤醒,唯一的办法就是唤醒所有线程。但是无缘无故被唤醒的线程其关键数据尚未准备好,导致其继续进入WAITING状态,即会产生过早唤醒问题。
还有就是如果暂停线程并不是放在循环语句中,且通知线程的执行在暂停线程之前,这样就导致暂停线程再也得不到唤醒,即信号丢失问题。
Thread.join()是Java用wait/notify实现的
2、条件变量:wait/notify太过于底层,且一堆问题,甚至都不能分清暂停线程是被notify唤醒还是wait超时唤醒。而使用JUC.locks包下的条件变量Condition能够解决这两个问题。Condition通过显式锁构造,像
Condition condition = lock.newCondition();就创造了一个显式锁lock的条件变量。类似于Object,Condition同样有暂停和唤醒方法await() / signal() awaitUntil(Date deadline) signalAll(),并且这几个方法的调用也需要线程持有该显式锁;类似于WaitSet,Condition中也维护了一个等待队列用于存放WAITING状态的线程。但是一个显式锁对象lock可以创造多个Condition实例,如果为使用不同条件的暂停/唤醒线程创造不同的Condition实例,则可以实现唤醒指定线程的功能。Condition.awaitUntil(Date date),如果date大于当前时间,则返回true,表示未超时,返回false则表示超时。所以当线程被唤醒时,调用该方法发现返回值为true则表示是被显示欢喜而不是超时唤醒的。
3、倒计时协调器,CountDownLatch:现有线程A、B,B线程中调用A线程的join方法,可让B在A线程执行完之后再执行,借此可以衍生出两个问题:①如何让B不等到A结束,而是等到A执行某个操作之后继续执行 ②如何让另外几个线程C、D......和B一同执行
CountDownLatch完美解决这两个问题,A执行的某个引发B或者B和其他线程继续执行的操作被叫做先决操作,CountDownLatch中维护了一个先决操作个数的变量,姑且称之为count,当一个线程调用CountDownLatch.await()时,如果count不等于0,则会被暂停。当其他线程调用一次CountDownLatch.countDown()时,相当于count--,如果调用时count = 0则调用无效,count-- == 0,则会唤醒所有调用过await()方法被暂停的线程。从count第一次为0开始,CountDownLatch就失效了,此时无论是再调用await或者是countDown都无效,即CountDownLatch是一次性的。
4、CyclicBarrier,栅栏:栅栏也可以暂停线程,暂停在栅栏上的线程被称为参与方。栅栏从表面意思来看就是起拦截作用,栅栏外堆积到一定的人数就释放这一批人然后继续拦截下一批人。一次拦截的人数就是count,每有一个人到栅栏外等待就相当于有一个线程调用了CyclicBarrier.await()方法使得count--然后被暂停进入WAITING状态,当第count个线程调用await()方法时count-- == 0,同时唤醒之前的count-1个参与方,使得所有参与方从同一状态开始运行。和CountDownLatch不同的是,Cyclic表示其是可重复使用的,当count == 0唤醒所有参与方之后,会将count重置为初始状态,拦截下一批参与方。并且在CyclicBarrier创建时可传入一个Runnable参数,当最后一个到达的线程唤醒之前暂停线程之前将调用这个Runnable对象的run()(注意不是start())方法。栅栏往往用来进行循环统一线程执行状态构造高并发环境
5、阻塞队列:能够导致线程被暂停(WATING BLOCKED)的操作或方法叫做阻塞操作/阻塞方法。Java1.5引入的JUC.BlockingQueue接口定义了阻塞队列的概念,并且有几个不同特性的默认实现类:ArrayBlockingQueue LinkedBlockingQueue SynchronousQueue等。按照容量又可分为有界队列(指定容量)和无界队列(Integer.MaxValue),入队列叫put,出队列叫take
ArrayBlockingQueue从名字就可以看出是一个通过数组实现的队列,因此其是有界队列,有界队列的特点是容量从开始就分配好,put take不会对垃圾收集造成压力(连续内存空间)。但是其puttake使用的是同一个显示锁,所以锁争用开销较高。
LinkeddBlockingQueue:从名字就可以看出是一个通过链表实现的队列,但是其既可以构造时指定容量作为有界队列,也可以不指定从而是无界队列。由于链表的动态生成节点特性,put或者take会给垃圾收集带来负担(碎片内存空间)。其put take使用的是两个显式锁,所以锁争用较小。内部使用了AtomicInteger防止更新size时出现线程同步问题。
synchronousQueue比较特殊,只有一个容量,且put时如果没有take线程阻塞在队列上,则put线程会阻塞。take时如果没有put线程阻塞在队列上,则take线程会阻塞。可见一定有一个线程会阻塞。
LinkedBlockingQueue仅支持非公平调度,ArrayBlockingQueueSynchronoussBlockingQueue既支持公平调度又支持非公平调度。
put线程和get线程效率差不多时,推荐使用synchronousBlockingQueueput get线程并发程度较大时建议使用LinkedBlockingQueue,并发程度较小时建议使用ArrayBlockingQueue
6、信号量Semaphore:如果使用队列,当put的效率远大于take的效率,那么就会造成无界队列内消息大量积压,而信号量则可以在此时用来控制put或者take的速度,即流量访问控制。Semaphore最主要的两个方法为acquire(申请一个信号量)和release(释放一个信号量);当信号量为0时申请线程会被暂停在信号量的等待队列上,当信号量为0时释放线程会唤醒等待队列上一个随机的申请线程,看到这里发现信号量与锁好像没什么区别。不过主要区别有两个:信号量可以初始化为多个值,当信号量初始值为1时就变成了类似monitor的互斥资源;另外就是信号量可以由任何线程使用,acquirerelease可以被任意调用没有任何限制条件,不像锁的释放需要先获得锁。因此如果初始值设为1,可以实现一个线程释放另一个线程持有的锁的功能。同样的,既然有争用那就分公平和非公平,信号量默认是非公平的。
7、PipedInputStream,管道:管道可以用来连接两个线程A、B,使得A能够通过Java流直接将数据写入到B,就像一条管道将两个线程连接了一样。这个将在学习IO的时候再仔细看,先了解是怎么回事就行,在多线程里不经常使用。
8、Exchanger和双缓冲:缓冲区可看做生产者与消费者之间的数据容器。多线程环境下,可以用两个缓冲区交换的方式来实现数据生产与消费的并发。public V Exchanger.exchange(V v),参数v是当前线程持有的缓冲区,返回值v是对方线程持有的缓冲区。一般情景是当生产缓冲区满了的时候,生产者调用exchange暂停当前线程,等待消费者缓冲区为空时消费者线程调用exchange时交换缓冲区唤醒生产者,两个线程继续运行,当生产者调用exchange的时候如果已经有消费者调用了exchange处于暂停状态,则交换缓冲区唤醒消费者,两个线程急继续运行。即生产者和消费者一直都是工作在两个缓冲区上的,不存在争用。仔细想一想。这个count = 2的栅栏CyclicBarrier是不是有点像呢,exchange就类似于await
9、线程中断:线程的中断请求并不是一个结果,而是一个请求或者说是消息,至于这个请求/消息目标线程不保证给予响应(由开发人员控制)。JVM给每个线程都维护了一个被叫做中断标记的布尔值,用来表示当前线程是否收到了中断请求, 可以使用Thread.currentThread().isInterrupted()方法来获取当前线程的中断标记,也可以使用Thread.interrupted()获取并且重置当前线程的中断标记。如果当前线程收到了中断请求,则中断标记为true,否则为false。可以调用线程对象interrupt()方法向其发送中断请求。
Java中有些方法可以响应中断标记,抛出线程中断异常,比如ReentrantLock.lockInterruptibly()。但也有一些毫无影响比如Inputstream.read()ReentrantLock.lock(),以及内部锁的申请等。依照惯例,响应中断请求并且抛出InterruptedException异常的方法,通常在抛异常之前会调用当前线程的interrupted()方法。如果A线程给B线程发送中断请求时B线程已经处于暂停状态,JVM可能会将B唤醒让其继续运行从而选择是否响应这个中断,即中断请求在某些情况下还能起到唤醒线程的作用。
10、线程停止:线程停止时状态会变为TERMINATED。但是Java并没有提供可以直接让线程终止的API。所以需要靠一些技巧来实现。比如可以设置一个共享变量来作为目标线程是否需要进行停止的标志。在标志其需要停止时最好也为其设置中断,中断主要是为了唤醒恰好处于阻塞状态的线程,让其处理线程停止请求。

6、从代码层面保证线程安全

1、无状态对象***:对象包含的数据就是对象的状态,无状态的对象一定没有数据,即实例变量和静态变量。但是没有数据的对象不一定是无状态对象。
2、不可变对象***:将对象设计成不可变的即,类型用final修饰,类中的所有字段都是用finalprivate修饰,如果要将数据暴露给外界,不能直接返回当前引用,要进行防御性复制
3、线程持有对象:如果一个对象即其内部所有的数据都只被一个线程访问,那么这个对象就是实际上的线程安全对象,虽然不是定义上的安全对象。这种对象也叫作线程特有对象,这个线程叫做持有线程
ThreadLocal<T>线程局部变量)是Java提供的用来保存线程特有对象的容器。T就是特有对象的类型,每一个ThreadLocal<T>都保存了多个线程各自的同一种线程特有对象。由于特有对象默认是靠线程对象来和当前线程关联,也就是说每一个ThreadLocal<T>实例中,只能有一个当前线程的特有对象。使用ThreadLocal.set(T)可以设置特有对象,使用ThreadLocal.get(T)可以取出特有对象。需要注意的是get(T)方法中会调用一个initvalue()的方法,但默认返回的是null,可以构造器子类重写这个方法来实现某些初始化动作。ThreadLocal<T>一般是作为某个类的静态变量使用。但是需要注意的是,ThreadLocal<T>并不是没有缺点的:①在某些场景下会产生退化数据错乱的问题。②可能导致内存泄露伪内存泄露,比如一个线程设立了特有对象后一直处于暂停状态,期间引用的ThreadLocal对象被垃圾收集清理了,但特有对象既无法被垃圾收集,也无法新加入特有对象时触发无效条目清理
4、装饰器模式:JUC下面的Collection.synchronizedXXX(Collection)能够使用装饰器模式给非线程安全对象使用锁重写其方法,实现线程安全,这些方法返回的对象叫做同步集合。使用装饰器模式可以实现更新操作的线程安全,但是不能保证遍历操作的线程安全。
5、并发集合:JUC也提供了一些本来就可以保障线程安全的并发集合。比如:

普通集合 并发集合 共同接口 遍历方式
ArrayList CopyOnWriteArrayList List 快照
HashSet CopyOnWriteArratSet Set 快照
LinkedList ConcurrentLinkedQueue Queue 准实时
HashMap ConcurrentHashMap Map 准实时
TreeMap ConcurrentSkipListMap SortedMap 准实时
TreeSet ConcurrentSkipListSet SortedSet 准实时

并发集合支持被不同线程同时更新和遍历。并发集合的遍历有两种方式:快照准实时。快照是在Iterator被建立的那一刻的只读副本(不支持remove()),是集合一瞬间的状态。不同线程遍历时持有各自的快照,相当于持有特有对象。优点就是不会受到更新操作的影响,但是如果集合太大很消耗内存和复制快照开销很大。准实时表示既不是使用快照(支持remove()),也不使用锁(CAS),更新和遍历并发进行,遍历时其他线程的更新操作可能被感知也可能不被感知。这些并发集合的特性

7、线程活性故障:

1、死锁:两个线程A和B,A持有互斥资源S1,申请S2;B持有互斥资源S2,申请S1。结果导致两个线程申请资源时由于资源互斥也已经被持有都进入暂停状态,导致两个线程永远不会被唤醒。死锁的产生必定有以下特征:
①资源互斥:资源同一时间只能被一个线程持有
②资源不可抢夺:资源只可能被持有线程主动释放
③占用并等待资源:线程以及占用部分资源,并在等待另一部分资源的过程中不会释放已持有资源
④循环等待资源:即A等B,B等A
产生死锁必定有以上条件,但同时具有以上条件不一定产生死锁(万一正常运行了呢)
解决死锁:由于锁在Java中的性质是不可改变的,互斥且可重入、必须由持有线程主动释放。所以只能从③④来解决。

解决方式 原因 缺点
锁粗化 扩大锁的粒度,比如将锁从申请筷子扩大至吃饭 一个人吃饭时其他人连申请筷子也不能
锁排序 给互斥资源编号,每个人都从编号较小的互斥资源开始申请 只需要保证排序正确,没什么太大缺点
控制资源等待时间 使用ReentrantLock.tryLock(long,TimeUtil)指定一个申请锁时的超时时间 没有明显缺陷 23
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,757评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,478评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,540评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,593评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,903评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,329评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,659评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,383评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,055评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,337评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,864评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,227评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,820评论 3 231
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,999评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,750评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,365评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,260评论 2 258

推荐阅读更多精彩内容

  • 本文首发于我的个人博客:尾尾部落 本文是我刷了几十篇一线互联网校招java后端开发岗位的面经后总结的多线程相关题目...
    繁著阅读 1,955评论 0 7
  • 线程池ThreadPoolExecutor corepoolsize:核心池的大小,默认情况下,在创建了线程池之后...
    irckwk1阅读 667评论 0 0
  • 第2章 java并发机制的底层实现原理 Java中所使用的并发机制依赖于JVM的实现和CPU的指令。 2.1 vo...
    kennethan阅读 1,289评论 0 2
  • layout: posttitle: 《Java并发编程的艺术》笔记categories: Javaexcerpt...
    xiaogmail阅读 5,728评论 1 19
  • 意志力,拥有了就无敌了吗?拥有不灭意志力最后取得非凡成就的那是马云;那是出身贫寒经历过痛苦磨练最后走上人生巅峰的励...
    小火柴1阅读 745评论 0 1