大话并发 – 基础知识扫盲

内容纲要

一、重磅开篇-形成完善的多线程世界观

多线程这部分内容确实比较高深而且每个知识点之间比较零散,让人摸不着头脑,不知道该从哪里下手。而且对于大部分学生群体来讲,很少有机会接触到高并发这方面的真实场景,平常自己敲代码也基本不会用到,所以也导致我们大部分同学都是面向面经学习,你问 synchronized,叭叭叭我能说一堆,你问 volatile,叭叭叭我也能说一堆,但总感觉差点意思,就是这些知识点是零散的,没有那么一根线把它们很好的串联起来。

所以今天我斗胆造一根线,站在小白的角度,讲讲多线程这部分我们到底要学啥,按照什么样的顺序去学,帮助各位建立一个比较完善的知识体系,形成正确的多线程世界观。后续的文章我也基本上会按照这根线写下来。

山外青山楼外楼,晚辈自知学识尚浅,大佬们若觉得有问题恳请评论区或者私聊我指正,晚辈感激不尽(抱拳)。

炼气

首先,学习多线程,你肯定得知道线程是啥吧,包括线程的一些基础概念(比如上下文切换),那么说到线程,肯定离不开进程。OK,进程和线程这两个概念其实我们在操作系统这门课中都接触过,当然并行和并发、同步与异步等这种基本概念咱也默认你学过,那么你还需要去了解一下 Java 线程和操作系统的线程有啥区别。

另外,容易被大家忽视的一点是,一项技术的出现必定不是凭空捏造的,他一定是为了某个目的而来,在某个成熟的时机应运而生。因此,你需要知道我们为啥要使用多线程,多线程的出现解决了什么问题。

掌握上面这一步,我们称之为炼气,所谓炼精化气,起步阶段需一心一意、沉心静气。

筑基

现在我们已经知道线程是啥了,那在 Java 中如何创建线程呢?为此你会接触到三种创建线程(Thread)的方式

  • 直接使用 Thread
  • Thread + Runnable
  • Thread + Callable + FutureTask

学会了如何创建线程,我们去翻一翻 Thread 类的源码,你会发现其中定义了 Java 线程的六种状态,也就是所谓的生命周期,它和操作系统中线程的五态模型又有啥区别和联系呢

既然都翻了 Thread 源码,岂有不深究的道理?我们接下来去学习一下 Thread 类给我们提供了哪些控制线程的方法,它们分别能干啥,怎样影响了线程的状态:

  • start / run
  • sleep / yield
  • join / join(long n)
  • interrupt
  • setDaemon 守护线程

这一阶段的学习,也就是入门阶段后的第一步,我们称之为筑基。基础不牢,地动山摇。

金丹

诚然,一个程序顺序的运行多个线程本身是没有问题的,但是如果多个线程同时访问了某个共享资源,就可能会发生不可预知的现象,也就是我们常说的线程安全问题,要了解这些问题产生的根本原因,我们就需要去深刻的了解 Java 内存模型(Java Memory Model,JMM)。

为此,我们会学习到和线程安全息息相关的三大性质:

1)原子性:一个操作是不可中断的,要么全部执行成功要么全部执行失败(也可以说是提供互斥访问,同一时刻只能有一个线程对数据进行操作)

2)可见性:当一个线程修改了共享变量后,其他线程能够立即得知这个修改

3)有序性:编译器和处理器为了优化程序性能,会对指令序列进行重新排序。由于重排序的存在,可能导致多线程环境下程序运行结果出错的问题。

那么编译器和处理器在重排序时会遵守什么原则呢?为此你会了解到数据依赖性as-if-serial,这里简单介绍一下这两个概念:

  • 编译器和处理器在重排序时,会遵守数据依赖性,它们不会改变存在数据依赖性关系的两个操作的执行顺序
  • as-if-serial 语义的意思是:不管怎么重排序,程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义

事实上,可见性和有序性其实是互相矛盾的两点。一方面,对于程序员来说,我们希望内存模型易于理解、易于编程,为此 JMM 的设计者要为程序员提供足够强的内存可见性保证,专业术语称之为 “强内存模型”。而另一方面,编译器和处理器则希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化(比如重排序)来提高性能,因此 JMM 的设计者对编译器和处理器的限制要尽可能地放松,专业术语称之为 “弱内存模型”。

当然,对于这个问题,JMM 的设计者找到了一个很好的平衡点,那就是 happens-before,这是 JMM 最核心的概念!理解 happens-before 是理解 JMM 的关键。

知其然而知其所以然,这一阶段,我们称为金丹。

渡劫

具体到 Java 语言层面,是怎么保证线程安全的呢?也就是如何保证原子性、可见性和有序性呢?(保证有序性上文已经说过了,就是使用 happens-before 原则)。

1)对于可见性,可以使用 volatile 关键字来保证,不仅如此,volatile 还能起到禁止指令重排的作用;另外, synchronizedfinal 这俩关键字也能保证可见性。

2)对于原子性,我们可以使用java.util.concurrent.atomic 包中的原子类来保证。(给萌新解释一下,java.util.concurrent,简称 J.U.C,就是一个包,也称为并发包。现在网上大部分博客都会直接说 JUC,对萌新不是很友好),我们可以看看 juc.atomic 中有哪些类:

当然, atomic 包下这些原子操作类保证原子性最关键的原因还是因为它们使用了 CAS 操作,于是,你需要先去深入学习一下 CAS,了解 CAS 存在的三个问题,然后再去挖一挖这些原子类的底层原理。

另外,上面我们提到的锁这个话题其实又是一个非常核心的知识点,在深入学习之前,你需要了解一下各种锁的概念:

  • 悲观锁和乐观锁
  • 重量级锁和轻量级锁
  • 自旋锁
  • 偏向锁
  • 重入锁和不可重入锁
  • 公平锁和非公平锁
  • 共享锁和排他锁

另外,与锁相关的概念的还有临界区竞态条件等,这些你都是要去了解的。

那么锁在 Java 中具体是怎么实现的呢?早先 Java 程序是靠 synchronized 关键字实现锁功能的,在我们掌握了 synchronized 的使用方式以及底层原理后,你还会接触到与 synchronized 配套的 wait/notify/notifyAll 方法。

在 Java SE 5 之后,并发包 JUC 中新增了 Lock 接口以及相关实现类(放在 java.util.concurrent.locks 包下)也可以用来实现锁功能。

为什么会新增这样一个 Lock 接口及其相关实现类呢?因为使用 synchronized 关键字会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放。当然,这种方式简化了同步的管理,可是扩展性没有显示的锁获取和释放来的好。

例如,针对一个场景,手把手进行锁获取和释放,先获得锁 A,然后再获取锁 B,当锁 B 获得后,释放锁 A 同时获取锁 C,当锁 C 获得后,再释放 B 同时获取锁 D,以此类推。这种场景下,如果使用 synchronized 关键字就不那么容易实现了,而使用 Lock 却容易许多。

它提供了与 synchronized 关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。虽然它缺少了隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种 synchronized 关键字所不具备的同步特性。

另外,还有一点非常重要的是!我们可以去翻一翻实现了 Lock 接口的类,比如 ReentrantLock(大部分文章都会直接把它翻译成重入锁),你会惊讶的发现它并没有多少代码,基本所有的方法都是调用了其静态内部类 Sync 中的方法,而 Sync 类继承了 AbstractQueuedSynchronizer 类(也就是大名鼎鼎的 AQS,译为队列同步器,简称同步器)。

可以把 AQS 理解为一个用来构建锁和同步器(工具类)的框架,locks 包中的各种锁以及接下来我们会学习的 JUC 中的工具类都是基于 AQS 来实现的。

OK,关于 AQS 这篇文章就不再多说了。上面我们提到了三个并发关键字synchronizedvolatile,和 final,可能很多小伙伴都不知道,啥?final 和并发有啥关系?当然,这些,后续文章都会写的。

本阶段的知识非常重要,并且相对来说知识点比较多也比较难,因此我们称之为渡劫。

JUC 其实可以分为五大类:

Lock 框架(locks 包)
原子类(atomic 包)
并发集合
线程池
工具类
后面三种正是我们在这一阶段需要学习的。并发集合和线程池就没啥好说的了,它们的知识点都比较集中,学习目标也很明确,网络上很容易就能找到一篇条理清晰的文章。

然后常用的工具类还是有必要学习下:

CountDownLatch
CyclicBarrier
Semaphore
Exchanger
所谓工具类嘛,那一定是封装了某些比较复杂的操作,使我们可以很简单的去完成这些操作。以 CountDownLatch 为例:在多线程协作完成业务功能时,有时候需要等待其他多个线程完成任务之后,主线程才能继续往下执行业务功能,在这种的业务场景下,通常可以使用 Thread 类的 join 方法,让主线程等待被 join 的线程执行完之后,主线程才能继续往下执行。而 Java 并发工具类中为我们提供了这样一个类似 “倒计时” 的工具类 CountDownLatch,可以十分方便的完成这种业务场景。

