并发编程(1)并发编程基础

并发性和多线程

并发性

在过去单 CPU 时代,单任务在一个时间点只能执行单一程序。之后发展到多任务阶段,计算机能在同一时间点并行执行多任务或多进程。虽然并不是真正意义上的“同一时间点”,而是多个任务或进程共享一个 CPU,并交由操作系统来完成多任务间对 CPU 的运行切换,以使得每个任务都有机会获得一定的时间片运行。

随着多任务对软件开发者带来的新挑战,程序不在能假设独占所有的 CPU 时间、所有的内存和其他计算机资源。一个好的程序榜样是在其不再使用这些资源时对其进行释放,以使得其他程序能有机会使用这些资源。

再后来发展到多线程技术,使得在一个程序内部能拥有多个线程并行执行。一个线程的执行可以被认为是一个 CPU 在执行该程序。当一个程序运行在多线程下,就好像有多个 CPU 在同时执行该程序。

多线程比多任务更加有挑战。多线程是在同一个程序内部并行执行,因此会对相同的内存空间进行并发读写操作。这可能是在单线程程序中从来不会遇到的问题。其中的一些错误也未必会在单 CPU 机器上出现,因为两个线程从来不会得到真正的并行执行。然而,更现代的计算机伴随着多核 CPU 的出现,也就意味着不同的线程能被不同的 CPU 核得到真正意义的并行执行。

如果一个线程在读一个内存时,另一个线程正向该内存进行写操作,那进行读操作的那个线程将获得什么结果呢?是写操作之前旧的值?还是写操作成功之后的新值?或是一半新一半旧的值?或者,如果是两个线程同时写同一个内存,在操作完成后将会是什么结果呢?是第一个线程写入的值?还是第二个线程写入的值?还是两个线程写入的一个混合值?因此如没有合适的预防措施,任何结果都是可能的。而且这种行为的发生甚至不能预测,所以结果也是不确定性的。

进程和线程

进程

进程具有一个独立的执行环境。通常情况下,进程拥有一个完整的、私有的基本运行资源集合。特别地,每个进程都有自己的内存空间。

进程往往被看作是程序或应用的代名词,然而,用户看到的一个单独的应用程序实际上可能是一组相互协作的进程集合。为了便于进程之间的通信,大多数操作系统都支持进程间通信(IPC),如 pipes 和 sockets。IPC 不仅支持同一系统上的通信,也支持不同的系统。Java 虚拟机的大多数实现是单进程的。

线程

线程有时也被称为轻量级的进程。进程和线程都提供了一个执行环境,但创建一个新的线程比创建一个新的进程需要的资源要少。

线程是在进程中存在的——每个进程最少有一个线程。线程共享进程的资源,包括内存和打开的文件。这样提高了效率,但潜在的问题就是线程间的通信。

多线程的优点

1)资源利用率更好

想象一下,一个应用程序需要从本地文件系统中读取和处理文件的情景。比方说,从磁盘读取一个文件需要5秒,处理一个文件需要2秒。处理两个文件则需要:

5秒读取文件A
2秒处理文件A
5秒读取文件B
2秒处理文件B
---------------------
总共需要14秒

从磁盘中读取文件的时候,大部分的 CPU 时间用于等待磁盘去读取数据。在这段时间里,CPU 非常的空闲。它可以做一些别的事情。通过改变操作的顺序,就能够更好的使用 CPU 资源。看下面的顺序:

5秒读取文件A
5秒读取文件B + 2秒处理文件A
2秒处理文件B
---------------------
总共需要12秒

CPU 等待第一个文件被读取完。然后开始读取第二个文件。当第二文件在被读取的时候,CPU 会去处理第一个文件。记住,在等待磁盘读取文件的时候,CPU 大部分时间是空闲的。

总的说来,CPU 能够在等待 IO 的时候做一些其他的事情。这个不一定就是磁盘 IO。它也可以是网络的 IO,或者用户输入。通常情况下,网络和磁盘的 IO 比 CPU 和内存的 IO 慢的多。

2)程序设计更简单

