《Java 后端面试经》多线程与并发编程篇

《Java 后端面试经》专栏文章索引:
《Java 后端面试经》Java 基础篇
《Java 后端面试经》Java EE 篇
《Java 后端面试经》数据库篇
《Java 后端面试经》多线程与并发编程篇
《Java 后端面试经》JVM 篇
《Java 后端面试经》操作系统篇
《Java 后端面试经》Linux 篇
《Java 后端面试经》设计模式篇
《Java 后端面试经》计算机网络篇
《Java 后端面试经》微服务篇

《Java 后端面试经》多线程与并发编程篇

  • 并发的三大特性
  • 线程的生命周期,线程有哪些状态?
  • sleep()、wait()、join()、yield() 的区别
  • 说说什么是线程安全?如何实现线程安全?
    • 如何使用 synchronized?
    • 谈谈 synchronized 的原理?
    • 谈谈 ReentrantLock 的实现原理?
    • synchronized 和 ReentrantLock 异同点?
    • synchronized 和 volatile 的区别
    • synchronized 和 lock 的区别
    • synchronized 锁升级的过程说一下?
    • synchronized 锁的作用范围
  • volatile
    • 谈谈 volatile 关键字
    • volatile 变量和 atomic 变量有什么不同?
  • 线程间的通信方式有哪些?
    • sleep 后进入什么状态,wait 后进入什么状态?
    • wait 为什么是 Object 类下面的方法?
    • start 方法和 run 方法有什么区别?
  • AQS
    • Java 中的并发关键字
  • 锁的分类
    • 公平锁与非公平锁
    • 共享式与独占式锁
    • 悲观锁与乐观锁
  • CAS
    • 什么是 CAS?
    • CAS 带来的问题是什么?如何解决的?
    • CAS 导致问题的解决方案
  • Java 中创建线程的方式有哪些?
    • Thread 和 Runnable 区别?
    • Runnable 和 Callable 区别?
  • 线程池
    • 什么是线程池?
    • 为什么要使用线程池?
    • 线程执行流程?
    • 线程池参数有哪些?
    • 线程池的大小怎么设置?
    • 线程池的类型有哪些?适用场景?
    • 线程池四种拒绝策略?
    • 线程池中线程复用原理
    • 线程池中阻塞队列的作用?为什么是先添加队列而不是先创建最大线程?
    • Java中常见的阻塞队列有哪些?
  • ThreaLocal 知道吗?
    • ThreadLocal 可能会带来什么问题?
    • ThreadLocal 使用场景有哪些?
    • 什么是强、软、弱、虚引用?
  • 说说你对守护线程的理解

并发的三大特性

1、原子性
原子性是指在一个操作中 CPU 不可以在中途暂停然后再调度,即不被中断操作,要么全部执行完成,要么全都不执行。就好比转账,从账户 A 向账户 B 转 1000元,那么必然包括两个操作:从账户 A 减去 1000 元,往账户 B 添加 1000 元,两个操作必须全部完成。

private long count = 0;
public void calc() {
    count++;
}
  1. 将 count 从主存读到工作内存中的副本中
  2. +1 的运算
  3. 将结果写入工作内存
  4. 将工作内存的值刷回主存(什么时候刷入由操作系统决定,这是不确定的)

那程序中的原子性指的是最小的操作单元,比如自增操作,它本身其实不是原子性操作,是分了 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 修饰之后就变得不一样了。

  • 使用 volatile 关键字会强制将修改的值立即写入主存
  • 使用 volatile 关键字的话,当线程 2 进行修改时,会导致线程 1 的工作内存中缓存变量 stop 的缓存行无效(反映到硬件层的话,就是 CPU 的 L1 或者 L2 缓存中对应的缓存行无效)
  • 由于线程 1 的工作内存中缓存变量 stop 的缓存无效,所以线程 1 再次读取变量 stop 的值时会去主存读取

inc++ 其实是两个步骤,先加加,然后再赋值,不是原子性操作,所以 volatile 不能保证线程安全。

线程的生命周期,线程有哪些状态?

线程通常有五种状态,创建就绪运行阻塞消亡状态。