另外,还有一个比较重要的类,我也不知道怎么给它分类,就是 ThreadLocal,江湖人称线程隔离术,必问高阶考点。

OK,学完了本阶段,多线程世界观已完整形成,我们称之为大乘,忘我之境,全在己心。

二、Java 线程和操作系统的线程有啥区别

不想看解释的小伙伴可直接翻到文末寻找答案。

1. 用户空间和内核空间

关于内核态和用户态可以参考这篇文章:了解操作系统的那些事儿,从这篇文章开始,这里不再过多赘述。

至于什么是系统空间和用户空间也非常好理解:在操作系统中,内存通常会被分成用户空间(User space)与内核空间(Kernel space)这两个部分。当进程/线程运行在用户空间时就处于用户态,运行在内核空间时就处于内核态:

  • 运行在内核态的程序可以访问用户空间和内核空间,或者说它可以访问计算机的任何资源,不受限制,为所欲为,例如协调 CPU 资源,分配内存资源,提供稳定的环境供应用程序运行等
  • 而应用程序基本都是运行在用户态的,或者说用户态就是提供应用程序运行的空间。运行在用户态的程序只能访问用户空间

那为什么要区分用户态和内核态呢

其实早期操作系统是不区分用户态和内核态的,也就是说应用程序可以访问任意内存空间,如果程序不稳定常常会让系统崩溃,比如清除了操作系统的内存数据。为此大佬们设计出了一套规则:对于那些比较危险的操作需要切到内核态才能运行,比如 CPU、内存、设备等资源管理器程序就应该在内核态运行,否则安全性没有保证。

举个例子,对于文件系统和数据来说,文件系统数据和管理就必须放在内核态,但是用户的数据和管理可以放在用户态。

用户态的程序不能随意操作内核地址空间,这样有效地防止了操作系统程序受到应用程序的侵害

那如果处于用户态的程序想要访问内核空间的话怎么办呢?就需要进行系统调用从用户态切换到内核态。

2. 操作系统线程

① 在用户空间中实现线程

早期的操作系统中,所有的线程都是在用户空间下实现的,操作系统只能看到线程所属的进程,而不能看到线程。

从我们开发者的角度来理解用户级线程就是说:在这种模型下,我们需要自己定义线程的数据结构、创建、销毁、调度和维护等,这些线程运行在操作系统的某个进程内,然后操作系统直接对进程进行调度。

这种方式的好处一目了然,首先第一点,就是即使操作系统原生不支持线程,我们也可以通过库函数来支持线程;第二点,线程的调度只发生在用户态,避免了操作系统从内核态到用户态的转换开销。

当然缺点也很明显:由于操作系统看不见线程,不知道线程的存在,而 CPU 的时间片切换是以进程为维度的,所以如果进程中某个线程进行了耗时比较长的操作,那么由于用户空间中没有时钟中断机制,就会导致此进程中的其它线程因为得不到 CPU 资源而长时间的持续等待;另外,如果某个线程进行系统调用时比如缺页中断而导致了线程阻塞,此时操作系统也会阻塞住整个进程,即使这个进程中其它线程还在工作。

② 在内核空间中实现线程

所谓内核级线程就是运行在内核空间的线程, 直接由内核负责,只能由内核来完成线程的调度。

几乎所有的现代操作系统,包括 Windows、Linux、Mac OS X 和 Solaris 等,都支持内核线程。

每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核(Multi-Threads Kernel)。

从我们开发者的角度来理解内核级线程就是说:我们可以直接使用操作系统中已经内置好的线程,线程的创建、销毁、调度和维护等,都是直接由操作系统的内核来实现,我们只需要使用系统调用就好了,不需要像用户级线程那样自己设计线程调度等。

上图画的是 1:1 的线程模型,所谓线程模型,也就是用户线程和内核线程之间的关联方式,线程模型当然不止 1:1 这一种,下面我们来详细解释以下这三种多线程模型:

下文翻译自
https://www.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/4_Threads.html

1)多对一线程模型

  • 在多对一模型中,多个用户级线程映射到某一个内核线程上
  • 线程管理由用户空间中的线程库处理,这非常有效
  • 但是,如果进行了阻塞系统调用,那么即使其他用户线程能够继续,整个进程也会阻塞
  • 由于单个内核线程只能在单个 CPU 上运行,因此多对一模型不允许在多个 CPU 之间拆分单个进程

从并发性角度来总结下,虽然多对一模型允许开发人员创建任意多的用户线程,但是由于内核只能一次调度一个线程,所以并未增加并发性。现在已经几乎没有操作系统来使用这个模型了,因为它无法利用多个处理核。

2)一对一线程模型

  • 一对一模型克服了多对一模型的问题
  • 一对一模型创建一个单独的内核线程来处理每个用户线程
  • 但是,管理一对一模型的开销更大,涉及更多开销和减慢系统速度
  • 此模型的大多数实现都限制了可以创建的线程数

从并发性角度来总结下,虽然一对一模型提供了更大的并发性,但是开发人员应注意不要在应用程序内创建太多线程(有时系统可能会限制创建线程的数量),因为管理一对一模型的开销更大。Windows (从 Win95 开始) 和 Linux 都实现了线程的一对一模型

3)多对多线程模型

  • 多对多模型将任意数量的用户线程复用到相同或更少数量的内核线程上,结合了一对一和多对一模型的最佳特性
  • 用户对创建的线程数没有限制
  • 阻止内核系统调用不会阻止整个进程
  • 进程可以分布在多个处理器上
  • 可以为各个进程分配可变数量的内核线程,具体取决于存在的 CPU 数量和其他因素

3. Java 线程

在进入 Java 线程主题之前,有必要讲解一下线程库 Thread library 的概念。

在上面的模型介绍中,我们提到了通过线程库来创建、管理线程,那么什么是线程库呢?

线程库就是为开发人员提供创建和管理线程的一套 API

当然,线程库不仅可以在用户空间中实现,还可以在内核空间中实现。前者涉及仅在用户空间内实现的 API 函数,没有内核支持。后者涉及系统调用,也就是说调用库中的一个 API 函数将会导致对内核的系统调用,并且需要具有线程库支持的内核。

下面简单介绍下三个主要的线程库:

1)POSIX Pthreads:可以作为用户或内核库提供,作为 POSIX 标准的扩展

2)Win32 线程:用于 Window 操作系统的内核级线程库

3)Java 线程:Java 线程 API 通常采用宿主系统的线程库来实现,也就是说在 Win 系统上,Java 线程 API 通常采用 Win API 来实现,在 UNIX 类系统上,采用 Pthread 来实现。

下面我们来详细讲解 Java 线程:

事实上,在 JDK 1.2 之前,Java 线程是基于称为 "绿色线程"(Green Threads)的用户级线程实现的,也就是说程序员大佬们为 JVM 开发了自己的一套线程库或者说线程管理机制。

在 JDK 1.2 及以后,JVM 选择了更加稳定且方便使用的操作系统原生的内核级线程,通过系统调用,将线程的调度交给了操作系统内核。而对于不同的操作系统来说,它们本身的设计思路基本上是完全不一样的,因此它们各自对于线程的设计也存在种种差异,所以 JVM 中明确声明了:虚拟机中的线程状态,不反应任何操作系统中的线程状态

需要注意的是,这里指的是主流平台上的主流商用 Java 虚拟机,比如 HotSpot。也就是说是存在例外情况的,比如 Solaris 平台上的 HotSpot 虚拟机就提供了 1:1 和 N:M 两种线程模型。

也就是说,在 JDK 1.2 及之后的版本中,Java 的线程很大程度上依赖于操作系统采用什么样的线程模型,这点在不同的平台上没有办法达成一致,JVM 规范中也并未限定 Java 线程需要使用哪种线程模型来实现,可能是一对一,也可能是多对多或多对一。

总结来说,回答下文题,现今 Java 中线程的本质,其实就是操作系统中的线程,其线程库和线程模型很大程度上依赖于操作系统(宿主系统)的具体实现,比如在 Windows 中 Java 就是基于 Win32 线程库来管理线程,且 Windows 采用的是一对一的线程模型

References

三、以 DEBUG 方式深入理解线程的底层运行原理

说到线程的底层运行原理,想必各位也应该知道我们今天不可避免的要讲到 JVM 了。其实大家明白了 Java 的运行时数据区域,也就明白了线程的底层原理,不过把这些东西明明白白写在纸面上的,网络上的文章并不多,所以今天我总结了一下,带着大家一步一步 DEBUG,来看看线程到底是怎么运行的,顺便把 IDEA 的 DEBUG 方法简单讲一下。

工具的使用应该是大部分同学都缺失的,我自己就深受其害,经常不由自主地习惯性用肉眼一行一行排 BUG(狗头)。

Java 运行时数据区域

友情提示:这部分内容可能大部分同学都有一定的了解了,可以跳过直接进入下一小节哈。

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途,以及创建和销毁的时间。

全文我们都将以 JDK 7 的运行时数据区域为例:

先简单解释下线程共享和线程私有是啥意思。

