1-并发编程线程基础

发布时间:2024年01月05日

什么是线程

在讨论什么是线程前有必要先说下什么是进程,因为线程是进程中的一个实体,线程本身是不会独立存在的。

进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,线程则是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源。

操作系统在分配资源时是把资源分配给进程的,但是CPU资源比较特殊,它是被分配到线程的,因为真正要占用CPU运行的是线程,所以也说线程是CPU分配的基本单位。

在Java中,当我们启动main函数时其实就启动了一个JVM的进程,而main函数所在的线程就是这个进程中的一个线程,也称主线程。

在这里插入图片描述
个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域。

程序计数器是一块内存区域,用来记录线程当前要执行的指令地址。

那么为何要将程序计数器设计为线程私有的呢?前面说了线程是占用CPU执行的基本单位,而CPU一般是使用时间片轮转方式让线程轮询占用的,所以当前线程CPU时间片用完后,要让出CPU,等下次轮到自己的时候再执行。

那么如何知道之前程序执行到哪里了呢?其实程序计数器就是为了记录该线程让出CPU时的执行地址的,待再次分配到时间片时线程就可以从自己私有的计数器指定地址继续执行。

另外需要注意的是,如果执行的是native方法,那么 pc计数器记录的是undefined地址,只有执行的是Java代码时pc计数器记录的才是下一条指令的地址。

另外每个线程都有自己的栈资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外栈还用来存放线程的调用栈帧。

堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分配的,堆里面主要存放使用new操作创建的对象实例。

方法区则用来存放JVM加载的类、常量及静态变量等信息,也是线程共享的。

线程创建与运行

在这里插入图片描述
当创建完thread对象后该线程并没有被启动执行,直到调用了start方法后才真正启动了线程。

其实调用start方法后线程并没有马上执行而是处于就绪状态,这个就绪状态是指该线程已经获取了除CPU资源外的其他资源,等待获取CPU资源后才会真正处于运行状态。

一旦run方法执行完毕,该线程就处于终止状态。

使用继承方式的好处是,在run()方法内获取当前线程直接使用this就可以了,无须使用Thread.currentThread()方法;

不好的地方是Java不支持多继承,如果继承了Thread类,那么就不能再继承其他类。

另外任务与代码没有分离,当多个线程执行一样的任务时需要多份任务代码,而Runable则没有这个限制。

下面看实现Runnable接口的run方法方式。

在这里插入图片描述
RunableTask可以继承其他类。但是上面介绍的两种方式都有一个缺点,就是任务没有返回值。

下面看最后一种,即使用FutureTask的方式。

在这里插入图片描述

线程通知与等待

wait()函数

当一个线程调用一个共享变量的wait()方法时,该调用线程会被阻塞挂起,直到发生下面几件事情之一才返回:

  1. 其他线程调用了该共享对象的notify()或者notifyAll()方法;
  2. 其他线程调用了该线程的interrupt()方法,该线程抛出InterruptedException异常返回。

另外需要注意的是,如果调用wait()方法的线程没有事先获取该对象的监视器锁,则调用wait()方法时调用线程会抛出IIlegalMonitorStateException异常。

那么一个线程如何才能获取一个共享变量的监视器锁呢?

  1. 执行synchronized同步代码块时,使用该共享变量作为参数。
synchronized(共享变量){

}
  1. 调用该共享变量的方法,并且该方法使用了synchronized修饰。
synchronized void add(int a,int b){
}

另外需要注意的是,一个线程可以从挂起状态变为可以运行状态(也就是被唤醒),即使该线程没有被其他线程调用notify()、notifyAll()方法进行通知,或者被中断,或者等待超时,这就是所谓的虚假唤醒。

虽然虚假唤醒在应用实践中很少发生,但要防患于未然,做法就是不停地去测试该线程被唤醒的条件是否满足,不满足则继续等待,也就是说在一个循环中调用wait()方法进行防范。

