[Advanced Java] 5 多线程

5 多线程

多线程是Java的一种机制,目的是为了降低耦合和提高效率。

5.1 线程基础

对于单核的CPU来说,无法做到真正意义上的多线程;而对于多核CPU或者多个CPU同时运作可以达到多线程的作用。多线程通过提供CPU利用率来提高效率。数据库访问、磁盘IO等操作的速度比CPU执行代码速度慢很多,单线程环境下,这些操作会阻塞程序执行,导致CPU空转,因此对于会产生这些阻塞的程序来说,使用多线程可以避免在等待期间CPU的空转,提高CPU利用率。

补充:对于2023年来说,如今市场上普遍的CPU都是六核或者八核,同时Intel和AMD都具备超线程技术,一个核心能够做到两个线程,这使得程序员开发多线程提高效率越来越有必要。

超线程技术利用特殊的硬件指令,把两个逻辑内核模拟成两个物理芯片,让单个处理器都能使用线程级并行计算,进而兼容多线程操作系统和应用软件,减少CPU的闲置时间,提高CPU的运行效率。支持超线程的CPU能同时执行两个线程,但超线程中的两个逻辑处理器并没有独立的执行单元、整数单元、寄存器甚至缓存等资源。他们在运行过程中仍需要共用执行单元、缓存和系统总线接口。在执行多线程时两个逻辑处理器均是交替工作,如果两个线程都同时需要某一个资源时,其中一个要暂停并要让出资源,要待那些资源闲置时才能继续。因此,前面说超线程技术仅可看作是对单个处理器运算资源的优化利用。

操作系统通常提供两种机制实现多任务同时执行:多进程和多线程。进程和线程的区别在于进程拥有独立的内存空间,而线程通常与其他线程共享内存空间,共享内存空间有利于线程之间的通信、协调配合,但共享空间可能导致多个线程在读写内存时数据不一致,这是使用多线程必须面对的风险。相比较进程来说,线程是一种更轻量级的多任务实现方式,创建、销毁一个线程消耗的计算资源比运行要小得多。

多线程的应用在于:提高运算速度、缩短响应时间。

实际上,在Java中,每次程序运行至少启动2个线程。一个是main线程(通常称为主线程),一个是垃圾收集线程。因为每当使用Java命令执行一个类的时候,实际上都会启动JVM,而每一个JVM就是在操作系统中启动了一个进程。

5.1.1 创建线程

创建线程有两种方式:实现Runnable接口、继承Thread类(还存在一种实现Callable接口,配合其他属性使用)。线程是驱动任务运行的载体,在Java中,要执行的任务定义在run()方法中,线程启动将执行run()方法,方法执行完后任务就执行完成。

1. 实现Runnable接口

public class Test implements Runnable {
    @override
    public void run() {
        /*function*/
    }
    public static void main(String[] args) {
        new Thread(new Test()).start();
    }
}

2. 继承Thread类

public class Test extends Thread {
    //没有重写的注解
    public void run() {
        /*function*/
    }
    public static void main(String[] args) {
        new Test().start();
    }
}

实现Runnable接口比继承Thread类所具有的优势:

  • 适合多个相同的程序代码的线程去处理同一个资源。

  • 可以避免java中的单继承的限制。

  • 增加程序的健壮性,代码可以被多个线程共享,代码和数据独立。

  • 线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类。

所有的多线程代码都是通过运行Thread的start()方法来运行的。因此,不管是扩展Thread类还是实现Runnable接口来实现多线程,最终还是通过Thread的对象的API来控制线程的,熟悉Thread类的API是进行多线程编程的基础。

start()方法并不是单纯调用run() 方法,而是启动一个分支线程,在JVM中开辟一个新的栈空间。简单来说,start()方法只是使得该线程变为可运行态(Runnable),什么时候运行是由操作系统决定的。下面介绍线程状态会更方便了解线程的运行逻辑。

注意:因为父类的run()方法并没有抛出异常,所以在调用Thread类的一些方法时只能配合try-catch语句使用。

5.1.2 线程状态

线程分为5个状态:

  1. 新建状态(New):新创建了一个线程对象。
  2. 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。
  3. 运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。
  4. 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
    1. 等待阻塞:运行的线程执行wait()方法,JVM会把该线程放入等待池中。(wait()会释放持有的锁)
    2. 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
    3. 其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。(注意,sleep()是不会释放持有的锁)
  5. 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
5.1.3 优先级

在Java中有一个程序负责为每一个线程分配CPU时间片,负责分配时间片的程序称为调度器。调度器会根据一个基本原则给每个线程发配CPU时间片:越紧急的线程分配的时间片越多,以利于紧急的任务优先完成。但这并不意味着不紧急的线程没有机会分配到时间片,没有执行的机会,只能说紧急的任务有更高频率分配到时间片。调度器的时间分配原则与操作系统有密切的关系,不同操作系统对任务紧急程度的分级也不一样。

Java通过线程的优先级确定CPU时间片的分配频率,通过Thread类的getPriority()方法和setPriority()方法可以获取和设置线程的优先级。Java线程的优先级分为10个等级,最低级是1级,最高级是10级。Java用常量MIN_PRIORITY、NORM_PRIORITY、MAX_PRIORITY分别代表1级、5级、10级。优先级的数值本身没有什么意义,而不同线程优先级的比较才是分配CPU时间片频率的参考。

注意两点:

  1. main线程的默认优先级是:5
  2. 优先级较高的,只是抢到的CPU时间片相对多一些。大概率方向更偏向于优先级比较高的。
5.1.4 让位

Java通过yield ()方法暂停当前正在执行的线程对象,并执行其他线程。yield ()方法的执行会让当前线程 从“运行状态”回到“就绪状态”。

注意:在回到就绪状态之后,还有可能会再次分配到时间片。

5.1.5 休眠

休眠是指线程在运行的过程中暂时停止运行,线程调度器不会线程分配时间片。线程在休眠期间不占用CPU时间,在休眠结束后,线程继续运行。调用Thread类的sleep()方法可以使当前线程休眠。sleep(long millis)方法接受以毫秒为单位的休眠时间,时间结束后,线程自动唤醒。线程休眠时间的精确性与系统时钟有关系。

sleep()方法会抛出中断异常,通常需要配合try-catch语句使用。

5.1.6 加入

在很多情况下,主线程生成并起动了子线程,如果子线程里要进行大量的耗时的运算,主线程往往将于子线程之前结束,但是如果主线程处理完其他的事务后,需要用到子线程的处理结果,也就是主线程需要等待子线程执行完成之后再结束,这个时候需要使用join()方法。同时还有更好的方法,请跳转该文章5.6.2。

join()是Thread类的一个方法,启动线程后直接调用。join()的作用是:“等待该线程终止”,这里需要理解的就是该线程是指的主线程等待子线程的终止。也就是在子线程调用了join()方法后面的代码,只有等到子线程结束了才能执行。

join()方法会抛出中断异常,通常需要配合try-catch语句使用。

下面演示了join()方法的使用:

