前言
线程是Java语言的一大特色,也是难点之一。在学习线程的过程中就会发现如果自己具备一些操作系统相关的进程知识,那么这一部分的学习就会相对而言变得容易些。线程其实就是一个微进程,线程是在进程中。了解进程的相关知识,某种程度上对理解线程也就没有什么障碍了。那么开始吧!
文章较长,但请耐心阅读完,但文章主要讲解最基础知识,其中包含了许多小细节。
线程概念
在多任务操作系统中,运行多个进程来并发执行多个任务。同理有时我们希望提高一个进程的运行速度,或者同时处理多个代码,那么就可以使用线程来解决。每个线程就是一个独立的执行自身指令的不同的控制流。其和进程十分相似。
有了线程,也就有了多线程。多线程值一个程序中包含多个执行流,多线程是实现并发的一种优先手段,多线程可以做到例如:一个线程运行GUI,一个线程运行I/O等。
了解了线程知识那么可以做到,一个application在处理一些任务同时,可以播放音乐或动画。
进程与线程的区别
- 进程是由代码,数据,内核状态(PCB)和一组寄存器组成,
- 线程是由表示程序运行状态的寄存器以及堆栈组成,不包含进程地址空间中的代码和数据
- 线程是计算过程中的某一个时刻的状态
- 进程结果所有成分都在内核空间中,用户程序不能直接访问
- 线程是一个用户级实体,数据驻留在用户空间中,可被用户程序直接访问
Java中线程模型
Java中的线程模型包括:
- 一个虚拟CPU
- 该CPU执行的代码
- 代码操作的数据
代码与数据是独立的。数据也可以被同时访问,也就是共享资源
线程的创建
java.lang中的 Thread类 是多线程程序设计基础。创建线程可以通过调用Thread的构造方法实现。
线程中的代码和数据构成了线程体,线程体决定了线程的行为。线程体是由线程类的run()方法定义,线程开始执行也是先从run()开始的。
Thread类的构造方法
1 | public Thread(ThreadGroup group, Runnable target, String name) |
group—— 指明此线程所属的线程组target—— 提供线程的对象。其中java.lang.Runnable接口中定义了run()方法,实现此接口的类所实例化的对象可以提供线程体。(可以理解Thread是提供了线程需要的环境配置,Runnable是提供一个实例对象线程行为)name—— 线程名称。当为null时,Java会提供一个唯一的名称。
以上的参数都是可以为 null 的。
创建一个线程有两种方法实现Runnable接口创建线程和通过继承Thread类创建线程。
通过实现 Runnable 接口创建线程
java.lang中 Runnable 接口定义为:
1 | public interface Runnable{ |
创建线程
- 创建一个线程类并实现 Runnable 接口,同时在
run()中实现线程体。 - 将实现 Runnable 接口的类的实例对象作为参数,传递给 Thread 的构造方法。
代码实例:
1 | // 主函数存在的类 |
运行结果:
1 | Hello! This is th1 |
通过继承 Thread类 创建线程
java.lang中的 Thread类 声明
1 | public class Thread extends Object implemets Runnable |
可以看出Thread类本身已经实现了 Runnable 接口了,因此Thread类中存在run()方法。
创建线程步骤:
- 创建 Thread 的子类,并重写
run()方法实现线程体。 - 创建该子类的实例对象,也就事创建了一个线程。
代码实现:
1 | // 主函数所在的类 |
运行结果:
1 | Hello! This is thr2 |
两种方式比较
- 利用继承 Thread类 的创建线程方法
- 代码十分简单,同时可以在其中调用其他方法,比如可以在
run()中调用线程中其他的一些方法。
- 利用实现 Runnable 的创建线程方法
- 符合面向对象的设计思想,毕竟在实现 Runnable 接口方法不会影响到 Thread类 的体系。
- 便于继承其他类,因为是实现 Runnable 的创建线程方法,因此同时继承其他类。
虽然相比而言,实现 Runnable 的方法比较推荐,但是现实中还以具体情况具体分析。
线程调度
说到线程的调度,我们会联想到进程的调度。线程的调度与进程的调度类似。当多个线程运行时,也会有资源抢占,资源共享等问题,因此对于线程的调度也是十分重要的。
概念上线程是并发执行,但是由于计算机是单个CPU,所以在微观一时刻只有一个线程运行。在单个CPU中以某种顺序运行多个线程,就是线程的调度。
线程优先级与调度策略
Java中的线程是有优先级的。
Thread类中有3个线程优先级相关常量:
MIN_PRIORITY—— 最低优先级,通常为1NORM_PRIORITY—— 普通优先级,默认值为5MAX_PRIORITY—— 最高优先级,通常为10
线程优先级的取值就是在IN_PRIORITY与MAX_PRIORITY之间,取值越大,优先级越高。
注意:
- 新创建的线程会继承父线程的优先级,父线程就是创建线程的线程。
- 主线程具有普通优先级。
- 可以利用
getPriority()获取线程的优先级,通过setPriority(int newPriority)来设置优先级 - Java的线程调度策略是基于优先级抢占式调度(也就是A线程运行中,如果出现比A线程优先级更高的B线程,则会中断A线程,先执行B线程)
- Java可以按照优先级设置多个线程等待池,JVM先运行高优先级的池中线程,直到高优先级池空,才考虑低优先级池(*****这里我还有点争议,还不是很清楚*****)
- 抢先机制,可能也是分时的,同等优先级池内线程轮流执行,具体由JVM而定,一般用
sleep()来为其他线程提供运行机会。线程基本控制方法
以下方法由 Thread类 提供
sleep()—— 可以暂时将CPU让给其他线程,休眠时间内此线程将不再运行,时间结束后,此进程将进入(Runnable)状态。static void sleep(int millsecond)—— 休眠时间以毫秒为单位static void sleep(int millsecond,int nanosecond)—— 休眠时间为毫秒与纳秒之和
注意sleep()是个静态方法,因此直接使用 Thread.sleep() 即可,一般将 sleep() 写在需要休眠的线程的 run() 中,来确保此线程进入休眠阻塞,并且此此方法会抛出 InterruptedException 异常,因此要进行异常处理。
yield()—— 也叫做让步方法。可使与 当前线程 相同优先级的线程有机会运行(执行态 →就绪态)。如果有 其他线程 与 当前线程 有相同优先级且可运行,此方法将把调用yield()的线程放入可运行线程池(就绪池),允许其他线程运行。其实可以理解为yeild()将线程的状态由执行态变为就绪态,此线程会与其他进程共同竞争CPU资源,有可能又是自己抢到了,进程继续执行。
注意 yield() 是个静态方法,因此直接使用 Thread.yield() 即可。
join()—— 让某个线程参与运行,当前进程等待(等待过程中 此线程 处于阻塞状态)等待直到线程B结束为止,此线程 恢复到 Runable (就绪态)状态。join()——当前线程发出调用B.join(),当前线程直到B执行结束再运行。join(long millis)—— 直到B线程结束后,或之后最多等待millis毫秒后,再执行join(long millis, int nanos)—— 直到B线程结束后,或最多等待millis + nanos(毫秒加纳秒)后再执行
注意:方法会抛出 InterruptedException 异常,因此要进行异常处理。
interrupt()—— 当一个线程A正在调用sleep(),join(),wait()等方法被阻塞时(这些方法都是使 线程 处于阻塞状态),A.interrrupt()将会中断A的阻塞状态,同时会抛出InterrruptException异常。- 如果线程正常在 执行 过程中使用
interrupt()是会将线程状态的 中断标志 设置为 true ,仅此而已,程序依旧运行。 - 如果在阻塞状态,使用
interrupt()同样使得 中断标志 为true ,同时会抛出异常,然后继续执行后序的代码(感觉就像是,强行从sleep()状态中 跳出来)
- 如果线程正常在 执行 过程中使用
currentThread()—— 返回当前线程(线程的引用),静态方法。isAlive()—— 测试线程是否已启动(相当于 就绪态 和 执行态)但没有运行结束。stop()—— 强行使线程关闭,但是不安全,不推荐,最好自行设置一个flag,利用flag使得线程结束。其实只要run()执行完毕,就是线程完成退出,因此利用flag使得run()执行完毕。例如1
2
3
4
5public boolean flag;
public void run(){
// 当flag为false时,就是线程结束
while(flag){}
}suspend()和resume()—— 其他线程中B.suspend()使得 线程B 暂停执行。恢复B 在其他线程中使用B.resume()。 非常不推荐,容易造成死锁
基础方法测试项目
Thread.yield()例子
1 | public class SomeFuctionOfThread { |
运行结果:
1 | This is A ------0 |
Thread.join()例子
1 | public class SomeFuncOfThread2 { |
运行结果:
1 | This is Main ------0 |
Thread.interrupt()例子
1 | public class SomeFuncOfThread3 { |
运行结果:
1 | This is A -----isInterrupt: true |
线程同步
多个线程并发执行,往往容易因为访问操作 共享资源 且对线程不加以限制而引起一些混乱(例如共享资源的修改,导致结果不可再现)。
这里简单解释一个例子:
前提:
有两个线程 A 和 B ,两线程有个共同的一个代码段是对 Stack 栈的pop()(出栈)操作以及之后的push()(压栈)操作,假若开始 Stack 中只有一个数据,线程A和B并发同时开始。
过程:
在某个时刻,A 线程对 Stack 正在push()操作(此时 Stack 中数据为空)。
可能下一刻线程 B 要对 Stack 进行pop()操作,但此时 Stack 中数据为空,就会产生异常。
对象死锁以及操作
概念
针对以上由于共享资源而可能产生的异常,Java中对共享资源的操作采用了传统的封锁计数。(是不是感觉和 进程 的一些知识很相似)
临界区:一个程序的各个并发线程对同一个对象(共享资源)进行访问的代码段,称为临界区。在Java中临界区可以是一个代码块或者一个方法,使用synchronized关键字标识。
临界区的控制是通过对象锁进行的。
对象锁:Java中将每个由synchronized(someObject){}语句指定对象someObject设置一个对象锁。对象锁是一种排他锁,当一个线程获取对象锁以后,才拥有了对该对象的操作权,其他没有获取到对象锁的线程则无法访问操作。(线程进入临界区时,先通过synchornized(someObject)语句测试并获取对象锁。如果对象锁以及被使用中,那么无法获取到对象锁)
例如:
1 | // 在一个Stack中的pop()方法设置对象锁 |
当然可以对整个方法设置对象锁:
1 | // 在一个Stack中的pop()方法设置对象锁 |
理解
还是之前的例子 :
前提:
有两个线程 A 和 B ,两线程有个共同的一个代码段是对 Stack 栈的pop()(出栈)操作以及之后的push()(压栈)操作,假若开始 Stack 中只有一个数据,线程A和B并发同时开始。
当pop()等方法有了对象锁后,则对Stack的对象设置了唯一的对象锁。
过程:
某个时刻A对 Stack 进行push(),此时 B 要对Stack进行pop()操作,但是由于对象锁被 A 使用中,B 无法获取对象锁,因此,B此时只能等待。
直到 A 释放了对象锁,B才能获取对象锁。
对象锁的使用相关说明
对象锁的返还,有以下几种情况。
- 当
synchornized()语句块执行完毕后。 - 当
synchornized()语句块中出现异常 - 持有锁的线程调用
wait()方法。此时线程将释放对象锁,线程放入对象wait pool 中,等待某个事件发生。
- 当
共享资源所有访问必须作为临界区,使用
synchornized关键词标记。没有标记对象锁的方法,将会绕过对象锁。用
synchornized保护的共享数据(也就是成员变量或者其他变量)必须是私有的。这样就要使用getxxx()和setxxx()访问数据,对这些方法设置对象锁,就是对私有变量数据进行了对象锁保护。如果整个方法的代码块都是
synchornized的作用域,那么可以将关键字放在方法声明中。
1 | // 在一个Stack中的pop()方法设置对象锁 |
- 对象锁有重入性。也就是当线程无法获取对象锁时(另一个线程正在使用此对象锁),另一个线程释放对象锁后,此线程可以再次获取对象锁。
死锁防治
如果多个线程互相争夺等待对方持有的锁,在得到对方锁之前都不会释放自己持有的锁,这就容易造成线程都不能继续执行,也就是死锁。
Java没有专门检测和避免死锁的机制,因此需要程序来进行控制,防止死锁。
一般方法为:如果多个线程访问共享数据,则要先从全局考虑定义一个获得锁的顺序,并且整个程序中都遵循这个顺序(也就是每个线程,要访问某个资源获得这个资源锁,则必须先获得上一个资源的锁)。释放锁时,按加锁的反序来释放锁。(反序释放锁,一个好处是,这样一边释放锁,另一边线程就能申请获取锁)
(类似 进程的防止死锁 )
线程间交互wait()和notify()
有些时候,某个线程进入了synchornized块 中,但是此时 共享资源 的状态并不满足它的需要,它要等其他线程将 这个共享资源 状态改变为它所需要的状态才可以继续运行。但是由于它占有了该对象锁,这使得其他线程无法对共享数据进行访问和操作。
为此在Java的java.lang.Object包中引入wait()和notify(),来实现线程间的通讯。
wait() 和 notify()
要说说wait()和 notify(),那就用一个简单的例子理解。
假设两个线程 A 和 B,它们共同访问一个共享资源X(共享资源的操作都有synchornized标识)。
A线程在访问共享资源 X(对象)的某个方法过程中需要,B来对共享源进行操作(但是此时 A 占有者对象的锁,),因此 X 对象中的这个方法此时调用wait()——X.wait()(实际情况是,wait()写在 X 的类的方法中,那么在那个方法中调用this.wait()即可),会将线程 A 放入 X 的 wait pool 中(等待池)并且释放X对象的锁。
此时,B 线程可能抢夺到 X 锁进而运行,当B处理完时,需要唤醒 A 时,使用X.notify()(同理在 X 类方法中 是使用 this.notify())随机唤醒一个线程(目前只有一个 A 线程在 X 的wait pool中,因此是唤醒了 A 线程)将 A 线程移入到 lock pool 中(锁池)准备获取 X 对象锁,一旦 A 获取锁,A 即可继续运行。
系统中类似这种使用某种资源的行为其实是一种 生产者-消费者 的关系。
一般 使用某个资源的线程被称为 消费者。
产生或释放同类资源的线程称为 生产者。
这里有相关的wait()和notify()的Java测试代码。
总结以及注意事项
wait()和notify()就是为synchornized而使用,因此这两个方法最好在synchorized块中使用wait()和notify()必须由有锁的对象来调用(往往由共享资源对象 来调用此方法)wait()的调用,会将当前使用对象锁的线程 移动到 wait pool (理解为线程此时处于 等待状态)中,同时会释放锁wait(long time)中填入参数time(毫秒),则线程进入等待状态,时间到了后,没有被唤醒,那么线程会继续执行后面的内容(其实就是不需要被 唤醒了,进入需要获取锁 lock pool 中)notify()的调用,会随机从 wait pool 中唤醒一个线程,将线程放入 lock pool(放入lock pool中的线程,就是需要获取此对象锁的线程)notifyAll()会唤醒所有在 wait pool 中的线程,将所有线程放入 lock pool 中
线程状态和生命周期
说完了这些线程基本知识,接下来我们来梳理以下线程的状态以及其生命周期。这下,会对线程每个 周期以及状态会有十分清晰的了解了。
线程生命周期模型
线程的生命周期和进程生命周期几乎相同。
线程状态
新建状态(new)
也就是调用一个线程的构造方法,简单的说就是创建线程。但是创建的线程并不会马上启动,此时的线程就是处于新建状态,线程也没有获取相关资源。只能使用
start()和stop()。可运行状态(Runnable)
新建的线程调用
start(),其会为线程分配必要的资源,将线程中的虚拟CPU置为Runnable 状态,此线程将会由系统来调度。
可运行状态其实也就是就绪状态,此时的线程并不一定获得CPU,不一定在运行。某个时刻可能多个线程处于此状态,这些线程会互相竞争CPU资源,这个通过系统根据其内部的线程调度策略来决定调度。运行状态(Running)
运行态是线程占有CPU并运行的状态,这时候的线程状态有3中变迁:
- 线程正常执行结束或应用程序结束,就会进入终止状态。
- 当前线程执行了
yiled()或者因调度策略(比如一个优先级更高的线程进入 可运行状态 ,那么这个高优先权线程会直接被调度使用CPU,当前的线程也就被抢走了CPU;也有可能使分时方式下,当前线程 时间片 结束了)会进入 可运行状态 。 - 发生以下情况进入 阻塞状态 :
- 线程调用了
sleep()或join()方法 - 线程调用
wait()方法 - 线程中使用
synchornized请求锁,但没有获得锁,进入阻塞状态。 - 线程中有输入/输出操作,也会进入阻塞状态,操作结束后,线程进入 可运行状态 。
阻塞状态(Blocked)
阻塞原因又有 对象锁阻塞(blocked in lock pool),等待阻塞(blocked in wait pool)和 其他阻塞(otherwise blocked)。变迁分别如下:
- 线程调用
sleep()和join()进入 其他阻塞状态。当sleep()的睡眠时间结束 或 者join()对方的线程执行完毕后或等待时间结束,就进入可运行状态 - 线程调用
wait()进入 等待阻塞。等待阻塞 下线程如果被notify()或notifyAll()唤醒,或被interrupt()中断 或 等待时间结束,进入 对象锁阻塞状态 - 线程中使用
synchornized请求对象的锁,但没有获得,进入对象锁阻塞,直到线程获取到锁,就可以进入 可运行状态
- 线程调用
终止状态(Dead)
线程执行结束,没有任何方法能改变这个状态。
结语
这也仅仅是 线程 的皮毛知识,后续会继续更新与线程相关的一些经验。
线程是Java的难点,基础知识必须掌握,这样才能更加灵活去运用,才能去更近一步了解更高阶的知识。
参考文献
非常感谢这些文献:
[1]:《Java语言程序设计(第3版)》—郎波—清华大学出版社
[2]:Java中interrupt()方法详解附带demo —wdfwolf3 博客 —https://www.cnblogs.com/wdfwolf3/p/7464260.html