文章目录
多线程自问自答
Drip
2025-06-20 22:53
累计阅读13
评论0


线程和进程的区别?

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

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

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

Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。在 windows 中进程是不活动的,只是作为线程的容器

二者对比

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

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

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

并行和并发有什么区别?

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

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

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

创建线程的四种方式

在java中一共有四种常见的创建方式,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程。通常情况下,我们项目中都会采用线程池的方式创建线程。

runnable 和 callable 有什么区别

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

  2. Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

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

线程的 run()和 start()有什么区别?

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

run(): 就是一个普通的方法,封装了要被线程执行的代码,可以被调用多次。

线程包括哪些状态,状态之间是如何变化的

在JDK中的Thread类中的枚举State里面定义了6中线程的状态分别是:新建、可运行、终结、阻塞、等待和有时限等待六种。

关于线程的状态切换情况比较多。我分别介绍一下

当一个线程对象被创建,但还未调用 start 方法时处于新建状态,调用了 start 方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。

如果线程获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态

如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态

还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态

image-20250608165605975

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

嗯~~,我思考一下 (适当的思考或想一下属于正常情况,脱口而出反而太假[背诵痕迹])

可以这么做,在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。

比如说:

使用join方法,T3调用T2,T2调用T1,这样就能确保T1就会先完成而T3最后完成

notify()和 notifyAll()有什么区别?

notifyAll:唤醒所有wait的线程

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

在 java 中 wait 和 sleep 方法的不同?

共同点

  • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态

不同点

  • 方法归属不同

    • sleep(long) 是 Thread 的静态方法

    • 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有

  • 醒来时机不同

    • 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来

    • wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去

    • 它们都可以被打断唤醒

  • 锁特性不同(重点)

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

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

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

如何停止一个正在运行的线程?

有三种方式可以停止线程

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

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

  • 使用interrupt方法中断线程

讲一下synchronized关键字的底层原理?

synchronized 底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。

synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。

monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因

monitor内部维护了三个变量

  • WaitSet:保存处于Waiting状态的线程

  • EntryList:保存处于Blocked状态的线程

  • Owner:持有锁的线程

只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner

在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。

synchronized关键字的底层原理-进阶

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

重量级锁:底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。

轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性

偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令

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

jdk1.6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了,Jdk1.6 之后,为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁和轻量级锁,增加了锁升级的过程,由无锁->偏向锁->自旋锁->重量级锁

你谈谈 JMM(Java 内存模型)

Java内存模型是Java虚拟机规范中定义的一种非常重要的内存模型。它的主要作用是描述Java程序中线程共享变量的访问规则,以及这些变量在JVM中是如何被存储和读取的,涉及到一些底层的细节。

这个模型有几个核心的特点。首先,所有的共享变量,包括实例变量和类变量,都被存储在主内存中,也就是计算机的RAM。需要注意的是,局部变量并不包含在内,因为它们是线程私有的,所以不存在竞争问题。

其次,每个线程都有自己的工作内存,这里保留了线程所使用的变量的工作副本。这意味着,线程对变量的所有操作,无论是读还是写,都必须在自己的工作内存中完成,而不能直接读写主内存中的变量。

最后,不同线程之间不能直接访问对方工作内存中的变量。如果线程间需要传递变量的值,那么这个过程必须通过主内存来完成。

image-20250608172947801

什么是CAS?

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

具体实现流程是这样的:有一个当前内存值V,旧的预期值A,和即将更新的值B。当且仅当旧的预期值A与内存值V相同时,才会将内存值V改为B,并返回true,否则什么都不做,返回false。如果CAS操作失败,会通过自旋的方式等待并再次尝试,直到成功

乐观锁和悲观锁

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

  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

谈谈你对volatile的理解

volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能

第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。

第二: 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化

什么是AQS?

AQS的话,其实就一个jdk提供的类AbstractQueuedSynchronizer。他是多线程的队列同步器。就是一个锁框架。

像ReentrantLock,Semaphore都是基于AQS实现的。

内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过cas 机制设置 state 状态。