5 种状态说明:

  • 新建状态 (New):初始状态,线程被创建,但是还没有调用 start() 方法。
  • 就绪状态(Runnable): 线程对象创建后,其他线程调用了该对象的 start() 方法,该状态的线程位于可运行程序池中,变得可运行,等待获取 CPU 的使用权。
  • 运行状态(Running): 就绪状态的线程获取了 CPU,执行程序代码。
  • 阻塞状态(Blocked): 线程因为某种原因放弃 CPU 使用权,暂时停止运行,进入阻塞队列。直到线程进入就绪状态,才有机会转到运行状态。
  • 消亡状态(Dead): 线程执行结束或者因异常退出了 run() 方法,该线程结束生命周期。

阻塞的情况又分为三种

  1. 等待阻塞:运行的线程执行 wait() 方法,该线程会释放占用的所有资源,JVM 会把该线程放入“等待池” 中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用 notify()notifyAll() 方法才能被唤醒,wait()Object 类的方法。
  2. 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则 JVM 会把该线程放入”锁池“中。
  3. 其他阻塞:运行的线程执行 sleep()join() 方法,或者发出了 I/O 请求时,JVM 会把该线程置为阻塞状态。当 sleep() 状态超时、join() 等待线程终止或者超时、或者 I/O 处理完毕时,线程重新传入就绪状态。sleep 是 Thread 类的方法。

sleep()、wait()、join()、yield() 的区别

锁池

所有需要竞争同步锁的线程都会放在锁池中,比如当前对象的锁已经被其中一个线程得到,则其他线程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到对象锁后会进入就绪队列进行等待 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

说说什么是线程安全?如何实现线程安全?

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