在单线程应用程序中,如果你想编写程序手动处理上面所提到的读取和处理的顺序,你必须记录每个文件读取和处理的状态。相反,你可以启动两个线程,每个线程处理一个文件的读取和操作。线程会在等待磁盘读取文件的过程中被阻塞。在等待的时候,其他的线程能够使用 CPU 去处理已经读取完的文件。其结果就是,磁盘总是在繁忙地读取不同的文件到内存中。这会带来磁盘和 CPU 利用率的提升。而且每个线程只需要记录一个文件,因此这种方式也很容易编程实现。

3)程序响应更快

将一个单线程应用程序变成多线程应用程序的另一个常见的目的是实现一个响应更快的应用程序。设想一个服务器应用,它在某一个端口监听进来的请求。当一个请求到来时,它去处理这个请求,然后再返回去监听。

服务器的流程如下所述:

while(server is active){
    listen for request
    process request
}

如果一个请求需要占用大量的时间来处理,在这段时间内新的客户端就无法发送请求给服务端。只有服务器在监听的时候,请求才能被接收。另一种设计是,监听线程把请求传递给工作者线程,然后立刻返回去监听。而工作者线程则能够处理这个请求并发送一个回复给客户端。这种设计如下所述:

while(server is active){
    listen for request
    hand request to worker thread
}

这种方式,服务端线程迅速地返回去监听。因此,更多的客户端能够发送请求给服务端。这个服务也变得响应更快。

桌面应用也是同样如此。如果你点击一个按钮开始运行一个耗时的任务,这个线程既要执行任务又要更新窗口和按钮,那么在任务执行的过程中,这个应用程序看起来好像没有反应一样。相反,任务可以传递给工作者线程。当工作者线程在繁忙地处理任务的时候,窗口线程可以自由地响应其他用户的请求。当工作者线程完成任务的时候,它发送信号给窗口线程。窗口线程便可以更新应用程序窗口,并显示任务的结果。对用户而言,这种具有工作者线程设计的程序显得响应速度更快。

多线程的代价

1)设计更复杂

虽然有一些多线程应用程序比单线程的应用程序要简单,但其他的一般都更复杂。在多线程访问共享数据的时候,这部分代码需要特别的注意。线程之间的交互往往非常复杂。不正确的线程同步产生的错误非常难以被发现,并且重现以修复。

2)上下文切换的开销

当 CPU 从执行一个线程切换到执行另外一个线程的时候,它需要先存储当前线程的本地的数据,程序指针等,然后载入另一个线程的本地数据,程序指针等,最后才开始执行。这种切换称为“上下文切换”。CPU 会在一个上下文中执行一个线程,然后切换到另外一个上下文中执行另外一个线程。

上下文切换并不廉价。如果没有必要,应该减少上下文切换的发生。

3)增加资源消耗

线程在运行的时候需要从计算机里面得到一些资源。除了 CPU,线程还需要一些内存来维持它本地的堆栈。它也需要占用操作系统中一些资源来管理线程。

线程基础

创建线程

线程是一个独立执行的调用序列,同一个进程的线程在同一时刻共享一些系统资源(比如文件句柄等)也能访问同一个进程所创建的对象资源(内存资源)。java.lang.Thread 对象负责统计和控制这种行为。

每个程序都至少拥有一个线程——即作为 Java 虚拟机启动参数运行在主类 main 方法的线程。在 Java 虚拟机初始化过程中也可能启动其他的后台线程。这种线程的数目和种类因 JVM 的实现而异。然而所有用户级线程都是显式被构造并在主线程或者是其他用户线程中被启动。

构造方法

Thread 类中不同的构造方法接受如下参数的不同组合:

  • 一个 Runnable 对象,这种情况下,Thread.start 方法将会调用对应 Runnable 对象的 run 方法。如果没有提供 Runnable 对象,那么就会立即得到一个 Thread.run 的默认实现。

  • 一个作为线程标识名的 String 字符串,该标识在跟踪和调试过程中会非常有用,除此别无它用。

    每个线程都有一个标识名。 多个线程可以具有相同的名称。 如果创建一个线程时没有指定名称,就会为其生成一个新名称。
    除非另有说明,传递一个 null 参数构造函数或方法在这个类会导致 NullPointerException 被抛出。

  • 线程组(ThreadGroup),用来放置新创建的线程,如果提供的 ThreadGroup 不允许被访问,那么就会抛出一个 SecurityException 。

