初识Java多线程编程

本文主要介绍下线程的相关概念,以及Java中关于多线程的标准类库java.lang.Thread的相关知识。

我们知道进程是程序的运行实例,当我们在操作系统上运行一个程序时,相当于新建了一个进程。那线程是什么呢?线程是操作系统能够进行运算调度的最小单位,一个进程可以包含多个线程。

线程和进程的区别如下:

  1. 进程是程序向操作系统申请资源(如内存空间和文件句柄)的基本单位。线程(Thread)是进程中可独立执行的最小单位。
  2. 一个进程可以包含多个线程。
  3. 同一个进程中的所有线程共享该进程中的资源,如内存空间、文件句柄等。

Java中的Thread

java标准库类java.lang.Thread就是java平台对多线程的实现。Thread类或者其子类的一个实例就是一个线程。

线程的创建、启动与运行

  1. 创建:在java中创建一个线程就是要创建一个Thread类(或其子类)的一个实例

    Thread类有两个常用构造器:Thread()和Thread(Runnable target)。因此创建线程的方式有两种:

    • 定义Thread类的子类:在子类中Override run方法,并实现线程任务处理逻辑;
    • 创建一个Runnable接口:new Thread(new RunnableImp())方式创建线程。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 定义Thread类的子类
    class WelcomeThread extends Thread {
    @Override
    public void run() {
    }
    }
    Thread t0 = new WelcomeThread();
    //实现Runnable接口
    class WelcomeTask implements Runnable {
    @Override
    public void run() {
    }
    }
    Thread t1 = new Thread(new WelcomTask());

    从OOP角度来看,第一种方式基于继承技术,第二种方式基于组合技术。组合的耦合度低于继承,因此大多数情况下我们使用第二种方式创建线程。不过需要注意的一点是:使用组合方式创建线程,多个线程实例会共享同一个Runnable实例

  2. 启动:要启动线程,需要调用Thread类中的run方法

    调用Thread.start()方法即可启动线程,需要注意的是,启动线程后,线程可能不会立即执行,具体什么时候执行由线程调度器决定。

  3. 运行:线程具体何时能够运行是由线程调度器决定的

    一旦线程的run方法执行完毕,相应的线程的运行也就结束了。虽然线程的run方法总是由Java虚拟机直接调用的,Java语言并不阻止我们直接通过Thread实例调用run方法。但是,多数情况下我们不能这样做,因为这样做有违创建线程(对象)的初衷。

Thread类中的属性

线程的属性包括线程的编号(ID)、名称(Name)、线程类别(Daemon)和优先级(Priority),详情如下表所示:

属性 属性类型及用途 只读属性 重要注意事项
编号(ID) long,用于表示不同线程,不同线程编号不同。 线程结束后,该线程的编号可能被其他后建线程使用。
名称(Name) String,面向人,默认值与线程的编号有关,默认格式为:“Thread-线程编号”。 Java不禁止不同线程有相同的名称,但是为了便于调试,线程应当合理取名。
线程类别(Daemon) boolean,true表示为守护线程,否则为用户线程,默认值继承自父线程。 该属性必须在线程启动之前设置,启动后修改该属性会抛出异常。
优先级(Priority) int,给线程调度器用的。Java定义了 1~10优先级,一般默认为5。对于具体线程而言,其优先级的默认值与父线程相同。 一般使用默认优先级即可,设置不当可能出现问题。

守护线程?用户线程? 所谓守护线程,是指在程序运行的时候在后台提供一种通用服务的线程,比如垃圾回收线程就是一个很称职的守护者,并且这种线程并不属于程序中不可或缺的部分。 两者的区别在于: 如果用户线程已经全部退出运行了,只剩下守护线程存在了,虚拟机也就退出了。因为没有了被守护者,守护线程也就没有工作可做了,也就没有继续运行程序的必要了。

Thread类中的常用方法

下表列出了Thread中的常用方法API:

方法 功能 备注
static Thread currentThread() 返回当前线程,即当前代码的执行线程 同一段代码调用该方法,返回值可能对应不同线程。
void run() 用于实现线程的任务处理逻辑 有Java虚拟机直接调用,用户一般不应该调用。
void start() 启动相应线程 一个Thread实例,只能调用一次start方法,多次调用会抛出异常。
void join() 等待相应线程运行结束 若线程A调用线程B的join方法,那么线程A的运行会被暂停,直到线程B运行结束。
static void yield() 使当前线程主动放弃对处理器的占用 这个方法是不可靠的。该方法被调用时当前线程可能仍然继续运行(视系统当前的运行状况而定)
static void sleep(long millis) 使当前线程休眠指定时间

