interview5-多线程篇

一、线程的基础知识

(1)线程与进程

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

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

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

一个进程之内可以分为一到多个线程。

interview5-多线程篇_第1张图片

二者对比

  • 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务

  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间

  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)

(2)并行与并发

interview5-多线程篇_第2张图片

interview5-多线程篇_第3张图片

并发(concurrent)是同一时间轮流应对(dealing with)多件事情的能力

并行(parallel)是同一时间同时动手做(doing)多件事情的能力

现在都是多核CPU,在多核CPU下

  • 并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU

  • 并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程

(3)线程创建的方式

共有四种方式可以创建线程,分别是:

  • 继承Thread类

interview5-多线程篇_第4张图片

  • 实现runnable接口

interview5-多线程篇_第5张图片

  • 实现Callable接口

interview5-多线程篇_第6张图片

  • 线程池创建线程

interview5-多线程篇_第7张图片

(4)runnable和callable

区别:

  1. Runnable 接口run方法没有返回值

  2. Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果

  3. Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛

(5)线程状态

JDK中的Thread类中的枚举State:

interview5-多线程篇_第8张图片

新建(NEW)、可运行(RUNNABLE)、阻塞(BLOCKED)、等待( WAITING )、时间等待(TIMED_WALTING)、终止(TERMINATED)

线程状态变化:

  1. 创建线程对象是新建状态

  2. 调用了start()方法转变为可运行状态

  3. 线程获取到了CPU的执行权,执行结束是终止状态

  4. 在可执行状态的过程中,如果没有获取CPU的执行权,可能会切换其他状态

    • 如果没有获取锁(synchronized或lock)进入阻塞状态,获得锁再切换为可执行状态

    • 如果线程调用了wait()方法进入等待状态,其他线程调用notify()唤醒后可切换为可执行状态

    • 如果线程调用了sleep(50)方法,进入计时等待状态,到时间后可切换为可执行状态

(6)wait和sleep方法的不同

wait和sleep方法都是用于让当前线程进入阻塞状态,但它们之间存在着几个主要的区别:

  1. 来源:wait方法来自Object类,而sleep方法来自Thread类。

  2. 线程状态:wait方法会释放对象的锁,使得其他线程可以进入同步方法或者同步代码块,而sleep方法在阻塞期间不会释放锁。

  3. 使用场景:wait,notify和notifyAll方法只能在同步控制方法或者同步控制块里面使用,而sleep方法可以在任何地方使用。

  4. 定时性:sleep方法可以接受一个时间参数,让线程暂停指定的时间后自动退出阻塞状态,而wait方法没有这个功能。当没有指定等待时间时,线程会一直等待,直到被其他线程中断才能结束等待。

  5. 唤醒方式:sleep方法不需要被唤醒,等待时间过后会自动退出阻塞状态。而wait方法在被其他线程中断后才能退出阻塞状态。

  6. 锁特性不同(重点):

    1. wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制

    2. wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)

    3. 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

总的来说,虽然这两个方法都可能导致线程进入阻塞状态,但在使用场景、线程状态、定时性和唤醒方式上都有显著的区别。

(7)如何保证线程顺序执行

新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?

可以使用线程中的join()方法解决:

interview5-多线程篇_第9张图片

(8)notify()和notifyAll()区别

notify:只随机唤醒一个 wait 线程

notifyAll:唤醒所有wait的线程

(9)线程的run()和start()区别

start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。

run(): 封装了要被线程执行的代码,可以被调用多次。

(10)停止线程

有三种方式可以停止线程:

  • 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止

  • 使用stop方法强行终止(不推荐,方法已作废)

  • 使用interrupt方法中断线程

    • 打断阻塞的线程( sleep,wait,join )的线程,线程会抛出InterruptedException异常

    • 打断正常的线程,可以根据打断状态来标记是否退出线程

在Java中,Thread.stop()方法已经被废弃,因为它可能导致线程在任意点停止,这可能会引发许多问题,如数据不一致、资源泄露等。