实现线程安全的方式有三大种方法,分别是互斥同步非阻塞同步无同步方案

  1. 互斥同步同步是指多个线程并发访问共享数据时,保证共享数据在同一各时刻只被一条(或一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是常见的互斥实现方式。Java 中实现互斥同步的手段主要有 synchronized 关键字或 ReentrantLock 等。
  2. 非阻塞同步类似是一种乐观并发的策略,比如 CAS(Compare And Swap)。
  3. 无同步方案,比如使用线程本地变量 ThreadLocal.

如何使用 synchronized?

synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

synchronized 关键字有三种使用方式:

  • 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前类的锁。由于静态成员不属于任何一个实例对象,是类成员。因此,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
  • 修饰代码块 :指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码块前要获得给定对象的锁。synchronized(类.class) 表示进入同步代码块前要获得当前类的锁。

总结

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。
  • synchronized 关键字加到实例方法上是给对象实例上锁。
  • 尽量不要使用 synchronized(String a),因为在 JVM 中字符串常量池具有缓存功能。

谈谈 synchronized 的原理?

一、当 synchronized 作用在代码块时,它的底层是通过 monitorenter、monitorexit 指令来实现的:

(1) monitorenter

  • 每个对象都是一个监视器锁(monitor),当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取 monitor 的所有权,过程如下:
  • 如果 monitor 的进入数为 0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor 的所有者。如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加 1。如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0,再重新尝试获取 monitor 的所有权。

(2) monitorexit

  • 执行 monitorexit 的线程必须是 objectref 所对应的 monitor 持有者。指令执行时,monitor 的进入数减 1,如果减 1 后进入数为 0,那线程退出 monitor,不再是这个 monitor 的所有者,其他被这个 monitor 阻塞的线程可以尝试去获取这个 monitor 的所有权。
  • monitorexit 指令出现了两次,第 1 次为同步正常退出释放锁,第 2 次为发生异步退出释放锁。

二、当 synchronized 作用在方法时,它的底层并没有通过 monitorenter 和 monitorexit 指令来完成,不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标识符,JVM 就是根据该标示符来实现方法的同步的

总结

  • 两种底层实现原理并没有本质区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
  • 两个指令的执行是 JVM 通过调用操作系统的互斥原语 mutex 来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

谈谈 ReentrantLock 的实现原理?

  • ReentrantLock 是基于 AQS(AbstractQueuedSynchronizer) 实现的,它是一个内部实现了两个队列的抽象类,分别是同步队列和条件队列。
  • 其中同步队列是一个双向链表,里面储存的是处于等待状态的线程,正在排队等待唤醒去获取锁;而条件队列是一个单向链表,里面储存的也是处于等待状态的线程,只不过这些线程唤醒的结果是加入到了同步队列的队尾AQS 所做的就是管理这两个队列里面线程之间的等待状态-唤醒的工作。
  • 在同步队列中,还存在两种模式,分别是独占模式和共享模式,这两种模式的区别就在于AQS 在唤醒线程节点的时候是不是传递唤醒,这两种模式分别对应独占锁和共享锁。
  • AQS 是一个抽象类,所以不能直接实例化,当我们需要实现一个自定义锁的时候可以去继承 AQS,然后重写获取锁的方式和释放锁的方式还有管理 state,而 ReentrantLock 就是通过重写了 AQS 的 tryAcquiretryRelease 方法实现的 lock 和 unlock.

ReentrantLock 结构如下图所示:

《Java 后端面试经》多线程与并发编程篇_第1张图片

  • 首先 ReentrantLock 实现了 Lock 接口,然后有 3 个内部类,其中 Sync 内部类继承自 AQS,另外的两个内部类继承自 Sync,这两个类分别是用来公平锁和非公平锁的。
  • 通过 Sync 重写的方法 tryAcquire、tryRelease 可以知道,ReentrantLock 实现的是 AQS 的独占模式,也就是独占锁,这个锁是悲观锁。

synchronized 和 ReentrantLock 异同点?

相同点

  1. 都是可重入锁: 指的是自己可以再次获取自己的内部锁。
  2. 都保证了可见性和互斥性。
  3. 都可以用于控制多线程对共享对象的访问。

不同点

  1. 底层实现synchronized 是 Java 中的关键字是 JVM 级别的锁,而 ReentrantLock 是一个 Lock 接口下的实现类,是 JDK 实现的锁。
  2. 是否可手动释放synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用ReentrantLock 则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象。一般通过 lock() 和 unlock() 方法配合 try/finally 语句块来完成,使用释放更加灵活。
  3. 是否可中断synchronized 是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成; ReentrantLock 则可以中断,可通过 trylock(long timeout,TimeUnit unit) 设置超时方法或者将 lockInterruptibly() 放到代码块中,调用 interrupt 方法进行中断。
  4. 是否公平锁synchronized 为非公平锁, ReentrantLock 虽然默认为非公平锁,但是也可以通过构造方法 new ReentrantLock 时传入 boolean 值进行选择,为空默认 false 非公平锁,true 为公平锁。
  5. 锁的对象synchronzied 锁的是对象,锁是保存在对象头里面的,根据对象头数据来标识是否有线程获得锁/争抢锁;ReentrantLock 锁的是线程,根据进入的线程和 int 类型的 state 标识锁的获得/争抢。

synchronized 和 volatile 的区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 只能使用在变量上,而 synchronized 可以在类、变量、方法和代码块上。
  • volatile 只保证可见性,synchronized 保证原子性与可见性。
  • volatile 禁用指令重排序,synchronized 不会。
  • volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。

synchronized 和 lock 的区别

  1. synchronized 是一个关键字,lock 是一个接口。
  2. synchronized 在发生异常时候会自动释放占有的锁,因此不会出现死锁;而 lock 发生异常时候,不会主动释放占有的锁,必须手动 unlock 来释放锁,可能引起死锁的发生。
  3. synchronized 只能等待锁的释放,不能响应中断;lock 等待锁过程中可以用 interrupt 来中断等待。
  4. lock 可以通过 trylock 来知道有没有获取锁,返回值类型为布尔,而 synchronized 不能。
  5. 在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时 lock 的性能要远远优于 synchronized. 所以说,synchronized 适合少量同步场景,lock 适合大量同步场景。

synchronized 锁升级的过程说一下?

在 JDK 1.6 后 Java 对 synchronized 锁进行了升级过程,主要包含偏向锁轻量级锁重量级锁,主要是针对对象头 MarkWord 的变化。

(1)偏向锁:

为什么要引入偏向锁?

因为经过 HotSpot 的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。

偏向锁的升级:

  • 当线程 1 访问代码块并获取锁对象时,会在 Java 对象头和栈帧中记录偏向的锁的 threadID,因为偏向锁不会主动释放锁,因此以后线程 1 再次获取锁的时候,需要比较当前线程的 threadID 和 Java 对象头中的 threadID 是否一致。
  • 如果一致(还是线程 1 获取锁对象),则无需使用 CAS 来加锁、解锁;如果不一致(其他线程,如线程 2 要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程 1 的 threadID),那么需要查看 Java 对象头中记录的线程 1 是否存活。
  • 如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程 2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程 1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。

(2)轻量级锁

为什么要引入轻量级锁?

轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要 CPU 从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋等待锁释放。

轻量级锁什么时候升级为重量级锁?

  • 线程 1 获取轻量级锁时会先把锁对象的对象头 MarkWord 复制一份到线程 1 的栈帧中创建的用于存储锁记录的空间(称为 DisplacedMarkWord),然后使用 CAS 把对象头中的内容替换为线程 1 存储的锁记录(DisplacedMarkWord)的地址。
  • 如果在线程 1 复制对象头的同时(在线程1 CAS 之前),线程 2 也准备获取锁,复制了对象头到线程 2 的锁记录空间中,但是在线程 2 CAS 的时候,发现线程 1 已经把对象头换了,线程 2 的 CAS 失败,那么线程 2 就尝试使用自旋锁来等待线程 1 释放锁。
  • 但是如果自旋的时间太长也不行,因为自旋是要消耗 CPU 的,因此自旋的次数是有限制的,比如 10 次或者 100 次,如果自旋次数到了线程 1 还没有释放锁,或者线程 1 还在执行,线程 2 还在自旋等待,这时又有一个线程 3 过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止 CPU 空转。

synchronized 锁的作用范围

  • synchronized 作用于成员变量和非静态方法时,锁住的是对象的实例,即 this 对象。
  • synchronized 作用于静态方法时,住的是 Class 实例
  • synchronized 作用于一个代码块时,锁住的是所有代码块中配置的对象

volatile

谈谈 volatile 关键字

《Java 后端面试经》多线程与并发编程篇_第2张图片
volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,当一个变量被定义为 volatile 后,它将具备两种特性:

1、保证了不同线程对该变量操作的内存可见性。

  • 当线程写一个 volatile 变量时,JMM 会立即把该线程对应的工作内存中的变量刷新到主内存。
  • 当线程读一个 volatile 变量时(如果变量已经在主内存被修改),JMM 会把该线程对应的工作内存的变量置为无效,线程接下来将从主内存中读取共享变量。
  • 虽然具有可见性,但是多线程在并发情况下对 volatile 修饰的变量进行操作时是会有线程安全性的问题的。这是因为 volatile 修饰的变量在各个线程工作内存中是不存在一致性的,但是由于每次使用都要进行刷新,导致执行引擎看不到不一致的情况。

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 类型的变量。

volatile 变量和 atomic 变量有什么不同?

《Java 后端面试经》多线程与并发编程篇_第3张图片
volatile 解决的是多线程可见性问题。

Atomic 解决的是多线程安全问题。

【参考】:volatile 解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题,但是如果多写,同样无法解决线程安全问题。如果是 count++ 操作,使用如下类实现:

AtomicInteger count = new AtomicInteger();
count.addAndGet(1);

线程间的通信方式有哪些?

  • 互斥量(Mutex):采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 synchronized 关键字和各种 lock 都是这种机制。
  • 信号量(Semphares) :它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。
  • 事件(Event) :wait/notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。

sleep 后进入什么状态,wait 后进入什么状态?

sleep 后进入 time waiting 超时等待状态,wait 后进入等待 waiting 状态。

wait 为什么是 Object 类下面的方法?

释放锁资源实际是通知对象内置的 monitor 对象进行释放,而只有所有对象都有内置的 monitor 对象才能实现任何对象的锁资源都可以释放。又因为所有类都继承自 Object,所以 wait()就成了 Object 方法,也就是通过 wait() 来通知对象内置的 monitor 对象释放,而且事实上因为这涉及对硬件底层的操作,所以 wait() 方法是 native 方法,底层是用 C 写的。

start 方法和 run 方法有什么区别?

  • start 方法属于 Thread 类,run 方法属于 Runnable 接口,所有实现了 Runnable 的接口的类都要重写 run 方法。
  • 当程序调用 start 方法时,会创建一个新线程, 然后执行 run 方法。但是如果我们直接调用 run 方法,则不会创建新的线程,run 方法将作为当前调用线程本身的常规方法调用执行,并且不会发生多线程。
  • start 方法不能多次调用,因为线程只能被创建一次,否则抛出 IllegalStateException 异常,而 run 方法可以进行多次调用,因为它只是一种正常的方法调用。

AQS

AQS(Abstract Queued Synchronizer) 是一个抽象队列同步器,通过维护一个 int 类型的状态标志位 state 和一个先进先出的(FIFO)的线程等待队列来实现一个多线程访问共享资源的同步框架。

private volatile int state;   //共享变量,使用 volatile 修饰保证线程可见性

AQS 的原理

  • 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。
  • 如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node) 来实现锁的分配。