线程进阶

线程的层次关系

Java中的线程并不是孤立的,线程与线程之间总是存在一些联系。假设线程A所执行的代码创建了线程B,那么,习惯上我们称线程B为线程A的子线程, 相应地线程A就被称为线程B的父线程。

当然,子线程所执行的代码中也可以创建其他线程,因此一个子线程也可以是其他线程的父线程。线程的这种父子关系就被称为线程的层次关系。下面我们总结了java中父子线程之间存在的一些关联:

  1. 一个线程是否是守护线程,默认和其父线程一样。
  2. 一个线程的默认优先级和其父进程一样。
  3. java平台没有提供API用于获取一个线程的父线程,或者获取一个线程的所有子线程。
  4. 父子线程之间的运行周期没有必然的联系。比如父线程运行结束,子线程可以继续运行。

线程的生命周期

一个线程从其创建、启动到其运行结束的整个生命周期可能经历若干状态,如下图所示:

Java线程的状态可以使用监控工具查看,也可以通过Thread.getState()调用来获取。Thread.getState() 的返回值类型Thread.State 是一个枚举类型( Enum ) 。Thread.State所定义的线程状态包括以下几种:

  • NEW:一个创建而未启动的线程所处的状态。
  • RUNNABLE:该状态包含两个子状态:READY和RUNNING。前者表示线程可以被线程调度器进行调度而转变为RUNNING状态。后者表示线程正在运行。
  • BLOCKED:一个线程发起一个阻塞式IO操作后,或者申请一个由其他线程持有的独占资源(比如锁)时,相应的线程会处于该状态。
  • WAITING:一个线程执行了某些特定方法之后就会处于这种等待其他线程执行另外一些特定操作的状态。能够使其执行线程变更为WAITING 状态的方法包括:Object.wait() 、Thread.join() 和LockSupport.park(Object) 。能够使相应线程从
    WAITING 变更为RUNNABLE 的相应方法包括: Object.notify()/notifyAll() 和LockSupport.unpark(Object))。
  • TIMED_WAITING:该状态和WAITING类似,差别在于处于该状态的线程并非无限制地等待其他线程执行特定操作,而是处于带有时间限制的等待状态。当其他线程没有在指定时间内执行该线程所期望的特定操作时,该线程的状态自动转换为RUNNABLE。
  • TERMINATED:已经执行结束的线程处于该状态。

Java线程 VS 操作系统线程

操作系统中进程(线程)的状态有:

  • 初始状态(NEW)

  • 可运行状态(READY)

    对应 Java中的 RUNNBALE 状态

  • 运行状态(RUNNING)

    对应 Java中的 RUNNBALE 状态

  • 等待状态(WAITING)

    该状态在 Java中被划分为了 BLOCKEDWAITINGTIMED_WAITING 三种状态

    当线程调用阻塞式 API时,进程(线程)进入等待状态,这里指的是操作系统层面的。从 JVM层面来说,Java线程仍然处于 RUNNABLE 状态

    JVM 并不关心操作系统线程的实际状态,从 JVM 看来,等待CPU使用权(操作系统状态为可运行态)与等待 I/O(操作系统处于等待状态)没有区别,都是在等待某种资源,所以都归入RUNNABLE 状态

  • 终止状态 (TERMINATED)

下面是几个常见问题:

  1. Java线程有几种状态: 六种而不是五种容易漏掉TIMEWAITING状态,操作系统线程状态可以说是五种也可以说三种(排除新建和终止)
  2. 线程sleep之后处于什么状态:timewaiting
  3. A线程被sychnolozy锁阻塞了,B线程被lock锁阻塞了 AB两个线程是否处于相同状态是什么状态:
    • sych锁是blocked状态,blocked状态只能从sych锁进入无其他方式
    • lock锁的阻塞是waiting或timewaiting状态
  4. 当java线程发生IO阻塞时 线程处于什么状态:
    rannable状态,当io阻塞时一定处于操作系统线程状态的waiting状态,操作系统的waiting状态的定义是:表示线程等待(或者说挂起),让出CPU资源给其他线程使用。java线程中 waiting、timewaiting、blocked都必然处于操作系统线程waiting状态、rannable则有可能处于waiting状态(调用阻塞式API)。IO阻塞在java的定义中就是rannable状态只不过让出了cpu资源。