所谓线程私有,通俗来说就是每个线程都会创建一个属于自己的东西,每个线程之间的这块私有区域互不影响,独立存储。比如程序计数器就是线程私有的,每个线程都会拥有一个属于自己的程序计数器,互不干涉。

线程共享就没啥好说的,简单理解为公共场所,谁都能去,存储的数据所有线程都能访问。

OK,然后我们来逐个分析下每个区域都是用来存储什么的。当然了,这里不会做太多详细的说明,不然会使文章显得非常臃肿,在理解本文的基础上能够让大家对各个区域有基本的认知就好了。

首先来看一下线程共享的两个区域:

1)Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在 Java 虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。

2)方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

很多人习惯的把方法区称为永久代(Permanent Generation),但实际上这两者并不等价。通俗来说,方法区是一种规范,而永久代是 HotSpot 虚拟机实现这个规范的一种手段,对于其他虚拟机(比如 BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。

另外,对于 HotSpot 虚拟机来说,它在 JDK 8 中完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Meta-space)来代替,把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

再来看看线程私有的三个区域:

1)虚拟机栈(Java Virtual Machine Stacks)其实是由一个一个的 栈帧(Stack Frame) 组成的,一个栈帧描述的就是一个 Java 方法执行的内存模型。也就是说每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法的返回地址等信息。

每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程,当然,出栈的顺序自然是遵守栈的后进先出原则的。

栈帧的概念在接下来的原理解析部分非常重要,各位务必搞懂哈。

2)本地方法栈(Native Method Stack)和上面我们所说的虚拟机栈作用基本一样,区别只不过是本地方法栈为虚拟机使用到的 Native 方法服务,而虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务。

这里解释一下 Native 方法的概念,其实不仅 Java,很多语言中都有这个概念。

"A native method is a Java method whose implementation is provided by non-java code."

就是说一个 Native 方法其实就是一个接口,但是它的具体实现是在外部由非 Java 语言写的。所以同一个 Native 方法,如果用不同的虚拟机去调用它,那么得到的结果和运行效率可能是不一样的,因为不同的虚拟机对于某个 Native 方法都有自己的实现,比如 Object 类的 hashCode 方法。

这使得 Java 程序能够超越 Java 运行时的界限,有效地扩充了 JVM。

3)程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于 Java 虚拟机的多线程是通过轮流分配 CPU 时间片的方式来实现的,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。

那么程序计数器里存的到底是什么东西呢?

《深入理解 Java 虚拟机:JVM 高级实践与最佳实战 - 第 2 版》给出了答案:如果线程正在执行的是一个 Java 方法,程序计数器中记录的就是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)

用 DEBUG 的方式看线程运行原理

接下来,我们就通过 DEBUG 这段代码来看下线程的运行原理:

上述代码的逻辑非常简单,main 方法调用了 method1 方法,而 method1 方法又调用了 method2 方法。

看下图,我们打了一个断点:

以 DEBUG 的方式运行 Test.main(),虽然这里我们没有显示的创建线程,但是 main 函数的调用本身就是一个线程,也被称为主线程(main 线程),所以我们一启动这个程序,就会给这个主线程分配一个虚拟机栈内存。

上文我们也说了,虚拟机栈内存其实就是个壳儿,里面真正存储数据的,其实是一个一个的栈帧,每个方法都对应着一个栈帧。

所以当主线程调用 main 方法的时候,就会为 main 方法生成一个栈帧,其中存储了局部变量表、操作数栈、动态链接、方法的返回地址等信息。

各位现在可以看看 DEBUG 窗口显示的界面:

左边的 Frames 就是栈帧的意思,可以看见现在主线程中只有一个 main 栈帧;

右边的 Variables 就是该栈帧存储的局部变量表,可以看到现在 main 栈帧中只有一个局部变量,也就是方法参数 args。

接下来 DEBUG 进入下一步,我们先来看看 DEBUG 界面上的每个按钮都是啥意思,总共五个按钮(已经了解的各位可以跳过这里),视频配合下方文字食用更佳:

1)Step Over:F8

程序向下执行一行,如果当前行有方法调用,这个方法将被执行完毕并返回,然后到下一行

2)Step Into:F7

程序向下执行一行,如果该行有自定义方法,则运行进入自定义方法(不会进入官方类库的方法)

3)Force Step Into:Alt + Shift + F7

程序向下执行一行,如果该行有自定义方法或者官方类库方法,则运行进入该方法(也就是可以进入任何方法)

4)Step Out:Shift + F8

如果在调试的时候你进入了一个方法,并觉得该方法没有问题,你就可以使用 Step Out 直接执行完该方法并跳出,返回到该方法被调用处的下一行语句。

5)Drop frame

点击该按钮后,你将返回到当前方法的调用处重新执行,并且所有上下文变量的值也回到那个时候。只要调用链中还有上级方法,可以跳到其中的任何一个方法。

回到我们的测试程序,点击 Step Into 进入 method1 方法,可以看到,虚拟机栈内存中又多出了一个 method1 栈帧:

再点击 Step Into 直到进入 method2 方法,于是虚拟机栈内存中又多出了一个 method2 栈帧:

当我们 Step Into 走到 method2 方法中的 return n 语句后,n 指向的堆中的地址就会被返回给 method1 中的 m,并且,满足栈后进先出的原则,method2 栈帧会从虚拟机栈内存中被销毁。

然后点击 Step Over 执行完输出语句(Step Into 会进入 println 方法,Force Step Into 会进入 Object.toString 方法)

至此,method1 的使命全部完成,method1 栈帧会从虚拟机栈内存中被销毁。

最后再往下走一步,main 栈帧也会被销毁,这里就不再贴图了。

线程运行原理详细图解

上面写了这么多,其实也就是教会了大家栈帧这个东西,接下来我们通过图解的方式,来带大家详细看看线程运行时,Java 运行时数据区域的各种变化。

首先第一步,类加载。

《深入理解 Java 虚拟机:JVM 高级实践与最佳实战 - 第 2 版》中是这样解释类加载的:虚拟机把描述类的数据从 Class 文件(字节码文件)加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。

加载进来的这些字节码信息,就存储在方法区中。看下图,这里为了各位理解方便,我就不写字节码了,直接按照代码来,大家知道这里存的其实是字节码就行

随后整体的执行流程是这样的,各位可以配合下方视频和文字解析一同食用:

① 主线程调用 main 方法,于是为该方法生成一个 main 栈帧;

② 那么 main 方法的参数 args 的值从哪里来呢?没错,就是从堆中 new 出来的。而 main 方法的返回地址就是程序的退出地址;

③ 再来看程序计数器,如果线程正在执行的是一个 Java 方法,程序计数器中记录的就是正在执行的虚拟机字节码指令的地址,也就是说此时 method1(10) 对应的字节码指令的地址会被放入程序计数器

④ CPU 根据程序计数器的指示,进入 method1 方法,于是 method1 栈帧就被创建出来了;

⑤ 局部变量表和方法返回地址安顿好后,就可以开始具体的方法调用了,首先 10 会被传给 x,然后走到 int y = x + 1 这步,也就是程序计数器会被修改成这步代码对应的字节码指令的地址;

⑥ 走到 Object m = method2() 这一步的时候,又会创建一个 method2 栈帧;

⑦ 可以看到,method2 方法的第一行代码 Object n = new Object 会在堆中创建一个 Object 对象;

⑧ 随后,走到 method2 方法中的最后一条 return n 语句,n 指向的堆中的地址就会被返回给 method1 中的 m,并且,满足栈后进先出的原则,method2 栈帧会从虚拟机栈内存中被销毁;

⑨ 根据 method2 栈帧指向的方法返回地址,我们接着执行 method1 方法中的最后一条语句 System.out.println(m.toString()) ,执行完后,method1 栈帧也被销毁了;

⑩ 再根据 method1 栈帧指向的方法返回地址,发现我们的程序已走到了生命的尽头,于是 main 栈帧也被销毁了。

用 DEBUG 的方式看多线程运行原理

上面说的是只有一个线程的情况,其实多线程的原理也差不多,因为虚拟机栈是每个线程私有的,大家互不干涉,这里我就简单的提一嘴。

分别在如下两个位置打上 Thread 类型的断点:

然后以 DEBUG 方式运行,你就会发现存在两个互不干涉的虚拟机栈空间:

当然,使用多线程就不可避免的会遇到一个问题,那就是线程的上下文切换(Thread Context Switch),就是说因为某些原因导致 CPU 不再执行当前的线程,转而执行另一个线程。

导致线程上下文切换的原因大概有以下几种:

1)线程的 CPU 时间片用完

2)发生了垃圾回收

3)有更高优先级的线程需要运行

4)线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

当线程的上下文切换发生时,也就是从一个线程 A 转而执行另一个线程 B 时,需要由操作系统保存当前线程 A 的状态(为了以后还能顺利回来接着执行),并恢复另一个线程 B 的状态。

这个状态就包括每个线程私有的程序计数器和虚拟机栈中每个栈帧的信息等,显然,每次操作系统都需要存储这么多的信息,频繁的线程上下文切换势必会影响程序的性能