Thread 类本身就已经实现了 Runnable 接口,因此,除了提供一个用于执行的 Runnable 对象作为构造参数的办法之外,也可以创建一个 Thread 的子类,通过重写其 run 方法来达到同样的效果。然而,比较好的实践方法却是分开定义一个 Runnable 对象并用来作为构造方法的参数。将代码分散在不同的类中使得开发人员无需纠结于 Runnable 和 Thread 对象中使用的同步方法或同步块之间的内部交互。更普遍的是,这种分隔使得对操作的本身与其运行的上下文有着独立的控制。更好的是,同一个 Runnable 对象可以同时用来初始化其他的线程,也可以用于构造一些轻量化的执行框架(Executors)。另外需要提到的是通过继承 Thread 类实现线程的方式有一个缺点:使得该类无法再继承其他的类。

Thread 对象拥有一个守护(daemon)标识属性,这个属性无法在构造方法中被赋值,但是可以在线程启动之前设置该属性(通过setDaemon 方法)。当程序中所有的非守护线程都已经终止,调用 setDaemon 方法可能会导致虚拟机粗暴的终止线程并退出。isDaemon 方法能够返回该属性的值。守护状态的作用非常有限,即使是后台线程在程序退出的时候也经常需要做一些清理工作。

创建并启动线程

应用程序在创建一个线程实例时,必须提供需要在线程中运行的代码。有两种方式去做到这一点:

  • 提供一个 Runnable 对象。Runnable 对象仅包含一个 run() 方法,在这个方法中定义的代码将在会线程中执行。将 Runnable 对象传递给 Thread 类的构造函数即可,如下面这个 HelloRunnable 的例子:

    public class HelloRunnable implements Runnable {
    
        public void run() {
            System.out.println("Hello from a thread!");
        }
    
        public static void main(String args[]) {
            (new Thread(new HelloRunnable())).start();
        }
    
    }
    
  • 继承 Thread 类。Thread 类自身已实现了 Runnable 接口,但它的 run() 方法中并没有定义任何代码。应用程序可以继承与 Thread类,并复写 run() 方法。如例子 HelloThread:

    public class HelloThread extends Thread {
    
        public void run() {
            System.out.println("Hello from a thread!");
        }
    
        public static void main(String args[]) {
            (new HelloThread()).start();
        }
    
    }
    

需要注意的是,上述两个例子都需要调用 Thread.start() 方法来启动一个新的线程。 哪一种方式是我们应该使用的?相对来说,第一种更加通用,因为 Runnable 对象可以继承于其他类(Java 只支持单继承,当一个类继承与 Thread 类后,就无法继承与其他类)。第二种方法更易于在简单的应用程序中使用,但它的局限就是:你的任务类必须是 Thread 的子类。我门更加聚焦于第一种将 Runnable 任务和Thread 类分离的方式。不仅仅是因为这种方式更加灵活,更因为它更适合后面将要介绍的高级线程管理 API。 Thread 类定义了一些对线程管理十分有用的的方法。在这些方法中,有一些静态方法可以给当前线程调用,它们可以提供一些有关线程的信息,或者影响线程的状态。而其他一些方法可以由其他线程进行调用,用于管理线程和 Thread 对象。

线程启动源码

进入 start 方法看下它的源码,代码如下:

/**
 * 使该线程开始执行; Java虚拟机调用run该线程的方法。
 * 结果是两个线程同时运行:当前线程(从调用返回start方法)和另一个线程(执行其run方法)。
 * 重复启动一个线程是不合法的。特别是,一个线程可能无法一旦完成执行重新启动。
 */
public synchronized void start() {

    if (threadStatus != 0)
        throw new IllegalThreadStateException();

    group.add(this);

    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
        }
    }
}