多线程编程中的挑战

多线程的目的在于将原本的串行计算改为并发乃至并行计算,提高程序运行效率。但是,在多线程编程中也存在很多挑战,下面对这些挑战进行简单介绍。

竞态

什么是竞态?竞态指多个线程共享某一资源,导致计算结果有时正确,有时错误的现象。

导致竞态的常见因素是多个线程在没有采取任何控制措施的情况下并发地更新、读取同一个共享变量。

竞态具有两种模式:read-modify-write(读-写-写)和check-then-act(检测后行动)。

  • read-modify-write:先读取共享变量的值,然后根据读到的数据作一些计算,接着更新共享变量的值。比如我们常见的“i++”c操作包含三条指令。

  • check-then-act:读取某个共享变量的值, 根据该变量的值决定下一步的动作是什么。

原子性

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

所谓“不可分割”,这里有两层含义:

  • 其中一个含义是指访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会“看到”该操作执行了部分的中间效果。
  • 访问同一组共享变量的原子操作是不能够被交错的。

Java中有两种方式来实现原子性:

  • :锁具有排他性,可以保证同一时间只能有一个线程访问共享变量。

  • CAS:其实现方式与锁本质上是相同的,差别在于锁通常在软件这一层实现的,而CAS是直接在硬件这一层次实现的。

    CAS是一种乐观锁,通过CPU指令实现,可以以无锁+循环方式实现原子性读写操作。 CAS有三个操作数,旧值A,新值B,以及需要读取的内存值V,在更新一个变量时,当且仅当A=V相同时,CAS才会将内存值V修改为B,否则什么都不做。

    其优点是,相比较重量级锁synchronized,它性能更高,是轻量级的乐观锁。但是也有缺点:

    • ABA问题: 线程C、D;线程D将A修改为B后又修改为A,此时C线程以为A没有改变过,java的原子类AtomicStampedReference,通过控制变量值的版本号来保证CAS的正确性。
    • 自旋时间过长: 如果资源竞争激烈,多线程自旋长时间消耗资源。
    • 只能保证一个变量的原子操作。

在Java中除了long和double外的所有其他基本类型的写操作都是原子的,对于任何变量的读操作都是原子的。

可见性

在多线程环境下,一个线程对某个共享变量进行更新之后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至永远也无法读取到这个更新的结果。这就是线程安全问题的另外一个表现形式:可见性(Visibility)。

并发编程下,导致可见性问题的原因来自两方面:第一个是JIT编译器优化;第二个是计算机的存储系统。

编译器优化导致不可见

看下面一个可见性的例子:启动一个线程,如果10秒钟后线程没有执行完,调用cancel()方法取消线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class VisibilityDemo {
public static void main(String[] args) throws InterruptedException {
TimeConsumingTask timeConsumingTask = new TimeConsumingTask();
Thread thread = new Thread(new TimeConsumingTask());
thread.start();
// 指定的时间内任务没有执行结束的话,就将其取消
Thread.sleep(10000);
timeConsumingTask.cancel();
}
}
class TimeConsumingTask implements Runnable {
private boolean toCancel = false;
@Override
public void run() {
while (! toCancel) {
if (doExecute()) {
break;
}
}
if (toCancel) {
System.out.println("Task was canceled.");
} else {
System.out.println("Task done.");
}
}
private boolean doExecute() {
boolean isDone = false;
System.out.println("executing...");
// 模拟实际操作的时间消耗
Tools.randomPause(50);
// 省略其他代码
return isDone;
}
public void cancel() {
toCancel = true;
System.out.println(this + " canceled.");
}
}

上述代码的运行结果可能是:线程一直在运行,没有打印“Task is canceled”。这种现象只有一种解释,那就是子线程thread所
读取到的toCancel变量值始终是false,尽管某个时刻main线程会将共享变量toCancel的值更新为true。可见,这里产生了可见性问题,即main线程对共享变量toCancel的更新对子线程thread而言不可见

上述例子中的可见性问题是因为代码没有给JIT编译器足够的提示而使得其认为状态变量toCancel只有一个线程对其进行访问,从而导致JIT编译器为了避免重复读取状态变量toCancel以提高代码的运行效率,而将TimeConsumingTask的run方法中的while循环优化成与如下代码等效的本地代码(机器码):