四、「跬步千里」详解 Java 内存模型与原子性、可见性、有序性

文题 “跬步千里” 主要是为了凸显这篇文章的基础性与重要性(狗头),并发编程这块的知识也确实主要围绕着 JMM 和三大性质来展开。

全文脉络如下:

1)为什么要学习并发编程?

2)为什么需要并发编程?

3)介绍 Java 内存模型

4)详解 Java 内存模型的三大性质(原子性、可见性、有序性),这也是判断线程安全的三个重要指标。以原子性为例,大致行文逻辑如下:

  • 什么是原子性
  • 不满足原子性会导致什么问题
  • 如何保证原子性

为什么要学习并发编程

对于 “我们为什么要学习并发编程?” 这个问题,就好比 “我们为什么要学习政治?” 一样,我们(至少作为学生党是这样)平常很少接触到,然后背了一堆 “正确且伟大无比的废话”,最终沦为八股被快速遗忘。

直到我开始去深入了解这块知识而不是盲目背诵的时候,我才明白,它正确且伟大无比,但不是废话

尽管并发编程的各种底层原理以及其庞大的知识体系容易让人心生畏惧,但是 Java 语言和 Java 虚拟机都提供了相当多的并发工具,替我们隐藏了很多的线程并发细节,使得我们在编码时能更关注业务逻辑,把并发编程的门槛降低了不少。

但是无论语言、中间件和框架再如何先进,我们都不应该完全依赖于它们完成并发处理的所有事情,了解并发的内幕并学习其中的思想,仍然是成为一个高级程序员的必经之路。

我想,上面这段话大概可以回答 “我们为什么要学习并发编程?” 这个问题了。

为什么需要并发编程

不知道各位有没有听说过被誉为计算机第一定律的摩尔定律,它是英特尔创始人之一戈登 · 摩尔长期观察总结出来的经验,虽然不是严格推导出来的真理,但最起码迄今为止仍然是令人深信不疑的。其核心内容通俗来说就是 处理器的性能每隔两年就会翻一倍。看起来像个废话(:dog:)。

而事实上,当今多核 CPU 的发展速度也确实正在支撑着摩尔定律的有效性。在时代的大背景下,并发编程已成燎原之势,通过并发编程的形式将多核 CPU 的计算能力发挥到极致,性能得到提升

举个例子,在当今诸神黄昏的图像处理领域,很多图像处理算法,在代码初步编写完毕并调试正确后,其实仍然需要进行一个漫长的优化过程。因为尽管有些算法的处理效果很棒,但是如果运算太过耗时,还是无法集成进产品给用户使用的。

对于一副 1000 x 800 分辨率的图像,我们最原始的处理思路就是从第 1 个像素开始,一直遍历计算到最后一个像素。那么面对如此庞大且复杂的计算量,为了提高算法的性能,最直接也最容易实现的想法就是基于多线程充分利用多核 CPU 的计算能力。

可以将整个图像分成若干块,比如我们的 CPU 是 8 核的,那么可以分成 8 块,每块图像大小为 1000 * 100 像素,我们可以创建 8 个线程,每个线程处理一个图像块,每个 CPU 分配执行一个线程。这样,运算速度将得到明显的提升。

当然了,这样操作后,运算速度并不会恐怖的提升 4 倍,因为线程创建和释放以及上下文切换都有一定的损耗。

这里摘录《Java 并发编程的艺术》书中的一段话来回答这个问题,我们为什么需要并发线程

多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。多个 CPU 意味着每个线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销,但随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫切的需求。

而至于多核 CPU 盛行的原因,《深入理解 Java 虚拟机 - 第 3 版》一书中也有所涉及,这里我略作修改摘录如下:

多任务处理在现代计算机操作系统中几乎已是一项必备的功能了。在许多场景下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大了,更重要的原因是计算机的运算速度与它的存储和通信子系统速度的差距太大,这样 CPU 不得不花费大量的时间等待其他资源,比如磁盘 I/O、网络通信或者数据库访问等。

为此,我们就必须使用一些手段去把处理器的运算能力“压榨”出来,否则就会造成很大的性能浪费,而让计算机同时处理几项任务则是最容易想到,也被证明是非常有效的“压榨”手段。

另外,除了充分利用计算机处理器的能力外,一个服务端要同时对多个客户端提供服务,则是另一个更具体的并发应用场景。

从物理机中得到启发

事实上,物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义,因此,我们有必要学习下物理机中处理问题的方法。

上文说过可以使用并发编程来充分利用 CPU 的资源,其中一个主要原因就是计算机的存储设备与 CPU 的运算速度有着几个数量级的差距,这样 CPU 不得不花费大量的时间去等待其他资源。

这是软件层面,而在硬件层面上,现代计算机系统都会在内存与 CPU 之间加入一层或多层读写速度尽可能接近 CPU 运算速度的高速缓存来作为缓冲。

将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

为此,这不可避免的带来了一个新的问题:缓存一致性(Cache Coherence)。

就是说当多个 CPU 的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内存时该以谁的缓存数据为准呢?

为了解决一致性的问题,需要各个 CPU 访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。于是,我们引出了内存模型的概念。

image.png

在物理机层面,内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

显然,不同架构的物理机器可以拥有不一样的内存模型,而 Java 虚拟机也拥有自己的内存模型,称为 Java 内存模型(Java Memory Model,JMM),其目的就是为了屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果

当然了,JMM 与这里我们介绍的物理机的内存模型具有高度的可类比性。

Java 内存模型

JMM 规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory)。

线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。

image.png

此处的主内存可以与前面所说的物理机的主内存类比,当然,实际上它仅是虚拟机内存的一部分,工作内存可与前面讲的高速缓存类比。

《Java 并发编程的艺术》中把 “工作内存” 称为 “本地内存”(Local Memory)。 “工作内存” 是《深入理解 Java 虚拟机 - 第 3 版》这本书中的写法。

多提一嘴,这里的变量其实和我们日常编程中所说的变量不一样,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后面这俩是线程私有的,不会被共享,自然就不会存在竞争问题。各位知道就好,不必太过深究。

原子性

什么是原子性

类比物理机,拥有缓存一致性协议来规定主内存和高速缓存之间的操作逻辑,那么 JMM 中主内存与工作内存之间有没有具体的交互协议呢?

Of Course!JMM 中定义了以下 8 种操作规范来完成一个变量从主内存拷贝到工作内存、以及从工作内存同步回主内存这一类的实现细节。Java 虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的

暂时放下到底是哪 8 种操作,我们先谈何为原子

原子(atomic)本意是 “不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为 “不可被中断的一个或一系列操作”。

举个经典的简单例子,银行转账,A 像 B 转账 100 元。转账这个操作其实包含两个离散的步骤:

  • 步骤 1:A 账户减去 100
  • 步骤 2:B 账户增加 100

我们要求转账这个操作是原子性的,也就是说步骤 1 和步骤 2 是顺续执行且不可被打断的,要么全部执行成功、要么执行失败。

试想一下,如果转账操作不具备原子性会导致什么问题呢?

比如说步骤 1 执行成功了,但是步骤 2 没有执行或者执行失败,就会导致 A 账户少了 100 但是 B 账户并没有相应的多出 100。

对于上述这种情况,符合原子性的转账操作应该是如果步骤 2 执行失败,那么整个转账操作就会失败,步骤 1 就会回滚,并不会将 A 账户减少 100。

OK,了解了原子性的概念后,我们再来看 JMM 定义的 8 种原子操作具体是啥,以下了解即可,没必要死记:

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量

事实上,对于 doublelong 类型的变量来说,load、store、read 和 write 操作在某些平台上允许有例外,称为 “long 和 double 的非原子性协定”,不过一般不需要我们特别注意,这里就不再过多赘述了。

这 8 种操作当然不是可以随便用的,为了保证 Java 程序中的内存访问操作在并发下仍然是线程安全的,JMM 规定了在执行上述 8 种基本操作时必须满足的一系列规则。

这我就不一一列举了,多提这么一嘴的原因就是下文会涉及一些这其中的规则,为了防止大家看的时候云里雾里,所以先前说明白比较好。

上面我们举了一个转账的例子,那么,在具体的代码中,非原子性操作可能会导致什么问题呢?

看下面这段代码,各位不妨考虑一个的问题,如果两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果一定是 0 吗?

image.png

耳熟能详的问题,我们无法保证这段代码执行结果的一定性(正确性),可能是正数、也可能是负数、当然也可能是 0。

那么,我们就把这段代码称为线程不安全的,就是说在单线程环境下正常运行的一段代码,在多线程环境中可能发生各种意外情况,导致无法得到正确的结果。

从线程安全的角度来反向理解线程不安全的概念可能更容易点,这里参考《Java 并发编程实践》上面的一句话:

一段代码在被多个线程访问后,它仍然能够进行正确的行为,那这段代码就是线程安全的