退出循环的条件是满足了唤醒该线程的条件。
在这里插入图片描述
如上代码是经典的调用共享变量wait()方法的实例,首先通过同步块获取obj上面的监视器锁,然后在while循环内调用obj的wait()方法。

下面从一个简单的生产者和消费者例子来加深理解。

如下面代码所示,其中queue为共享变量,生产者线程在调用queue的wait()方法前,使用synchronized关键字拿到了该共享变量queue的监视器锁,所以调用wait()方法才不会抛出IlegalMonitorStateException异常。如果当前队列没有空闲容量则会调用queued的wait()方法挂起当前线程,这里使用循环就是为了避免上面说的虚假唤醒问题。

假如当前线程被虚假唤醒了,但是队列还是没有空余容量,那么当前线程还是会调用wait()方法把自己挂起。

当前线程只会释放当前共对象的锁,当前线程持有的其他共的监视器锁并不会被释放。

当一个线程调用共享对象的wait()方法被阻塞挂起后,如果其他线程中断了该线程,则该线程会抛出InterruptedException异常并返回。

在这里插入图片描述

wait(long timeout)函数

该方法相比wait()方法多了一个超时参数,它的不同之处在于,如果一个线程调用共享对象的该方法挂起后,没有在指定的timeout ms时间内被其他线程调用该共享变量的notify()或者notifyAll()方法唤醒,那么该函数还是会因为超时而返回。

如果将timeout设置为0则和wait方法效果一样,因为在wait方法内部就是调用了wait(0)。

需要注意的是,如果在调用该函数时,传递了一个负的timeout则会抛出IllegalArgumentException异常。

wait(long timeout,int nanos)函数

在其内部调用的是wait(long timeout)函数,如下代码只有在nanos>0时才使参数timeout递增1。
在这里插入图片描述

notify()函数

一个线程调用共享对象的notify()方法后,会唤醒一个在该共享变量上调用wait系列方法后被挂起的线程。

一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。

此外,被唤醒的线程不能马上从wait方法返回并继续执行,它必须在获取了共享对象的监视器锁后才可以返回,也就是唤醒它的线程释放了共享变量上的监视器锁后,被唤醒的线程也不一定会获取到共享对象的监视器锁,这是因为该线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行。

类似wait系列方法,只有当前线程获取到了共享变量的监视器锁后,才可以调用共享变量的notify()方法,否则会抛出IllegalMonitorStateException异常。

notifyAll()函数

不同于在共享变量上调用notify()函数会唤醒被阻塞到该共享变量上的一个线程,notifyAll()方法则会唤醒所有在该共享变量上由于调用wait系列方法而被挂起的线程。

个需要注意的地方是,在共享变量上调用notifyAll()方法只会唤醒调用这个方法前调用了wait系列函数而被放入共享变量等待集合里面的线程。如果调用notifyAll()方法后一个线程调用了该共享变量的wait()方法而被放入阻塞集合,则该线程是不会被唤醒的。

等待线程执行终止的join方法

在项目实践中经常会遇到一个场景,就是需要等待某几件事情完成后才能继续往下执行,比如多个线程加载资源,需要等待多个线程全部加载完毕再汇总处理。

Thread类中有一个join方法就可以做这个事情,前面介绍的等待通知方法是Object类中的方法,而join方法则是Thread类直接提供的。join是无参且返回值为void的方法。

线程A调用线程B的join方法后会被阻塞,当其他线程调用了线程A的interrupt()方法中断了线程A时,线程A会抛出InterruptedException异常而返回。

让线程睡眠的sleep方法

Thread类中有一个静态的sleep方法,当一个执行中的线程调用了Thread的sleep方法后,调用线程会暂时让出指定时间的执行权,也就是在这期间不参与CPU的调度,但是该线程所拥有的监视器资源,比如锁还是持有不让出的。

指定的睡眠时间到了后该函数会正常返回,线程就处于就绪状态,然后参与CPU的调度,获取到CPU资源后就可以继续运行了。如果在睡眠期间其他线程调用了该线程的interrupt()方法中断了该线程,则该线程会在调用sleep方法的地方抛出InterruptedException异常而返回。