public static void main(String[] args) {
    Thread threadA = new Thread("A");
    threadA.start();
    /*function*/
    try {
        threadA.join();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    /*function*/
}

此时mian方法会等待threadA结束后才y会结束程序。程序在main线程中调用threadA的join方法,则main线程放弃CPU控制权,并返回threadA继续执行直到threadA执行完毕,所以结果是threadA执行完后,才到主线程执行,相当于在main线程中同步threadA,threadA执行完了,main线程才有执行的机会。

join()方法的底层实现原理时wait()方法,该方法将在线程协作中说明。

5.1.7 中断

线程在任务的run()方法执行完毕,或者run()方法抛出未捕获的异常时,线程将终止。由于外部的原因,其他线程可能希望终止另外一个线程的正常执行,Thread类的interrupt()方法可以用来请求终止线程。

interrupt()方法并不能直接强制线程的执行,而是设置线程对象的中断状态。每个线程都有一个boolean型的属性标志线程是否被外部中断(这个标志被称为中断状态),如果在线程对象上调用interrupt()方法,中断状态就会被设置为true,标志已经有其他线程请求该线程中断运行。从以上分析中可以看出,实际上线程中断和线程终止运行没有直接的关系,线程是否对外部的中断做出相应,或者终止线程的运行,完全由线程本身做出决定。

线程是否终止运行线程本身做出决定是合理的。每个线程都是一个完整的指令运行序列,线程必须保证所执行的任务在任何时候必须得到完整的处理,而不能随意地放弃部分或者全部任务的执行,如果这样随意对待任务的执行是非常危险的。例如,在医用X光机中用一个线程来管理X光的打开或者关闭,如果线程已经打开X光,在病人检查过程中,另外一个线程强制终止这个线程的执行,将带来危险的结果。基于以上原因,在Java早期版本中,用于强制停止和暂停线程运行的stop()、supend()方法已遭到弃用。

线程在合适的时机对外部的中断请求做出响应也是必需的。线程应该在能保证任务完整性的前提下时常检查自身的中断状态,并作出合适的响应。在线程任务定义中,经常采用以下模式的代码以便对中断做出响应:

//如果线程没有被请求中断,并且还有未完成的工作,则继续执行循环体
while (还有工作未完成) {
    if (Thread.currentThread().isInterrupt()) {
        //响应中断请求,首先决定是否终止线程,如果要终止线程,需要完成必须的结束工作
        //(例如关闭资源占用)后退出run()方法
    }
    //处理未完成的工作
}

在以上代码中,通过静态方法Thread.currentThread()可以取得当前线程,通过线程对象的isInterrupted()方法获得线程对象是否被其他线程提出中断请求。Thread类还有静态方法interrupted()可以获得当前线程的中断状态,与对象方法isInterrupted()不同的是:在调用interrupted()后,会清除线程的中断状态,即置中断状态为false,而isInterrupted()不会清除中断状态。

如果线程处于不可中断阻塞状态(获取对象锁时导致的阻塞,不可中断的I/O操作导致的阻塞),线程没执行,所以没有机会检查中断状态。

如果线程处于可中断阻塞状态(sleep()、wait()、join()等方法调用产生的阻塞状态时),另外一个线程对其提出的中断请求,线程会抛出InteruptedException异常或者ClosedByInterruptException异常,并且跳出阻塞状态,线程可以通过捕获这两个异常来对中断请求做出响应。线程如果处于不可中断阻塞状态,不会请求做出响应。

5.1.8 捕获异常

线程在运行过程中,如果抛出未处理的异常,线程本身会终止。例如,如果线程的run()方法抛出异常,运行run()方法的线程已经终止,这个线程已经没有机会来处理这个异常,那么这个异常将抛向JVM,通常JVM会直接把异常显示在控制台上。

main()方法并不会捕获到线程抛出的异常,因为主线程和子线程是完全不同的两个指令序列,虚拟机以同样的方式对待两个线程,因而主线程没责任来处理子线程抛出的异常,并且主线程也可能没有时间来处理主线程抛出的异常(主线程并不知道子线程何时抛出异常,此时主线程正在执行自己的指令序列,完成自己的工作,根本没有时间来处理异常)

可以使用Thread类的setUncaughtExceptionHandler()方法为任何线程安装一个异常处理器,如果线程抛出未处理的异常,则由这个处理器进行处理。也可以用Thread类的静态方法setUncaughtExceptionHandler()为所有线程设置一个默认的异常处理器。

下面演示了线程异常处理器的使用:

public class Test {
    public static void main(String[] args) {
        Thread exceptionThread = new Thread(new ExceptionHandlerThread());
        System.out.println("开始启动线程0");
        exceptionThread.setUncaughtExceptionHandler(new UncaughtExceptionTestHandler());
        exceptionThread.start();
        System.out.println("线程0启动完成");
        Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionDefaultTestHandler());
        Thread exceptionThread1 = new Thread(new ExceptionHandlerThread());
        System.out.println("开始启动线程1");
        exceptionThread1.start();
        System.out.println("线程1启动完成");
    }
}
class ExceptionHandlerThread implements Runnable {
    public void run() {
        System.out.println("线程已经开始执行任务");
        throw new RuntimeException();
    }
}
class UncaughtExceptionTestHandler implements Thread.UncaughtExceptionHandler {
    public void uncaughtException(Thread t, Throwable e) {
    	System.out.printf("线程[%s]抛出异常,由UncaughtExceptionTestHandler进行处理%n",t.getName());
    }
}
class UncaughtExceptionDefaultTestHandler implements Thread.UncaughtExceptionHandler {
    public void uncaughtException(Thread t, Throwable e) {
        System.out.printf("线程[%s]抛出异常,由UncaughtExceptionDefaultTestHandler进行处理%n",t.getName());
    }
}

运行结果为:

开始启动线程0
线程0启动完成
线程已经开始执行任务
开始启动线程1
线程1启动完成
线程已经开始执行任务
线程[Thread-0]抛出异常,由UncaughtExceptionTestHandler进行处理
线程[Thread-1]抛出异常,由UncaughtExceptionDefaultTestHandler进行处理

从输出可以看出,线程的异常得到了处理,虽然Java提供了线程未捕获异常的处理机制,但还是建议为了提高程序的稳定性,在线程任务中对异常进行捕获,而不是由线程异常处理器来处理异常。

5.2 线程工具

5.2.1 线程工具类

1. 执行器 Executor

Executor时一个简单的标准化接口,用于定义类似于线程的自定义子系统,包括线程池,异步IO和轻量级任务框架。根据所使用的具体Executor类的不同,可能在新创建的线程中、现有的任务执行线程中或者调用execute()的线程中执行任务,并且可能顺序或者并发执行。Executor的子接口ExecutorService提供了多个完整的异步任务执行框架。ExecutorService管理任务的排队和安排,并允许受控制的关闭。ExecutorService的子接口ScheduledExecutorService添加了对延迟的、定期任务执行的支持。ExecutorService提供了安排异步执行的方法,可执行由Callable表示的任何函数,结果类似于Runnable。Future返回函数的结果,允许确定执行是否完成,并提供取消执行的方法。

类ThreadExecutor和ScheduledThreadPoolExecutor分别实现了ExecutorService接口和ScheduledExecutorService接口,两个类提供可调的、灵活的线程池。线程池可以解决两个不同问题:由于减少了每个任务调用的开销,他们通常可以在执行大量异步任务时提供增强的性能,并且还可以提供绑定和管理资源(包括执行集合任务时使用的线程)的方法。每个ThreadPoolExecutor还维护着一些基本的统计数据,如完成的任务数。Executor类提供大多数Executor的常见类型和配置的工厂方法,以及使用他们的几种实用工具方法。其他基于Executor的实用工具包括具体类FutureTask,它提供Future的常见可扩展实现,以及ExecutorCompletionService,它有助于协调对异步任务组的处理。

2. 队列 Queue

java.util.concurrent ConcurrentLinkedQueue类提供了高效的、可伸缩的、线程安全的非阻塞FIFO队列。java.util.concurrent中的5个实现都支持扩展的BlockingQueue接口,该接口定义了put和take的阻塞版本:LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue、PriorityBlockingQueue和DelayQueue。这些不同的类覆盖了生产者-使用者、消息传递、并发任务执行和相关并发设计的大多数常见使用的上下文。

3. 计时 TimeUnit

TimeUnit类为指定和控制基于超时的操作提供了多重粒度(包括纳秒级)。该包中的大多数类除了包含不确定的等待之外,还包含基于超时的操作。在使用超时的所有情况中,超时指定了在表明已超时前该方法应该等待的最少时间。在超时发生后,实现会“尽力”检测超时。但是,在检测超时与超时之后再次实际执行线程之前可能要经过不确定的时间。

4. 同步器

4个类可协助实现常见的专用同步语句:

  • Semaphore时一个经典的并发工具。
  • CountDownLatch时一个极其简单但又极其常用的实用工具,用于保持给定数目的信号、事件或条件前阻塞执行。
  • CyclicBarrier时一个可重置的多路同步点,在某些并行编程风格中很有用。
  • Exchanger允许两个线程在集合点交换对象,用于多流水线设计。