如果 threadStatus != 0,会抛出一个异常,也就是说,只有处于 threadStatus = 0(线程状态为 NEW)的时候才能启动线程。还是看下代码示例:

public class HelloThread extends Thread {

    @Override
    public void run() {
        try {
            Thread.sleep(1000); // 休眠1秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Hello Thread");
    }

    public static void main(String[] args) {
        Thread thread = new HelloThread();
        thread.start(); 
        thread.start(); 
    }
}

输出结果:

Exception in thread "main" java.lang.IllegalThreadStateException
	at java.lang.Thread.start(Thread.java:708)
	at com.spurs.concurrent.thread.HelloThread.main(HelloThread.java:22)
Hello Thread
执行run方法的线程为:Thread-0

因为第二次调用 start 时,第一次 start 还未结束,在 run 里面休眠,所以会抛出异常。

Runnable 接口实现

Runnable 接口可以被看作对任务进行的抽象,任务的处理逻辑就体现在 run 方法之中。Thread 类实际上是 Runnable 接口的一个实现类,其对 Runnable 接口的实现如下:

@Override
public void run() {
    // 如果构造Thread时传递了Runnable,则会执行Runnable的run方法
    if (target != null) {
        target.run();
    }
   // 否则需要重写Thread类的run方法
} 

Thread 类的 run 方法的这种处理逻辑决定了创建线程的两种方式:一种是在 Thread 子类的 run 方法中直接实现任务处理逻辑,另一种是在一个 Runnable 实例中实现任务处理逻辑,该逻辑由 Thread 类的 run 方法负责调用。

常见错误:调用 run() 方法而非 start() 方法

创建并运行一个线程所犯的常见错误是调用线程的 run() 方法而非 start() 方法,如下所示:

PrimeRun p = new PrimeRun(143);
new Thread(p).run();

起初你并不会感觉到有什么不妥,因为 run() 方法的确如你所愿的被调用了。但是,事实上 run() 方法并非是由刚创建的新线程所执行的,而是被创建新线程的当前线程所执行了。也就是被执行上面两行代码的线程所执行的。想要让创建的新线程执行 run() 方法,必须调用新线程的 start 方法。

使用 Sleep 方法暂停一个线程

使用 Thread.sleep() 方法可以暂停当前线程一段时间。这是一种使处理器时间可以被其他线程或者运用程序使用的有效方式。sleep() 方法还可以用于调整线程执行节奏和等待其他有执行时间需求的线程。

在 Thread 中有两个不同的 sleep() 方法,一个使用毫秒表示休眠的时间,而另一个是用纳秒。由于操作系统的限制休眠时间并不能保证十分精确。休眠周期可以被 interrupts 所终止,我们将在后面看到这样的例子。不管在任何情况下,我们都不应该假定调用了 sleep() 方法就可以将一个线程暂停一个十分精确的时间周期。

SleepMessages 程序为我们展示了使用 sleep() 方法每四秒打印一个信息的例子:

public class SleepMessages {
    public static void main(String args[])
        throws InterruptedException {
        String importantInfo[] = {
            "Mares eat oats",
            "Does eat oats",
            "Little lambs eat ivy",
            "A kid will eat ivy too"
        };

        for (int i = 0;
             i < importantInfo.length;
             i++) {
            //Pause for 4 seconds
            Thread.sleep(4000);
            //Print a message
            System.out.println(importantInfo[i]);
        }
    }
}

main() 方法声明了它有可能抛出 InterruptedException。当其他线程中断当前线程时,sleep() 方法就会抛出该异常。由于这个应用程序并没有定义其他的线程,所以并不用关心如何处理该异常。

中断

中断是给线程的一个指示,告诉它应该停止正在做的事并去做其他事情。一个线程究竟要怎么响应中断请求取决于程序员,不过让其终止是很普遍的做法。

一个线程通过调用对被中断线程的 Thread 对象的 interrupt() 方法,发送中断信号。为了让中断机制正常工作,被中断的线程必须支持它自己的中断(即要自己处理中断)。

中断支持

线程如何支持自身的中断?这取决于它当前正在做什么。如果线程正在频繁调用会抛 InterruptedException 异常的方法,在捕获异常之后,它只是从 run() 方法中返回。例如,假设在 SleepMessages 的例子中,关键的消息循环在线程的 Runnable 对象的 run 方法中,代码可能会被修改成下面这样以支持中断:

for (int i = 0; i < importantInfo.length; i++) {
    // Pause for 4 seconds
    try {
        Thread.sleep(4000);
    } catch (InterruptedException e) {
        // We've been interrupted: no more messages.
        return;
    }
    // Print a message
    System.out.println(importantInfo[i]);
}

许多会抛 InterruptedException 异常的方法(如 sleep()),被设计成接收到中断后取消它们当前的操作,并在立即返回。

如果一个线程长时间运行而不调用会抛 InterruptedException 异常的方法会怎样? 那它必须周期性地调用 Thread.interrupted() 方法,该方法在接收到中断请求后返回 true。例如:

for (int i = 0; i < inputs.length; i++) {
    heavyCrunch(inputs[i]);
    if (Thread.interrupted()) {
        // We've been interrupted: no more crunching.
        return;
    }
}

在这个简单的例子中,代码只是检测中断,并在收到中断后退出线程。在更复杂的应用中,抛出一个 InterruptedException 异常可能更有意义。

if (Thread.interrupted()) {
    throw new InterruptedException();
}

这使得中断处理代码能集中在 catch 语句中。

中断状态标记

中断机制通过使用称为中断状态的内部标记来实现。调用 Thread.interrupt() 设置这个标记。当线程通过调用静态方法Thread.interrupted() 检测中断时,中断状态会被清除。非静态的 isInterrupted() 方法被线程用来检测其他线程的中断状态,不改变中断状态标记。

按照惯例,任何通过抛出一个 InterruptedException 异常退出的方法,当抛该异常时会清除中断状态。不过,通过其他的线程调用 interrupt() 方法,中断状态总是有可能会立即被重新设置。

Joins

Join() 方法可以让一个线程等待另一个线程执行完成。若 t 是一个正在执行的 Thread 对象,

t.join();

将会使当前线程暂停执行并等待 t 执行完成。重载的 join() 方法可以让开发者自定义等待周期。然而,和 sleep() 方法一样 join() 方法依赖于操作系统的时间处理机制,你不能假定 join() 方法将会精确的等待你所定义的时长。

如同 sleep() 方法,join() 方法响应中断并在中断时抛出 InterruptedException。

优先级

Java 虚拟机为了实现跨平台(不同的硬件平台和各种操作系统)的特性,Java 语言在线程调度与调度公平性上未作出任何的承诺,甚至都不会严格保证线程会被执行。但是 Java 线程却支持优先级的方法,这些方法会影响线程的调度:

  • 每个线程都有一个优先级,分布在 Thread.MIN_PRIORITY 和 Thread.MAX_PRIORITY 之间(分别为1 和 10)
  • 默认情况下,新创建的线程都拥有和创建它的线程相同的优先级。main 方法所关联的初始化线程拥有一个默认的优先级,这个优先级是 Thread.NORM_PRIORITY (5).
  • 线程的当前优先级可以通过 getPriority 方法获得。
  • 线程的优先级可以通过 setPriority 方法来动态的修改,一个线程的最高优先级由其所在的线程组限定。

当可运行的线程数超过了可用的 CPU 数目的时候,线程调度器更偏向于去执行那些拥有更高优先级的线程。具体的策略因平台而异。比如有些 Java 虚拟机实现总是选择当前优先级最高的线程执行。有些虚拟机实现将 Java 中的十个优先级映射到系统所支持的更小范围的优先级上,因此,拥有不同优先级的线程可能最终被同等对待。还有些虚拟机会使用老化策略(随着时间的增长,线程的优先级逐渐升高)动态调整线程优先级,另一些虚拟机实现的调度策略会确保低优先级的线程最终还是能够有机会运行。设置线程优先级可以影响在同一台机器上运行的程序之间的调度结果,但是这不是必须的。

线程优先级对语义和正确性没有任何的影响。特别是,优先级管理不能用来代替锁机制。优先级仅仅是用来表明哪些线程是重要紧急的,当存在很多线程在激励进行 CPU 资源竞争的情况下,线程的优先级标识将会显得非常有用。

线程组

每一个线程都是一个线程组中的成员。默认情况下,新建线程和创建它的线程属于同一个线程组。线程组是以树状分布的。当创建一个新的线程组,这个线程组成为当前线程组的子组。getThreadGroup 方法会返回当前线程所属的线程组,对应地,ThreadGroup 类也有方法可以得到哪些线程目前属于这个线程组,比如 enumerate 方法。

ThreadGroup 类存在的一个目的是支持安全策略来动态的限制对该组的线程操作。比如对不属于同一组的线程调用 interrupt 是不合法的。这是为避免某些问题(比如,一个 applet 线程尝试杀掉主屏幕的刷新线程)所采取的措施。ThreadGroup 也可以为该组所有线程设置一个最大的线程优先级。

线程组往往不会直接在程序中被使用。在大多数的应用中,如果仅仅是为在程序中跟踪线程对象的分组,那么普通的集合类(比如java.util.Vector)应是更好的选择。

在 ThreadGroup 类为数不多的几个方法中,uncaughtException 方法却是非常有用的,当线程组中的某个线程因抛出未检测的异常(比如空指针异常 NullPointerException)而中断的时候,调用这个方法可以打印出线程的调用栈信息。

线程的生命周期

Java 语言中, 多线程是由 Thread 的核心概念驱动的。因为多线程中的每一个线程都相互独立,有着自己的生命周期和状态转换。

关于线程的生命周期,可以看一下 java.lang.Thread.State 这个类,它是线程的内部枚举类,定义了线程的各种状态,并且注释也很清晰。

public enum State {
    /**
     * 线程还未启动的线程的状态
     */
    NEW,

