Wetts's blog

Stay Hungry, Stay Foolish.

0%

Java多线程编程实战指南-第1章-Java 多线程实战基础

读书笔记 - Java 多线程编程实战指南

第一章 Java 多线程实战基础

进程与线程

  • 进程(Process):代表运行中的程序。一个运行的Java程序就是一个进程。
  • 线程(Thread):进程中可独立执行的字任务。

JVM启动的时候会创建一个 main 线程,该线程负责执行Java程序的入口方法(main方法)。

-

Java 中线程的分类

  • 守护线程(Daemon Thread)
  • 守护线程不会影响 JVM 的正常停止,即应用程序中有守护线程在运行也不影响 JVM 的长长停止。
  • 守护线程常用于执行一些重要性不是很高的任务,例如用于监视其他线程的运行情况。
  • 用户线程(User Thread)
  • 用户线程会阻止 JVM 的正常停止,即JVM正常停止前应用程序的所有用户线程必须先停止完毕,否则 JVM 无法停止。

-

Java 中线程的创建与运行

Java 中创建一个线程就是创建一个 Thread 类的实例。
创建一个 Thread 实例与创建其他类的实例不同之处是,JVM 会为一个 Thread 实例分配两个调用栈(Call Stack)所需的空间。这两个调用栈作用分别是:

  • 一个用于跟踪 Java 代码间的调用关系
  • 另一个用于跟踪 Java 代码对本地代码(即 Native 代码,通常是 C 代码)的调用关系

一个 Thread 实例通常对应两个线程:

  • 一个是 JVM 中的线程(或称之为 Java 线程)
  • 另一个是与 JVM 中的线程相对应的依赖于 JVM 宿主机操作系统的本地(Native)线程

-

线程的状态

Java 线程的状态可以通过调用相应 Thread 实例的 getState 方法获取。

该方法的返回值类型 Thread.State 是一个枚举类型(Enum)。包含以下几种:

  • NEW:一个刚创建而未启动的线程处于该状态。由于一个线程实例只能够被启动一次,因此一个线程只可能有一次处于该状态
  • RUNNABLE:该状态可以看成是一个复合的状态。它包括两个子状态:
  • READY:处于该状态的线程可以被 JVM 的线程调度器(Scheduler)进行调度而使之处于 RUNNING 状态。
  • RUNNING:处于该状态的线程正在运行,即相应线程对象的 run 方法中的代码所对应的指令正在由 CPU 执行。当 Thread 实例的 yield 方法被调用时或者由于线程调度器的原因,相应的线程的状态会由 RUNNING 转换为 READY。
  • BLOCKED:一个线程发起一个阻塞式 I/O(BLOCKING I/O)操作后,或者试图去获得一个由其他线程持有的锁时,相应的线程会处于该状态。处于该状态的线程并不会占用 CPU 资源。当相应的 I/O 操作完成后,或者相应的锁被其他线程释放后,该线程的状态又可以转换为 RUNNABLE。
  • WAITING:一个线程执行了某些方法调用之后就会处于这种无限等待其他线程执行特定操作的状态。这些方法包括:Object.wait()、Thread.join() 和 LockSupport.park()。能够使相应线程从 WAITING 转换到 RUNNABLE 的相应方法包括:Object.notify()、Object.notifyAll() 和 LockSupport.unpark(thread)。
  • TIMED_WAITING:该状态和 WAITING类似,差别在于处于该状态的线程并非无限等待其他线程执行特定操作,而是出于带有时间限制的等待状态。当其他线程没有在指定时间内执行该线程所期望的特定操作时,该线程的状态自动转换为 RUNNABLE。
  • TERMINATED:已经执行结束的线程处于该状态。由于一个线程实例只能够被启动一次,因此一个线程也只可能有一次处于该状态。Thread 实例的 run 方法正常返回或者由于抛出异常而提前终止都会导致相应线程处于该状态。

-

线程上下文切换

  • 一个线程的状态从 RUNNABLE 状态转换为 BLOCKED、WAITING 和 TIMED_WAITING 这几个状态中的任何一个状态都意味着上下文切换(Context Switch)的产生。
  • 多线程环境中,当一个线程的状态由 RUNNABLE 转换为非 RUNNABLE(BLOCKED、WAITING 或者 TIMED_WAITING)时,相应线程的上下文信息(即所谓的Context,包括 CPU 的寄存器和程序计数器在某一时间点的内容等)需要被保存,以便相应线程稍后再次进入 RUNNABLE 状态时能够在之前的执行进度的基础上继续前进。而一个线程的状态由非 RUNNABLE 状态进入 RUNNABLE 状态时可能涉及恢复之前保存的线程上下文信息并在此基础上前进。
  • 上下文切换回带来额外的开销,这包括保存和恢复线程上下文信息的开销、对线程进行调度的 CPU 时间开销以及 CPU 缓存内容失效(即 CPU 的 L1 Cache、L2 Cache 等)的开销。

-

线程的原子性、内存可见性和重排序(synchronied 和 volatile)

原子性(Atomic)
  • 原子操作指相应的操作时单一不可分割的操作。例如,对 int 型变量 count 执行 count++ 的操作就不是原子操作。这是因为 count++ 实际上可以分解为 3 个操作:
  1. 读取变量 counter 的当前值;
  2. 拿 counter 的当前值和 1 做加法运算;
  3. 将 counter 的当前值增加 1 后的值赋值给 counter 变量。