(详见5.5 同步器)

5. 并发集合 Concurrent Collection

除队列外,此包还提供了几个用于多线程上下文中的Collection实现:ConcurrentHashMap、CopyOnWriteArrayList和CopyOnWriteArraySet。

此包中与某些类一起使用的Concurrent前缀,时一种简写,表明与类似的“同步”类有所不同,例如,java.util.Hashtable和Collections.synchronizedMap(new HashMap())是同步的,但ConcurrentHashMap则是“并发的”。并发集合是线程安全的,但是不允许单个排他锁定的管理。在ConcurrentHashMap这一特定情况下,它可以安全地允许进行任意数目的并发读取,以及数目可调的并发写入。需要通过单个锁定阻止对集合的所有访问时,“同步”类时很有用的,其代价是较差的可伸缩性。在期望多个线程访问公共集合的其他情况中,通常“并发”版本要更好一些。当集合是未共享的,或者仅保持其他锁定时集合是可访问的情况下,非同步集合则要更好一些。

大多数并发Collection实现(包括大多数Queue)与常规的java.util约定也不同,因为它们的迭代器提供了弱一致的,而不是快速失败的遍历。弱一致的迭代器是线程安全的,但是在迭代时没必要冻结集合,所以它不一定反映自迭代器创建以来的所有更新。

6. 锁 Lock

为锁定和等待条件提供一个框架的接口和类,它不同于内置同步和监视器。该框架允许更灵活地使用锁定和条件,但以更难用的语法为代价。

Lock接口支持那些语义不同(重入、公平等)的锁定规则,可以在非阻塞式结构的上下文(包括hand-over-hand和锁定重排算法)中使用这些规则。主要实现是ReentranLock。

ReadWriteLock接口以类的方式定义了一些读取者可以共享而写入者独占的锁定。此包只提供了一个实现,即ReentrantReadWriteLock,因为它适用于大部分的标准用法上下文。但程序员可以创建自己的、适用于非标准要求的实现。

Condition接口描述了可能会与锁定有关联的条件变量。这些变量在用法上与使用Object.wait访问的隐式监视器类似,但提供了更强大的功能。需要特别指出的是,单个Lock可能与多个Condition对象关联。为了避免兼容性问题,Condition方法的名称与对应的Object版本中的不同。

AbstractQueuedSynchronizer类是一个非常有用的超类,可以用来定义锁定以及依赖于排队阻塞线程的其他同步器。LockSupport类提供了更低级别的阻塞和接触阻塞支持,者对那些实现自己的定制锁定类的开发人员很有用。

5.2.2 执行器

执行器(Executor)能辅助管理Thread对象,从而简化并发程序的开发。Executor在客户程序与任务之间提供了一个间接层。这个间接层负责执行任务,因而客户程序只需定义并发执行的任务,提交给执行器,执行器负责并行地执行任务,并以合适的方式报告执行结果。

补充:Executor是一个接口,位于java.util.concurrent包下,它的主要作用是为我们提供任务与执行机制(包括线程使用和调度细节)之间的解耦。比如我们定义了一个任务,我们是通过线程池来执行该任务,还是直接创线程来执行该任务呢?通过Executor就能为任务提供不同的执行机制。执行器的实现方式各种各样,常见的包括同步执行器、一对一执行器、线程池执行器、串行执行器等等。

  • 同步执行器(DirectExecutor)——该执行器会运行放入的Runnable实例的run()方法。

    class DirectExector implements Executor {
        @Override
        public void execute(Runnable command) {
            command.run();
        }
    }
    
  • 一对一执行器(ThreadPerTaskExecutor)——该执行器会对于放入的Runnable实例创建一个新的线程并执行任务。

    class ThreadPerTaskExecutor implements Executor {
        @Override
        public void execute(Runnable command) {
            new Thread(command).start();
        }
    }
    
  • 线程执行器(ThreadPoolExecutor)——该执行器拥有线程池功能的执行器,任务提交后将由线程池负责执行。
    该执行器会不断地查找任务队列中是否含有任务。通过调用该执行器的execute()方法会将放入的Runnable实例放入任务队列中,然后执行器检测之后会调用Runnable的run()方法。

  • 串行执行器(SerialExecutor)——该执行器是一种具有串行功能的执行器,所有任务被加入到一个先进先出队列中,然后内部的另外一个执行器会按照队列的顺序执行任务。前一任务执行完后负责启动后一任务的执行,这样就形成了串行。

5.2.3 线程池

构建一个新的线程是有一定代价的,因为涉及与操作系统的交互。若程序中创建了大量的生命期很短的线程,应该使用线程池。一个线程池中包含许多准备运行的空闲线程。将Runnable对象交给线程池,就会有一个线程调用run()方法。当run()方法退出时,线程不会死亡,而是在池中准备为下一个请求提供服务。

另一个使用线程池的理由是减少并发线程的数目。创建大量线程会大大降低性能甚至使虚拟机崩溃。如果有一个会创建许多线程的算法,应该使用一个线程数“固定的”线程池以限制并发线程的总数。

执行器(Executor)类有许多静态工厂方法用来构建线程池:

方法描述
Executors.newCachedThreadPool()必要时创建新线程;空线程被保留60秒
Executors.newFixedThreadPool()该池包含固定数量的线程;空闲线程会一直被保留
Executors.newSingleThreadPool()只有一个线程的“池”,该线程顺序执行每一个提交的任务
Executors.newScheduledThreadPool()用于预定执行而构建的固定线程池,替代java.util.Timer
Executors.newSingleThreadScheduledExecutor()用于预定执行而构建的单线程“池”

下面演示了Executors.newCachedThreadPool的使用:

public static void main(String[] args) {
    ExecutorSerivce executorService = Executors.newCachedThreadPool();
    for (int i = 1; i <= 10; i++) {
        Thread thread = new Thread();
    	executorService.execute(thread);
    }
    executorService.shutdown();
}

调用ExecutorService的shutdown()方法后,ExecutorService拒绝接受新任务,而在调用shutdown()之前提交的任务会继续执行,如果所有任务执行完毕,ExecutorService会停止运行,通过isTerminated()方法可以获取任务是否执行完毕,通过isShutDown()方法可以获得执行器是否已经关闭。

shutdownNow()方法与shutdown()方法类似,但调用shudown()方法后,执行器试图停止所有提交的任务,如果任务还未开始执行,则不再执行这些任务,如果任务已经开始执行,则尝试通过Thread.interrupt()中断任务的执行,如果任何任务屏蔽或无法响应中断,则可能永远无法终止该任务。

5.2.4 返回值的任务

Runnable时执行任务的独立任务,但是它无法返回值,在实际编程中,经常需要在线程执行完成后,向主线程任务执行结果。在Java SE5中引入的Callable接口规定了一种有返回值的任务。Callable是一种具有类型参数的泛型接口,它的类型参数表示任务执行后的返回值类型。Callable有唯一的方法call(),相当于Runnable接口的run()方法,唯一不同的是方法call()有返回值,而run()没有。

可以通过ExecutorService的submit()方法向执行器提交具有返回值的任务。submit()方法提交任务后返回一个Future对象。

Future表示异步计算的结果。它提供了检查计算是否已经完成的方法,以等待计算的完成,并检索计算的结果。可以用isDone()方法检测任务是否已经执行完成,如果任务执行完成,则返回true。计算完成后只能使用get()方法来检索结果,如果有必要,计算完成前可以阻塞此方法。取消则由cancel()方法来执行,可以通过isCancelled()方法检测任务是否已经取消。执行器的shutdownNow()方法也可以导致任务取消,还提供了其他方法,以确定任务是正常完成还是被取消。一旦计算完成,就不能再取消计算。如果为了可取消性而使用Future但又不提供可用的结果免责可以声明Futrue<?>形式类型,并返回null作为基础任务的结果。