相比之下,Thread.interrupt()方法是一种更安全的终止线程的方式。该方法设置线程的中断状态,然后让线程自行决定如何响应中断。线程可以检查中断状态,并决定如何优雅地停止自己。例如,如果线程正在执行一个循环,它可以在每次循环时检查中断状态,并在适当的时候退出循环。

使用Thread.interrupt()方法可以让线程有机会清理资源、保存状态,并以一种可预测的方式停止。

二、线程中并发安全

(1)synchronized关键字的底层原理

  • synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】

  • 它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor

  • 在monitor内部有三个属性,分别是owner、entrylist、waitset

  • 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程

monitor实现的锁属于重量级锁,你了解过锁升级吗?

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

描述
重量级锁 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
轻量级锁 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性。
偏向锁 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令。

一旦锁发生了竞争,都会升级为重量级锁。

(2)JMM

JMM(Java Memory Model)Java内存模型,定义了共享内存多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性。

interview5-多线程篇_第10张图片

JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)。

线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存。

(3)CAS

CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。

在JUC( java.util.concurrent )包下实现的很多类都用到了CAS操作:

  • AbstractQueuedSynchronizer(AQS框架)

  • AtomicXXX类

在操作共享变量的时候使用的自旋锁,效率上更高一些。CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现。

CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。

(4)AQS

AQS全称是 AbstractQueuedSynchronizer,即抽象队列同步器。它是构建锁或者其他同步组件的基础框架

synchronized AQS
关键字,c++ 语言实现 java 语言实现
悲观锁,自动释放锁 悲观锁,手动开启和关闭
锁竞争激烈都是重量级锁,性能差 锁竞争激烈的情况下,提供了多种解决方案

AQS常见的实现类:

  • ReentrantLock 阻塞式锁

  • Semaphore 信号量

  • CountDownLatch 倒计时锁

AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程。

interview5-多线程篇_第11张图片

在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是0(无锁状态),如果队列中的有一个线程修改成功了state为1,则当前线程就相等于获取了资源。

在对state修改的时候使用的cas操作,保证多个线程修改的情况下原子性。

(5)ReentrantLock的实现原理

ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:

interview5-多线程篇_第12张图片

  • 可中断

  • 可以设置超时时间

  • 可以设置公平锁

  • 支持多个条件变量

  • 与synchronized一样,都支持重入

ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

(6)synchronized和Lock有什么区别

  1. 语法层面:

    • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现

    • Lock 是接口,源码由 jdk 提供,用 java 语言实现

    • 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁

  2. 功能层面:

    • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能

    • Lock 提供了许多 synchronized 不具备的功能,例如公平锁、可打断、可超时、多条件变量

    • Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock(读写锁)

  3. 性能层面:

    • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖

    • 在竞争激烈时,Lock 的实现通常会提供更好的性能

(7)死锁

死锁产生的条件:一个线程需要同时获取多把锁,这时就容易发生死锁。

如何进行死锁诊断?

当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和 jstack

  • jps:输出JVM中运行的进程状态信息

  • jstack:查看java进程内线程的堆栈信息

  • 可视化工具jconsole、VisualVM也可以检查死锁问题

(8)volatile

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:保证线程间的可见性、 禁止进行指令重排序

  1. 保证线程间的可见性用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。

  2. 禁止进行指令重排序:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。

(9)ConcurrentHashMap

ConcurrentHashMap 是一种线程安全的高效Map集合

底层数据结构:

  • JDK1.7底层采用分段的数组+链表实现

  • JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

加锁的方式:

  • JDK1.7采用Segment分段锁,底层使用的是ReentrantLock

  • JDK1.8采用CAS添加新节点,采用synchronized锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好

(10) 导致并发程序出现问题的根本原因

Java程序中怎么保证多线程的执行安全?

