并发编程篇
- 1. 线程和进程的区别?
- 2. 并行和并发有什么区别?
- 3. 创建线程的方式有哪些?
- 4. runnable 和 callable 有什么区别
- 5. 线程的 run()和 start()有什么区别?
- 6. 线程包括哪些状态,状态之间是如何变化的
- 7. 新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
- 8. notify()和 notifyAll()有什么区别?
- 9. 在 java 中 wait 和 sleep 方法的不同?
- 10. 如何停止一个正在运行的线程?
- 11. 讲一下synchronized关键字的底层原理?
- 12. 偏向锁、轻量级锁、重量级锁
- 13. 你谈谈 JMM(Java 内存模型)
- 14. CAS
- 15. 乐观锁和悲观锁
- 16. CAS 算法存在的问题
- 17. volatile 关键字
- 18. 什么是AQS?
- 19. ReentrantLock的实现原理
- 20. synchronized和Lock有什么区别 ?
- 21. 死锁产生的条件是什么?
- 22. 如何进行死锁诊断?
- 23. ConcurrentHashMap
- 24. 导致并发程序出现问题的根本原因是什么(Java程序中怎么保证多线程的执行安全)
- 25. 说一下线程池的核心参数(线程池的执行原理知道嘛)
- 26. 线程池中有哪些常见的阻塞队列
- 27. 如何确定核心线程数
- 28. 线程池的种类有哪些
- 29. 为什么不建议用Executors创建线程池
- 30. TreadLocal 有什么用
- 31. ThreadLocal的底层原理实现
- 32. ThreadLocal导致内存溢出问题
1. 线程和进程的区别?
难易程度:☆☆
出现频率:☆☆☆
- 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。 - 一个进程之内可以分为一到多个线程。
一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行。
Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。在 windows 中进程是不活动的,只是作为线程的容器。 - 二者对比
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
2. 并行和并发有什么区别?
难易程度:☆
出现频率:☆
并发:两个及两个以上的作业在同一 时间段 内执行。
并行:两个及两个以上的作业在同一 时刻 执行。
最关键的点是:是否是 同时 执行。
现在都是多核CPU,在多核CPU下
并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU
并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程
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. 线程包括哪些状态,状态之间是如何变化的
难易程度:☆☆☆
出现频率:☆☆☆☆
线程包括:新建(NEW)、可运行(RUNNABLE)、终止(TERMINATED)、阻塞(BLOCKED)、等待(WAITING)和计时等待(TIMED_WAITING)六种状态。
状态之间如何变化:
- 创建线程对象是 新建状态
- 调用了 start() 方法转变为 可执行状态
- 线程获取到了 CPU 的执行权,执行结束是 终止状态
- 在可执行状态的过程中,如果没有获取CPU的执行权,可能会切换到其它状态
- 如果没有获取锁进入 阻塞状态,获得锁之后切换到 可执行状态
- 如果线程调用了 wait() 方法进入 等待状态,其他线程调用 notify() 唤醒后切换为可执行状态
- 如果线程调用了 sleep(50) 方法,进入 计时等待状态,到时间后可切换为可执行状态
7. 新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
在多线程中有多种方法让线程按特定顺序执行,可以用线程类的 join()
方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。
比如说:
使用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
方法中断线程
11. 讲一下synchronized关键字的底层原理?
难易程度:☆☆☆☆☆
出现频率:☆☆☆
- Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
- 它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor
- 在monitor内部有三个属性,分别是owner、entrylist、waitset
- 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程
具体的流程: - 代码进入synchorized代码块,先让lock(对象锁)关联的monitor,然后判断Owner是否有线程持有
- 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功
- 如果有线程持有,则让当前线程进入entryList进行阻塞,如果Owner持有的线程已经释放了锁,在EntryList中的线程去竞争锁的持有权(非公平)
- 如果代码块中调用了wait()方法,则会进去WaitSet中进行等待
12. 偏向锁、轻量级锁、重量级锁
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
- 重量级锁:底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
- 轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性
- 偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令
- 一旦锁发生了竞争,都会升级为重量级锁
13. 你谈谈 JMM(Java 内存模型)
- JMM(ava Memory Model)Java内存模型,定义了共享内存 中 多线程程序读写操作 的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性
- JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)
- 线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存
14. CAS
CAS的全称是: Compare And Swap
(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。
- CAS使用到的地方很多:AQS框架、AtomicXXX类
- 在操作共享变量的时候使用的自旋锁,效率上更高一些
- CAS的底层是调用的
Unsafe
类中的方法,都是操作系统提供的,其他语言实现
15. 乐观锁和悲观锁
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗(在提交修改的时候去验证对应的资源是否被其它线程修改了)。
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
16. CAS 算法存在的问题
- ABA 问题
如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。 - 循环时间长开销大
自旋CAS的方式如果长时间不成功,会给CPU带来很大的开销。 - 只能保证一个共享变量的原子操作
CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。
17. volatile 关键字
volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能:
第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
第二: 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
18. 什么是AQS?
- 是多线程中的队列同步器(AbstractQueuedSynchronizer)。是一种锁机制,它是做为一个基础框架使用的,像ReentrantLock、Semaphore都是基于AQS实现的
- AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程
- 在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是0 (无锁状态),如果队列中的有一个线程修改成功了state为1,则当前线程就相等于获取了资源
- 在对state修改的时候使用的 CAS 操作,保证多个线程修改的情况下原子性。
19. ReentrantLock的实现原理
- ReentrantLock是一个可重入锁:,调用 lock 方 法获取了锁之后,再次调用 lock,是不会再阻塞的。
- ReentrantLock是属于juc包下的类,属于api层面的锁,跟synchronized一样,都是悲观锁。通过lock()用来获取锁,unlock()释放锁。
- 它的底层实现原理主要利用 CAS+AQS 队列来实现。它支持公平锁和非公平锁,两者的实现类似
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。
20. synchronized和Lock有什么区别 ?
难易程度:☆☆☆☆
出现频率:☆☆☆☆
第一,语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
- Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁
第二,功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock
第三,性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
21. 死锁产生的条件是什么?
一个线程需要同时获取多把锁,这时就容易发生死锁,举个例子来说:
t1 线程获得A对象锁,接下来想获取B对象的锁
t2 线程获得B对象锁,接下来想获取A对象的锁
这个时候t1线程和t2线程都在互相等待对方的锁,就产生了死锁
22. 如何进行死锁诊断?
我们只需要通过jdk自动的工具就能搞定
我们可以先通过 jps
来查看当前java程序运行的进程id
然后通过 jstack
来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。
23. ConcurrentHashMap
难易程度:☆☆☆
出现频率:☆☆☆☆
ConcurrentHashMap 是一种线程安全的高效Map集合.
底层数据结构:
- JDK1.7底层采用分段的数组+链表实现
- JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
(1)JDK1.7 中concurrentHashMap
数据结构
- 提供了一个segment数组,在初始化ConcurrentHashMap 的时候可以指定数组的长度,默认是16,一旦初始化之后中间不可扩容
- 在每个segment中都可以挂一个HashEntry数组,数组里面可以存储具体的元素,HashEntry数组是可以扩容的
- 在HashEntry存储的数组中存储的元素,如果发生冲突,则可以挂单向链表
存储流程
- 先去计算key的hash值,然后确定segment数组下标
- 再通过hash值确定hashEntry数组中的下标存储数据
- 在进行操作数据的之前,会先判断当前segment对应下标位置是否有线程进行操作,为了线程安全使用的是ReentrantLock进行加锁,如果获取锁是被会使用cas自旋锁进行尝试
(2) JDK1.8中concurrentHashMap
在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表。采用 CAS + Synchronized来保证并发安全进行实现。
- CAS控制数组节点的添加(当前插槽没有头节点时的线程安全问题)
- synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题 , 效率得到提升(当前插槽有头节点时)
24. 导致并发程序出现问题的根本原因是什么(Java程序中怎么保证多线程的执行安全)
Java并发编程有三大核心特性,分别是原子性、可见性和有序性。
- 首先,原子性指的是一个线程在CPU中的操作是不可暂停也不可中断的,要么执行完成,要么不执行。比如,一些简单的操作如赋值可能是原子的,但复合操作如自增就不是原子的。为了保证原子性,我们可以使用synchronized关键字或JUC里面的Lock来进行加锁。
- 其次,可见性是指让一个线程对共享变量的修改对另一个线程可见。由于线程可能在自己的工作内存中缓存共享变量的副本,因此一个线程对共享变量的修改可能不会立即反映在其他线程的工作内存中。为了解决这个问题,我们可以使用synchronized关键字、volatile关键字或Lock来确保可见性。
- 最后,有序性是指处理器为了提高程序运行效率,可能会对输入代码进行优化,导致程序中各个语句的执行先后顺序与代码中的顺序不一致。虽然处理器会保证程序最终执行结果与代码顺序执行的结果一致,但在某些情况下我们可能需要确保特定的执行顺序。为了解决这个问题,我们可以使用volatile关键字来禁止指令重排。
25. 说一下线程池的核心参数(线程池的执行原理知道嘛)
难易程度:☆☆☆
出现频率:☆☆☆☆
线程池核心参数主要参考 ThreadPoolExecutor
这个类的7个参数的构造函数
- corePoolSize 核心线程数目
- maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
- keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
- unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
- workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
- threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
- handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
- 任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行
- 如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列
- 如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务
- 如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务
- 如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略
- 拒绝策略:
- AbortPolicy:直接抛出异常,默认策略;
- CallerRunsPolicy:用调用者所在的线程来执行任务;
- DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
- DiscardPolicy:直接丢弃任务;
26. 线程池中有哪些常见的阻塞队列
难易程度:☆☆☆
出现频率:☆☆☆
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
比较常见的有4个,用的最多是 ArrayBlockingQueue
和LinkedBlockingQueue
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
- LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
- DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
- SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
左边是LinkedBlockingQueue加锁的方式,右边是ArrayBlockingQueue加锁的方式
- LinkedBlockingQueue读和写各有一把锁,性能相对较好
- ArrayBlockingQueue只有一把锁,读和写公用,性能相对于LinkedBlockingQueue差一些
27. 如何确定核心线程数
难易程度:☆☆☆☆
出现频率:☆☆☆
- 高并发、任务执行时间短 -->( CPU核数+1 ),减少线程上下文的切换
- 并发不高、任务执行时间长
- IO密集型任务
- 一般来说:文件读写、DB读写、网络请求等
- 推荐:核心线程数大小设置为2N+1 (N为计算机的CPU核数)
- CPU密集型任务
- 一般来说:计算型代码、Bitmap转换、Gson转换等
- 推荐:核心线程数大小设置为N+1 (N为计算机的CPU核数)
- 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)
28. 线程池的种类有哪些
难易程度:☆☆☆
出现频率:☆☆☆
newFixedThreadPool
: 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待newSingleThreadExecutor
: 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行newCachedThreadPool
: 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程newScheduledThreadPool
: 可以执行延迟任务的线程池,支持定时及周期性任务执行
29. 为什么不建议用Executors创建线程池
难易程度:☆☆☆
出现频率:☆☆☆
主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。
所以,我们一般推荐使用ThreadPoolExecutor来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。
30. TreadLocal 有什么用
通常情况下,我们创建的变量可以被任何一个线程访问和修改。这在多线程环境中可能导致数据竞争和线程安全问题。
ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享。
ThreadLocal 类允许每个线程绑定自己的值,当你创建一个 ThreadLocal 变量时,每个访问该变量的线程都会拥有一个独立的副本。线程可以通过 get()
方法获取自己线程的本地副本,或通过 set()
方法修改该副本的值,从而避免了线程安全问题。
31. ThreadLocal的底层原理实现
在ThreadLocal内部维护了一个一个 ThreadLocalMap
类型的成员变量,用来存储资源对象。
- 当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
- 当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
- 当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
32. ThreadLocal导致内存溢出问题
ThreadLocalMap 的 key 和 value 引用机制:
- key 是弱引用:ThreadLocalMap 中的 key 是 ThreadLocal 的弱引用 (WeakReference<ThreadLocal<?>>)。 这意味着,如果 ThreadLocal 实例不再被任何强引用指向,垃圾回收器会在下次 GC 时回收该实例,导致 ThreadLocalMap 中对应的 key 变为 null。
- value 是强引用:ThreadLocalMap 中的 value 是强引用。 即使 key 被回收(变为 null),value 仍然存在于 ThreadLocalMap 中,被强引用,不会被回收。
当 ThreadLocal 实例失去强引用后,其对应的 value 仍然存在于 ThreadLocalMap 中,因为 Entry 对象强引用了它。如果线程持续存活(例如线程池中的线程),ThreadLocalMap 也会一直存在,导致 key 为 null 的 entry 无法被垃圾回收,就会造成内存泄漏。
也就是说,内存泄漏的发生需要同时满足两个条件:
- ThreadLocal 实例不再被强引用;
- 线程持续存活,导致 ThreadLocalMap 长期存在。
因此,在使用完 ThreadLocal 后,务必调用 remove() 方法。 这是最安全和最推荐的做法。