至于这段代码线程不安全的原因,就是 Java 中对静态变量自增和自减操作并不是原子操作,它俩其实都包含三个离散的操作:

  • 步骤 1:读取当前 i 的值
  • 步骤 2:将 i 的值加 1(减 1)
  • 步骤 3:写回新值

可以看出来这是一个 读 - 改 - 写 的操作。

i ++ 操作为例,我们来看看它对应的字节码指令:

image.png

上方这段代码对应的字节码是这样的:

image.png

简单解释下这些字节码指令的含义:

  • getstatic i:获取静态变量 i 的值
  • iconst_1:准备常量 1
  • iadd:自增(自减操作对应 isub)
  • putstatic i:将修改后的值存入静态变量 i

如果是在单线程的环境下,先自增 5000 次,然后再自减 5000 次,那当然不会发生任何问题。

image.png

但是在多线程的环境下,由于 CPU 时间片调度的原因,可能 Thread1 正在执行自增操作着呢,CPU 剥夺了它的资源占用,转而分配给了 Thread2,也就是发生了线程上下文切换。这样,就可能导致本该是一个连续的读改写动作(连续执行的三个步骤)被打断了。

下图出现的就是结果最终是负数的情况:

image.png

总结来说,如果多个 CPU 同时对某个共享变量进行读-改-写操作,那么这个共享变量就会被多个 CPU 同时处理,由于 CPU 时间片调度等原因,某个线程的读-改-写操作可能会被其他线程打断,导致操作完后共享变量的值和我们期望的不一致。

另外,多说一嘴,除了自增自减,我们常见的 i = j 这个操作也是非原子性的,它分为两个离散的步骤:

  • 步骤 1:读取 j 的值
  • 步骤 2:将 j 的值赋给 i

如何保证原子性

那么,如何实现原子操作,也就是如何保证原子性呢

对于这个问题,其实在处理器和 Java 编程语言层面,它们都提供了一些有效的措施,比如处理器提供了总线锁和缓存锁,Java 提供了锁和循环 CAS 的方式,这里我们简单解释下 Java 保证原子性的措施。

由 Java 内存模型来直接保证的原子性变量操作包括 readloadassignusestorewrite 这 6 个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是 long 和 double 的非原子性协定,各位只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。

如果应用场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lockunlock 操作来满足这种需求。

尽管 JVM 并没有把 lockunlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorentermonitorexit 来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块 — synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。

而除了 synchronized 关键字这种 Java 语言层面的锁,juc 并发包中的 java.util.concurrent.locks.Lock 接口也提供了一些类库层面的锁,比如 ReentrantLock

另外,随着硬件指令集的发展,在 JDK 5 之后,Java 类库中开始使用基于 cmpxchg 指令的 CAS 操作(又来一个重点),该操作由 sun.misc.Unsafe 类里面的 compareAndSwapInt()compareAndSwapLong() 等几个方法包装提供。不过在 JDK 9 之前 Unsafe 类是不开放给用户使用的,只有 Java 类库可以使用,譬如 juc 包里面的整数原子类,其中的 compareAndSet()getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作来实现。

使用这种 CAS 措施的代码也常被称为无锁编程(Lock-Free)。

可见性

什么是可见性

回到物理机,前文说过,由于引入了高速缓存,不可避免的带来了一个新的问题:缓存一致性。而同样的,这个问题在 Java 虚拟机中同样存在,表现为工作内存与主内存的同步延迟,也就是内存可见性问题。

何为可见性?就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

回顾下 Java 内存模型:

image.png

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

  • 1)线程 A 把工作内存 A 中更新过的共享变量刷新到主内存中去
  • 2)线程 B 到主内存中去读取线程 A 之前已更新过的共享变量

也就是说,线程 A 在向线程 B 的通信过程必须要经过主内存

那么,这就可能出现一个问题,举个简单的例子,看下面这段代码:

// 线程 1 执行的代码
int i = 0;
i = 1;
// 线程 2 执行的代码
j = i;

当线程 1 执行 i = 1 这句时,会先去主内存中读取 i 的初始值,然后加载到线程 1 的的工作内存中,再赋值为1,至此,线程 1 的工作内存当中 i 的值变为 1 了,不过还没有写入到主内存当中。

如果在线程 1 准备把新的 i 值写回主内存的时候,线程 2 执行了 j = i 这条语句,它会去主存读取 i 的值并加载到线程 2 的工作内存当中,而此时主内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 1。

这就是内存可见性问题,线程 1 修改了共享变量 i 的值,线程 2 并没有立即得知这个修改。

如何保证可见性

各位可能脱口而出使用 volatile 关键字修饰共享变量,但除了这个,容易被大家忽略的是,其实 synchronizedfinal 这俩关键字也能保证可见性

上面我提过一嘴,为了保证 Java 程序中的内存访问操作在并发下仍然是线程安全的,JMM 规定了在执行 8 种基本原子操作时必须满足的一系列规则,这其中有一条规则正是 sychronized 能够保证原子性的理论支撑,如下:

  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)

也就是说 synchronized在修改了工作内存中的变量后,解锁前会将工作内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性。

至于 final 关键字的可见性需要结合其内存语义深入来讲,这里就先简单的概括下:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 this 的引用传递出去,那么在其他线程中就能看见 final 字段的值。

有序性

什么是有序性

OK,说完了可见性,我们再回到物理机,其实除了增加高速缓存之外,为了使 CPU 内部的运算单元能尽量被充分利用,CPU 可能会对输入代码进行乱序执行优化,CPU 会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。

与之类似的,Java 的编译器也有这样的一种优化手段:指令重排序(Instruction Reorder)。

那么,既然能够优化性能,重排序可以没有限制的被使用吗?

当然不,在重排序的时候,CPU 和编译器都需要遵守一个规矩,这个规矩就是 as-if-serial 语义:不管怎么重排序,单线程环境下程序的执行结果不能被改变。

为了遵守 as-if-serial 语义,CPU 和编译器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

那么这里,我们又引出了 “数据依赖性” 的概念。

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

数据依赖性分为三种类型:写后读、写后写、读后写,看下图

image.png

上面 3 种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

其实考虑数据依赖关系的时候,各位可以通过画图来直观的判断。举个例子:

int a = 1;       // A
int b = 2;       // B
int sum = a + b; // C

上面 3 个操作的数据依赖关系如下图所示:

image.png

可以看出,A 和 C、B 和 C 之间存在数据依赖关系,因此在最终执行的指令序列中,C 不能被重排序到 A 或 B 的前面。但 A 和 B 之间没有数据依赖关系,所以 CPU 和处理器可以重排序 A 和 B 之间的执行顺序。如下是程序的两种执行顺序:

image.png

看起来好像没啥问题,重排序之后程序的结果并没有发生改变,还提升了性能。

然而,很不幸的是,我们这里所说的数据依赖性仅针对单个 CPU 中执行的指令序列和单个线程中执行的操作,不同 CPU 之间和不同线程之间的数据依赖性是不被 CPU 和编译器考虑的

这就是为啥我在写 as-if-serial 语义的时候把 “单线程” 加粗的目的了。

看下面这段代码:

image.png

假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 把共享变量 a 修改成了 1 呢?

答案是不一定。

由于操作 1 和操作 2 没有数据依赖关系,CPU 和编译器可以对这两个操作重排序;同样的,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

以操作 1 和操作 2 重排序为例,可能会产生什么效果呢?

image.png

如上图右边所示,程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还没有被线程 A 写入,因此线程 B 读到的 a 值仍然是 0。也就是说在这里多线程程序的语义被重排序破坏了

这样,我们可以得出结论:CPU 和 Java 编译器为了优化程序性能,会自发地对指令序列进行重新排序。在多线程的环境下,由于重排序的存在,就可能导致程序运行结果出现错误

了解了重排序的概念,我们可以这样总结下 Java 程序天然的有序性:

  • 如果在本线程内观察,所有的操作都是有序的(简单来说就是线程内表现为串行)
  • 如果在一个线程中观察另一个线程,所有的操作都是无序的(这个无序主要就是指 “指令重排序” 现象和 “工作内存与主内存同步延迟” 现象)

如何保证有序性

Java 语言提供了 volatilesynchronized 两个关键字来保证线程之间操作的有序性。

volatile 本身除了保证可见性的语义外,还包含了禁止指令重排序的语义,所以天生就具有保证有序性的功能。

synchronized 保证有序性的理论支撑,仍然是 JMM 规定在执行 8 种基本原子操作时必须满足的一系列规则中的某一个提供的:

  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作

这个规则决定了持有同一个锁的两个 synchronized 同步块只能串行地进入。

不是很难理解吧,通俗来说,synchronized 通过排他锁的方式保证了同一时间内,被 synchronized 修饰的代码是单线程执行的。所以,这就满足了 as-if-serial 语义的一个关键前提,那就是单线程,这样,有了 as-if-serial 语义的保证,单线程的有序性也就得到保障了。

But,遗憾的是,如果仅仅依靠这俩个关键字来保证有序性的话,编码将会变得非常繁琐,为此,Happens-before 原则应运而生。

Happens-before 原则

