Java 并发编程

一、开篇

本篇只要是对Java基础中的并发编程进行巩固、知识回顾。

几乎所有的资料在讲述本篇时都是先讲一下进程和线程的区别。在此将不详细介绍,只介绍了解以下几点区别:

1、进程大、线程小,一个程序至少有一个进程,一个进程至少有一个线程

2、线程不能独立执行,必须存在进程中

3、每个进程拥有一套自己的变量,而线程则共享数据

二、使用线程

1、创建一个新线程

下面是在一个单独的线程中执行一个任务的简单过程:

1 ) 将任务代码移到实现了 Runnable 接口的类的 run 方法中。这个接口非常简单, 只有一个方法:

public interface Runnable
{
    void run();
}

由于 Runnable 是一个函数式接口,可以用 lambda 表达式建立一个实例:

Runnable r = () -> { task code };

2 ) 由 Runnable 创建一个 Thread 对象:

Thread t = new Thread(r);

3 ) 启动线程:

t.start();

也可以通过构建一个 Thread 类的子类定义一个线程, 如下所示:

class MyThread extends Thread
{
    public void run()
    {
        task code
    }
}

然后, 构造一个子类的对象, 并调用 start 方法。 不过, 这种方法已不再推荐。 应该将要并行运行的任务与运行机制解耦合。如果有很多任务, 要为每个任务创建一个独立的线程所付出的代价太大了。 可以使用线程池来解决这个问题,将在后面介绍。

注意:不要调用 Thread 类或 Runnable 对象的 run 方法。 直接调用 run 方法, 只会执行同一个线程中的任务, 而不会启动新线程。 应该调用 Thread.start 方法。这个方法将创建一个执行 run 方法的新线程。

重要方法说明:

// 构造一个新线程, 用于调用给定目标的 nm() 方法。
ThreadCRunnable target)

// 启动这个线程, 将引发调用 run() 方法。这个方法将立即返回, 并且新线程将并发运行。
void start( )

// 调用关联 Runnable 的 run 方法。
void run( )

// 必须覆盖这个方法, 并在这个方法中提供所要执行的任务指令。
void run( )

2、中断线程

当线程的 run 方法执行方法体中最后一条语句后, 并经由执行 return 语句返冋时, 或者出现了在方法中没有捕获的异常时, 线程将终止。 在 Java 的早期版本中, 还有一个 stop 方法, 其他线程可以调用它终止线程。但是, 这个方法现在已经被弃用了。

没有可以强制线程终止的方法。然而, interrupt 方法可以用来请求终止线程。

当对一个线程调用 interrupt 方法时,线程的中断状态将被置位。这是每一个线程都具有的 boolean 标志。每个线程都应该不时地检査这个标志, 以判断线程是否被中断。

要想弄清中断状态是否被置位, 首先调用静态的 Thread.currentThread 方法获得当前线程, 然后调用 islnterrupted 方法:

while (!Thread.currentThread().islnterrupted() && more work to do)
{
    do more work
}

但是, 如果线程被阻塞, 就无法检测中断状态。 这是产生 InterruptedException异常的地方。当在一个被阻塞的线程 (调用 sleep 或 wait) 上调用 interrupt 方法时, 阻塞调用将会被Interrupted Exception 异常中断。

没有任何语言方面的需求要求一个被中断的线程应该终止。中断一个线程不过是引起它的注意。被中断的线程可以决定如何响应中断。某些线程是如此重要以至于应该处理完异常后, 继续执行, 而不理会中断。但是,更普遍的情况是,线程将简单地将中断作为一个终止的请求。这种线程的 run 方法具有如下形式:

Runnable r = () -> {
    try
    {
        ......
        while (!Thread.currentThread().islnterrupted && more work todo)
        {
           // do more work
        }
    }
    catch(InterruptedException e)
    {
        // thread was interr叩ted during sleep or wait
    }
    finally
    {
        // cleanup,if required
    }
    // exiting the run method terminates the thread
};

如果在每次工作迭代之后都调用 sleep 方法(或者其他的可中断方法 ), islnterrupted 检测既没有必要也没有用处。 如果在中断状态被置位时调用 sleep 方法, 它不会休眠。 相反,它将清除这一状态(!)并拋出 IntemiptedException。因此, 如果你的循环调用 sleep, 不会检测中断状态。

注意:有两个非常类似的方法,interrupted 和 islnterrupted。

interrupted 方法是一个静态方法, 它检测当前的线程是否被中断。 而且, 调用 interrupted 方法会清除该线程的中断状态。 

islnterrupted 方法是一个实例方法, 可用来检验是否有线程被中断。调用这个方法不会改变中断状态。

几个重要的方法:

// 向线程发送中断请求。线程的中断状态将被设置为 true。如果目前该线程被一个 sleep调用阻塞,那么, InterruptedException 异常被抛出。
void interrupt()