让出CPU执行权的yield方法

Thread类中有一个静态的yield方法,当一个线程调用yield方法时,实际就是在暗示线程调度器当前线程请求让出自己的CPU使用,但是线程调度器可以无条件忽略这个暗示。

我们知道操作系统是为每个线程分配一个时间片来占有CPU的,正常情况下当一个线程把分配给自己的时间片使用完后,线程调度器才会进行下一轮的线程调度,而当一个线程调用了Thread类的静态方法yield时,是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,这暗示线程调度器现在就可以进行下一轮的线程调度。

当一个线程调用yield方法时,当前线程会让出CPU使用权,然后处于就绪状态,线程调度器会从线程就绪队列里面获取一个线程优先级最高的线程,当然也有可能会调度到刚刚让出CPU的那个线程来获取CPU执行权。

线程中断

Java中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理。

void interrupt()方法

中断线程,例如,当线程A运行时,线程B可以调用线程A的interrupt()方法来设置线程A的中断标志为true并立即返回。

设置标志仅仅是设置标志,线程A实际并没有被中断,它会继续往下执行。

如果线程A因为调用了wait系列函数、join方法或者sleep方法而被阻塞挂起,这时候若线程B调用线程A的interrupt()方法,线程A会在调用这些方法的地方抛出InterruptedException异常而返回。

boolean isInterrupted()方法

检测当前线程是否被中断,如果是返回true,否则返回false;

public boolean isInterrupted(){
	//传递false,说明不清除中断标志
	return isInterrupted(false);
}

boolean interrupted()方法

检测当前线程是否被中断,如果是返回true,否则返回false。

与isInterrupted不同的是,该方法如果发现当前线程被中断,则会清除中断标志,并且该方法是static方法,可以通过Thread类直接调用。

另外从下面的代码可以知道,在interrupted()内部是获取当前调用线程的中断标志而不是调用interrupted()方法的实例对象的中断标志。

public static boolean interrupted(){
	//清除中断标志
	return currentThread().isInterrupted(true);
}

理解线程上下文切换

在多线程编程中,线程个数一般都大于CPU个数,而每个CPU同一时刻只能被一个线程使用,为了让用户感觉多个线程是在同时执行的,CPU资源的分配采用了时间片轮转的策略,也就是给每个线程分配一个时间片,线程在时间片内占用CPU执行任务。

当前线程使用完时间片后,就会处于就绪状态并让出CPU让其他线程占用,这就是上下文切换,从当前线程的上下文切换到了其他线程。

那么就有一个问题,让出CPU的线程等下次轮到自己占有CPU时如何知道自己之前运行到哪里了?所以在切换线程上下文时需要保存当前线程的执行现场,当再次执行时根据保存的执行现场信息恢复执行现场。

线程上下文切换时机有: 当前线程的CPU时间片使用完处于就绪状态时,当前线程被其他线程中断时。

线程死锁

什么是线程死锁

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。
在这里插入图片描述
线程A已经持有了资源2,它同时还想申请资源1,线程B已经持有了资源1,它同时还想申请资源2,所以线程1和线程2就因为相互等待对方已经持有的资源,而进入了死锁状态。

那么为什么会产生死锁呢?学过操作系统的朋友应该都知道,死锁的产生必须具备以下四个条件。

  • 互斥条件:指线程对已经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。
  • 请求并持有条件:指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源。
  • 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源。
  • 环路等待条件:指在发生死锁时,必然存在一个线程一资源的环形链,即线程集合{T0,T1,T2,…,Tn}中的T0正在等待一个T1占用的资源,T1正在等待T2占用的资源,……Tn正在等待已被T0占用的资源。

如何避免线程死锁

要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,但是学过操作系统的读者应该都知道,目前只有请求并持有和环路等待条件是可以被破坏的。

造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原则就可以避免死锁,那么什么是资源申请的有序性呢?

