深入理解java线程(看这篇就够了)


1. 线程基础知识

1.1 线程和进程

进程

程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中 还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的 。

当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。 进程就可以视为程序的一个实例。

大部分程序可以同时运行多个实例进程(例如记事 本、画图、浏览器 等),也有的程序只 能启动一个实例进程(例如网易云音乐、360 安全卫士等)。

操作系统会以进程为单位,分配系统资源(CPU时间片、内存等资源),进程是 资源分配的最小单位。

线程

线程是进程中的实体,一个进程可以拥有多个线程,一个线程必须有一个父进程。

一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行 。

线程,有时被称为轻量级进程(Lightweight Process,LWP),是操作系统调度 (CPU调度)执行的最小单位。

进程与线程的区别

进程基本上相互独立的,而线程存在于进程内,是进程的一个子集

进程拥有共享的资源,如内存空间等,供其内部的线程共享

进程间通信较为复杂 同一台计算机的进程通信称为 IPC(Inter-process communication)不同计算机之间的进程通信,需要通过网络,并遵守共同的协议, 例如 HTTP 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一 个共享变量

线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

进程间通信的方式

1. 管道(pipe)及有名管道(named pipe):管道可用于具有亲缘关系的父子进程间的 通信,有名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。

2. 信号(signal):信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方 式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果 上可以说是一致的。

3. 消息队列(message queue):消息队列是消息的链接表,它克服了上两种通信方式 中信号量有限的缺点,具有写权限得进程可以按照一定得规则向消息队列中添加新信息; 对消息队列有读权限得进程则可以从消息队列中读取信息。

4. 共享内存(shared memory):可以说这是最有用的进程间通信方式。它使得多个进 程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更 新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。

5. 信号量(semaphore):主要作为进程之间及同一种进程的不同线程之间得同步和互 斥手段。

6. 套接字(socket):这是一种更为一般得进程间通信机制,它可用于网络中不同机器之 间的进程间通信,应用非常广泛。

1.2 上下文切换(Context switch)

上下文切换是指CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换。

上下文切换可以更详细地描述为内核(即操作系统的核心)对CPU上的进程(包括线程)执行以下活动:

1. 暂停一个进程的处理,并将该进程的CPU状态(即上下文)存储在内存中的某个地方

2. 从内存中获取下一个进程的上下文,并在CPU的寄存器中恢复它

3. 返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程

上下文切换只能在内核模式下发生。内核模式是CPU的特权模式,其中只有内核运行,并提供对所有内存位置和所有其他系统资源的访问。其他程序(包括应用程序)最初在用户模式下运行,但它们可以通过系统调用运行部分内核代码。

上下文切换是多任务操作系统的一个基本特性。在多任务操作系统中,多个进程似乎同时在一个 CPU上执行,彼此之间互不干扰。这种并发的错觉是通过快速连续发生的上下文切换(每秒数十次 或数百次)来实现的。这些上下文切换发生的原因是进程自愿放弃它们在CPU中的时间,或者是调 度器在进程耗尽其CPU时间片时进行切换的结果。

上下文切换通常是计算密集型的。就CPU时间而言,上下文切换对系统来说是一个巨大的成本, 实际上,它可能是操作系统上成本最高的操作。因此,操作系统设计中的一个主要焦点是尽可能地避免不必要的上下文切换。与其他操作系统(包括一些其他类unix系统)相比,Linux的众多优 之一是它的上下文切换和模式切换成本极低。

1.3 操作系统层面线程生命周期

操作系统层面的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态。

1. 初始状态,指的是线程已经被创建,但是还不允许分配CPU 执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。

2. 可运行状态,指的是线程可以分配CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配CPU 执行。

3. 当有空闲的CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就转换成了运行状态。

4. 运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件 (例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件结束了,线程就会从休眠状态转换到可运行状态。

5. 线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状 态,进入终止状态也就意味着线程的生命周期结束了。

这五种状态在不同编程语言里会有简化合并。例如,C 语言的 POSIX Threads 规范,就把 初始状态和可运行状态合并了;Java 语言里则把可运行状态和运行状态合并了,这两个状态在操 作系统调度层面有用,而 JVM 层面不关心这两个状态,因为 JVM 把线程调度交给操作系统处理 了。

2. Java线程详解

2.1 Java线程的实现方式

方式1:使用 Thread类或继承Thread类

方式2:实现 Runnable 接口配合Thread

方式3:使用有返回值的 Callable

class CallableTask implements Callable<Integer> {
 @Override
 public Integer call() throws Exception {
 return new Random().nextInt();
 }
 }
 //创建线程池
 ExecutorService service = Executors.newFixedThreadPool(10);
 //提交任务,并用 Future提交返回结果
 Future<Integer> future = service.submit(new CallableTask());

方式4:使用 lambda

new Thread(() ‐
> System.out.println(Thread.currentThread().getName())).start();

本质上Java中实现线程只有一种方式,都是通过new Thread()创建线程,调用Thread#start启 动线程最终都会调用Thread#run方法

2.2 Java线程实现原理

思考:Java线程执行为什么不能直接调用run()方法,而要调用start()方法?

首先我们需要明白一个问题:JAVA真的可以开启线程吗? 答案是否定的。Java是没有权限去开启线程、操作硬件的,它通过调用底层的C++代码来开启一个新的线程。查看start()方法源码,我们会发现它调用了一个native修饰的start0()方法。

这里说明了java是不能开启线程的,只能通过本地方法start0()启动线程,而当我们直接使用run()方法时,既然线程都还没有开启,又怎么会得到正常的结果呢?

而start() 会执⾏线程的相应准备⼯作,然后⾃动执⾏ run() ⽅法的内容

说白了就是一句话:调⽤ start() ⽅法⽅可启动线程并使线程进⼊就绪状态,直接执⾏ run() ⽅法会把 run()⽅法当成⼀个 main 线程下的普通⽅法去执⾏,并不会在某个线程中执⾏它,不会以多线程的⽅式执⾏。

Java线程属于内核级线程

JDK1.2——基于操作系统原生线程模型来实现。Sun JDK,它的Windows版本和Linux版本 都使用一对一的线程模型实现,一条Java线程就映射到一条轻量级进程之中。

内核级线程(Kernel Level Thread ,KLT):它们是依赖于内核的,即无论是用户进程中的线程,还是系统进程中的线程,它们的创建、撤消、切换都由内核实现。

用户级线程(User Level Thread,ULT):操作系统内核不知道应用线程的存在。

协程

协程,英文Coroutines, 是一种基于线程之上,但又比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行),具有对内核来说不可见的特性。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

