1. 线程的基础知识
1.1 聊一下并行和并发有什么区别?
现在都是多核CPU
,在多核CPU
下:
并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU
;
并行是同一时间动手做多件事情的能力,4核CPU
同时执行4个线程。
1.2 说一下线程和进程的区别?
进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务。
不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间。
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)。
1.3 如果在java
中创建线程有哪些方式?
在java
中一共有四种常见的创建方式,分别是:继承Thread
类、实现runnable
接口、实现Callable
接口、线程池创建线程。通常情况下,我们项目中都会采用线程池的方式创建线程。
1.4 刚才你说的runnable
和callable
两个接口创建线程有什么不同呢?
最主要的两个线程一个是有返回值,一个是没有返回值的。
Runnable
接口run
方法无返回值;Callable
接口call
方法有返回值,是个泛型,和Future
、FutureTask
配合可以用来获取异步执行的结果。
还有一个就是,他们异常处理也不一样。Runnable
接口run
方法只能抛出运行时异常,也无法捕获处理;Callable
接口call
方法允许抛出异常,可以获取异常信息。
在实际开发中,如果需要拿到执行的结果,需要使用Callalbe
接口创建线程,调用FutureTask.get()
得到可以得到返回值,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
1.5 线程包括哪些状态,状态之间是如何变化的?
在JDK
中的Thread
类中的枚举State
里面定义了6中线程的状态分别是:新建、可运行、终结、阻塞、等待和有时限等待六种。
关于线程的状态切换情况比较多。我分别介绍一下:
当一个线程对象被创建,但还未调用start
方法时处于新建状态,调用了start
方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。
如果线程获取锁失败后,由可运行进入Monitor
的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态;
如果线程获取锁成功后,但由于条件不满足,调用了wait()
方法,此时从可运行状态释放锁等待状态,当其它持锁线程调用notify()
或notifyAll()
方法,会恢复为可运行状态;
还有一种情况是调用sleep(long)
方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态。
1.6 刚才你说的线程中的wait
和sleep
方法有什么不同呢?
它们两个的相同点是都可以让当前线程暂时放弃CPU
的使用权,进入阻塞状态。
不同点主要有三个方面:
第一:方法归属不同
sleep(long)
是Thread
的静态方法。而wait()
,是Object
的成员方法,每个对象都有。
第二:线程醒来时机不同
线程执行sleep(long)
会在等待相应毫秒后醒来,而wait()
需要被notify
唤醒,wait()
如果不唤醒就一直等下去。
第三:锁特性不同
wait
方法的调用必须先获取wait
对象的锁,而sleep
则无此限制;
wait
方法执行后会释放对象锁,允许其它线程获得该对象锁(相当于我放弃cpu
,但你们还可以用);
而sleep
如果在synchronized
代码块中执行,并不会释放对象锁(相当于我放弃cpu
,你们也用不了)。
1.7 我现在举一个场景,你来分析一下怎么做,新建T1
、T2
、T3
三个线程,如何保证它们按顺序执行?
嗯~~,我思考一下 (适当的思考或想一下属于正常情况,脱口而出反而太假[背诵痕迹])
可以这么做,在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()
方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。
比如说:
使用join
方法,T3
调用T2
,T2
调用T1
,这样就能确保T1
就会先完成而T3
最后完成;
在我们使用线程的过程中,有两个方法。线程的run()
和start()
有什么区别?
start
方法用来启动线程,通过该线程调用run
方法执行run
方法中所定义的逻辑代码。start
方法只能被调用一次。run
方法封装了要被线程执行的代码,可以被调用多次。
1.8 那如何停止一个正在运行的线程呢?
有三种方式可以停止线程:
第一:可以使用退出标志,使线程正常退出,也就是当run
方法完成后线程终止,一般我们加一个标记。
第二:可以使用线程的stop
方法强行终止,不过一般不推荐,这个方法已作废。
第三:可以使用线程的interrupt
方法中断线程,内部其实也是使用中断标志来中断线程。
我们项目中使用的话,建议使用第一种或第三种方式中断线程。
2. 线程中并发锁
2.1 讲一下synchronized
关键字的底层原理?
synchronized
底层使用的JVM
级别中的Monitor
来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized
属于悲观锁。
synchronized
因为需要依赖于JVM
级别的Monitor
,相对性能也比较低。
2.2 你能具体说下Monitor
吗?
monitor
对象存在于每个Java
对象的对象头中,synchronized
锁便是通过这种方式获取锁的,也是为什么Java
中任意对象可以作为锁的原因。
monitor
内部维护了三个变量
WaitSet
:保存处于Waiting
状态的线程EntryList
:保存处于Blocked
状态的线程Owner
:持有锁的线程
只有一个线程获取到的标志就是在monitor
中设置成功了Owner
,一个monitor
中只能有一个Owner
。
在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList
进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList
中等待的线程竞争锁,竞争的时候是非公平的。
2.3 那关于synchronized
的锁升级的情况了解吗?
Java
中的synchronized
有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
重量级锁:底层使用的Monitor
实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS
操作,保证原子性。
偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS
操作,之后该线程再获取锁,只需要判断mark word
中是否是自己的线程id即可,而不是开销相对较大的CAS
命令一旦锁发生了竞争,都会升级为重量级锁。
2.4 刚才你说了synchronized
它在高并发量的情况下,性能不高,在项目该如何控制使用锁呢?
嗯,其实,在高并发下,我们可以采用ReentrantLock
来加锁。
2.5 那你说下ReentrantLock
的使用方式和底层原理?
ReentrantLock
是一个可重入锁:,调用lock
方法获取了锁之后,再次调用lock
,是不会再阻塞,内部直接增加重入次数 就行了,标识这个线程已经重复获取一把锁而不需要等待锁的释放。
ReentrantLock
是属于juc
报下的类,属于api
层面的锁,跟synchronized
一样,都是悲观锁。通过lock()
用来获取锁,unlock()
释放锁。
它的底层实现原理主要利用CAS+AQS队列
来实现。它支持公平锁和非公平锁,两者的实现类似。
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true
时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。
2.6 刚才你说了CAS
和AQS
,你能介绍一下吗?
CAS
的全称是: Compare And Swap
(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。
CAS
使用到的地方很多:AQS
框架、AtomicXXX
类;- 在操作共享变量的时候使用的自旋锁,效率上更高一些;
CAS
的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现;
AQS
的话,其实就一个jdk
提供的类AbstractQueuedSynchronizer
,是阻塞式锁和相关的同步器工具的框架。
内部有一个属性state
属性来表示资源的状态,默认state
等于0,表示没有获取锁,state
等于1的时候才标明获取到了锁。通过cas
机制设置state
状态。
在它的内部还提供了基于FIFO
的等待队列,是一个双向列表,其中
tail
指向队列最后一个元素head
指向队列中最久的一个元素
其中我们刚刚聊的ReentrantLock
底层的实现就是一个AQS
。
2.7 synchronized
和Lock
有什么区别 ?
主要有三个方面不太一样:
第一,语法层面
synchronized
是关键字,源码在jvm
中,用c++
语言实现,退出同步代码块锁会自动释放Lock
是接口,源码由jdk
提供,用java
语言实现,需要手动调用unlock
方法释放锁
第二,功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
Lock
提供了许多synchronized
不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock
可以实现不同的场景,如ReentrantLock
,ReentrantReadWriteLock
第三,性能层面
- 在没有竞争时,
synchronized
做了很多优化,如偏向锁、轻量级锁,性能不赖 - 在竞争激烈时,
Lock
的实现通常会提供更好的性能 - 统合来看,需要根据不同的场景来选择不同的锁的使用。
2.8 死锁产生的条件是什么?
嗯,是这样的,一个线程需要同时获取多把锁,这时就容易发生死锁,举个例子来说:
t1
线程获得A
对象锁,接下来想获取B
对象的锁;
t2
线程获得B
对象锁,接下来想获取A
对象的锁 ;
这个时候t1
线程和t2
线程都在互相等待对方的锁,就产生了死锁。
2.9 那如果产出了这样的,如何进行死锁诊断?
这个也很容易,我们只需要通过jdk
自动的工具就能搞定。
我们可以先通过jps
来查看当前java
程序运行的进程id
。
然后通过jstack
来查看这个进程id
,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。
2.10 请谈谈你对volatile
的理解?
volatile
是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能:
第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile
关键字会强制将修改的值立即写入主存。
第二: 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
2.11 那你能聊一下ConcurrentHashMap
的原理吗?
ConcurrentHashMap
是一种线程安全的高效Map
集合,jdk1.7
和1.8
也做了很多调整。
JDK1.7
的底层采用是分段的数组+链表 实现;JDK1.8
采用的数据结构跟HashMap 1.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
臃肿的设计,取而代之的是采用Node + CAS + Synchronized
来保 证并发安全进行实现,synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要hash
不冲 突,就不会产生并发 , 效率得到提升。
3. 线程池
3.1 线程池的种类有哪些?
在jdk
中默认提供了4中方式创建线程池:
第一个是:newCachedThreadPool
创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回 收空闲线程,若无可回收,则新建线程。
第二个是:newFixedThreadPool
创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列 中等待。
第三个是:newScheduledThreadPool
创建一个定长线程池,支持定时及周期性任务执行。
第四个是:newSingleThreadExecutor
创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO
, LIFO
, 优先级)执行。
3.2 线程池的核心参数有哪些?
在线程池中一共有7个核心参数:
corePoolSize
核心线程数目 - 池中会保留的最多线程数;maximumPoolSize
最大线程数目 - 核心线程+救急线程的最大数目;keepAliveTime
生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放;unit
时间单位 - 救急线程的生存时间单位,如秒、毫秒等;workQueue
- 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务;threadFactory
线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等;handler
拒绝策略 - 当所有线程都在繁忙,workQueue
也放满时,会触发拒绝策略;
在拒绝策略中又有4中拒绝策略:
当线程数过多以后,第一种是抛异常、第二种是由调用者执行任务、第三是丢弃当前的任务,第四是丢弃最早排队任务。默认是直接抛异常。
3.4 如何确定核心线程池呢?
是这样的,我们公司当时有一些规范,为了减少线程上下文的切换,要根据当时部署的服务器的CPU
核数来决定,我们规则是:CPU核数+1
就是最终的核心线程数。
3.5 线程池的执行原理知道吗?
嗯~,它是这样的。
首先判断线程池里的核心线程是否都在执行任务,如果不是则创建一个新的工作线程来执行任务。如果核心线程都在执行任务,则线程池判断工作队列是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任 务。如果已经满了,则交给拒绝策略来处理这个任务。
3.6 为什么不建议使用Executors
创建线程池呢?
好的,其实这个事情在阿里提供的最新开发手册《Java
开发手册-嵩山版》中也提到了。
主要原因是如果使用Executors
创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE
,这样的话,有可能导致堆积大量的请求,从而导致OOM
(内存溢出)。
所以,我们一般推荐使用ThreadPoolExecutor
来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。
4. 线程使用场景问题
4.1 如果控制某一个方法允许并发访问线程的数量?
在jdk
中提供了一个Semaphore
类(信号量)。
它提供了两个方法,semaphore.acquire()
请求信号量,可以限制线程的个数,是一个正数,如果信号量是-1,就代表已经用完了信号量,其他线程需要阻塞了。
第二个方法是semaphore.release()
,代表是释放一个信号量,此时信号量的个数+1。
4.2 那该如何保证Java
程序在多线程的情况下执行安全呢?
刚才讲过了导致线程安全的原因,如果解决的话,jdk
中也提供了很多的类帮助我们解决多线程安全的问题,比如:
JDK Atomic
开头的原子类、synchronized
、LOCK
,可以解决原子性问题;synchronized
、volatile
、LOCK
,可以解决可见性问题;Happens-Before
规则可以解决有序性问题。
4.3 你在项目中哪里用了多线程?
我想一下当时的场景[根据自己简历上的模块设计多线程场景]
参考场景一:
es数据批量导入
在我们项目上线之前,我们需要把数据量的数据一次性的同步到es
索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom
异常),如果分批执行的话,耗时也太久了。所以,当时我就想到可以使用线程池的方式导入,利用CountDownLatch+Future
来控制,就能大大提升导入的时间。
参考场景二:
在我做那个xx
电商网站的时候,里面有一个数据汇总的功能,在用户下单之后需要查询订单信息,也需要获得订单中的商品详细信息(可能是多个),还需要查看物流发货信息。因为它们三个对应的分别三个微服务,如果一个一个的操作的话,互相等待的时间比较长。所以,我当时就想到可以使用线程池,让多个线程同时处理,最终再汇总结果就可以了,当然里面需要用到Future
来获取每个线程执行之后的结果才行。
参考场景三:
我当时做了一个文章搜索的功能,用户输入关键字要搜索文章,同时需要保存用户的搜索记录(搜索历史),这块我设计的时候,为了不影响用户的正常搜索,我们采用的异步的方式进行保存的,为了提升性能,我们加入了线程池,也就说在调用异步方法的时候,直接从线程池中获取线程使用。
5. 其他
5.1 谈谈你对ThreadLocal
的理解
嗯,是这样的~~
ThreadLocal
主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享。
5.2 那你知道ThreadLocal
的底层原理实现吗?
在ThreadLocal
内部维护了一个一个ThreadLocalMap
类型的成员变量,用来存储资源对象。
当我们调用set
方法,就是以ThreadLocal
自己作为key
,资源对象作为value
,放入当前线程的 ThreadLocalMap
集合中。
当调用get
方法,就是以ThreadLocal
自己作为key
,到当前线程中查找关联的资源值。
当调用remove
方法,就是以ThreadLocal
自己作为key
,移除当前线程关联的资源值。
5.3 那关于ThreadLocal
会导致内存溢出这个事情,了解吗?
嗯,我之前看过源码,我想一下~~
是应为ThreadLocalMap
中的 key 被设计为弱引用,它是被动的被GC
调用释放key
,不过关键的是只有key
可以得到内存释放,而value
不会,因为value
是一个强引用。
在使用ThreadLocal
时都把它作为静态变量(即强引用),因此无法被动依靠GC
回收,建议主动的remove
释放key
,这样就能避免内存溢出。