让在线程B中获取资源的顺序和在线程A中获取资源的顺序保持一致,其实资源分配有序性就是指,假如线程A和线程B都需要资源1,2,3,…,n时,对资源进行排序,线程A和线程B只有在获取了资源n-1时才能去获取资源n。

我们可以简单分析一下为何资源的有序分配会避免死锁,比如上面的代码,假如线程A和线程B同时执行到了synchronized(resourceA),只有一个线程可以获取到resourceA上的监视器锁,假如线程A获取到了,那么线程B就会被阻塞而不会再去获取资源B,线程A获取到resourceA的监视器锁后会去申请resourceB的监视器锁资源,这时候线程A是可以获取到的,线程A获取到resourceB资源并使用后会放弃对资源resourceB的持有,然后再释放对resourceA的持有,释放resourceA后线程B才会被从阻塞状态变为激活状态。

所以资源的有序性破坏了资源的请求并持有条件和环路等待条件,因此避免了死锁。

守护线程与用户线程

Java中的线程分为两类,分别为daemon线程(守护线程)和user线程(用户线程)。

在JVM启动时会调用main函数,main函数所在的线程就是一个用户线程,其实在JVM内部同时还启动了好多守护线程,比如垃圾回收线程。

那么守护线程和用户线程有什么区别呢?区别之一是当最后一个非守护线程结束时,JVM会正常退出,而不管当前是否有守护线程,也就是说守护线程是否结束并不影响JVM的退出。

言外之意,只要有一个用户线程还没结束,正常情况下JVM就不会退出。

在这里插入图片描述

main线程运行结束后,JVM会自动启动一个叫作DestroyJavaVM的线程,该线程会等待所有用户线程结束后终止JVM进程。

如果你希望在主线程结束后JVM进程马上结束,那么在创建线程时可以将其设置为守护线程,如果你希望在主线程结束后子线程继续工作,等子线程结束后再让JVM进程结束,那么就将子线程设置为用户线程。

ThreadLocal

多线程访问同一个共享变量时特别容易出现并发问题,特别是在多个线程需要对一个共享变量进行写入时。

为了保证线程安全,一般使用者在访问共享变量时需要进行适当的同步。
在这里插入图片描述

同步的措施一般是加锁,这就需要使用者对锁有一定的了解,这显然加重了使用者的负担。那么有没有一种方式可以做到,当创建一个变量后,每个线程对其进行访问的时候访问的是自己线程的变量呢?其实ThreadLocal就可以做这件事情,虽然ThreadLocal并不是为了解决这个问题而出现的。

ThreadLocal是JDK包提供的,它提供了线程本地变量,也就是如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。

当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。

创建一个ThreadLocal变量后,每个线程都会复制一个变量到自己的本地内存。
在这里插入图片描述

ThreadLocal的实现原理

首先看一下ThreadLocal相关类的类图结构。
在这里插入图片描述
Thread类中有一个threadLocals和一个inheritableThreadLocals,它们都是ThreadLocalMap类型的变量,而ThreadLocalMap是一个定制化的Hashmap。

在默认情况下,每个线程中的这两个变量都为null,只有当前线程第一次调用ThreadLocal的set或者get方法时才会创建它们。

其实每个线程的本地变量不是存放在ThreadLocal实例里面,而是存放在调用线程的threadLocals变量里面。

也就是说,ThreadLocal类型的本地变量存放在具体的线程内存空间中。

ThreadLocal就是一个工具壳,它通过set方法把value值放入调用线程的threadLocals里面并存放起来,当调用线程调用它的get方法时,再从当前线程的threadLocals变量里面将其拿出来使用。

如果调用线程一直不终止,那么这个本地变量会一直存放在调用线程的threadLocals变量里面,所以当不需要使用本地变量时可以通过调用ThreadLocal变量的remove方法,从当前线程的threadLocals里面删除该本地变量。

