目录
1. 什么是线程和进程?
线程与进程有什么区别?
那什么是上下文切换?
进程间怎么通信?
什么是用户线程和守护线程?
2. 并行和并发的区别?
3. 创建线程的几种方式?
Runnable接口和Callable接口的区别?
run()方法和start()有什么区别?
4. Java线程状态和方法?
描述线程的生命周期?
一个线程两次调用start()方法会出现什么情况?
sleep()和wait()方法的区别是什么?
5. 并发编程的三要素是什么?
6. 什么是线程死锁?
怎么定位死锁?
7. Java并发包提供了哪些并发工具类?
并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别?
8. 什么是线程池?
讲讲线程池的生命周期?
线程池有哪些类型?
线程池的拒绝策略?
线程池的执行流程?
Java并发类库提供的线程池有哪几种? 分别有什么特点?
Executor创建线程为什么不建议使用了?
9. AtomicInteger底层实现原理是什么?
什么是CAS?
10. 锁的分类有哪些?
synchronized和ReentrantLock有什么区别呢?
为什么Synchronized能实现线程同步?
锁升级过程?
进程:是指一个在内存中运行的应用程序,常见的app都是一个个进程。进程具有自己独立的内存空间,一个进程可以有多个线程;
线程:是指进程中的一个执行任务的单元,负责执行当前进程中程序的执行,一个进程至少有一个线程,一个进程内的多个线程见可共享数据。
线程与进程有什么区别?
- 根本区别:进程是操作系统资源分配的单位,而线程是处理器任务调度和执行的基本单位;
- 资源开销:进程有独立的代码和数据空间(程序上下文),程序之间切换会有较大开销;线程可以看作轻量级进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,线程间切换开销较小;
- 包含关系:一个进程中至少有一个线程,基本都是有多个线程共同完成一个进行的运行;而线程是属于进行中的一部分;
- 内存分配:进程分配到独立的地址空间和资源,而线程是功效一个进行内的地址空间和资源的;
- 影响关系:进程崩溃不影响其他进程的执行,但线程崩溃会导致进程崩溃,所以进程比线程的健壮性好;
- 执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但线程不可单独执行,必须依附在进程中,由应用程序提供多线程的控制。
那什么是上下文切换?
- 在多线程编程中,大多线程数量大于CPU核心个数,而一个CPU在某一时刻只能执行一个线程。为了让这些线程都能得到执行,CPU采用的策略是为每个线程分配一个时间片去轮流执行,当一个线程的时间片用完就会让这个线程重新处于就绪状态而把CPU让给其他线程使用,这个过程就叫做上下文切换。简单来说:任务从保存到再加载的过程就是一次上下文切换。
进程间怎么通信?
进程间的通信方式有很多,比如:管道、消息队列、共享内存、信号、嵌套字
- 管道:包含无名管道和命名管道,无名管道半双工,只能用于具有亲缘关系的进程间通信,可以看作一种特殊文件;命名管道可以允许无亲缘关系的进程间通信;
- 消息队列: 就是一个消息的链表,是一系列保存在内核中消息的列表。当一个进程需要通信的时候,只需要将数据写入这个消息列表当中,就可以正常退出干其他事情了,另一个进程需要数据的时候只需去读取数据就行了。消息队列独立于发送和接收进程,哪怕发送进程终止了,队列中数据也不会被删除;
- 信号:用于通知接收进程某个事件发生
- 内存共享:使多个进程访问同一块内存空间
- 嵌套字:socket,用于不同主机间直接的通信
什么是用户线程和守护线程?
- 用户线程:指的是运行在前台,执行具体任务的线程,如main所在县丞就是用户线程;
- 守护线程:指的是运行在后台,为其他用户线程服务的线程,如垃圾回收线程。特点是守护线程不影响JVM的退出。
多线程:就是指程序中包含多个执行流,就是在一个程序中可以同时运行多个不同的线程来执行不同的任务。
- 好处:提升了CPU的使用率:在多线程环境中,一个线程在等待时,其他线程可以运行(也就是说允许单个程序创建多个并行执行的线程来完成各自的任务),这样大大提高了程序的效率。
- 劣势:线程数量与内存占用成正比,线程越多消耗内存也越多;多线程需要协调和管理,所以需要CPU时间跟踪线程;线程之间对共享资源的访问会相互影响,也就是解决我们常说的线程共享资源问题(并发问题)。
Ps:有的文档也把匿名内部类和Lambda表达式的方式也分别算作创建线程的方式,主要回答这四种比较常规。
Runnable接口和Callable接口的区别?
- 返回值:Runnable的run()方法执行没有返回值,而Callable执行的call()方法可以返回执行结果;
- 异常处理:Runnable的run()方法不能抛出可被检查的异常,只能抛出非受检查的RuntimeException。而Callable接口的call()方法可以抛出任何类型的异常,包括受检查的异常。
- 兼容性:Callable接口是在Java 5中引入的新接口,而Runnable接口是在Java 1.0中就存在的。Callable接口提供了更多的灵活性和功能,但Runnable接口仍然是使用较多的接口之一,因为它的简单性和兼容性。
- 并发集合:Callable接口通常与ExecutorService和Future配合使用,以支持异步任务执行和获取结果。 Runnable接口通常与Thread类或者Executor框架一起使用,用于执行简单的线程任务。
run()方法和start()有什么区别?
每个线程都是通过其特定的Thread对象所对应的方法run()来完成其操作的,run()方法称为线程体,通过调用Thread类的start()方法来启动一个线程。
- start()方法用于启动线程,run()方法用于执行线程的运行代码;
- run()方法可以重复调用,而start()方法只能调用一次。
- start()方法来启动一个线程,真正的实现了多线程运行。调用satrt()方法无需等待run()方法体代码执行完毕,可以继续执行其他代码。此时线程处于就绪状态,并没有运行。然后调用run()方法来等待分配资源运行线程。
Java线程状态有6种,分别是 NEW(新建状态)、RUNNABLE(就绪状态)、 BLOCKED(阻塞状态)、WAIT(等待状态)、TIME_WAIT(超时等待状态)、TERMINATED(终止状态):
状态和方法对应图如下:
描述线程的生命周期?
线程的生命周期是指操作系统层面上的线程的五种状态,五大生命周期 分别为:新建(NEW),就绪(Runnable),运行(Running),阻塞(Blocked)(又分为 Blocked,waiting,time-waiting),死亡(Dead/TERMINATED)具体如下:
- 新建(NEW):新创建了一个线程对象。
- 可运行(RUNNABLE):线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu 的使用权 。
- 运行(RUNNING):可运行状态(RUNNABLE)的线程获得了CPU 时间片,执行程序代码。
- 阻塞(BLOCKED):阻塞状态是指线程因为某种原因放弃了cpu 使用权,也即让出了cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu timeslice 转到运行(running)状态。
- 死亡(DEAD):线程run()、main() 方法执行结束或因异常退出了run()方法,则该线程结束生命周期。
一个线程两次调用start()方法会出现什么情况?
Java 的线程是不允许启动两次的,第二次调用必然会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start被认为是编程错误。
sleep()和wait()方法的区别是什么?
虽然两个方法都有让线程暂停的作用,但是两个还是发挥不同作用的:
- wait()是Object类的方法,sleep()是Thread类的方法;
- wait()释放锁,让线程进入等待状态,sleep()不释放锁,让线程进入阻塞状态;
- wait()方法后线程不会自动恢复执行,需要手动调用notify()/notifyAll()方法唤醒,sleep()在睡眠固定时间后会走动苏醒;
- wait()常用于线程间交互/通信,sleep通常被用于暂停等待。
可能出现线程安全问题的原因:
解决办法:
死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。死锁示意图:
处理死锁的方法:
怎么定位死锁?
最常见的方式就是利用jstack等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁。如果是比较明显的死锁,往往 jstack 等就能直接定位,类似 JConsole 甚至可以在图形界面进行有限的死锁检测。
如果程序运行时发生了死锁,绝大多数情况下都是无法在线解决的,只能重启、修正程序本身问题。所以,代码开发阶段互相审查,或者利用工具进行预防性排查,往往也是很重要的。
Ps:java.util.concurrent 包提供的容器(Queue、List、Set)、Map,从命名上可以大概区分为 Concurrent*、CopyOnWrite和 Blocking等三类,同样是线程安全容器,可以简单认为:
- Concurrent 类型没有类似 CopyOnWrite 之类容器相对较重的修改开销。
- 但是,凡事都是有代价的,Concurrent 往往提供了较低的遍历一致性。
- 你可以这样理解所谓的弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历。
- 与弱一致性对应的,就是我介绍过的同步容器常见的行为“fail-fast”,也就是检测到容器在遍历过程中发生了修改,则抛出 ConcurrentModificationException,不再继续遍历。
- 弱一致性的另外一个体现是,size 等操作准确性是有限的,未必是 100% 准确。与此同时,读取的性能具有一定的不确定性。
并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别?
LinkedBlockingQueue 和 ConcurrentLinkedQueue 是 Java 高并发场景中最常使用的队列。尽管这两个队列经常被用作并发场景的数据结构,但它们之间仍有细微的特征和行为差异。LinkedBlockingQueue 是一个 “可选且有界” 的阻塞队列实现,ConcurrentLinkedQueue 是一个无边界、线程安全且无阻塞的队列。
相似之处:
- 都实现 Queue 接口
- 它们都使用 linked nodes 存储节点
- 都适用于并发访问场景
不同之处:
特性
LinkedBlockingQueue
ConcurrentLinkedQueue
阻塞性
阻塞队列,并实现
blocking queue
接口
非阻塞队列,不实现
blocking queue
接口
队列大小
可选的有界队列,这意味着可以在创建期间定义队列大小
无边界队列,并且没有在创建期间指定队列大小的规定
锁特性
基于锁的队列
无锁队列
算法
锁的实现基于 “双锁队列(two lock queue)” 算法
依赖于
Michael&Scott算法来实现无阻塞、无锁队列
实现
在双锁队列算法机制中,
LinkedBlockingQueue使用两种不同的锁,putLock和takeLock。put/take
操作使用第一个锁类型,take/poll操作使用另一个锁类型
使用CAS(Compare And Swap)进行操作
阻塞行为
当队列为空时,它会阻塞访问线程
当队列为空时返回 null,它不会阻塞访问线程
线程池就是管理线程的一个容器,有任务需要处理时,会相继判断核心线程数是否还有空闲、线程池中的任务队列是否已满、是否超过线程池大小,然后调用或创建线程或者排队,线程执行完任务后并不会立即被销毁,而是仍然在线程池中等待下一个任务,如果超过存活时间还没有新的任务就会被销毁,通过这样复用线程从而降低开销。
使用线程池的优点:
讲讲线程池的生命周期?
- RUNNING :接收新的任务,并且可执行队列里的任务
- SHUTDOWN :shutdown()方法将线程池状态转换为SHUTDOWN,停止接收新任务,但可执行队列里的任务
- STOP :shutdownNow()方法将线程池状态转换为STOP,同时中断所有线程,停止接收新任务,不执行队列里的任务,中断正在执行的任务
- TIDYING :所有任务都已终止,线程数为0,线程池变为TIDYING状态,会执行钩子函数terminated(),钩子方法是指使用一个抽象类实现接口,一个抽象类实现这个接口,需要的方法设置为abstract,其它设置为空方法
- TERMINATED :执行完terminated()钩子方法,线程池已终止,变为TERMINATED状态
线程池有哪些类型?
- FixedThreadPool:固定线程数的线程池,核心线程数和最大线程数一样。特点是当线程达到核心线程数后,如果任务队列满了,也不会创建额外的非核心线程去执行任务,而是执行拒绝策略。
- CachedThreadPool:缓存线程池,特点是线程数几乎是可以无限增加的(最大值是Integer.MAX_VALUE,基本不会达到),当线程闲置时还可以进行回收,而且它采用的存储任务的队列是SynchronousQueue队列,队列容量是0,实际不存储任务,只负责对任务的中转和传递,所以来一个任务线程池就看是否有空闲的线程,有的话就用空闲的线程去执行任务,否则就创建一个线程去执行,效率比较高。
- ScheduledThreadPool:支持定时或者周期性执行任务
- SingleThreadExecutor:线程池中只有一个线程去执行任务,如果执行任务过程中发生了异常,则线程池会创建一个新线程来执行后续任务,这个线程因为只有一个线程,所以可以保证任务执行的有序性。
- SingleThreadScheduleExecutor:和ScheduledThreadPool很相似,只不过它的内部也只有一个线程,他只是将核心线程数设置为了1,如果执行期间发生异常,同样会创建一个新线程去执行任务。
- ForkJoinPool:支持将一个任务拆分成多个“小任务”并行计算,这个线程池是在jdk1.7之后加入的,它主要用于实现“分而治之”的算法,特别是分治之后递归调用的函数。
线程池的拒绝策略?
当任务队列和线程池都满了时所采取的应对策略:
- 默认是AbordPolicy,表示无法处理新任务,并抛出RejectedExecutionException异常;
- CallerRunsPolicy:用调用者所在的线程处理任务。此策略提供简单的反馈机制,能够减缓新任务的提交速度;
- DiscardPolicy:不能执行任务,并将任务删除;
- DiscardOldestPolicy:丢弃队列最近的任务,并执行当前的任务。
线程池的执行流程?
Java并发类库提供的线程池有哪几种? 分别有什么特点?
通常开发者都是利用 Executors 提供的通用线程池创建方法,去创建不同配置的线程池,主要区别在于不同的 ExecutorService 类型或者不同的初始参数。Executors 目前提供了 5 种不同的线程池创建配置:
- newCachedThreadPool(),它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过 60 秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用 SynchronousQueue 作为工作队列。
- newFixedThreadPool(int nThreads),重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有 nThreads 个工作线程是活动的。这意味着,如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目 nThreads。
- newSingleThreadExecutor(),它的特点在于工作线程数目被限制为 1,操作一个无界的工作队列,所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目。
- newSingleThreadScheduledExecutor() 和 newScheduledThreadPool(int corePoolSize),创建的是个 ScheduledExecutorService,可以进行定时或周期性的工作调度,区别在于单一工作线程还是多个工作线程。
- newWorkStealingPool(int parallelism),这是一个经常被人忽略的线程池,Java 8 才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务,不保证处理顺序。
Executor创建线程为什么不建议使用了?
- 缺乏对线程池的精细控制:Executors 提供的方法通常创建一些简单的线程池,如固定大小的线程池、单线程线程池等。然而,这些线程池的配置通常是有限制的,难以进行进一步的定制和优化。
- 可能引发内存泄漏:一些 Executors 创建的线程池,特别是 FixedThreadPool 和 SingleThreadExecutor,使用无界队列来存储等待执行的任务。这意味着如果任务提交速度远远快于任务执行速度,队列中可能会积累大量未执行的任务,可能导致内存泄漏。
- 不易处理异常:Executors 创建的线程池默认使用一种默认的异常处理策略,通常只会将异常打印到标准输出或记录到日志中,但不会提供更多的控制。这可能导致异常被忽略或无法及时处理。
- 不支持线程池的动态调整:某些线程池应该支持动态调整线程数量以应对不同的负载情况。Executors 创建的线程池通常是固定大小的,不容易进行动态调整。
- 可能导致不合理的线程数目:一些 Executors 方法创建的线程池默认将线程数目设置为非常大的值,这可能导致系统资源的浪费和性能下降。
AtomicIntger 是对 int 类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于 CAS(compare-and-swap)技术。AtomicInteger是java.util.concurrent.atomic 包下的一个原子类,该包下还有AtomicBoolean, AtomicLong,AtomicLongArray, AtomicReference等原子类,主要用于在高并发环境下,保证线程安全。
什么是CAS?
所谓 CAS,表征的是一系列操作的集合,获取当前数值,进行一些运算,利用 CAS 指令试图进行更新。如果当前数值未变,代表没有其他线程进行并发修改,则成功更新。否则,可能出现不同的选择,要么进行重试,要么就返回一个成功或者失败的结果。CAS 是 Java 并发中所谓 lock-free 机制的基础。执行流程如图:
常见描述种的锁以及其解释:
synchronized和ReentrantLock有什么区别呢?
- 原始构成:synchronized是java关键字属于JVM层面。 monitorenter(底层通过monitor对象来完成,其实wait/notify等方法也依赖于monitor对象只有在同步块或方法中才能调用wait/notify等方法)。ReentrantLock是具体类(java.util.concurrent.locks.Lock)是API层面。
- 使用方法:synchronized不需要手动释放锁,代码执行完系统会自动让线程释放对锁的占用 ReentrantLock则需要手动去释放锁,若没有主动释放优肯出现死锁,也就是lock()和unlock()方法需要配合try/finally语句快来使用
- 等待是否可以中断:synchronized不可中断,除非抛出异常或者正常运行完成。ReentrantLock可中断:
- 设置超时时间 tryLock(long timeout,TimeUnit unit)
- lockInterruptibly()房代码块中调用interrupt()方法可中断
- 枷锁是否公平:synchronized是非公平锁,ReentrantLock 两者都可,默认是非公平锁(构造方法传入boolean值,true是公平锁,false是非公平锁)
- 锁绑定多个条件Condition:ReentrantLock 用来实现分组唤醒需要唤醒的线程,可以精确唤醒,而不是像synchronized要么随机唤醒一个要么全部唤醒
为什么Synchronized能实现线程同步?
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的。synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。 如同我们在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
Ps:
- Java对象头 :以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
- Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象无关的数据,所以Mark Word被设计成一个非固定的数据结构,以便在极小的空间内存尽量存储更多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
- synchronized的底层实现是完全依赖JVM虚拟机的,所以谈synchronized的底层实现,就不得不谈数据在JVM内存的存储:Java对象头,以及Monitor对象监视器。
- Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- Monitor:可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。 Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
锁升级过程?
锁升级过程是由无锁,偏向锁,轻量级锁,到重量级锁的过程。多个线程在争抢synchronized 锁时,在某些情况下,会由无锁状态一步步升级为最终的重量级锁状态。整个升级过程大致包括如下几个步骤:
- 线程在竞争 synchronized 锁时,JVM 首先会检测锁对象的 Mark word 中偏向锁锁标记位是否为 1,锁标记位是否为 01,如果两个条件都满足,则当前锁处于可偏向的状态。
- 争抢 synchronized 锁的线程检查锁对象的 Mark Word 中存储的线程 ID 是否是自己的线程 ID ,如果是自己的线程 ID,则表示处于偏向锁状态。当前线程可以,直接进入方法或者代码块执行逻辑。
- 如果锁对象的 Mark word 中存储的不是当前线程的 ID,则当前线程会通过 CAS 自旋的方式竞争锁资源。如果成功抢占到锁,则将 Mark Word 中存储的线程 ID 修改为自己的线程 ID ,将偏向锁标记设置为 1,锁标志位设置为 01,当前锁处于偏向锁状态。
- 如果当前线程通过 CAS 自旋操作竞争锁失败,则说明此时有其他线程也在争抢锁资源。此时会撤销偏向锁,触发升级为轻量级锁的操作。
- 当前线程会根据锁对象的 Mark word 中存储的线程 ID 通知对应的线程暂停,对应的线程会将 Mark Word 的内容置空。
- 当前线程与上次获取到锁的线程都会把锁对象的 HashCode 等信息复制到自己的 Displaced Mark Word中,随后两个线程都会执行 CAS 自旋操作,尝试把锁对象的 Mark Word 中的内容修改为指向自己的 Displaced Mark Word 空间来竞争锁。
- 竞争锁成功的线程获取到锁,执行方法或代码块中的逻辑。同时,竞争锁成功的线程会将锁对象的 Mark Word 中的锁标志位设置为 00,此时进入轻量级锁状态。
- 竞争失败的线程会继续使用 CAS 自旋的方式尝试竞争锁,如果自旋成功竞争到锁,则当前锁仍然处于轻量级锁状态。
- 如果线程的 CAS 自旋操作达到一定次数仍未获取到锁,则轻量级锁会膨胀为重量级锁,此时会将镇对锁的 Mark Word 中的锁标志位设置为 10,进人重量级锁状态。