《Java 后端面试经》多线程与并发编程篇_第4张图片

  • 当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点并将其加入到同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
  • 同步器包含两个节点类型的引用,一个指向头节点,另一个指向尾节点。首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。

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 定义了两种资源共享方式:独占式和共享式:

  • 独占式:同一时刻只有一个线程能获取锁,其他线程只能处于同步队列中等待,具体的 Java 实现有 ReentrantLock.
  • 共享式:多个线程可同时执行,具体的 Java 实现有 Semaphore 和 CountDownLatch.

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) 当前同步器是否在独占模式下被线程占用,一般该方法表示是否被当前线程所占

使用案例:

  • 以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。
  • 以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown() 一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state=0 ),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作。

Java 中的并发关键字

Java 中常见的并发关键字有 CountDownLatch、CylicBarrier、Semaphore 和 volatile.

锁的分类

公平锁与非公平锁

公平锁:按照线程在队列中的排队顺序,先到者先拿到锁。
非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的。

synchronized 是非公平锁, Lock 默认是非公平锁,可以设置为公平锁,公平锁会影响性能。

public ReentrantLock() {
    sync = new NonfairSync();
}
 
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

共享式与独占式锁

  • 独占式锁:同一时刻独占式只能有一个线程获取同步状态,文件写操作同一时刻只能由一个线程进行,其他线程都会被阻塞。
  • 共享式锁:在同一时刻可以有多个线程获取同步状态,读操作可以有多个线程同时进行。