在它的内部还提供了基于 FIFO 的等待队列,是一个双向列表,队列中存储的就是排队的线程image-20250609161736316

ReentrantLock的实现原理

ReentrantLock是一个可重入锁。调用 lock 方 法获取了锁之后,再次调用 lock,是不会再阻塞,内部直接增加重入次数 就行了,标识这个线程已经重复获取一把锁而不需要等待锁的释放。

ReentrantLock是属于juc包下的类,属于api层面的锁,跟synchronized一样,都是悲观锁。通过lock()用来获取锁,unlock()释放锁。

它的底层实现原理主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似

构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。

synchronized和Lock有什么区别 ?

第一,语法层面上:

  • synchronized是关键字,源码在jvm中, 底层由C++实现,退出同步代码块会自动释放锁

  • Lock是接口,源码由jdk提供的,由Java实现,需要手动调用unlock方法释放锁。

第二,功能层面上:

  • 两者都是悲观锁,都具备互斥,同步,可重入

  • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时。同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock

第三,性能层面上:

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

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

死锁产生的条件是什么?

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

例如:

t1 线程获得A对象锁,接下来想获取B对象的锁

t2 线程获得B对象锁,接下来想获取A对象的锁

这个时候t1线程和t2线程都在互相等待对方的锁,就产生了死锁。

如何进行死锁诊断?

我们只需要通过jdk自动的工具就能搞定

我们可以先通过jps来查看当前java程序运行的进程id

然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。

说说ConcurrentHashMap

ConcurrentHashMap 是一种线程安全的HashMap。

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

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

在jdk1.7中 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一 种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构 的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。

Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁

在jdk1.8中的ConcurrentHashMap 做了较大的优化,性能提升了不少。首先是它的数据结构与jdk1.8的hashMap数据结构完全一致。其次是放弃了Segment臃肿的设计,取而代之的是采用 CAS + Synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发 , 效率得到提升

导致并发程序出现问题的根本原因是什么

Java并发编程有三大核心特性,分别是原子性、可见性和有序性

首先,原子性指的是一个线程在CPU中的操作是不可暂停也不可中断的,要么执行完成,要么不执行。比如,一些简单的操作如赋值可能是原子的,但复合操作如自增就不是原子的。为了保证原子性,我们可以使用synchronized关键字或JUC里面的Lock来进行加锁。

其次,可见性是指让一个线程对共享变量的修改对另一个线程可见。由于线程可能在自己的工作内存中缓存共享变量的副本,因此一个线程对共享变量的修改可能不会立即反映在其他线程的工作内存中。为了解决这个问题,我们可以使用synchronized关键字、volatile关键字或Lock来确保可见性。

最后,有序性是指处理器为了提高程序运行效率,可能会对输入代码进行优化,导致程序中各个语句的执行先后顺序与代码中的顺序不一致。虽然处理器会保证程序最终执行结果与代码顺序执行的结果一致,但在某些情况下我们可能需要确保特定的执行顺序。为了解决这个问题,我们可以使用volatile关键字来禁止指令重排。

说一下线程池的核心参数(线程池的执行原理知道嘛)

在线程池中一共有7个核心参数:

  1. corePoolSize 核心线程数目 - 池中会保留的最多线程数

  2. maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目

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

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

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

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

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

拒绝策略有4种,当线程数过多以后,第一种是抛异常、第二种是由调用者执行任务、第三是丢弃当前的任务,第四是丢弃最早排队任务。默认是直接抛异常。

执行流程:

  1. 任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行

  2. 如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列

  3. 如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务

  4. 如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务

  5. 如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略

image-20250609174404589

线程池中有哪些常见的阻塞队列

Jdk中提供了很多阻塞队列,开发中常见的有两个:ArrayBlockingQueueLinkedBlockingQueue

ArrayBlockingQueueLinkedBlockingQueue是Java中两种常见的阻塞队列,它们在实现和使用上有一些关键的区别。

首先,ArrayBlockingQueue是一个有界队列,它在创建时必须指定容量,并且这个容量不能改变。而LinkedBlockingQueue默认是无界的,但也可以在创建时指定最大容量,使其变为有界队列。