在多线程环境中,非原子操作可能会受到其他线程的干扰。
比如,上述例子如果没有对相应的代码进行同步(Synchronization)处理,则可能出现执行第 2 个操作的时候 counter 的值已经被其他线程修改了,因此这一步的操作所使用 counter 变量的“当前值”其实已经是过期的了。
synchronized 关键字可以实现操作的原子性。 其本质是通过该关键字包括的临界区(Critical Section)的排他性保证在任何一个时刻只有一个线程能够执行临界区中的代码。
但是,synchronized 关键字所起的另外一个作用-保证内存的可见性。

内存可见性(Memory Visibility)
  • CPU 在执行代码的时候,为了减少变量访问的时间消耗可能将代码中访问的变量值缓存到该 CPU 的缓存区(如 L1 Cache、L2 Cache 等)中。因此相应代码再次访问某个变量时,相应的值可能是从 CPU 缓存区而不是主内存中读取的。同样的,代码对这些被缓存过的变量值的修改也可能仅是被写入 CPU 缓存区,而没有被写回主内存。由于每个 CPU 都有自己的缓存区,因此一个 CPU 缓存区中的内容对于其他 CPU 而言是不可见的。这就导致了在其他 CPU 上运行的其他线程可能无法“看到”该线程对某个变量值所做的更改。这就是所谓的内存可见性。
  • synchronized 关键字的另外一个作用就是它保证了一个线程执行临界区中的代码时所修改的变量值对于稍后执行该临界区中的代码的线程来说时可见的。
  • 而 volatile 关键字也能够保证内存可见性。即一个线程对一个采用 volatile 关键字修饰的变量值的更改对于其他访问该变量的线程而言总是可见的。
  • volatile 关键字实现内存可见性的核心机制是当一个线程修改了一个 volatile 修饰的变量值时,该值会被写入主内存(即 RAM)而不仅仅是当前线程所在的 CPU 的缓存区,而其他 CPU 的缓存区中储存的该变量值也会因此而失效(从而得以更新为主内存中该变量的对应值)。
  • volatile 关键字的另外一个作用是它禁止了指令重排序。
指令重排序(Re-order)
  • 编译器和 CPU 为了提高指令的执行效率可能会进行指令重排序,这使得代码的实际执行方式可能不是按照我们所认为的方式进行的。例如下面的实例变量初始化语句:

private SomeClass someObject = new SomeClass();
上述语句所做的事情非常简单:

  1. 创建类 SomeClass 的实例;
  2. 将类 SomeClass 的实例的应用赋值给变量 someObject。

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

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

因此,当其他线程访问 someObject 变量的值时,其得到的仅是指向存储 SomeClass 实例的内存空间的引用而已,而该内存空间相应的 SomeClass 实例的初始化可能尚未完成。

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

-

线程的优势和风险

多线程编程具有一下优势:
  • 提高系统的吞吐率(Throughput)。 多线程编程使得一个进程中可用有多个并发(Concurrent,即同时进行的)的操作。例如,当一个线程因为 I/O 操作而处于等待时,其他线程仍然可用执行其操作。
  • 提高响应性(Responsiveness)。 使用多线程编程的情况下,对于 GUI 软件(如桌面应用程序)而言,一个慢的操作(比如从服务器上下载一个大的文件)并不会导致软件界面出现被“冻住”的现象而无法响应用户的其他操作;对于 Web 应用程序而言,一个请求的处理慢了并不会影响其他请求的处理。
  • 充分利用多核(Multicore)CPU 资源。
  • 最小化对系统资源的使用。 一个进程中的多个线程可用共享其所在进程所申请的资源(如内存空间),因此使用多个线程相比于使用多个进程进行编程来说,节约了对系统资源的使用。
  • 简化程序的结构。
多线程编程的问题和风险:
  • 线程安全(Thread Safe)问题。 多个线程共享数据的时候,如果没有采取相应的并发访问控制措施,那么就可能产生数据一致性问题,如读取脏数据(过期的数据)、丢失更新(某些线程所作的更新被其他线程所做的更新覆盖)等。
  • 线程的生命特征(Thread Liveness)问题。 一个线程从其创建到运行结束的整个生命周期会经历若干个状态。从单个线程的角度来看,RUNNABLE 状态是我们所期望的状态。但实际上,代码编写不适当可能导致某些数据一直等待其他线程释放锁的状态(BLOCKED),即产生了死锁(Dead Lock)。
  • 上下文切换。 由于 CPU 资源的稀缺,上下文切换可用看作是多线程编程的必然副产物,它增加了系统的消耗,不利于系统的吞吐率。
  • 可靠性。 线程是进程的一个组件,它总是存在于特定的进程中的,如果这个进程由于某种原因意外提前终止了,比如某个 Java 进程由于内存泄漏导致 JVM 崩溃而意外终止了,那么该进程中所有的线程也就随之无法继续运行。因此,从提高软件的可靠性角度来说,某些情况下可能考虑多进程多线程的编程方式,而非简单的单进程多线程方式。