下面演示了Callable和Futrue的使用:

public class Test {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();
        ThreadTest thread = new ThreadTest();
        Future<Integer> future = executorService.submit(thread);
        while (true) {
            if (future.isDone()) {
                try {
                    int x = future.get();
                } catch (InterruptedException e) { }
                break;
            } else if (future.isCancelled()) break; 
            try {
                sleep(100);
            } catch (InterruptedException e) { }
        }
    }
}
class ThreadTest implements Callable<Integer> {
    public Integer call() throw Exception {
        sleep(1000);
        return 1;
    }
}

不局限于单个线程使用,可以配合多个线程使用,同时可以再自定义一个Thread子类的Monitor类,配合 ConcurrentHashMap<Future<int>, int>用来检测输出结果。总之,熟练使用这些工具类可以更简便地设计多线程。

5.3 线程共享资源

在实际应用中,多个线程常常希望共享数据。例如,在模拟股票交易的程序中,一个线程可能要减少一个账户的股票金额,而另外一个线程可能要增加这个账户的股票金额,两个线程就要共享账户的股票金额。

线程在CPU上执行是分时间片进行的,线程调试器可能随时中断一个线程的指令执行,而去执行另外一个线程的指令,并且什么时候会被中断是不可能预测的,这种分时执行可能产生意想不到的结果。

5.3.1 竞争条件

当线程需要修改一个变量的时候,它们会先复制得到的数据,然后通过加减,将自己得到的数据替换成原来的数据。对于股票的例子,一个线程需要减少金额,一个需要增加金额,那么理想状态下是这样的:

  1. “减少线程”运行。
  2. 得到当前金额;通过减法后得到减少金额;将当前金额修改为减少金额。
  3. “减少线程”结束。
  4. “增加线程”运行。
  5. 得到当前金额;通过加法后得到增加金额;将当前金额修改为增加金额。
  6. “增加线程”结束。

但因为线程的不确定性,很有可能两个线程所执行的操作顺序是如下情况:

  1. “减少线程”运行;
  2. 得到当前金额;通过减法后得到减少金额;
  3. “增加线程”运行;
  4. 得到当前金额;通过加法后得到增加金额;
  5. 将当前金额修改为减少金额。
  6. “减少线程”结束。
  7. 将当前金额修改为增加金额。
  8. “增加线程”结束。

这明显是错误的,增加金额覆盖了减少金额的结果。多个线程在执行过程中相互干扰的现象就称为竞争条件(race condition)。

真正在虚拟机上执行的是编译后的虚拟机指令,虚拟机指令比Java代码的粒度更小,这种可以随意打断产生的结果可能更为混乱。

如果多个线程在执行过程中不相互共享数据,竞争条件就不会产生;如果在读写共享数据时不会被任意打断后插入其他线程对共享数据读写的代码,也不会产生竞争条件。由于功能的需要,不共享数据是不可能的,唯一解决的办法就是调度器保证在读写共享数据时不被任意打断后插入其他线程对共享数据读写的代码,Java用称为锁的机制来保护这一点。

5.3.2 Lock对象

多个线程对共享资源的竞争读写可能导致程序执行的混乱,产生错误的运行结果。Java通过对共享资源加锁的机制来解决对共享资源的竞争读写。加锁机制基本目标是保证在同一个时间内,只有一个线程对共享数据进行读写,如果有两个线程同时试图访问共享数据,就要排队,让队列线程中的数据依次读写共享数据。

以多人公用电话亭的例子说明锁的机制,如果有多个人想使用电话亭,为了避免使用冲突,为电话亭加上一把只能从里面开关的锁,进入电话亭的 人从里面把锁锁上,然后打电话,这时外面的人只能排队,打完电话后,里面的人打开锁出来,下一个等待的人进入电话亭,这样就可以避免两个人共占一个电话亭产生冲突。

Java提供了两种方法为共享资源加锁:一种是用synchronized关键字,另一个种是用Lock对象,两种方法的实现原理是一样的,使用Lock对象加锁更加灵活一些。

ReentrantLock类

Java的concurrent框架为共享资源加锁提供了Lock接口,类ReentrantLock实现Lock接口,是常用的锁对象。ReentranLock类通常使用的方式如下:

Lock lock = new ReentrantLock();
try {
	lock.lock();
	/*function*/
} finally {
	lock.unlock();
}

在对共享数据读写之前,通过ReentrantLock类的lock()方法获取锁(实际上应该是获取对锁的控制,或者说是获取钥匙)。在同一个时间锁只能被一个线程获取。若该锁没有被另一个线程保持,则获取该锁并立刻返回。如果该锁被另一个线程保持,则出于线程调度的问目的,阻塞当前线程,并且在获得锁之前,该线程将一直出于休眠状态。保持计数是指拥有锁的线程共几次调用了lock()方法,每调用一次保持计数加1。

如果线程对共享资源访问完毕,通过ReentrantLock类的unlock()方法试图释放对锁的拥有,为其他线程获取锁提供机会。

注意:获取锁后的代码应该采用try-finally进行保护,如果代码出现异常,线程就没有机会释放锁,导致其他线程永远没有机会访问共享资源。

ReentrantLock类被称为可重入锁,即同一个线程得到对锁的控制后,还可以继续调用这个锁的lock()方法试图获取锁,此时锁使用保持计数来记录线程有多少次获取了锁,即如果线程第一次通过调用lock()获得锁,则锁的保持计数置为1,这个线程以后每调用一次lock()方法,则保持计数加1,这个线程每调用一次unlock()方法,则保持计数减1,如果计数减到0,则线程才真正释放对锁的拥有。可以通过getHoldCount()方法查询锁的保持计数。

下面演示了锁的重入:

class Test {
    private final ReentrantLock lock = new ReentrantLock();
    public void f() {
        lock.lock();
        try {
            ref();
        } finally {
            lock.unlock();
        }
    }
    public void ref() {
        lock.lock();
        try {
            /*function*/
        } finally {
            lock.unlock();
        }
    }
}

在上面的代码中,f()方法中调用ref()方法,ref()方法也获取了对锁的控制。递归调用是另外一个需要重复获取锁的典型例子。

需要说明的是:锁能够有效地解决对共享资源竞争访问的问题,但并不是没有代价的。仔细分析可以发现,锁实际上使得线程只能顺序地调用含锁的方法,无论多少个线程并行请求锁,但在具体执行时,同一个时刻只有一个线程的读写得到处理,这对程序运行效率多线程的优势完全不能发挥。因此,使用锁的时候必须要慎重,不要滥用。在程序设计时,应该把对共享数据访问的代码尽量集中,对集中后的代码采用锁来保护。

5.3.3 锁测试与超时

线程在调用Lock对象的lock()方法获取另一个线程所持有的锁时,当前线程将会阻塞,并且其他线程不能中断阻塞。但一些使用场景并不是必须要获取锁,如果发现锁已经被其他线程占用则做其他处理,Concurrent框架对此提供了lockInterruptibly()方法、tryLock()方法、tryLock(long time, TimeUnit unit)方法。

tryLock()方法仅在调用时锁为空闲状态才获取该锁。如果锁可用,则获取锁,并立即返回true。如果锁不可用,则立即返回值false。此方法典型使用语句如下:

Lock lock = new ReentrantLock();
if (lock.tryLock()) {
    try {
        /*deal data function*/
    } finally {
        lock.unlock();
    }
} else {
    /*else function*/
}

此用法可确保如果获取了锁,则会释放锁;如果未获取锁,则不会试图将其释放。

trylock(long time, TimeUnit unit):如果所在给定等待时间内没有被另一个线程保持,且当前线程未被中断,则获取该锁,并立即返回true。如果超出了指定的等待时间,则返回值未false。如果该时间小于等于0,则此方法根本不会等待。

如果锁被另一个线程保持,则出于线程调度目的,阻塞当前线程,并且在发生一下三种情况之一以前,该线程将一直处于休眠状态:

  1. 锁由当前线程获得。
  2. 其他某个线程中断当前线程。
  3. 已超过指定的等待时间。

