Java面经之多线程(本人亲身经历)
2021/7/18 20:39:12
本文主要是介绍Java面经之多线程(本人亲身经历),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
本人的春招就告一段落了,也找到了理想的工作,特分享一下自己整理的资料来做成的一个面经(都是本人亲自经历过的面试题),用于自己以后的学习和进步,由于都是网上搜集而来如有错误,望各位指正。并且会实时更新。。。 PS:如果想知道每个公司问我的什么可以私聊我。
目录
1、进程和线程的区别
2、多线程的好处和缺点
3、多线程安全的三要素
4、什么是上下文切换
5、一个线程同时使用两次start操作
6、进程之间通信的方式
7、线程之间是如何通信的?
8、创建线程的四种方式
10、线程的状态和基本操作
11、Synchronized与ReentrantLock(可重入锁)的区别
12、乐观锁与悲观锁、公平锁与非公平锁、独享锁与共享锁、互斥锁与读写锁
13、锁的升级
14、Volatile
15、AtomicInteger、CAS、ABA
16、ThreadLocal
17、AtomicInteger底层实现原理是什么?如何在自己的产品代码中应用CAS操作?
18、Java内存模型中的happen-before是什么
19、线程池的四种类型和应用场景
20、线程池
21、JMM(Java内存模型)
22、产生死锁的四个必要条件和如何处理死锁?
23、AQS
24、Sleep和wait方法
25、为什么wait、notify、notifyAll是Object类中的方法
26、栅栏(CyclicBarrier)
27、同步与异步、阻塞与非阻塞
28、NIO与BIO、AIO
29、Synchronized在类、代码块、静态代码块
1、进程和线程的区别
进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位。一个进程都有若干个线程,至少包含一个线程。资源开销上进程有独立的代码和数据空间(程序上下文),进程的切换有较大的开销,线程有共享的代码和数据空间,线程之间切换开销小。内存分配上同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的
2、多线程的好处和缺点
优点:充分利用多核CPU的计算能力、方便业务拆分、提高程序的执行效率、提高程序运行速度。
缺点:并发编程可能会遇到很多问题,如内存泄漏、上下文切换、线程安全、死锁等问题
3、多线程安全的三要素
多线程编程的三个保证:
原子性:原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。一个操作中途不会被其他线程干扰。(JDK Atomic开头的原子类、synchronized、LOCK)
可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized、volatile、LOCK)
有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
Happens-Before 规则可以解决(volatile)
4、什么是上下文切换
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换
当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换
5、一个线程同时使用两次start操作
Java的线程是不允许启动两次的,第二次调用必然会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start被认为是编程错误。因为线程是有着新建,就绪,运行,阻塞,等待,终止这个一个状态,在第二次调用start() 方法的时候,已经被start的线程已经不再是(NEW)状态了,所以无论如何是不能再次启动的。
6、进程之间通信的方式
管道pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
命名管道FIFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
消息队列MessageQueue:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
共享存储SharedMemory:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
信号量Semaphore:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
套接字Socket:套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。
信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
7、线程之间是如何通信的?
线程之间的通信有两种:共享内存、消息传递。
共享内存:在共享内存中的并发模型中,线程之间的共享程序的公共状态,线程之间通过读-写内存中的公共状态来隐式通信。典型的共享内存通信方式,就是通过共享对象进行通信。
例如:线程A把本地内存A更新过的共享变量刷新到主内存中去。线程B到主内存中去读取线程A之前更新过的共享变量。
消息传递:在消息传递的并发模型中,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,比如wait()、notify()、或者BlockingQueue。
8、创建线程的四种方式
1)继承Thread继承 Thread 类 重写run方法,start方法运行;
2)实现 Runnable 接口,重写run方法,创建实现runnable的实例,Thread thread = new Thread(myRunnable); 创建Thread对象;
3)实现 Callable 接口 重写call方法,有方法返回值,这个返回值可以被 Future 拿到,也就是说,Future 可以拿到异步执行任务的返回值,可以声明异常,需要以实现的callable接口的类为参数创建FutureTask对象,将FutureTask作为参数创建Thread对象;
4)使用 Executors 工具类创建线程池:主要有newFixedThreadPool,newCachedThreadPool,newSingleThreadExecutor,newScheduledThreadPool,都实现了ExecutorService接口。
9、Synchronized与lock的区别
1)来源:lock是一个接口,而synchronized是java的一个关键字,synchronized是内置的语言实现;
2)异常是否释放锁:
synchronized在发生异常时候会自动释放占有的锁,因此不会出现死锁;而lock发生异常时候,不会主动释放占有的锁,必须手动unlock来释放锁,可能引起死锁的发生。(所以最好将同步代码块用try catch包起来,finally中写入unlock,避免死锁的发生。)
3)是否响应中断
lock等待锁过程中可以用interrupt来中断等待,而synchronized只能等待锁的释放,不能响应中断;
4)是否知道获取锁。
Lock可以通过trylock来知道有没有获取锁,而synchronized不能;
5)Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离)
6)在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。
7)synchronized使用Object对象本身的wait 、notify、notifyAll调度机制,而Lock可以使用Condition进行线程之间的调度(await、singleall)
2种机制的具体区别:
synchronized原始采用的是CPU悲观锁机制,即线程获得的是独占锁。独占锁意味着其他线程只能依靠阻塞来等待线程释放锁。而在CPU转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起CPU频繁的上下文切换导致效率很低。
而Lock用的是乐观锁方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁实现的机制就是CAS操作(Compare and Swap)。我们可以进一步研究ReentrantLock的源代码,会发现其中比较重要的获得锁的一个方法是compareAndSetState
10、线程的状态和基本操作
线程的基本操作:
调度算法:分时调度模型和抢占式调度模型
分时调度模型是指让所有的线程轮流获得 cpu 的使用权,并且平均分配每个线程占用的 CPU 的时间片
Java虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。
- wait():是Object类的方法,使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁,用于线程间的交互/通信,需要notify或者notifyAll方法;
wait() 方法应该在循环调用,因为当线程获取到 CPU 开始执行的时候,其他条件可能还没有满足,所以在处理前,循环检测条件是否满足会更好
(2)sleep():是Thread线程类的一个静态方法,使一个正在运行的线程处于睡眠状态,但是不释放锁,用于暂停执行,线程超时后会自动苏醒。调用此方法要处理 InterruptedException 异常;
(3)notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关;
(4)notityAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;
11、Synchronized与ReentrantLock(可重入锁)的区别
这两种同步方式有很多相似之处,它们都是加锁方式同步,而且都是阻塞式的同步,也就是说当如果一个线程获得了对象锁,进入了同步块,其他访问该同步块的线程都必须阻塞在同步块外面等待,而进行线程阻塞和唤醒的代价是比较高的
都是可重入锁。“可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。
synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
synchronized 是依赖于 JVM 实现的,虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 1.5之后实现的(也就是 API 层面的互斥锁,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),因此锁的细粒度和灵活度:很明显ReenTrantLock优于Synchronized
Synchronized:Synchronized进行编译,对象内存中的头对象,它实现synchronized的锁对象的基础。会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令。在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计算器加1,相应的,在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。如果获取对象锁失败,那当前线程就要阻塞,直到对象锁被另一个线程释放为止。
ReentrantLook:ReentrantLock是java.util.concurrent包下提供的一套互斥锁,ReenTrantLock的实现是一种自旋锁和CAS+AQS队列,相比Synchronized,ReentrantLock类提供了一些高级功能,主要有以下3项:
1.等待可中断,持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于Synchronized来说可以避免出现死锁的情况。通过lock.lockInterruptibly()来实现这个机制。
2.公平锁,多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁非公平锁,ReentrantLock默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好
3.锁绑定多个条件,一个ReentrantLock对象可以同时绑定对个对象。ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。
12、乐观锁与悲观锁、公平锁与非公平锁、独享锁与共享锁、互斥锁与读写锁
乐观锁:乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。适合读操作非常多的场景。
乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。
悲观锁:悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。适合写操作非常多的场景。
公平锁:公平锁是指多个线程按照申请锁的顺序来获取锁。
非公平锁:非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。非公平锁吞吐量大。
独享锁:指该锁一次只能被一个线程所持有(synchronized、ReentrantLook)
共享锁:指该锁可被多个线程所持有。对于Lock的另一个实现类ReentrantReadWriteLock(读写锁),其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享
互斥锁:是一种简单的加锁的方法来控制对共享资源的访问,互斥锁只有两种状态,即上锁( lock )和解锁( unlock )。互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。
读写锁:ReentrantReadWriteLock,整体思路是它有两把锁,第 1 把锁是写锁,获得写锁之后,既可以读数据又可以修改数据,而第 2 把锁是读锁,获得读锁之后,只能查看数据,不能修改数据。读锁可以被多个线程同时持有,所以多个线程可以同时查看数据
写的时候不能读、写的时候不能写、读的时候不能写、读的时候可以读
ReentrantReadWriteLock 是 ReadWriteLock 的实现类,最主要的有两个方法:readLock() 和 writeLock() 用来获取读锁和写锁
使用场景:
相比于 ReentrantLock 适用于一般场合,ReadWriteLock 适用于读多写少的情况,合理使用可以进一步提高并发效率
java中的ConcurrentHashMap在jdk1.8之前的版本,使用一个Segment 数组
Segment继承自ReenTrantLock,所以每个Segment就是个可重入锁
13、锁的升级
锁只能升级不能降级。
针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
14、自旋锁、锁粗化/锁消除、偏向锁/轻量级锁/重量级锁、分段锁、读写锁
自旋锁与互斥锁
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;
自旋锁:自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。采用CAS循环。
等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁。
该方法是不可重入的,实现重入需要引用一个线程计数器,记录获取锁的线程数。
优点:
1、自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
2、非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。
缺点:
1、如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
2、上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。
使用场景:能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁
锁粗化:大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;
在以下场景下需要粗化锁的粒度:
假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的
锁消除:通过逃逸分析发现其实根本就没有别的线程产生竞争的可能(别的线程没有临界量的引用),而“自作多情”地给自己加上了锁。有可能虚拟机会直接去掉这个锁。
偏向锁:它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁
轻量级锁:轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁
重量级锁:Synchronized
作用于方法时,锁住的是对象的实例(this);
当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen(jdk1.8则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
synchronized作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。
分段锁:通过ConcurrentHashMap先分段再锁,将原本的一整个的Entry数组分成了若干段,分别将这若干段放在了不同的新的Segment数组中(分房间),每个Segment有各自的锁,以此提高效率
15、Volatile
Volatile关键字可以保证线程的的有序性和可见性。一旦一个共享变量被Volatile修饰,就具备了两层含义。
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了该变量的值,其他线程可以立马可见,volatile关键字会强制将修改后的值立刻写入主存。
- 禁止指令重排序。
但是volatile不是原子性操作,原子性一般使用原子类AtomicInteger等或者synchronized来保证。一般应用在单例模式的双检锁和状态标记量中。Volatile不会造成线程阻塞,synchronized会造成线程阻塞。
16、AtomicInteger、CAS、ABA
AtomicInteger 原子类:
CAS:是英文单词CompareAndSwap的缩写,中文意思是:比较并替换。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。是乐观锁的一种实现方式,是一种轻量级锁,线程在读取数据时不进行加锁,在准备写回数据时,先去查询原值,操作的时候比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。原子类是通过CAS实现的。通过unsafe类的compareAndSwap方法实现。
CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。
简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多
getAndAddInt
使用场景:订单表,流水表,为了防止并发问题,就会加入CAS的校验过程,保证了线程的安全,但是看场景使用,并不是适用所有场景,他的优点缺点都很明显。
缺点:
- 循环时间开销大,自选CAS的方式如果长时间不成功,会给CPU带来很大的开销。
- ABA问题
- 只能保证一个共享变量的原子操作。CAS操作单个共享变量的时候可以保证原子的操作,多个变量就不行了,JDK 5之后 AtomicReference可以用来保证对象之间的原子性,就可以把多个对象放入CAS中操作。
ABA:
ABA问题的保证:加标志位,例如搞个自增的字段,操作一次就自增加一,或者搞个时间戳,比较时间戳的值
17、ThreadLocal
ThreadLocal可以理解为线程本地变量,他会为每一个线程创建一个副本,线程之间访问内部副本变量即可,做到了线程的互相隔离。
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。JDK 中提供的ThreadLocal类实现每一个线程都有自己的专属本地变量。ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
ThreadLocal在Spring中发挥着巨大的作用,在管理Request作用域中的Bean、事务管理、任务调度、AOP等模块都出现了它的身影。
Spring中绝大部分Bean都可以声明成Singleton作用域,采用ThreadLocal进行封装,因此有状态的Bean就能够以singleton的方式在多线程中正常工作了
18、AtomicInteger底层实现原理是什么?如何在自己的产品代码中应用CAS操作?
AtomicInteger是对int类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于CAS(compare-and-swap)技术。CAS避免了因为一条线程出现错误而导致其他线程也受到影响,保证了并发的安全性。
AtomicInteger底层,主要依赖于Unsafe进行操作,以volatile的value字段保证可见性。
原子更新,Unsafe会利用value字段的内存地址偏移,直接完成操作。
方法有:getandset(获取并设置新值)、getandincrement(获取当前值,并自增1)、getandDecrement(获取当前值并自减1)、getandadd、Compareandset
getAndIncrement需要返回值,所以需要添加失败重试逻辑
应用CAS操作,目前Java提供了两种公共API,可以实现这种CAS操作,比如使用 java.util.concurrent.atomic.AtomicLongFieldUpdater,它是基于反射机制创建,我们需要保证类型和字段名称正确。
19、Java内存模型中的happen-before是什么
前面一个操作的结果对后续操作是可见的。
Happens-Before原则它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。
在java内存模型(JMM)中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
原则定义如下:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
happens-before原则规则:
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
锁定规则:一个unLock操作先行发生于后面对同一个锁lock操作;(synchronized)
变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;(volatile)
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
20、线程池的四种类型和应用场景
newFixedThreadPool:固定线程数的线程池。corePoolSize = maximumPoolSize,keepAliveTime为0,即使线程是空闲状态也不会被回收,工作队列使用无界的LinkedBlockingQueue。适用于为了满足资源管理的需求,而需要限制当前线程数量的场景,适用于负载比较重的服务器。由于该线程池线程数固定,且不被回收,线程与线程池的生命周期同步,所以适用于任务量比较固定但耗时长的任务
newSingleThreadExecutor:只有一个线程的线程池。corePoolSize = maximumPoolSize = 1,keepAliveTime为0, 工作队列使用无界的LinkedBlockingQueue。适用于需要保证顺序的执行各个任务的场景。
和new FixedThreadPool(1)有什么区别呢? 根据官方注释上说,两者的区别是:后者可以重新构造核心线程的数量,但是前者不行。意思就是FixedThreadPool构造完成后可以设置核心线程的数量,但是singleThreadExecutor不行
newCachedThreadPool: 按需要创建新线程的线程池。核心线程数为0,最大线程数为 Integer.MAX_VALUE,keepAliveTime为60秒,工作队列使用同步移交 SynchronousQueue。SynchronousQueue,这个队列是无法插入任务的,一有任务立即执行。该线程池可以无限扩展,当需求增加时,可以添加新的线程,而当需求降低时会自动回收空闲线程。适用于执行很多的短期异步任务,或者是负载较轻的服务器。适合双十二提交订单
newScheduledThreadPool:创建一个以延迟或定时的方式来执行任务的线程池,工作队列为 DelayedWorkQueue,是个无界的队列,延时执行队列任务,或者每隔一段时间执行一个任务。适用于需要多个后台线程执行周期的重复任务。
newWorkStealingPool:JDK 1.8 新增,用于创建一个可以窃取的线程池,底层使用 ForkJoinPool 实现。
21、线程池
线程池的好处: 秒杀场景、双十二订单数据录入
1)降低资源消耗。通过重复利用已创建的线程,降低线程创建和销毁造成的消耗。
2)提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
3)增加线程的可管理性。线程是稀缺资源,使用线程池可以进行统一分配,调优和监控。
线程池的七个参数:
核心线程数:线程池中会维护一个最小的线程数量,即使这些线程处理空闲状态,他们也不会被销毁,除非设置了allowCoreThreadTimeOut。这里的最小线程数量即是corePoolSize。
最大线程数:一个任务被提交到线程池以后,首先会找有没有空闲存活线程,如果有则直接将任务交给这个空闲线程来执行,如果没有则会缓存到工作队列(后面会介绍)中,如果工作队列满了,才会创建一个新线程,然后从工作队列的头部取出一个任务交由新线程来处理,而将刚提交的任务放入工作队列尾部。线程池不会无限制的去创建新线程,它会有一个最大线程数量的限制,这个数量即由maximunPoolSize指定。
活跃时间:
空闲线程存活时间:一个线程如果处于空闲状态,并且当前的线程数量大于corePoolSize,那么在指定时间后,这个空闲线程会被销毁,这里的指定时间由keepAliveTime来设定。
空闲线程存活单位:keepAliveTime的计量单位
阻塞队列:
①ArrayBlockingQueue 基于数组的有界阻塞队列,按FIFO排序。新任务进来后,会放到该队列的队尾,有界的数组可以防止资源耗尽问题。当线程池中线程数量达到corePoolSize后,再有新任务进来,则会将任务放入该队列的队尾,等待被调度。如果队列已经是满的,则创建一个新线程,如果线程数量已经达到maxPoolSize,则会执行拒绝策略。
②LinkedBlockingQuene 基于链表的无界阻塞队列(其实最大容量为Interger.MAX),按照FIFO排序。由于该队列的近似无界性,当线程池中线程数量达到corePoolSize后,再有新任务进来,会一直存入该队列,而不会去创建新线程直到maxPoolSize,因此使用该工作队列时,参数maxPoolSize其实是不起作用的。
③SynchronousQuene 一个不缓存任务的阻塞队列,生产者放入一个任务必须等到消费者取出这个任务。也就是说新任务进来时,不会缓存,而是直接被调度执行该任务,如果没有可用线程,则创建新线程,如果线程数量达到maxPoolSize,则执行拒绝策略。
④PriorityBlockingQueue 具有优先级的无界阻塞队列,基于最小二插堆。优先级通过参数Comparator实现。
* DelayQueue:一个使用优先级队列实现的无界阻塞队列;
* LinkedTransferQueue:一个由链表结构组成的无界阻塞队列;
* LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
线程工厂:创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等等
拒绝策略:
AbortPolicy:直接丢弃任务,抛出异常,这是默认的策略
CallerRunPolicy:用调用者所在的线程处理任务,也就是说,放下手中的活帮我处理掉的意思。
DiscardOldestPolicy:丢弃队列中最旧的任务,并执行新任务。
DiscardPolicy:直接丢弃新任务,也不抛出异常。
当一个任务提交到线程时,执行流程为:
- 当我们提交任务,线程池会根据核心线程数的大小创建若干任务数量的线程执行任务
- 当任务数量超过核心线程数量之后,后续的任务将会进入阻塞队列中阻塞排队
- 当阻塞队列也满了以后,那么将会继续创建(最大线程数-核心线程数)个数量的线程来执行任务,如果任务处理完成,(最大线程数-核心线程数)额外创建的线程等待keepAliveTime之后被自动销毁
22、JMM(Java内存模型)
JMM即Java Memory Model(Java内存模型),是一种内存访问规范,定义了程序中共享变量的访问规则(私有变量为线程私有,所以不会有并发问题,比如局部变量等等),每个线程都有自己的工作内存,工作内存中的“共享变量”只是主存共享变量在本地的私有拷贝,对数据的操作都只能在工作内存中进行,更新之后需要同步回主存,而不同的线程之间无法访问其它线程工作内存里的变量,线程之间值的传递必须通过主存来完成。
read:读取
目标:主内存的变量
功能:把一个变量从主内存传输到线程的工作内存中
load:载入
目标:工作内存的变量
功能:它把read操作从主内存中得到的变量值放入工作内存的本地变量副本中
use:使用
目标:工作内存的变量
功能:把工作内存中的一个变量值传递给执行引擎,执行引擎遇到需要使用变量的值的指令的时候会触发此操作,比如i=i+1,需要先将i的值传递给执行引擎
assign:赋值
目标:工作内存的变量
功能:它把一个从执行引擎接收到的值赋给工作内存的变量,当执行引擎对变量做出更新后,执行赋值操作指令的时候会触发操作,比如i=i+1,需要把i+1的结果赋值给i
store:存储
目标:工作内存的变量
功能:把工作内存中的一个变量的值传送到主内存中,以便接下来的write的操作
write:写入
目标:工作内存的变量
功能:它把store操作从工作内存中获取的一个变量的值覆盖到主内存的对应变量中
23、产生死锁的四个必要条件和如何处理死锁?
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源而导致相互等待,由此代码无法继续下。此时称系统处于死锁状态或系统产生了死锁。
- 互斥条件:一个资源只能一个线程使用,直到该线程被释放
- 请求与保持条件:当一个线程因请求获得资源时造成阻塞,该线程已获得的资源不释放。
- 不剥夺条件:线程已经获得的资源,在未使用完,不可被其他进程剥夺,只有自己使用完才释放资源。
- 循环等待条件:发生死锁时,所等待的线程必定会形成一个环路,造成永久阻塞。
产生原因:系统资源分配不当,推进顺序不合法
避免死锁,从四个必要条件出发,首先互斥条件无法破坏,因为锁就是为了让他们互斥的。
破坏请求与保持条件:一次性申请所有的资源。
破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
破坏循环等待条件:按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件
24、AQS
它维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列。
AQS就是队列同步器,这是实现 ReentrantLock 的基础。
AQS 有一个 state 标记位,值为1 时表示有线程占用,通过CAS去修改状态符。其他线程需要进入到同步队列等待,同步队列是一个双向链表。因此AQS是自旋锁:在等待唤醒的时候,经常会使用自旋(while(!cas()))的方式,不停地尝试获取锁,直到被其他线程获取成功。
当获得锁的线程需要等待某个条件时,会进入 condition 的等待队列,等待队列可以有多个。当 condition 条件满足时,线程会从等待队列重新进入同步队列进行获取锁的竞争。
AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
实现了AQS的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物
25、Sleep和wait方法
两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
两者都可以暂停线程的执行。
wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行。
wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒
26、为什么wait、notify、notifyAll是Object类中的方法
Java提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。如果线程需要等待某些锁那么调用对象中的wait方法就有意义了。如果wait方法定义在Thread类中,线程正在等待的是哪个锁就不明显了。简单地说,由于wait,notify,notifyAll都是锁级别的操作,所以把他们定义在Object类中,因为锁属于对象。
27、栅栏(CyclicBarrier)
栅栏类似闭锁,但是它们是有区别的.
闭锁用来等待事件,而栅栏用于等待其他线程.什么意思呢?就是说闭锁用来等待的事件就是countDown事件,只有该countDown事件执行后所有之前在等待的线程才有可能继续执行;而栅栏没有类似countDown事件控制线程的执行,只有线程的await方法能控制等待的线程执行.
CyclicBarrier强调的是n个线程,大家相互等待,只要有一个没完成,所有人都得等着。
28、同步与异步、阻塞与非阻塞
同步与异步
同步: 同步就是发起一个调用后,被调用者未处理完请求之前,调用不返回。
异步: 异步就是发起一个调用后,立刻得到被调用者的回应表示已接收到请求,但是被调用者并没有返回结果,此时我们可以处理其他的请求,被调用者通常依靠事件,回调等机制来通知调用者其返回结果。
同步和异步的区别最大在于异步的话调用者不需要等待处理结果,被调用者会通过回调等机制来通知调用者其返回结果。
阻塞和非阻塞
阻塞和非阻塞是相对于线程是否被阻塞
阻塞: 阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。
非阻塞: 非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。
那么同步阻塞、同步非阻塞和异步非阻塞又代表什么意思呢?
举个生活中简单的例子,你妈妈让你烧水,小时候你比较笨啊,在哪里傻等着水开(同步阻塞)。等你稍微再长大一点,你知道每次烧水的空隙可以去干点其他事,然后只需要时不时来看看水开了没有(同步非阻塞)。后来,你们家用上了水开了会发出声音的壶,这样你就只需要听到响声后就知道水开了,在这期间你可以随便干自己的事情,你需要去倒水了(异步非阻塞)
同步阻塞:单任务按顺序执行。
同步非阻塞:多任务,定时查看任务执行状态。
异步阻塞:单任务,自动提交任务执行状态。
异步非阻塞:多任务,自动提交任务执行状态,合理分配,最大化利用资源。
其实,这两者存在本质的区别,它们的修饰对象是不同的。阻塞和非阻塞是指进程访问的数据如果尚未就绪,进程是否需要等待,简单说这相当于函数内部的实现区别,也就是未就绪时是直接返回还是等待就绪。
而同步和异步是指访问数据的机制,同步一般指主动请求并等待I/O操作完毕的方式,当数据就绪后在读写的时候必须阻塞,异步则指主动请求数据后便可以继续处理其它任务,随后等待I/O,操作完毕的通知,这可以使进程在数据读写时也不阻塞。
29、NIO与BIO、AIO
BIO同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。典型的一请求一应答通信模型。
对于低负载、低并发的应用程序,可以使用同步阻塞I/O来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的同步非阻塞模式来开发。
IO流是阻塞的,NIO流是不阻塞的
IO 面向流(Stream oriented),而 NIO 面向缓冲区(Buffer oriented)。
NIO 通过Channel(通道) 进行读写。通道是双向的,可读也可写,而流的读写是单向的。无论读写,通道只能和Buffer交互。因为 Buffer,通道可以异步地读写。
NIO有选择器,而IO没有。选择器用于使用单个线程处理多个通道。因此,它需要较少的线程来处理这些通道。线程之间的切换对于操作系统来说是昂贵的。 因此,为了提高系统效率选择器是有用的。
BIO:线程发起IO请求,不管内核是否准备好IO操作,从发起请求起,线程一直阻塞,直到操作完成。
NIO(reactor模型):线程发起IO请求,立即返回;内核在做好IO操作的准备之后,通过调用注册的回调函数通知线程做IO操作,线程开始阻塞,直到操作完成。
AIO(proactor模型):线程发起IO请求,立即返回;内存做好IO操作的准备之后,做IO操作,直到操作完成或者失败,通过调用注册的回调函数通知线程做IO操作完成或者失败。
BIO的优缺点:
优点:能够及时返回数据,无延迟;方便调试;
缺点:需要付出等待的代价;
NIO的优缺点:
优点:相较于阻塞模型,非阻塞不用再等待任务,而是把时间花费到其它任务上,也就是这个当前线程同时处理多个任务;
缺点:导致任务完成的响应延迟增大了,因为每隔一段时间才去执行询问的动作,但是任务可能在两个询问动作的时间间隔内完成,这会导致整体数据吞吐量的降低。
AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的IO模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
NIO 读数据和写数据方式
通常来说NIO中的所有IO都是从 Channel(通道) 开始的。
从通道进行数据读取 :创建一个缓冲区,然后请求通道读取数据。
从通道进行数据写入 :创建一个缓冲区,填充数据,并要求通道写入数据。
30、Synchronized在类、代码块、静态代码块
synchronized修饰非静态方法的时候其实获得的是对象实例锁,如果是修饰静态方法,所以对象公用这个方法,就是类实例锁,也就是synchronized修饰静态方法则是修饰当前class。synchronized修饰代码块的时候,既可以修饰当前对象本身synchronized (A.class),也可以修饰其他对象。也可以修饰当前class 。synchronized (A.class)。
这篇关于Java面经之多线程(本人亲身经历)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-06JS面试真题详解:新手必备的JavaScript面试指南
- 2024-11-06JavaScript大厂面试真题详解与实战指南
- 2024-11-05安全渗透学习入门指南
- 2024-11-05内存马学习:从入门到实践
- 2024-11-05初学者指南:渗透攻防学习入门教程
- 2024-11-05渗透技术学习入门指南
- 2024-11-05数据库服务漏洞学习指南
- 2024-11-05网络安全学习:新手入门指南
- 2024-11-05Web开发入门指南
- 2024-11-05初学者指南:理解和防范跨域漏洞