悲观锁与乐观锁

  • 悲观锁:每次访问资源都会加锁,执行完同步代码释放锁,synchronized 和 ReentrantLock 属于悲观锁。
  • 乐观锁:不会锁定资源,所有的线程都能访问并修改同一个资源,如果没有冲突就修改成功并退出,否则就会继续循环尝试,乐观锁最常见的实现就是 CAS.

乐观锁一般来说有以下两种方式:

  1. 使用数据版本记录机制实现,这是乐观锁最常用的一种实现方式。给数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 version 字段来实现。当读取数据时,将version 字段的值一同读出,数据每更新一次,对此 version 值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的 version 值进行比对,如果数据库表当前版本号与第一次取出来的 version 值相等,则予以更新,否则认为是过期数据。
  2. 使用时间戳。数据库表增加一个字段,字段类型使用时间戳(timestamp),和上面的version 类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则 OK,否则就是版本冲突。

适用场景:

  • 悲观锁适合写操作多的场景。
  • 乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。

CAS

什么是 CAS?

CAS(Compare and swap) 比较和替换, CAS 的思想比较简单,主要涉及到三个值:当前内存值 V旧的内存值 A即将更新的内存值 B当且仅当旧的内存值 A 与当前内存值 V 相等时,将内存值 V 修改为更新值 B,否则什么都不做,整个比较并替换的操作是一个原子操作。

CAS 主要使用在一些需要上锁的场景充当乐观锁解决方案,一般在一些简单且要上锁的操作但又不想引入锁场景,这时候来使用 CAS 代替锁

CAS 带来的问题是什么?如何解决的?

ABA 问题循环时间长开销很大只能保证一个共享变量的原子操作

ABA:如果另一个线程修改 V 值假设原来是 A,先修改成 B,再修改回成 A. 当前线程的 CAS 操作无法分辨当前 V 值是否发生过变化。
例子:在你非常渴的情况下你发现一个盛满水的杯子,你一饮而尽。之后再给杯子里重新倒满水。然后你离开,当杯子的真正主人回来时看到杯子还是盛满水,他当然不知道是否被人喝完重新倒满。解决这个问题的方案的一个策略是每一次倒水假设有一个自动记录仪记录下,这样主人回来就可以分辨在她离开后是否发生过重新倒满的情况。这也是解决 ABA 问题目前采用的策略。

CAS 导致问题的解决方案

ABA 问题的解决方案

  1. 在对变量进行操作的时候给变量加一个版本号,每次对变量操作都将版本号加 1,常见在数据库的乐观锁中可见。

  2. Java 提供了相应的原子引用类 AtomicStampedReference,它通过包装 [E,Integer] 的元组来对对象标记版本戳 stamp,从而避免 ABA 问题。