Happens-before 是 JMM 的灵魂,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。为了知识体系的完整性,这里简单提一下,后续文章会详细解释的。

如果 Java 内存模型中所有的有序性都仅靠 volatile 和 synchronized 来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写 Java 并发代码的时候并没有察觉到这一点,这就归功于 “先行发生”(Happens-Before)原则。

依赖这个原则,我们可以通过几条简单规则快速解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入 Java 内存模型苦涩难懂的定义之中。

References

  • 《Java 并发编程的艺术》
  • 《深入理解 Java 虚拟机 - 第 3 版》

五、JMM 最最最核心的概念:Happens-before 原则

关于 Happens-before,《Java 并发编程的艺术》书中是这样介绍的:

Happens-before 是 JMM 最核心的概念。对应 Java 程序员来说,理解 Happens-before 是理解 JMM 的关键。

《深入理解 Java 虚拟机 - 第 3 版》书中是这样介绍的:

Happens-before 是 JMM 的灵魂,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。

我想,这两句话就已经足够表明 Happens-before 原则的重要性。

那为什么 Happens-before 被不约而同的称为 JMM 的核心和灵魂呢?

生来如此。

JMM 设计者的难题与完美的解决方案

上篇文章我们学习了 JMM 及其三大性质,事实上,从 JMM 设计者的角度来看,可见性和有序性其实是互相矛盾的两点:

  • 一方面,对于程序员来说,我们希望内存模型易于理解、易于编程,为此 JMM 的设计者要为程序员提供足够强的内存可见性保证,专业术语称之为 “强内存模型”。
  • 而另一方面,编译器和处理器则希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化(比如重排序)来提高性能,因此 JMM 的设计者对编译器和处理器的限制要尽可能地放松,专业术语称之为 “弱内存模型”。

对于这个问题,从 JDK 5 开始,也就是在 JSR-133 内存模型中,终于给出了一套完美的解决方案,那就是 Happens-before 原则,Happens-before 直译为 “先行发生”,《JSR-133:Java Memory Model and Thread Specification》对 Happens-before 关系的定义如下:

1)如果一个操作 Happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2)两个操作之间存在 Happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 Happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 Happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)

并不难理解,第 1 条定义是 JMM 对程序员强内存模型的承诺。从程序员的角度来说,可以这样理解 Happens-before 关系:如果 A Happens-before B,那么 JMM 将向程序员保证 — A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。注意,这只是 Java内存模型向程序员做出的保证!

需要注意的是,不同于 as-if-serial 语义只能作用在单线程,这里提到的两个操作 A 和 B 既可以是在一个线程之内,也可以是在不同线程之间。也就是说,Happens-before 提供跨线程的内存可见性保证

针对这个第 1 条定义,我来举个例子:

// 以下操作在线程 A 中执行
i = 1; // a

// 以下操作在线程 B 中执行
j = i; // b

// 以下操作在线程 C 中执行
i = 2; // c

假设线程 A 中的操作 a Happens-before 线程 B 的操作 b,那我们就可以确定操作 b 执行后,变量 j 的值一定是等于 1。

得出这个结论的依据有两个:一是根据 Happens-before 原则,a 操作的结果对 b 可见,即 “i=1” 的结果可以被观察到;二是线程 C 还没运行,线程 A 操作结束之后没有其他线程会修改变量 i 的值。

现在再来考虑线程 C,我们依然保持 a Happens-before b ,而 c 出现在 a 和 b 的操作之间,但是 c 与 b 没有 Happens-before 关系,也就是说 b 并不一定能看到 c 的操作结果。那么 b 操作的结果也就是 j 的值就不确定了,可能是 1 也可能是 2,那这段代码就是线程不安全的。

再来看 Happens-before 的第 2 条定义,这是 JMM 对编译器和处理器弱内存模型的保证,在给予充分的可操作空间下,对编译器和处理器的重排序进行一定的约束。也就是说,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行

JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是执行结果不能被改变。

文字可能不是很好理解,我们举个例子,来解释下第 2 条定义:虽然两个操作之间存在 Happens-before 关系,但不意味着 Java 平台的具体实现必须要按照 Happens-before 关系指定的顺序来执行。

int a = 1;      // A
int b = 2;      // B
int c = a + b;  // C

根据 Happens-before 规则(下文会讲),上述代码存在 3 个 Happens-before 关系:

1)A Happens-before B

2)B Happens-before C

3)A Happens-before C

可以看出来,在 3 个 Happens-before 关系中,第 2 个和第 3 个是必需的,但第 1 个是不必要的。

也就是说,虽然 A Happens-before B,但是 A 和 B 之间的重排序完全不会改变程序的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。

看下面这张 JMM 的设计图更直观:

image.png

其实,可以这么简单的理解,为了避免 Java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法,JMM 就出了这么一个简单易懂的 Happens-before 原则,一个 Happens-before 规则就对应于一个或多个编译器和处理器的重排序规则,这样,我们只需要弄明白 Happens-before 就行了。

image.png

8 条 Happens-before 规则

《JSR-133:Java Memory Model and Thread Specification》定义了如下 Happens-before 规则, 这些就是 JMM 中“天然的” Happens-before 关系,这些 Happens-before 关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,JVM 可以对它们随意地进行重排序:

1)程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生(Happens-before)于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。

这个很好理解,符合我们的逻辑思维。比如我们上面举的例子:

int a = 1;      // A
int b = 2;      // B
int c = a + b;  // C

根据程序次序规则,上述代码存在 3 个 Happens-before 关系:

  • A Happens-before B
  • B Happens-before C
  • A Happens-before C

2)管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是 “同一个锁”,而 “后面” 是指时间上的先后。

这个规则其实就是针对 synchronized 的。JVM 并没有把 lockunlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorentermonitorexit 来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块 — synchronized

举个例子:

synchronized (this) { // 此处自动加锁
    if (x < 1) {
        x = 1;
    }      
} // 此处自动解锁

根据管程锁定规则,假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 1,执行完自动释放锁,线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x == 1。

3)volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的 “后面” 同样是指时间上的先后。

这个规则就是 JDK 1.5 版本对 volatile 语义的增强,其意义之重大,靠着这个规则搞定可见性易如反掌。

举个例子:

image.png

假设线程 A 执行 writer() 方法之后,线程 B 执行 reader() 方法。

根据根据程序次序规则:1 Happens-before 2;3 Happens-before 4。

根据 volatile 变量规则:2 Happens-before 3。

根据传递性规则:1 Happens-before 3;1 Happens-before 4。

也就是说,如果线程 B 读到了 “flag==true” 或者 “int i = a” 那么线程 A 设置的“a=42”对线程 B 是可见的。

看下图:

image.png

4)线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。

比如说主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的所有操作。

5)线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread 对象的 join() 方法是否结束、Thread 对象的 isAlive() 的返回值等手段检测线程是否已经终止执行。

6)线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread 对象的 interrupted() 方法检测到是否有中断发生。

7)对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

8)传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。

“时间上的先发生” 与 “先行发生”

上述 8 种规则中,还不断提到了时间上的先后,那么,“时间上的先发生” 与 “先行发生(Happens-before)” 到底有啥区别?

一个操作 “时间上的先发生” 是否就代表这个操作会是“先行发生” 呢?一个操作 “先行发生” 是否就能推导出这个操作必定是“时间上的先发生”呢?

很遗憾,这两个推论都是不成立的。

举两个例子论证一下:

private int value = 0;

// 线程 A 调用
pubilc void setValue(int value){    
    this.value = value;
}

// 线程 B 调用
public int getValue(){
    return value;
}

假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了 setValue(1),然后线程 B 调用了同一个对象的 getValue() ,那么线程 B 收到的返回值是什么?

我们根据上述 Happens-before 的 8 大规则依次分析一下:

由于两个方法分别由线程 A 和 B 调用,不在同一个线程中,所以程序次序规则在这里不适用;

由于没有 synchronized 同步块,自然就不会发生 lock 和 unlock 操作,所以管程锁定规则在这里不适用;

同样的,volatile 变量规则,线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。

因为没有一个适用的 Happens-before 规则,所以第 8 条规则传递性也无从谈起。

因此我们可以判定,尽管线程 A 在操作时间上来看是先于线程 B 的,但是并不能说 A Happens-before B,也就是 A 线程操作的结果 B 不一定能看到。所以,这段代码是线程不安全的。

想要修复这个问题也很简单?既然不满足 Happens-before 原则,那我修改下让它满足不就行了。比如说把 Getter/Setter 方法都用 synchronized 修饰,这样就可以套用管程锁定规则;再比如把 value 定义为 volatile 变量,这样就可以套用 volatile 变量规则等。

这个例子,就论证了一个操作 “时间上的先发生” 不代表这个操作会是 “先行发生(Happens-before)”

再来看一个例子:

// 以下操作在同一个线程中执行
int i = 1;
int j = 2;