    /**
     * 线程可运行线程状态
     * 在可运行状态的线程在Java虚拟机正在执行,但它可以从操作系统,诸如处理器在等待其他资源
     */
    RUNNABLE,

    /**
     * 一个线程的线程状态阻塞等待监视器锁
     * 处于阻塞状态的线程正在等待监视器锁进入同步块/方法或调用后重新进入同步块/方法Object.wait
     */
    BLOCKED,

    /**
     * 线程等待线程状态
     * 线程处于等待状态,因为调用下列方法之一:
	 * Object.wait不带超时
	 * Thread.join没有超时
	 * LockSupport.park
	 * 在等待状态的线程正在等待另一个线程来执行特定动作。 
	 * 例如,一个已调用的Object.wait()的对象上正在等待另一个线程在该对象上调用Object.notify()或Object.notifyAll()。 
	 * 已调用的Thread.join(A线)正在等待指定线程终止
     */
    WAITING,

    /**
     * 线程状态与指定的等待时间等待的线程
     * 线程是在定时等待状态由于调用与指定正等待时间以下方法之一:
	 * Thread.sleep
	 * Object.wait与超时
	 * Thread.join与超时
	 * LockSupport.parkNanos
	 * LockSupport.parkUntil
     */
    TIMED_WAITING,

    /**
     * 线程终止的线程状态
     * 线程执行完毕
     */
    TERMINATED;
}

线程生命周期中各状态的注释完毕了,下面我们再来看看各状态之间的流转:

image-20200701100505300

NEW:新建状态,线程还未开始。

RUNNABLE:表示当前线程正在运行中。处于 RUNNABLE 状态的线程在 Java 虚拟机中运行,也有可能在等待其他系统资源(比如 I/O)。

BLOCKED:阻塞状态。处于 BLOCKED 状态的线程正等待锁的释放以进入同步区。

WAITING:等待状态。处于等待状态的线程变成 RUNNABLE 状态需要其他线程唤醒。

TIMED_WAITING:超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。

TERMINATED:终止状态。此时线程已执行完毕。

参考资料

Jenkov.com Java Concurrency and Multithreading Tutorial

Doug Lea 并发编程文章:http://ifeve.com/doug-lea/

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值