循环时间长开销很大解决方案

  1. 代码层面破坏掉 for 循坏,设置合适的循环次数。
  2. 使用 JVM 能支持处理器提供的 pause 指令来提升效率,它可以延迟流水线执行指令,避免消耗过多 CPU 资源。

只能保证一个共享变量的原子操作,多个共享变量使用 CAS 无法保证原子性解决方案:

  1. 在 JDK1.5 中新增的 java.util.concurrent(JUC),就是建立在 CAS 之上的,一般来说 CAS 这种乐观锁适合读多写少的场景。

Java 中创建线程的方式有哪些?

  1. 写一个类继承 Thread 类,重写 run 方法
  2. 写一个类实现 Runnable 接口,重写 run 方法
  3. 写一个类实现 Callable 接口,重写 call 方法
  4. 使用 Executors 框架来创建线程池,阿里巴巴开发手册建议使用 ThreadPoolExecutor 创建线程池,目的是让开发人员明确线程池运行规则,规避资源耗尽的风险。

Thread 和 Runnable 区别?

Thread 和 Runnable 的实质是继承关系,没有可比性。无论使用 Runnable 还是 Thread,都会new Thread,然后执行 run 方法。用法上,如果有复杂的线程操作需求,那就选择继承Thread,如果只是简单的执行一个任务,那就实现 Runnable 接口。

Runnable 和 Callable 区别?

  1. Callable 接口方法是 call(),Runnable 接口的方法是 run().
  2. Callable 接口 call 方法有返回值,支持泛型,Runnable 接口 run 方法无返回值。
  3. Callable 接口 call() 方法允许抛出异常,而 Runnable 接口 run() 方法不能向上抛出异常,只能在 run 方法内部处理。
  4. 如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口 ,这样代码看起来会更加简洁。

线程池

什么是线程池?

通俗地讲就是,管理线程的池子。

定义:线程池就是事先创建若干个可执行的线程放入一个池(容器)中,需要的时候从池中获取线程不用自行创建,使用完毕不需要销毁线程而是放回池中,从而减少创建和销毁线程对象的开销

为什么要使用线程池?

线程池的优点:

  1. 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  2. 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行。
  3. 提高线程的可管理性:线程作为一种稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

线程执行流程?

  1. 线程池中的线程数是否大于设置的核心线程数,如果不大于,就创建一个核心线程来执行任务。
  2. 如果大于核心线程数,就会判断阻塞队列是否满了,如果没有满,则放入队列,等待线程空闲时执行任务。
  3. 当阻塞队列满的时候,则判断是否达到了线程池设置的最大线程数,如果没有达到,就创建新线程来执行任务。
  4. 如果已经达到了线程池设置的最大线程数,则执行指定的拒绝策略。

《Java 后端面试经》多线程与并发编程篇_第5张图片

线程池参数有哪些?

ThreadPoolExecutor 的通用构造函数:

public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, 
BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler);

线程池有 7 大核心参数,分别是:

  • corePoolSize:核心线程数定义了最小可以同时运行的线程数量。
  • maximumPoolSize:当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • keepAliveTime线程活动保持时间,当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime 才会被回收。
  • TimeUnit: 线程活动保持时间的单位。
  • runnableTaskQueue:用于保存等待执行的任务的阻塞队列。
  • threadFactory用于创建线程池中工作线程的线程工厂
  • rejectedExecutionHandler饱和策略,当阻塞队列满了并且工作线程大于线程池的最大线程数
    (maximumPoolSize)。

《Java 后端面试经》多线程与并发编程篇_第6张图片

线程池的大小怎么设置?

线程池的大小需要根据实际的业务场景去设置,可以大致分为 CPU 密集型和 IO 密集型。

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止某些原因导致的任务暂停(线程阻塞,如 I/O 操作,等待锁,线程 sleep)而带来的影响。一旦某个线程被阻塞,释放了 CPU 资源,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N): 系统会用大部分的时间来处理 I/O 操作,而线程等待 I/O 操作会被阻塞,释放 CPU 资源,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法:最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (I/O耗时/CPU耗时)),一般可设置为 2N.

线程池的类型有哪些?适用场景?

常见的线程池有 newCachedThreadPoolnewFixedThreadPoolnewScheduledThreadPoolnewSingleThreadExecutor. 这几个都是 ExecutorService (线程池)的实例。

四种线程池的特性如下图:

类型 特性
newCachedThreadPool 线程池的大小不固定,可灵活回收空闲线程,若无可回收,则新建线程
newFixedThreadPool 固定大小的线程池,当有新的任务提交,线程池中如果有空闲线程,则立即执行,否则新的任务会被缓存在一个任务队列中,等待线程池释放空闲线程。
newScheduledThreadPool 定时线程池,支持定时及周期性任务执行。
newSingleThreadExecutor 只创建一个线程,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。

适用场景分别如下:

  1. newCachedThreadPool:适用于并发执行大量短期的小任务。newCachedThreadPool 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM.
  2. newFixedThreadPool:适用于处理 CPU 密集型的任务,确保 CPU 在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。需要注意的是,newFixedThreadPool 不会拒绝任务,在任务比较多的时候会导致 OOM.
  3. newScheduledThreadPool:适用于周期性执行任务的场景,需要限制线程数量的场景。
  4. newSingleThreadExecutor:适用于串行执行任务的场景,一个任务一个任务地执行。允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM.

线程池四种拒绝策略?

当线程池的任务缓存队列已满并且线程池中的线程数目达到 maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:

  1. ThreadPoolExecutor.AbortPolicy

    线程池的默认拒绝策略为 AbortPolicy,即丢弃任务并抛出 RejectedExecutionException 异常(即后面提交的请求不会放入队列也不会直接消费并抛出异常)。

  2. ThreadPoolExecutor.DiscardPolicy

    丢弃任务,但是不抛出异常。如果线程队列已满,则后续提交的任务都会被丢弃,且是静默丢弃(也不会抛出任何异常,任务直接就丢弃了)。

  3. ThreadPoolExecutor.DiscardOldestPolicy

    丢弃队列最前面的任务,然后重新提交被拒绝的任务(丢弃掉了队列最前的任务,并不抛出异常,直接丢弃了)。

  4. ThreadPoolExecutor.CallerRunsPolicy

    由调用线程处理该任务(不会丢弃任务,最后所有的任务都执行了,并不会抛出异常)

线程池中线程复用原理

  • 线程池将线程和任务进行解耦,线程是线程,任务是任务,摆脱了之前通过 Thread 创建线程时的一个线程必须对应一个任务的限制。
  • 在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对 Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个 ”循环任务“。
  • 在这个 ”循环任务“ 中不停检查是否有任务需要被执行,如果有则直接执行,也就是调用任务中的 run 方法,将 run 方法当成一个普通的方法执行,通过这种方式只使用固定的线程就将所有任务的 run 方法串联起来。

线程池中阻塞队列的作用?为什么是先添加队列而不是先创建最大线程?

1、一般的队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前的任务了,阻塞队列通过阻塞可以保留住当前想要继续入队的任务

阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入 wait 状态,释放 CPU 资源。

阻塞队列自带阻塞和唤醒功能,不需要额外处理,无任务执行时,线程池利用阻塞队列的 take 方法挂起,从而维持核心线程的存活、不至于一直占用 CPU 资源。

2、在创建新线程的时候,是要获取全局锁的,这个时候其他线程就得阻塞,影响了整体效率。

就好比一个企业里面有10个(core)正式工的名额,最多招10个正式工,要是任务超过正式工人数(task > core)的情况下,工厂领导(线程池)不是首先扩招工人,还是这10人,但是任务可以稍微积压一下,即先放到队列去(代价低)。10个正式工慢慢干,迟早会干完的,要是任务还在继续增加,超过正式工的加班忍耐极限了(队列满了),就的招外包帮忙了(注意是临时工)要是正式工加上外包还是不能完成任务,那新来的任务就会被领导拒绝了(线程池的拒绝策略)。

Java中常见的阻塞队列有哪些?

  • ArrayBlockingQueue:基于常量数组final Object[] items实现,需要指定初始容量大小,插入和删除用的是同一把锁ReentrantLock lock.
  • LinkedBlockingQueue :基于链表实现,不指定容量大小的话,作为一个无界队列默认容量大小为 Integer.MAX_VALUE ,有可能会造成 OOM 问题,插入和删除使用不同的锁ReentrantLock putLockReentrantLock takeLock,并发处理效率高。
  • SynchronousQueue 是一个不存储任何元素的阻塞队列,每一个 put 操作必须等待 take 操作,否则不能添加元素,同时它也支持公平锁和非公平锁。
  • PriorityBlockingQueue 是一个支持优先级排序的无界阻塞队列,可以通过自定义实现 compareTo() 方法来指定元素的排序规则,或者通过构造器参数 Comparator 来指定排序规则。但是需要注意插入队列的对象必须是可比较大小的,也就是 Comparable 的,否则会抛出 ClassCastException 异常。
  • DelayQueue 是一个实现 PriorityBlockingQueue 的延迟获取的无界队列。具有 “延迟” 的功能。