假设这段代码中的两条赋值语句在同一个线程之中,那么根据程序次序规则,“int i = 1” 的操作先行发生(Happens-before)于 “int j = 2”,但是,还记得 Happens-before 的第 2 条定义吗?还记得上文说过 JMM 实际上是遵守这样的一条原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

所以,“int j=2” 这句代码完全可能优先被处理器执行,因为这并不影响程序的最终运行结果。

那么,这个例子,就论证了一个操作 “先行发生(Happens-before)” 不代表这个操作一定是“时间上的先发生”

这样,综上两例,我们可以得出这样一个结论:Happens-before 原则与时间先后顺序之间基本没有因果关系,所以我们在衡量并发安全问题的时候,尽量不要受时间顺序的干扰,一切必须以 Happens-before 原则为准。

Happens-before 与 as-if-serial

综上,我觉得其实读懂了下面这句话也就读懂了 Happens-before 了,这句话上文也出现过几次:JMM 其实是在遵循一个基本原则,即只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

再回顾下 as-if-serial 语义:不管怎么重排序,单线程环境下程序的执行结果不能被改变。

各位发现没有?本质上来说 Happens-before 关系和 as-if-serial 语义是一回事,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。只不过后者只能作用在单线程,而前者可以作用在正确同步的多线程环境下:

  • as-if-serial 语义保证单线程内程序的执行结果不被改变,Happens-before 关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。Happens-before 关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 Happens-before 指定的顺序来执行的。

References

  • 《Java 并发编程的艺术》
  • 《深入理解 Java 虚拟机 - 第 3 版》

六、「有点收获」三种基本方法创建线程

挺基础的知识,一开始不是很愿意写,毕竟这种简单的知识大家不一定愿意看,而且容易写的大众化,不过还好梳理一遍下来还算是有点收获,比如我看了 Thread 类重写的 run 方法,才明白为什么可以把任务(Runnable)和线程本身(Thread)分开来。

创建线程的三种方法

线程英译是 Thread,这也是 Java 中线程对应的类名,在 java.lang 包下。

注意下它实现了 Runnable 接口,下文会详细解释。

image.png

线程与任务合并 — 直接继承 Thread 类

线程创建出来自然是需要执行一些特定的任务的,一个线程需要执行的任务、或者说需要做的事情就在 Thread 类的 run 方法里面定义。

这个 run 方法是哪里来的呢?

事实上,它并不是 Thread 类自己的。Thread 实现了 Runnable 接口,run 方法正是在这个接口中被定义为了抽象方法,而 Thread 实现了这个方法。

所以,我们把这个 Runnable 接口称为任务类可能更好理解。

image.png

如下,就是通过集成 Thread 类创建一个自定义线程 Thread1 的示例:

// 自定义线程对象
class Thread1 extends Thread {
    @Override
    public void run() {
        // 线程需要执行的任务
        ......
    }
}

// 创建线程对象
Thread1 t1 = new Thread1();

看这里,Thread 类提供了一个构造函数,可以为某个线程指定名字:

image.png

所以,我们可以这样:

// 创建线程对象
Thread1 t1 = new Thread1("t1");

这样,控制台打印的时候就比较明了,一眼就能知道是哪个线程输出的。

当然了,一般来说,我们写的代码都是下面这种匿名内部类简化版本的:

// 创建线程对象
Thread t1 = new Thread("t1") {
    @Override
    // run 方法内实现了要执行的任务
    public void run() {
        // 线程需要执行的任务
        ......
    }
};

线程与任务分离 — Thread + 实现 Runnable 接口

假如有多个线程,这些线程执行的任务都是一样的,那按照上述方法一的话我们岂不是就得写很多重复代码?

所以,我们考虑把线程执行的任务与线程本身分离开来。

class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程需要执行的任务
        ......
    }
}

// 创建任务类对象
MyRunnable runnable = new MyRunnable();
// 创建线程对象
Thread t2 = new Thread(runnable);

除了避免了重复代码,使用实现 Runnable 接口的方式也比方法一的单继承 Thread 类更具灵活性,毕竟一个类只能继承一个父类,如果这个类本身已经继承了其它类,就不能使用第一种方法了。另外,用这种方式,也更容易与线程池等高级 API 相结合。

因此,一般来说,更推荐使用这种方式去创建线程。也就是说,不推荐直接操作线程对象,推荐操作任务对象。

上述代码使用匿名内部类的简化版本如下:

// 创建任务类对象
Runnable runnable = new Runnable() {
    public void run(){
        // 要执行的任务
        ......
    }
};

// 创建线程对象
Thread t2 = new Thread(runnable);

同样的,我们也可以为其指定线程名字:

Thread t2 = new Thread(runnable, "t2");

以上两个 Thread 的构造函数如图所示:

image.png

可以发现,Thread 类的构造函数无一例外全部调用了 init 方法,这个方法到底做了啥?我们点进去看看:

image.png

它将构造函数传进来的 Runnable 对象传给了一个成员变量 target。

image.png

target 就是 Thread 类中定义的 Runnable 对象,代表着需要执行的任务(What will be run)。

这个变量的存在,就是我们能够把任务(Runnable)和线程本身(Thread)分开的原因所在。看下面这段代码:

image.png

没错,这就是 Thread 类默认实现的 run 方法。

在使用第一种方法创建线程的时候,我们定义了一个 Thread 子类并重写了其父类的 run 方法,所以这个父类实现的 run 方法不会被执行,执行的是我们自定义的子类中的 run 方法。

而在使用第二种方法创建线程的时候,我们并没有在 Thread 子类中重写 run 方法,所以父类默认实现的 run 方法就会被执行。

而这段 run 方法代码的意思就是说,如果 taget != null,也就是说如果 Thread 构造函数中传入了 Runnable 对象,那就执行这个 Runnable 对象的 run 方法。

线程与任务分离 — Thread + 实现 Callable 接口

虽然 Runnable 挺不错的,但是仍然有个缺点,那就是没办法获取任务的执行结果,因为它的 run 方法返回值是 void。

这样,对于需要获取任务执行结果的线程来说,Callable 就成为了一个完美的选择。

Callable 和 Runnable 基本差不多:

image.png

和 Runnbale 比起来,Callable 不过就是把 run 改成了 call。当然,最重要的是!和 void run 不同,这个 call 方法是拥有返回值的,而且能够抛出异常。

这样,一个很自然的想法,就是把 Callable 作为任务对象传给 Thread,然后 Thread 重写 call 方法就完事儿。

But,遗憾的是,Thread 类的构造函数里并不接收 Callable 类型的参数。

所以,我们需要把 Callable 包装一下,包装成 Runnable 类型,这样就能传给 Thread 构造函数了。

为此,FutureTask 成为了最好的选择。

image.png

可以看到 FutureTask 间接继承了 Runnable 接口,因此它也可以看作是一个 Runnable 对象,可以作为参数传入 Thread 类的构造函数。

另外,FutureTask 还间接继承了 Future 接口,并且,这个 Future 接口定义了可以获取 call() 返回值的方法 get:

image.png

看下面这段代码,使用 Callable 定义一个任务对象,然后把 Callable 包装成 FutureTask,然后把 FutureTask 传给 Thread 构造函数,从而创建出一个线程对象。

另外,Callable 和 FutureTask 的泛型填的就是 Callable 任务返回的结果类型(就是 call 方法的返回类型)。

class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        // 要执行的任务
        ......
        return 100;
    }
}
// 将 Callable 包装成 FutureTask,FutureTask也是一种Runnable
MyCallable callable = new MyCallable();
FutureTask<Integer> task = new FutureTask<>(callable);
// 创建线程对象
Thread t3 = new Thread(task);

当线程运行起来后,可以通过 FutureTask 的 get 方法获取任务运行结果:

Integer result = task.get();

不过,需要注意的是,get 方法会阻塞住当前调用这个方法的线程。比如说我们在主线程中调用了 get 方法去获取 t3 线程的任务运行结果,那么只有这个 call 方法成功返回了,主线程才能够继续往下执行。

换句话说,如果 call 方法一直得不到结果,那么主线程也就一直无法向下运行。

启动线程

OK,综上,我们已经把线程成功创建出来了,那么怎么把它启动起来呢?

以第一种创建线程的方法为例:

// 创建线程
Thread t1 = new Thread("t1") {
    @Override   
    // run 方法内实现了要执行的任务
    public void run() {
        // 线程需要执行的任务
        ......  
    }
};// 启动线程t1.start();

这里涉及一道经典的面试题,即为什么使用 start 启动线程,而不使用 run 方法启动线程

使用 run 方法启动线程看起来好像并没啥问题,对吧,run 方法内定义了要执行的任务,调用 run 方法不就执行了这个任务了?

这确实没错,任务确实能够被正确执行,但是并不是以多线程的方式,当我们使用 t1.run() 的时候,程序仍然是在创建 t1 线程的 main 线程下运行的,并没有创建出一个新的 t1 线程。

举个例子:

// 创建线程
Thread t1 = new Thread("t1") {    
    @Override   
    // run 方法内实现了要执行的任务 
    public void run() {
        // 线程需要执行的任务
        System.out.println("开始执行");
        FileReader.read(文件地址);
        // 读文件
    }
};

