-
前言
❗表示必掌握,❔表示基本不会问 -
更新
1 | 24-06-03 初始记录 |
高并发(锁)
❗Java 内存模型的理解
大白话:Java 的对象都是处于主内存之中,而线程拥有自己的工作内存(CPU 级别的缓存)。操作过程,1) 线程把主内存中的数据读出 read,2) 线程把值加载到工作内存中 load,3) 线程使用这个值 use,4) 线程把算好的值设置到工作内存 assign,5) 把值存入 store,6) 写入主内存 write。如果有两个线程,会同时操作,即并发(操作结果可能只有一次的结果)。
首先,JAVA 内存模型是指 JMM,而不是指内存结构,内存结构是在物理上的区域划分,而 JMM 则是抽象概念上的划分。
JMM(内存模型)主要包括两块:主内存+工作内存。
主内存:多个线程间通信的共享内存称之为主内存,即,数据是多个线程共享的,在物理内存结构上通常对应“堆”中的线程共享数据。
工作内存:多个线程各自对应自己的本地内存,即,数据只属于该线程自己的,在物理内存结构上通常对应“本地方法栈”中的线程私有数据。
Java 内存模型规定了所有的变量都存储在主内存 (Main Memory) 中,每条线程还有自己的工作内存 (Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作 (读取、赋值等) 都必须在工作内存中进行,而不能直接读写主内存中的变量,不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值得传递均需要通过主内存来实现。
❗Java 内存模型中的原子性、有序性、可见性
可见性:一个线程操作完,另一个线程必须读取操作后的数据。
原子性:同时只有一个线程能操作。(不加操作默认情况下就是没有原子性的)
-
Java 中的简单赋值操作(比如 int i=0)是原子性的,但是复杂的比如 i++ 是不能保证的。
-
特例:32 位虚拟机的 long/double 类型的变量的简单赋值写操作,不是原子的
- 为什么? 因为 long/double 是 64 位的,高低 32 位由两个线程操作,可能会赋值错误。
- 加上 volatile long/double 可以保障是原子的。(volatile 只有这里展示了原子性)
有序性:编译之后,可能会指令重排。如果具备有序性,就不会发生指令重排。
有哪些操作在 Java 规范中不能保障原子性?
-
比如:
i = x * y
。需要先把 x 和 y 分别从主内存加载到工作内存里,然后再从工作内存里加载出来执行计算,计算后的结果再写回到工作内存里,最后还要从工作内存里把 i 的最新的值刷回主内存。 -
有复杂的代码逻辑的操作不是原子的。解决:加锁,对对象加锁。
可见性的硬件级别的说明
每个处理器都有自己的寄存器。多个处理器各自运行一个线程的时候,导致某个变量放到寄存器里,其他线程无法看到。(第一个问题)
处理器运行的线程对变量的写操作都是针对写缓冲来的,并不是直接更新主内存,导致一个线程更新了变量,但是仅仅在写缓冲器里面。(第二个问题)
每个处理器有个自己的高速缓存,需要把数据写到高速缓存/主内存,但是其他处理器还是从自己的高速缓存读取数据。(第三个问题)
❗从 Java 底层角度聊聊 volatile 关键字的原理
volatile 关键字与可见性的关系
volatile 关键字是用来解决可见性和有序性。给数据加上 volatile 关键字,在线程更新数据时,会把其他线程内缓存的数据值失效,这时其他线程会强制重新从主内存加载数据值(变成新的缓存)。
在很多开源中间件系统中,大量使用 volatile。例子:在 main 方法中,会有一段逻辑,如果主线程在执行,不可以后台退出(这里的执行状态用一个值表示,根据值判断系统是否在运行)。如果提供了一个方法,需要关闭主线程,如果不加 volatile 关键字,其他线程更新状态,主线程没有同步,就会出问题。(这个类似的用法会在多处使用)
❗指令重排以及 happens-before 原则
volatile 关键字与有序性的关系、连带问题
❔happens-before 原则:
-
程序次序规则:一个线程内,代码按写的顺序执行
-
锁定规则:一个 unLock 操作先行与后一个 Lock 的操作(先解锁,后加锁)
-
volatile 变量规则:加了 volatile 关键字,写操作先,读操作后
-
传递规则:如果 A 先于 B,B 先于 C,那么 A 一定先于 C
-
线程启动规则:线程的启动
start()
方法先于此线程的每个操作 -
线程中断规则:对线程的
interrupt()
调用,先于检测到终端的发生 -
线程终结规则:线程的所有操作都先于线程的终止检测操作
-
对象终结规则:对象的初始化在
finalize()
之前
这个规则制定了在特殊情况下,不允许指令重排。而因为这个规则,volatile 关键字有一定的防止指令重排的效果(只能先写后读)。
同时,volatile 关键字本身,就有其他避免指令重排的规则(就是底层。。。最好不要问这个,谁记得住啊)。
❗volatile 底层是如何基于内存屏障保证可见性和有序性的
volatile 不能保证原子性。(只能加锁 synchronized、lock)。
volatile 保证可见性:对于 volatile 修饰的变量,执行写操作,JVM 会发送一条 lock 前缀指令给 CPU,CPU 在计算完之后将值强制刷回主内存。其他线程有一个嗅探机制(MESI 缓存一致性协议),会去失效线程内的缓存。
volatile 保证有序性:加入内存屏障,可以禁止指令重排。
对于 volatile 修改变量的读写操作,都会加入内存屏障。每个 volatile 写操作前面,加 StoreStore 屏障,禁止上面的普通写和他重排;每个 volatile 写操作后面,加 StoreLoad 屏障,禁止跟下面的 volatile 读/写重排。每个 volatile 读操作后面,加 LoadLoad 屏障,禁止下面的普通读和 voaltile 读重排;每个 volatile 读操作后面,加 LoadStore 屏障,禁止下面的普通写和 volatile 读重排。
synchronized 为什么又叫内置锁?
synchronized 是内置于 JDK 中的,底层实现是 native,由 C/C++ 语言实现;同时,加锁、解锁都是 JDK 自动完成,不需要用户显示控制,非常方便。
❗说说 synchronized 关键字的底层原理
因为 synchronized 可以同时保证原子性、可见性和有序性,所以在并发编程中经常会用到他,synchronized 主要有三种用法:修饰实例方法、修饰静态方法、修饰代码块。
synchronized 修饰代码块时,JVM 采用 monitorenter 、monitorexit 两个指令来实现同步,monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指向同步代码块的结束位置。
synchronized 修饰同步方法时,JVM 采用 ACC_SYNCHRONIZED 标记符来实现同步,这个标记了该方法是一个同步方法。
❗大白话(修饰代码块):每个对象实例都会关联一个 monitor,一个类的 class 对象也会关联一个 monitor。一个线程过来,把 monitor 置 1(可以重复加锁,二次加 monitor 变成 2、3…)。当线程走出 synchronized,执行 monitorexit,底层获取对应的 monitor 进行释放。线程 2 加锁失败,陷入一个阻塞等待的状态。
Syncrhronized 怎么保证可见性?
JMM 中使用 happens-before 语义:
-
线程解锁前,必须把共享变量的最新值刷新到主内存中。
-
线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值。(注意:加锁与解锁需要是同一把锁)
Synchronized 怎么保证原子性?
为什么会有两个 monitorexit 呢?
这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个 monitorexit 是保证在异常情况下,锁也可以得到释放,避免死锁,它由编译器自动产生的一个异常处理器来执行。
Synchronized 可重入的原理
重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁,且不再被阻塞。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为 0 时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。
Synchronized 怎么保证有序性?
首先,Synchronized 保证有序性,但不表示他能禁止指令重排。
而之所以会有序性问题,是因为硬件层面做了很多优化,比如处理器做强化和指令重排等,这些技术引入会导致有序性问题。这有序性问题主要出在多线程中,因为单线程中是遵循 JMM 的 as-if-serial 语义的,能保证数据间的依赖关系的,比如 A 依赖于 B,B 依赖于 C,那 A 的实现之前,必须会先执行 C。as-if-serial 语义的意思是:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。
但是多线程就可能因为指令重排导致在另一个线程中先执行到了 C,多线程程序的语义就被重排序破坏了!Synchronized 同步代码块可以锁住当前线程,这样每个线程单独执行,就可以保证有序性了。
Synchronized 和监视器(monitor)有什么关系?为什么 Synchronized 可以使用任意对象?
首先,每个对象都可以被认为是一个“监视器 monitor”,这个监视器由三部分组成:独占锁、入口队列,等待队列。
注意:一个对象只能有一个独占锁,但是任意线程都可以拥有这个独占锁(说白了,独占锁就是一个标记)。
Synchronized 需要获取对象锁,实际上就是获取的是对象中的独占锁,通过这个标记来判断是否已有线程进入占用(所以 synchronized 无论使用什么对象都可以,每个对象在堆中都有独占锁)。
而入口队列中放的则是要竞争锁资源的其他线程,如果线程使用了 wait 方法,则进入对象的等待列队中。
Synchronized 中的锁中什么是重量锁(对象锁),自旋锁,自适应自旋锁,轻量锁,偏向锁,锁消除,锁粗化?
线程的阻塞和唤醒需要 CPU 从用户态转为核心态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的,所以引入自旋锁。
就是等待锁的线程并不进入阻塞状态,而是执行一个无意义的循环。在循环结束后查看锁是否已经被释放,若已经释放则直接进入执行状态。因为长时间无意义循环也会大量浪费系统资源,因此自旋锁适用于间隔时间短的加锁场景。
自适应自旋锁:
-
自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
偏向锁:
-
偏向于第一个获得它的线程。当线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 加锁和解锁操作。适用于只有 1 个线程的情况。无法代替重量锁。
轻量锁:
-
如果有第二线程过来竞争,则从偏向锁升级为轻量锁,线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁。适用于只有 2 个线程情况。无法代替重量锁。
重量锁:
-
当有 3 个及以上的线程竞争时,升级为重量锁,获得锁的执行,没获得锁的阻塞挂起,直到持有锁的线程执行完同步块唤醒它们。重量级锁通过对象内部的监视器(monitor)实现,其中 monitor 的本质是依赖于底层操作系统的实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
锁消除:
-
JVM 检测到不可能存在共享数据竞争,这时 JVM 会对这些同步锁进行锁消除。比如一个方法中使用变量是属于自己方法中的,那么这个变量是只属于该线程自己的,其他线程抢不走,这时候这个方法中的变量就没必要加锁了。锁消除的依据是逃逸分析(底层判断该数据是否有被全局引用或者程序指向无法被访问到的地方等)的数据支持。
锁粗化:
-
锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector 每次 add 的时候都需要加锁操作,JVM 检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到 for 循环之外。
多线程中 synchronized 锁升级的原理是什么?
偏向所锁,轻量级锁都是乐观锁,重量级锁是悲观锁。
一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用 CAS 操作,并将对象头中的 ThreadID 改成自己的 ID,之后再次访问这个对象时,只需要对比 ID,不需要再使用 CAS 在进行操作。
一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象的偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止 CPU 空转。
Synchronized 锁只会自动升级,不会降级(ReentrantReadWriteLock 读写锁可以降级)。
synchronized 和 volatile 的区别是什么?
synchronized 表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程。
volatile 表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性;禁止指令重排序。
区别:
-
volatile 是变量修饰符;synchronized 可以修饰类、方法、变量。
-
volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
-
volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
-
volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。
❗对 CAS 的理解及其底层实现原理
CAS 叫做比较并交换,CAS 指令包含 3 个参数:新值,旧值,内存值(内存位置),线程会先获取内存值,然后复制到变量副本,生成旧值,旧值在一系列操作后生成新值。
若旧值=内存值,说明没有被其他线程抢先占有,则修改内存值为新值;
若旧值≠内存值,说明内存值已经被其他线程修改,则自旋获取新的内存值,然后重新操作。
大白话:比如说使用 synchronized 对于代码块进行加锁,这个时候多个线程的执行是串行的。比如一个累加的计数器,优化可以把 int 类型的累加字段改成AtomicInteger类型(原子类),方法改为 incrementAndGet,此时不用 synchronized 也是线程安全的。这个原子类底层就是 CAS(Compare and Set)。在线程执行 incrementAndGet 时,会先读取,再尝试设置:当一个线程成功执行 CAS 操作(就是对比旧值是否是原来的值),另一个就会失败,失败就会重新读取,再次设置。
❗CAS 会出现经典的 ABA 问题
原因:第一个线程刚获得 A,第二个线程就抢走也获得 A,然后改成 B 后又改成 A,这时候第一个线程发现变量是 A,就继续执行。但是这样会出现潜藏的问题,比如修改的是金额,存一笔和存两笔是两个概念。
解决:加入版本号解决。
❗对 JDK 中的 AQS 的理解,AQS 的实现原理
多线程访问数据除了 synchronized,CAS,ConcurrentHashMap,还有 Lock。ReentrantLock 的底层就是 AQS(Abstract Queue Synchronizer)。
1 | ReentrantLock lock = new ReentrantLock(); // 默认使用非公平锁 |
大白话:AQS 底层有个 state,多个线程执行 CAS 更新 state;还有一个变量记录加锁线程。AQS 中会有一个等待队列的概念。当前一个线程释放了锁,会去唤醒等待队列中的队首元素。
非公平锁:在唤醒等待队列的过程中,如果有另一个线程抢占锁,是可以成功的。
公平锁:在唤醒等待队列的过程中,如果需要抢占锁,需要加入到等待队列内,进行等待。
ReetrantReadWriteLock 读写锁和 RenntrantLock 有什么区别?
ReentrantLock 有一定的局限性,它的读锁与读锁间也会互斥,但读数据并不会改动数据,没有必要加锁保护,这就降低了程序的性能。
因以上问题,诞生了读写锁,读写锁一种读写分离技术,它的读锁是共享的,写锁是独占的,也就是说,多个线程是可以一起读数据的,只有写数据的时候,才会同步线程。
读写锁 ReentrantReadWriteLock 有什么特点?
-
公平性可以选择:支持非公平 (默认) 和公平的锁获取,吞吐量非公平优于公平。
-
重进入:读锁和写锁都支持线程重进入。
-
锁降级:获取写锁,再获取读锁,然后释放写锁,这样写锁就降级为了读锁。(注:Synchronized 是不能进行锁降级的,意义不一样)。
ReentrantLock 与 Synchronized 的区别
-
二者的本质区别:synchronized 是关键字,ReentrantLock 是一个类 相同点:这两个都是可重入锁。
-
ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁
-
ReentrantLock 只适用于代码块锁,而 synchronized 可以修饰类、方法、变量等。
什么是死锁?
A 需要 B 解锁,B 需要 A 解锁,两个都在中间互相等待,却谁也无法满足条件,从而发生阻塞,就是死锁。
怎么防止死锁?
-
不要写嵌套锁,容易死锁;
-
尽量少用同步代码块 (Synchronized);
-
尽量使用 ReentrantLock 的 tryLock 方法设置超时时间,超时可以退出,防止死锁;
-
尽量降低锁粒度,尽量不要几个功能一把锁;
-
尽量使用 JUC 包;
❗ConcurrentHashMap 实现线程安全的底层原理
JDK1.8 以前,多个数组,分段加锁,一个数组一个锁。他将一个大的 ConcurrentHashMap 分成 16 个小的 Segment。也就是说可以同时承受 16 个线程的并发。
JDK1.8 以后,数组里每个元素进行 put 操作,都是有一个不同的锁,对当个位置进行 put 操作时,采取的是 CAS 的策略。如果 CAS 操作失败,就使用 synchronized 对这个位置的对象进行锁定,然后基于链表或红黑树,对数组元素进行写入。
多线程
❗创建线程的 4 种方法
-
继承 Thread 类。通过继承 Thread 类,并重写它的 run 方法,我们就可以创建一个线程。
-
首先定义一个类来继承 Thread 类,重写 run 方法。
-
然后创建这个子类对象,并调用 start 方法启动线程。
1 | public class MyThread extends Thread { |
-
实现 Runnable 接口。通过实现 Runnable ,并实现 run 方法,也可以创建一个线程。
-
首先定义一个类实现 Runnable 接口,并实现 run 方法。
-
然后创建 Runnable 实现类对象,并把它作为 target 传入 Thread 的构造函数中
-
最后调用 start 方法启动线程。
1 | public class RunnableThread implements Runnable { |
-
实现 Callable 接口,并结合 Future 实现
-
首先定义一个 Callable 的实现类,并实现 call 方法。call 方法是带返回值的。
-
然后通过 FutureTask 的构造方法,把这个 Callable 实现类传进去。
-
把 FutureTask 作为 Thread 类的 target ,创建 Thread 线程对象。
-
通过 FutureTask 的 get 方法获取线程的执行结果。
1 | public class TestFuture { |
-
通过线程池创建线程此处用 JDK 自带的 Executors 来创建线程池对象。
-
首先,定一个 Runnable 的实现类,重写 run 方法。
-
然后创建一个拥有固定线程数的线程池。
-
最后通过 ExecutorService 对象的 execute 方法(这个方法接收一个 Runnable 实例)传入线程对象。
1 | public class ExecutorThread implements Runnable{ |
说一下 Runnable 和 Callable 有什么区别?
相同点:
-
都是接口
-
都可以编写多线程程序
-
都采用 Thread.start() 启动线程
主要区别:
-
Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,是个泛型,和 Future、FutureTask 配合可以用来获取异步执行的结果。
-
Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息。
注:Callalbe 接口支持返回执行结果,需要调用 FutureTask.get() 得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
线程的 run() 和 start() 有什么区别?
每个线程都是通过某个特定 Thread 对象所对应的方法 run() 来完成其操作的,run() 方法称为线程体。通过调用 Thread 类的 start() 方法来启动一个线程。
start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。
start() 方法来启动一个线程,真正实现了多线程运行。调用 start() 方法无需等待 run 方法体代码执行完毕,可以直接继续执行其他的代码; 此时线程是处于就绪状态,并没有运行。 然后通过此 Thread 类调用方法 run() 来完成其运行状态, run() 方法运行结束, 此线程终止。然后 CPU 再调度其它线程。
run() 方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用 run(),其实就相当于是调用了一个普通函数而已,直接调用 run() 方法必须等待 run() 方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用 start() 方法而不是 run() 方法。
为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。
而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。
start() 方法为什么能开启多线程?
真正实现开启多线程的是 start()
方法中的 start0()
方法。
调用 start0()
方法后,该线程并不一定会立马执行,只是将线程变成了可运行状态(NEW —> RUNNABLE);具体什么时候执行,取决于 CPU ,由 CPU 统一调度;我们又知道 Java 是跨平台的,可以在不同系统上运行,每个系统的 CPU 调度算法不一样,所以就需要做不同的处理,这件事情就只能交给 JVM 来实现了,start0()
方法自然就表标记成了 native。
线程的 6 种状态是什么?
-
新建状态(new):创建线程对象。
-
就绪状态(runnable):start 方法。
-
阻塞状态(blocked):无法获得锁对象(线程没抢到)。
-
等待状态(waiting):wait 方法。
-
计时状态(timed_waiting):sleep 方法。
-
死亡状态(terminated):全部代码运行完毕。
线程的调度模式是什么?
分时调度:轮流获取 CPU 使用权。
抢占式调度:优先级高的线程占用 CPU。
请说出与线程同步以及线程调度相关的方法
-
wait():使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;
-
sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要处理 InterruptedException 异常;
-
notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关;
-
notityAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;
sleep() 和 wait() 有什么区别?
相同点:两者都可以暂停线程的执行。
不同点:
-
sleep 方法,不会释放资源(本质是占用线程),如果占具锁资源,则其他线程不可进;wait 方法会释放锁资源,即其他线程可进来。
-
wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。
Java 中你怎样唤醒一个阻塞的线程?
首先 ,wait()、notify() 方法是针对对象的,调用任意对象的 wait() 方法都将导致线程阻塞,阻塞的同时也将释放该对象的锁,相应地,调用任意对象的 notify() 方法则将随机解除该对象阻塞的线程,但它需要重新获取该对象的锁,直到获取成功才能往下执行;
其次,wait、notify 方法必须在 synchronized 块或方法中被调用,并且要保证同步块或方法的锁对象与调用 wait、notify 方法的对象是同一个,如此一来在调用 wait 之前当前线程就已经成功获取某对象的锁,执行 wait 阻塞后当前线程就将之前获取的对象锁释放。
notify() 和 notifyAll() 有什么区别?
如果线程调用了对象的 wait() 方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。
notifyAll() 会唤醒所有的线程,notify() 只会唤醒一个线程。
notifyAll() 调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而 notify() 只会唤醒一个线程,具体唤醒哪一个线程由虚拟机控制。
为什么 wait(), notify() 和 notifyAll() 必须在同步方法或者同步块中被调用?
这是 JDK 强制的,wait() 方法和 notify()/notifyAll() 方法在调用前都必须先获得对象的锁,也就是 synchronized 对象锁。
Java 线程数过多会造成什么异常?
-
线程的生命周期开销非常高
-
消耗过多的 CPU 资源如果可运行的线程数量多于可用处理器的数量,那么有线程将会被闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量的线程在竞争 CPU 资源时还将产生其他性能的开销。
-
降低稳定性。JVM 在可创建线程的数量上存在一个限制,这个限制值将随着平台的不同而不同,并且承受着多个因素制约,包括 JVM 的启动参数、Thread 构造函数中请求栈的大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么可能抛出 OutOfMemoryError 异常。
ThreadLocal
Threadlocal 是一个线程内部的存储类,提供了线程内存储变量的能力,可以在指定线程内存储数据,数据存储以后,只有指定线程可以得到存储数据。这些变量不同之处在于每一个线程读取的变量是对应的互相独立的。
其内部维护了一个 ThreadLocalMap,该 Map 用于存储每一个线程的变量副本。并且 key 为线程对象,value 为对应线程的变量副本。
为什么 ThreadLocal 会发生内存泄漏?
因为 ThreadLocal 中的 key 是弱引用,而 value 是强引用。当 ThreadLocal 没有被强引用时,在进行垃圾回收时,key 会被清理掉,而 value 不会被清理掉,这时如果不做任何处理,value 将永远不会被回收,产生内存泄漏。
如何解决 ThreadLocal 的内存泄漏?
其实在 ThreadLocal 在设计的时候已经考虑到了这种情况,在调用 set()
、get()
、remove()
等方法时就会清理掉 key 为 null
的记录,所以在使用完 ThreadLocal 后最好手动调用 remove() 方法。
为什么要将 key 设计成 ThreadLocal 的弱引用?
如果 ThreadLocal 的 key 是强引用,是会发生内存泄漏的。如果 ThreadLocal 的 key 是强引用,引用的 ThreadLocal 的对象被回收了,但是 ThreadLocalMap 还持有 ThreadLocal 的强引用,如果没有手动删除,ThreadLocal 不会被回收,发生内存泄漏。
如果是弱引用的话,引用的 ThreadLocal 的对象被回收了,即使没有手动删除,ThreadLocal 也会被回收。value 也会在 ThreadLocalMap 调用 set() 、get()、remove() 的时候会被清除。
线程池
Executors 类有哪几种常见的线程池?
4 种:单例线程池、固定大小线程池、可缓存线程池、大小无限线程池。
(1)newSingleThreadExecutor:创建一个单例线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。只有 1 个核心线程,无非核心线程,执行完立即回收,任务队列为链表结构的有界队列。
(2)newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。如果希望在服务器上使用线程池,建议使用 newFixedThreadPool 方法来创建线程池,这样能获得更好的性能。只有核心线程,线程数量固定,执行完立即回收,任务队列为链表结构的有界队列。
(3) newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60 秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。无核心线程,非核心线程数量无限,执行完闲置 60s 后回收,任务队列为不存储元素的阻塞队列。
(4)newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。核心线程数量固定,非核心线程数量无限,执行完闲置 10ms 后回收,任务队列为延时阻塞队列。
❗说说线程池的底层工作原理
避免频繁的创建线程,销毁线程……
1 | ExecutorService threadPool = Executors.newFixedThreadPool(10) // corePoolSize |
如果线程池内数量小于 corePoolSize,就会创建一个线程执行任务。线程池一般会带一个队列,当线程执行任务完成,会等待阻塞在队列上(队首),尝试从队列继续获取任务。如果任务多于 corePoolSize,会进入阻塞队列。如果阻塞队列满了,可以根据 maximumPoolSize 创建额外的线程。额外线程的空闲时间根据 keepAliveTime 进行自行销毁。
如果额外的线程都创建完了,队列还是满的,还有新来的任务。会 reject 掉,有几种 reject 策略,可以传入 RejectedExecutionHandler。
-
AbortPolicy:抛异常
-
DiscardPolicy:扔掉
-
DiscardOldestPolicy:删除最旧的任务
-
CallerRunsPolicy:返回调用者线程执行任务
-
自定义
(常用)FixedThreadPool 的队列是 LinkedBlockingQueue,无界阻塞队列,即队列长度无线大。corePoolSize 与 maximumPoolSize 一样大。
❗线程池的核心配置参数
代表线程池的类是 ThreadPoolExecutor。corePoolSize(核心线程数),maximumPoolSize(线程池),keepAliveTime(多余的空闲线程在终止之前等待新任务的最长时间),workQueue(阻塞队列)。
七个核心参数:
-
参数一:核心线程数(不能小于 0)
-
参数二:最大线程数(>=核心线程数)
-
参数三:临时线程最大存活时间(不能小于 0)
-
参数四:时间单位(参数三的单位)
-
参数五:等待列队(不能为 null)
-
参数六:创建线程工厂(不能为 null,一般用默认线程工厂)
-
参数七:任务的拒绝策略(不能为 null)
拒绝策略有哪些
-
ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出异常(默认);
-
ThreadPoolExecutor.DiscardPolicy:丢弃任务,不抛异常(不推荐);
-
ThreadPoolExecutor.DiscardOldestPolicy:丢弃等待最久的任务;
-
ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程(main)运行 run 方法。
❗如果在线程池中使用无界阻塞队列会发生什么问题?
面试题:在远程服务异常的情况下,使用无界阻塞队列,是否会导致内存异常飙升?
调用超时,队列变得越来越大,会导致内存飙升,可能还会导致 OOM
❗线程池队列满了之后,会发生什么事情
无界队列,内存溢出。
有界队列,但是如果 maximumPoolSize 数量很大,可以无限制创建线程,但是每个线程占用栈内存,可能会导致内存资源耗尽,或者线程太多,CPU 负载太高。
有界队列,maximumPoolSize 数量固定,多余的任务会被拒绝。
建议:自定义一个 reject 策略,如果线程池无法执行更多任务,可以把任务信息持久化写入磁盘。等负载降低重新执行这些任务。
❗如果线上机器突然宕机,线程池的阻塞队列中的请求怎么办?
线程池内积压的任务丢失。
解决:在提交任务前,在数据库内插入任务信息,并更新他的状态:已提交、未提交、已完成……。系统重启后把任务状态重新提交。
❗如何合理设置线程池的核心线程数?
-
CPU 密集型任务:如果应用程序执行的是 CPU 密集型任务,通常情况下,核心线程数应该设置为等于 CPU 核心数。这可以充分利用 CPU 资源。
- 核心线程数=CPU 核心数
- 核心线程数=CPU 核心数 +1
-
IO 密集型任务:如果应用程序执行的是 IO 密集型任务(例如,文件读写、网络通信等),通常情况下,核心线程数可以设置为大于 CPU 核心数,以充分利用等待 IO 操作时的线程空闲时间。
- 核心线程数=2 * CPU 核心数
- 核心线程数=CPU 核心数 / (1- 阻塞系数)
-
混合型任务:核心线程数=(线程等待时间/线程 CPU 时间 +1) * CPU 核心数