Java并发编程 - 基础概念

并发编程是Java语言的重要特点之一,在Java平台上提供了许多基本的并发功能来辅助开发多线程应用程序。然而,这些相对底层的并发功能与上层应用程序的并发语义之间并不存在一种简单而直观的映射关系。因此,如何在Java并发应用程序中正确且搞笑的使用这些功能就成了Java开发人员的关注重点。

很多时候我们发现并没有接触什么实际的多线程开发场景,大部分都是写一些业务代码而已。这是因为现在很多框架已经封装了多线程的实现与调度,从而让开发人员关注在业务逻辑,简化开发成本。

今天我们主要来了解一些多线程相关的知识,了解线程的前因后果,它的优势和伴随的风险,并且简单介绍我们常见的多线程使用。

1. 进程与线程

进程

说起进程的由来,我们需要从操作系统的发展历史谈起。

也许在今天,我们无法想象在很多年以前计算机是什么样子。我们现在可以用计算机来做很多事情:办公、娱乐、上网,但是在计算机刚出现的时候,是为了解决数学计算的问题,因为很多大量的计算通过人力去完成是很耗时间和人力成本的。在最初的时候,计算机只能接受一些特定的指令,用户输入一个指令,计算机就做一个操作。当用户在思考或者输入数据时,计算机就在等待。显然这样效率和很低下,因为很多时候,计算机处于等待用户输入的状态。

那么能不能把一系列需要操作的指令预先写下来,形成一个清单,然后一次性交给计算机,计算机不断地去读取指令来进行相应的操作?就这样,批处理操作系统诞生了。用户可以将需要执行的多个程序写在磁带上,然后交由计算机去读取并逐个地执行这些程序,并将输出结果写到另一个磁带上。

虽然批处理操作系统的诞生极大地提高了任务处理的便捷性,但是仍然存在一个很大的问题:

假如有两个任务A和B,任务A在执行到一半的过程中,需要读取大量的数据输入(I/O操作),而此时CPU只能静静地等待任务A读取完数据才能继续执行,这样就白白浪费了CPU资源。人们于是想,能否在任务A读取数据的过程中,让任务B去执行,当任务A读取完数据之后,让任务B暂停,然后让任务A继续执行?

但是这样就有一个问题,原来每次都是一个程序在计算机里面运行,也就说内存中始终只有一个程序的运行数据。而如果想要任务A执行I/O操作的时候,让任务B去执行,必然内存中要装入多个程序,那么如何处理呢?多个程序使用的数据如何进行辨别呢?并且当一个程序运行暂停后,后面如何恢复到它之前执行的状态呢?

这个时候人们就发明了进程,用进程来对应一个程序,每个进程对应一定的内存地址空间,并且只能使用它自己的内存空间,各个进程间互不干扰。并且进程保存了程序每个时刻的运行状态,这样就为进程切换提供了可能。当进程暂时时,它会保存当前进程的状态(比如进程标识、进程的使用的资源等),在下一次重新切换回来时,便根据之前保存的状态进行恢复,然后继续执行。

这就是并发,能够让操作系统从宏观上看起来同一个时间段有多个任务在执行。换句话说,进程让操作系统的并发成为了可能。

注意,虽然并发从宏观上看有多个任务在执行,但是事实上,任一个具体的时刻,只有一个任务在占用CPU资源(当然是对于单核CPU来说的)。

线程

在出现了进程之后,操作系统的性能得到了大大的提升。虽然进程的出现解决了操作系统的并发问题,但是人们仍然不满足,人们逐渐对实时性有了要求。因为一个进程在一个时间段内只能做一件事情,如果一个进程有多个子任务,只能逐个地去执行这些子任务。比如对于一个监控系统来说,它不仅要把图像数据显示在画面上,还要与服务端进行通信获取图像数据,还要处理人们的交互操作。如果某一个时刻该系统正在与服务器通信获取图像数据,而用户又在监控系统上点击了某个按钮,那么该系统就要等待获取完图像数据之后才能处理用户的操作,如果获取图像数据需要耗费10s,那么用户就只有一直在等待。显然,对于这样的系统,人们是无法满足的。

那么可不可以将这些子任务分开执行呢?即在系统获取图像数据的同时,如果用户点击了某个按钮,则会暂停获取图像数据,而先去响应用户的操作(因为用户的操作往往执行时间很短),在处理完用户操作之后,再继续获取图像数据。人们就发明了线程,让一个线程去执行一个子任务,这样一个进程就包括了多个线程,每个线程负责一个独立的子任务,这样在用户点击按钮的时候,就可以暂停获取图像数据的线程,让UI线程响应用户的操作,响应完之后再切换回来,让获取图像的线程得到CPU资源。从而让用户感觉系统是同时在做多件事情的,满足了用户对实时性的要求。