//测试当前线程(即正在执行这一命令的线程)是否被中断。注意,这是一个静态方法。这一调用会产生副作用————它将当前线程的中断状态重置为 false。
static boolean interrupted()

// 测试线程是否被终止。不像静态的中断方法,这一调用不改变线程的中断状态。
boolean islnterrupted()

// 返回代表当前执行线程的 Thread 对象。
static Thread currentThread()

三、线程状态

线程可以分为以下6种状态:

  1. New(新创建)
  2. Runnable(可运行)
  3. Blocked(被阻塞)
  4. Waiting(等待)
  5. Timed waiting(计时等待)
  6. Terminated(被终止)

要确定一个线程的当前状态,可调用getState方法。

1、新创建线程

当用new操作符创建一个线程时,如new Thread(r),该现场还没有开始运行,只是处于新创建状态。

2、可运行线程

一旦调用了start方法,线程就处于runnable状态。一个可运行的线程可能正在运行也可能没有运行。一个线程一旦开始运行,他不必始终保持运行,这里涉及到线程调度问题(如时间片轮转等)。

3、被阻塞线程和等待线程

当线程处于被阻塞或等待状态时,它暂时不活动。它不运行任何代码且消耗最少的资源。直到线程调度器重新激活它。

细节是取决于它是怎样达到非活动状态的:

  • (1)当一个线程试图获取一个内部对象锁,而该锁被其他对象持有,则该线程进入阻塞状态。当所有其他线程释放该锁时,并且线程调度器允许本线程持有它的时候,该线程将变成非阻塞状态。
  • (2)当线程等待另一个线程通知调度器一个条件时,它自己进入等待状态。例如,在调用Object.wait方法或者Thread.join方法,或者是等待java.util.concurrent库中的Lock或Condition时,就会出现这种情况。
  • (3)有几个方法有一个超时参数。调用它们导致线程进入计时等待状态。这一状态将一直保持到超时期满或者接收到适当的通知。带有超时参数的方法有Thread.sleep和Object.wait、Thread.join、Lock.tryLock以及Condition.await的计时。

4、被终止的线程

线程因如下两个原因之一而被终止:

  • (1)因为run方法正常退出而自然死亡
  • (2)因为一个没有捕获的异常终止了run方法而意外死亡。

        下图展示了线程可以具有的状态以及从一个状态到另一个状态可能的转换。当一个线程被阻塞或等待时,另一个线程被调度为运行状态。当一个线程被重新激活(例如,因为超时期满或成功地获得了一个锁),调度器检查它是否具有比当前运行线程更高的优先级。 如果是这样,调度器从当前运行线程中挑选一个,剥夺其运行权,选择一个新的线程运行。

 几个重要的方法:

// 等待终止指定的线程。
void join( )

//等待指定的线程死亡或者经过指定的毫秒数。
void join(long millis)

//得到这一线程的状态;NEW、RUNNABLE、BLOCKED、 WAITING、HMED_WAITING或TERMINATED 之一。
Thread.State getState() 5.0

// 停止该线程。这一方法已过时。
void stop( )

// 暂停这一线程的执行。这一方法已过时。
void suspend()

// 恢复线程。这一方法仅仅在调用 suspendO 之后调用。这一方法已过时。
void resume()

四、线程属性

下面将讨论线程的各种属性,其中包括:线程优先级、守护线程、 线程组以及处理未捕获异常的处理器。

1、线程优先级

        在 Java 程序设计语言中, 每一个线程有一个优先级。 默认情况下, 一个线程继承它的父线程的优先级。可以用 setPriority 方法提高或降低任何一个线程的优先级。可以将优先级设置为在 MIN_PRIORITY (在 Thread 类中定义为 1 ) 与 MAX_PRIORITY (定义为 10 ) 之间的任何值。NORM_PRIORITY 被定义为 5。

        每当线程调度器有机会选择新线程时, 它首先选择具有较高优先级的线程。但是,线程优先级是高度依赖于系统的。当虚拟机依赖于宿主机平台的线程实现机制时, Java 线程的优先级被映射到宿主机平台的优先级上, 优先级个数也许更多,也许更少。

        例如, Windows 有 7 个优先级别。一些 Java 优先级将映射到相同的操作系统优先级。在Oracle 为 Linux 提供的 Java 虚拟机中, 线程的优先级被忽略——所有线程具有相同的优先级。

        如果确实要使用优先级, 应该避免初学者常犯的一个错误。 如果有几个高优先级的线程没有进入非活动状态, 低优先级的线程可能永远也不能执行。每当调度器决定运行一个新线程时, 首先会在具有高优先级的线程中进行选择, 尽管这样会使低优先级的线程完全饿死。

几个重要的方法:

// 设置线程的优先级。优先级必须在 Thread.MIN_PRIORITY 与 Thread.MAX_PRIORITY之间。一般使用Thread.NORM_PRIORITY 优先级。
void setPriority(int newPriority)

// 线程的最小优先级。最小优先级的值为 1。
static int MIN_PRIORITY

// 线程的默认优先级。默认优先级为 5。
static int NORM_PRI0RITY

// 线程的最高优先级。最高优先级的值为 10。
static int MAX_PRIORITY

// 导致当前执行线程处于让步状态。如果有其他的可运行线程具有至少与此线程同样高的优先级,那么这些线程接下来会被调度。注意,这是一个静态方法。
static void yield( )

2、守护线程

可以通过调用

t.setDaemon(true);

将线程转换为守护线程(daemon thread)。这一方法必须在线程启动之前调用。这样一个线程没有什么神奇。守护线程的唯一用途是为其他线程提供服务。计时线程就是一个例子,它定时地发送“ 计时器嘀嗒” 信号给其他线程或清空过时的高速缓存项的线程。当只剩下守护线程时, 虚拟机就退出了, 由于如果只剩下守护线程, 就没必要继续运行程序了。

        守护线程有时会被初学者错误地使用, 他们不打算考虑关机(shutdown) 动作。但是,这是很危险的。守护线程应该永远不去访问固有资源, 如文件、 数据库,因为它会在任何时候甚至在一个操作的中间发生中断。

一个常用方法:

// 标识该线程为守护线程或用户线程。这一方法必须在线程启动之前调用。
void setDaemon( boolean isDaemon )

3、未捕获异常处理器

        线程的 run 方法不能抛出任何受查异常, 但是,非受査异常会导致线程终止。在这种情况下,线程就死亡了。

        但是,不需要任何 catch 子句来处理可以被传播的异常。 相反, 就在线程死亡之前, 异常被传递到一个用于未捕获异常的处理器。

该处理器必须属于一个实现 Thread.UncaughtExceptionHandler 接口的类。这个接口只有一个方法。

void uncaughtException(Thread t, Throwable e)

        可以用 setUncaughtExceptionHandler 方法为任何线程安装一个处理器。也可以用 Thread类的静态方法 setDefaultUncaughtExceptionHandler 为所有线程安装一个默认的处理器。 替换处理器可以使用日志 API 发送未捕获异常的报告到日志文件。

如果不安装默认的处理器, 默认的处理器为空。 但是, 如果不为独立的线程安装处理器,此时的处理器就是该线程的 ThreadGroup 对象。

        线程组是一个可以统一管理的线程集合。默认情况下, 创建的所有线程属于相同的线程组, 但是, 也可能会建立其他的组。现在引入了更好的特性用于线程集合的操作,所以建议不要在自己的程序中使用线程组。

        ThreadGroup 类实现 Thread.UncaughtExceptionHandler 接口。 它的 uncaughtException 方
法做如下操作:

  • 1 ) 如果该线程组有父线程组, 那么父线程组的 uncaughtException 方法被调用。
  • 2 ) 否则, 如果 Thread.getDefaultExceptionHandler 方法返回一个非空的处理器, 则调用该处理器。
  • 3 ) 否则, 如果 Throwable 是 ThreadDeath 的一个实例, 什么都不做。
  • 4 ) 否则, 线程的名字以及 Throwable 的栈轨迹被输出到 System.err 上。

五、同步

        在大多数实际的多线程应用中, 两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取相同的对象, 并且每一个线程都调用了一个修改该对象状态的方法,将会发生什么呢? 可以想象, 线程彼此踩了对方的脚。 根据各线程访问数据的次序, 可能会产生讹误的对象。这样一个情况通常称为竞争条件(race condition)。

为了避免多线程引起的对共享数据的说误,必须学习如何同步存取。一个最典型的例子就是银行账户转账因为线程问题导致最终金额不正确,在此不具体展开细讲,老生常谈的话题了。

1、锁对象

        有两种机制防止代码块受并发访问的干扰。Java 语言提供一个 synchronized 关键字达到这一目的, 并且 Java SE 5.0 引入了 ReentrantLock 类。synchronized 关键字自动提供一个锁以及相关的“ 条件”, 对于大多数需要显式锁的情况, 这是很便利的。

用 ReentrantLock 保护代码块的基本结构如下:

myLock.lock(); // a ReentrantLock object
try
{
    critical section
}
finally
{
    myLock.unlockO;// make sure the lock is unlocked even if an exception is thrown
}

        这一结构确保任何时刻只有一个线程进人临界区。一旦一个线程封锁了锁对象, 其他任何线程都无法通过 lock 语句。当其他线程调用 lock 时,它们被阻塞, 直到第一个线程释放锁对象。