t1.run();
System.out.println("执行完毕");

如果使用 run 方法启动线程,"执行完毕" 这句话需要在文件读取完毕后才能够输出,也就是说读文件这个操作仍然是同步的。假设读取操作花费了 5 秒钟,如果没有线程调度机制,这 5 秒 CPU 什么都做不了,其它代码都得暂停。

而如果使用 start 方法启动线程,"执行完毕" 这句话在文件读取完毕之前就会被很快地输出,因为多线程让方法执行变成了异步的,读取文件这个操作是 t1 线程在做,而 main 线程并没有被阻塞。

七、线程的状态与《Java 并发编程的艺术》

源码对初学者来说可能并不通俗易懂,但不可否认源码才是最权威的学习文档,我自己刚开始的时候根本没有看源码的习惯,宁愿多百度几下也不去翻源码,很多大佬老师都说这是对源码的畏惧心理,从我自己的角度来说,我觉得其实就是懒罢了,懒得从那么多文件那么多行代码里面找到自己需要看的东西,不如百度两下看看大佬们怎么说的拿来即用,多舒服。

后来慢慢地自己去翻源码之后,才发现自己翻一遍确实印象更深刻一点。而且网络上各种文章层次不齐,如果没有辨别的能力,对照源码百度不失为一个正确的选择。

至于为什么我要在文章开头提一嘴源码的重要性,因为《Java 并发编程的艺术》一书中关于线程状态及转换的那张被广为流传的图片,其实也存在几处小错误或者说不那么准确的地方,这个下文我会指出来的,诚然,对于已经掌握的小伙伴这几个小错误自然无伤大雅,But 对于初学者或者不是很了解的小伙伴可能就需要浪费一些时间去辨别。

OK,回到本文的主题,大部分同学接触并发编程的时候,最先开始学的应该都是如何创建线程以及线程的几种状态吧。上篇文章我们学习了如何去创建线程,这篇文章我们来了解下线程拥有的几种状态也就是线程的生命周期。

总览

线程在自身的生命周期中,并不是固定地处于某个状态,而是随着代码的执行在不同的状态之间进行切换。

线程的六种状态在以及各种状态之间的转换操作在 Thread 类中写得非常清楚:

image.png

再来看看文章开头提到的《Java 并发编程艺术》中线程状态变迁的图片:

image.png

参照上述这段源码注释以及图片,我们来解释下这六种状态。

NEW 状态

Thread state for a thread which has not yet started.

初始状态,线程被创建出来但是还没有开始运行,也就是还没有调用 start 方法。

RUNNABLE 状态

Thread state for a runnable thread.

A thread in the runnable state is executing in the Java virtual machine

but it may waiting for other resources from the operating system such as processor.

当调用了 start 方法后,线程就进入了运行状态(RUNNABLE ),Java 线程将操作系统中线程的就绪(READY)和运行(RUNNING)两种状态统称为 “RUNNABLE”。

回顾下操作系统中经典的线程五态模型:

image.png

BLOCKED 状态

Thread state for a thread blocked waiting for a monitor lock.

A thread in the blocked state is waiting for a monitor lock

to enter a synchronized block/method or

reenter a synchronized block/method after calling {Object.wait}.

阻塞状态,表示线程阻塞于锁。

从 Thread 类的源码注释中我们可以看出,这个锁指的是 monitor 锁,即 synchronized

就是说当线程试图获取 synchronized 锁住的对象的时候,如果竞争锁失败了,那么这个线程就进入 BLOCKED 态

当持有该对象锁的线程的 synchronized 代码块执行完毕的时候,会唤醒该对象上所有 BLOCKED 的线程(BLOCKED 队列)重新竞争,如果其中 t 线程竞争成功,那么 t 线程就会从 BLOCKED 状态进入 RUNNABLE 状态 ,而其它竞争失败的线程仍然处于 BLOCKED 状态。

不同于 synchronized 锁的是,阻塞在 J.U.C 的 Lock 接口下的锁的线程状态并不是 BLOCKED 态,因为 Lock 接口对于阻塞的实现均使用了LockSupport 类中的相关方法。从图片中可以看出,阻塞在 Lock 接口的线程状态是 WAITING 状态。这个下文还会详细讲解的。

关于如何进入 BLOCKED 状态这块是网络上文章出现错误的重灾区,其罪魁祸首我想就是《Java 并发编程艺术》中的那张图片,从图片中可以看到,线程进入 BLOCKED 状态只有两种方法:进入 synchronized 方法或 synchronized 块。

我百度了下 “Java 线程状态” 然后挑了排名第一的文章:

image.png

文章中写的和《Java 并发编程艺术中》那张图片一样。

当然,并不是说它这么写是错的,但是,他这样写确实还有一点并没有说清楚,很模棱两可,但是源码中写得很明白,请看注释:

image.png

非常清楚,翻译一下,当一个调用了 Object.wait 的线程(处于 WAITING 状态),被另一个线程唤醒后,重新进入 synchronized 区域,此时需要重新竞争获取锁,如果竞争失败了,那么线程就变成 BLOCKED 状态。

!!!是不是很重要。如果没有写清楚这一点,很多小伙伴是不是都会认为当一个调用了 Object.wait 的线程(处于 WAITING 状态),被另一个线程唤醒后就可以直接等待操作系统分配 CPU 资源然后继续往下运行了(RUNNABLE)?

看到这里,可能小伙伴心里有数了,会得出 WAITING -> BLOCKED 这样的一个状态转换流程。

这当然是错误的。 WAITING -> RUNNABLE -> BLOCKED 这样的流程才是正确的。

解释下,当调用了 Object.wait 后,线程进入该对象的 WAITING 队列(等待队列),线程状态变为 WAITING。当被另一个线程唤醒后,会先转换为 RUNNABLE 状态,等待操作系统分配 CPU 资源。分配到 CPU 资源后,该线程去竞争锁,如果竞争成功,才可以继续往下运行;如果竞争失败,就会从 RUNNABLE 状态变为 BLOCKED 状态。

image.png

其实可以看出来,上面说的这点和我们一开始说的 “当线程试图获取 synchronized 锁住的对象(也就是进入 synchronized 方法或块)的时候,如果竞争失败了,那么这个线程就会进入阻塞态” 最终结论是一致的。

只不过大部分文章写得都比较模糊罢了,所以为了更清楚点,《Java 并发编程的艺术》中的图片可以修改成这样:

image.png

WAITING 状态

Thread state for a waiting thread. A thread is in the waiting state due to calling one of the following methods:

  • {Object.wait} with no timeout
  • {Thread.join} with no timeout
  • {LockSupport.park}

A thread in the waiting state is waiting for another thread to form a particular action.

For example, a thread that has called Object.wait() on an object is waiting for another thread to call Object.notify() or Object.notifyAll() on that object. A thread that has called Thread.join() is waiting for a specified thread to terminate.

等待状态,进入等待状态的线程需要依靠其他线程的通知才能够返回到 RUNNABLE 状态。

从 Thread 类的源码注释以及图片中可以看出来,线程从 RUNNABLE 到 WAITING 状态的转换有 3 种场景:

1)调用了 Object.wait

2)调用了 Thread.join(显然这里《Java 并发编程的艺术》图片中出现了笔误,图片中写的是 Object.join)

image.png

3)调用了 LockSupport.park 暂停线程

关于 wait 和 notify/notifyAll 的应用以及 park/unpark,后续会有文章详细解释,这里就简单介绍下 wait 和 join 的区别:

比如说 t 线程用 synchronized 获取了对象锁后,然后调用了 wait() 方法,t 线程就会从 RUNNABLE 状态变成 WAITING 状态。注意,这里是 t 线程。

如果当前线程也就是 main 线程调用 t.join() 方法时,当前线程从 RUNNABLE 态变成 WAITING 态。注意这里是当前线程。

主线程中调用 t1.join() 可以这么理解,当 t1 线程执行完毕后,主线程再继续执行

另外,线程从 WAITING 状态回到 RUNNABLE 的场景,从图片和源码注释中同样也可以很清楚的看出来:

1)调用了 Object.notify

2)调用了 Object.notifyAll

3)调用了 LockSupport.unpark 恢复某个线程的运行

TIMED_WAITING 状态

Thread state for a waiting thread with a specified waiting time.

A thread is in the timed waiting state due to calling one of the following methods with a specified positive waiting time:

  • {Thread.sleep}
  • {Object.wait} with timeout
  • {Thread.join} with timeout
  • {LockSupport.parkNanos}
  • {LockSupport.parkUntil}

超时等待状态,该状态不同于 WAITING,它是可以在指定的时间自行返回到 RUNNABLE 状态。

这个没啥好说的,各种转换场景直接看源码注释和图片即可。

TERMINATED 状态

Thread state for a terminated thread. The thread has completed execution.

终止状态,表示当前线程已经执行完毕。

1 Comment

Leave a Comment

您的电子邮箱地址不会被公开。 必填项已用*标注

close
arrow_upward