换句话说,进程让操作系统的并发性成为可能,而线程让进程的内部并发成为可能。

但是要注意,一个进程虽然包括多个线程,但是这些线程是共同享有进程占有的资源和地址空间的。进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位。

并发

由于多个线程是共同占有所属进程的资源和地址空间的,那么就会存在一个问题:

如果多个线程要同时访问某个资源,怎么处理?

这个问题就是后序文章中要重点讲述的同步问题。

那么可能有朋友会问,现在很多时候都采用多线程编程,那么是不是多线程的性能一定就由于单线程呢?

不一定,要看具体的任务以及计算机的配置。比如说:

对于单核CPU,如果是CPU密集型任务,如解压文件,多线程的性能反而不如单线程性能,因为解压文件需要一直占用CPU资源,如果采用多线程,线程切换导致的开销反而会让性能下降。

但是对于比如交互类型的任务,肯定是需要使用多线程的。

而对于多核CPU,对于解压文件来说,多线程肯定优于单线程,因为多个线程能够更加充分利用每个核的资源。

虽然多线程能够提升程序性能,但是相对于单线程来说,它的编程要复杂地多,要考虑线程安全问题。因此,在实际编程过程中,要根据实际情况具体选择。

2. JVM内存模型(JMM)

在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化

我们用网上一个图来更清晰的展示Java虚拟机内存模型。

Java内存模型抽象示意图

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

下面通过示意图来说明这两个步骤:

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

3. 多线程的优势以及风险

在了解线程的概念以及Java内存模型这些基本概念之后,我们来想想使用多线程究竟会有哪些优势,同时又会带来什么样的问题。

优势

  1. 能发挥多处理器的强大能力
  2. 建模的简单性
    通过使用线程,可以将复杂并且异步的工作流进一步分解为一组简单并且同步的工作流,每个工作流在一个单独的线程中执行,并在特定的同步位置进行交互。减少不同类型任务的上下文切换。
  3. 异步事件的简易处理
    就像一个socket请求,来一个请求分配一个线程来处理,比单线程情况下异步处理(非阻塞I/O)简单的多。
  4. 响应更灵敏的用户界面

虽然多线程能带来这么多优势,但伴随的风险依然不少。

风险

  1. 安全性问题
    譬如下面的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @NotThreadSafe
    public class UnsafeSequence {
    private int value;

    /** 返回一个唯一的数值。 **/
    public int getNext() {
    return value++;
    }
    }

    如果执行时机不对,那么两个线程在调用getNext时会得到相同的值。

  2. 活跃性问题 - 即“某件正确的事情最终会发生”
    譬如死锁,饥饿和活锁的问题。

  3. 性能问题
    在设计良好的并发应用程序中,线程能提升程序的性能,但无论如何,线程总会带来某种程序的运行时开销 - 频繁的上下文切换操作,同步数据时抑制编译器优化以及增加共享内存总线的同步流量。

4. 线程无处不在

即使在程序中没有显示地创建线程,但在框架中仍可能会创建线程,因此在这些线程中调用的代码同样必须是线程安全的。

每个Java程序都会使用线程。当JVM启动时,它将为JVM的内部任务,例如垃圾收集,终结操作等,创建后台线程,并创建一个主线程来运行main方法。AWT(Abstract Window Tookit,抽象窗口工具库)和Swing的用户界面框架将创建线程来管理用户界面时间。Timer将创建线程来执行延迟任务。一些组件框架,例如Servlet和RMI,都会创建线程池并调用这些线程中的方法。

下面给出的模块都将在应用程序之外的线程中调用应用程序的代码。尽管线程安全性需求可能源自这些模块,但却不会止步于它们。

  1. Timer
    Timer类的作用是使任务在稍后的时刻运行,或者运行一次,或者周期性地运行。引入Timer可能会使串行程序变得复杂,因为TimerTask将在Timer管理的线程中执行,而不是由应用程序来管理。
  2. Servlet和JavaServer Page(JSP)
  3. 远程调用方法(Remote Method Invocation,RMI)
  4. Swing和AWT

5. 总结

通过上面的了解,我们知道线程的历史,Java多线程的信息传递,以及多线程带来的优势和风险,这一切都吸引着我们去更多的了解Java多线程是如何实现的,以及如何去设计一个线程安全的类,来保证我们程序的稳定性。

期待后续更进一步的学习。