目录
多线程
在我的项目中,发布和审核的文章用到了多线程相关知识,就是发布文章成功后,直接异步向审核的功能发送请求,不需要同步等待其响应结果,由于发布和审核都是在同一个微服务下,所以就没有考虑用rabbitmq,而是选择使用多线程来实现异步,接下来我想说一下关于多线程相关知识。
1,进程和线程
进程是系统进行资源分配和调度的一个独立单位,线程是进程的一个实体,是CPU调度和分派的基本单位
线程是进程中的一个最小执行单元。
在进程和线程区别上
-
进程是正在运行程序的实例,进程中包含了线程,一个进程最少得有一个线程,每个线程执行不同的任务
-
不同的进程使用不同的内存空间,而当前进程下的所有线程可以共享一片内存空间
-
线程相比进程更轻量,从一个线程切换到另一个线程的成本一般上要比从一个进程程切换到另一个进程低
2,创建线程
在创建线程上共有四种方式可以创建线程,分别是:
-
继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程
2.1 继承Thread类和实现Runnable接口,有什么区别?你一般选择哪个?为什么?
继承Thread类和实现Runnable接口的区别在于,继承Thread类可以直接使用Thread类提供的方法,而实现Runnable接口则需要先创建一个Thread对象,然后将实现了Runnable接口的对象传递给Thread对象来创建线程。
实现Runnable接口的方式更加灵活,因为一个类可以同时实现多个接口,而继承Thread类则只能单继承。
一般来说,我会选择实现Runnable接口来创建线程,因为这样可以更好地遵循面向对象的设计原则,将线程的逻辑与线程的管理分离。同时,实现Runnable接口还可以避免由于Java的单继承机制而造成的继承局限。
总之,无论选择哪种方式,都需要根据实际情况来进行选择,根据需求、性能考虑、代码可读性等因素来进行选择。
2.2 实现runnable 和 实现callable 两个接口创建线程有什么不同呢?
其中实现runnable 和 实现callable接口创建线程池最主要的区别是一个是有返回值,一个是没有返回值的。
Runnable 接口run方法无返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
还有一个就是,他们异常处理也不一样。Runnable接口run方法只能抛出运行时异常,也无法捕获处理;Callable接口call方法允许抛出异常,可以获取异常信息
在实际开发中,如果需要拿到执行的结果,需要使用Callalbe接口创建线程,调用FutureTask.get()得到可以得到返回值,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
2.3 线程的 run()和 start()有什么区别?
-
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
-
run(): 封装了要被线程执行的代码,可以被调用多次。
3,线程池
线程池是一种常用的多线程并发处理技术,它可以在应用程序启动时预先分配一些线程资源,用来处理后续进入的请求任务。这些线程会被复用,避免了频繁创建和销毁线程的开销,提高了应用程序的并发处理能力,降低了系统负载,增加了系统的稳定性。线程池通过分配线程来控制并发,可以有效利用计算资源和内存,减少线程的创建和销毁次数,提高程序的运行效率。
3.1 线程池的工作流程
1,任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行
2,如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列
3,如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务
如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务
4,如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略
线程池创建了,里面是没有线程的,只有任务提交过来,才会去创建线程去执行
3.2 说一下线程池的核心参数
线程池核心参数主要参考ThreadPoolExecutor这个类的7个参数的构造函数
-
核心线程数
-
最大线程数
-
阻塞队列
-
线程保活时间
-
临时保活时间单位
-
线程工厂
-
拒绝策略
核心线程数就是最大线程数
在拒绝策略中又有4中拒绝策略
当线程数过多以后,第一种是抛异常、第二种是由调用者执行任务、第三是丢弃当前的任务,第四是丢弃最早排队任务。默认是直接抛异常。
3.2.1 你一般是怎么来设置核心线程数?
是这样的,我们公司当时有一些规范,为了减少线程上下文的切换,要根据当时部署的服务器的CPU核数来决定,
① 对于高并发、任务执行时间短的情况 -->一般设置为( CPU核数+1 ),减少线程上下文的切换
②对于并发不高、任务执行时间长的
-
IO密集型的任务 (文件读写、DB读写、网络请求等) --> 一般设置为(CPU核数 * 2 + 1)
-
计算密集型任务 --> 一般设置为( CPU核数+1 )
4,线程状态
4.1 线程包括哪些状态,状态之间是如何变化的?
在JDK中的Thread类中的枚举State里面定义了6中线程的状态分别是:新建、可运行、终结、阻塞、等待和有时限等待六种。
关于线程的状态切换情况比较多。我分别介绍一下
当一个线程对象被创建,但还未调用 start 方法时处于新建状态,调用了 start 方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。
如果线程获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态
还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态
4.2 刚才你说的线程中的 wait 和 sleep方法有什么不同呢?
它们两个的相同点是都可以让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。
它们两个的不同点主要有三个方面:
第一:方法归属不同 sleep(long) 是 Thread 的静态方法。而 wait(),是 Object 的成员方法,每个对象都有
第二:线程醒来时机不同 线程执行 sleep(long) 会在等待相应毫秒后醒来,
而 wait() 需要被 notify 唤醒,wait() 如果不唤醒就一直等下去
第三:锁特性不同
wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(相当于我放弃 cpu,但你们还可以用)
而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(相当于我放弃 cpu,你们也用不了)
4.3 新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
可以这么做,在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。
比如说:
使用join方法,T3调用T2,T2调用T1,这样就能确保T1就会先完成而T3最后完成
5,多线程安全问题
多线程的安全问题通常是指由于多个线程同时访问共享的变量或资源,导致程序出现不可预期的错误或结果不符合预期的情况,例如数据丢失、死锁等。
5.1 常见的解决方案有哪些
-
synchronized关键字:使用synchronized关键字修饰代码块或方法,保证同一时刻只有一个线程可以访问共享资源。synchronized可以保证原子性、可见性和有序性。
-
Lock接口:Lock接口提供了比synchronized更加灵活的锁机制,例如可重入锁、公平锁等
-
volatile关键字:volatile关键字可以保证多线程下的可见性和禁止指令重排序。可以用于轻量级的同步、读多写少的场景下。
5.2 关于synchronized关键字
-
Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
-
它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor
-
在monitor内部有三个属性,分别是owner、entrylist、waitset
-
其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程
5.2.1 关于synchronized 的锁升级的情况了解吗
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
描述 | |
---|---|
重量级锁 | 底层使用的Monitor实现,它的里面涉及到了用户态和内核态之间的切换、进程的上下文的切换,所以成本较高,性能比较低。 |
轻量级锁 | 线程加锁的时间是错开的(也就是没有竞争),相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性 |
偏向锁 | 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令 |
一旦锁发生了竞争,都会升级为重量级锁
5.3 Synchronized 与 Lock 的区别?
-
性能层面
-
在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
-
在竞争激烈时,Lock 的实现通常会提供更好的性能
-
5.4 锁的分类
1,悲观锁和乐观锁:
-
悲观锁是指线程在访问共享资源时,会将资源上锁,以保证同一时刻只有一个线程可以访问。实现悲观锁的常用方式有Synchronized关键字和Lock接口
-
而乐观锁则是在访问共享资源时,假设其他线程不会修改该资源,因此无需加锁,直接访问即可。实现乐观锁的方式包括数据库的version字段、CAS等。
2,公平锁和非公平锁:
-
公平锁是指线程在获取锁时,按照先来后到的顺序进行获取,以保证每个线程都有公平的机会获取锁。
-
而非公平锁则是在获取锁时,不考虑线程之间的到达时间,可能会出现某个线程占据锁时间过长的情况。
-
公平锁可以减少线程饥饿等问题,但是在高并发场景下,开启公平锁机制会降低程序的吞吐量。
3,可重入锁和非可重入锁:
-
可重入锁是指一个线程可以多次获得同一把锁,而非可重入锁则不允许一个线程多次获得同一把锁。
-
在实现可重入锁时,需要考虑线程持有锁的情况,并在释放锁时判断此时线程是否还持有锁。
4,读写锁:
-
读写锁允许多个线程同时读取共享资源,但是在写入时需要排斥其他线程的读取和写入,以保证共享资源的正确性。
-
在读多写少的情况下,使用读写锁可以提高代码的执行效率和并发性能。
6,线程通信
7,多线程并发问题
线程安全是指在多线程环境下,对共享资源的访问不会产生不确定的结果。线程安全需要满足以下三个特性:
-
原子性:原子操作是指在执行过程中不会被线程调度机制打断的操作,要么全部执行成功,要么全部不执行。
-
可见性:在多线程环境中,线程操作共享资源时,需要保证其他线程可以看到修改后的值。Java中使用volatile变量和同步关键字实现可见性。
-
有序性:在多线程环境中,不同线程对共享资源的访问可能会出现多个线程并发修改同一资源,这时需要对访问顺序进行控制,保证访问的顺序是可预测的。Java中使用同步关键字和Lock接口保证有序性。
如果一个程序不满足以上三个特性中的任何一个,就不是线程安全的,可能会出现各种并发问题,
7.1 关于volatile 的理解
一旦一个共享变量被volatile修饰之后,那么就具备了两层语义:
-
第一,它保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
-
第二点就是在用 volatile 修饰共享变量,会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
-
可见性和有序性都是基于内存屏障来实现的
7.2 单例模式
单例模式用于限制一个类只有一个实例对象,并提供一个全局的访问点
实现单单例模式比较 推荐用双重加锁的方式来实现
public class Singleton { private static volatile Singleton instance = null; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
7