资讯专栏INFORMATION COLUMN

Java 多线程编程基础——Thread 类

zhoutk / 3208人阅读

摘要:程序执行时,至少会有一个线程在运行,这个运行的线程被称为主线程。程序的终止是指除守护线程以外的线程全部终止。多线程程序由多个线程组成的程序称为多线程程序。线程休眠期间可以被中断,中断将会抛出异常。

线程
我们在阅读程序时,表面看来是在跟踪程序的处理流程,实际上跟踪的是线程的执行。

单线程程序

在单线程程序中,在某个时间点执行的处理只有一个。

Java 程序执行时,至少会有一个线程在运行,这个运行的线程被称为主线程(Main Thread)。

Java 程序在主线程运行的同时,后台线程也在运行,例如:垃圾回收线程、GUI 相关线程等。

Java 程序的终止是指除守护线程(Daemon Thread)以外的线程全部终止。守护线程是执行后台作业的线程,例如垃圾回收线程。我们可以通过 setDaemon() 方法把线程设置为守护线程。

多线程程序

由多个线程组成的程序称为多线程程序(Multithreaded Program)。多个线程运行时,各个线程的运行轨迹将会交织在一起,同一时间点执行的处理有多个。

多线程应用场景:

GUI 应用程序:存在专门执行 GUI 操作的线程(UI Thread)

耗时任务:文件与网络的 I/O 处理

网络服务器同时处理多个客户端请求场景

P.S. 使用 java.nio 包中的类,有时即便不使用线程,也可以执行兼具性能和可扩展性的 I/O 处理。

并行(parallel)与并发(concurrent)的区别

程序运行存在顺序、并行与并发模式。

顺序(sequential)用于表示多个操作依次处理。

并行用于表示多个操作同时处理,取决于 CPU 的个数。

并发用于表示将一个操作分割成多个部分并且允许无序处理。

并发相对于顺序和并行来说比较抽象。单个 CPU 并发处理即为顺序执行,多个 CPU 并发处理可以并行执行。

如果是单个 CPU,即便多个线程同时运行,并发处理也只能顺序执行,在线程之间不断切换。

并发处理包括:并发处理的顺序执行、并发处理的并行执行。

线程和进程的区别

线程之间共享内存
进程和线程之间最大的区别就是内存是否共享。
通常,每个进程都拥有彼此独立的内存空间。一个进程不可以擅自读取、写入其他进程的内存。正因为每个进程内存空间独立,无需担心被其他进程破坏。
线程之间共享内存,使得线程之间的通信实现起来更加自然、简单。一个线程向实例中写入内容,其他线程就可以读取该实例的内容。当有多个线程可以访问同一个实例时,需要正确执行互斥处理。

线程的上下文切换快
进程和线程之间的另一个区别就是上下文切换的繁重程度。
当运行中的进程进行切换时,进程要暂时保存自身的当前状态(上下文信息)。而接着开始运行的进程需要恢复之前保存的自身的上下文信息。
当运行中的线程进行切换时,与进程一样,也会进行上下文切换。但由于线程管理的上下文信息比进程少,所以一般来说,线程的上下文切换要比进程快。

当执行紧密关联的多项工作时,通常线程比进程更加适合。

多线程程序的优点和成本

优点:

充分利用硬件资源如多核 CPU、I/O 设备、网络设备并行工作。

提高 GUI 应用程序响应性,UI Thread 专注界面绘制、用户交互,额外开启线程执行后台任务。

网络应用程序简化建模,每个客户端请求使用多带带的线程进行处理。

缺点(成本):

创建线程需要消耗系统资源和时间,准备线程私有的程序计数器和栈。

线程调度和切换同样需要成本,线程切换出去时需要保存上下文状态信息,以便再次切换回来时能够恢复之前的上下文状态。

相对而言,若是存在耗时任务需要放入子线程中实际执行,线程使用成本可以不计。

多线程编程的重要性

硬件条件满足多线程并行执行的条件之外,还需要程序逻辑能够保证多线程正确地运行,考虑到线程之间的互斥处理和同步处理。

Thread 类 线程的创建与启动

创建与启动线程的两种方法:

利用 Thread 类的子类实例化,创建并启动线程。

利用 Runnable 接口的实现类实例化,创建并启动线程。

线程的创建与启动步骤——方法一:

声明 Thread 的子类(extends Thread),并重写 run() 方法。

创建该类的实例

调用该实例的 start() 方法启动线程