如果当前线程在进入此方法时已经设置了该线程的中断状态(isInterrupt()返回true),或者在等待获取锁的同时被中断,则抛出InterruptedException,并且清除当前线程的已中断状态。

lockInterruptibly():如果该锁没有被另一个线程保持,则获取该锁并立即返回。如果锁被另一个线程保持,则处于线程调度目的,阻塞当前线程,并且在发生一下两种情况之一以前,该线程将一直处于休眠状态:

  1. 锁由当前线程获得。
  2. 其他某个线程中断当前线程。

如果当前线程在进入此方法时已经设置了该线程的中断状态(isInterrupt()返回true),或者在等待获取锁的同时被中断,则抛出InterruptedException,并且清除当前线程的已中断状态。与此方法相反,lock()方法是不可以中断的。

5.3.4 synchronized关键字

前面使用Lock对象来保护对共享数据的访问,Java还提供了一种嵌入到语言内部的机制。从Java SE1.0版开始,Java中的每一个对象都有一个内部锁,使用这个内部锁能起到Lock对象类似的保护目标。synchronized是语言本身的一个关键字,通过使用synchronized关键字就能使用到对象的内部锁。

synchronized可以使用到一个方法的定义上,也可以作为语句使用到一个代码块上面。synchronized使用到方法上的方式如下:

public synchronized void f() {
    /*function*/
}

上面的f()方法就受到锁的保护,也可使用到代码块上,例如:

public void f() {
	synchronized (this) {
        /*function*/
    }
}

synchronized(this)对所包含的代码块进行保护,上面两种方式在功能上基本一样,都能保证所保护的代码一次只能被一个线程执行。

上面的代码从表面上并没有使用到锁,但实际上都是用到了内部锁,Java设计设希望锁对程序员来说是透明的,可以简化程序的设计。上面的代码相当于下面显式使用锁的代码:

public void f() {
    this.lock();
    try {
        /*function*/
    } finally {
        this.unlock();
    }
}

由于在Java中,任何对象都有内部锁,因此this.lock()是可能实现的,但获取锁是Java内部实现的。如果synchronized使用在方法上,则在执行到方法的代码时就要去获取锁,在退出方法时释放锁。如果使用在代码块上,则在进入到代码时就要去获取锁,在退出代码块时释放锁。

synchronized作为语句使用不仅能获取this对象的锁,还能获取任意对象的锁,例如:

public final Object locker = new Object();
public void f() {
    synchronized (locker) {
        /*function*/
    }
}

上面的代码就是使用locker对象的锁,相当于使用下面的显式锁:

public final Object locker = nw Object();
public void f() {
    locker.lock();
    try {
        /*function*/
    } finally {
        locker.unlock();
    }
}
5.3.5 原子性

原子性是指一个操作的一系列子操作不会被打断,这个操作一旦开始执行,要不完全成功,要不完全失败。这里的操作是一个抽象上的概念,是指程序执行过程中有特定功能、相对独立、有特定名称的执行单元。可以是一个服务、操作、Java的代码指令、JVM指令、汇编指令、CPU指令、CPU操作等,一个操作通常可以在更微观的层次上分为更小的操作,例如:

public int increase() {
    return i++;
}

其中,i++看起来应该是单一的操作,但如果从JVM指令度来看,这个操作被分为下面几条JVM指令(可以从Java自带的javap.exe程序查看class文件的指令):

public int increase();
 Code:
	0: aload_0
    1: dup
	2: getfield		#12; //Field i:I
	5: dup_x1
	6: iconst_1
	7: iadd
	8: putfild		#12; //Field i:I
	11: ireturn

实际上,JVM指令在CPU上执行也会被编译成一系列CPU指令,CPU指令也能继续往下分。例如,CPU指令集中常见的一个指令比较交换指令CAS,它完成两个操作,一个比较,一个交换,后一个完不完成依赖于前一个操作的结果,从逻辑上来说,它们是两个操作。

既然由一系列的子操作组成,那么就有肯呢个再完成到子操作的某一步时被打断,例如,被进程调度打断,被线程调度打断,甚至可能被硬件中断请求打断,如果子操作能够被打断,并且子操作又使用了公共资源,就可能造成执行的混乱,因此为了让一个操作有预期地执行结果,就要求有某一种机制保证,在特定的操作执行过程中是不允许被打断的,即保证操作的原子性。

锁机制是保证原子性的方法之一,在操作的子序列执行之前加锁,在操作完成后解除锁。例如,在方法上加上synchronized可以保证操作的原子性,例如:

public synchronized int increase() {
    return i++;
}

方法increase()就变成了一个原子操作,可以保证前面的多条指令不会被打断。

锁虽然能保证操作的原子性,但是加锁的代码运行效率较差,这是需要注意的问题。特别在一些底层代码中使用锁,例如前面的i++,甚至简单的赋值,例如:

long i = 1, j;
j = i;

其中j=i就不是原子性的,因为long是64位数,如果在32位的机器上,这个赋值必须用两条指令才能完成。如果这样简单的赋值上就使用锁,代价实在是太大了。如果不使用锁能够保证操作的原子性,是非常好的主意,但这是比较困难的一件事,除非对JVM有深入的研究。在Java的concurrent框架中提供了许多包装类用于原子的整数、浮点、数组等运算,在编写多线程程序时可以使用这些类。下面时原子类的简介:

  • AtomicBoolean:可以用原子方式更新的boolean值。

  • AtomicInteger:可以用原子方式更新的int值。

  • AtomicIntegerArray:可以用原子方式更新其元素的int数组。

  • AtomicIntegerFieldUpdater<T>:基于反射的实用工具,可以对其指定类的指定volatile int字段进行原子更新。

  • AtomicLong:可以用原子方式更新其元素的long值。

  • AtomicLongArray:可以用原子方式更新其元素的long数组。

  • AtomicLongFieldUpdater<T>:基于反射的实用工具,可以对指定类的指定volatile long字段进行原子更新。

  • AtomicMarkableReference<T>:AtomicMarkableReference维护带有标记位的对象引用,可以原子方式对其进行更新。

  • AtomicReference<T>:可以用原子方式更新的对象引用。

  • AtomicReferenceArray<T>:可以用原子方式更新其元素的对象引用数组。

  • AtomicReferenceFieldUpdater<T, V>:基于反射的实用工具,可以对指定类的指定volatile字段进行原子更新。

  • AtomicStampedReference<T>:AtomicStampedReference维护带有整数“标志”的对象引用,可以用原子方式对其进行更新。

5.3.6 线程的局部变量

多个线程同时读写共享资源将产生冲突,导致程序运行错误,如果每个线程都有独立的变量副本(独立的存储空间),每个线程对不同的副本进行读写,自然不会产生冲突,Java提供的线程局部(thread-local)变量就是这样的变量。

线程局部(thread-local)变量是一种自动化机制,可以使用相同的变量名为不同的线程创建不同的变量存储,即对象(或者类)相同的变量名,在不同的线程中访问,返回的值是不一样的。

ThreadLocal<T>类提供了线程局部(thread-local)变量。这些变量不同于他们的普通对应物,因为访问某个变量(通过其get和set方法)的每个线程都有自己的局部变量,它独立于变量的初始化副本。ThreadLocal实例通常是类中的private static字段,它们希望将状态(例如,用户的ID或事务ID)与某一个线程相关联,而不是在线程执行过程中的相对象中通过参数传递。

下面演示了ThreadLocal的使用:

public class Test extends Thread {
    private String name = null;
    public Test(String name) {
        this.name = name;
    }
    public void run() {
        PersonManager.threadName.set(this.name);
        /*function*/
        String personName = PersonManager.threadName.get();
    }
}
public static class PersonManager {
    private static ThreadLocal<String> threadName = new ThreadLocal<String>();
}

从线程的角度看,每个线程都保持一个对其线程局部变量副本的隐式引用,只要线程是活动的并且 ThreadLocal 实例是可访问的;在线程消失之后,其线程局部实例的所有副本都会被垃圾回收(除非存在对这些副本的其他引用)。