另外,Thread里面的threadLocals为何被设计为map结构?很明显是因为每个线程可以关联多个ThreadLocal变量。
下面简单分析ThreadLocal的set、get及remove方法的实现逻辑。

void set(T value)

在这里插入图片描述

代码(1)首先获取调用线程,然后使用当前线程作为参数调用getMap(t)方法,getMap(Thread t)的代码如下,

ThreadLocalMap getMap(Thread t){return t.threadLocals};

可以看到,getMap(t)的作用是获取线程自己的变量threadLocals,threadlocal变量被绑定到了线程的成员变量上。

如果getMap(t)的返回值不为空,则把value值设置到threadLocals中,也就是把当前变量值放入当前线程的内存变量threadLocals中。

threadLocals是一个HashMap结构,其中key就是当前ThreadLocal的实例对象引用,value是通过set方法传递的值。

如果getMap(t)返回空值则说明是第一次调用set方法,这时创建当前线程的threadLocals变量。

下面来看createMap(t,value)做什么。

void createMap(Thread t,T firstValue){
	t.threadLocals =new ThreadLocalMap(this,firstValue);
}

它创建当前线程的threadLocals变量。

T get()

在这里插入图片描述

在这里插入图片描述

void remove()

在这里插入图片描述
在每个线程内部都有一个名为threadLocals的成员变量,该变量的类型为HashMap,其中key为我们定义的ThreadLocal变量的this引用,value则为我们使用set方法设置的值。

每个线程的本地变量存放在线程自己的内存变量threadLocals中,如果当前线程一直不消亡,那么这些本地变量会一直存在,所以可能会造成内存溢出,因此使用完毕后要记得调用ThreadLocal的remove方法删除对应线程的threadLocals中的本地变量。
在这里插入图片描述

ThreadLocal不支持继承性

同一个ThreadLocal变量在父线程中被设置值后,在子线程中是获取不到的。

这应该是正常现象,因为在子线程thread里面调用get方法时当前线程为thread线程,而这里调用set方法设置线程变量的是main线程,两者是不同的线程,自然子线程访问时返回null。

那么有没有办法让子线程能访问到父线程中的值?答案是有。

InheritableThreadLocal类

为了解决上节提出的问题,InheritableThreadLocal应运而生。

InheritableThreadLocal继承自ThreadLocal,其提供了一个特性,就是让子线程可以访问在父线程中设置的本地变量。

在这里插入图片描述
InheritableThreadLocal继承了ThreadLocal,并重写了三个方法。

由代码(3)可知,InheritableThreadLocal重写了createMap方法,那么现在当第一次调用set方法时,创建的是当前线程的inheritableThreadLocals变量的实例而不再是threadLocals。

由代码(2)可知,当调用get方法获取当前线程内部的map变量时,获取的是inheritableThreadLocals而不再是threadLocals。

综上可知,在InheritableThreadLocal的世界里,变量inheritableThreadLocals替代了threadLocals。

下面我们看一下重写的代码(1)何时执行,以及如何让子线程可以访问父线程的本地变量。这要从创建Thread的代码说起,打开Thread类的默认构造函数。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
InheritableThreadLocal类实例的set或者get方法设置变量时,就会创建当前线程的inheritableThreadLocals变量。

当父线程创建子线程时,构造函数会把父线程中inheritableThreadLocals变量里面的本地变量复制一份保存到子线程的inheritableThreadLocals变量里面。

那么在什么情况下需要子线程可以获取父线程的threadlocal变量呢?情况还是蛮多的,比如子线程需要使用存放在threadlocal变量中的用户登录信息,再比如一些中间件需要把统一的id追踪的整个调用链路记录下来。

其实子线程使用父线程中的threadlocal方法有多种方式,比如创建线程时传入父线程中的变量,并将其复制到子线程中,或者在父线程中构造一个map作为参数传递给子线程,但是这些都改变了我们的使用习惯,所以在这些情况下InheritableThreadLocal就显得比较有用。

文章来源:https://blog.csdn.net/zhuyufan1986/article/details/135370932
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。