Thread 实例和线程本身不是同一个东西,创建 Thread 实例,线程并未启动,直到 start() 方法调用,同样就算线程终止了,实例也不会消失。但是一个 Thread 实例只能创建一个线程,一旦调用 start() 方法,不管线程是否正常/异常结束,都无法再次通过调用 start() 方法创建新的线程。并且重复调用 start() 方法会抛出 IllegalThreadStateException 异常。

Thread run( ) 方法 和 start() 方法:

run() 方法是可以重复调用的,但是不会启动新的线程,于当前线程中执行。run() 方法放置于 Runnable 接口旨在封装操作。

start() 方法主要执行以下操作:启动新的线程,并在其中调用 run() 方法。

线程的创建与启动步骤——方法二:

声明类并实现 Runnable 接口(implements Runnable),要求必须实现 run() 方法。

创建该类的实例

以该实例作为参数创建 Thread 类的实例 Thread(Runnable target)

调用 Thread 类的实例的 start() 方法启动线程

不管是利用 Thread 类的子类实例化的方法(1),还是利用 Runnable 接口的实现类实例化的方法(2),启动新线程的方法最终都是 Thread 类的 start() 方法。

Java 中存在单继承限制,如果类已经有一个父类,则不能再继承 Thread 类,这时可以通过实现 Runnable 接口来实现创建并启动新线程。

Thread 类本身实现了 Runnable 接口,并将 run() 方法的重写(override)交由子类来完成。

线程的属性

id 和 name

通过 Thread(String name) 构造方法或 void setName(String name),给 Thread 设置一个友好的名字,可以方便调试。

优先级

Java 语言中,线程的优先级从1到10,默认为5。但因程序实际运行的操作系统不同,优先级会被映射到操作系统中的取值,因此 Java 语言中的优先级主要是一种建议,多线程编程时不要过于依赖优先级。

线程的状态

Thread.State 枚举类型(Enum)包括:

NEW
线程实例化后,尚未调用 start() 方法启动。

RUNNABLE
可运行状态,正在运行或准备运行。

BLOCKED
阻塞状态,等待其他线程释放实例的锁。

WAITING
等待状态,无限等待其他线程执行特定操作。

TIMED_WAITING
时限等待状态,等待其他线程执行指定的有限时间的操作。

TERMINATED
线程运行结束

线程的方法 currentThread() 方法

Thread 类的静态方法 currentThread() 返回当前正在执行的线程对象。

sleep() 方法

Thread 类的静态方法 sleep() 能够暂停(休眠)当前线程(执行该语句的线程)运行,放弃占用 CPU。线程休眠期间可以被中断,中断将会抛出 InterruptedException 异常。sleep() 方法的参数以毫秒作为单位,不过通常情况下,JVM 无法精确控制时间。

sleep() 方法调用需要放在 try catch 语句中,可能抛出 InterruptedException 异常。InterruptedException 异常能够取消线程处理,可以使用 interrupt() 方法在中途唤醒休眠状态的线程。

多线程示例程序中经常使用 sleep() 方法模拟耗时任务处理过程。

yield() 方法

Thread 类的静态方法 yield() 能够暂停当前线程(执行该语句的线程)运行,让出 CPU 给其他线程优先执行。如果没有正在等待的线程,或是线程的优先级不高,当前线程可能继续运行,即 yield() 方法无法确保暂停当前线程。yield() 方法类似 sleep() 方法,但是不能指定暂停时间。

join() 方法

Thread 类的实例方法,持有 Thread 实例的线程,将会等待调用 join() 方法的 Thread 实例代表的线程结束。等待期间可以被中断,中断将会抛出 InterruptedException 异常。

示例程序:

public class HelloThread extends Thread {
    @Override
    public void run() {
        System.out.println("hello");
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new HelloThread();
        thread.start();
        thread.join();
    }
}

main() 方法所在的主线程将会等待 HelloThread 子线程执行 run() 方法结束后再执行,退出程序。

并发编程特性

原子性

可见性

有序性

