《Java 后端面试经》专栏文章索引:
《Java 后端面试经》Java 基础篇
《Java 后端面试经》Java EE 篇
《Java 后端面试经》数据库篇
《Java 后端面试经》多线程与并发编程篇
《Java 后端面试经》JVM 篇
《Java 后端面试经》操作系统篇
《Java 后端面试经》Linux 篇
《Java 后端面试经》设计模式篇
《Java 后端面试经》计算机网络篇
《Java 后端面试经》微服务篇
1、原子性
原子性是指在一个操作中 CPU 不可以在中途暂停然后再调度,即不被中断操作,要么全部执行完成,要么全都不执行。就好比转账,从账户 A 向账户 B 转 1000元,那么必然包括两个操作:从账户 A 减去 1000 元,往账户 B 添加 1000 元,两个操作必须全部完成。
private long count = 0;
public void calc() {
count++;
}
那程序中的原子性指的是最小的操作单元,比如自增操作,它本身其实不是原子性操作,是分了 3 步的,包括读取变量的原始值、进行加 1 操作、写入工作内存。所以在多线程中,有可能一个线程还没自增完,可能才执行到第二步,另一个进程就已经读取了值,导致结果错误,如果我们能保证自增操作是一个原子性的操作,那么就能保证其他线程读取到的一定是自增后的数据。
关键字: synchronized
2、可见性
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。若两个线程在不同的 CPU,那么线程 1 改变了 i 的值还没刷新到主存,线程 2 又使用了 i,那么这个 i 值肯定还是之前的,线程 1 对变量的修改,线程没看到这就是可见性问题。
// 线程1
boolean stop = false;
while (!stop) {
doSomething();
}
// 线程2
stop = true;
如果线程 2 改变了 stop 的值,线程 1 一定会停止吗?不一定。当线程 2 更改了 stop 变量的值之后,但是还没来得及写入主存当中,线程 2 转去做其他事情了,那么线程 1 由于不知道线程 2 对 stop 变量的更改,因此还会一直循环下去。
关键字: volatile、synchronized、final
3、有序性
虚拟机在进行代码编译时,对于那些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按照我们写的代码的顺序来执行,有可能将他们重排序。实际上,对于有些代码进行重排序之后,虽然对变量的值没有造成影响,但有可能会出现线程安全问题。
int a = 0;
bool flag = false;
public void write() {
a = 2; // 1
flag = true; // 2
}
public void multiply() {
if (flag) { // 3
int ret = a * a; // 4
}
}
write 方法里的 1 和 2 做了重排序,线程 1 先对 flag 赋值为 true,随后执行到线程 2,ret 直接计算出结果,再到线程 1,这时候 a 才赋值为 2,很明显迟了一步。
关键字:volatile、synchronized
volatile 本身就包含了禁止指令重排序的语义,而 synchronized 关键字是由 “一个变量在同一时刻只允许一条线程对其进行锁操作”这条规则明确的。
synchronized 关键字同时满足以上三种特性,但是 volatile 关键字不满足原子性。
在某些情况下,volatile 的同步机制的性能确实要优于锁(使用 synchronized 关键字或 java.util.concurrent 包里面的锁),因为 volatile 的总开销要比锁低。判断使用 volatile 还是加锁的唯一依据就是 volatile 的语义能否满足使用的场景(原子性) 。
volatile
1、保证被 volatile 修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被 volatile 修饰共享变量的值,新值总是可以被其他线程立即得知。
//线程1 boolean stop = false;
while(!stop) {
doSomething();
}
//线程2
stop = true;
如果线程 2 改变了 stop 的值,线程 1 一定会停止吗?不一定。当线程 2 更改了 stop 变量的值之后,但是还没来得及写入主存当中,线程 2 转去做其他事情了,那么线程 1 由于不知道线程2 对 stop 变量的更改,因此还会一直循环下去。
2、禁止指令重排序优化
int a = 0;
bool flag = false;
public void write() {
a = 2; //1
flag = true; //2
}
public void multiply() {
if (flag) { //3
int ret = a * a;//4
}
}
write 方法里的 1 和 2 做了重排序,线程 1 先对 flag 赋值为 true,随后执行到线程 2,ret 直接计算出结果,再到线程 1,这时候 a 才赋值为 2,很明显迟了一步,但是用 volatile 修饰之后就变得不一样了。
inc++ 其实是两个步骤,先加加,然后再赋值,不是原子性操作,所以 volatile 不能保证线程安全。
线程通常有五种状态,创建、就绪、运行、阻塞和消亡状态。
5 种状态说明:
start()
方法。start()
方法,该状态的线程位于可运行程序池中,变得可运行,等待获取 CPU 的使用权。阻塞的情况又分为三种:
wait()
方法,该线程会释放占用的所有资源,JVM 会把该线程放入“等待池” 中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用 notify()
或 notifyAll()
方法才能被唤醒,wait()
是 Object
类的方法。sleep()
或 join()
方法,或者发出了 I/O 请求时,JVM 会把该线程置为阻塞状态。当 sleep()
状态超时、join()
等待线程终止或者超时、或者 I/O 处理完毕时,线程重新传入就绪状态。sleep 是 Thread 类的方法。锁池
所有需要竞争同步锁的线程都会放在锁池中,比如当前对象的锁已经被其中一个线程得到,则其他线程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到对象锁后会进入就绪队列进行等待 CPU 资源分配。
等待池
当我们调用 wait() 方法后,线程会放到等待池当中,等待池中的线程是不会去竞争同步锁。只有调用了 notify() 或 notifyAll() 后等待池中的线程才会开始去竞争锁,notify() 是随机从等待池选出一个线程放到锁池,而 notifyAll() 是将等待池的所有线程放到锁池当中。
1、sleep 是 Thread 类的静态方法,wait 则是 Object 类的本地方法。
2、sleep 方法不会释放锁, 但是 wait 会释放,而且会加入到等待队列中。
sleep 就是把 cpu 的执行资格和执行权释放出去,不再运行此线程,当定时时间结束再取回 cpu 资源,参与 cpu 的调度,获取到 cpu 资源后就可以继续运行了。如果 sleep 时该线程有锁,那么 sleep 不会释放这个锁,而是把锁带着进入了冻结状态,也就是说其他需要这个锁的线程根本不可能获取到这个锁。也就是说无法执行程序,如果在睡眠期间其他线程调用了这个线程的 interrupt 方法,那么这个线程也会抛出 interruptexception 异常返回,这点和 wait 是一样的。
3、sleep 方法不依赖于同步器 synchronized, 但是 wait 需要依赖 synchronized 关键字。
4、sleep 不需要被唤醒(休眠之后退出阻塞),但是 wait 需要(不指定时间需要被别人中断)。
5、sleep 一般用于当前线程休眠,或者轮询暂停操作,wait 则多用于多线程之间的通信。
6、sleep 会让出 CPU 执行时间且强制上下文切换,而 wait 则不一定,wait 后可能还是有机会重新竞争到锁继续执行的。
start: 启动线程,实现了多线程运行,无需等待 run 方法体代码执行完毕而直接继续执行下面的代码。通过调用 Thread 类的 start() 方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到 cpu 时间片,就开始执行 run() 方法,这里方法 run() 称为线程体,它包含了要执行的这个线程的内容,run 方法运行结束,此线程随即终止。
join:很多情况下,主线程生成启动了子线程,如果子线程里要进行大量的耗时的运算,主线程往往将于子线程之前结束,但是如果主线程处理完其他的事务后,需要用到子线程的处理结果,也就是主线程需要等待子线程执行完成之后再结束,这个时候就要用到 join() 方法。
yield() 执行后线程直接进入就绪状态,马上释放了 CPU 的执行权,但是依然保留了 CPU 的执行资格,所以有可能 CPU 下次进行线程调度还会让这个线程获取到执行权继续执行。
join() 执行后线程进入阻塞状态,例如在线程 B 中调用线程 A 的 join(),那线程 B 会进入到阻塞队列,直到线程 A 结束或中断线程。
public static void main(String[] args) throws InterruptedException{
Thread t1 = new Thread(new Runnable(){
@Override
public void run() {
try {
Thread.sleep(1000);
} catch(InterruptedException e) {
e.printStack();
}
System.out.println("222222");
}
});
t1.start();
t1.join();
// 这行代码必须要等 t1 全部执行完毕才会执行
System.out.println("111111");
}
执行结果:
222222
111111
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
实现线程安全的方式有三大种方法,分别是互斥同步、非阻塞同步和无同步方案:
synchronized
关键字或 ReentrantLock
等。synchronized
关键字解决的是多个线程之间访问资源的同步性,synchronized
关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
synchronized 关键字有三种使用方式:
总结:
一、当 synchronized 作用在代码块时,它的底层是通过 monitorenter、monitorexit 指令来实现的:
(1) monitorenter
(2) monitorexit
二、当 synchronized 作用在方法时,它的底层并没有通过 monitorenter 和 monitorexit 指令来完成,不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED
标识符,JVM 就是根据该标示符来实现方法的同步的:
总结:
mutex
来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。state
,而 ReentrantLock 就是通过重写了 AQS 的 tryAcquire
和 tryRelease
方法实现的 lock 和 unlock.ReentrantLock 结构如下图所示:
相同点:
不同点:
synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!
在 JDK 1.6 后 Java 对 synchronized 锁进行了升级过程,主要包含偏向锁、轻量级锁和重量级锁,主要是针对对象头 MarkWord 的变化。
(1)偏向锁:
为什么要引入偏向锁?
因为经过 HotSpot 的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
偏向锁的升级:
(2)轻量级锁
为什么要引入轻量级锁?
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要 CPU 从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋等待锁释放。
轻量级锁什么时候升级为重量级锁?
volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,当一个变量被定义为 volatile 后,它将具备两种特性:
1、保证了不同线程对该变量操作的内存可见性。
2、禁止指令重新排序。
我们从一个最经典的例子来分析重排序问题。大家应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。其源码如下:
package com.paddx.test.concurrent;
public class Singleton {
public static volatile Singleton singleton;
/**
* 构造函数私有,禁止外部实例化
*/
private Singleton() {};
public static Singleton getInstance() {
if (singleton == null) {
synchronized (singleton) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
现在我们分析一下为什么要在变量 singleton 之间加上 volatile 关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:
(1)分配内存空间。
(2)初始化对象。
(3)将内存空间的地址赋值给对应的引用。
但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:
(1)分配内存空间。
(2)将内存空间的地址赋值给对应的引用。
(3)初始化对象
如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为 volatile 类型的变量。
Atomic 解决的是多线程安全问题。
【参考】:volatile 解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题,但是如果多写,同样无法解决线程安全问题。如果是 count++ 操作,使用如下类实现:
AtomicInteger count = new AtomicInteger();
count.addAndGet(1);
sleep 后进入 time waiting 超时等待状态,wait 后进入等待 waiting 状态。
释放锁资源实际是通知对象内置的 monitor 对象进行释放,而只有所有对象都有内置的 monitor 对象才能实现任何对象的锁资源都可以释放。又因为所有类都继承自 Object,所以 wait()就成了 Object 方法,也就是通过 wait() 来通知对象内置的 monitor 对象释放,而且事实上因为这涉及对硬件底层的操作,所以 wait() 方法是 native 方法,底层是用 C 写的。
AQS(Abstract Queued Synchronizer) 是一个抽象队列同步器,通过维护一个 int
类型的状态标志位 state
和一个先进先出的(FIFO)的线程等待队列来实现一个多线程访问共享资源的同步框架。
private volatile int state; //共享变量,使用 volatile 修饰保证线程可见性
AQS 的原理:
CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node) 来实现锁的分配。
AQS 主要的使用方式是继承,子类通过继承 AQS 并实现它的抽象方法来管理同步状态,在抽象方法的实现过程如果要对同步状态进行更改,可以使用 AQS 提供的 getState()、setState(int newState) 和 compareAndSetState(int expect, int update)
来进行操作。
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
AQS 定义了两种资源共享方式:独占式和共享式:
AQS 只是一个框架(模板模式),只定义了一个抽象类,具体资源的获取、释放都交由自定义同步器去实现。不同的自定义同步器争取用共享资源的方式也不同,自定义同步器在实现时只需实现共享资源 state
的获取与释放方式即可,至于具体线程等待队列的维护,如获取资源失败入队、唤醒出队等,AQS 已经在顶层实现好,不需要具体的同步器在做处理。
同步器可重写的方法如下表所示:
方法名称 | 描述 |
---|---|
protected boolean tryAcquire(int arg) | 独占式获取同步状态,实现该方法需要查询当前状态并判断同步状态是否符合预期,然后再进行 CAS 设置同步状态 |
protected boolean tryRelease(int arg) | 独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态 |
protected int tryAcquireShared(int arg) | 共享式获取同步状态,返回大于等于0 的值,表示获取成功,否则,获取失败 |
protected boolean tryReleaseShared(int arg) | 共享式释放同步状态 |
protected boolean isHeldExclusively(int arg) | 当前同步器是否在独占模式下被线程占用,一般该方法表示是否被当前线程所占 |
使用案例:
Java 中常见的并发关键字有 CountDownLatch、CylicBarrier、Semaphore 和 volatile.
公平锁:按照线程在队列中的排队顺序,先到者先拿到锁。
非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的。
synchronized 是非公平锁, Lock 默认是非公平锁,可以设置为公平锁,公平锁会影响性能。
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
乐观锁一般来说有以下两种方式:
适用场景:
CAS(Compare and swap) 比较和替换, CAS 的思想比较简单,主要涉及到三个值:当前内存值 V、旧的内存值 A、即将更新的内存值 B,当且仅当旧的内存值 A 与当前内存值 V 相等时,将内存值 V 修改为更新值 B,否则什么都不做,整个比较并替换的操作是一个原子操作。
CAS 主要使用在一些需要上锁的场景充当乐观锁解决方案,一般在一些简单且要上锁的操作但又不想引入锁场景,这时候来使用 CAS 代替锁。
ABA 问题、循环时间长开销很大、只能保证一个共享变量的原子操作。
ABA:如果另一个线程修改 V 值假设原来是 A,先修改成 B,再修改回成 A. 当前线程的 CAS 操作无法分辨当前 V 值是否发生过变化。
例子:在你非常渴的情况下你发现一个盛满水的杯子,你一饮而尽。之后再给杯子里重新倒满水。然后你离开,当杯子的真正主人回来时看到杯子还是盛满水,他当然不知道是否被人喝完重新倒满。解决这个问题的方案的一个策略是每一次倒水假设有一个自动记录仪记录下,这样主人回来就可以分辨在她离开后是否发生过重新倒满的情况。这也是解决 ABA 问题目前采用的策略。
ABA 问题的解决方案:
在对变量进行操作的时候给变量加一个版本号,每次对变量操作都将版本号加 1,常见在数据库的乐观锁中可见。
Java 提供了相应的原子引用类 AtomicStampedReference,它通过包装 [E,Integer] 的元组来对对象标记版本戳 stamp,从而避免 ABA 问题。
循环时间长开销很大解决方案:
只能保证一个共享变量的原子操作,多个共享变量使用 CAS 无法保证原子性解决方案:
ThreadPoolExecutor
创建线程池,目的是让开发人员明确线程池运行规则,规避资源耗尽的风险。Thread 和 Runnable 的实质是继承关系,没有可比性。无论使用 Runnable 还是 Thread,都会new Thread,然后执行 run 方法。用法上,如果有复杂的线程操作需求,那就选择继承Thread,如果只是简单的执行一个任务,那就实现 Runnable 接口。
通俗地讲就是,管理线程的池子。
定义:线程池就是事先创建若干个可执行的线程放入一个池(容器)中,需要的时候从池中获取线程不用自行创建,使用完毕不需要销毁线程而是放回池中,从而减少创建和销毁线程对象的开销。
线程池的优点:
ThreadPoolExecutor 的通用构造函数:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler);
线程池有 7 大核心参数,分别是:
线程池的大小需要根据实际的业务场景去设置,可以大致分为 CPU 密集型和 IO 密集型。
常见的线程池有 newCachedThreadPool
、newFixedThreadPool
、newScheduledThreadPool
和 newSingleThreadExecutor
. 这几个都是 ExecutorService (线程池)的实例。
四种线程池的特性如下图:
类型 | 特性 |
---|---|
newCachedThreadPool | 线程池的大小不固定,可灵活回收空闲线程,若无可回收,则新建线程 |
newFixedThreadPool | 固定大小的线程池,当有新的任务提交,线程池中如果有空闲线程,则立即执行,否则新的任务会被缓存在一个任务队列中,等待线程池释放空闲线程。 |
newScheduledThreadPool | 定时线程池,支持定时及周期性任务执行。 |
newSingleThreadExecutor | 只创建一个线程,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。 |
适用场景分别如下:
Integer.MAX_VALU
E ,可能会创建大量线程,从而导致 OOM.Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM.当线程池的任务缓存队列已满并且线程池中的线程数目达到 maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:
ThreadPoolExecutor.AbortPolicy
线程池的默认拒绝策略为 AbortPolicy,即丢弃任务并抛出 RejectedExecutionException 异常(即后面提交的请求不会放入队列也不会直接消费并抛出异常)。
ThreadPoolExecutor.DiscardPolicy
丢弃任务,但是不抛出异常。如果线程队列已满,则后续提交的任务都会被丢弃,且是静默丢弃(也不会抛出任何异常,任务直接就丢弃了)。
ThreadPoolExecutor.DiscardOldestPolicy
丢弃队列最前面的任务,然后重新提交被拒绝的任务(丢弃掉了队列最前的任务,并不抛出异常,直接丢弃了)。
ThreadPoolExecutor.CallerRunsPolicy
由调用线程处理该任务(不会丢弃任务,最后所有的任务都执行了,并不会抛出异常)
1、一般的队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前的任务了,阻塞队列通过阻塞可以保留住当前想要继续入队的任务。
阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入 wait 状态,释放 CPU 资源。
阻塞队列自带阻塞和唤醒功能,不需要额外处理,无任务执行时,线程池利用阻塞队列的 take 方法挂起,从而维持核心线程的存活、不至于一直占用 CPU 资源。
2、在创建新线程的时候,是要获取全局锁的,这个时候其他线程就得阻塞,影响了整体效率。
就好比一个企业里面有10个(core)正式工的名额,最多招10个正式工,要是任务超过正式工人数(task > core)的情况下,工厂领导(线程池)不是首先扩招工人,还是这10人,但是任务可以稍微积压一下,即先放到队列去(代价低)。10个正式工慢慢干,迟早会干完的,要是任务还在继续增加,超过正式工的加班忍耐极限了(队列满了),就的招外包帮忙了(注意是临时工)要是正式工加上外包还是不能完成任务,那新来的任务就会被领导拒绝了(线程池的拒绝策略)。
final Object[] items
实现,需要指定初始容量大小,插入和删除用的是同一把锁ReentrantLock lock
.Integer.MAX_VALUE
,有可能会造成 OOM 问题,插入和删除使用不同的锁ReentrantLock putLock
和ReentrantLock takeLock
,并发处理效率高。PriorityBlockingQueue
的延迟获取的无界队列。具有 “延迟” 的功能。Java 中每一个线程都有自己的专属本地变量, JDK 中提供的 ThreadLocal 类,ThreadLocal 类主要解决的就是让每个线程绑定自己的值,可以将 ThreadLocal 类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
使用场景:
1、在进行对象跨层传递的时候,使用 ThreadLocal 可以避免多次传递,打破层次间的约束。
2、线程间数据隔离。
3、进行事务操作,用于存储线程事务信息。
4、数据库连接,Session 会话管理。
Spring 框架在事务开始时会给当前线程绑定一个 JDBC Connection,在整个事务过程都是使用该线程绑定的 connection 来执行数据库操作,实现了事务的隔离性。Spring 框架里面就是用的 ThreadLocal 来实现中各种隔离。
Thread --> ThreadLocalMap-->Entry-->Value
,随着任务的执行,value 就有可能越来越多且无法释放,最终导致内存泄漏。解决方法:每次使用完 ThreadLocal 就调用它的 remove() 方法,手动将对应的键值对删除,从⽽避免内存泄漏。
currentTime.set(System.currentTimeMillis());
result = joinPoint.proceed();
Log log = new Log("INFO",System.currentTimeMillis() - currentTime.get());
currentTime.remove();
ThreadLocal 适用场景:每个线程需要有自己单独的实例,且需要在多个方法共享实例,即同时满足实例在线程间的隔离与方法间的共享,这种情况适合使用 ThreadLocal. 比如 Java web 应用中,每个线程有自己单独的 Session 实例,就可以使用 ThreadLocal 来实现。
JDK 1.2 之前,一个对象只有 “已被引用” 和 “未被引用” 两种状态,这将无法描述某些特殊情况下的对象,比如,当内存充足时需要保留,而内存紧张时才需要被抛弃的一类对象。所以在 JDK.1.2 之后,Java 对引用的概念进行了扩充,将引用分为了:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4 种,这 4 种引用的强度依次减弱。
一、强引用
Object obj = new Object();
,只要 obj 还指向 Object 对象,Object 对象就不会被回收 obj = null;
// 手动置 null,只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足时,JVM 也会直接抛出 OutOfMemoryError,不会去回收。如果想中断强引用与对象之间的联系,可以显式地将强引用赋值为 null,这样一来,JVM 就可以适时的回收对象了。
二、软引用
软引用是用来描述一些非必需但仍有用的对象。在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。这种特性常常被用来实现缓存技术,比如网页缓存,图片缓存等。
在 JDK1.2 之后,用 java.lang.ref.SoftReference 类来表示软引用。
三、弱引用
弱引用的引用强度比软引用要更弱一些,无论内存是否足够,只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收。在 JDK1.2 之后,用 java.lang.ref.WeakReference 来表示弱引用。
四、虚引用
虚引用是最弱的一种引用关系,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收,在 JDK 1.2 之后,用 PhantomReference 类来表示,通过查看这个类的源码,发现它只有一个构造函数和一个 get() 方法,而且它的 get() 方法仅仅是返回一个 null,也就是说将永远无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue 引用队列一起使用。
守护线程的作用是什么?
举例:GC 垃圾回收线程就是一个经典的守护线程,当我们的程序中不再有任何运行 Thread,程序就不会再产生垃圾,垃圾回收器也就是无事可做,所以当垃圾回收线程是 JVM 上仅剩的线程时,垃圾回收线程会自动离开,它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。
应用场景:(1)来为其他线程提供服务支持的情况(2)或者在任何情况下,程序结束时,这个线程必须正常且立刻关闭,就可以作为守护线程来使用;反之,如果一个正在执行某个操作的线程必须要正确地关闭掉否则就会出现不好的后果的话,那么这个线程就不能是守护线程,而是用户线程。通常都是些关键的事物,比如说,数据库录入或者更新,这些操作都是不能中断的。
thread.setDaemon(true)
必须在 thread.start() 之前设置,否则会出 IllegalThreadStateException
异常,你不能把正在运行的常规线程设置为守护线程。在 Daemon 线程中产生的新线程也是 Daemon 的。
守护线程不能用于去访问固有资源,比如读写操作或者计算逻辑,因为它会在任何时候甚至在一个操作的中间发生中断。
Java 自带的多线程框架,比如 ExecutorService,会将守护线程转换为用户线程,所以如果要使用后台线程就不能用 java 的线程池。