其次,它们在内部数据结构上也有所不同。ArrayBlockingQueue是基于数组实现的,而LinkedBlockingQueue则是基于链表实现的。这意味着ArrayBlockingQueue在访问元素时可能会更快,因为它可以直接通过索引访问数组中的元素。而LinkedBlockingQueue则在添加和删除元素时可能更快,因为它不需要移动其他元素来填充空间。

另外,它们在加锁机制上也有所不同。ArrayBlockingQueue使用一把锁来控制对队列的访问,这意味着读写操作都是互斥的。而LinkedBlockingQueue则使用两把锁,一把用于控制读操作,另一把用于控制写操作,这样可以提高并发性能。

如何确定核心线程数

  • IO密集型任务

一般来说:文件读写、DB读写、网络请求等

推荐:核心线程数大小设置为2N+1 (N为计算机的CPU核数)

  • CPU密集型任务

一般来说:计算型代码、Bitmap转换、Gson转换等

推荐:核心线程数大小设置为N+1 (N为计算机的CPU核数)

线程池的种类有哪些

在jdk中默认提供了4中方式创建线程池

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

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

第三个是:newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。

第四个是:newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

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

image-20250610170247571

线程使用场景问题

1.线程池使用场景CountDownLatch、Future(你们项目哪里用到了多线程)

案例一(es数据批量导入)

在我们项目上线之前,我们需要把数据库中的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制,就能避免一次性加载过多,防止内存溢出

整体流程就是通过CountDownLatch+线程池配合去执行

案例二(数据汇总)

image-20250610171500076

  • 在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能

参考回答

嗯~~,我想一下当时的场景[根据自己简历上的模块设计多线程场景]

参考场景一:

es数据批量导入

在我们项目上线之前,我们需要把数据量的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),如果分批执行的话,耗时也太久了。所以,当时我就想到可以使用线程池的方式导入,利用CountDownLatch+Future来控制,就能大大提升导入的时间。

参考场景二:

在我做那个xx电商网站的时候,里面有一个数据汇总的功能,在用户下单之后需要查询订单信息,也需要获得订单中的商品详细信息(可能是多个),还需要查看物流发货信息。因为它们三个对应的分别三个微服务,如果一个一个的操作的话,互相等待的时间比较长。所以,我当时就想到可以使用线程池,让多个线程同时处理,最终再汇总结果就可以了,当然里面需要用到Future来获取每个线程执行之后的结果才行

参考场景三:

我当时做了一个文章搜索的功能,用户输入关键字要搜索文章,同时需要保存用户的搜索记录(搜索历史),这块我设计的时候,为了不影响用户的正常搜索,我们采用的异步的方式进行保存的,为了提升性能,我们加入了线程池,也就说在调用异步方法的时候,直接从线程池中获取线程使用

如何控制某个方法允许并发访问线程的数量?

在jdk中提供了一个Semaphore[seməfɔːr]类(信号量)

它提供了两个方法,semaphore.acquire() 请求信号量,可以限制线程的个数,是一个正数,如果信号量是-1,就代表已经用完了信号量,其他线程需要阻塞了

第二个方法是semaphore.release(),代表是释放一个信号量,此时信号量的个数+1

谈谈你对ThreadLocal的理解

ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。

实现了同一线程内的资源共享

最常见的ThreadLocal使用场景为 用来解决数据库连接、Session管理

案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。同时Spring的事务中,也是类似的做法。

参考回答

面试官:谈谈你对ThreadLocal的理解

候选人

嗯,是这样的~~

ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享

面试官:好的,那你知道ThreadLocal的底层原理实现吗?

候选人

嗯,知道一些~

在ThreadLocal内部维护了一个 ThreadLocalMap 类型的成员变量,用来存储资源对象

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

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

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

面试官:好的,那关于ThreadLocal会导致内存溢出这个事情,了解吗?

候选人

嗯,我之前看过源码,我想一下~~

是因为ThreadLocalMap 中的 key 被设计为弱引用,它是被动的被GC调用释放key,不过关键的是只有key可以得到内存释放,而value不会,因为value是一个强引用。

在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。

评论