Java多线程编程基础

线程与进程

进程(Process)代表运行中的程序。一个运行中的Java程序就是一个进程。

从操作系统的角度来说,线程(Thread)是进程中可以独立运行的子任务。一个进程中可以包含多个线程,同一个进程中的多个线程共享该进程所申请到的资源,如内存空间和文件句柄等。

从JVM的角度来看,线程是进程中的一个组件(Component),它可以看作执行Java代码的最小单位。Java程序中的任何一段代码总是执行在一个确定的线程中。JVM启动时默认会创建一个main线程,该线程负责执行Java程序的入口方法(main方法)。

Java中的线程可以分为用户线程(UserThread)和守护线程(DaemonThread)。用户线程会阻止JVM的正常停止(通过System.exit调用停止JVM),即JVM在正常停止前应用程序中的用户线程必须全部停止完毕,否则JVM无法停止成功(在Linux中可以使用kill命令强制停止进程)。守护线程不会影响JVM的正常停止,即应用程序中有守护线程在运行也不会影响JVM的正常停止。一般守护线程用来监视其他线程的执行情况。

线程的创建与运行

在Java语言中个,一个线程就是一个java.lang.Thread类的实例。因此在Java语言中创建一个线程就是创建一个java.lang.Thread类的实例,当然也有内存的分配。JVM在创建Thread类的实例时与创建其他类的实例不同,创建前者时,JVM会为一个Thread类实例分配两个调用栈(CallStack),一个用于跟踪Java代码间的调用关系,一个用于跟踪Java代码对本地代码(即操作系统中的非Java代码,一般为C/C++)的调用关系。

一个Threah实例通常对应两个线程。一个是JVM中的线程(Java线程),一个是JVM中的线程相对应依赖于所在的操作系统中的线程。

启动一个线程只需要调用相应Thread实例的start方法即可,线程启动后,该线程被JVM的线程调度器调度到运行时,而线程的run方法则由JVM自动调用。

在Java中Thread类本身实现了java.lang.Runnable接口,所有可用通过实现该接口创建线程。

在java中线程的创建必须拥有父线程,即线程不能凭空创建,必须依赖于一个已经存在的线程。新创建的线程被称为子线程,而子线程所依赖的线程一般被称为父线程。子线程一般与父线程保持相同的类型,即如果父线程是一个守护线程则子线程也是一个守护线程,当然可以在创建子线程时,通过 setDaemon() 方法修改子线程的属性。

线程的状态与上下文切换

在Java中一个线程从创建、启动带起运行结束的整个生命周期会经历若干个状态。
Thread切换图
Java线程的状态可以通过相应Thread实例的 getState() 方法获取。该方法的返回类型Thread.State 是一个枚举类型。Thread.State 所定义的状态包括以下几种:


  • NEW: 一个刚创建但并没有启动的线程处于该状态。因为一个线程只能被创建一次,所以一个线程只能有一次处于该状态的机会。

  • RUNNABLE:该状态包括READY 和 RUNNING 两种状态,READY状态表示一个线程被创建后处于线程池中,没有被JVM线程调度器调用,但具有可运行的条件,如果线程调度器调用该线程,则线程进入 RUNNING 状态。RUNNING 状态表示一个线程正处于运行阶段,即该线程 run() 方法中代码正被 CPU 处理执行。但当Thread实例的 yield() 方法被调用或者由于线程调度器的原因,相应线程的状态会由 RUNNING 变为 READY。

  • BLOCKED: 一个线程发起一个阻塞式I/O时(例如读写操作),或者试图去获取一个其他线程所持有的锁时,相应的线程就会出去该状态。处于该状态的线程并不会占用CPU资源,当相应的I/O操作完成后,或者相应的锁被其他线程释放后,该线程的状态又可以转换为 RUNNING。

  • WAITING: 一个线程实例执行了某些方法之后就会处于这种无限等待其他线程执行特定操作的状态。这些方法包括:Obeject.wait()Thread.join()LockSupport.park()。能够使相应线程从WAITING转换到RUNNABLE的相应方法包括: Object.notify()Object.notifyAll()LockSupport.unpark(thread)

  • TIMED_WAITING:该状态和 WAITING 类似,区别在于处于该状态的线程并不会无限等待其他线程执行特定操作,而是处于带时间限制的等待状态。当其他线程没有在指定的时间内执行该线程期望执行的特定操作时,该线程自动转换为RUNNANLE.

  • TERMINATED:已经执行结束的线程处于该状态。由于一个线程实例只能被启动一次,因此一个线程也只可能有一次处于该状态。Thread实例的 run() 正常执行完成或者由于抛出异常而提前终止都会是线程处于该状态。