注意:把解锁操作括在 finally 子句之内是至关重要的。如果在临界区的代码抛出异常,锁必须被释放。 否则, 其他线程将永远阻塞。

        锁是可重入的, 因为线程可以重复地获得已经持有的锁。锁保持一个持有计数( hold count) 来跟踪对 lock 方法的嵌套调用。线程在每一次调用 lock 都要调用 unlock 来释放锁。由于这一特性, 被一个锁保护的代码可以调用另一个使用相同的锁的方法。

        通常,可能想要保护需若干个操作来更新或检查共享对象的代码块。要确保这些操作完成后, 另一个线程才能使用相同对象。

注意:要留心临界区中的代码, 不要因为异常的抛出而跳出临界区。 如果在临界区代码结束之前抛出了异常, finally 子句将释放锁,但会使对象可能处于一种受损状态。

几个方法API:

//获取这个锁;如果锁同时被另一个线程拥有则发生阻塞。
void lock( )

//释放这个锁。
void unlock( )

//构建一个可以被用来保护临界区的可重入锁。
ReentrantLock( )

//构建一个带有公平策略的锁。一个公平锁偏爱等待时间最长的线程。 但是,这一公平的保证将大大降低性能。 所以, 默认情况下, 锁没有被强制为公平的。
ReentrantLock(boo1ean fair)

2、条件对象

        通常, 线程进人临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。

        等待获得锁的线程和调用 await 方法的线程存在本质上的不同。一旦一个线程调用 await方法, 它进人该条件的等待集。 当锁可用时,该线程不能马上解除阻塞。 相反,它处于阻塞状态,直到另一个线程调用同一条件上的 signalAll 方法时为止。

        当一个线程调用 await 时, 它没有办法重新激活自身。它寄希望于其他线程。 如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁(deadlock) 现象。 如果所有其他线程被阻塞, 最后一个活动线程在解除其他线程的阻塞状态之前就调用 await 方法, 那么它也被阻塞。没有任何线程可以解除其他线程的阻塞,那么该程序就挂起了。

        应该何时调用 signalAll 呢? 经验上讲, 在对象的状态有利于等待线程的方向改变时调用signalAll。

        注意调用 signalAll 不会立即激活一个等待线程。它仅仅解除等待线程的阻塞, 以便这些线程可以在当前线程退出同步方法之后, 通过竞争实现对对象的访问。

        另一个方法 signal, 则是随机解除等待集中某个线程的阻塞状态。这比解除所有线程的阻塞更加有效,但也存在危险。 如果随机选择的线程发现自己仍然不能运行, 那么它再次被阻塞。如果没有其他线程再次调用 signal, 那么系统就死锁了。

注意:当一个线程拥有某个条件的锁时, 它仅仅可以在该条件上调用 await、signalAll 或signal 方法。

一些重要方法的API:

//返回一个与该锁相关的条件对象。
Condition newCondition( )

// 将该线程放到条件的等待集中。
void await( )

//解除该条件的等待集中的所有线程的阻塞状态。
void signalAll( )

//从该条件的等待集中随机地选择一个线程, 解除其阻塞状态。
void signal( )

3、synchronized 关键字

        在前面介绍了如何使用 Lock 和 Condition 对象。在进一步深人之前, 总结一下有关锁和条件的关键之处:

  • 锁用来保护代码片段, 任何时刻只能有一个线程执行被保护的代码。
  • 锁可以管理试图进入被保护代码段的线程。
  • 锁可以拥有一个或多个相关的条件对象。
  • 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。

        Lock 和 Condition 接口为程序设计人员提供了高度的锁定控制。然而, 大多数情况下,并不需要那样的控制,并且可以使用一种嵌入到 Java 语言内部的机制。 从 1.0 版开始,Java中的每一个对象都有一个内部锁。 如果一个方法用 synchronized 关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法, 线程必须获得内部的对象锁。

换句话说,

public synchronized void method()
{
    // method body
}

等价于

public void method()
{
    this.intrinsidock.lock();
    try
    {
        // method body
    }
    finally { this.intrinsicLock.unlock(); }
}

        例如, 可以简单地声明 Bank 类的 transfer 方法为 synchronized, 而不是使用一个显式的锁。

        内部对象锁只有一个相关条件。wait 方法添加一个线程到等待集中,notifyAll /notify 方法解除等待线程的阻塞状态。换句话说,调用 wait 或 notityAll 等价于:

intrinsicCondition.await();
intrinsicCondition.signalAll()

