《多线程编程实战指南》总结

Java 并发和多线程编程推荐《Java 并发编程实战》和《多线程编程实战指南》,前者是外国非常受欢迎的书籍的翻译本,后者是国人写的书,符合国人的思维模式。

进程、线程与任务

在操作系统中会运行多个程序,一个运行中的程序就是一个进程,如一个运行中的 Idea 就是一个进程,一个 Java 虚拟机就是一个进程。进程是程序向操作系统申请资源的基本单位

一个进程中可以包含多个线程,同一个进程中的所有线程共享该进程中的资源,如内存空间、文件句柄等。线程是进程中可独立执行的最小单位

线程所要完成的计算被称为任务。特定的线程总是执行着特定的任务。

一个程序往往需要完成许多独立的任务,也就是包含多个线程,操作系统抽象出进程和线程的改变,这样操作系统只需要管理进程,由进程来管理许多的线程,可以降低由操作系统直接管理线程的复杂度。

Java 中线程的实现

Java 是一门面向对象设计语言,所以在 Java 中线程也是一个对象,它是 Java 标准类库 java.lang.Thread。Thread 类或其子类的一个实例就是一个线程。

Thread 类的两个常用构造器是:Thread() 和 Thread(Runnable target)。对应了 Java 中创建线程的两种方式,一种是使用第 1 个构造函数,继承 Thread 类并调用 start() 方法,第二种是实现 Runnable 接口然后传给第 2 个构造函数并调用 start() 方法。

线程的处理逻辑需要写在 run() 方法中,但是不能通过调用 run() 方法来启动线程,而是需要调用 start() 方法。从 JVM 的运行时数据区来看,线程拥有程序计数器、虚拟机栈和本地方法栈,这些需要 JVM 来分配,start() 方法会请求 JVM 分配这些资源并向操作系统申请一个线程,而调用 run() 方法只会在当前线程中执行处理逻辑。由此可见,相比普通对象,创建线程对象的成本要高一些。

Java 中的线程存在层次关系,在当前线程中创建的其他线程被称为当前线程的子线程,子线程也可以有子线程,子线程部分属性的默认值会继承父线程中的属性值,如线程优先级、是否为守护线程等。虽然线程之间有父子的层次关系,但是父线程和子线程的生命周期并没有必然的联系,即父线程运行结束后,子线程可以继续运行,子线程运行结束也不妨碍其父线程的继续运行。

Thread 类

属性

线程的属性包括线程的编号(ID)、名称(name)、是否为守护线程(Daemon)和优先级(Priority)。

线程的编号由 JVM 分配,在 JVM 运行中的一个时间点中,线程的编号是唯一的,也就是说 JVM 会复用已经终止的之前的线程的编号。所以该属性不能用来做线程的唯一标识。

线程的名称可以由程序员设置,默认的线程名称和线程的编号有关,该线程定义名称有助于代码调试和问题定位。

线程分为守护线程和非守护线程,该属性的默认值与父线程的该属性的值相同,非守护线程会阻止进程的终止,但是守护线程不会,也就是说当进程中只剩下守护线程的时候,即使该守护线程还在运行,进程也会终止。守护线程适合执行一些重要性不是很高的任务,例如监控其他线程的执行情况。

Java 定义了 1~10 的 10 个优先级,默认是 5,如果线程有父线程,则默认优先级与父线程的优先级值相等。Java 中定义的优先级数量和操作系统中的并不一样,而且不恰当地设置该属性可能导致线程饥饿,所以不推荐修改该属性。

方法

  • static Thread currentThread():该方法返回当前线程
  • void run():用于定义线程的任务处理逻辑
  • void start():启动相应线程
  • void join():等待相应线程运行结束,如线程 A 调用线程 B 的 join 方法,那么线程 A 的运行会被暂停,直到线程 B 运行结束
  • static void yield():线程礼让,使当前线程失去 CPU 的时间片,但是当前线程有机会重新占用 CPU 时间片,所以这个方法是不可靠的
  • static void sleep(long millis):使当前线程暂停指定时间

线程的生命周期