协程的特点在于是一个线程执行,那和多线程比,协程有何优势?

线程的切换由操作系统调度,协程由用户自己进行调度,因此减少了上下文切换,提高了效率。

线程的默认stack大小是1M,而协程更轻量,接近1k。因此可以在相同的内存中开启 更多的协程。

不需要多线程的锁机制:因为只有一个线程,也不存在同时写变量冲突,在协程中控 制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

注意: 协程适用于被阻塞的,且需要大量并发的场景(网络io)。不适合大量计算的场景。

2.3 Java线程的调度机制

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式分两种,分别是协同式线程调度和抢占式线程调度

协同式线程调度

线程执行时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另 外一个线程上。最大好处是实现简单,且切换操作对线程自己是可知的,没啥线程同步问题。坏 处是线程执行时间不可控制,如果一个线程有问题,可能一直阻塞在那里。

抢占式线程调度

每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(Java中, Thread.yield()可以让出执行时间,但无法获取执行时间)。线程执行时间系统可控,也不会有 一个线程导致整个进程阻塞。

2.4 Java线程的生命周期

Java 语言中线程共有六种状态,分别是:

1. NEW(初始化状态)

2. RUNNABLE(可运行状态+运行状态)

3. BLOCKED(阻塞状态)

4. WAITING(无时限等待)

5. TIMED_WAITING(有时限等待)

6. TERMINATED(终止状态)

在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态, 即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永 远没有 CPU 的使用权。

2.5 Thread常用方法

sleep方法

调用 sleep 会让当前线程从 Running 进入TIMED_WAITING状态,不会释放对象锁 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException,并且会清除中断标志 睡眠结束后的线程未必会立刻得到执行 sleep当传入参数为0时,和yield相同

yield方法

yield会释放CPU资源,让当前线程从 Running 进入 Runnable状态,让优先级更高 (至少是相同)的线程获得执行机会,不会释放对象锁; 假设当前进程只有main线程,当调用yield之后,main线程会继续运行,因为没有比它优先级更高的线程; 具体的实现依赖于操作系统的任务调度器

join方法

等待调用join方法的线程结束之后,程序再继续执行,一般用于等待异步线程执行完结果之 后才能继续运行的场景。

stop方法

stop()方法已经被jdk废弃,原因就是stop()方法太过于暴力,强行把执行到一半的线程终止。

stop会释放对象锁,可能会造成数据不一致。

思考:如何正确优雅的停止线程?

2.5 Java线程的中断机制

Java没有提供一种安全、直接的方法来停止某个线程,而是提供了中断机制。中断机制是一 种协作机制,也就是说通过中断并不能直接终止另一个线程,而需要被中断的线程自己处理。被中断的线程拥有完全的自主权,它既可以选择立即停止,也可以选择一段时间后停止,也可以选择压根不停止。

API的使用

interrupt(): 将线程的中断标志位设置为true,不会停止线程

isInterrupted(): 判断当前线程的中断标志位是否为true,不会清除中断标志位

Thread.interrupted():判断当前线程的中断标志位是否为true,并清除中断标志 位,重置为fasle

注意:使用中断机制时一定要注意是否存在中断标志位被清除的情况

sleep可以被中断 抛出中断异常:sleep interrupted, 清除中断标志位

wait可以被中断 抛出中断异常:InterruptedException, 清除中断标志位

2.6 Java线程间通信

volatile

volatile有两大特性,一是可见性,二是有序性,禁止指令重排序,其中可见性就是可以让线程 之间进行通信。

等待唤醒(等待通知)机制

1.sychronized+wait/notify/notifyAll

等待唤醒机制可以基于wait和notify方法来实现,在一个线程内调用该线程锁对象的wait方法, 线程将进入等待队列进行等待直到被唤醒。

2.cas+park/unpark

LockSupport是JDK中用来实现线程阻塞和唤醒的工具,线程调用park则等待“许可”,调用 unpark则为指定线程提供“许可”。使用它可以在任何场合使线程阻塞,可以指定任何线程进行 唤醒,并且不用担心阻塞和唤醒操作的顺序,但要注意连续多次唤醒的效果和一次唤醒是一样 的。

3.reentrantLock+condition(await/singal/sinagAll)

管道输入输出流

管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。管道输入/输出流主要包括了如下4种具体实现: PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前两种面向字节, 而后两种面向字符。

使用管道通信时,大致的流程是:我们在线程A中向PipedOutputStream中写入数据,这些数据会自动的发送到与PipedOutputStream对应的PipedInputStream中,进而存储在PipedInputStream的缓冲中;此时,线程B通过读取PipedInputStream中的数据。就可以实现,线程A和线程B的通信。

原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/290755.html

(0)
上一篇 2022年10月8日
下一篇 2022年10月8日

相关推荐

发表回复

登录后才能评论