在多线程环境中,一个线程从RUNNABLE转换到非RUNNABLE状态,相应的线程上下文信息(即线程序和数据,包括CPU的寄存器和程序计数器在某一时间点的内容等)需要保存,以便在相应线程再次进入RUNNABLE状态后能够在之前的执行进度的基础上继续执行。通常将线程从上下文信息的保存到恢复的过程称为上下文切换。上下文切换回涉及到时间的消耗。所以并不是线程越多程序的执行速度越快。

原子性、内存可见性和重排序————重新认识synchronized和volatile

原子(Atomic)操作是指相应的操作是单一不可分割的操作。比如:对int型的变量count执行count++操作就不是原子操作。这是因为count++实际上可以分解为3个操作:

1. 读取变量count的当前值
2. 拿count当前值和1做加法运算
3. 将count的当前值增加1后的值赋值给count变量

在多线程环境中,非原子性操作可能会受到其他线程的干扰。比如上述的例子如果没有对应的代码进行同步(synchronization)处理,则有可能出现在执行第2步操作时count的值已经被其他线程修改,因此这一步所操作的count变量的“当前值”其实已经过期了。当然,synchronzed关键字可以帮助我们实现操作的原子性,以避免这种线程间的干扰情况。

synchronzed关键字能够实现操作的原子性,其本质是通过该关键字所包含的临界区(CriticalSection)的排他性保证在任何一个时刻只有一个线程能够执行临界区中的代码,这使得临界区中的代码代表了一个原子操作。synchronzed关键字的另一个作用是保证内存的可见性(MemoryVisibility).

内存可见性是指CPU在执行代码时,为了减少变量访问的时间消耗,会将代码执行时可能会访问到的变量的值缓存到该CPU的缓存区内。因此相应的代码再次访问该变量时,相应的值可能会从缓存区而不是主存中读取。同样的,代码对这些被缓存过的变量的值得修改也可能只是被写入缓存区,而没有被写回主存。由于每个CPU都有自己的缓存区,因此一个CPU缓存区中的内容对其他缓存区是不可见的。这就导致在其他CPU上运行的其他线程可能无法“看到”该线程对某个变量所做的修改。

synchronzed关键字的另一个作用就是保证一个线程执行临界区中的代码时所修改的变量值对于稍后执行该临界区中的代码的线程来说是可见的。这样就保证多线程代码的正确性。

volatile关键字也能够保证内存的可见性。即一个线程对一个采用volatile关键字修饰的变量的值得更改对于其他访问该变量的线程而言总是可见的。也就是说,其他线程不会读到一个过期的变量值。因此,用人将volatile关键字和synchronzed关键字所代表的内部锁做比较,将其称为轻量级锁。意识不然,volatile关注字只能保证内存可见性,不能像synchronzed关键字所代表的内部锁那样保证操作的原子性。

volatile关键字实现内存可见性的核心机制是当一个线程修改了一个volatile修饰的变量时,该值会被写入主存,而不仅仅是当前线程所在的CPU缓存区,而其他CPU缓存区中存储的变量的值也会因此而失效(从而得以更新为主存中该变量的相应值)。这就保证了其他线程访问该volatile修饰的变量时总是可以获取该变量的最新值。

volatile的另一个作用是禁止指令重排序(Re-order)。编译器和CPU为了提高指令的执行效率可能会进行指令重排序,这使得代码的实际执行方式不是按照我们认为的方式进行。例如下面的实例变量初始化语句:

private SomeClass someObject = new SomeClass();

上述语句所做的事情非常简单:

  1. 创建SomeClass的实例
  2. 将SomeClass实例的引用赋值给变量 someObject

但由于指令重排序的缘故,这段代码的实际执行顺序可能是:

  1. 分配一段用于存储SomeClass实例的内存空间
  2. 将对该内存空间的引用赋值给变量 someObject
  3. 创建SomeClass实例

因此,当其他线程访问 someObject 变量的值时,其得到的仅是一段存储SomeClass实例的内存空间的引用而已,而该内存空间对应的SomeClass实例的初始化可能尚未完成,这就可能导致一些意想不到的结果。而禁止指令重排序可以使得上述代码按照我们期望的顺序执行。

禁止指令重排序虽然导致编译器和CPU无法对一些指令进行肯能的优化,但是他某种程度上使代码的执行看起来更符合我们的期望。

线程的优势和风险

多线程编程具有以下优势:

  • 提高系统的吞吐率(Throughput):多线程编程使得一个进程中可以有多个并发(Concurrent)的操作。例如,当一个线程因为 I/O 操作处于等待时,其他线程仍然可以执行操作。
  • 提高响应性(Responsiveness):使用多线程编程的情况下,对于GUI软件而言,一个慢的操作(比如从服务器下载一个大的文件)并不会导致软件界面出现被“冻住”的现象而无法响应用户的其他操作;对于web应用而言,一个请求的处理慢并不会影响其他请求的处理慢。
  • 充分利用多核CPU资源:实施恰当的多线程编程有助于充分利用设备的多核CPU资源,从而避免资源的浪费。
  • 简化程序结构:线程可以简化复杂应用程序的结构。

多线程编程存在的风险:

  • 线程安全问题:多线程共享数据时,如果没有采用相应的并发访问控制措施,那么就可能产生数据一致性问题,如读取脏数据(数据过期)、丢失更新(某些线程所做的更新被其他线程所做的更新覆盖)等。
  • 线程的生命特征问题:一个线程从其创建到运行结束的整个生命周期会经历若干个状态,从单个线程的角度来看,RUNNABLE状态是我们所期望的状态。但实际上,代码编写不适当可能导致某些线程一直处于等待其他线程释放锁的状态(BLOCKED状态),即产生死锁(Dead Lock)。
  • 上下文切换:由于CPU资源的有限性,上下文切换可以看作是多线程编程的必然副产物,它增加了系统的消耗,不利于系统的吞吐率。
  • 可靠性:因为线程是进程的一个组件,它总是存在于特定的进程中,如果这个进程由于某种意义意外提前终止了,那么该进程中的所有线程也就随之无法继续执行。

多线程编程常用术语

  • 任务(Task)

    任务与线程并非一一对应,通常一个线程可以执行多个任务。任务是一个相对的概念。一个文件可以看作一个任务,一个文件中的多个记录可以看作一个任务,多个文件也可以看作一个任务。

  • 并发(Concurrent)

    表示多个任务在同一时间段内被执行。这些任务并不是顺序执行的,而往往是以交替的方式被执行。

  • 并行(Parallel)

    表示多个任务在同一时刻被执行

  • 客户端线程(Client Thread)

    从面相对象的编程角度来看,一个类总是对外提供某些服务(这也是这个类存在的意义)。其他类是通过调用该类的相应方法来使用这些服务的。因此,一个类的方法的调用方代码就被称为该类的客户端代码。相应地,执行客户端代码的线程被称为客户端线程。因此,客户端代码也是一个相对概念,某个类的客户端线程随着执行该方法调用方代码的线程的变化而变化。

  • 工作者线程(Worker Thread)

    工作者线程是相对于客户端线程而言的。它表示客户端线程之外的用于特定用途的其他线程。

  • 线程安全(Thread Safe)

    一个操作共享数据的代码能够保证在同一时间内被多个线程执行仍然保持其正确性,就被称为是线程安全的。