一、线程的基础知识
1、线程和进程的区别?
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务。
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间。
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)。
2、并行和并发有什么区别?
-
并发(concurrent)是同一时间应对(dealing with)多件事情的能力。
-
并行(parallel)是同一时间动手做(doing)多件事情的能力。
3、创建线程的四种方式
- 继承Thread类。
- 实现runnable接口。
- 实现Callable接口。
- 线程池创建线程。
4、runnable 和 callable 有什么区别?
- Runnable 接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。
- Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
- Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛。
5、线程的 run()和 start()有什么区别?
-
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
-
run(): 封装了要被线程执行的代码,可以被调用多次。
6、线程包括哪些状态,状态之间是如何变化的?
- 新建
- 当一个线程对象被创建,但还未调用 start 方法时处于新建状态。
- 此时未与操作系统底层线程关联。
- 可运行
- 调用了 start 方法,就会由新建进入可运行。
- 此时与底层线程关联,由操作系统调度执行。
- 终结
- 线程内代码已经执行完毕,由可运行进入终结。
- 此时会取消与底层线程关联。
- 阻塞
- 当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用 cpu 时间。
- 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态。
- 等待
- 当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间。
- 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态。
- 有时限等待
- 当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从可运行状态释放锁进入 Monitor 等待集合进行有时限等待,同样不占用 cpu 时间。
- 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁。
- 如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁。
- 还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态。
7、新建 T1、T2、T3 三个线程,如何保证它们按顺序执行。?
使用线程类的join()方法在一个线程中启动另一个线程。
T3调用T2,T2调用T1,这样T1就会先完成而T3最后完成。
8、notify()和 notifyAll()有什么区别?
- notifyAll:唤醒所有wait的线程。
- notify:只随机唤醒一个 wait 线程。
9、在 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,你们也用不了)。
10、如何停止一个正在运行的线程?
有三种方式可以停止线程
- 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止。
- 使用stop方法强行终止(不推荐,方法已作废)。
- 使用interrupt方法中断线程。
二、线程中并发安全
1、讲一下synchronized关键字的底层原理?
- Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】。
- 它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor。
- 在monitor内部有三个属性,分别是owner、entrylist、waitset。
- 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程。
2、Monitor实现的锁属于重量级锁,你了解过锁升级吗?
- 重量级锁:
底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 - 轻量级锁:
线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性。 - 偏向锁:
一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令。
3、你谈谈 JMM(Java 内存模型)
- JMM(Java Memory Model)Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性。
- JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)。
- 线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存。
4、CAS你知道吗?
- CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。
- CAS使用到的地方很多:AQS框架、AtomicXXX类。
- 在操作共享变量的时候使用的自旋锁,效率上更高一些。
- CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现。
5、乐观锁和悲观锁
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
6、请谈谈你对 volatile 的理解
- ①保证线程间的可见性
用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。 - ② 禁止进行指令重排序
指令重排:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。
7、什么是AQS?
- 是多线程中的队列同步器。是一种锁机制,它是做为一个基础框架使用的,像ReentrantLock、Semaphore都是基于AQS实现的。
- AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程。
- 在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是0(无锁状态),如果队列中的有一个线程修改成功了state为1,则当前线程就相等于获取了资源。
- 在对state修改的时候使用的cas操作,保证多个线程修改的情况下原子性。
8、ReentrantLock的实现原理
- ReentrantLock表示支持重新进入的锁,调用 lock 方 法获取了锁之后,再次调用 lock,是不会再阻塞。
- ReentrantLock主要利用CAS+AQS队列来实现。
- 支持公平锁和非公平锁,在提供的构造器的中无参默认是非公平锁,也可以传参设置为公平锁。
9、synchronized和Lock有什么区别 ?
- 语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现。
- Lock 是接口,源码由 jdk 提供,用 java 语言实现。
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁。
- 功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能。
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量。
- Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock。
- 性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖。
- 在竞争激烈时,Lock 的实现通常会提供更好的性能。
10、死锁产生的条件是什么?
一个线程需要同时获取多把锁,这时就容易发生死锁。
11、如何进行死锁诊断?
- 当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和 jstack。
- jps:输出JVM中运行的进程状态信息
- jstack:查看java进程内线程的堆栈信息,查看日志,检查是否有死锁;
如果有死锁现象,需要查看具体代码分析后,可修复。
- 可视化工具jconsole、VisualVM也可以检查死锁问题。
12、聊一下ConcurrentHashMap?
- 底层数据结构:
JDK1.7底层采用分段的数组+链表实现。
JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。 - 加锁的方式
JDK1.7采用Segment分段锁,底层使用的是ReentrantLock。
JDK1.8采用CAS添加新节点,采用synchronized锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好。
13、导致并发程序出现问题的根本原因是什么?
- 1.原子性:
解决:synchronized、lock。 - 2.内存可见性:
解决:volatile、synchronized、lock。 - 3.有序性:
解决:volatile。
三、线程池
1、说一下线程池的核心参数(线程池的执行原理知道嘛)
核心参数
- corePoolSize 核心线程数目。
- maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)。
- keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放。
- unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等。
- workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务。
- threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等。
- handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略。
执行原理
- 1、任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行。
- 2,如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列。
- 3,如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务。
如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务。 - 4,如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略。
2、拒绝策略:
- 1.AbortPolicy:直接抛出异常,默认策略;
- 2.CallerRunsPolicy:用调用者所在的线程来执行任务;
- 3.DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
- 4.DiscardPolicy:直接丢弃任务;
3、线程池中有哪些常见的阻塞队列
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
- LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
- DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的。
- SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
4、如何确定核心线程数
- 高并发、任务执行时间短 -->( CPU核数+1 ),减少线程上下文的切换
- 并发不高、任务执行时间长
- IO密集型的任务 --> (CPU核数 * 2 + 1)。
- 计算密集型任务 --> ( CPU核数+1 )。
- 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,参考(2)。
5、线程池的种类有哪些?
- newFixedThreadPool:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
- newSingleThreadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行。
- newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
- newScheduledThreadPool:可以执行延迟任务的线程池,支持定时及周期性任务执行。
6、为什么不建议用Executors创建线程池?
参考阿里开发手册《Java开发手册-嵩山版》。
主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。
所以,我们一般推荐使用ThreadPoolExecutor来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。
四、使用场景
1、你们项目哪里用到了多线程?
参考场景一:
es数据批量导入
在我们项目上线之前,我们需要把数据量的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),如果分批执行的话,耗时也太久了。所以,当时我就想到可以使用线程池的方式导入,利用CountDownLatch+Future来控制,就能大大提升导入的时间。
参考场景二:
在我做那个xx电商网站的时候,里面有一个数据汇总的功能,在用户下单之后需要查询订单信息,也需要获得订单中的商品详细信息(可能是多个),还需要查看物流发货信息。因为它们三个对应的分别三个微服务,如果一个一个的操作的话,互相等待的时间比较长。所以,我当时就想到可以使用线程池,让多个线程同时处理,最终再汇总结果就可以了,当然里面需要用到Future来获取每个线程执行之后的结果才行。
参考场景三:
xxx项目中使用的。
我当时做了一个文章搜索的功能,用户输入关键字要搜索文章,同时需要保存用户的搜索记录(搜索历史),这块我设计的时候,为了不影响用户的正常搜索,我们采用的异步的方式进行保存的,为了提升性能,我们加入了线程池,也就说在调用异步方法的时候,直接从线程池中获取线程使用。
2、如何控制某个方法允许并发访问线程的数量?
在多线程中提供了一个工具类Semaphore,信号量。在并发的情况下,可以控制方法的访问量
- 创建Semaphore对象,可以给一个容量。
- acquire()可以请求一个信号量,这时候的信号量个数-1。
- release()释放一个信号量,此时信号量个数+1。
3、谈谈你对ThreadLocal的理解?
ThreadLocal 主要功能有两个
- 第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题。
- 第二个是实现了线程内的资源共享。
4、你知道ThreadLocal的底层原理实现吗?
在ThreadLocal内部维护了一个一个 ThreadLocalMap 类型的成员变量,用来存储资源对象。
- 当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中。
- 当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值。
- 当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值。
5、关于ThreadLocal会导致内存溢出这个事情,了解吗?
ThreadLocalMap 中的 key 被设计为弱引用,它是被动的被GC调用释放key,不过关键的是只有key可以得到内存释放,而value不会,因为value是一个强引用。
在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。
结束!!!!!!
个人能否有成就,只看他是否具备自尊心与自信心两个条件。---苏格拉底