注释: wait、notifyAll 以及 notify 方法是 Object 类的 final 方法。Condition 方法必须被命名为 await、signalAll 和 signal 以便它们不会与那些方法发生冲突。

        使用 synchronized 关键字来编写代码要简洁得多。 当然, 要理解代码, 你必须了解每一个对象有一个内部锁, 并且该锁有一个内部条件。由锁来管理那些试图进入synchronized 方法的线程, 由条件来管理那些调用 wait 的线程。

        将静态方法声明为 synchronized 也是合法的。如果调用这种方法,该方法获得相关的类对象的内部锁。例如, 如果 Bank 类有一个静态同步的方法,那么当该方法被调用时,Bank.class对象的锁被锁住。因此,没有其他线程可以调用同一个类的这个或任何其他的同步静态方法。

        内部锁和条件存在一些局限。包括:

  • 不能中断一个正在试图获得锁的线程。
  • 试图获得锁时不能设定超时。
  • 每个锁仅有单一的条件, 可能是不够的。

        在代码中应该使用哪一种? Lock 和 Condition 对象还是同步方法?下面是一些建议:

  • 最好既不使用 Lock/Condition 也不使用 synchronized 关键字。 在许多情况下你可以使用 java.util.concurrent 包中的一种机制,它会为你处理所有的加锁。
  • 如果 synchronized 关键字适合你的程序, 那么请尽量使用它,这样可以减少编写的代码数量,减少出错的几率。
  • 如果特别需要 Lock/Condition 结构提供的独有特性时,才使用 Lock/Condition。

一些重要方法的API:

// 解除那些在该对象上调用 wait 方法的线程的阻塞状态。该方法只能在同步方法或同步块内部调用。 如果当前线程不是对象锁的持有者,该方法拋出一个 IllegalMonitorStateException异常。
void notifyAll( )

//随机选择一个在该对象上调用 wait 方法的线程, 解除其阻塞状态。该方法只能在一个同步方法或同步块中调用。 如果当前线程不是对象锁的持有者, 该方法抛出一个IllegalMonitorStateException 异常。
void notify( )

//导致线程进人等待状态直到它被通知。该方法只能在一个同步方法中调用。 如果当前线程不是对象锁的持有者,该方法拋出一个 IllegalMonitorStateException 异常。
void wait( )

//导致线程进入等待状态直到它被通知或者经过指定的时间。这些方法只能在一个同步方法中调用。如果当前线程不是对象锁的持有者该方法拋出一个 IllegalMonitorStateException异常。参数: millis毫秒数;nanos纳秒数,<1 000 000
void wait(long millis)
void wait(long millis, int nanos )

4、同步阻塞

        正如刚刚讨论的, 每一个 Java 对象有一个锁。线程可以通过调用同步方法获得锁。还有另一种机制可以获得锁,通过进入一个同步阻塞。当线程进入如下形式的阻塞:

synchronized (obj) // this is the syntax for a synchronized block
{
    critical section
}

于是它获得 Obj 的锁。

5、监视器概念

        锁和条件是线程同步的强大工具,但是,严格地讲, 它们不是面向对象的。多年来,研究人员努力寻找一种方法,可以在不需要程序员考虑如何加锁的情况下,就可以保证多线程的安全性。最成功的解决方案之一是监视器 (monitor), 这一概念最早是由 PerBrinchHansen和 Tony Hoare 在 20 世纪 70 年代提出的。用 Java 的术语来讲, 监视器具有如下特性:

  • 监视器是只包含私有域的类。
  • 每个监视器类的对象有一个相关的锁。
  • 使用该锁对所有的方法进行加锁。换句话说, 如果客户端调用 obj.method(), 那么obj对象的锁是在方法调用开始时自动获得, 并且当方法返回时自动释放该锁。因为所有的域是私有的,这样的安排可以确保一个线程在对对象操作时, 没有其他线程能访问该域。
  • 该锁可以有任意多个相关条件。

        Java 设计者以不是很精确的方式采用了监视器概念, Java 中的每一个对象有一个内部的锁和内部的条件。 如果一个方法用 synchronized 关键字声明,那么,它表现的就像是一个监视器方法。通过调用 wait/notifyAU/notify 来访问条件变量。

然而, 在下述的 3 个方面 Java 对象不同于监视器, 从而使得线程的安全性下降:

  • 域不要求必须是 private。
  • 方法不要求必须是 synchronized。
  • 内部锁对客户是可用的。

6、Volatile 域

        有时, 仅仅为了读写一个或两个实例域就使用同步, 显得开销过大了。毕竟, 什么地方能出错呢? 遗憾的是,使用现代的处理器与编译器, 出错的可能性很大。

  • 多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
  • 编译器可以改变指令执行的顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义, 但是编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变。然而,内存的值可以被另一个线程改变!

        如果你使用锁来保护可以被多个线程访问的代码, 那么可以不考虑这种问题。 编译器被要求通过在必要的时候刷新本地缓存来保持锁的效应, 并且不能不正当地重新排序指令。

        volatile 关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为 volatile ,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。

例如, 假定一个对象有一个布尔标记 done, 它的值被一个线程设置却被另一个线程査询, 如同我们讨论过的那样,你可以使用锁:

private boolean done;
public synchronized boolean isDone() { return done; }
public synchronized void setDone() { done = true; }

        或许使用内部锁不是个好主意。如果另一个线程已经对该对象加锁, isDone 和 setDone方法可能阻塞。 如果注意到这个方面, 一个线程可以为这一变量使用独立的 Lock。但是, 这也会带来许多麻烦 。

在这种情况下,将域声明为 volatile 是合理的:

private volatile boolean done;
public boolean isDone() { return done; }
public void setDone() { done = true; }

 Volatile 变量不能提供原子性。例如, 方法public void flipDone() { done = !done; } // not atomic

不能确保翻转域中的值。 不能保证读取、 翻转和写入不被中断。

7、final 变置

        上一节已经了解到, 除非使用锁或 volatile 修饰符, 否则无法从多个线程安全地读取一个域。

        还有一种情况可以安全地访问一个共享域, 即这个域声明为 final 时。考虑以下声明:

final Map<String, Double>accounts = new HashKap<>();

        其他线程会在构造函数完成构造之后才看到这个 accounts 变量。

        如果不使用 final,就不能保证其他线程看到的是 accounts 更新后的值,它们可能都只是看到 null, 而不是新构造的 HashMap。

        当然,对这个映射表的操作并不是线程安全的。如果多个线程在读写这个映射表,仍然需要进行同步。

8、原子性

        假设对共享变量除了赋值之外并不完成其他操作, 那么可以将这些共享变量声明为volatile。

        java.util.concurrent.atomic 包中有很多类使用了很高效的机器级指令(而不是使用锁) 来保证其他操作的原子性。 例如, Atomiclnteger 类提供了方法 incrementAndGet 和decrementAndGet, 它们分别以原子方式将一个整数自增或自减。

        如果有大量线程要访问相同的原子值, 性能会大幅下降, 因为乐观更新需要太多次重试。Java SE 8 提供了 LongAdder 和 LongAccumulator 类来解决这个问题。LongAdder 包括多个变量(加数),其总和为当前值。可以有多个线程更新不同的加数,线程个数增加时会自动提供新的加数。通常情况下, 只有当所有工作都完成之后才需要总和的值, 对于这种情况,这种方法会很高效。性能会有显著的提升。

9、死锁

        有可能会因为每一个线程要等待更多的资源而导致所有线程都被阻塞。这样的状态称为死锁(deadlock)。

        遗憾的是, Java 编程语言中没有任何东西可以避免或打破这种死锁现象。必须仔细设计程序, 以确保不会出现死锁。

关于死锁,此处不做详细介绍。

10、锁测试与超时

        线程在调用 lock 方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。应该更加谨慎地申请锁。tryLock 方法试图申请一个锁, 在成功获得锁后返回 true, 否则, 立即返回false, 而且线程可以立即离开去做其他事情。

if (myLock.tryLockO)
{
    // now the thread owns the lock
    try { . . . }
    finally { myLock.unlockO; }
    }
    else
    // do something else

可以调用 tryLock 时,使用超时参数,像这样:

if (myLock.tryLock(100, TineUnit.MILLISECONDS)) .. .

        TimeUnit 是一个枚举类型,可以取的值包括 SECONDS、MILLISECONDS, MICROSECONDS和 NANOSECONDS。

        lock 方法不能被中断。如果一个线程在等待获得一个锁时被中断,中断线程在获得锁之前一直处于阻塞状态。如果出现死锁, 那么, lock 方法就无法终止。

        然而, 如果调用带有用超时参数的 tryLock, 那么如果线程在等待期间被中断,将抛出InterruptedException 异常。这是一个非常有用的特性,因为允许程序打破死锁。

        也可以调用 locklnterruptibly 方法。它就相当于一个超时设为无限的 tryLock 方法。

        在等待一个条件时, 也可以提供一个超时:

myCondition.await(100, TineUniBILLISECONDS))

        如果一个线程被另一个线程通过调用 signalAU 或 signal 激活, 或者超时时限已达到, 或者线程被中断, 那么 await 方法将返回。

        如果等待的线程被中断, await 方法将抛出一个 InterruptedException 异常。在你希望出现这种情况时线程继续等待(可能不太合理), 可以使用 awaitUninterruptibly 方法代替 await。

11、读 / 写锁

        java.util.concurrent.locks 包 定 义 了 两 个 锁 类, 我 们 已 经 讨 论 的 ReentrantLock 类 和ReentrantReadWriteLock 类。 如果很多线程从一个数据结构读取数据而很少线程修改其中数据的话, 后者是十分有用的。在这种情况下, 允许对读者线程共享访问是合适的。当然,写者线程依然必须是互斥访问的。

下面是使用读 / 写锁的必要步骤:

1 ) 构 造 一 个 ReentrantReadWriteLock 对象:

private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock():

2 ) 抽取读锁和写锁:

private Lock readLock = rwl.readLock();
private Lock writeLock = rwl.writeLock();