Java并发编程三大特性: 原子性可见性有序性

  • 原子性:一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行

  • 内存可见性:让一个线程对共享变量的修改对另一个线程可见

  • 有序性:指令重排,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的

三、线程池

(1)线程池的核心参数

interview5-多线程篇_第13张图片

  1. corePoolSize 核心线程数目

  2. maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)

  3. keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放

  4. unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等

  5. workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

  6. threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等

  7. handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

线程池的执行原理:

interview5-多线程篇_第14张图片

拒绝策略:

  1. AbortPolicy:直接抛出异常,默认策略;

  2. CallerRunsPolicy:用调用者所在的线程来执行任务;

  3. DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;

  4. DiscardPolicy:直接丢弃任务;

(2)线程池中常见的阻塞队列

workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

  1. ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO

  2. LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO

  3. DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的。

  4. SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

LinkedBlockingQueue ArrayBlockingQueue
默认无界,支持有界 强制有界
底层是链表 底层是数组
是懒惰的,创建节点的时候添加数据 提前初始化 Node 数组
入队会生成新 Node Node需要是提前创建好的
两把锁(头尾) 一把锁

(3)如何确定核心线程数

IO密集型任务一般来说:文件读写、DB读写、网络请求等。核心线程数大小设置为2N+1

CPU密集型任务一般来说:计算型代码、Bitmap转换、Gson转换等。核心线程数大小设置为N+1

// 查看机器的CPU核数
System.out.println(Runtime.getRuntime().availableProcessors());

并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置。

(4)线程池的种类有哪些

1. newFixedThreadPool:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待

interview5-多线程篇_第15张图片

2. newSingleThreadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行

interview5-多线程篇_第16张图片

3. newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程

interview5-多线程篇_第17张图片

4. newScheduledThreadPool:可以执行延迟任务的线程池,支持定时及周期性任务执行

interview5-多线程篇_第18张图片

为什么不建议用Executors创建线程池?

Executors是Java中创建线程池的一种常见方式,但不建议使用它的主要原因有以下几点:

  1. 资源耗尽风险:Executors创建的线程池没有任何的限制,如果任务提交的速度远快于任务 执行的速度,那么线程池中的线程数量会不断增加,直到耗尽系统资源。

  2. 线程管理问题:Executors创建的线程池没有提供任何的管理机制,例如线程的生命周期管理、线程的最大并发数设置、任务队列的管理等,这样可能会导致程序的行为不可预测。

  3. 关闭线程池困难:Executors创建的线程池没有提供关闭线程池的方法,如果你要停止线程池的运行,只能调用shutdown()或shutdownNow()方法,但这样的操作可能会引发一些未预期的行为。

  4. 不利于性能调优:由于Executors创建的线程池没有提供任何参数来设置线程池的行为,因此不利于根据实际需要进行性能调优。

因此,虽然Executors提供了一种简单的方式来创建线程池,但在实际的生产环境中,我们更推荐使用ThreadPoolExecutor来创建线程池,因为它提供了更多的控制和灵活性。

(5)线程池使用场景

批量导入:使用了线程池+CountDownLatch批量把数据库中的数据导入到了ES(任意)中,避免OOM。

interview5-多线程篇_第19张图片

数据汇总:调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能。

异步线程(线程池):为了避免下一级方法影响上一级方法(性能考虑),可使用异步线程调用下一个方法(不需要下一级方法返回值),可以提升方法响应时间。

(6)ThreadLocal

ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal同时实现了线程内的资源共享。

ThreadLocal 主要功能有两个:

  • 第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题。

  • 第二个是实现了线程内的资源共享。

每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象 ,原理:

  1. 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中

  2. 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值

  3. 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值

ThreadLocal内存泄漏问题:ThreadLocalMap 中的 key 是弱引用,值为强引用; key 会被GC 释放内存,关联 value 的内存并不会释放。建议主动 remove 释放 key,value。

你可能感兴趣的:(java多线程,Thread,Executor)