在 Java 中,线程的状态定义在 Thread 类内部的 State 枚举类中,State 枚举类包含的值有:

  • NEW:一个已创建而未启动的线程处于该状态。由于一个线程实例只能被启动一次,因此一个线程只可能有一次处于该状态。
  • RUNNABLE:正在运行中的线程和正在等待 CPU 时间片的线程(就绪状态)处于该状态。
  • BLOCKED:当线程发起阻塞时 IO 操作后,线程会处于该状态。处于该状态的线程并不会占用处理器资源,当阻塞式 IO 操作完成后,该线程的状态又可以转换为 RUNNABLE 状态。
  • WAITING:当线程执行了某些特定方法之后就会处于这种等待其他线程执行另外一些特定操作的状态。能够使其执行线程变更为 WAITING 状态的方法包括:Object.wait()、Thread.join() 和 LockSupport.park(Object)。能够使相应线程从 WAITING 变更为 RUNNABLE 状态的相应方法包括:Object.notify() 和 LockSupport.unpark(Object)。
  • TIMED_WAITING:该状态与 WAITING 类似,差别在于处于该状态的线程并非无限制等待其他线程执行特定操作,而是处于带有时间限制的等待状态。当其他线程没有在指定时间内执行该线程所期望的特定操作时,该线程的状态自动转换为 RUNNABLE。
  • TERMINATED:已经执行结束的线程处于该状态。由于一个线程实例只能被启动一次,因此一个线程也只可能有一次处于该状态。

线程生命周期流转图:
在这里插入图片描述

线程的终止

Thread 类有有些用于线程停止的方法:stop()、suspend(),但是这些方法已经被废弃。

Java 平台为每个线程唯一一个被称为中断标记的布尔型状态变量用于表示相应线程是否接收到了中断,中断标记值为 true 表示相应线程收到了中断。目标线程可以通过 Thread.currentThread().isInterrupted() 调用来获取该线程的中断标记值,也可以通过 Thread.interrupted() 来获取并重置中断标记值,即 Thread.interrupted() 会返回当前线程的中断标记值并将当前线程中断标记重置为 false。调用一个线程的 interrupt() 相当于将该线程的中断标记置为 true。

目标线程检查中断标记后所执行的操作,被称为目标线程对中断的相应,简称中断响应。设有个发起线程 originator 和目标线程 target,那么 target 对中断的响应一般包括:

  • 无影响。originator 调用 target.interrupt() 不会对 target 的运行产生任何影响。这种情形也可以称为目标线程无法对中断进行响应。InputStream.read()、ReentrantLock.lock() 以及申请内部锁等阻塞方法/操作就属于这种类型。
  • 取消任务的运行。originator 调用 target.interrupt() 会使 target 在侦测到中断那一刻所执行的任务被取消,而这并不会影响 target 继续处理其他任务。
  • 工作者线程停止。originator 调用 target.interrupt() 会使 target 终止,即 target 的生命周期状态变更为 TERMINATED。

Java 标准库中的许多阻塞方法如 Object.wait()、Object.notify()、Thread.sleep() 等对中断的响应方式都是抛出 InterruptedException 等异常。也有些阻塞方法如 InputStream.read()、Lock.lock() 无法中断异常。

能够响应中断的方法通常是执行阻塞前判断中断标记,若中断标记值为 true 则抛出 InterruptedException 异常。按照惯例,抛出 InterruptedException 异常的方法,通常会在其抛出该异常时将当前线程的线程中断标记重置为 false。

如果发现线程给目标线程发送中断的那一刻,目标线程已经由于执行了一些阻塞方法而被暂停(声明周期状态为 WAITING 或者 BLOCKED)了,那么此时 JVM 可能会设置会将该线程唤醒,从而使目标线程得到响应中断的机会。由此可将,给目标线程发送中断还能够产生唤醒目标线程的效果。

线程停止的原因有 run() 方法执行结束的正常停止和运行中抛出异常的异常停止。因此我们可以给线程设置一个布尔类型的线程停止标记,目标线程检测到该标记为 true 使让其 run() 方法返回,这样就实现了线程的终止。

我们之前提到的线程中断标记也是一个布尔类型的,它是否可以用来做线程停止标记呢?

由于线程中断标记可能会被目标线程的某些方法清空,因此从通用性的角度来看线程中断标记并不能作为线程停止标记!而如果只用一个布尔类型的线程停止标记,当线程执行了一些阻塞方法的时候不会检查线程停止标记,所以我们需要将线程停止标记和线程中断标记结合使用。

当需要停止目标线程的时候,除了修改线程停止标记为 true 之外,还需要给目标线程发送线程中断标记。

串行、并行和并发

串行是指一次执行一个任务,多个任务被依次执行,task1 -> task2 -> task3。则执行时间是所有任务耗时的总和。

并行是指一次执行多个任务,多个任务同时开始执行,则执行时间是最长耗时任务的执行时间。