ThreaLocal 知道吗?

Java 中每一个线程都有自己的专属本地变量, JDK 中提供的 ThreadLocal 类,ThreadLocal 类主要解决的就是让每个线程绑定自己的值,可以将 ThreadLocal 类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。

使用场景:
1、在进行对象跨层传递的时候,使用 ThreadLocal 可以避免多次传递,打破层次间的约束。
2、线程间数据隔离。
3、进行事务操作,用于存储线程事务信息。
4、数据库连接,Session 会话管理。

Spring 框架在事务开始时会给当前线程绑定一个 JDBC Connection,在整个事务过程都是使用该线程绑定的 connection 来执行数据库操作,实现了事务的隔离性。Spring 框架里面就是用的 ThreadLocal 来实现中各种隔离。

《Java 后端面试经》多线程与并发编程篇_第7张图片

  1. ThreadLocal 是 Java 中所提供的线程本地存储机制,可以利用该机制将数据存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据。
  2. ThreadLocal 底层是通过 ThreadLocalMap 来实现的,每个 Thread 对象(注意不是ThreadLocal 对象)中都存在一个 ThreadLocalMap,Map 的 key 为 ThreadLocal 对象,Map 的 value 为需要缓存的值。
  3. ThreadLocal 经典的应用场景就是连接管理(一个线程持有一个连接,该连接对象可以在不同的方法之间进行线程传递,线程之间不共享同一个连接)。

ThreadLocal 可能会带来什么问题?

  • 每个 Thread 都有⼀个 ThreadLocalMap 的内部属性,map 的 key 是 ThreadLocal,定义为弱引用,value 是强引用类型。
  • GC 的时候会自动回收 key,而 value 的回收取决于 Thread 对象的生命周期。一般会通过线程池的方式复用 Thread 对象节省资源,这也就导致了 Thread 对象的生命周期比较长。
  • 这样便一直存在一条强引用链的关系: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 适用场景:每个线程需要有自己单独的实例,且需要在多个方法共享实例,即同时满足实例在线程间的隔离与方法间的共享,这种情况适合使用 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 引用队列一起使用。

说说你对守护线程的理解

  • 守护线程是为所有的非守护线程提供服务的线程,任何一个守护线程都是整个 JVM 中所有非守护线程的保姆。
  • 守护线程类似于整个进程的一个默默无闻的小喽啰,它的生死无关紧要,它依赖整个进程而运行。如果其他线程结束了,没有要执行的了,程序就结束了,并且直接将守护线程中断。、
  • 由于守护线程的终止是自身无法控制的,因此千万不要把 I/O、File 等重要的逻辑分配给它,因为它不靠谱。

守护线程的作用是什么?
举例:GC 垃圾回收线程就是一个经典的守护线程,当我们的程序中不再有任何运行 Thread,程序就不会再产生垃圾,垃圾回收器也就是无事可做,所以当垃圾回收线程是 JVM 上仅剩的线程时,垃圾回收线程会自动离开,它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。

应用场景:(1)来为其他线程提供服务支持的情况(2)或者在任何情况下,程序结束时,这个线程必须正常且立刻关闭,就可以作为守护线程来使用;反之,如果一个正在执行某个操作的线程必须要正确地关闭掉否则就会出现不好的后果的话,那么这个线程就不能是守护线程,而是用户线程。通常都是些关键的事物,比如说,数据库录入或者更新,这些操作都是不能中断的。

thread.setDaemon(true) 必须在 thread.start() 之前设置,否则会出 IllegalThreadStateException 异常,你不能把正在运行的常规线程设置为守护线程。在 Daemon 线程中产生的新线程也是 Daemon 的。

守护线程不能用于去访问固有资源,比如读写操作或者计算逻辑,因为它会在任何时候甚至在一个操作的中间发生中断。

Java 自带的多线程框架,比如 ExecutorService,会将守护线程转换为用户线程,所以如果要使用后台线程就不能用 java 的线程池。

你可能感兴趣的:(Java,#,Java,后端面试经,java,后端,并发编程)