1
2
3
4
5
6
7
if (! toCancel) {
while (true) {
if (doExecute()) {
break;
}
}
}

不幸的是,此时这种优化导致了死循环,也就是我们所看到的程序一直运行而没有退出。

存储系统导致不可见

每个处理器都有其寄存器,而一个处理器无法读取另外一个处理器上的寄存器中的内容。因此,如果两个线程分别运行在不同的处理器上,而这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。

另外,即便某个共享变量是被分配到主内存中进行存储的,也不能保证该变量的可见性。这是因为处理器对主内存的访问并不是直接访问,而是通过其高速缓存(Cache)子系统进行的。一个处理器上运行的线程对变量的更新可能只是更新到该处理器的写缓冲器(Store Buffer)中,还没有到达该处理器的高速缓存中,更不用说到主内存中了。而一个处理器的写缓冲器中的内容无法被另外一个处理器读取,因此运行在另外一个处理器上的线程无法看到这个线程对某个共享变量的更新。

有序性

有序性是指程序按照代码的先后顺序执行。但是一方面编译器在编译代码的时候,为了优化性能有时候会改变程序中语句的先后顺序。另一方面,处理器在执行指令的时候也有可能进行指令重排序。

在单线程场景中,这种重排序不会导致程序运行结果的变化,但是在多线程场景中,重排序可能给程序带来意向不到的错误。

我们可以将重排序划分成两类,如下表所示:

  • 指令重排序

    主要发生在三个地方:javac编译生成字节码文件,JIT编译字节码生成机器码,以及处理器执行机器码。一般重排序导致程序出错的概率很小,但是一旦出错,可能造成很大影响。

  • 存储子系统重排序(又叫内存重排序)

    主存相对于处理器是一个慢速设备,为了提高效率,处理器并不是直接访问主内存,而是通过高速缓存(Cache)访问主内存的,并且引入了写缓冲器以提高写告诉缓存操作的效率。这里将高速缓存和写缓冲器称之为存储子系统。

    存储子系统重排序是指:它是一种现象而不是一种动作,它并没有真正对指令执行顺序进行调整,而只是造成了一种指令的执行顺序像是被调整过一样的现象,其重排序的对象是内存操作的结果,因此又叫“内存重排序”。

    从处理器的角度来说,读内存操作的实质是从指定的RAM地址加载数据(通过高速缓存加载)到寄存器,因此读内存操作通常被称为Load,写内存操作的实质是将数据(可能作为操作数直接存储在指令中,也可能存储在寄存器中)存储到指定地址表示的RAM存储单元中,因此写内存操作通常被称为Store。所以,内存重排序实际上只有以下4种可能:

    重排序类型 含义
    LoadLoad重排序(Loads reordered after loads) 该重排序指一个处理器上先后执行两个读内存操作L1和L2,其他处理器对这两个内存操作的感知顺序可能是L2→L1 [10] ,即L1被重排序到L2之后
    StoreStore重排序(Stores reordered after stores) 该重排序指一个处理器上先后执行两个写内存操作W1和W2,其他处理器对这两个内存操作的感知顺序可能是W2→W1,即W1被重排序到W2之后
    StoreStore重排序(Stores reordered after stores) 该重排序指一个处理器上先后执行读内存操作L1和写内存操作W2,其他处理器对这两个内存操作的感知顺序可能是W2→L1,即L1被重排序到W2之后
    StoreLoad重排序(Stores reordered after loads) 该重排序指一个处理器上先后执行写内存操作W1和读内存操作L2,其他处理器对这两个内存操作的感知顺序可能是L2→W1,即W1被重排序到L2之后

上下文切换

概念

上下文切换(Context Switch)在某种程度上可以被看作多个线程共享同一个处理器的产物 [12] ,它是多线程编程中的一个重要概念。

单处理器上的多线程其实就是通过这种时间片分配的方式实现的。时间片决定了一个线程可以连续占用处理器运行的时间长度。当一个进程中的一个线程由于其时间片用完或者其自身的原因(比如,它需要稍后再继续运行)被迫或者主动暂停其运行时,另外一个线程(可能是同一个进程或者其他进程中的一个线程)可以被操作系统(线程调度器)选中占用处理器开始或者继续其运行。这种一个线程被暂停,即被剥夺处理器的使用权,另外一个线程被选中开始或者继续运行的过程就叫作线程上下文切换