3 ) 对所有的获取方法加读锁:

public double getTotalBalanceO
{
    readLock.lock();
    try { . . . }
    finally { readLock.unlock() ; }
}

4 ) 对所有的修改方法加写锁:

public void transfer(. . .)
{
    writeLock.lock();
    try { . . . }
    finally { writeLock.unlock(); }
}

方法API:

//得到一个可以被多个读操作共用的读锁, 但会排斥所有写操作。
Lock readLock( )

//得到一个写锁, 排斥所有其他的读操作和写操作。
Lock writeLock( )

12、为什么弃用 stop 和 suspend 方法

        初始的 Java 版本定义了一个 stop 方法用来终止一个线程, 以及一个 suspend 方法用来阻塞一个线程直至另一个线程调用 resume。stop 和 suspend 方法有一些共同点: 都试图控制一个给定线程的行为。

        stop、 suspend 和 resume 方法已经弃用。stop 方法天生就不安全,经验证明 suspend 方法会经常导致死锁。

        首先来看看 stop 方法, 该方法终止所有未结束的方法, 包括 run 方法。当线程被终止,立即释放被它锁住的所有对象的锁。这会导致对象处于不一致的状态。例如假定 TransferThread在从一个账户向另一个账户转账的过程中被终止,钱款已经转出,却没有转人目标账户,现在银行对象就被破坏了。因为锁已经被释放,这种破坏会被其他尚未停止的线程观察到。

        当线程要终止另一个线程时, 无法知道什么时候调用 stop 方法是安全的, 什么时候导致对象被破坏。 因此,该方法被弃用了。在希望停止线程的时候应该中断线程, 被中断的线程会在安全的时候停止。

        接下来, 看看 suspend 方法有什么问题。 与 stop 不同,suspend 不会破坏对象。但是,如果用 suspend 挂起一个持有一个锁的线程, 那么,该锁在恢复之前是不可用的。 如果调用suspend 方法的线程试图获得同一个锁, 那么程序死锁: 被挂起的线程等着被恢复, 而将其挂起的线程等待获得锁。

六、阻塞队列

        对于许多线程问题, 可以通过使用一个或多个队列以优雅且安全的方式将其形式化。生产者线程向队列插人元素, 消费者线程则取出它们。使用队列,可以安全地从一个线程向另一个线程传递数据。

        当试图向队列添加元素而队列已满, 或是想从队列移出元素而队列为空的时候, 阻塞队列(blocking queue) 导致线程阻塞。在协调多个线程之间的合作时, 阻塞队列是一个有用的工具。工作者线程可以周期性地将中间结果存储在阻塞队列中。其他的工作者线程移出中间结果并进一步加以修改。队列会自动地平衡负载。如果第一个线程集运行得比第二个慢, 第二个线程集在等待结果时会阻塞。 如果第一个线程集运行得快, 它将等待第二个队列集赶上来。

表 14-1 给出了阻塞队列的方法。

        阻塞队列方法分为以下 3 类, 这取决于当队列满或空时它们的响应方式。如果将队列当作线程管理工具来使用, 将要用到 put 和 take 方法。 当试图向满的队列中添加或从空的队列中移出元素时,add、 remove 和 element 操作抛出异常。当然,在一个多线程程序中, 队列会在任何时候空或满, 因此,一定要使用 offer、 poll 和 peek 方法作为替代。这些方法如果不能完成任务,只是给出一个错误提示而不会抛出异常。

注释: poll 和 peek 方法返回空来指示失败。 因此,向这些队列中插入 null 值是非法的。

七、Callable 与 Future

        Runnable 封装一个异步运行的任务,可以把它想象成为一个没有参数和返回值的异步方法。Callable 与 Runnable 类似, 但是有返回值。Callable 接口是一个参数化的类型, 只有一个方法 call。

public interface Callable<V>
{
    V call() throws Exception;
}

        类型参数是返回值的类型。 例如, Callable<Integer> 表示一个最终返回 Integer 对象的异步计算。

        Future 保存异步计算的结果。可以启动一个计算,将 Future 对象交给某个线程,然后忘掉它。Future 对象的所有者在结果计算好之后就可以获得它。

Future 接口具有下面的方法:

public interface Future<V>
{
    V get() throws . .
    V get(long timeout, TimeUnit unit) throws . .
    void cancel(boolean mayInterrupt);
    boolean isCancelled();
    boolean isDone();
}

        第一个 get 方法的调用被阻塞, 直到计算完成。如果在计算完成之前, 第二个方法的调用超时,拋出一个 TimeoutException 异常。如果运行该计算的线程被中断, 两个方法都将拋出 IntermptedException。如果计算已经完成, 那么 get 方法立即返回。

        如果计算还在进行, isDone 方法返回 false; 如果完成了, 则返回 true。

        可以用 cancel 方法取消该计算。如果计算还没有开始,它被取消且不再开始。如果计算处于运行之中,那么如果 maylnterrupt 参数为 true, 它就被中断。

        FutureTask 包装器是一种非常便利的机制, 可将 Callable 转换成 Future 和 Runnable, 它同时实现二者的接口。 例如:

Callable<Integer> nyComputation = . . .;
FutureTask<Integer> task = new FutureTask<Integer>(myConiputation);
Thread t = new Thread(task); // it's a Runnable
t.start();
Integer result = task.get();// it's a Future

八、执行器

1、线程池

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

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

        执行器 ( Executor) 类有许多静态工厂方法用来构建线程池, 表 14-2 中对这些方法进行了汇总。

 下面总结了在使用连接池时应该做的事:

  • 1 ) 调用 Executors 类中静态的方法 newCachedThreadPool 或 newFixedThreadPool。
  • 2 ) 调用 submit 提交 Runnable 或 Callable 对象。
  • 3 ) 如果想要取消一个任务, 或如果提交 Callable 对象, 那就要保存好返回的 Future对象。
  • 4 ) 当不再提交任何任务时,调用 shutdown。

2、预定执行

        ScheduledExecutorService 接口具有为预定执行(Scheduled Execution) 或 重 复 执 行 任务而设计的方法。它是一种允许使用线程池机制的 java.util.Timer 的泛化。 Executors 类的newScheduledThreadPool 和 newSingleThreadScheduledExecutor 方法将返回实现了 ScheduledExecutorService 接口的对象。

        可以预定 Runnable 或 Callable 在初始的延迟之后只运行一次。也可以预定一个 Runnable对象周期性地运行。

3、控制任务组

        invokeAny 方法提交所有对象到一个 Callable 对象的集合中, 并返回某个已经完成了的任务的结果。无法知道返回的究竟是哪个任务的结果, 也许是最先完成的那个任务的结果。对于搜索问题, 如果你愿意接受任何一种解决方案的话, 你就可以使用这个方法。例如, 假定你需要对一个大整数进行因数分解计算来解码 RSA 密码。可以提交很多任务, 每一个任务使用不同范围内的数来进行分解。只要其中一个任务得到了答案, 计算就可以停止了。

        invokeAll 方法提交所有对象到一个 Callable 对象的集合中,并返回一个 Future 对象的列表,代表所有任务的解决方案。

        这个方法的缺点是如果第一个任务恰巧花去了很多时间, 则可能不得不进行等待。将结果按可获得的顺序保存起来更有实际意义。可以用 ExecutorCompletionService 来进行排列。

4、Fork-Join 框架

        有些应用使用了大量线程, 但其中大多数都是空闲的。举例来说, 一个 Web 服务器可能会为每个连接分别使用一个线程。另外一些应用可能对每个处理器内核分别使用一个线程,来完成计算密集型任务, 如图像或视频处理。Java SE 7 中新引入了 fork-join 框架,专门用来支持后一类应用。

        在后台, fork-join 框架使用了一种有效的智能方法来平衡可用线程的工作负载,这种方法称为工作密取(work stealing)。每个工作线程都有一个双端队列 ( deque) 来完成任务。一个工作线程将子任务压人其双端队列的队头。(只有一个线程可以访问队头, 所以不需要加锁。)一个工作线程空闲时,它会从另一个双端队列的队尾“ 密取” 一个任务。由于大的子任务都在队尾, 这种密取很少出现。

5、可完成 Future

        处理非阻塞调用的传统方法是使用事件处理器, 程序员为任务完成之后要出现的动作注册一个处理器。 当然, 如果下一个动作也是异步的, 在它之后的下一个动作会在一个不同的事件处理器中。尽管程序员会认为“ 先做步骤 1, 然后是步骤 2, 再完成步骤 3”, 但实际上程序逻辑会分散到不同的处理器中。 如果必须增加错误处理, 情况会更糟糕。 假设步骤 2是“ 用户登录”。可能需要重复这个步骤, 因为用户输入凭据时可能会出错。要尝试在一组事件处理器中实现这样一个控制流, 或者想要理解所实现的这样一组事件处理器, 会很有难度。
        Java SE 8 的 CompletableFuture 类提供了一种候选方法。 与事件处理器不同,“ 可完成future" 可以“ 组合”(composed ) 。

        从概念上讲, CompletableFuture 是一个简单 API, 不过有很多不同方法来组合可完成future。下面先来看处理单个 fiiture 的方法(如表 14-3 所示)。(对于这里所示的每个方法,还有两个 Async 形式,不过这里没有给出,其中一种形式使用一个共享 ForkJoinPool, 另一种形式有一个 Executor 参数 )。在这个表中, 我使用了简写记法来表示复杂的函数式接口, 这里会把 Function<? super T,U> 写为 T-> U。当然这并不是真正的 Java 类型。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值