原子性操作问题
原子性概念来源于数据库系统,一个事务(Transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被恢复(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。

并发编程的原子性指对于共享变量的操作是不可分的,Java 基本类型除 long、double 外的赋值操作是原子操作。

非原子操作例如:

counter++;

读取 counter 的当前值

在当前值基础上加1

将新值重新赋值给 counter

Java 语言的解决方式:

使用 synchronized 关键字

使用 java.util.concurrent.atomic

内存可见性问题

计算机结构中,CPU 负责执行指令,内存负责读写数据。CPU 执行速度远超内存读写速度,缓解两者速度不一致引入了高速缓存。 预先拷贝内存数据的副本到缓存中,便于 CPU 直接快速使用。

因此计算机中除内存之外,数据还有可能保存在 CPU 寄存器和各级缓存当中。这样一来,当访问一个变量时,可能优先从缓存中获取,而非内存;当修改一个变量时,可能先将修改写到缓存中,稍后才会同步更新到内存中。

对于单线程程序来说没有太大问题,但是多线程程序并行执行时,内存中的数据将会不一致,最新修改可能尚未同步到内存中。需要提供一种机制保证多线程对应的多核 CPU 缓存中的共享变量的副本彼此一致——缓存一致性协议。

Java 语言的解决方式:

使用 volatile 关键字

使用 synchronized 关键字

如果只是解决内存可见性问题,使用 synchronized 关键字成本较高,考虑使用 volatile 关键字更轻量级的方式。

指令重排序问题

有序性:即程序执行的顺序严格按照代码的先后顺序执行。

Java 允许编译器和处理器为了提高效率对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会可能影响到多线程程序并发执行时候的正确性。

volatile 关键字细节

Java 使用 volatile 关键字修饰变量,保证可见性、有序性。

保证变量的值一旦被修改后立即更新写入内存,同时默认从内存读取变量的值。(可见性)

禁止指令重排序(有序性)

但是 volatile 关键字无法保证对变量操作是原子性的。

线程的互斥处理(synchronized 关键字细节)

每个线程拥有独立的程序计数器(指令执行行号)、栈(方法参数、局部变量等信息),多个线程共享堆(对象),这些区域对应 JVM 内存模型。当多个线程操作堆区的对象时候,可能出现多线程共享内存的问题。

竞态条件

银行取款问题
if(可用余额大于等于取款金额) {

可用余额减去取款金额

}
多个线程同时操作时,余额确认(可用余额大于等于取款金额)和取款(可用余额减去取款金额)两个操作可能穿插执行,无法保证线程之间执行顺序。

线程 A 线程 B
可用余额(1000)大于等于取款金额(1000)?是的 切换执行线程 B
线程 A 处于等待状态 可用余额(1000)大于等于取款金额(1000)?是的
线程 A 处于等待状态 可用余额减去取款金额(1000-1000 = 0)
切换执行线程 A 线程 B 结束
可用余额减去取款金额(0 - 1000 = -1000) 线程 B 结束

当有多个线程同时操作同一个对象时,可能出现竞态条件(race condition),无法预期最终执行结果,与执行操作的时序有关,需要“交通管制”——线程的互斥处理。

Java 使用 synchronized 关键字执行线程的互斥处理。synchronized 关键字可以修饰类的实例方法、静态方法和代码块。

synchronized 关键字保护的是对象而非方法、代码块,使用锁来执行线程的互斥处理。

synchronized 修饰静态方法和实例方法时保护的是不同的对象:

synchronized 修饰实例方法是使用该类的实例对象 this。

synchronized 修饰静态方法是使用该类的类对象 class。

每个对象拥有一个独立的锁,同一对象内的所有 synchronized 方法共用。

synchronized 方法注意事项

基于 synchronized 关键字保护的是对象原则,有如下推论:

一个实例中的 synchronized 方法每次只能由一个线程运行,而非 synchronized 方法则可以同时由多线程运行。

一个实例中的多个 synchronized 方法同样无法多线程运行。

不同实例中的 synchronized 方法可以同时由多线程运行。

synchronized 修饰的静态方法(this 对象)和实例方法(class 对象)之间,可以同时被多线程执行。

synchronized 方法同步使用

synchronized 方法具有可重入性,即获取锁后可以在一个 synchronized 方法,调用其他需要同样锁的 synchronized 方法。

一般在保护实例变量时,将所有访问该变量的方法设置为 synchronized 同步方法。

如果只是想让方法中的某一部分由一个线程运行,而非整个方法,则可使用 synchronized 代码块,精确控制互斥处理的执行范围。

synchronized 方法执行流程

尝试获取对象锁,如果获取到锁进入2,未获取到锁则加入锁的等待队列进入阻塞状态等待被唤醒。

执行 synchronized 方法

释放对象锁,如果等待队列存在线程正在等待获取锁,将其唤醒,当有多个线程处于等待队列,无法明确唤醒某一个,由多个线程竞争获取。

死锁

死锁是指两个或两个以上的进程(线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。

死锁产生的四个必要条件

互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。

请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。

不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。

循环等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合 {P0,P1,P2,···,Pn} 中的 P0 正在等待一个 P1 占用的资源;P1 正在等待 P2 占用的资源,……,Pn 正在等待已被 P0 占用的资源。

产生死锁必须同时满足上述四个条件,只要其中任一条件不成立,死锁可避免。

应该尽量避免在持有一个锁的同时,申请另一个锁。如果确实需要多个锁,应该按照相同的顺序获取锁。

线程的协作(wait()、notify() 方法细节)

多线程之间除了在竞争中做互斥处理,还需要相互协作。协作的前提是清楚共享的条件变量。

wait()、notify()、notifyAll() 都是 Object 类的实例方法,而不是 Thread 类中的方法。这三个方法与其说是针对线程的操作,倒不如说是针对实例的条件等待队列的操作。

操作 obj 条件等待队列中的线程(唤醒、等待):

obj.wait() 将当前线程放入 obj 的条件等待队列。

obj.notify() 从 obj 的条件等待队列唤醒一个线程。

obj.notifyAll() 唤醒 obj 条件等待队列中的所有线程。

wait() 等待方法

每个对象拥有一个锁和锁的等待队列,另外还有一个表示条件的等待队列,用于线程间的协作。调用 wait() 方法会将当前线程放入条件队列等待,等待条件需要等待时间或者依靠其他线程改变(notify()/notifyAll() )。等待期间同样可以被中断,中断将会抛出 InterruptedException 异常。

Object 类的 wait() 方法和 Thread 类的 sleep() 方法在控制线程上主要区别在于对象锁是否释放,从方法所属类可以看出 Object 类的 wait() 方法包含对象锁管理机制。

wait() 实例方法用于线程间通信协作

sleep() 静态方法用于暂停当前线程

两者均会放弃占用 CPU

wait() 方法执行过程

将当前线程放入条件队列等待,释放对象锁。

当前线程进入 WAITINGTIMED_WAITING 状态。

等待时间或者被其他线程唤醒(notify()/notifyAll() ),从条件队列中移除等待线程。

唤醒的线程获得对象锁,进入 RUNNABLE 状态,从 wait() 方法返回,重新执行等待条件检查。

唤醒的线程无法获得对象锁,进入 BLOCKED 状态,加入对象锁的等待队列,继续等待。

notify() 唤醒方法

notify() 和 notifyAll() 方法的区别

notify() 方法会唤醒等待队列中的一个线程。

notifyAll() 方法会唤醒等待队列中所有线程。

通常使用 notifyAll() 方法,相比于 notify() 方法代码更具健壮性,但是唤醒多个线程速度慢些。

注意:调用 notify() 方法之后,唤醒条件队列中等待的线程,并将其移除队列。被唤醒的线程并不会立即运行,因为执行 notify() 方法的线程还持有着锁,等待 notify() 方法所处的同步(synchronized)代码块执行结束才释放锁。随后等待的线程获得锁从 wait() 方法返回,重新执行等待条件检查。

总结:

线程必须持有实例的锁才能执行上述方法(wait()、notify()、notifyAll())

wait()/notify() 方法只能在 synchronized 代码块内被调用,如果调用 wait()/notify() 方法时,当前线程没有持有对象锁,会抛出异常 java.lang.IllegalMonitorStateException

生产者/消费者模式应用

生产者(Producer)生成数据的线程

消费者(Consumer)使用数据的线程

生产者线程和消费者线程通过共享队列进行协作,

生产者/消费者模式在生产者和消费者之间加入了一个桥梁角色,该桥梁角色用于消除线程间处理速度的差异。

Channel 角色持有共享队列 Data,对 Producer 角色和 Consumer 角色的访问执行互斥处理,并隐藏多线程实现。

线程的中断

线程正常结束于 run() 方法执行完毕,但在实际应用中多线程模式往往是死循环,考虑到存在特殊情况需要取消/关闭线程。Java 使用中断机制,通过协作方式传递信息,从而取消/关闭线程。

中断的方法
public static boolean interrupted()
public boolean isInterrupted()
public void interrupt()

interrupt() 和 isInterrupted() 是实例方法,通过线程对象调用。

interrupted() 是静态方法,由当前线程 Thread.currentThread() 实际执行。

线程存在 interrupted 中断状态标记,用于判断线程是否中断。

isInterrupted() 实例方法返回对应线程的中断状态。

interrupted() 静态方法返回当前线程的中断状态,存在副作用清空中断状态。

不同线程状态的中断反应

NEWTERMINATED
调用 interrupt() 方法不起任何作用

RUNNABLE
调用 interrupt() 方法,线程正在运行,且与 I/O 操作无关,设置线程中断状态标记而已。如果线程等待 I/O 操作,则会进行特殊处理。

BLOCKED
调用 interrupt() 方法无法中断正在 BLOCKED 状态的线程

WAITINGTIMED_WAITING
调用 interrupt() 方法设置线程中断状态标记,抛出 InterruptedException 异常。这是一个受检异常,线程必须进行处理。

中断的使用

对于提供线程服务的模块,应该封装取消/关闭线程方法对外提供接口,而不是交由调用者自行调用 interrupt() 方法。

线程状态转换综合图解

结合线程的方法(Thread 类 + Object 类)来看线程的状态转换:

注:

Thread t = new Thread(); Thread 类调用静态方法,t 对象调用实例方法。

Object o = new Object(); Object 类调用静态方法,o 对象调用实例方法。

Running 表示运行中状态,并非 Thread.State 枚举类型。

附录 Runnable 接口和 Callable 接口

Runnable 接口提供的 run() 方法返回值为 void

Callable 接口提供的 call() 方法返回值为泛型

Callable 接口常用与配合 Future、FutureTask 类获取异步执行结果。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/71909.html

相关文章

  • JAVA 线程和并发基础

    摘要:线程可以被称为轻量级进程。一个守护线程是在后台执行并且不会阻止终止的线程。其他的线程状态还有,和。上下文切换是多任务操作系统和多线程环境的基本特征。在的线程中并没有可供任何对象使用的锁和同步器。 原文:Java Multi-Threading and Concurrency Interview Questions with Answers 翻译:并发编程网 - 郑旭东 校对:方腾飞 多...

    vboy1010 评论0 收藏0
  • JAVA 线程和并发基础面试问答

    摘要:多线程和并发问题是技术面试中面试官比较喜欢问的问题之一。线程可以被称为轻量级进程。一个守护线程是在后台执行并且不会阻止终止的线程。其他的线程状态还有,和。上下文切换是多任务操作系统和多线程环境的基本特征。 多线程和并发问题是 Java 技术面试中面试官比较喜欢问的问题之一。在这里,从面试的角度列出了大部分重要的问题,但是你仍然应该牢固的掌握Java多线程基础知识来对应日后碰到的问题。(...

    dreamans 评论0 收藏0
  • Java并发编程笔记(一)

    摘要:并发编程实战水平很高,然而并不是本好书。一是多线程的控制,二是并发同步的管理。最后,使用和来关闭线程池,停止其中的线程。当线程调用或等阻塞时,对这个线程调用会使线程醒来,并受到,且线程的中断标记被设置。 《Java并发编程实战》水平很高,然而并不是本好书。组织混乱、长篇大论、难以消化,中文翻译也较死板。这里是一篇批评此书的帖子,很是贴切。俗话说:看到有这么多人骂你,我就放心了。 然而知...

    cnsworder 评论0 收藏0
  • 浅谈Java并发编程系列(八)—— LockSupport原理剖析

    摘要:此对象在线程受阻塞时被记录,以允许监视工具和诊断工具确定线程受阻塞的原因。阻塞当前线程,最长不超过纳秒,返回条件在的基础上增加了超时返回。唤醒线程唤醒处于阻塞状态的线程。 LockSupport 用法简介 LockSupport 和 CAS 是Java并发包中很多并发工具控制机制的基础,它们底层其实都是依赖Unsafe实现。 LockSupport是用来创建锁和其他同步类的基本线程阻塞...

    jeyhan 评论0 收藏0
  • JAVA并发编程--1.基础概念

    摘要:线程线程,是程序执行流的最小单元。由于线程之间的相互制约,致使线程在运行中呈现出间断性。线程的状态机线程也有就绪阻塞和运行三种基本状态。在单个程序中同时运行多个线程完成不同的工作,称为多线程。可以视为不同线程竞争一把锁。 进程线程协程 进程 进程是一个实体。每一个进程都有它自己的地址空间, 文本区域(text region) 数据区域(data region) 堆栈(stack re...

    abson 评论0 收藏0

发表评论

0条评论

zhoutk

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<