从Java应用的角度来看,一个线程的生命周期状态在RUNNABLE状态与非RUNNABLE状态(包括BLOCKED、WAITING和TIMED_WAITING中的任意一个子状态)之间切换的过程就是一个上下文切换的过程。当一个线程的生命周期状态由RUNNABLE转换为非RUNNABLE时,我们称这个线程被暂停 。线程的暂停就是相应线程被切出的过程,这里操作系统会保存相应线程的上下
文,以便该线程稍后再次进入RUNNABLE状态时能够在之前执行进度的基础上进展。而一个线程的生命周期状态由非RUNNABLE 状态进入RUNNABLE 状态时, 我们就称这个线程被唤醒(Wakeup)。一个线程被唤醒仅代表该线程获得了一个继续运行的机会,而并不代表其立刻可以占用处理器运行。

分类

按照导致上下文切换的因素进行划分,可以将上下文切换分为自发性上下文切换和非自发性上下文切换。

  • 自发性上下文切换
    • 从Java平台的角度来看,一个线程在其运行过程中执行下列任意一个方法都会引起自发性上下文切换:Thread.sleep,Object.wait()/wait(long timeout)/wait(long timeout, int nanos),Thread.yield (),Thread.join()/Thread.join(long timeout),LockSupport.park ()。
    • 线程发起了I/O操作(如读取文件)或者等待其他线程持有的锁也会导致自发性上下文切换。
  • 非自发性上下文切换
    • 非自发性上下文切换 指线程由于线程调度器的原因被迫切出。导致非自发性上下文切换的常见因素包括被切出线程的时间片用完或者有一个比被切出线程优先级更高的线程需要被运行。
    • 比如JVM中的垃圾会收可能导致非自发性上下文切换。

活性故障

由资源稀缺性或者程序自身的问题和缺陷导致线程一直处于非RUNNABLE状态,或者线程虽然处于RUNNABLE状态但是其要执行的任务却一直无法进展的现象就被称为线程活性故障。常见的活性故障包括以下几种:

  • 死锁:死锁产生的典型场景是一个线程X持有资源A的时候等待另外一个线程释放资源B,而另外一个线程Y在持有资源B的时
    候却等待线程X释放资源A。最终导致两个线程都处于非RUNNABLE状态。
  • 锁死:锁死就好比睡美人的故事中睡美人醒来的前提是她要得到王子的亲吻,但是如果王子无法亲吻她(比如王子“挂了”……),那么睡美人将一直沉睡!
  • 活锁:活锁好比小猫试图咬自己的尾巴,虽然它总是追着自己的尾巴咬,但却始终无法咬到。活锁的外在表现是线程可能处于RUNNABLE状态,但是线程所要执行的任务却丝毫没有进展,即线程可能一直在做无用功。
  • 饥饿:饥饿好比母鸟给雏鸟喂食的情形,健壮的雏鸟总是抢先从母鸟的嘴中抢到食物,从而导致那些弱小的雏鸟总是挨饿。饥饿就是线程因无法获得其所需的资源而使得任务执行无法进展的现象。

资源争用

由于资源的稀缺性或者资源本身的特性,我们往往需要在多个线程之间共享同一个资源。但是有些资源具有排他性,即一次只能被一个线程占用,比如处理器、数据库连接、文件等。

资源争用:指一个线程占用一个排他性资源进行访问而未释放其对资源所有权时,其他线程视图访问该资源的现象。

既然存在资源争用,那么就会带来另外一个问题,即资源调度。当多个线程同时申请某个资源时,应该把资源分配给哪个线程呢?资源调度策略的一个常见特性就是它能否保证公平性。如果按照线程申请顺序,分配资源,那么这种策略被称为公平的,反之则不公平。

  • 公平调度策略:一种常见策略就是排队
    • 优点:线程申请资源所需的时间偏差较小,并且不会导致饥饿现象。
    • 缺点:吞吐率小,因为需要维护线程申请资源的顺序,开销较大。
  • 非公平调度策略:允许插队现象
    • 优点:吞吐率高,是我们多数情况下首选的资源调度策略。
    • 缺点:资源申请者申请资源所需的时间偏差可能较大,并可能导致饥饿现象。
打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2021-2022 Yin Peng
  • 引擎: Hexo   |  主题:修改自 Ayer
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信