线程
线程与进程的区别是什么?
- 进程指的是应用程序在操作系统中执行的副本(系统分配资源的最小单位),线程是程序执行的最小单位;
- 进程使用独立的数据空间,而线程共享进程的数据空间。
线程状态图
多线程会带来哪些性能问题?
- 调度开销,一般线程数往往大于CPU核心数,这样操作系统再执行线程时就会出现上下文切换,从而产生一定性能开销;
- 协作开销,为了保证线程之间共享变量的线程安全,有可能会禁用编译器和CPU的重排序等优化,还可能会频繁的将工作内存刷新到主内存,主内存再同步给工作内存,这些开销都是单线程下不存在的。
JMM内存模型
什么是JMM内存模型?
- JMM 是和多线程相关的一组规范,需要各个 JVM 的实现来遵守 JMM 规范;
- JMM 与处理器、缓存、并发、编译器有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的结果不可预期的问题。
什么是指令重排序?有什么好处?
- 重排序是指编译器、JVM 或者 CPU 为了提高执行效率,对于实际指令执行的顺序进行调整;
- 重排序通过减少执行指令,从而提高整体的运行速度。
什么是内存可见性问题?
- 共享变量的值已经被第 1 个线程修改了,但是其他线程却看不到。
主内存和工作内存的关系是什么?
- 所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;
- 线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;
- 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。
什么是 happens-before 关系?
- 如果第一个操作和第二个操作之间满足 happens-before 关系,那么我们就说第一个操作对于第二个操作一定是可见的;
volatile
volatile的作用是什么?
- 保证内存可见性以及多线程之间操作的有序性
volatile如何保证可见性?
- volatile变量修饰的共享变量,在进行写操作的时候会多出一个lock前缀的汇编指令,当对其进行写操作时,JVM就会向处理器发送一条Lock前缀的指令,把这个变量所在的缓存行的数据写回到系统内存。然后处理器会根据MESI缓存一致性协议来保证多CPU下的各个高速缓存中的数据的一致性。
volatile是否可以保证原子性?
- volatile是一种轻量级的同步机制,它主要有两个特性:
- 保证共享变量对所有线程的可见性;
- 禁止指令重排序优化;
- 同时需要注意的是,在多线程场景下,如果仅仅是赋值操作,volatile可以保证原子性,但是像num++这种复合操作(取值、计算、赋值),volatile无法保证其原子性。
synchronized
synchronized有几种使用方式?
- 类、方法、代码块
synchronized的底层实现原理是什么?
- 每个Object对象中都内置了一个Monitor监视器,通过指令Monitor.enter和Monitor.exit进行加锁和释放锁,加锁失败的线程会被加入到一个同步队列中,当锁被释放时再重新竞争锁。
JVM对synchronized做了哪些优化?
- 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
描述锁升级的过程
- 偏向锁升级轻量级锁:当一个对象持有偏向锁,一旦第二个线程访问这个对象,如果产生竞争,偏向锁升级为轻量级锁。
- 轻量级锁升级重量级锁:一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
wait和notify为什么需要在synchronized里面?
- wait方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列,而这些操作都和监视器是相关的,所以wait必须要获得一个监视器锁。
- 而对于notify来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在哪里,所以就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。
wait/notify 和 sleep 方法的区别是什么?
- wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求;
- 在同步代码中执行 sleep 方法时,并不会释放 monitor 锁,但执行 wait 方法时会主动释放 monitor 锁;
- sleep 方法中会要求必须定义一个时间,时间到期后会主动恢复,而对于没有参数的 wait 方法而言,意味着永久等待,直到被中断或被唤醒才能恢复,它并不会主动恢复;
- wait/notify 是 Object 类的方法,而 sleep 是 Thread 类的方法。
为什么 wait/notify/notifyAll 被定义在 Object 类中,而 sleep 定义在 Thread 类中?
- 因为 Java 中每个对象都有一把称之为 monitor 监视器的锁,锁信息保存在对象头中,wait/notify/notifyAll 都是锁级别的操作,所以把它们定义在 Object 类中是最合适;
- 如果把 wait/notify/notifyAll 方法定义在 Thread 类中,会带来很大的局限性,比如一个线程可能持有多把锁,以便实现相互配合的复杂逻辑,假设此时 wait 方法定义在 Thread 类中,如何实现让一个线程持有多把锁呢?又如何明确线程等待的是哪把锁呢?既然我们是让当前线程去等待某个对象的锁,自然应该通过操作对象来实现,而不是操作线程。
synchronize与volatile的区别是什么?
- volatile 可以看作是一个轻量版的 synchronized,比如一个共享变量如果自始至终只被各个线程赋值和读取,而没有其他操作的话,那么就可以用 volatile 来代替 synchronized 或者代替原子变量,足以保证线程安全;
- volatile无法保证对“i++”一类复合操作(包括取值、计算、赋值)的原子性和互斥性,它保证了变量间的可见性,并禁用了指令重排序;
- synchronize没有禁用指令重排序,这也是单例double check模式,对象必须用volatile修饰的原因。
AQS
什么是AQS,内部组成有哪些?
- AQS提供了一个FIFO双向队列,可以看做是一个用来实现锁以及其他需要同步功能的框架。
- AQS主要由三部分组成:
- 第一个是 state,它是一个数值,在不同的类中表示不同的含义,往往代表一种状态;
- 第二个是一个FIFO的队列,该队列用来存放阻塞状态的线程;
- 第三个是“获取/释放”的相关方法,需要利用 AQS 的工具类根据自己的逻辑去实现。
AQS的底层结构具体是怎样的?
- 底层是由head节点、tail节点、双向链表组成的双向队列;
- head与tail节点主要负责节点的出队与入队,时间复杂度O(1);
- 之所以使用双向链表而不是单向链表,是因为AQS考虑到高并发的场景下,节点的状态时刻都有可能发生变化,当前节点的一些动作需要依赖前序节点的状态,例如:
- 只有当前节点的prev节点为head时,才有资格参与锁竞争;
- 当前节点进入阻塞之前需要判断该节点的prev节点的状态是否为SIGNAL(节点的线程释放或被取消会通知后继节点)。
AQS解决了哪些问题?
- 状态的原子性管理;
- 线程的阻塞与解除阻塞;
- 队列的管理。
AQS中state的应用有哪些?
- 对于ReentrantLock,持有锁的线程每次lock重入,state+1,每次unlock,state -1,只有state = 0才表示彻底释放锁,其它线程才可以获取;
- 对于Semaphore,acquire 方法代表获取许可,此时能不能获取许可取决于state的值是否足够,如果足够state值会减掉对应的许可数量,如果不够则会进入阻塞,release方法代表释放许可,state值会增加直到定义的上限值;
- 对于CountDownLatch,await方法会判断state值是否为0,不为0则进入阻塞等待,直到其它线程通过countDown方法将state减为0才会执行;
- 对于CyclicBarrier,线程调用await方法state会+1,如果state值小于初始设置的阈值,线程阻塞等待,直到state累加等于该阈值,所有等待的线程会一起释放,同时state会清0。
Lock
Lock和synchronized的区别?
类别 | synchronized | Lock |
---|---|---|
存在层次 | Java的关键字,在jvm层面上 | 是一个接口 |
锁的释放 | 1、以获取锁的线程执行完同步代码,释放锁;2、线程执行发生异常,jvm会让线程释放锁。 | 必须在finally中释放锁,不然容易造成线程死锁 |
锁的获取 | 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待。 | Lock有多种获取锁的方式,如lock、tryLock |
锁状态 | 无法判断,只能阻塞 | 可以判断;tryLock();tryLock(long time, TimeUnit unit);可避免死锁。 |
锁类型 | 可重入,非公平,不可中断 | 可重入,可公平(两者皆可)可中断:lockInterruptibly(); |
功能 | 功能单一 | API丰富;tryLock();tryLock(long time, TimeUnit unit);可避免死锁。 |
描述Lock的加锁的全流程
- 当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取,转而被构造成为尾节点并加入AQS同步队列,这个过程通过CAS来保证的线程安全。
- 同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。
- 设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。
公平锁与非公平锁的区别,如何实现的?
- 非公平锁在获取锁的时候,会先通过CAS进行抢占,而公平锁则不会;
- 公平锁会优先从同步队列中去唤醒,这样就保证了先到先得的顺序;
- 非公平锁的效率更高,因为唤醒线程的过程是比较耗时的,非公平锁会利用这部分时间完成其它任务,但有可能造成锁饥饿。
对比悲观锁,乐观锁的优点和缺点都有哪些?
- 乐观锁优点:
- 悲观锁需要遵循下面三种模式:一锁、二读、三更新,即使在没有冲突的情况下,执行也会非常慢;
- 乐观锁本质上不是锁,它只是一个判断逻辑,资源冲突少的情况下,它不会产生任何开销;
- 乐观锁缺点:
- 在并发量比较高的情况下,有些线程可能会一直尝试修改某个资源,但由于冲突比较严重,一直更新不成功,这时候,就会给 CPU 带来很大的压力(并发量大可以考虑通过分段锁的方式优化,例如LongAdder,或者直接使用悲观锁);
- 无法解决ABA问题,意思是指在 CAS 操作时,有其他的线程现将变量的值由 A 变成了 B,然后又改成了 A,当前线程在操作时,发现值仍然是 A,于是进行了交换操作。大部分场景下ABA问题不会给业务带来影响,可以通过引入版本号的方式解决。
线程池
线程池的核心参数有哪些?
public ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 临时线程等待时间 TimeUnit unit, // 时间单位 BlockingQueue<Runnable> workQueue, // 阻塞队列 RejectedExecutionHandler handler) // 拒绝策略
线程池执行任务的流程是什么?
拒绝策略有哪些?
- AbortPolicy(默认):丢弃任务并抛出RejectedExecutionException异常;
- DiscardPolicy:丢弃任务,但是不抛出异常;
- DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入到队列中;
- CallerRunsPolicy:提交任务的主线程调用任务的run()方法,绕过线程池直接执行(这种方法不会发生数据丢失,并且可以延缓任务提交的速度,缓解线程池压力)。
ForkJoinPool有什么特点?
- 它每个线程都有一个自己的双端队列来存储分裂出来的子任务,避免了公共队列的阻塞;
- 采用工作窃取模式,空闲线程 t1 可以帮助繁忙线程 t0 完成工作,这也是队列设计为双端队列的目的,t0是按LIFO的顺序处理任务,而t1 在steal t0任务时会按照FIFO的顺序;
- ForkJoinPool 非常适合用于递归的场景,例如树的遍历、最优路径搜索等场景。
ForkJoinPool与ThreadPoolExecutor区别是什么?
- ForkJoinPool中的每个线程都会有一个队列,而ThreadPoolExecutor只有一个队列,并根据queue类型不同,细分出各种线程池;
- ForkJoinPool能够使用数量有限的线程来完成非常多的具有父子关系的任务,ThreadPoolExecutor中根本没有什么父子关系任务;
- ForkJoinPool在多任务,且任务分配不均是有优势,但是在单线程或者任务分配均匀的情况下,效率没有ThreadPoolExecutor高。
JDK提供的线程池用到了哪些阻塞队列?
- LinkedBlockingQueue:FixedThreadPool 和 SingleThreadExector 的默认队列,容量为 Integer.MAX_VALUE,可以认为是无界队列,不会生成多于核心线程数的线程;
- SynchronousQueue:CachedThreadPool的默认队列,是一种没有容量的阻塞队列。与FixedThreadPool正好相反,CachedThreadPool为了尽可能创建新的线程执行任务,它的最大线程数是 Integer.MAX_VALUE,队列容量为0;
- DelayedWorkQueue:ScheduledThreadPool的默认队列,可以周期性执行任务或延迟一定时间执行任务,DelayedWorkQueue会按照延迟的时间长短对任务排序,内部数据结构是堆(二叉树)。
CPU核心数和线程数的关系是什么?
- 如果是CPU密集型任务,例如加密、解密、编译、压缩、计算等任务,一般可以考虑线程数为CPU核心数的1~2倍,具体还应该参考压测结果;
- 如果是IO密集型任务,可参考公式:线程数 = CPU 核心数 *(1 + 线程平均等待时间 / 线程平均工作时间)。
队列
队列常见api的区别?
有哪些常见的阻塞队列?
- ArrayBlockingQueue
- 最典型的有界队列,其内部是用数组存储元素的,不会扩容,利用 ReentrantLock 实现线程安全;
- LinkedBlockingQueue
- 内部用链表实现的 BlockingQueue,容量默认就为整型的最大值 Integer.MAX_VALUE,一般称为无界队列;
- SynchronousQueue
- 容量为0的传递队列,存数据会阻塞,知道有人来存,同理取数据也会阻塞,直到有人来存;
- PriorityBlockingQueue
- 无界的优先级阻塞队列(有初始容量,可扩容),可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则,内部的数据结构是堆;
- DelayQueue
- 无界的延迟队列,DelayQueue 内部使用了 PriorityQueue 的能力来进行排序。
ArrayBlockingQueue的实现原理是什么?
- ArrayBlockingQueue 实现并发同步的原理就是利用 ReentrantLock 和它的两个 Condition,读操作和写操作都需要先获取到 ReentrantLock 独占锁才能进行下一步操作;
- 进行读操作时如果队列为空,线程就会进入到读线程专属的 notEmpty 的 Condition 的队列中去排队,等待写线程写入新的元素;
- 同理,如果队列已满,这个时候写操作的线程会进入到写线程专属的 notFull 队列中去排队,等待读线程将队列元素移除并腾出空间。
阻塞队列和非阻塞队列在实现上有哪些区别?
- 阻塞队列最主要是利用了 ReentrantLock 以及它的 Condition 来实现,而非阻塞队列则是利用 CAS 方法实现线程安全。
多线程工具类
CountDownLatch与CyclicBarrier的区别是什么?
- 作用对象不同:CyclicBarrier 要等固定数量的线程都到达了栅栏位置才能继续执行,而 CountDownLatch 只需等待数字倒数到 0,也就是说 CountDownLatch 作用于事件,但 CyclicBarrier 作用于线程;
- 可重用性不同:CountDownLatch 在倒数到 0 并且触发门闩打开后,就不能再次使用了,除非新建一个新的实例;而 CyclicBarrier 可以重复使用,并不需要重建实例。CyclicBarrier 还可以随时调用 reset 方法进行重置,如果重置时有线程已经调用了 await 方法并开始等待,那么这些线程则会抛出 BrokenBarrierException 异常。
- 执行动作不同:CyclicBarrier 有执行动作 barrierAction,而 CountDownLatch 没这个功能。
Future
Future与CompletableFuture区别?
- 通过Future接收异步任务时,主线程需要通过get()方法去阻塞轮询获取结果,如果是多个任务,则需要等到所有任务完成之后才能做后续操作;
- 而通过CompletableFuture接收异步任务时,无需等待所有任务全部完成,每个任务都可以通过thenAccept、thenApply、thenCompose等方式将前面的结果交给另一个异步事件来处理,最后还可以通过allOf或anyOf等方法来汇总结果。
FutureTask的实现原理是什么?
- 当我们通过Future接收异步任务时,底层是通过其实现类FutureTask的run方法来执行任务的,run方法主要完成了以下流程:
- 检查线程的状态,执行用户定义的call方法;
- 执行结束后,设置返回值或异常,并更新线程状态,然后唤醒队列中阻塞等待获取结果的线程;
- 当其它线程通过future.get获取结果时:
- 首先根据状态判断任务是否完成,如果已经完成则直接返回;
- 如果没有完成则会阻塞当前线程,并将其加入到阻塞队列(如果没有指定阻塞时间则一直阻塞知道任务完成唤醒);
- 当任务完成时,阻塞的线程会被唤醒,拿到结果后返回;
ThreadLocal
ThreadLocal的作用与使用场景是什么?
- ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了线程安全。
- ThreadLocal 用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景。每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念。
ThreadLocal与Thread的关系是什么?
一个 Thread 里面只有一个ThreadLocalMap ,而在一个 ThreadLocalMap 里面却可以有很多的 ThreadLocal,每一个 ThreadLocal 都对应一个 value。因为一个 Thread 是可以调用多个 ThreadLocal 的,所以 Thread 内部就采用了 ThreadLocalMap 这样 Map 的数据结构来存放 ThreadLocal 和 value。
ThreadLocal与Synchronized的区别是什么?
- ThreadLocal 是通过让每个线程独享自己的副本,避免了资源的竞争。
- synchronized 主要用于临界资源的分配,在同一时刻限制最多只有一个线程能访问该资源。
- ThreadLocal 并不是用来解决共享资源的多线程访问的问题,因为每个线程中的资源只是副本,并不共享。因此ThreadLocal适合作为线程上下文变量,简化线程内传参。
ThreadLocal为什么可能产生内存泄漏,如何避免?
- 通过ThreadLocalMap的源码可以看到,Entry中的key被定义为弱引用类型,当发生GC时,key会被直接回收,无需手动清理。
- 而value属于强引用类型,被当前的Thread对象关联,所以说value的回收取决于Thread对象的生命周期。
- 如果说一个线程执行完毕,线程Thread随之被释放,那么value便不存在内存泄漏的问题。
- 然而,我们一般会通过线程池的方式来复用Thread对象来节省资源,这就会导致一个Thread对象的生命周期会非常长,随着任务的执行,value就有可能越来越多且无法释放,最终导致内存泄漏。
- 因此,我们在使用完ThreadLocal变量后,要手动调用remove()方法来清理ThreadLocalMap(一般在finally代码块中)。
子线程如何共享主线程的ThreadLocal变量?
- 因为ThreadLocal变量保存在当前线程的成员变量ThreadLocalMap中,新创建子线程后无法再次使用父线程的ThreadLocal变量;
- 为了解决子线程复用主线程ThreadLocal的问题,Thread类中还有另一个ThreadLocalMap:inheritableThreadLocals,里面保存的是InheritableThreadLocal,它是ThreadLocal的子类,Thread类初始化时会默认从父线程继承inheritableThreadLocals;
- 因此我们可以用InheritableThreadLocal代替ThreadLocal实现父子线程共享线程变量的问题。