
01 Java并发编程 笔记
Java并发编程
并发编程基础

为什么要使用并发编程?
答:
- 充分利用多核CPU的计算能力
- 方便进行业务的拆分,提升应用性能
并发编程有什么缺点?
答:
- 并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度
- 但是并发编程不能总是提高程序运行速度(例如:如果是单核CPU,并且线程操作都是消耗CPU的,那太多的线程没有什么意义,并不会加快速度,并且线程的切换会带来开销,整体速度变慢)
- 同时并发编程会遇到很多问题,比如:内存泄漏(ThreadLocal没有remove清理相关对象)、上下文切换、线程安全、死锁等问题
并发编程三个必要因素是什么?
答:
- 原子性:要么全部执行成功要么执行失败
- 可见性:一个线程对共享变量的修改,另一个线程能过立刻看的(synchronized、volatile)
- 有序性:程序执行顺序按照代码的先后顺序执行(处理器可能会对指令重排序)
并发出问题的根源是什么?
答:出现线程安全问题的原因一般就是三个原因:
- 线程切换 带来的 原子性 问题
- 解决办法:使用多线程之间的同步(
synchronized)或者锁(lock)
- 解决办法:使用多线程之间的同步(
- 缓存 导致的 可见性 问题(比如说:A线程对主线程的修改并未及时从本地缓存同步到主线程,这就导致B线程拿到的数据依旧是旧数据)
- 解决办法:使用
synchronized、Lock等同步机制,或者volatile关键字、final关键字来保证修改对所有线程都是可见的
- 解决办法:使用
- 编程优化 带来的 有序性 问题(比如说:编译器重排会导致预期执行顺序被打破,导致不可预期的错误)
- 解决办法:使用
synchronized、Lock等同步机制,或者volatile关键字来禁止重排,也可以使用Happens-Before规则来解决有序性问题
- 解决办法:使用
并行和并发的区别?
答:
- 并发:多个任务在同一个CPU核上,按照细分的时间片轮流交替进行,从逻辑上看那些任务是同时执行
- 并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的 “同时进行”
- 串行:有n个任务,由一个线程按顺序执行,由于任务、方法都在一个线程执行,所以不会存在线程不安全的情况,也就输不存在临界区的问题
Java怎么进行并发控制?
答:
并发控制中的锁一般有两种,悲观锁 和 乐观锁,一般来说悲观锁是基于 Monitor 实现的,这在Java中分别对应 synchronized 关键字和 AQS。乐观锁是基于 CAS + 自旋 来实现的,Java中用的比较多的是 并发原子类。
乐观锁和悲观锁的理解及如何实现,有哪些实现方式?
答:
- 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁
- 传统的关系型数据库里边就用到了很多这种锁机制,比如:行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如:Java里面的同步原语 synchronized 关键字的实现也是悲观锁
- 乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制
- 乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write condition 机制,其实都是提供的乐观锁。在 Java中
java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的
- 乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write condition 机制,其实都是提供的乐观锁。在 Java中
死锁与活锁的区别,死锁与饥饿的区别?
答:
- 死锁:是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去
- 活锁:任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。
- 活锁和死锁的区别在于:处于活锁的实体是在不断的改变状态,这就是所谓的 “活”,而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能
- 饥饿:一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态
- Java 中导致饥饿的原因:
- 高优先级线程吞噬所有的低优先级线程的 CPU 时间
- 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问
- 线程在等待一个本身也处于永久等待完成的对象(比如:调用这个对象的 wait 方法),因为其他线程总是被持续地获得唤醒
- Java 中导致饥饿的原因:
JMM
什么是Java内存模型(JMM)?
答:JMM决定一个线程对共享变量的写入时,能对另一个线程可见
- 从抽象角度来看,JMM定义了线程和主内存之间的抽象关系,即:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。当多个线程同时访问一个数据的时候,可能本地内存没有及时刷新到主内存,所以就会发生线程安全的问题
- 本地内存是JMM的一个抽象概念,并不是真正存在的,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化
- 从下图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
- 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去
- 然后,线程B到主内存中去读取线程A之前已更新过的共享变量

什么是 Happens-Before 原则?
答:
- happens-before 字面意思就是先行发生,可以理解为 A happens before B,就是 A 发生在 B 之后
- happens-before(HB)是在 JMM 中的一个很重要的规则,即:一个操作的结果对于另一个操作是可见的,用来指定两个操作之间的执行顺序
synchronized关键字
说说自己是怎么使用synchronized关键字,synchronized关键字最主要的三个使用方式?
答:
- 修饰实例方法:调用某对象的该方法前 获取该对象实例的锁
- 修饰静态方法:调用某对象的该方法前 获取该类的锁
- 两个线程分别执行同一个对象synchronized修饰的实例方法和静态方法时不会发生互斥,因为锁的资源不同,一个锁了对象实例,一个锁了类
- 修饰代码块:对于同步方法块,锁是
synchonized括号里配置的对象synchronized(对象的引用)锁的是对象实例,synchronized(类.class)锁的是类- 尽量不要使用
synchronized(String a),因为 JVM 中,字符串常量池具有缓存功能 synchronized不能修饰构造方法,也没必要修饰,构造方法本身就是线程安全的
说一下synchronized底层实现原理?
答:
首先,synchronized的底层语义是通过一个 monitor(监视器锁)的对象完成的,每个对象都有一个 monitor(监视器锁)。每个 synchronized 修饰过的代码当它的 monitor 被占用时就会处于锁定状态,当同步代码执行完成,释放 monitor 后,其他线程重新尝试获取 monitor 的所有权
具体过程:
- 如果
monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者 - 如果线程已经占有该
monitor,只是重新进入,则进入monitor的进入数加1 - 如果其他线程已经占用了
monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权

synchronized可重入的原理?
答:
重入锁 指:一个线程获取到该锁之后,该线程可以继续获得该锁
- 底层原理维护一个计数器,当线程获取该锁时,计数器加1,再次获得该锁时继续加1,对同一个对象反复加锁只会导致monitor计数器加1,释放该锁时,计数器减1,当计数器值为0时,表明该锁未被任何线程所持有,其他线程可以竞争获取锁
多线程中synchronized锁升级的原理是什么?
答:
synchronized 锁升级的原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入时会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则 升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时会把锁 从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级
-
无锁状态:对象刚创建时,没有任何线程来竞争锁,此时为无锁状态
-
偏向锁:当第一个线程尝试获取锁时,JVM会将锁升级为偏向锁,并将该线程的 ID 记录到对象的 Mark Word 中(对象头通常由两部分组成:Mark Word 和 类型指针(Class Pointer))。这样,只要该线程再次尝试获取锁,JVM就会检查 Mark Word 中的 线程 ID 是否与 当前线程 ID 相同,如果相同则可以直接获取锁,无需进行任何同步操作。这种策略对于只有一个线程频繁访问的场景非常高效

-
轻量级锁:如果有多个线程交替访问同一个对象,偏向锁会失效。此时,JVM会将锁升级为轻量级锁,轻量级锁使用了一种称为 “自旋等待” 的策略,即:当线程尝试获取锁但失败时,就会进入一个循环,不断再次尝试获取锁,而不是立刻阻塞。这种策略对于短时间的锁竞争非常有效,可以避免线程频繁地阻塞和唤醒
-
重量级锁:如果自旋等待持续较长时间仍未获取锁,JVM就会将锁升级为重量级锁。这时,线程的竞争就会更加激烈,JVM就会 使用操作系统的互斥量(如互斥锁或者条件变量)来实现同步。线程的阻塞和唤醒会涉及到操作系统的内核态和用户态切换,因此性能开销较大
当一个线程进入一个对象的 synchronized 方法 A 之后,其它线程是否可进入此对象的 synchronized 方法 B?
答:不能。其它线程只能访问该对象的非同步方法,同步方法则不能进入。因为非静态方法上的synchronized 修饰符要求执行方法时要获得对象的锁,如果已经进入 A 方法说明对象锁已经被取走,那么试图进入 B 方法的线程就只能在等锁池(注意不是等待池哦)中等待对象的锁。
volatile关键字
volatile关键字的作用?
答:
- 首先对于可见性,Java提供了
volatile关键字来 保证可见性 和 禁止指令重排volatile确保一个线程的修改能对其他线程是可见的。当一个共享变量被volatile修饰时,它会保证修改的值立刻被更新到主内存中,当有其他线程需要读取时,它会去内存中读取新值
- 从实践角度而言,
volatile的一个重要作用就是和 CAS 结合,保证了原子性,详细的可以参见java.util.concurrent.atomic包下的类,比如:AtomicInteger volatile常用于 多线程环境下的单次操作(单次读或者单次写)
volatile变量和atomic原子类变量有什么不同?
答:
-
volatile变量 可以确保可见性,但不能保证原子性-
例如:用 volatile 修饰 count 变量,那么 count++ 操作就不是原子性
1
读取count -> count++ -> 赋值count,不能保证其原子性
-
-
而
AtomicInteger类提供的atomic方法可以让这种操作具有原子性- 例如:
getAndIncrement()方法会原子性的进行增量操作把当前值加1,其他数据类型和引言变量也可以进行相似操作
- 例如:
volatile 能使得一个非原子操作变成原子操作吗?
答:
- 关键字
volatile的主要作用:是使变量在多个线程间可见,但无法保证原子性,对于多个线程访问同一个实例变量需要加锁进行同步 - 虽然
volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性- 原因:double、long本身有64位宽,对于Java虚拟机,目前是将32位的读写作为原子操作,所以double和long的读写需要分为两次,每次读写32位。volatile对于double、long的读写原子性仅指单次读或单次写,如果一个操作同时具有读写,则不能保证整体的原子性,如i++;
synchronized 和 volatile 的区别是什么?
答:
synchronized表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程volatile表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性,禁止指令重排序- 区别:
volatile是变量修饰符;synchronized可以修饰方法,代码块volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性。volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用synchronized关键字的场景还是更多一些
final关键字
final关键字有哪些用法?
答:
- 修饰类:当某个类的整体定义为 final 时,就表明了你不打算继承该类,而且不允许别人也这么做。即这个类不能有子类的,不可以继承
- 修饰方法:父类的 final 方法是不能被子类重写
- 修饰参数:Java允许在参数列表中以声明的方式将参数指明为 final,这意味着无法在方法中更改参数引用所指向的对象。这个特性主要用来向匿名内部类传递数据
- 修饰变量:变量的值无法更改
所有的final修饰的字段都是编译器常量吗?
答:
- 不是
- 不是所有的final修饰的字段都是编译期常量,只有在被初始化后无法被更改
线程
线程和进程的区别?
答:
- 定义区别
- 线程:进程中的一个执行任务(控制单元),它负责在程序里独立执行
- 进程:一个在内存中运行的应用程序,每一个正在系统上运行的程序都是一个进程
- 一个进程至少有一个线程,一个进程可以运行多个线程,多个线程可以共享数据
- 根本区别
- 进程是 操作系统资源分配 的基本单位
- 线程是 处理器任务调度和执行 的基本单位
- 资源开销
- 每个进程都有独立的代码和数据空间(程序的上下文),程序之间的切换会有较大的开销
- 线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换开销小
- 包含关系
- 如果一个进程内含有多个线程,则执行过程不是一条线,而是多条线(线程)共同完成
- 线程是进程的一部分,所以线程也可以被称为轻权进程或者轻量级进程
- 内存分配
- 同一进程的线程共享本进程的地址空间和资源,而进程与进程之间的地址空间和资源是相互独立的
- 影响关系
- 一个进程崩溃后,在保护模型下不会对其他进程产生影响
- 一个线程崩溃后有可能导致整个进程都死掉
- 因此,多进程要比多线程更健壮
- 执行过程
- 每个独立的进程有程序的入口、顺序执行序列和程序出口
- 线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行
什么是上下文切换?
答:
- 多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是 为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换
- 简单来说,当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作
Linux相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少
守护线程和用户线程有什么区别?
答:
- 用户(User)线程:运行在 前台,执行具体的任务,如:程序的主线程、连接网络的子线程等都是用户线程
- 守护(Daemon)线程:运行在 后台,为其他前台线程服务。也可以说守护线程是 JVM 中非守护线程的“佣人”。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作
什么是死锁?
答:
- 死锁:是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程(线程)称为死锁进程(线程)
- 多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止
形成死锁的四个必要条件是什么?
答:
- 互斥条件:在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,就只能等待,直至占有资源的进程用完释放
- 请求并保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放
- 不可抢占条件:别人已经占有了某项资源,不能因为自己也需要该资源,就去把别人的资源抢过来
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系(比如:一个进程集合,A在等B,B在等C,C在等A)
如何避免线程死锁?
答:
- 避免一个线程同时获得多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
- 尝试使用定时锁,使用
lock.tryLock(timeout)来替代使用内部锁机制
Java线程有哪些状态?
答:
NEW:初始状态,线程被创建,但是还没有调用start()方法RUNNABLE:运行状态,Java线程将操作系统中的就绪和运行两种状态笼统地称为 “运行中”BLOCKED:阻塞状态,表示线程阻塞于锁WAITING:等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些等待动作(通知或中断)TIME_WAITING:超时等待状态,该状态不同于WAITING状态,它可以在指定的时间自行返回TERMINATED:终止状态,表示当前线程已经执行完毕

创建线程的几种方式?
答:
常用的三种方式:
-
实现
Runnable接口- 创建一个类并实现 Runnable 接口
- 在类中重写 run() 方法,定义线程执行的任务
- 创建这个类的实例,将该实例作为参数传递给 Thread 对象
- 使用 Thread 对象调用 start() 方法启动线程
1
2
3
4
5
6
7
8
9
10
11
12
13
14class MyRunnable implements Runnable {
public void run() {
System.out.println("Thread is running");
}
}
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
}
} -
实现
Callable接口,并结合Future实现- 实现 Callable 接口,并重写 call() 方法
- 使用 FutureTask 来封装 Callable 实例
- 创建一个 Thread,并传递 FutureTask 对象
- 启动线程,通过 FutureTask 的 get() 方法获取返回结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class MyCallable implements Callable<String> {
public String call() throws Exception {
return "Thread executed and returned result";
}
}
public class Main {
public static void main(String[] args) {
FutureTask<String> futureTask = new FutureTask<>(new MyCallable());
Thread thread = new Thread(futureTask);
thread.start();
try {
System.out.println(futureTask.get()); // 获取线程执行结果
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
} -
继承
Thread类- 继承
Thread类并重写run方法,这是最直接的一种方式。创建的线程类可以通过调用start()方法启动线程
1
2
3
4
5
6
7
8
9
10
11
12
13class MyThread extends Thread {
public void run() {
System.out.println("Thread is running");
}
}
public class Main {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
} - 继承
还有一种是通过线程池的方式创建线程:
1 | // 1. 创建3个核心线程数,并使用 |
对比:
- 继承
Thread类:简单直接,但不支持多继承 - 实现
Runnable接口:推荐使用的方式,支持多继承 - 使用
Callable和Future:适用于需要返回值或处理异常的场景
实现 Runnable 和 Callable 接口的类只能当作一个可以在线程中运行的任务,不是真正意义上的线程,最后还是需要通过 Thread 来调用,可以说任务是通过线程驱动从而执行的
说一下Runnable和Callable有什么区别?
答:
-
相同点:
- 都是接口
- 都可以编写多线程程序
- 都采用
Thread.start()启动线程
-
主要区别:
-
Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,是个泛型,和 Future、FutureTask 配合可以用来获取 异步执行 的结果
-
Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息
注:Callable 接口支持返回执行结果,需要调用
FutureTask.get()得到,此方法会阻塞主线程的继续往下执行,如果不调用不会阻塞
-
什么是Callable和Future?什么是FutureTask?
答:
- Callable 接口类似于 Runnable,从名字就可以看出来了,但是 Runnable 不会返回结果,并且无法抛出返回结果的异常,而 Callable 功能更强大一些,被线程执行后,可以返回值,这个返回值可以被 Future 拿到,也就是说,Future 可以拿到异步执行任务的返回值
- Future 接口表示异步任务,是一个可能还没有完成的异步任务的结果。所以说 Callable 用于产生结果,Future 用于获取结果。
- FutureTask 表示一个异步运算的任务。FutureTask 里面可以传入一个 Callable 的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。一个 FutureTask 对象可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是 Runnable 接口的实现类,所以 FutureTask 也可以放入线程池中
sleep()和 wait()有什么区别?
答:
- 相同点:两者都可以暂停线程的执行
- 类的不同:sleep() 是 Thread 线程类的静态方法,wait()是 Object 类的方法。
- 是否释放锁:sleep() 不释放锁;wait() 释放锁。
- 用途不同:wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行
- 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法;sleep() 方法执行完成后,线程会自动苏醒。或者可以使用
wait(long timeout)超时后线程会自动苏醒
为什么线程通信的方法 wait(), notify()和 notifyAll()被定义在 Object 类里?
答:
- 因为Java所有类的都继承了Object,Java想让任何对象都可以作为锁,并且 wait(),notify() 等方法用于等待对象的锁或者唤醒线程,在 Java 的线程类中并没有可供所有对象使用的锁,所以任意对象都可调用的方法一定定义在Object类中
- 有的人会说,既然是线程放弃对象锁,那也可以把 wait() 定义在 Thread 类里面啊,新定义的线程继承于 Thread 类,也不需要重新定义 wait() 方法的实现。然而,这样做有一个非常大的问题,一个线程完全可以持有很多锁,你一个线程放弃锁的时候,到底要放弃哪个锁?当然了,这种设计并不是不能实现,只是管理起来更加复杂
线程的 sleep()方法和 yield()方法有什么区别?
答:
sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield()方法只会给相同优先级或更高优先级的线程以运行的机会- 线程执行
sleep()方法后转入阻塞(超时等待状态),而执行yield()方法后转入就绪(ready)状态 sleep()方法声明抛出 InterruptedException,而yield()方法 没有声明任何异常sleep()方法比yield()方法(跟操作系统 CPU 调度相关)具有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行
如何停止一个正在运行的线程?
答:
在java中有以下3种方法可以终止正在运行的线程:
- 使用退出标志,使线程正常退出,也就是当 run 方法完成后线程终止
- 使用 stop 方法强行终止,但是不推荐这个方法,因为 stop 和 suspend 及 resume 一样都是过期作废的方法
- 使用 interrupt 方法中断线程
Java中interrupted和isInterrupted方法的区别?
答:
interrupt:用于中断线程,调用该方法的线程的状态位将被置为 “中断” 状态- 注:线程中断仅仅是设置线程的中断状态位,不会停止线程。需要用户自己去监视线程的状态并做处理。支持线程中断的方法(也就是线程中断后会抛出
interruptedException的方法)就是在监视线程的中断状态,一旦线程中断状态被置为 “中断状态”,就会抛出中断异常
- 注:线程中断仅仅是设置线程的中断状态位,不会停止线程。需要用户自己去监视线程的状态并做处理。支持线程中断的方法(也就是线程中断后会抛出
interrupted:是静态方法,查看当前中断信号是true还是false并且清除中断信号- 如果一个线程被中断了,第一次调用interrupted则返回true,第二次和后面的就返回false了
isinterrupted:可以返回当前中断信号是true还是false,与interrupted最大的区别是不会清除中断信号
什么是阻塞式方法?
答:阻塞式方法:是指程序会一直等待该方法完成期间不做其他事情,ServerSocket 的 accept() 方法就是一直等待客户端连接。这里的阻塞是指调用结果返回之前,当前线程会被挂起,直到得到结果之后才会返回。此外,还有异步和非阻塞式方法在任务完成前就返回
Java中怎样唤醒一个阻塞的线程?
答:
- 首先,
wait()、notify()方法是针对对象的,调用任意对象的wait()方法都将导致线程阻塞,阻塞的同时也将释放该对象的锁,相应地,调用任意对象的notify()方法则将随机解除该对象阻塞的线程,但它需要重新获取该对象的锁,直到获取成功才能往下执行 - 其次,wait、notify 方法必须在 synchronized 块或方法中被调用,并且要保证同步块或方法的锁对象与调用 wait、notify 方法的对象是同一个,如此一来在调用 wait 之前当前线程就已经成功获取某对象的锁,执行 wait 阻塞后当前线程就将之前获取的对象锁释放
notify()和 notifyAll()有什么区别?
答:
- 如果线程调用了对象的
wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁 notifyAll()会唤醒所有的线程,notify()只会唤醒一个线程notifyAll()调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行如果不成功则留在锁池等待锁被释放后再次参与竞争。而notify()只会唤醒一个线程,具体唤醒哪一个线程由虚拟机控制
Java是如何实现多线程之间的通讯与协作?
答:
- 可以通过 中断 和 共享变量 的方式实现线程间的通讯和协作
- 例如:最经典的 生产者-消费者 模型,这种互相通信的过程就是线程间的协作
- Java中线程通信协作的最常见方式:
- synchronized加锁的线程的 Object 类的
wait()/notify()/notifyAll() - ReentrantLock类加锁的线程的 Condition 类的
await()/signal()/signalAll()
- synchronized加锁的线程的 Object 类的
- 线程间直接的数据交换:
- 通过管道进行线程间通:字节流、字符流
线程之间如何通信及线程之间如何同步?
答:
- 通信:是指线程之间以如何来交换信息。一般线程之间的通信机制有两种:共享内存(wait/notify 属于Java提供的一种线程间通信的具体技术手段)和 消息传递。同步:是指程序中用于控制不同线程间操作发生相对顺序的机制
- Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行的,整个通信过程对程序员完全透明
- 在共享内存并发模型里,同步是显示进行的,必须显示指定某个方法或者某段代码需要在线程之间互斥执行
同步方法和同步块,哪个是更好的选择?
答:
- 同步块是更好的选择,因为它不会锁住整个对象(当然你也可以让它锁住整个对象)。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁
- 同步块更要符合开放调用的原则,只在需要锁住的代码块锁住相应的对象,这样从侧面来说也可以避免死锁
- 请知道一条原则:同步的范围越小越好
什么是线程同步和线程互斥,有哪几种实现方式?
答:
- 线程同步:当一个线程对共享的数据进行操作时,应使之成为一个”原子操作“,即:在没有完成相关操作之前,不允许其他线程打断它,否则,就会破坏数据的完整性,必然会得到错误的处理结果
- 线程互斥:是指对于共享的进程系统资源,在各单个线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步
- 线程间的同步方法大体可分为两类:用户模式 和 内核模式
- 顾名思义,内核模式:就是指利用系统内核对象的单一性来进行同步,使用时需要切换内核态与用户态,而用户模式:就是不需要切换到内核态,只在用户态完成操作
- 用户模式下的方法有:原子操作(例如:一个单一的全局变量),临界区
- 内核模式下的方法有:事件,信号量,互斥量
- 实现线程同步的方法:
- 同步代码方法:
sychronized关键字修饰的方法 - 同步代码块:
sychronized关键字修饰的代码块 - 使用特殊变量域
volatile实现线程同步:volatile关键字为域变量的访问提供了一种免锁机制 - 使用重入锁实现线程同步:
reentrantlock类是可重入、互斥、实现了lock接口的锁他与sychronized方法具有相同的基本行为和语义
- 同步代码方法:
在监视器(Monitor)内部,是如何做线程同步的?程序应该做哪种级别的同步?
答:
- 在 Java 虚拟机中,监视器 和 锁 在Java虚拟机中是一块使用的。监视器监视一块同步代码块,确保一次只有一个线程执行同步代码块。每一个监视器都和一个对象引用相关联。线程在获取锁之前不允许执行同步代码
- 一旦方法或者代码块被
synchronized修饰,那么这个部分就放入了监视器的监视区域,确保一次只能有一个线程执行该部分的代码,线程在获取锁之前不允许执行该部分的代码 - 另外 Java 还提供了 显式监视器(
Lock)和 隐式监视器(synchronized)两种锁方案
如果你提交任务时,核心线程数已达到配置的数量,这时会发生什么?
答:
有两种可能:
- 如果使用的是无界队列
LinkedBlockingQueue,也就是无界队列的话,没关系,继续添加任务到阻塞队列中等待执行,因为LinkedBlockingQueue可以近乎认为是一个无穷大的队列,可以无限存放任务 - 如果使用的是有界队列,比如:
ArrayBlockingQueue,任务首先会被添加到ArrayBlockingQueue中,ArrayBlockingQueue满了,会根据maximumPoolSize的值增加线程数量,如果增加了线程数量还是处理不过来,ArrayBlockingQueue继续满,那么则会使用拒绝策略RejectedExecutionHandler处理满了的任务,默认是AbortPolicy
在Java中怎么保证多线程的安全?
答:
-
方法一:使用安全类,比如:
java.util.concurrent下的类,使用原子类AtomicInteger -
方法二:使用自动锁
synchronized -
方法三:使用手动锁
Lock,手动锁 Java 示例代码如下:1
2
3
4
5
6
7
8
9
10
11
12Lock lock = new ReentrantLock();
lock.lock();
try {
System.out.println("获得锁");
}
catch (Exception e) {
// ToDo: handle exception
}
finally {
System.out.println("释放锁");
lock.unlock();
}
你对线程优先级的理解是什么?
答:
- 每一个线程都是有优先级的,一般来说,高优先级的线程在运行时会具有优先权,但这依赖于线程调度的实现,这个实现是和操作系统相关的(OS dependent)。我们可以定义线程的优先级,但是这并不能保证高优先级的线程会在低优先级的线程前执行。线程优先级是一个 int 变量(从 1-10),1 代表最低优先级,10 代表最高优先级
- Java 的线程优先级调度会委托给操作系统去处理,所以与具体的操作系统优先级有关,如非特别需要,一般无需设置线程优先级
- 当然,如果你真的想设置优先级可以通过
setPriority()方法设置,但是设置了不一定会改变,这个是不准确的
线程类的构造方法、静态块是被哪个线程调用的?
答:
- 线程类的构造方法、静态块是被 new 这个线程类所在的线程所调用的,而 run 方法里面的代码才是被线程自身所调用的
- 举个例子,假设 Thread2 中 new 了Thread1,main 函数中 new 了 Thread2,那么:
- Thread2 的构造方法、静态块是 main 线程调用的,Thread2 的 run() 方法是 Thread2 自己调用的
- Thread1 的构造方法、静态块是 Thread2 调用的,Thread1 的 run() 方法是 Thread1 自己调用的
Java 中怎么获取一份线程 dump 文件?你如何在 Java 中获取线程堆栈?
答:
- dump文件:是进程的内存镜像,可以把程序的执行状态通过调试器保存到 dump 文件中
- 在 Linux 下,可以通过命令
jstack -| PID(Java 进程的进程 ID)来获取 Java应用的 dump 文件 - 在 Windows 下,可以按下
Ctrl + Break来获取。这样 JVM 就会将线程的 dump 文件打印到标准输出或错误文件中,它可能打印在控制台或者日志文件中,具体位置依赖应用的配置
一个线程运行时发生异常会怎样?
答:
- 如果异常没有被捕获该线程将会停止执行
Thread.UncaughtExceptionHandler:是用于处理未捕获异常造成线程突然中断情况的一个内嵌接口,当一个未捕获异常将造成线程中断的时候,JVM 会使用Thread.getUncaughtExceptionHandler()来查询线程的UncaughtExceptionHandler,并将线程和异常作为参数传递给 handler 的UncaughtException()方法进行处理
Java 线程数过多会造成什么异常?
答:
- 线程的生命周期开销非常高
- 消耗过多的 CPU
- 资源如果可运行的线程数量多于可用处理器的数量,那么有线程将会被闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量的线程在竞争 CPU 资源时还将产生其他性能的开销
- 降低 JVM 稳定性
- 在可创建线程的数量上存在一个限制,这个限制值将随着平台的不同而不同,并且承受着多个因素制约,包括:JVM 的启动参数、Thread 构造函数中请求栈的大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么可能抛出
OutOfMemoryError异常
多线程的常用方法?
答:

线程B 怎么知道线程 A 修改了变量?
答:
volatile修饰变量synchronized修饰修改变量的方法wait/notify
ThreadLocal
什么是ThreadLocal?说一说你对ThreadLocal的理解
答:
ThreadLocal是线程变量,意思是ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量- Thread类有两个变量:
threadLocals和inheritableThreadLocals - 这两个变量默认为null,只有当该线程调用了
ThreadLocal类的get/set方法时才会创建他们,而调用ThreadLocal的get/set实际上是调用ThreadLocalMap的get/set ThreadLocalMap可理解成给ThreadLocal定制化的HashMap- 最终的变量放在了线程的
ThreadLocalMap中,而不是ThreadLocal中,ThreadLocal只是对其进行封装,向其传递变量值 - 用一个场景分析
ThreadLocal的get/set流程:- 首先在所有线程外部创建一个共享的
ThreadLocal对象,记为TL1,在一个线程中调用TL1.get()时首先获取到当前线程对象,记为t - 然后判断
t.threadLocals是否为 null,如果为 null,就在 t 中创建一个新的ThreadLocalMap对象赋值给t.threadLocals,并将<TL1,null>插入其中,最后 get 方法返回 null,如果不为 null,则尝试获取threadLocals中 TL1 所在的键值对,如果该键值对为 null,则向 threadLocals 中通过 set 方法插入<TL1,null>,最后返回 null,如果键值对不为 null,则返回键值对中的值 - 调用 set 方法时,流程和 get 基本一致,只是从读变成了写
- 这样就可以实现不同线程访问同一个 ThreadLocal(TL1) 能拿到各自向其中存放的值
- 首先在所有线程外部创建一个共享的

为什么ThreadLocal会造成内存泄漏?如何解决
答:
ThreadLocalMap中的key为ThreadLocal的弱引用,value为强引用- 如果
ThreadLocal对象没有被外部强引用,垃圾回收时key会被清理掉,但value不会。这时key=null,而 value 不为 null,如果不做处理,value 将永远不会被GC掉,就有可能造成内存泄漏 - 因为 ThreadLocal 本身不存储对象,是调用 Thread 中的 ThreadLocalMaps 来保存,而 Thread 强引用 ThreadLocalMaps 对象,如果 Thread 对象生命周期过长,不能及时被回收,就会导致 ThreadLocalMaps 对象里 Entry 的 value 存在内存泄露的可能
- ThreadLocal 自身会在调用
get/set/remove时清理掉 key 为 null 的 entry,如果编写的 run 方法里不会再使用 ThreadLocal 对象,最好手动调用 remove
为什么用ThreadLocal不用线程成员变量?
答:
- 如果用成员变量,那么成员变量必须在 Thread 里,不能在 Runnable 里,因为一个 Runnable 对象可以被多个 Thread 执行
- 而如果在 Thread 中添加成员变量,就要加强 Thread 和 Runnable 的耦合,将 Thread 作为 Runnable 的成员变量,并在 Runnable 中调用具体的 Thread 变量,如果执行 Runnable 的 Thread 可能有很多子类,不同子类有不同的成员变量,则要在 run 方法中进行复杂处理,扩展性较低,不利于维护。而 ThreadLocal 就是将成员变量统一为一个 Map 放到线程里
Lock
Lock接口和synchronized同步,对比它有什么优势?
答:
- Lock 接口对比同步方法和同步块提供了更多扩展性的锁操作,他们允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象
- 优势:
- 可以使锁更公平
- 可以使线程在等待锁的时候响应中断
- 可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间
- 可以在不同的范围,以不同的顺序获取和释放锁
- 整体来说,Lock 是 synchronized 的拓展版,Lock 提供了无条件、可轮询的(
tryLock方法)、定时的(tryLock带参方法)、可中断的(lockInterruptibly)、可多条件队列的(newCondition方法)锁操作 - 另外,Lock实现类基本都是支持非公平锁(默认)和公平锁,synchronized只支持非公平锁
synchronized 和 Lock 有什么区别?
答:
- 首先 synchronized 是Java内置关键字,在JVM层面,Lock 是个接口
- synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁
- synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不容易造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock() 去释放锁就会造成死锁。
- 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法做到
怎么理解Lock与AQS的关系?
答:
- Lock 锁是面向锁的使用者,它定义了使用者与锁的交互接口,隐藏了实现细节
- AQS 是面向锁的实现者,它简化了锁的实现方式,屏蔽了同步状态的管理,线程的排队,等待与唤醒等底层操作
- 锁和同步器很好的隔离了使用者和实现者所需关注的领域
AQS
什么是AQS?
答:
- AQS 是多线程同步器,它是
J.U.C包中多个组件的底层实现,如:Lock、CountDownLatch、Semaphore等都用到了 AQS - 从本质上来说,AQS 提供了两种锁机制,分别是 排它锁 和 共享锁
- 排它锁:就是存在多线程竞争同一共享资源时,同一时刻只允许一个线程访问该共享资源,也就多个线程中只能有一个线程获得锁资源,比如:Lock 中的 ReentrantLock 重入锁实现就是用到了AQS 中的排它锁功能
- 共享锁:也称为读锁,就是在同一时刻允许多个线程同时获得锁资源,比如:
CountDownLatch和Semaphore都是用到了 AQS 中的共享锁功能
AQS是怎么实现同步管理的?底层数据结构?
答:
- AQS 主要依赖一个 双向链表 和一个 volatile类型的整数state 来实现同步控制
- 该整数 state 用来表示同步状态,一般情况下,
state=0表示没有线程占用同步资源,state>0表示有线程占用同步资源,state>1表示同步资源已经被争用了多次 - 比如:ReentrantLock 可以允许一个线程多次获得锁,每次 state 值加 1
- 该整数 state 用来表示同步状态,一般情况下,
- AQS 实现同步的关键:它提供了一个基于 FIFO 队列的同步队列,通过将等待线程加入同步队列中,然后在释放同步状态的时候,从同步队列中唤醒等待线程,从而实现了同步机制
- AQS有两种实现方式:**独占式(Exclusive)**和 共享式(Shared)
- 独占式:是指一个线程可以占用同步资源,比如:
ReentrantLock - 共享式:是指多个线程可以同时占用同步资源,比如:
CountDownLatch - 在AQS中,这两种方式的实现是基本相同的,区别在于获取和释放同步状态的方式不同
- 独占式:是指一个线程可以占用同步资源,比如:

AQS有哪些核心的方法?
答:
一共三类方法:
- 第一类:3个访问和修改同步状态的方法
- 第二类:5个可重写方法
- 第三类:9个模板方法
三类方法的关系:实现一个同步组件时,使用者继承 AbstractQueuedSynchronizer 并重写5个指定的方法(第二类),重写同步器指定的方法时,需要使用同步器提供的3个方法来访问或者修改同步状态(第一类)。最后将AQS组合在自定义同步组件的实现中,并调用其9个模板方法(第三类)和5个重写过的方法来实现。另外模板方法会调用使用者重写的方法
ReentrantLock
什么是可重入,什么是可重入锁?
答:
- 可重入锁:自己可以再次获取自己的内部锁,比如:一个线程获取了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的。如果是不可重入锁的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0才能释放锁
公平锁和非公平锁的区别?
答:
- 公平锁:锁被释放之后,先申请的线程先得到锁,性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁
- 非公平锁:锁被释放之后,后申请的线程可能会先获得锁,是随机或者按照其他优先级排序的,性能更好,但可能会导致某些线程永远无法获取到锁
为什么非公平锁比公平锁性能更好?
答:
- 公平锁的执行流程:获取锁时,先将线程自己添加到同步队列的队尾并休眠,当某线程用完锁之后,会去唤醒同步队列中队首的线程尝试获取锁,锁的使用顺序就是队列中的先后顺序
- 在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态恢复成运行状态,但线程每次休眠和恢复都需要从用户态切换到内核态,而这个状态的转换比较慢,所以公平锁的执行速度会比较慢
- 非公平锁的执行流程:当线程获取锁时,会先通过CAS尝试获取锁,如果获取成功就直接拥有锁,如果获取失败就会进入同步队列,等待下次尝试获取锁
- 好处:获取锁不用遵循先到先得的规则,从而避免了线程休眠和恢复的操作,这样就加速了程序的执行效率
ReentrantLock是如何实现公平锁的?非公平锁的?
答:
- ReentrantLock类内部总共存在
Sync、NonfairSync、FairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类 继承自AbstractQueuedSynchronizer抽象类 - ReentrantLock的默认实现是非公平锁。公平锁对比非公平锁的实现差异主要体现在tryAcquire方法(获取锁)
- 非公平锁(
NonfairSync)的tryAcquire实现直接调用了父类Sync中的nonfairTryAcquire - 公平锁
tryAcquire的唯一不同点为判断条件多了hasQueuedPredecessors()方法,即:加入了同步队列中当前结点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁
- 非公平锁(
synchronized 和 ReentrantLock 区别是什么?
答:
synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁,二者的主要区别有以下5个:
- 用法不同:
synchronized可以用来修饰普通方法、静态方法和代码块,而ReentrantLock只能用于代码块 - 获取锁和释放锁的机制不同:
synchronized是自动加锁和释放锁的,而ReentrantLock需要手动加锁和释放锁 - 锁类型不同:
synchronized是非公平锁,而ReentrantLock默认为非公平锁,也可以手动指定为公平锁 - 响应中断不同:
ReentrantLock可以响应中断,解决死锁的问题,而synchronized不能响应中断 - 底层实现不同:
synchronized是 JVM 层面通过监视器实现的,而ReentrantLock是基于 AQS 实现的
ReentrantReadWriteLock
ReentrantReadWriteLock是什么?
答:
ReentrantReadWriteLock实现了ReadWriteLock,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全ReentrantReadWriteLock其实是两把锁,一个是WriteLock(写锁),一个是ReadLock(读锁)- 读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,写锁最多只能同时被一个线程持有
共享锁和独占锁有什么区别?
答:
- 共享锁:一把锁可以被多个线程同时获得
- 独占锁:一把锁只能被一个线程获得
线程持有读锁还能获取写锁吗?
答:
- 在线程持有读锁的情况下:该线程不能取得写锁
- 因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有
- 在线程持有写锁的情况下:该线程可以继续获取读锁
- 因为获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况下才会获取失败
什么是锁的升降级?ReentrantReadWriteLock为什么不支持锁升级?
答:
- 写锁可以降级为读锁,但是读锁不能升级为写锁
- 因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于独占锁,影响性能
- 还可能发生死锁的问题
- 例如:两个线程的读锁都想升级写锁,则需要对方都释放自己的锁,而双方都不释放锁,就会产生死锁
ReentrantReadWriteLock底层读写状态如何设计?
答:高16位为读锁,低16位为写锁
ConcurrentHashMap
ConcurrentHashMap JDK1.7实现的原理是什么?
答:
- 首先将数据分成一段一段(Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也可以被其他线程访问
- ConcurrentHashMap 是由 Segment数组结构 和 HashEntry数据结构 组成
- Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据
- 一个ConcurrentHashMap包含一个Segment数组,Segment的个数一旦初始化就不能改变。Segment数组的大小默认为16,也就是说默认可以同时支持16个线程并发写
ConcurrentHashMap JDK1.8实现的原理是什么?
答:
- 在 JDK1.8 取消了 Segment 分段锁,采用 Node+CAS+synchronized 来保证并发安全。数据结构跟HashMap1.8 的结构相似,数组+链表/红黑树。Java8 在链表长度超过一定阈值(8)时将链表(
O(N))转换为红黑树(O(logN)) - Java8中,锁粒度更细,sychronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,也不会影响其他 Node 的读写,效率大幅度提升
ConcurrentHashMap JDK1.7的实现和1.8的实现有什么区别?
答:
- 线程安全的实现方式
- Hash碰撞解决办法
- 并发度:JDK1.7最大并发度是segment的个数,默认是16;JDK1.8最大并发度是Node数组的大小,并发度更大
JDK1.8中,ConcurrentHashMap什么情况下链表才会转换成红黑树进行存储?
答:链表长度大于等于8,且数组长度大于等于64。并非一开始就创建红黑树结构,如果当前Node数组长度小于 MIN_TREEIFY_CAPACITY,默认为 64,先通过扩容数组容量为原来的两倍以缓解单个链表元素过大的性能问题
ConcurrentHashMap的get方法是否需要加锁,为什么?
答:
- 不需要
- 因为get方法不涉及对变量的修改,所以会导致并发下可能处理问题的原因就是读共享变量的可见性问题。而ConcurrentHashMap中,对get方法中用到的共享变量都使用
volatile关键字修饰,所以整个get方法不加锁也不会有问题
ConcurrentHashMap的key、value是否可以为null?
答:
- 不行
- 如果 key 或者 value 为 null 会抛出空指针异常。原因是:没有办法解决 get 的返回值为 NULL 时的二义性问题,即:因为 value 在 get 操作下返回的 NULL 没办法确定是因为key不存在所以返回NULL,还是key存在value为NULL这个问题
- 但是 HashMap 不存在二义性问题,因为 NULL 作为key put进去,hashcode 为 0 在 HashMap 中可以正确定位和存储的,通过 containsKey 也可以正常判断
存储在ConcurrentHashMap中的每个节点是什么样子的,有哪些变量?
答:它是实现 Map.Entry<K, V>接口,里面存放了 hash、key、value 以及 next 节点,它的 value 和 next 节点是用 volatile 进行修饰,可以保证多线程之间的可见性
BlockingQueue阻塞队列
什么是BlockingQueue?
答:
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列,这两个附加操作支持阻塞的插入和移除的方法
- 支持阻塞的插入方法:指当队列满时,队列会阻塞插入元素的线程,直到队列不满
- 支持阻塞的移除方法:指当队列为空时,获取元素的线程会等待队列变为非空
你了解的阻塞队列有哪些?
答:
ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列LinkedBlockingQueue:一个由链表结构组成的阻塞队列,可以有界也可以无界PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。默认采取自然排序升序排列DelayQueue:一个使用优先级队列实现的无界阻塞队列。队列使用PriorityQueue来实现,但是队列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能够从队列中提取元素- 应用:缓存有效期、定时任务调度等场景
SynchronousQueue:一个不存储元素的阻塞队列。每一个put操作必须等待一个take操作,否则不能继续添加元素。队列本身不存储任何元素,非常适合传递性场景LinkedTransferQueue:一个由链表结构组成的单向无界阻塞队列。它设计了一种直接在生产者和消费者之间传输元素的机制,称为“transfer”。当生产者调用transfer(e)方法时,它会阻塞直到有一个消费者接收该元素- 应用:适用于需要高效地在生产者和消费者之间直接传输数据的场景,尤其是当生产者和消费者之间的速度大致匹配时
LinkedBlockingQueue:一个由链表结构组成的双向阻塞队列。所谓双向队列指的是可以从队列的两端插入和移出元素。相比其他的阻塞队列,LinkedBlockingDeque多了addFirst、addLast、offer-First、offerLast、peekFirst和peekLast等方法- 应用:双向阻塞队列可以运用在“工作窃取”模式中
ArrayBlockingQueue和LinkedBlockingQueue有什么区别?
答:
相同点:都是Java并发包中常用的两种阻塞队列实现,它们都是线程安全的
不同点:
- 底层实现:
ArrayBlockingQueue基于数组实现,而LinkedBlockingQueue基于链表实现 - 是否有界:
ArrayBlockingQueue是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的 - 锁是否分离:
ArrayBlockingQueue中的锁是没有分离的,即:生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即:生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺 - 内存占用:
ArrayBlockingQueue需要提前分配数组内存,而LinkedBlockingQueue则是动态分配链表节点内存。这意味着,ArrayBlockingQueue在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue则是根据元素的增加而逐渐占用内存空间
如果队列是空的,消费者会一直等待,当生产者添加元素时,消费者是如何知道当前队列存在元素的呢?
答:
- 使用通知模式实现
- 所谓通知模式,当消费者从空的队列获取元素时会阻塞住消费者,此时如果生产者放了一个元素进入队列,则需要通知阻塞住消费者当前有元素可取。同理当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。通过查看JDK源码发现部分阻塞队列使用了Condition来实现
CAS
你自己怎样理解CAS机制的?
答:
- 全称
Compare and swap,即:“比较并交换”。相当于通过一个原子的操作,同时完成“读取内
存、比较是否相等、修改内存”这三个步骤 - Java 中可以通过 CAS 操作来保证原子性,原子性就是不可被中断的一些列操作或者一个操作,简单来说就是一系列操作,要么全部完成,要么失败,不能被中断
- 本质上需要 CPU 指令的支撑
- CAS主要包含三个参数**(V,expect,update)**,V表示要更新的变量(内存值)、expect 表示预期值(旧值)、 update 表示新值。算法流程是:首先比较 V 和 expect 的值,如果相等,将 update 值赋值给V,如果不相等说明有其他线程对该变量做了更新。这个参数有的地方也会用
(V,A,B)表示,其中:A表示预期值,B表示新值 - 当多个线程同时操作一个共享变量时,只有一个线程可以对变量进行成功更新,其他线程均会失败,但是失败并不会被挂起,进行再次尝试,也就是:自旋。Java 中的自旋锁就是利用 CAS 来实现的
你知道JDK中哪些地方用到了CAS吗?例举几个
答:
- 自旋锁的实现
- 就是一个循环里面不停CAS操作,直到成功或者超时,一般会设置一个自旋超时时间
- Java原子类
- 并发容器
- 例如:
ConcurrentHashMap
- 例如:
CAS有什么问题和缺陷?
答:
- ABA问题
- 只能保证单个变量的原子性,不能保证代码块的原子性。要保证多个变量共同进行原子性操作,那只能借助别的方式,例如:
synchronized - CAS会造成CPU利用率增加,因为CAS是不阻塞线程的,通常的用法是把CAS放到循环中,所以如果竞争比较激烈的话,CPU消耗会显著增加
什么是ABA问题?
答:因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA很多业务场景是可以容忍的,但是有些业务场景就会带来问题
ABA问题怎么解决?
答:
- 给要修改的数据引入版本号
- 在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。如果发现当前版本号和之前读到的版本号一致,就真正执行修改操作,并让版本号自增;如果发现当前版本号比之前读到的版本号大,就认为操作失败
- JDK的Atomic包里提供了一个类 AtomicStampedReference 来解决ABA问题
- 这个类的
compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值
- 这个类的
synchronized、volatile、CAS 之间的比较?
答:
- synchronized 是悲观锁,属于抢占式,会引起其他线程阻塞
- volatile 提供多线程共享变量可见性和禁止指令重排序优化
- CAS 是基于冲突检测的乐观锁(非阻塞)
原子类
什么是原子类?
答:
-
java.util.concurrent.atomic包:是原子类的小工具包,支持在单个变量上解除锁的线程安全编程原子变量类相当于一种泛化的 volatile 变量,能够支持原子的和有条件的读-改-写操作- 比如:AtomicInteger 表示一个 int 类型的值,并提供了 get 和 set 方法,这些 volatile 类型的 int 变量在读取和写入上有着相同的内存语义。它还提供了一个原子的 compareAndset 方法(如果该方法成功执行,那么将实现与读取/写入一个 volatile 变量相同的内存效果),以及原子的添加、递增和递减等方法
-
AtomicInteger 表面上非常像一个扩展的 Counter 类,但在发生竞争的情况下能提供更高的可伸缩性,因为它直接利用了硬件对并发的支持
-
简单来说:就是原子类来实现CAS无锁模式的算法
原子类的常用类有哪些?
答:
AtomicBooleanAtomicIntegerAtomicLongAtomicReference
说一下 Atomic 的原理?
答:
Atomic 包中的类基本的特性就是:在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即:当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以像自旋锁一样,继续尝试,一直等到执行成功
并发工具类(了解)
CountDownLatch、CyclicBarrier、Semaphore、Exchanger了解吗?
答:
CountDownLatch:倒计数器- 允许一个或多个线程等待其他线程完成操作
CyclicBarrier:意思是可循环使用(Cyclic)的屏障(Barrier)- 它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行
Semaphore(信号量):是用来控制同时访问特定资源的线程数量,它通过协调各个线程以保证合理的使用公共资源Exchanger(交换者):是一个用于线程间协作的工具类,Exchanger 用于进行线程间的数据交换- 它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据
CyclicBarrier和CountDownLatch有什么区别?
答:
- CyclicBarrier是可重用的,其中的线程会等待所有的线程完成任务。届时,屏障将被拆除,并可以选择性地做一些特定的动作。CountDownLatch是一次性的,不同的线程在同一个计数器上工作,直到计数器为0
- CyclicBarrier面向的是线程数;CountDownLatch面向的是任务数
- 在使用CyclicBarrier时,必须在构造中指定参与协作的线程数,这些线程必须调用await()方法;使用CountDownLatch时,则必须要指定任务数,至于这些任务由哪些线程完成无关紧要
- CyclicBarrier可以在所有的线程释放后重新使用;CountDownLatch在计数器为0时不能再使
用
线程池
什么是线程池?为什么要用线程池?
答:
- Java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。在开发过程中,合理地使用线程池能够带来许多好处:
- 降低资源消耗
- 通过重复利用已创建的线程降低线程创建和销毁造成的消耗
- 提高响应速度
- 当任务到达时,任务可以不需要等到线程创建就能立即执行
- 提高线程的可管理性
- 线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。但是,要做到合理利用
- 降低资源消耗
线程池的核心参数都哪些?
答:
1 | public ThreadPoolExecutor(int corePoolSize, |
线程池的构造函数有7个参数:
corePoolSize:核心线程数maximumPoolSize:线程池能创建线程的最大个数keepAliveTime:空闲线程存活时间unit:时间单位,为 keepAliveTime 指定时间单位workQueue:阻塞队列,用于保存任务的阻塞队列threadFactory:创建线程的工程类handler:饱和策略(拒绝策略)
常用线程池的区别及特点?
答:
newCachedThreadPool- 特点:
newCachedThreadPool创建一个可缓存线程池,如果当前线程池的长度超过了处理的需要时,它可以灵活的回收空闲的线程,当需要增加时, 它可以灵活的添加新的线程,而不会对池的长度作任何限制 - 缺点:他虽然可以无限的新建线程,但是容易造成堆内存溢出,因为它的最大值是在初始化的时候设置为
Integer.MAX_VALUE,一般来说机器都没那么大内存给它不断使用。当然知道可能出问题的点,就可以去重写一个方法限制一下这个最大值 - 使用场景:执行很多短期异步的小程序
- 特点:
newFixedThreadPool- 特点:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。定长线程池的大小最好根据系统资源进行设置
- 缺点:线程数量是固定的,但是阻塞队列是无界队列。如果有很多请求积压,阻塞队列越来越长,容易导致OOM(超出内存空间)
- 使用场景:执行长期的任务,性能好很多
newScheduledThreadPool- 特点:创建一个固定长度的线程池,而且支持定时的以及周期性的任务执行,类似于Timer(Timer是Java的一个定时器类)
- 缺点:同样使用无界队列。如果有很多请求积压,阻塞队列越来越长,容易导致OOM(超出内存空间)
- 使用场景:周期性执行任务的场景
newSingleThreadExecutor- 特点:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它,它必须保证前一项任务执行完毕才能执行后一项。保证所有任务按照指定顺序(FIFO,LIFO,优先级)执行
- 缺点:缺点的话,很明显,它是单线程的,高并发业务下有点无力
- 使用场景:适合多个任务顺序执行的场景,不需要考虑并发问题
线程池的拒绝策略都有哪些?
答:
AbortPolicy,默认- 该策略是线程池的默认策略。使用该策略时,如果线程池队列满了丢掉这个任务并且抛出
RejectedExecutionException异常
- 该策略是线程池的默认策略。使用该策略时,如果线程池队列满了丢掉这个任务并且抛出
DiscardPolicy- 这个策略和
AbortPolicy的 slient 版本,如果线程池队列满了**,会直接丢掉这个任务并且不会有任何异常**
- 这个策略和
DiscardOldestPolicy- 这个策略从字面上也很好理解,丢弃最老的。也就是说如果队列满了,会将最早进入队列的任务删掉腾出空间,再尝试加入队列。因为队列是队尾进,队头出,所以队头元素是最老的,因此每次都是移除对头元素后再尝试入队
CallerRunsPolicy- 使用此策略,如果添加到线程池失败,那么主线程会自己去执行该任务,不会等待线程池中的线程去执行。就像是个急脾气的人,我等不到别人来做这件事就干脆自己干
在Java中,Executor和Executors的区别?
答:
- Executors 工具类的不同方法按照我们的需求创建了不同的线程池,来满足业务的需求
- Executor 接口对象能执行我们的线程任务
- ExecutorService 接口继承了 Executor 接口并进行了扩展,提供了更多的方法我们能获得任务执行的状态并且可以获取任务的返回值
线程池都有哪些状态?
答:

RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程TIDYING:所有的任务都销毁了,workerCount 为 0,线程池的状态在转换为 TIDYING 状态时,会执行钩子方法terminated()TERMINATED:terminated()方法结束后,线程池的状态就会变成这个
线程池中submit()和execute()方法有什么区别?
答:
- 相同点:
- 相同点就是都可以开启线程执行池中的任务
- 不同点:
- 接收参数:
execute()只能执行 Runnable 类型的任务,submit()可以执行 Runnable 和 Callable 类型的任务 - 返回值:
submit()方法可以返回持有计算结果的 Future 对象,而execute()没有 - 异常处理:
submit()方便 Exception 处理
- 接收参数:
分析线程池的实现原理和线程池的调度过程?
答:

提交一个任务到线程池中,线程池的处理流程如下:
- 判断线程池里的 核心线程数 是否已满,如果不是(核心线程空闲或者还有核心线程没有被创建)则创建-1个新的工作线程来执行任务。如果核心线程数已满,则进入下个流程
- 线程池判断 工作队列 是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程
- 判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任务。如果已经满
了,则交给饱和策略(拒绝策略)来处理这个任务
如何合理分配线程池大小?
答:
要合理的分配线程池的大小要根据实际情况来定,简单的来说的话就是根据 CPU密集 和 IO密集 来分配:
- CPU密集型时,设置线程数为cpu核心数+1,这样可以使得每个线程都在执行任务
- IO密集型时,大部分线程都阻塞故需要多配置线程数,2*cpu核数
总结:
- 线程等待时间比CPU执行时间比例越高,需要越多线程
- 线程CPU执行时间比等待时间比例越高,需要越少线程
- 当然,实际应用中没有固定的公式,需要结合测试和监控来进行调整
线程池如何调优?
答:
- CPU 密集型任务配置尽可能小的线程,cpu核数+1
- IO 密集型任务则由于线程并不是一直在执行任务,则配置尽可能多的线程,如:2*cpu核数
- 混合型任务,如果可以拆分,则将其拆分成一个 CPU 密集型任务和一个 IO 密集型任务。只要这两个任务执行的时间相差不是太大,那么分解后并发执行的吞吐率要高于串行执行的吞吐率;如果这两个任务执行时间相差太大,则没必要进行分解
- 优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 来处理,它可以让优先级高的任务先得到执行
- 执行时间不同的任务可以交给不同线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行
- 依赖数据库连接池的任务,因为线程提交 SQL 后需要等待数据库返回结果,线程数应该设置得较大,这样才能更好的利用 CPU
- 建议使用有界队列,有界队列能增加系统的稳定性和预警能力。可以根据需要设大一点,比如几千。使用无界队列,线程池的队列就会越来越大,有可能会撑满内存,导致整个系统不可用
线程池如何实现动态修改?
答:
- 首先线程池提供了部分 setter 方法可以设置线程池的参数
- 修改核心线程数、最大线程数、空闲线程停留时间、拒绝策略等
- 可以将线程池的配置参数放入配置中心,当需要调整的时候,去配置中心修改就行
- 什么时候修改呢?
- 这里需要监控报警策略,获取线程池状态指标,当指标判定为异常之后进行报警
- 分析指标异常原因,评估处理策略,最后通过上述线程池提供的接口进行动态修改(可以将动态配置)
使用无界队列的线程池会导致什么问题?
答:例如:newFixedThreadPool使用了无界的阻塞队列LinkedBlockingQueue,如果线程获取一个任务后,任务的执行时间比较长,会导致队列的任务越积越多,导致机器内存使用不停升,最终导致OOM
线程池的问题,如果线程池当前处于空闲的状态,核心线程数量是不会被销毁的,那这几个核心线程处于什么状态?为什么处于这个状态?
答:首先线程本身创建和销毁都是成本比较高的,那就排除 new 和 terminated 状态,没有任务运行排除 runnable 状态,剩下阻塞和等待,因为线程不会销毁需要一直等待执行任务,超时等待也不太可能,最后同步锁才会进入阻塞状态,所以我猜是一直等待
线程池代码题
写一个双线程轮流打印1-100,同题目:“2个线程,交替打印100的奇偶数”
以下是一个简单的Java双线程程序,其中一个线程打印奇数,另一个线程打印偶数。线程之间通过 wait() 和 notifyAll() 方法进行协调,以确保它们轮流打印数字
1 | public class AlternatePrinting { |
三个线程顺序打出1-100
为了改为 3 个线程交替打印 1-100,我们需要对每个线程分配一个打印范围,例如:第一个线程打印 3 的倍数,第二个线程打印 3n+1 的数,第三个线程打印 3n+2 的数(其中n为非负整数)。同时,我们需要一种机制来确保三个线程能够交替执行
1 | public class AlternatePrintingThreeThreads { |
注意:在这个程序中,我们使用了 turn 变量来控制哪个线程应该打印。每个线程在打印前都会检查 turn 的值,如果不是自己的轮次,就会调用 wait() 方法进入等待状态。当一个线程打印完毕后,它会更新 turn 的值,并通过 notifyAll() 方法唤醒其他可能在等待的线程
多线程问题:线程A,B、C,分别打印1、2、3,顺序执行10次
为了顺序执行10次打印任务,其中线程A打印1、线程B打印2、线程C打印3,我们可以使用一个共享的计数器来跟踪当前的打印轮次,并确保每个线程在正确的轮次中执行
1 | public class SequentialPrinting { |
注意:
- 在这个程序中, count 变量用来跟踪当前的打印轮次,并且确保每个线程在正确的轮次执行。每个线程都会检查 count 的值,如果当前轮次不是自己的,就会调用 wait() 方法进入等待状态。当一个线程打印完毕后,它会增加 count 的值,并通过 notifyAll() 方法唤醒其他可能在等待的线程
- 注意, count 变量的上限设置为30(10轮,每轮3个数字),以确保程序只执行10轮打印。每个线程都会检查这个条件,以避免超出所需的打印轮次
- 此程序会按顺序(线程A、线程B、线程C)执行打印任务,每个线程打印自己的数字,总共进行10轮
计数累加怎么线程安全,可以怎么实现,100个线程,每个线程累加100次
以下是一个简单的 Java 示例,演示了如何使用 AtomicInteger 来实现线程安全的计数累加。在这个示例中,我们将创建 100 个线程,并且每个线程将对计数器执行 100 次累加操作
1 | public class AtomicCountDemo { |
- 在这个示例中,定义了一个
AtomicInteger类型的静态变量counter作为我们的计数器incrementCounterHundredTimes方法包含了一个循环,它会使计数器递增100次。由于AtomicInteger的incrementAndGet方法是线程安全的,所以不需要额外的同步措施 - 在main方法中,创建了一个拥有100个线程的线程池,并提交了100个任务,每个任务都会执行
incrementCounterHundredTimes方法。在所有任务完成后,输出最终的计数值。由于每个线程都会将计数器增加100,所以最终的计数值应该是100*100=10000
线程交叉打印12A34B56C,多种实现方式(一个打印数字,一个打印字母)
1 | public class AlternatePrintingDemo { |
两个线程交替打印ABCD…Z字母,一个大写一个小写
目标输出:AbCdEfGhIjKlMnOpQrStUvWxYz
1 | public class PrintLettersDemo { |
两个线程交替打印出a1b2c3…z26
1 | // 两个线程交替打印出a1b2c3....z26 |
两个线程,一个打印abcd,一个打印1234,需求交替打印出a1b2c3d4a1b2c3d4;打印10轮
1 | // 两个线程,一个打印abcd,一个打印1234,需求交替打印出a1b2c3d4a1b2c3d4;打印10轮 |