通过ThreadLocal存取的数据,总是与当前线程相关,也就是说,JVM 为每个运行的线程,绑定了私有的本地实例存取空间,从而为多线程环境常出现的并发访问问题提供了一种隔离机制。

在ThreadLocal类中有一个Map,用于存储每一个线程的变量的副本。以实现为每一个线程维护变量的副本。

概括起来说,对于多线程资源共享的问题,同步机制采用了“以时间换空间”的方式,而ThreadLocal采用了“以空间换时间”的方式。前者仅提供一份变量,让不同的线程排队访问,而后者为每一个线程都提供了一份变量,因此可以同时访问而互不影响。

5.4 线程协作

前面讲述了多个线程共享资源的问题,锁机制保证了顺序读写共享资源,在实际工作中,经常要求线程之间有更紧密的协作。例如,一类线程负责接受任务,另外一类线程负责处理任务,那么接受任务的线程在接收到新任务时要通知任务处理线程处理新任务,或者任务处理线程处理完任务后要通知任务接收器分配新任务。

Java提供了两套用于线程之间协作的机制:一个是使用Object类的wait()方法和notify()方法,另一个时使用Condition类的await()方法和signal()方法。

5.4.1 wait与notify

wait()方法可以使一个线程任务等待条件发生变化,而这个条件只能由其他线程任务来改变。例如,任务处理线程(handler)要等待任务接受线程(receiver)分配新的任务,那么任务处理线程可以采用以下代码实现等待:

//class Receiver
public void addTask(Task task) {
    synchronized () {
        this.getTasks().add(task);
    }
}
public List getTasks() {
    return this.tasks;
}
//class Handler
public void run() {
    synchronized (receiver.getTask()) {
        while(receiver.getTask().size() == 0) {};
        Task task = receiver.getTasks().get(0);
        receiver.getTasks().remove(0);
    }
} 

如果receiver中没有新任务,handler一直空循环等待,称为忙等待。忙等待虽然没做实质的事情,单线程调度器仍会给handler分配执行时间片,显示这是一种对CPU计算能力的浪费。上面代码的问题不仅如此,更糟糕的是synchronized块锁定了receiver.getTasks(),那么其他线程无法获得锁,包括receiver,即使receiver接收到新的任务,也无法添加任务列中,这样就形成了无法解决的矛盾:handler要接受到新的任务才能释放对任务列表的锁定,而receiver要获得任务列表的锁后才能向列表中添加新的任务,就会造成无休止的等待。