并发是指一次执行一个任务,如先执行 task1,在 task1 执行一段时间后暂停 task1 的执行,转而执行 task 2,在 task 2 执行一段时间后暂停 task2 转而执行 task 3,依次类推,直到所有任务都执行完成。

并发描述的就是多线程运行在一个 CPU 中和情形,线程任务可能会因为 CPU 时间片到期或者发生阻塞式 IO 而暂时不会用到处理器资源,这时候线程任务还没有执行完成,为了充分利用 CPU 资源,提高程序的性能,操作系统不会等待当前线程,而是把当前线程的调用栈和当前指令等资源保存起来,然后执行另一个线程任务。

竞态

在多线程编程中,对于同样的输入,程序的输出有时候是正确的有时候是错误的。这种一个计算结果的正确性与时间有关的现象被称为竞态。竞态是多线程编程的产物,即使运行程序的 CPU 是单核的,也会导致竞态的产生。

竞态产生的条件

竞态的产生伴随着在多线程下对共享变量的访问,竞态产生的条件是一个线程读取共享变量并以该共享变量为基础进行计算的期间另外一个线程更新了该共享变量的值而导致的读取脏数据或者丢失更新的结果

对于局部变量来说,不同的线程访问的是各自的副本,并不存在共享的情况下,所以局部变量的使用不会导致竞态!

竞态的模式

  • read-modify-write

该模式描述的场景是线程 A 读取了一组共享变量并更新了共享变量的值,在还没有将修改后的值同步回主内存的时候,线程 B 从主内存读取了共享变量的旧值,这就造成了读脏数据,线程 B 继续依赖共享变量的旧值计算更新共享变量的结果,该结果是错误的结果,然后线程 B 将错误的结果同步到主内存,最后线程 A 将更新同步到主内存,线程 A 覆盖了线程 B 的更新,这就造成了丢失更新。

read-modify-write 二维表如下:

时间/线程 线程 A 线程 B
t1 从主内存中读取共享变量 var
t2 在 CPU 中修改共享变量 var
t3 从主内存中读取共享变量 var
t4 在 CPU 中修改共享变量 var
t5 将更新后的共享变量同步会主内存
t6 将更新后的共享变量同步会主内存
  • check-then-act

该模式描述的场景是线程 A 读取了共享变量的值并将该值用于条件判断语句决定后续执行代码块 C1,(如使用 if 条件判断),在线程 A 执行条件判断语句之后,执行 C1 代码块之前,另一个线程 B 修改了共享变量的值导致此时基于共享变量进行判断会执行代码块 C2,但是线程 A 依旧是执行代码块 C1。

check-then-act 的二维表如下:

时间/线程 线程 A 线程 B
t1 从主内存中读取共享变量 var
t2 判断共享变量 var,确定执行代码块 C1
t3 从主内存中读取共享变量 var
t4 在 CPU 中修改共享变量 var(基于共享新值判断会执行代码块 C2)
t5 将更新后的共享变量同步会主内存
t6 执行代码块 C1

线程安全

在 Java 多线程编程中,如果一个类在单线程环境下能够运行正常,并且在多线程环境下,在其使用方不必为其做任何改变的情况下也能运行正常,那么我们就称其是线程安全的。反之,如果一个类的单线程环境下运行正常而在多线程环境下则无法正常运行,那么这个类就是非线程安全的。

一个类如果不是线程安全的,我们就说它在多线程环境下直接使用存在线程安全问题。在 Java 标准库中定义了线程安全的类如 Vector、CopyOnWriteArrayList 和 HashTable 等,也定义了非线程安全的类如 ArrayList、HashMap 等。

线程安全问题概括来说表现为 3 个方面:原子性、可见性和有序性。

原子性

原子(Atomic)的字面意思是不可分割的。对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作。相应地我们称该操作具有原子性。

在 Java 语言中,变量的写操作和读操作都是原子操作,这由 JVM 来保证。

Java 提供了多种方式来实现一组操作的原子性,如使用锁,使用 CAS,这也从侧面说明了锁和 CAS 保证了操作的原子性。

理解原子操作这个概念主要需要注意以下两点:

  • 原子操作是针对访问共享变量的操作而言的。也就是说,仅涉及局部变量访问的操作无所谓是否是原子的,或者干脆把这一类操作都看成原子操作。
  • 原子操作时从该操作的执行线程以外的线程来描述的,也就是说它只有在多线程环境下有意义。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值