wait()方法可以解决这个问题,如果调用wait()方法,就会导致本线程等待,并且释放本线程拥有的锁,给其他线程提供改变条件的机会。当其他线程修改了条件后,就调用notify()(或者notifyall()通知等待的线程退出等待。如上面代码可以修改为:

//class Receiver
public void addTask(Task task) {
    synchronized (this.tasks) {
        this.getTasks().add(task);
        this.getTasks().notifyall();
    }
}
public List getTasks() {}
//class Handler
public void run() {
    synchronized (receiver.getTasks()) {
        if (receiver.getTasks().size == 0) {
            receiver.getTasks().wait();
        }
        Task task = receiver.getTasks().get(0);
        receiver.getTasks().remove(0);
    }
}

在handler类中,如果任务列表中没有任何任务就本线程等待,并且释放拥有的对receiver.getTasks()的锁,那么弱国receiver接收到新的任务,就锁定this.tasks,并添加新任务到任务列表中(注意:代码中的this.tasks,receiver.getTasks()是对同一个对象的引用),并且通知所有因为在对象tasks调用wait()方法而等待的线程试图退出等待,之所以是“试图退出等待”是因为等待的线程是否真正立刻退出等待要看是否能获取在tasks上的锁,如果获得锁就可以立刻退出,其他线程要等获得锁的线程释放后,获得锁后再推出等待,这样等待的线程顺序退出等待的状态,即等待的线程是否能退出等待需要两个条件:是否得到通知,是否获得锁。如果多个线程在等待,具体哪个线程最先获得锁,是由线程调度器决定的。

receiver类中的通知方法this.getTasks().notifyall()只是告诉任务有变化,但并不能保证一定能获得任务(这种现象被称为虚假唤醒(spurious wakeup)),因此handler类中等待的代码应该修改为下面的形式:

//class handler
public void run() {
    synchronized (receiver.getTasks()) {
        try {
            while (receiver.getTasks().size() == 0) {
            	receiver.getTasks().wait();
        } catch (InterruptedException e) {}
        Task task = receiver.getTasks().get(0);
        receiver.getTasks().remove(0);
    }
}

if改为while后,就意味着真正获得锁后,并且还有待分配的任务时,才真正能取得任务。

关于wait()和notifyall()还有如下问题需要说明:

  1. wait()方法还有形如void wait(long timeout)的重载形式,接受一个时间参数,表示等待的最长时间,如果超过时间,线程退出等待。
  2. 和notifyall()功能类似的还有notify()方法,notify()只通知一个在等待的线程,而不是所有等待的线程,具体通知哪一个线程,有线程调度器根据相关的策略选择。要谨慎使用notify(),如果得到通知的线程不能处理很好地响应通知,而其他线程有没有机会得到通知,很可能造成等待的线程永远等待下去。如果正确使用notify(),其效率要比notifyall()高。
  3. 调用wait(),notify(),notifyall()的线程必须拥有锁,即这些方法必须在同步方法(synchronized)或者同步代码块中调用,否则在运行时将抛出IllegalMonitorStateException异常。
  4. sleep()方法、wait()方法都有使当前线程等待的功能,但sleep()方法与锁没关系,即sleep()不必再同步方法(synchronized)或者同步代码块中调用;sleep()也不存在释放锁的问题。
  5. 如果线程在调用wait()方法后处于等待状态时,线程被中断(其他线程调用本线程的interrupt()方法),将抛出InterruptedException异常,并且清除当前线程的中断状态(即isInterrupted()返回false)。
5.4.2 Condition对象

在concurrent框架中提供了显示的、更加灵活的工具类来实现线程间的协作。使用互斥并允许等待的基本类时Condition,可以通过在Condition上调用await()方法来实现线程等待。当外部条件发生变化,意味着某个任务可以继续执行时,可以调用Condition的signalAll()方法,或者signal()方法来通知等待的线程,从而结束等待线程的等待。

与wait()方法不同的是,可以在锁对象上创建多个Condition对象,每个Condition对象代表一中不同的等待类型。

Condition是个接口,基本的方法就是await()和signal()方法。

Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition()。

调用Condition的await()和signal()方法,都必须在lock保护之内,就是说必须在lock.lock()和lock.unlock之间才可以使用

  • Conditon中的await()对应Object的wait()。

  • Condition中的signal()对应Object的notify()。

  • Condition中的signalAll()对应Object的notifyAll()。

笔者尝试了很多方法,发现Condition只能配合锁对象进行使用,而无法配合Object对象使用(无法获取Object的锁对象)。简单来说,Java语言设计师创建Condition对象的目的是为了配合Lock对象独家使用,synchronized没有任何可以配合的方法。因为设计Lock对象的目的就是为了更加灵活地使用锁机制,而Condition对象将灵活的锁机制发展为灵活的协作机制。相比较Object的等待和唤醒,Condition能够提供能加丰富的唤醒角度。

下面演示了Condition类的使用:

public class Test {
    public static void main(String[] args) {
        Receiver receiver = new Receiver();
        new Handler(receiver).start();
        new Handler(receiver).start();
        receiver.f();
    }
}
class Receiver {
    final Lock lock = new ReentrantLock();;
    final Condition = lock.newCondition();
   
    public void f() {
        lock.lock();
        try {
            /*function*/
        } finally {
            lock.unlock();
        }
    }
}
class Handler extends Thread {
    final Receiver receiver;
    final Lock lock;
    final Condition condition;
    public Handler(Receiver receiver) {
        this.receiver = receiver;
        this.lock = receiver.lock;
        this.condition = receiver.condition;
    }
    
    @Override
    public void run() {
        lock.lock();
        try {
            condition.await();
            /*function*/
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

Condition类的等待方法除了await()方法,还有以下等待方法:

  1. boolean await(long time, TimeUnit unit):当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。
  2. long awaitNanos(long nanosTimeout):当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。
  3. void awaitUninterruptibly():当前线程在接到信号之前一直处于等待状态。
  4. boolean awaitUntil(Date deadline):当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态。
5.4.3 死锁

线程在进入同步(synchronized)方法或者同步代码块时,就需要获得锁,如果此时锁已经被其他线程拥有,那么这个线程就会处于阻塞状态,直到其他线程释放锁,阻塞的这个线程获得锁,阻塞可能出现一种极端的情况:某个线程任务的完成需要等待另一个线程的任务完成,而后者又在等待别的任务,形成一个等待链,如果等待链的最后一个任务等待的正是等待链的第一个任务,等待链最终形成等待环,在等待环中线程都不能继续,这种无休止的等待就是死锁。

例如,你购买匡威的鞋子需要穿匡威的鞋子才能进匡威的店;高度近视的人,眼镜丢了,要先找到眼镜,才能看清东西,才能找到眼镜;翻墙软件需要注册账号,但注册账号的网站需要翻墙。这都是死锁的现实例子。

根据上面的例子,可以总结出线程死锁必须同时满足以下4个条件:

  1. 互斥条件,即资源是不能被共享的。例如注册账号和翻墙是互斥的,两个事情不能同时达到。
  2. 至少有一个线程在使用一个资源却在等待另一个线程所持有的一个资源。例如需要买匡威才能穿匡威,需要穿匡威才能买匡威。
  3. 资源不能被线程抢占。例如,不能在找到眼镜之前就突然看清东西;不能直接不翻墙就注册账号。
  4. 必须有循环的等待,永远等待。例如,永远翻不了墙。

如果死锁要发生,必须同时满足以上四个条件;所以要防止死锁,只需要破外其中一个条件就可以了。例如,可以线上购买匡威;让其他人帮忙找到眼镜。在程序中,最容易的办法是破坏第4个条件,将某一个线程的等待设置为时间等待,超出时间就停止运行

5.5 同步器

4个类可协助实现常见的专用同步语句:

  • Semaphore时一个经典的并发工具。
  • CountDownLatch时一个极其简单但又极其常用的实用工具,用于保持给定数目的信号、事件或条件前阻塞执行。
  • CyclicBarrier时一个可重置的多路同步点,在某些并行编程风格中很有用。
  • Exchanger允许两个线程在集合点交换对象,用于多流水线设计。
5.5.1 Semaphore

Semaphore的意思是信号量。

Semaphore的原理

Semaphore内部维护了一个计数器,其值为可以访问的共享资源的个数。一个线程要访问共享资源,先获得信号量,如果信号量的计数器值大于1,意味着有共享资源可以访问,则使其计数器值减去1,再访问共享资源。如果计数器值为0,线程进入休眠。当某个线程使用完共享资源后,释放信号量,并将信号量内部的计数器加1,之前进入休眠的线程将被唤醒并再次试图获得信号量。

Semaphore的使用场景

  1. 多个共享资源互斥使用。
  2. 并发线程的控制。

Semaphore的使用

Semaphore通常维护一个集合。当集合存在空位时,唤醒等待线程并允许线程访问;当集合不存在空位时,使请求的线程进入休眠。简单来讲,Semaphore是wait()和notifyall()的一种衍生使用。

例如,管理员对有限个物品进行管理,当用户想要使用物品时,需要对管理员发出请求。这时候Semaphore充当的是真假指示器,代表请求是否可用。若可用,管理员通过用户的需求做出回应;若不可用,用户则排队等待。

以停车场管理员为例,下面演示了Semaphore的使用:

public class Manager {
    private static final int MAX_AVAILABLE = 5;
    private final Semaphore available = new Semaphore(MAX_AVAILABLE, true);
    private final boolean[] hasCar = new boolean[MAX_AVAILABLE];
    private final Car[] cars = new Car[MAX_AVAILABLE];

    boolean park(Car car) throws InterruptedException {
        available.acquire();
        int index = findAvailable();
        if (index != -1) {
            cars[index] = car;
            return true;
        } else return false;
    }

    boolean out(Car car) {
        int index = isParking(car);
        if (index != -1) {
            cars[index] = null;
            available.release();
            return true;
        } else return false;
    }

    private synchronized int findAvailable() {
        for (int i = 0; i < MAX_AVAILABLE; i++) {
            if (!hasCar[i]) {
                hasCar[i] = true;
                return i;
            }
        }
        return -1;
    }

    private synchronized int isParking(Car car) {
        for (int i = 0; i < MAX_AVAILABLE; i++) {
            if (hasCar[i] && cars[i] == car) {
                hasCar[i] = false;
                return i;
            }
        }
        return -1;
    }

}
class User extends Thread {
    private Manager manager;
    private final Car car = new Car();
    private boolean isPack = false;

    public void park() {
        try {
            if(manager.park(car)) isPack = true;
        } catch (InterruptedException ignored) { }
    }

    public void out() {
        if(manager.out(car)) isPack = false;
    }
    
    public void run() {
        while (!isPack) park();
        try {
            sleep(1000);
        } catch (InterruptedException ignored) { }
        out();
    }
}
class Car {
}

对于使用Semaphore强调几个特殊的点:

  1. 需要通过锁来控制boolean数组。
  2. 功能分离,查找位子和操作车分离,保证锁的使用效率。
  3. acquire()方法会抛出InterruptiontedException异常,特殊情况需要对异常进行处理。

需要说明

将信号量初始化为1,使得它在使用时最多只有一个可用的状态,从而可用做一个相互排斥的锁。这通常也成为二进制信号量,因为它只能有两种状态:一个可用的许可,或零个可用的许可。按此方法使用时,二进制信号量具有某种属性(与很多Lock实现不同),即可由线程释放“锁”,而不是由所有者(因为信号没有权的概念)。在某些专门的上下文(如死锁恢复)中这会很有用。

此类的构造方法可选地接受一个公平参数。当设置为false时,此类不对线程获取许可的顺序做保证。特别地,闯入时允许的,也就是说可以在已经等待线程队列前为调用acquire()的线程分配一个许可。从逻辑上来说,就是新线程将自己置于等待线程队列的头部。当公平设置为true时,信号量保证对任何调用获取方法的线程而言,都按照处理它们调用这些方法的顺序(即先进先出)来选择线程、获取许可。

通常,应该将用于控制资源访问的信号量初始化为公平的,以确保所有线程都可以访问资源。为其他的种类的同步控制使用信号量时,非公平排序的吞吐量优势通常要比公平考虑更为重要。

此类还提供便捷的方法来同时acquire和释放多个许可。注意:在未将公平设置为true时使用这些方法会增加不确定的延期风险。

5.5.2 CountDownLatch

CountDownLatch的意思是信号量。它是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。

CountDownLatch的方法

  • CountDownLatch(int count):count为需要等待的线程数量。
  • await():调用此方法的线程会被阻塞,直到CountDownLatch的count为0。
  • await(long timeout, TimeUnit unit):与上面方法相同,配置了时间等待,返回值为boolean类型。
  • countDown():会将count减1,直至为0。

用给定的计数初始化CountDownLatch。在计数到达零之前,调用await()方法的线程会一直受阻塞。之后,CountDownLatch会释放所有等待的线程,后续调用await()的线程都将立即返回。这种现象只出现一次——计数无法恢复。如果需要重置计数,需要使用CyclicBarrier。

CountDownLatch的使用

CountDownLatch是一个通用同步工具,它有很多用途。将计数1初始化的CountDownLatch用作一个简单的开/关锁存器,或入口:在通过调用countDown()的线程打开入口前,所有调用await()的线程都一直在入口处等待。用N初始化的CountDownLatch可以使一个线程在N个线程完成某项操作之前一直等待,或者使其在某项操作完成N次前一直等待。

CountDownLatch的一个有用特性是,它不要求调用countDown()方法的线程等到计时达到零时才继续,而在计数达到零之前,它只是阻塞任何线程继续通过一个await()方法。

CountDownLatch与线程的join()方法很相似,但CountDownLatch更加灵活。

一种典型的用法是:创建两个CountDownLatch,第一个作用是代表启动信号;第二个作用是代表完成信号。

  • 启动信号:在所有工作线程完成准备工作前,阻止所有工作线程的执行直至某线程准备完成。
  • 完成信号:在所有工作线程完成所有工作后,开启某项线程运行。

用Worker代表工作线程,用Driver表示某一特殊线程。下面演示了CountDownLatch的第一种使用方法:

public class Worker implements Runnable {
    private final CountDownLatch workSignal;
    private final CountDownLatch driveSignal;

    public Worker(CountDownLatch workSignal, CountDownLatch driveSignal) {
        this.workSignal = workSignal;
        this.driveSignal = driveSignal;
    }

    @Override
    public void run() {
        try {
            workSignal.await();
            /*function*/
            driveSignal.countDown();
        } catch (InterruptedException ignored) { }
    }
}

public class Driver implements Runnable {
    private final CountDownLatch workSignal;
    private final CountDownLatch driveSignal;
    
    public Driver(CountDownLatch workSignal, CountDownLatch driveSignal) {
        this.workSignal = workSignal;
        this.driveSignal = driveSignal;
    }

    @Override
    public void run() {
        try {
            /*function*/
            workSignal.countDown();
            driveSignal.await();
            /*function*/
        } catch (InterruptedException ignored) { }
    }
}

另一种典型用法是:将一个问题分成N个部分,用执行每个部分并让锁存器倒计数的Runnable来描述每个部分,然后将所有Runnable加入到Executor队列。当所有的子部分完成后,协调线程就能够通过await。(当线程必须用到这种方法反复倒计数时,可以改为使用CyclicBarrier。)

下面演示了CountDownLatch的第二种使用方法:

public class Test {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch signal = new CountDownLatch(5);
        Executor executor = Executors.newFixedThreadPool(5);
        for (int i = 1; i <= 5; i++) {
            executor.execute(new Worker(signal));
        }
        signal.await();
        /*function*/
    }
}
class Worker implements Runnable{
    private final CountDownLatch signal;

    public Worker(CountDownLatch signal) {
        this.signal = signal;
    }

    @Override
    public void run() {
        /*function*/
        signal.countDown();
        /*function*/
    }
}
5.5.3 CyclicBarrier

CyclicBarrier的意思是阻栅。它是一个同步辅助类,允许一组线程互相等待,直到到达某个公共屏障点(common barrier point)。在涉及一组固定大小的线程的程序中,这些线程必须不时地相互等待,此时CyclicBarrier很有用,因为该barrier在释放等待线程后可以重用,所以称它为循环的barrier。

CyclicBarrier支持一个可选的Runnable命令,在一组线程中的最后一个线程到达之后(但在释放所有线程之前),该命令只在每个屏障点运行一次。若在继续所有参与线程之前更新共享状态,此屏障操作很有用。

CyclicBarrier的使用

例如,几个旅行团需要途径哈尔滨,深圳,郑州,最后到达广州。旅行团中有自驾游的,有徒步的,有乘坐旅游大巴的;这些旅行团同时出发,并且每到一个目的地,都要等待其他旅行团道道此地后再同时出发,直到都到达终点站广州。

简而言之,每个旅行团每到达一个地方就调用await()方法,当所有旅行团到达后就再开始下一段旅行。

下面演示了CyclicBarrier的使用:

public class Test implements Runnable{
    private final CyclicBarrier barrier;
    private final int time;

    public Test(CyclicBarrier barrier, int time) {
        this.barrier = barrier;
        this.time = time;
    }

    @Override
    public void run() {
        try {
            for (int i = 1; i <= time; i++) {
                /*function*/
                barrier.await();
            }
        } catch (InterruptedException | BrokenBarrierException ignored) { }
    }
}

如果屏障操作再执行时不依赖于正挂起的线程,则线程组中任何线程在获得释放时都能执行该操作。为方便此操作,每次调用await()都将返回能到达屏障处的线程的索引。然后可以选择哪个线程应该执行屏障操作,例如:

if (barrier.await() == 0) {
    /*function*/
}

对于失败的同步尝试,CyclicBarrier使用了一种要么全部全么全不(all-or-none)的破坏模式:如果因为中断、失败或者超时等原因,导致线程过早地离开了屏障点,那么在该屏障点等待的所有其他线程也将通过BrokenBarrierException(如果它们几乎同时被中断,则用InterruptiontedException)以反常的方式离开。

5.5.4 Exchanger

Exchanger的意思是交换器。它可以用于两个线程进行数据交换,线程将要交换的数据提交给交换器的exchange()方法,交换器负责匹配伙伴线程,并且在exchange方法返回时接收其伙伴的返回数据。

Exchanger可能被视为SynchronousQueue的双向形式。Exchanger可能在应用程序(例如遗传算法和管道设计)中很有用。

Exchange的使用

下面演示了Exchange的使用:

public class Test implements Runnable{
    private final Exchanger<Objects> exchanger;

    public Test(Exchanger<Objects> exchanger) {
        this.exchanger = exchanger;
    }

    private Objects f() {
        return null; 
    }
    
    @Override
    public void run() {
        try {
            Objects o = f();
            o = exchanger.exchange(o);
            /*function*/
        } catch (InterruptedException ignored) { }
    }
}

5.6 补充

5.6.1 进程关闭后的线程

我们在运行Java代码中的main()方法时,就是开启了一个进程。Java执行时若依照代码逻辑开启新线程,即使新线程处于死循环状态,会持续运行,但仍然会因为你停止了进程而关闭。

5.6.2 线程的守护性

Java代码运行开始会默认创建两个线程,一个是主线程即通过main()方法运行的线程,一个是垃圾回收线程。主线程就是非守护线程(也可以称为被守护线程,常称为用户线程),垃圾回收线程就是守护线程,负责回收主线程的垃圾对象,当所有守护线程结束后,垃圾回收线程才会随之结束。

设置线程的守护性

在指定线程运行前,利用该线程的setDaemon()方法即可设置。值得一提的是,程序员无法设置主线程为守护线程,因为运行Java代码时必定需要开启主线程,从而主线程无法被修改其守护性。同时,一般线程默认为非守护线程,则为设置守护线程的情况下很有可能主线程运行结束后,其子线程仍然在运行。

下面演示了线程的守护性的设置:

Thread thread = new Thread();
thread.setDaemon(true);
thread.setDaemon(false);//default
thread.start();

守护与非守护的区别

  • 守护线程会随着主线程一起销毁。
  • 非守护线程与主线程的生命周期互不相连。

下面演示了守护线程的使用:

public static void test1() throws InterruptedException {
    Thread t1 = new Thread(()-> {
        while (true) {
            try {
                Thread.sleep(1000);
                System.out.println("SubThread: I am running");
            } catch (Exception ignored) {
            }
        }
    });
    t1.setDaemon(true);
    t1.start();
    Thread.sleep(3000);
    System.out.println("MainThread: have done.");
}

运行结果:

SubThread: I am running
SubThread: I am running
MainThread: have done.
//Program shutdown automatically.

下面演示了非守护线程的使用:

public static void test2() throws InterruptedException {
    Thread t1 = new Thread(()-> {
        while (true) {
            try {
                Thread.sleep(1000);
                System.out.println("SubThread: I am running");
            } catch (Exception ignored) {
            }
        }
    });
    t1.start();
    Thread.sleep(3000);
    System.out.println("MainThread: have done.");
}

运行效果:

SubThread: I am running
SubThread: I am running
MainThread: have done.
SubThread: I am running
SubThread: I am running
SubThread: I am running
//Shutdown the program by user.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值