语雀完整版:
https://www.yuque.com/g/mingrun/embiys/yf93au/collaborator/join?token=ZaH4itL0eqefhfD2&source=doc_collaborator# 《Java源码》
JAVA源码精讲
问题
- 延迟队列为基础的 定时线程池怎么用的
- 看下 threadLocal,的实现,并看下thread中的代码,是如何处理的
- 排序中的双轴快速排序算法
- 几种 泛型关键字的用法,T V 啥的
- 线程池 summit 是怎么调用 execute的
- 思考下 为什么线程池中 要定义一个 worker,他的作用到底是什么,怎么体现的
- Stream 流是如何优化的
第1章:基础
01 开篇词:为什么学习本专栏
- 进大厂,避免踩坑,结合场景熟练的使用 API,并对其拓展
- 每部分源码的分析步骤
-
- 怎么用
-
- 底层实现,流程图
-
- 总结出设计思想,最优使用 和坑
-
- 连环面试题
02 String和Long源码解析和面试题
String
- String的不变性:
String s ="hello";
// 第二个已经改变了内存地址
s ="world";
原因
// 类被final修饰 不可再被继承
public final class String implements java.io.Serializable, Comparable<String>, CharSequence{
// 变量被final修饰 只能赋值一次
/** The value is used for character storage. */
private final char value[];
}
这种不变性还体现在 如果使用 replace()方法,那么他一定是有返回值的,返回给你一个新的对象
- 字符串乱码:
要在二进制数据 进行转换的时候 进行统一:String s2 = new String(bytes,"utf-8");
- 首字母大小写:
name.substring(0, 1).toLowerCase() + name.substring(1);
其中substring 底层使用Arrays.copyOfRange(字符数组, 开始 位置, 结束位置);,而copyOfRange底层用的还是 系统的拷贝数组方法(navite修饰的)
- equals的实现:
-
- 引用一样话返回true
-
- 比较对象是String的话,就比较他们里面存的 数组,判断数组的长度 和每个字符是否相等
-
- 比较对象不是String返回false
- 替换和删除:
替换用 replace,删除可以把其中一个字符替换成 “”
- 拆分和合并:
java提供的是 split和join方法,但有缺点,这块可以看看Guava提供的方法
Long
- 缓存:[-128,127] 范围内的值 不会被初始化,long,short,integer都有这个缓存
// 就是初始化很多 long对象
private static class LongCache {
private LongCache(){}
// 缓存,范围从 -128 到 127,+1 是因为有个 0
static final Long cache[] = new Long[-(-128) + 127 + 1];
// 容器初始化时,进行加载
static {
// 缓存 Long 值,注意这里是 i - 128 ,所以再拿的时候就需要 + 128
for(int i = 0; i < cache.length; i++)
cache[i] = new Long(i - 128);
}
}
03 Java 常用关键字理解
- static:
修饰类、方法、代码块,变量, 修饰变量要注意线程安全,其中有两个加载顺序如下
-
- 父类静态变量 和静态代码块比子类先加载
-
- 静态变量和静态代码块比构造器 优先初始化
- final:
被final修饰的类无法继承,方法无法重写,变量无法改变内存地址
- try-catch-finally:
注意这一块的面试题
- valatil:
注意这一块的面试题
- transient:
序列化时 忽略该变量
- default:
用过
04 Arrays. Collections. Objects 常用方法源码解析
- Arrays:给数组使用
-
- 排序(sort): 要重点看看 双轴快速排序算法
-
- 查找(binarySearch):要先排好序,否则会查不到,注意复习尚硅谷代码,要会写
-
- 拷贝(copyOfRange)
- Collections:给集合使用
-
- 排序 和查找与arrays中的实现一样
-
- 求集合中的最大值和最小值

- 求集合中的最大值和最小值
-
- 其它类型的集合:
-
-
- 线程安全:synchronized开头的,底层是加了 sy锁
-
-
-
- 不可修改的集合UnmodifiableList:底层就是在set方法中,直接抛异常
-
- Objects:
-
- 相等判断:有equals和deepEquals,其中后者如果判断是数组的话,就会循环对每个元素进行比较

- 相等判断:有equals和deepEquals,其中后者如果判断是数组的话,就会循环对每个元素进行比较
-
- 判空:isNull(),nonNull,requireNoNull等这些方法
第二章:集合
05 ArrayList源码解析和设计思路
- 概述:

-
- 默认数组大小是10,会自动扩容
-
- 其中数组大小 size,没加锁,不安全
-
- size、isEmpty、get、set、add 等方法时间复杂度都是 O (1)
- 源码
-
- 初始化:直接初始化、指定大小初始化、指定初始数据初始化
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
//无参数直接初始化,数组大小为空
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//指定初始数据初始化
public ArrayList(Collection<? extends E> c) {
//elementData 是保存数组的容器,默认为null
elementData = c.toArray();
//如果给定的集合(c)数据有值
if ((size = elementData.length) != 0) {
// c.toArray might (incorrectly) not return Object[] (see 6260652 这里是个bug)
//如果集合元素类型不是 Object 类型,我们会转成 Object
if (elementData.getClass() != Object[].class) {
elementData = Arrays.copyOf(elementData, size, Object[].class);
}
} else {
// 给定集合(c)无值,则默认空数组
this.elementData = EMPTY_ELEMENTDATA;
}
}
-
- 添加:会先检查容量够不够,然后再扩容,最大扩容不能超过
Integer.MAX_VALUE
- 添加:会先检查容量够不够,然后再扩容,最大扩容不能超过
// 计算新的容量大小,就是原来大小的1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
扩容的本质:
/**
* @param src 被拷贝的数组
* @param srcPos 从数组那里开始
* @param dest 目标数组
* @param destPos 从目标数组那个索引位置开始拷贝
* @param length 拷贝的长度
* 此方法是没有返回值的,通过 dest 的引用进行传值
*/
public static native void arraycopy(Object src, int srcPos, Object dest, int destPos,
int length);
-
- 删除:主要思路是先找到 要删除元素的索引,然后计算出后面有多少个元素,然后就拷贝这些数组 往前移一位,然后把最后一个元素设置为null
- 迭代器(Iterator)
由集合实现该接口,如调用lists.iterator()
源码解析:
-
- 三个关键值
// 迭代过程中,下一个元素的位置,默认从 0 开始。
int cursor;
// 新增场景:表示上一次迭代过程中,索引的位置;删除场景:为 -1。
int lastRet = -1;
// expectedModCount 表示迭代过程中,期望的版本号;modCount 表示数组实际的版本号。
// 这个值 上面的add 和delete方法都会加一,如果迭代时发现不一样 就会报快速异常
int expectedModCount = modCount;
-
- hashNext() 方法:
return cursor != size;
- hashNext() 方法:
-
- next() 方法:先检查版本号,然后记录cursor 和lastRet的值,最后返回数组中对应的元素
-
- remove() 方法:这里主要就是用了
expectedModCount = modCount;,所以用这个方法可以避免快速失败
- remove() 方法:这里主要就是用了
06 LinkedList源码解析
- 概述:

注意 看Node源码,它是双向的链表
- 源码:
-
- 增加删除,要会写
-
- 节点查询:
linkedList.get(9),底层会判断 索引在链表的前半部分还是后半部分,然后从头节点遍历或者从尾部遍历
- 节点查询:
-
- 迭代(ListInterator):差不太多
07 List源码会问哪些面试题
下面
08 HashMap源码解析
其它讲解([逐行分析HashMap源码](C:/Users/Administrator/Desktop/要看的/【带你逐行分析 HashMap 源码】- 优快云.mhtml))
- HashMap官方说明
-
- 允许Null值和Null键
-
- 迭代集合所需的时间和 与HashMap的容量(桶的数量)和大小(键值对的数量)成正比
-
- 有两个参数会影响Map的性能,他们是初始容量和加载因子,当哈希表的条数 大于了 加载因子与容量的乘积时,就要调用rehash方法扩容,其中加载因子(0.75)是空间和时间上的一种折中
- HashMap存储结构
-
Node:map中包含着Node类型的table,源码如下
static class Node<K, V> implements Map.Entry<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next;
Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
//getter setter toString equals 。。。。
}
-
- 上面的Entry<K, V>是Map接口中的内部接口,如下所示
interface Entry<K,V> {
K getKey();
V getValue();
V setValue(V value);
boolean equals(Object o);
//省略 一堆比较方法。。。。
}
-
- 从中可以看出存储的是链表结构,即数组中每个位置被当作一个桶,一个桶存放一个链表,就是用链地址法解决的哈希冲突,哈希值和散列桶取模运算结果相同的 都被放到一个链表里面

- 从中可以看出存储的是链表结构,即数组中每个位置被当作一个桶,一个桶存放一个链表,就是用链地址法解决的哈希冲突,哈希值和散列桶取模运算结果相同的 都被放到一个链表里面
- HashMap的静态属性
//默认的table的容量 必须是2的幂(二进制位中只有一个1)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大的table容量,因为int是32位,而且要是2的幂,最大的就只能是下面这个了
static final int MAXIMUM_CAPACITY = 1 << 30;
//缺省的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//转成红黑树的条件之一,链表长度大于8
static final int TREEIFY_THRESHOLD = 8;
//转成红黑树的另外一个条件,table的容量需要大于64
static final int MIN_TREEIFY_CAPACITY = 64;
//链表长度如果小于这个值 就会转换回链表
static final int UNTREEIFY_THRESHOLD = 6;
- HashMap成员属性
transient Node<K,V>[] table;
//hashMap进行结构修改的次数,用于检测map内部结构是否发生了改变
transient int modCount;
//这里的 Set是一个接口,具体实现的就是Map中的一个内部类EntrySet,可以将它理解为工具类,对Map进行了简单的封装,提供了方便遍历删除等操作
transient Set<Map.Entry<K,V>> entrySet;
//键值对的数量
transient int size;
//size的临界值,超过这个数量就要进行扩容操作
int threshold;
//缺省的负载因子,在构造函数中初始化
final float loadFactor;
- 构造方法
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
//1. 对initialCapacity做校检
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//2. 对loadFactor做校检
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//3. 对临界值做一个计算(算作是合理的初始容量)
this.threshold = tableSizeFor(initialCapacity);
}
- tableSizeFor(int cap) 方法
-
- 它的目的就是为了计算合理的初始容量,就是要满足2的幂,而且要大于等于参数cap,取最接近的那一个数,代码如下:
//反正就是不断位移 和按位或 最终得到2的整数次幂
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
- hash(Object key) 方法
-
- 用于计算Key的哈希值,是核心方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
-
- 分析
-
-
- 如果key为null时直接返回0,然后再根据桶下标公式
(capacity - 1) & hash可得 桶下标的公式会为0,因为0与上任何数都是0
- 如果key为null时直接返回0,然后再根据桶下标公式
-
-
-
- 当 key 不为 null 时,调用 key.hashCode() 并将 hashCode 的低 16 位与高 16 位异或。
-
-
-
-
- 如果不进行高低位异或,初始容量16后28位全是0,hash值也是高位都是0,这样对于桶下标公式而言 就很容易发生冲突,所以用这种方式(需要重看)
-
-
- 桶下标计算公式
-
hash % capacity就是用上面计算出来的哈希值对容量取模,而如果能保证容量为2的幂,那就会用(capacity - 1) & hash
-
- 以上就是要保证 capacity 为 2 的幂的原因之一,另外一个原因是在 resize() 扩容方法中可以更高效的重新计算桶下标。
- put(K key, V value) 方法
-
- 若是 Key 已存在,则覆盖并返回旧的 Value(可为 null);若是没有键值对映射,则返回 null。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 步骤①:table为空则创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算桶下标,若是没有碰撞直接放桶里
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 步骤③:发生hash碰撞,若键已存在就返回该Node,并用属性 e 引用,若键不存在就创建一个新的Node,并直接插入到桶中
Node<K,V> e; K k;
// 检查碰撞的节点是否是头节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 若该桶的内部结构是树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 若该桶的内部结构是链表
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 直接插入新节点到链表的尾部
p.next = newNode(hash, key, value, null);
// 链表长度大于8转为红黑树处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 步骤④:该键已经存在
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
// 直接覆盖,并返回旧值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 步骤⑤:检查键值对数量是否超过临界值,是则扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
- ReSize方法()
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 步骤①:根据 oldCap 判断是扩容还是初始化数组,若是扩容..
if (oldCap > 0) {
// 超过最大容量就不再扩容,随它去碰撞
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩容为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 步骤②:若是初始化数组,若是字段 threshold 中已经保存初始容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 步骤③:若是初始化数组,若是字段 threshold 中没有保存初始容量,则使用默认容量
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 步骤④:计算新的键值对临界值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 步骤⑤:实例化新的 table 数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 步骤⑥:将每个桶及内部节点都移到新的 table 数组中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 去掉旧数组对该桶的引用
oldTab[j] = null;
// 若是该桶无哈希碰撞,重新计算桶下标
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 若是该桶内部结构为树
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 若是该桶内部结构为链表,则碰撞的节点要么在原桶,要么在新桶
else { // preserve order
// 原桶的头尾节点引用
Node<K,V> loHead = null, loTail = null;
// 新桶的头尾节点引用
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 循环遍历桶内碰撞节点
do {
next = e.next;
// 新增位是 0 放原桶
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 新增位是 1 放新桶
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原桶放新 table 数组
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 新桶放新 table 数组
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
本篇讲解
- 概述:HashMap由 数组+链表+红黑树组成

- 类注释:
-
- 不扩容的条件:数组容量 > 需要的数组大小 /load factor
-
- 也会出现 快速失败,如果在迭代的时候 被其他线程修改
-
- Collections#synchronizedMap 来实现线程安全的原理是在每个 map的方法上加了sy锁
- 成员变量:
存放了各种参数,其中链表的节点 和红黑树的节点如下
//链表的节点
static class Node<K,V> implements Map.Entry<K,V> {
//红黑树的节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V>
- 新增

-
- 链表的新增:链表新增很简单,和正常添加节点一样,就是当链表的长度超过8 并且数组的大小超过64才会扩容为64,之所以用8是因为 考虑了泊松分布概览函数,链表长度能到8的概率不到千分之一(其中链表的查询速度为O(n),红黑树的查询长度为 O (log (n)) )
-
- 红黑树的节点新增
-
-
- 先判断节点在树上是不是已经存在,有下面两种手段
-
-
-
-
- 如果节点没有实现Comparable 接口,使用 equals 进行判断;
-
-
-
-
-
- Comparable 接口,使用 compareTo 进行判断。
-
-
-
-
- 新增的节点如果在红黑树上直接返回,如果不在就判断是新增到 当前节点的左边还是右边
-
-
-
- 递归运行上面两步,直到当前节点的左节点或右节点为空
-
-
-
- 与当前节点建立父子关系,然后就是旋转(为了保证树的平衡 )和着色(按照红黑树的功能)
-
- 查找
-
- 根据hash定位数组的索引位置,然后用eques判断当前节点是不是要寻找的,不是的话往下
-
- 判断当前节点有无 next 节点,有的话判断是链表类型,还是红黑树类型。
-
- 分别走链表和红黑树不同类型的查找方法
09 TreeMap和LinkedHashMap核心源码解析
TreeMap
- 知识储备-两种排序方式
-
- 实现Comparable 接口
-
- 继承Comparator
- 概述
-
- 底层用的也是红黑树,并且因为红黑树具有排序树的特性,所以适合key需要排序的场景
-
- 因为底层使用的是平衡红黑树的结构,所以 containsKey、 get、 put、 remove 等方法的时间复杂
度都是 log(n)。
- 因为底层使用的是平衡红黑树的结构,所以 containsKey、 get、 put、 remove 等方法的时间复杂
- 属性
//比较器,如果外部有传进来 Comparator 比较器,首先用外部的
//如果外部比较器为空,则使用 key 自己实现的 Comparable#compareTo 方法
//比较手段和上面日常工作中的比较 demo 是一致的
private final Comparator<? super K> comparator;
//红黑树的根节点
private transient Entry<K,V> root;
//红黑树的已有元素大小
private transient int size = 0;
//树结构变化的版本号,用于迭代过程中的快速失败场景private transient int modCount = 0;
private transient int modCount = 0;
//红黑树的节点
static final class Entry<K,V> implements Map.Entry<K,V> {}
- 新增节点
-
- 判断红黑树的节点是否为空,为空的话,新增的节点直接作为根节点
-
- 根据红黑树左小右大的特性,进行判断,找到应该新增节点的父节点
-
- 在父节点的左边或右边插入新增节点
-
- 着色旋转,达到平衡,结束 。
- 注意
-
- 查找过程中,发现 key 值已经存在,直接覆盖;
-
- TreeMap 是禁止 key 是 null 值的。
LinkedHashMap
- 概述
-
- 继承了HashMap,另外拥有两大特性如下
-
-
- 按照插入顺序进行访问
-
-
-
- 最少访问,最先删除(LRU)
-
- 按照插入顺序访问
-
- 链表结构:从下面代码中可以看出List的节点换成了 Map的Node
transient LinkedHashMap.Entry<K,V> head;// 链表头
transient LinkedHashMap.Entry<K,V> tail;// 链表尾
// 继承 Node,为数组的每个元素增加了 before 和 after 属性
// 继承 Node,为数组的每个元素增加了 before 和 after 属性
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
// 控制两种访问模式的字段,默认 false
// true 按照访问顺序,会把经常访问的 key 放到队尾
// false 按照插入顺序提供访问
final boolean accessOrder
-
- 如何按照顺序新增:调用了Map的put方法,但却重写了newNode/newTreeNode 和 afterNodeAccess 方法
-
-
- newNode/newTreeNode 方法让新增的节点 都加入到LinkedHashMap链表的尾部
-
-
- 按照顺序访问:LinkedHashMap 只能从头向尾单向访问(通过前一个节点的after结构,内部实现的迭代器确定的)
- 访问最少删除策略
-
- demo
public void testAccessOrder() {
// 新建 LinkedHashMap
LinkedHashMap<Integer, Integer> map = new LinkedHashMap<Integer,
Integer>(4,0.75f,true) {
{
put(10, 10);
put(9, 9);
put(20, 20);
put(1, 1);
}
@Override
// 覆写了删除策略的方法,我们设定当节点个数大于 3 时,就开始删除头节点
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > 3;
}
};
log.info("初始化: {}",JSON.toJSONString(map));//初始化: {9:9,20:20,1:1}
Assert.assertNotNull(map.get(9));
log.info("map.get(9): {}",JSON.toJSONString(map));//map.get(9):{20:20,1:1,9:9}
Assert.assertNotNull(map.get(20));
log.info("map.get(20): {}",JSON.toJSONString(map));//map.get(20):{1:1,9:9,20:20}
}
总结:刚被访问的元素会移到队尾,并且重写的removeEldestEntry中规定了节点>3之后会删除头节点
-
- 元素被移到队尾
-
-
- 先调用Map的getNode()方法 判断得到的Node是不是null,不是的话就看 accessOrder,true的话代表开启了LRU,就把当前这个节点放到队尾
-
-
- 删除元素
-
-
- LinkHashMap实现了Map的put方法要调用的 afterNodeInsertion() 方法,以此来删除头节点
-
- 总结
-
- 原本HashMap中的存储结构是不变的,LinkedHashMap其内部定义的 节点也就是Entry,继承了HashMap的Node,并且添加了before, after前后节点的引用,然后重写了 HashMap中的put方法要调用的newNode和newTreeNode,为他们加上before, after引用,以此来构建一条链表
10 Map源码会问哪些面试题
下面
11 HashSet, TreeSet源码解析
HashSet
- HashMap与HashSet的组合:
Java中要对一个基础类进行扩展 就两种方式,继承和组合,这里set和map是两种事物,所以用组合
// 把 HashMap 组合进来,key 是 Hashset 的 key,value 是下面的 PRESENT
private transient HashMap<E,Object> map;
// HashMap 中的 value
private static final Object PRESENT = new Object()
- 初始化
其中入参为集合时如下:
// 对 HashMap 的容量进行了计算
public HashSet(Collection<? extends E> c) {
//最大值(期望的值 / 0.75+1,默认值 16) 正好不会触发扩容
//这个 计算容量大小的方法 very good
map = new HashMap<>(Math.max((int) (c.size()/0.75f) + 1, 16
addAll(c);
}
- Add方法:就直接put就行了
public boolean add(E e) {
// 直接使用 HashMap 的 put 方法,进行一些简单的逻辑判断
return map.put(e, PRESENT)==null;
}
TreeSet
- 概述
-
- 底层使用的是TreeMap,对TreeMap进行复用,所以就有的key能够排序的功能,也可以按照key的排序顺序进行迭代
- 复用TreeMap思路一
-
- add()方法,直接复用的TreeMap的
public boolean add(E e) {
return m.put(e, PRESENT)==null;
}
- 复用TreeMap思路二
-
- 迭代TreeSet中的元素:由TreeSet定义接口规范,让TreeMap去完成具体的实现
- 总结:因为对于add这种方法,逻辑较为简单,所以直接让TreeSet去实现,
对于迭代场景,可能取第一个值,也可能取最后一个值,TreeMap底层数据结构也比较复杂,TreeSet可能不清楚它的复杂数据结构,所以不如直接交给TreeMap
12 彰显细节:看集合源码对我们实际工作的帮助和应用
- 注意事项
-
- 线程安全

这些sy方法的原理就是 调用原来的方法上直接加锁
- 线程安全
-
- 集合性能
-
-
- 批量新增:ArrayList尽量不要用for循环 add,put,尽量使用addAll和putAll,他们的性能会相差两百倍,因为后者只会扩容一次
-
-
-
- 批量删除:ArrayList提供的的RemoveAll方法,通过当前数组中元素是不是要被删除的元素,不是的话移到数组头 ,这样就不会像原来一样,每调用一次remove方法都会拷贝一次数组
-
-
- 一些坑
-
-
- 当集合的元素是自定义类时,自定义类强制实现 equals 和 hashCode 方法,并且两个都要实现
-
-
-
- 快速失败,ConcurrentModificationException 的错误
-
-
-
- List newList = Arrays.asList(array) ;
-
-
-
-
- 坑一:如果对 array值进行了修改,新的newList 就也会被修改
-
-
-
-
-
- 坑二:使用 add、 remove 等操作 list 的方法时, 会报UnsupportedOperationException 错误,因为上面的newList是Arrays里面的一个静态内部类,就没有实现这两个方法

- 坑二:使用 add、 remove 等操作 list 的方法时, 会报UnsupportedOperationException 错误,因为上面的newList是Arrays里面的一个静态内部类,就没有实现这两个方法
-
-
-
-
- toArray 方法时,申明的数组(返回值)大小一定要大于等于 List 的大小,如果小于的话,你会得到一个空数组。
-
13 差异对比:集合在Java7和8有何不同和改进
- 所有集合都加了forEach方法
-
- Iterable 接口提供的

- Iterable 接口提供的
- List区别
-
- ArrayList,创建的时候直接就 初始化为10的大小,java8就是第一次add的时候才会创建了(底层数据结构的加载变成懒加载了都)
- map区别
-
- hashMap
-
-
- hash算法更简洁、第一次put才初始化table、增加了红黑树接口,还增加了下面这个好用的方法
-
// 如果 key 对应的值不存在,返回期望的默认值 defaultValue
public V getOrDefault(Object key, V defaultValue) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? defaultValue : e.value;
}
-
-
- 还提供了方法 compute 、computeIfPresent (计算key和value值)
-
-
- LinkedHashMap
- Arrays 提供了一些parallel 开头的方法(多线程),比如parallelSort(),只有数据量大的时候(>8192)
14 简化工作: Guava Lists Maps实际工作运用和源码
回头看
第三章:并发集合类
01 CopyOnWriteArrayList 源码解析和设计思路
- 对于List的线程安全问题,除使用Collections.synchronizedList外,还可使用CopyOnWriteArrayList ,他有特征如下:
-
- 线程安全,多线程下无需加锁
-
- 通过锁 + 数组拷贝 + volatile 关键字保证了线程安全;
-
- 每次数组操作,都会把数组拷贝一份出来,在新数组上进行操作,操作成功之后再赋值回去。
- 整体架构
-
- 对数组操作的四步
-
-
- 加锁;
-
-
-
- 从原数组中拷贝出新数组;
-
-
-
- 在新数组上进行操作,并把新数组赋值给数组容器;
-
-
-
- 解锁。
-
-
- 其中底层数组被volatile修饰
-
- 类注释:
-
-
- 所有的操作都是线程安全的,因为操作都是在新拷贝数组上进行的;
-
-
-
- 数组的拷贝虽然有一定的成本,但往往比一般的替代方案效率高;
-
-
-
- 迭代过程中,不会影响到原来的数组,也不会抛出 ConcurrentModificationException 异常。
-
- 新增
-
- 向尾部添加一个元素
// 添加元素到数组尾部
public boolean add(E e) {
final ReentrantLock lock = this.lock;
// 加锁
lock.lock();
try {
// 得到所有的原数组
Object[] elements = getArray();
int len = elements.length;
// 拷贝到新数组里面,新数组的长度是 + 1 的,因为新增会多一个元素
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 在新数组中进行赋值,新元素直接放在数组的尾部
newElements[len] = e;
// 替换掉原来的数组
setArray(newElements);
return true;
// finally 里面释放锁,保证即使 try 发生了异常,仍然能够释放锁
} finally {
lock.unlock();
}
}
-
-
- 整个流程是加锁后完成的,拷贝原来的数组,容量加一,创建一个新数组,这里已经上锁,添加元素的时候却还是做数组拷贝 有以下两个原因
-
-
-
-
- volatile 关键字修饰的是数组,如果我们简单的在原来数组上修改其中某几个元素的值,是
无法触发可见性的,我们必须通过修改数组的内存地址才行,也就说要对数组进行重新赋值
才行。
- volatile 关键字修饰的是数组,如果我们简单的在原来数组上修改其中某几个元素的值,是
-
-
-
-
-
- 在新的数组上进行拷贝,对老数组没有任何影响,只有新数组完全拷贝完成之后,外部才能
访问到,降低了在赋值过程中,老数组数据变动的影响。
- 在新的数组上进行拷贝,对老数组没有任何影响,只有新数组完全拷贝完成之后,外部才能
-
-
-
- 向任意一个位置添加元素
// len:数组的长度、 index:插入的位置
int numMoved = len - index;
// 如果要插入的位置正好等于数组的末尾,直接拷贝数组即可
if (numMoved == 0)
newElements = Arrays.copyOf(elements, len + 1);
else {
// 如果要插入的位置在数组的中间,就需要拷贝 2 次
// 第一次从 0 拷贝到 index。
// 第二次从 index+1 拷贝到末尾。
newElements = new Object[len + 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index, newElements, index + 1,
numMoved);
}
// index 索引位置的值是空的,直接赋值即可。
newElements[index] = element;
// 把新数组的值赋值给数组的容器中
setArray(newElements);
-
-
- 先判断插入的是不是尾部,是的话走上面的逻辑
-
-
-
- 如果插入的是中间,那就要把原数组一分为二 复制到新数组中,并且留个空位,插入这个元素
-
- 删除
-
- 加锁
-
- 判断索引的位置,根据不同的策略复制数组
-
- 解锁
- 批量删除
-
- 加锁
-
- 把在给定集合中不包含的数组元素 添加到一个临时数组中,这样临时数组中的元素都是不需要删除的了,再拷贝这个临时数组就可以了
-
- 解锁
-
- 思想总结:他和ArraysList有异想同工之妙,但是这里如果原有100个元素,我传过来的c集合只有一个元素,那就要拷贝剩下的99个元素,很是浪费
- 其它方法
-
- index方法:就是简单的对 数组进行遍历,然后比较里面的元素(equals方法)
-
- 迭代:首先它不会出现快速异常,并且迭代过程中不会出现线程安全问题,因为迭代器先持有了原数组的引用地址,如果迭代过程中add一个元素,原数组的引用就会指向一个新的数组地址,但迭代器持有的不变,所以不会受数组结构变化的影响
02 ConcurrentHashMap 源码解析和设计思路
- 类注释
-
- 线程安全,多个线程同时进行put和remove等操作时不会发生阻塞
-
- 迭代过程中不会出现快速失败
-
- 除了数组 + 链表 + 红黑树的基本结构外,新增了转移节点,是为了保证扩容时的线程安全的
节点;
- 除了数组 + 链表 + 红黑树的基本结构外,新增了转移节点,是为了保证扩容时的线程安全的
-
- 提供了很多 Stream 流式方法,比如说: forEach、 search、 reduce 等等。
- 结构
-
- 从类图看和hashMap没有半毛钱关系,虽然有很多代码相似,但因为很多地方要加锁,没法用继承

- 从类图看和hashMap没有半毛钱关系,虽然有很多代码相似,但因为很多地方要加锁,没法用继承
-
- 和HashMap的相同之处
-
-
- 数组、链表结构几乎相同,所以底层对数据结构的操作思路是相同的(只是思路相同,底层实现不同);
-
-
-
- 都实现了 Map 接口,继承了 AbstractMap 抽象类,所以大多数的方法也都是相同的,HashMap 有的方法, ConcurrentHashMap 几乎都有,所以当我们需要从 HashMap 切换到ConcurrentHashMap 时,无需关心两者之间的兼容问题。
-
-
- 和hashMap的不同之处
-
-
- 红黑树结构略有不同, HashMap 的红黑树中的节点叫做 TreeNode, TreeNode 不仅仅有属性,还维护着红黑树的结构,比如说查找,新增等等; ConcurrentHashMap 中红黑树被拆分成两块,TreeNode 仅仅维护的属性和查找功能,新增了 TreeBin,来维护红黑树结构,并负责根节点的加锁和解锁;
-
-
-
- 新增 ForwardingNode (转移)节点,扩容的时候会使用到,通过使用该节点,来保证扩容时的线程安全。
-
- Put
-
- 整体思路
-
-
- 如果数组为空,初始化,初始化完成之后,走 2;
-
-
-
- 计算当前槽点有没有值,没有值的话, cas 创建,失败继续自旋(for 死循环),直到成功,槽点有值的话,走 3;
-
-
-
- 如果槽点是转移节点(正在扩容),就会一直自旋等待扩容完成之后再新增,不是转移节点走4;
-
-
-
- 槽点有值的,先锁定当前槽点,保证其余线程不能操作,如果是链表,新增值到链表的尾部,
如果是红黑树,使用红黑树新增的方法新增;
- 槽点有值的,先锁定当前槽点,保证其余线程不能操作,如果是链表,新增值到链表的尾部,
-
-
-
- 新增完成之后 check 需不需要扩容,需要的话去扩容。
-
-
- 数组初始化时侯的线程安全
-
-
- 数组初始化时首先通过自旋来保证一定可以初始化成功,然后通过 CAS 设置 SIZECTL 变量的
值,来保证同一时刻只能有一个线程对数组进行初始化, CAS 成功之后,还会再次判断当前数组
是否已经初始化完成,如果已经初始化完成,就不会再次初始化,通过自旋 + CAS + 双重 check
等手段保证了数组初始化时的线程安全
- 数组初始化时首先通过自旋来保证一定可以初始化成功,然后通过 CAS 设置 SIZECTL 变量的
-
-
- 新增槽点值时的线程安全:
-
-
- 通过死循环来保证一定可以新增成功
-
-
-
-
- 在新增之前,通过 for (Node[] tab = table;;) 这样的死循环来保证一定可以新增成功,成功后再退出
-
-
-
-
- 当前槽点为空就用CAS进行尝试赋值
-
-
-
-
- 当前节点为空的情况下这里没有直接赋值,因为判断为空之后槽点可能快速被其它线程赋值,所以用CAS尝试,失败了之后再走有槽节点的流程
-
-
-
-
- 当前槽节点有值就锁住它
-
-
-
-
- 通过sy锁来锁着,如下所示:

- 通过sy锁来锁着,如下所示:
-
-
-
-
- 红黑树旋转时锁住根节点,来让红黑树只能被一个线程旋转
-
-
- 扩容时候的线程安全
-
-
- 扩容方法是transfer(),主要思想如下:
-
-
-
-
- 首先需要把老数组的值全部拷贝到扩容之后的新数组上,先从数组的队尾开始拷贝;
-
-
-
-
-
- 拷贝数组的槽点时,先把原数组槽点锁住,保证原数组槽点不能操作,成功拷贝到新数组时,把原数组槽点赋值为转移节点;
-
-
-
-
-
- 这时如果有新数据正好需要 put 到此槽点时,发现槽点为转移节点,就会一直等待,所以在
扩容完成之前,该槽点对应的数据是不会发生变化的;
- 这时如果有新数据正好需要 put 到此槽点时,发现槽点为转移节点,就会一直等待,所以在
-
-
-
-
-
- 从数组的尾部拷贝到头部,每拷贝成功一次,就把原数组中的节点设置成转移节点;
-
-
-
-
-
- 直到所有数组数据都拷贝到新数组时,直接把新数组整个赋值给数组容器,拷贝完成。
-
-
-
-
- 代码中是如何保证线程安全的
-
-
-
-
- 拷贝槽点时,会把原数组的槽点锁住;
-
-
-
-
-
- 拷贝成功之后,会把原数组的槽点设置成转移节点,这样如果有数据需要 put 到该节点时,
发现该槽点是转移节点,会一直等待,直到扩容成功之后,才能继续 put,可以参考 put 方
法中的 helpTransfer 方法;
- 拷贝成功之后,会把原数组的槽点设置成转移节点,这样如果有数据需要 put 到该节点时,
-
-
-
-
-
- 从尾到头进行拷贝,拷贝成功就把原数组的槽点设置成转移节点。
-
-
-
-
-
- 等扩容拷贝都完成之后,直接把新数组的值赋值给数组容器,之前等待 put 的数据才能继续
put。
- 等扩容拷贝都完成之后,直接把新数组的值赋值给数组容器,之前等待 put 的数据才能继续
-
-
- get
-
- 概述:先获取数组的下标,然后通过判断数组下标的 key是否和我们的 key 相等,相等的话直接返回,如果下标的槽点是链表或红黑树的话,分别调用相应的查找数据的方法,整体思路和 HashMap 很像,
03 [*]并发 List、 Map 源码面试题
下面
04 场景集合:并发 List、 Map 的应用场景
在流引擎中的使用
第四章:队列
01 LnkedBlockingQueue源码解析
- 整体架构

BlockingQueue的一些主要操作
- 类注释:
基于链表、先进先出、链表大小最大为 Integer.MaxValue、可以使用 Collention和Interator的所有操作
- 主要构成
-
- 链表存储+锁+迭代器
-
- 数据节点:Node节点(存放数据),可以看出它是单向的链表
-
- 成员变量:
-
-
- 链表的容量(默认是Integer.MAX_VALUE)
-
-
-
- 已有元素的大小(这里用的原子类,线程安全的)
-
-
-
- 链表头尾指针
-
-
-
- 用于 take和put的两把lock锁,以及对应的两个Condition
-
- 初始化
-
- 构造函数-不指定容量
-
- 构造函数-指定容量
-
- 构造函数-指定了一个集合:然后加 putLock,把元素放入,最后解锁
- 阻塞新增
Put()
-
- 先设置一个中断锁
-
- 如果队列已满 就进入等待
notFull.await()(这里用了Condition 后续需要知晓其原理)
- 如果队列已满 就进入等待
-
- 没满就加入元素,加入后还没满就尝试 唤醒其它put
notFull.signal();,然后解锁,如果队列里面只有一个元素 再唤醒一个take等待线程
- 没满就加入元素,加入后还没满就尝试 唤醒其它put
- 阻塞删除
take()
-
- 和put的流程差不太多 反过来了就
-
- 而对于 peek操作,加锁后 用头节点指针 拿到第一个元素 返回即可
02 SynchronousQueue源码解析
- 概述
-
- 放入一个数据,必须要等消费掉才能返回,在MQ中间件中使用的比较多
-
- 抽象出了两种实现,一个是队列,一个是栈,对应了两个内部类,put和take方法 调用了这两个内部类中的transfer方法
-
- SynchronousQueue的类图与 LnkedBlockingQueue是一样的,只是他其中的 isEmpty remove等方法给了默认实现
- 注释
-
- 队列不存储数据,无法迭代,没有大小
-
- 插入操作必须等待 另外一个删除线程 完成操作
-
- 堆栈不公平,队列是公平的
- 结构细节
-
- 接口Transferer:负责put or take
-
- TransferStack:非公平的堆栈,默认使用这个,效率较高
-
- TransferQueue:队列结构,公平的
- 非公平的堆栈,
class SNode
-
- 先入后出,所以非公平
-
- 代码结构:其中 最重要的是 SNode match,有数据时可以被 take,无数据时 可以put
-
- 具体实现:没看
- 公平的队列,
class QNode
-
- 具体实现:very 复杂
03 DelayQueue源码解忻
- 概述
延迟队列,在延迟一定时间后再去获取资源
- 类注释
对头的元素 会更早过期,过期后才能被take
- 类图
和上面的一样,其中对元素的要求是 要继承Delayed
public class DelayQueue<E extends Delayed> extends AbstractQueue<E> implements BlockingQueue<E>
- 放数据
上锁,然后就是新增元素的时候要进行扩容,然后还进行排序,确保过期最快的 队列放到队头
- 拿数据
先判断队头是否为空,为空就等待,不为空就看过期没,没过期就等待,可设置过期时间
- 总结:要学会对已有的代码 尽量复用
04 ArayBlockingQueue源码解析
- 概述
-
- 有界阻塞数组,创建后无法扩容
-
- 先进先出队列(这是个循环队列)
-
- take和put会阻塞
- 数据结构
-
- 一个数组,初始化时需要设置
-
- 下次拿数据 和下次放数据的索引位置
-
- 可重入锁和 notEmpty 和 notNull两个Condition,如下所示
// take 的队列
private final Condition notEmpty;
// put 的队列
private final Condition notFull;
- 初始化
-
- 初始化数组大小
-
- 设置锁(公平or非公平)
-
- 初始化 condition
- 数据新增
put()
如果队列满 就要进入无限阻塞 ,notFull.await()
如果要插入,要判断下次是否在队尾,如果是就要从头开始插入(循环队列)
最后会唤醒 因为队列为空导致等待的线程notEmpty.signal();
- 拿数据
take()
如果队列为空,就进入无限阻塞,notEmpty.await()
如果数据也分是否是队尾,如果是就要从头开始拿数据
最后会唤醒 因为队列满了导致等待的线程notFull.signal();
- 删除
remove()
要考虑好几个特殊的场景,回头再仔细看看
05 队列在源码方面的面试题
下面
06 举一反三:队列在Java 其它源码中的应用
- 队列与线程池
-
- newFixedThreadPool
// 不建议使用这玩意儿,队列的大小是Integer的最大值,容量太大
public static ExecutorService newFixedThreadPool(int nThreads) {
// LinkedBlockingQueue
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
-
- newSingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() {
// LinkedBlockingQueue
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
-
- newCachedThreadPool
public static ExecutorService newCachedThreadPool() {
//SynchronousQueue
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
-
- newScheduledThreadPool
public ScheduledThreadPoolExecutor(int corePoolSize) {
//DelayedWorkQueue
super(corePoolSize, Integer.MAX_VALUE,
DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,
new DelayedWorkQueue());
}
- 队列和锁,ReentrantLock中 自己实现了一个同步队列
07 整体设计:队列设计思想工作中使用场景
- 区别

区别可以从 数据结构、入队出队方式、生产者和消费者之间的通信机制(强关联和无关联)
- 工作中使用的场景
一般多用 LinkedBlockingQueue,固定数据用 ArrayBlockingQueue,对于DelayQueue 有以下场景,
转账时调用接口超时,这时使用延时队列,等一会去对个账
08 惊叹面试官:由浅入深手写队列
看代码 demo.four.DIYQueue和 demo.four.DIYQueueDemo
第5章线程
01 Thread源码解析
- 类注释
-
- 每个线程都有优先级,优先级高的可能会先执行
-
- 父线程创建的子线程,子线程和父线程的 优先级、是否是守护线程等属性是一致的
-
- Java中有两种线程,用户线程和守护线程 ,守护线程需要在用户线程全退出的时候才能结束,而这个时候Jvm也停止了运行,其中守护线程创建方式为
new Thread() .setDaemon,而像垃圾回收进程就是守护线程
- Java中有两种线程,用户线程和守护线程 ,守护线程需要在用户线程全退出的时候才能结束,而这个时候Jvm也停止了运行,其中守护线程创建方式为
- 线程基本概念
-
- 线程的状态转换

- 线程的状态转换
-
- 优先级:要注意 这只是可能性比较大,对于优先级较高的而言
// 最低优先级
public final static int MIN_PRIORITY = 1;
// 普通优先级,也是默认的
public final static int NORM_PRIORITY = 5;
// 最大优先级
public final static int MAX_PRIORITY = 10
-
- 守护线程
默认线程都是非守护的,可以设置daemon属性 来变成守护线程,jvm退出时不考虑守护线程,一般用于一些监控系统,即使抛错也不影响主业务
- 守护线程
-
- ClassLoader
- 创建线程的两种方式
-
- 继承Thread类
-
-
- start() 方法流程
-
-
-
-
- 判断是否已经初始化
-
-
-
-
-
- 加入线程组
-
-
-
-
-
- 调用一个 navite 方法
start0();来创建一个线程
- 调用一个 navite 方法
-
-
-
- 实现Runnable接口
-
-
- 直接调用run方法 依然是主线程在调用,不创建线程
-
-
-
- 调用start方法,才会去新建一个线程
-
// 简单的运行,不会新起线程,target 是 Runnable
public void run() {
if (target != null) {
target.run();
}
}
- 线程初始化
-
- 无参构造器:其中有自动命名
-
- init()函数:无参和Runable为参数的构造方法 中都会调用这个方法,在这个方法中 会获取父线程,然后继承父线程的 守护属性、优先级、calssLoader(contextClassLoader)、inheritableThreadLocals 里面的值
- 其它操作
-
- join:
-
- yield(navite方法):让CPU重新选择一个线程来执行
-
- sleep(navite方法):睡一会,不释放资源
-
- interrupt: 线程通过
Object#wait ()、Thread#join ()、Thread#sleep (long),这些方法运行后进入 WAITING 或者 TIMED_WAITING ,这时候打断这些线程就会抛出InterruptedException异常,然后进入 TERMINATED 状态
- interrupt: 线程通过
02 Future、ExecutorService 源码解析
- 线程API之间的关联

- demo
// 首先我们创建了一个线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 3, 0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>());
// futureTask 我们叫做线程任务,构造器的入参是 Callable
FutureTask futureTask = new FutureTask(new Callable<String> () {
@Override
public String call() throws Exception {
Thread.sleep(3000);
// 返回一句话
return "我是子线程"+Thread.currentThread().getName();
}
});
// 把任务提交到线程池中,线程池会分配线程帮我们执行任务
executor.submit(futureTask);
// 得到任务执行的结果
String result = (String) futureTask.get();
- Callable
public interface Callable<V> {
V call() throws Exception;
}
- FutureTask ->(实现) RunnableFuture -> (继承)Future & Runable
-
- Future,主要方法如下:
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
// 带有超时时间的 获取结果
V get(long timeout, TimeUnit unit) 。。。
-
- RunnableFuture,这个接口的目的 就是为了 能够管理run方法
public interface RunnableFuture<V> extends Runnable, Future<V> {
void run();
}
-
- 统一 Runable&Callable 的FutrueTask,这样避免了 线程池还得实现两套为他俩
- FutrueTask 细探究
-
- 类定义:
public class FutureTask implements RunnableFuture {}
- 类定义:
-
- FutureTask的属性:
-
-
- 各种任务状态
-
-
-
- 组合了 Callable,
private Callable callable;
- 组合了 Callable,
-
-
-
- 当前任务线程,和调用get的线程被
-
-
- 构造函数:
-
-
- callable 构造函数,设置callable 和 任务状态
-
-
-
- runnable 构造函数,
FutureTask(Runnable runnable, V result)这里用了 适配器模式,把runable 转换成 callable,要重点看看,这样 FutureTask对外提供的就只是 功能更加丰富的 Callable接口了。
- runnable 构造函数,
-
-
- FutureTask 对 Future 接口方法的实现
-
-
V get(long timeout, TimeUnit unit)
其中 先判断等待是否超时,超时就抛异常,其中等待方法 awaitDone() 里面有个死循环,很重要
-
-
-
-
- 用了 yield方法 ,让给其它线程
-
-
-
-
-
- Thread.interrupted(),判断是否已经被中断了,被中断的话就抛 中断异常
-
-
-
-
-
- 底层阻塞使用的是 LockSupport.park 使线程进入等待 或超时等待状态
-
-
-
-
- run(),后续再看下 到底是什么线程执行的
-
-
-
- cancel(),进行打断
-
03 押宝线程源码面试题
下面
第6章锁
01 AbstractQueuedSynchronizer源码解析(上)
整体架构
- 概述 AQS中有两个队列,同步队列和条件队列,而aqs本身算是一套框架,定义了获得锁和释放锁的代码结构

- 类注释(重要的)
-
- 提供了一个框架,定义了先进先出的同步队列,获取不到锁的进程就先进去排队
-
- 有个状态字段,通过它来判断是否能获得锁
-
- 子类 可以通过cas来对上面的 状态字段进行赋值,来判断什么值 可以拿到锁
-
- 子类可以新建非 public 的内部类,用内部类来继承 AQS,从而实现锁的功能;
-
- AQS 提供了排它模式和共享模式两种锁模式。排它模式下:只有一个线程可以获得锁,共享 模式可以让多个线程获得锁,子类 ReadWriteLock 实现了两种模式;
-
- 内部类 ConditionObject 可以被用作 Condition,我们通过 new ConditionObject () 即可得到 条件队列
-
- AQS 实现了锁、排队、锁队列等框架,至于如何获得锁、释放锁的代码并没有实现,比如 tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared、isHeldExclusively 这些方法, AQS 中默认抛 UnsupportedOperationException 异常,都是需要子类去实现的;
-
- AQS 继承 AbstractOwnableSynchronizer 是为了方便跟踪获得锁的线程,可以帮助监控和诊 断工具识别是哪些线程持有了锁;
-
- AQS 同步队列和条件队列,获取不到锁的节点在入队时是先进先出,但被唤醒时,可能并不会按照先进先出的顺序执行。
- 类定义
// 1. 是个抽象类,就是让子类去实现它的一些功能的
// 2. AbstractOwnableSynchronizer 的作用就是为了 方便知道当前是哪个线程获得了锁
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer
implements java.io.Serializable {
}
- 基础属性
-
- 基础属性
//同步器的状态,比如通过cas设置成了1就加锁成功, 0算是解锁,赋值失败相当于操作失败
// 可重入锁的话就是 获得锁就 +1 ,释放锁就 -1
private volatile int state;
// 等待时间超时阈值
static final long spinForTimeoutThreshold = 1000L;
-
- 同步队列属性
获取不到锁的线程 都要在队列中进行等待,释放锁之后从队头拿一个,让他去重新竞争,这个队列是双向链表,
- 同步队列属性
// 同步队列的头。 其中Node节点 同步队列和条件队列共用
private transient volatile Node head;
// 同步队列的尾
private transient volatile Node tail;
-
- 条件队列的属性
// 条件队列,从属性上可以看出是链表结构 他在功能上只是对 锁功能的一种补充
public class ConditionObject implements Condition, java.io.Serializable {
private transient Node firstWaiter;
private transient Node lastWaiter;
}
-
- Node,它为条件队列和同步队列所用,包装了线程
static final class Node {
/** 同步队列单独的属性*/
//node 是共享模式
static final Node SHARED = new Node();
//node 是排它模式
static final Node EXCLUSIVE = null;
// 当前节点的前节点 节点 acquire 成功后就会变成 head 节点不能被 cancelled
volatile Node prev;
// 当前节点的下一个节点
volatile Node next;
/** 两个队列共享的属性*/
// 表示当前节点的状态,来控制节点的行为 普通同步节点,就是 0 ,条件节点是 CONDITION -2
volatile int waitStatus;
// waitStatus 的状态有以下几种
static final int CANCELLED = 1;
// 节点在自旋获取锁的时候,如果前一个节点的状态是SIGNAL,那就阻塞休息,否则一直自旋
static final int SIGNAL = -1;
// 当前 node 正在条件队列中,当有节点从同步队列转移到条件队列时,状态就会被更改成CONDITION
static final int CONDITION = -2;
// 无条件传播,共享模式下,该状态的进程处于可运行状态
static final int PROPAGATE = -3;
// 当前节点的线程
volatile Thread thread;
/**
* 在同步队列中,nextWaiter 并不真的是指向其下一个节点,我们用 next 表示同步队列的下
* 一个节点,nextWaiter 只是表示当前 Node 是排它模式还是共享模式
* 但在条件队列中,nextWaiter 就是表示下一个节点元素
*/
Node nextWaiter;
}
-
- 共享锁和排他锁
排它锁是指 同一时刻只有一个线程能获得锁和释放锁,排他锁则可以有多个线程,并且可以设置数量
- 共享锁和排他锁
- Condition
-
- 他的实现类 比如有ConditionObject (条件队列)
-
- 主要类注释:
-
-
- 当 lock 代替 synchronized 来加锁时,Condition 就可以用来代替 Object 中相应的监控方法了, 比如 Object#wait ()、Object#notify、Object#notifyAll 这些方法
-
-
-
- Condition 提供了明确的语义和行为,这点和 Object 监控方法不同
-
-
- 注意其在 队列中的应用
-
- await() 方法
是当前线程一直等待,直到下面中的一种情况发生
- await() 方法
-
-
- 有线程使用了 signal 方法,正好唤醒了条件队列中的当前线程;
-
-
-
- 有线程使用了 signalAll 方法;
-
-
-
- 其它线程打断了当前线程,并且当前 线程支持被打断
-
-
-
- 被虚假唤醒(有一些没有用的线程也被唤醒了)
-
-
- 其它方法
// long值表示剩余等待时间,如果小于等于 0 ,说明等待时间过了,纳秒能避免 计算时间导致误差
long awaitNanos(long nanosTimeout) throws InterruptedException;
// 虽然入参可以是任意单位的时间,但底层仍然转化成纳秒
boolean await(long time, TimeUnit unit) throws InterruptedException;
// 唤醒条件队列中的一个线程,在被唤醒前必须先获得锁
void signal();
// 唤醒条件队列中的所有线程
void signalAll();
同步器的状态
- state
-
- state 是锁的状态,上面讲过
- waitStatus
-
- 是节点Node的状态,上面讲过
获取锁
- 概述
获取锁 最常见的案例就是Lock.lock (),这个lock方法 会去调用acquire或tryAcquire方法,
前者aqs已经实现,他会先尝试是否能获取到锁,获取不到时 再进入同步队列中等待锁
后者需要子类实现
acquire()排他锁
// 排它模式下,尝试获得锁
public final void acquire(int arg) {
// tryAcquire 方法是需要实现类去实现的,实现思路一般都是 cas 给 state 赋值来决定是否能获得锁
if (!tryAcquire(arg) &&
// addWaiter 入参代表是排他模式
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
-
- 主要步骤
-
-
- 先调用tryAcquire(), 看能否获取成功,成功直接返回,要注意这个tryAcquire 是子类实现的
-
-
-
- 然后调用 addWaiter(),将当前放到同步队列的队尾
-
-
-
-
- 这里是先试了一下,不成功再走自旋
-
-
-
-
- 最后调用 acquireQueued()方法,有两个作用
-
-
-
-
- 阻塞当前节点(必须要把前一个节点的 状态设置为SIGNAL才行)
-
-
-
-
-
- 节点被唤醒时,使其能 够获得锁;
-
-
-
-
- 上面 如果都失败了 就打断线程
-
-
- addWaiter()
-
- acquireQueued()
-
-
- 如上所述,这个节点要把前面的所有节点的状态设置成 SIGNAL才行
-
-
-
- 然后才会调用 parkAndCheckInterrupt()使当前线程阻塞
-
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
-
-
- 上面的这个 LockSupport.park(this);底层使用的还是 Unsafe类
-
public static void park(Object blocker) {
Thread t = Thread.currentThread();
setBlocker(t, blocker);
U.park(false, 0L);
setBlocker(t, null);
}
private static void setBlocker(Thread t, Object arg) {
// Even though volatile, hotspot doesn't need a write barrier here.
U.putObject(t, PARKBLOCKER, arg);
}

-
-
- 与上面相对的 unPark
-
public static void unpark(Thread thread) {
if (thread != null)
U.unpark(thread);
}
acquireShared()共享锁
主要就是 一个节点获取锁之后,也会释放后面的节点
02 AbstractQueuedSynchronizer源码解析(下)
- 释放排他锁
release(int arg) - > release(int arg) 这个是子类实现的 -> 找到要唤醒的节点,然后使用LockSupport.unpark(s.thread);唤醒线程
- 释放共享锁
- 条件队列
为什么要用 条件队列(不好理解),下面都是定义在ConditionObject中的方法(注意在队列中用过)
-
await():先是加入到 条件队列的队尾,然后阻塞到这个地方,直到被唤醒的时候,再把当前这个节点加入到同步队列中去
-
signal(): 从条件队列中的 头节点开始唤醒,并把这个 节点加入到同步队列中去
-
signalAll():则是唤醒全部
03 Reentrantlock源码解析
- 类结构
//类定义
public class ReentrantLock implements Lock, java.io.Serializable {
}
// 获得锁方法,获取不到锁的线程会到同步队列中阻塞排队
void lock();
// 获取可中断的锁
void lockInterruptibly() throws InterruptedException;
// 尝试获得锁,如果锁空闲,立马返回 true,否则返回 false
boolean tryLock();
// 带有超时等待时间的锁,如果超时时间到了,仍然没有获得锁,返回 false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 释放锁
void unlock();
// 得到新的 Condition
Condition newCondition();



- 两个构造器
// 无参数构造器,相当于 ReentrantLock(false),默认是非公平的
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
- Sync同步器
-
- nonfairTryAcquire
-
-
- 本方法用于尝试获得非公锁
-
-
-
- 先判断同步器的状态,如果没人持有,就让当前线程持有,如果当前线程已经持有了,那就增加线程持有的数量(可重入锁),上面条件都不满足的话 就返回false,接下来就会是加入同步队列的操作
-
-
- tryLock()
public boolean tryLock() {
// 入参数是 1 表示尝试获得一次锁
return sync.nonfairTryAcquire(1);
}
-
- tryRelease() 方法,公平和非公平锁都在用
-
-
- 计算出 要释放的数量
int c = getState() - releases;
- 计算出 要释放的数量
-
-
-
- 然后对c 进行校检,比如等于0的话就代表已经都是放了锁,不为零在设置就行了
-
- NonfairSync 非公平锁
NonfairSync实现了lock和tryAcquire两个方法,如下
// 先赋值,赋值成功就记录拿到锁的线程为当前所有,否则走acquire方法
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
// 直接使用的是 Sync.nonfairTryAcquire 方法
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
- FairSync 公平锁
也是 实现了lock和tryAcquire两个方法
// acquire 是 AQS 的方法,表示先尝试获得锁,失败之后进入同步队列阻塞等待
final void lock() {
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// hasQueuedPredecessors 是实现公平的关键
// 会判断当前线程是不是属于同步队列的头节点的下一个节点(头节点是释放锁的节点)
// 如果是(返回 false),符合先进先出的原则,可以获得锁
// 如果不是(返回 true),则继续等待
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 可重入锁
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
- 捋一下
-
- FairSync 和 NonfairSync 各自实现了 lock方法,和tryAcquire方法,Sync中实现了tryLock() 和reyRelese() 方法
-
- lock():非公平锁是先设置一下状态值,不成功再调用aqs的acquire() 方法,公平锁则是直接acquire() 方法
-
- tryLock() 方法:调用nonfairTryAcquire 尝试一下
-
- tryAcquire() 方法:非公平的就是直接调用nonfairTryAcquire 去尝试,公平的会先使用 hasQueuedPredecessors 看看自己是不是在队头的下一个节点,是的话才是能获取锁的
-
- tryRelease(): 这个就没有公平与不公平的区别了
04 CountDownLatch、Atomic 等其它源码解析
CountDownLatch

- await
-
- 有定时不定时两种
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
// 带有超时时间的,最终都会转化成毫秒
public boolean await(long timeout, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
-
- acquireSharedInterruptibly和tryAcquireSharedNanos都是aqs底层的方法,主要分为两步
-
-
- 使用子类的 tryAcquireShared判断能否获取锁
-
// 如果当前同步器的状态是 0 的话,表示可获得锁
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
-
-
- 加入队列进行等待,aqs已经实现了这个
-
-
- CountDownLatch的初始化方法
// 初始化,count 代表 state 的初始化值
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
// new Sync 底层代码是 state = count;
this.sync = new Sync(count);
}
- countDown
public void countDown() {
sync.releaseShared(1);
}
releaseShared是aqs实现的,第一步是调用子类的 tryReleaseShared尝试释放锁,第二步是释放当前节点的后置等待节点。
Atomic
- 它是线程安全的,但要看你咋用,里面的value只是用 volatile修饰,直接使用get和set方法的话如下所示,都没加锁
public final int get() {
return value;
}
public final void set(int newValue) {
value = newValue;
}
- 线程安全的设置方法,可以使用 compareAndSet 或者 compareAndSet,底层使用的都是unsafe()类,最终的原子性由操作系统来保证
public final native boolean compareAndSetInt(Object o, long offset,
int expected,
int x)
05 只求问倒:连环相扣系列锁面试题
AQS 相关面试题
- 说说自己对 AQS 的理解?
答:回答这样的问题的时候,面试官主要考察的是你对 AQS 的知识有没有系统的整理,建议回 答的方向是由大到小,由全到细,由使用到原理。 如果和面试官面对面的话,可以边说边画出我们在 AQS 源码解析上中画出的整体架构图,并且 可以这么说:
-
- AQS 是一个锁框架,它定义了锁的实现机制,并开放出扩展的地方,让子类去实现,比如我 们在 lock 的时候,AQS 开放出 state 字段,让子类可以根据 state 字段来决定是否能够获得 锁,对于获取不到锁的线程 AQS 会自动进行管理,无需子类锁关心,这就是 lock 时锁的内 部机制,封装的很好,又暴露出子类锁需要扩展的地方;
-
- AQS 底层是由同步队列 + 条件队列联手组成,同步队列管理着获取不到锁的线程的排队和释 放,条件队列是在一定场景下,对同步队列的补充,比如获得锁的线程从空队列中拿数据, 肯定是拿不到数据的,这时候条件队列就会管理该线程,使该线程阻塞;
-
- AQS 围绕两个队列,提供了四大场景,分别是:获得锁、释放锁、条件队列的阻塞,条件队列的唤醒,分别对应着 AQS 架构图中的四种颜色的线的走向。
-
- 以上三点都是 AQS 全局方面的描述,接着你可以问问面试官要不要说细一点,可以的话,按照 AQS 源码解析上下两篇,把四大场景都说一下就好了。 这样说的好处是很多的:
-
-
- 面试的主动权把握在自己手里,而且都是自己掌握的知识点;
-
-
-
- 由全到细的把 AQS 全部说完,会给面试官一种你对 AQS 了如指掌的感觉,再加上全部说完 耗时会很久,面试时间又很有限,面试官就不会再问关于 AQS 一些刁钻的问题了,这样 AQS 就可以轻松过关。 当然如果你对 AQS 了解的不是很深,那么就大概回答下 AQS 的大体架构就好了,就不要说的特别细,免得给自己挖坑。
-
- 多个线程通过锁请求共享资源,获取不到锁的线程怎么办?
答:加锁(排它锁)主要分为以下四步:
-
- 尝试获得锁,获得锁了直接返回,获取不到锁的走到 2;
-
- 用 Node 封装当前线程,追加到同步队列的队尾,追加到队尾时,又有两步,如 3 和 4;
-
- 自旋 + CAS 保证前 一个节点的状态置为 signal;
-
- 阻塞自己,使当前线程进入等待状态。 获取不到锁的线程会进行 2、3、4 步,最终会陷入等待状态,这个描述的是排它锁。
- 排它锁和共享锁的处理机制是一样的么?
答:排它锁和共享锁在问题 1.2 中的 2、3、4 步骤都是一样的, 不同的是在于第一步,线程获 得排它锁的时候,仅仅把自己设置为同步队列的头节点即可,但如果是共享锁的话,还会去唤醒 自己的后续节点,一起来获得该锁。
- 共享锁和排它锁的区别?
答:排它锁的意思是同一时刻,只能有一个线程可以获得锁,也只能有一个线程可以释放锁。 共享锁可以允许多个线程获得同一个锁,并且可以设置获取锁的线程数量,共享锁之所以能够做 到这些,是因为线程一旦获得共享锁,把自己设置成同步队列的头节点后,会自动的去释放头节 点后等待获取共享锁的节点,让这些等待节点也一起来获得共享锁,而排它锁就不会这么干。
- 排它锁和共享锁说的是加锁时的策略,那么锁释放时有排它锁和共享锁的策略么?
答:是的,排它锁和共享锁,主要体现在加锁时,多个线程能否获得同一个锁。 但在锁释放时,是没有排它锁和共享锁的概念和策略的,概念仅仅针对锁获取。
- 描述下同步队列?
答:同步队列底层的数据结构就是双向的链表,节点叫做 Node,头节点叫做 head,尾节点叫做 tail,节点和节点间的前后指向分别叫做 prev、next,如果是面对面面试的话,可以画一下 AQS 整体架构图中的同步队列。 同步队列的作用:阻塞获取不到锁的线程,并在适当时机释放这些线程。 实现的大致过程:当多个线程都来请求锁时,某一时刻有且只有一个线程能够获得锁(排它锁), 那么剩余获取不到锁的线程,都会到同步队列中去排队并阻塞自己,当有线程主动释放锁时,就 会从同步队列中头节点开始释放一个排队的线程,让线程重新去竞争锁。
- 描述下线程入、出同步队列的时机和过程?
答:(排它锁为例)从 AQS 整体架构图中,可以看出同步队列入队和出队都是有两个箭头指向,所 以入队和出队的时机各有两个。 同步队列入队时机:
- 多个线程请求锁,获取不到锁的线程需要到同步队列中排队阻塞;
- 条件队列中的节点被唤醒,会从条件队列中转移到同步队列中来。 同步队列出队时机: 1. 锁释放时,头节点出队;
- 获得锁的线程,进入条件队列时,会释放锁,同步队列头节点开始竞争锁。 四个时机的过程可以参考 AQS 源码解析,1 参考 acquire 方法执行过程,2 参考 signal 方法,3 参考 release 方法,4 参考 await 方法。
- 为什么 AQS 有了同步队列之后,还需要条件队列?
答:的确,一般情况下,我们只需要有同步队列就好了,但在上锁后,需要操作队列的场景下, 一个同步队列就搞不定了,需要条件队列进行功能补充,比如当队列满时,执行 put 操作的线程 会进入条件队列等待,当队列空时,执行 take 操作的线程也会进入条件队列中等待,从一定程 度上来看,条件队列是对同步队列的场景功能补充。
- 描述一下条件队列中的元素入队和出队的时机和过程?
答:入队时机:执行 await 方法时,当前线程会释放锁,并进入到条件队列。 出队时机:执行 signal、signalAll 方法时,节点会从条件队列中转移到同步队列中。 具体的执行过程,可以参考源码解析中 await 和 signal 方法。
- 描述一下条件队列中的节点转移到同步队列中去的时机和过程?
答:时机:当有线程执行 signal、signalAll 方法时,从条件队列的头节点开始,转移到同步队列 中去。 过程主要是以下几步:
-
- 找到条件队列的头节点,头节点 next 属性置为 null,从条件队列中移除了;
-
- 头节点追加到同步队列的队尾;
-
- 头节点状态(waitStatus)从 CONDITION 修改成 0(初始化状态);
-
- 将节点的前一个节点状态置为 SIGNAL。
- 线程入条件队列时,为什么需要释放持有的锁?
答:原因很简单,如果当前线程不释放锁,一旦跑去条件队里中阻塞了,后续所有的线程都无法 获得锁,正确的场景应该是:当前线程释放锁,到条件队列中去阻塞后,其他线程仍然可以获得 当前锁。
AQS 子类锁面试题
- 你在工作中如何使用锁的,写一个看一看?
答:这个照实说就好了,具体 demo 可以参考:demo.sixth.ConditionDemo。
- 如果我要自定义锁,大概的实现思路是什么样子的?
答:现在有很多类似的问题,比如让你自定义队列,自定义锁等等,面试官其实并不是想让我们 重新造一个轮子,而是想考察一下我们对于队列、锁理解的深度,我们只需要选择自己最熟悉的 API 描述一下就好了,所以这题我们可以选择 ReentrantLock 来描述一下实现思路:
- 新建内部类继承 AQS,并实现 AQS 的 tryAcquire 和 tryRelease 两个方法,在 tryAcquire 方 法里面实现控制能否获取锁,比如当同步器状态 state 是 0 时,即可获得锁,在 tryRelease 方法里面控制能否释放锁,比如将同步器状态递减到 0 时,即可释放锁;
- 对外提供 lock、release 两个方法,lock 表示获得锁的方法,底层调用 AQS 的 acquire 方法, release 表示释放锁的方法,底层调用 AQS 的 release 方法。
- 描述 ReentrantLock 两大特性:可重入性和公平性?底层分别如何实现的?
-
- 可重入性说的是线程可以对共享资源重复加锁,对应的,释放时也可以重复释放,对于 ReentrantLock 来说,在获得锁的时候,state 会加 1,重复获得锁时,不断的对 state 进行递增 即可,比如目前 state 是 4,表示线程已经对共享资源加锁了 4 次,线程每次释放共享资源的锁 时,state 就会递减 1,直到递减到 0 时,才算真正释放掉共享资源。
-
- 公平性和非公平指的是同步队列中的线程得到锁的机制,如果同步队列中的线程按照阻塞的顺序 得到锁,我们称之为公平的,反之是非公平的,公平的底层实现是 ReentrantLock 的 tryAcquire 方法(调用的是 AQS 的 hasQueuedPredecessors 方法)里面实现的,要释放同步队列的节点时 (或者获得锁时),判断当前线程节点是不是同步队列的头节点的后一个节点,如果是就释放, 不是则不能释放,通过这种机制,保证同步队列中的线程得到锁时,是按照从头到尾的顺序的。
- 如果一个线程需要等待一组线程全部执行完之后再继续执行,有什么好的办法么?是如何实 现的?
答:CountDownLatch 就提供了这样的机制,比如一组线程有 5 个,只需要在初始化 CountDownLatch 时,给同步器的 state 赋值为 5,主线程执行 CountDownLatch.await ,子线程 都执行 CountDownLatch.countDown 即可。
- Atomic 原子操作类可以保证线程安全,如果操作的对象是自定义的类的话,要如何做呢?
答: Java 为这种情况提供了一个 API:AtomicReference,AtomicReference 类可操作的对象是个 泛型,所以支持自定义类。
06 经验总结:各种锁在工作中使用场景和细节
没啥讲的
07 从容不迫:重写锁的设计结构和细节
第7章线程池
01 ThreadPoolExecutor源码解析
- 整体架构(问线程池的 时候下面这个完整说出来)

类图
其中上面这几个类的大致介绍如下
-
- Executor : 这个接口中只定义了一个方法
void execute(Runnable cammand)
- Executor : 这个接口中只定义了一个方法
-
- ExecutorService: 定义了一些方法
-
- AbstractExecutorService:封装了 Executor的很多通用功能,其中就有summit功能
-
- ThreadPoolExecutor:拥有上面仨的 全部功能
- ThreadPoolExecutor 类的注释
-
- 好多面试题
- ThreadPoolExecutor 的重要属性
-
- 记录了好多 ,有对任务执行情况的计数
-
- 线程池的状态扭转

- 线程池的状态扭转
-
- 有一个非常重要的属性
Worker,它是任务的代理,线程池中的最小执行单位
- 有一个非常重要的属性
private final class Worker extends AbstractQueuedSynchronizer implements Runnable
{// 任务运行的线程
final Thread thread;
// 需要执行的任务
Runnable firstTask;
// 非常巧妙的设计,Worker 本身是个 Runnable,把自己作为任务传递给 thread
// 内部有个属性又设置了 Runnable
Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
// 把 Worker 自己作为 thread 运行的任务
this.thread = getThreadFactory().newThread(this);
}
// Worker 本身是 Runnable, run 方法是 Worker 执行的入口, runWorker 是外部的方法
public void run() {
runWorker(this);
}
// 省略一堆的 aqs方法
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
// 这行代码 代表如果task为空的话 还会去队列中拿其它的task
while (task != null || (task = getTask()) != null) {
//省略。。。
beforeExecute(wt, task); //钩子前置
task.run();
afterExecute(task, thrown); //钩子后置
//省略。。。
}
}
- 线程池的任务提交 ThreadPoolExecutor的execute() 方法
-
- 主要就是判断 是否能分配线程来运行,能的话就走addWork() 方法去创建新的线程来运行,不能的话就是会加入到 等待队列或者走拒绝策略
-
- addWork()方法
-
-
- 主要功能是添加Work
-
-
-
- 先是执行一堆校检
-
-
-
- 主要流程
-
void execute(Runnable command)
// 调用addWork()
addWorker(Runnable firstTask, boolean core)
// addworker中的重点代码
w = new Worker(firstTask);
final Thread t = w.thread;
workers.add(w); // 加入到这个类中的集合里
t.start();

- 线程执行完之后
上述runwork中有一行代码如下
while (task != null || (task = getTask()) != null) {
其中getTask() 方法是从阻塞队列里面 继续拿worker
private Runnable getTask() {
//省略 。。。
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take();
//省略
- 补充:summit方法和execute方法
summit方法 由ExecutorService接口定义,AbstractExecutorService 进行实现,如下所示
public Future<?> submit(Runnable task) {
if (task == null) throw new NullPointerException();
RunnableFuture<Void> ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}
public <T> Future<T> submit(Runnable task, T result) {
if (task == null) throw new NullPointerException();
RunnableFuture<T> ftask = newTaskFor(task, result);
execute(ftask);
return ftask;
}
public <T> Future<T> submit(Callable<T> task) {
if (task == null) throw new NullPointerException();
RunnableFuture<T> ftask = newTaskFor(task);
execute(ftask);
return ftask;
}
由此可看出底层执行全是execute方法
- execute() 总结
-
- 提交过来的线程就是要先看用不用排队,不排队的话就执行,执行的话肯定不能直接用传过来的线程调用start方法,所以用worker进行包装,worker在执行的时候 还会去阻塞队列中拿任务,而且执行前执行后还提供了钩子方法
-
- worker还继承了aps,能干一堆事儿
02 线程池源码面试题
下面
03 经验总结:不同场景,如何使用线程池
- 线程池的共用和独立
-
- 一般不会公用一个线程池,比如写入和读取,一般读取的并发量较大,如果共用一个线程池可能会造成写操作堵塞·
-
- 可以根据业务划分多个线程池
- 如何计算线程大小和队列大小
-
- 如果实时性要求高,队列设置小一些,coreSize和maxSize设置大一些,如果实时性要求低队列就可以设置大一点
-
- 线程池的设置更要根据实际环境
04 打动面试官:线程池流程编排中的运用实战
面试题
第1章:基础
- 为什么使用 Long 时,大家推荐多使用 valueOf 方法,少使用 parseLong 方法
答:因为 Long 本身有缓存机制,缓存了 -128 到 127 范围内的 Long,valueOf(Long l)方法会从缓存中去 拿值,如果命中缓存,会减少资源的开销,parseLong 方法就没有这个机制。
- 如何解决 String 乱码的问题
答:乱码的问题的根源主要是两个:字符集不支持复杂汉字、二进制进行转化时字符集不匹配, 所以在 String 乱码时我们可以这么做:
- 所有可以指定字符集的地方强制指定字符集,比如 new String 和 getBytes 这两个地方;
- 我们应该使用 UTF-8 这种能完整支持复杂汉字的字符集。
- 为什么大家都说 String 是不可变的
答:主要是因为 String 和保存数据的 char 数组,都被 final 关键字所修饰,所以是不可变的,具 体细节描述可以参考上文。
- String 一些常用操作问题,如问如何分割、合并、替换、删除、截取等等问题
答:这些都属于问 String 的基本操作题目,考察我们平时对 String 的使用熟练程度,可以参考 上文。
- 如何证明 static 静态变量和类无关?
答:从三个方面就可以看出静态变量和类无关。
- 我们不需要初始化类就可直接使用静态变量;
- 我们在类中写个 main 方法运行,即便不写初始化类的代码,静态变量都会自动初始化;
- 静态变量只会初始化一次,初始化完成之后,不管我再 new 多少个类出来,静态变量都不 会再初始化了。 不仅仅是静态变量,静态方法块也和类无关。
- 常常看见变量和方法被 static 和 final 两个关键字修饰,为什么这么做?
答:这么做有两个目的:
- 变量和方法于类无关,可以直接使用,使用比较方便;
- 强调变量内存地址不可变,方法不可继承覆写,强调了方法内部的稳定性。
- catch 中发生了未知异常,finally 还会执行么?
答:会的,catch 发生了异常,finally 还会执行的,并且是 finally 执行完成之后,才会抛出 catch 中的异常。 不过 catch 会吃掉 try 中抛出的异常,为了避免这种情况,在一些可以预见 catch 中会发生异常 的地方,先把 try 抛出的异常打印出来,这样从日志中就可以看到完整的异常了。
- volatile 关键字的作用和原理
答:这个上文说的比较清楚,可以参考上文。
- 工作中有没有遇到特别好用的工具类,如何写好一个工具类
答:有的,像 Arrays 的排序、二分查找、Collections 的不可变、线程安全集合类、Objects 的判 空相等判断等等工具类,好的工具类肯定很好用,比如说使用 static final 关键字对方法进行修饰, 工具类构造器必须是私有等等手段来写好工具类。
- 写一个二分查找算法的实现
答:可以参考 Arrays 的 binarySearch 方法的源码实现。
- 如果我希望 ArrayList 初始化之后,不能被修改,该怎么办
答:可以使用 Collections 的 unmodifiableList 的方法,该方法会返回一个不能被修改的内部类集 合,这些集合类只开放查询的方法,对于调用修改集合的方法会直接抛出异常。
第二章:集合
列表
基础
- 说说你自己对 ArrayList 的理解?
很多面试官喜欢这样子开头,考察面试同学对 ArrayList 有没有总结经验,介于 ArrayList 内容很 多,建议先回答总体架构,再从某个细节出发作为突破口,
比如这样: ArrayList 底层数据结构 是个数组,其 API 都做了一层对数组底层访问的封装,比如说 add 方法的过程是……(这里可以 引用我们在 ArrayList 源码解析中 add 的过程)。 一般面试官看你回答得井井有条,并且没啥漏洞的话,基本就不会深究了,这样面试的主动权就 掌握在自己手里面了,如果你回答得支支吾吾,那么面试官可能就会开启自己面试的套路了。 说说你自己对 LinkedList 的理解也是同样套路。
扩容类问题
- ArrayList 无参数构造器构造,现在 add 一个值进去,此时数组的大小是多少,下一次扩容 前最大可用大小是多少?
答:此处数组的大小是 1,下一次扩容前最大可用大小是 10,因为 ArrayList 第一次扩容时,是 有默认值的,默认值是 10,在第一次 add 一个值进去时,数组的可用大小被扩容到 10 了。
- 如果我连续往 list 里面新增值,增加到第 11 个的时候,数组的大小是多少?
这里的考查点就是扩容的公式,当增加到 11 的时候,此时我们希望数组的大小为 11,但实 际上数组的最大容量只有 10,不够了就需要扩容,扩容的公式是:oldCapacity + (oldCapacity>> 1),oldCapacity 表示数组现有大小,目前场景计算公式是:10 + 10 /2 = 15,然后我们发现 15 已经够用了,所以数组的大小会被扩容到 15。
- 数组初始化,被加入一个值后,如果我使用 addAll 方法,一下子加入 15 个值,那么最终 数组的大小是多少?
答:第一题中我们已经计算出来数组在加入一个值后,实际大小是 1,最大可用大小是 10 ,现 在需要一下子加入 15 个值,那我们期望数组的大小值就是 16,此时数组最大可用大小只有 10, 明显不够,需要扩容,扩容后的大小是:10 + 10 /2 = 15,这时候发现扩容后的大小仍然不到 我们期望的值 16,这时候源码中有一种策略如下:
// newCapacity 本次扩容的大小,minCapacity 我们期望的数组最小大小
// 如果扩容后的值 < 我们的期望值,我们的期望值就等于本次扩容的大小
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
所以最终数组扩容后的大小为 16。
总结:如果扩容后的值 没法满足,那就直接把新的容量设置成 期望值
- 现在我有一个很大的数组需要拷贝,原数组大小是 5k,请问如何快速拷贝?
答:因为原数组比较大,如果新建新数组的时候,不指定数组大小的话,就会频繁扩容,频繁扩 容就会有大量拷贝的工作,造成拷贝的性能低下,所以回答说新建数组时,指定新数组的大小为 5k 即可。
- 为什么说扩容会消耗性能?
答:扩容底层使用的是 System.arraycopy 方法,会把原数组的数据全部拷贝到新数组上,所以 性能消耗比较严重。
- 源码扩容过程有什么值得借鉴的地方?
答:有两点:
-
- 是扩容的思想值得学习,通过自动扩容的方式,让使用者不用关心底层数据结构的变化,封装得很好,1.5 倍的扩容速度,可以让扩容速度在前期缓慢上升,在后期增速较快,大部分 工作中要求数组的值并不是很大,所以前期增长缓慢有利于节省资源,在后期增速较快时, 也可快速扩容。
-
- 扩容过程中,有数组大小溢出的意识,比如要求扩容后的数组大小,不能小于 0,不能大于 Integer 的最大值。 这两点在我们平时设计和写代码时都可以借鉴
删除类问题
- 有一个 ArrayList,数据是 2、 3、 3、 3、 4,中间有三个 3,现在我通过 for (int i=0;i<list.size
();i++) 的方式,想把值是 3 的元素删除,请问可以删除干净么?最终删除的结果是什么,为什么?
删除代码如下 :
List<String> list = new ArrayList<String>() {{
add("2");
add("3");
add("3");
add("3");
add("4");
}};
for(int i=0;i<list.size();i++){
if(list.get(i).equals("3")){
list.remove(i);
}
}
- 还是上面的 ArrayList 数组,我们通过增强 for 循环进行删除,可以么?
答:不可以,会报错。因为增强 for 循环过程其实调用的就是迭代器的 next () 方法,当你调用
list#remove () 方法进行删除时, modCount 的值会 +1,而这时候迭代器中的 expectedModCount
的值却没有变,导致在迭代器下次执行 next () 方法时, expectedModCount != modCount 就会报
ConcurrentModificationException 的错误。
- 还是上面的数组,如果删除时使用 Iterator.remove () 方法可以删除么,为什么?
答:可以的,因为 Iterator.remove () 方法在执行的过程中,会把最新的 modCount 赋值给
expectedModCount,这样在下次循环过程中, modCount 和 expectedModCount 两者就会相等。
- 以上三个问题对于 LinkedList 也是同样的结果么?
答:是的,虽然 LinkedList 底层结构是双向链表,但对于上述三个问题,结果和 ArrayList 是一
致的。
对比类问题
- ArrayList 和 LinkedList 有何不同?
答:可以先从底层数据结构开始说起,然后以某一个方法为突破口深入,比如:最大的不同是两 者底层的数据结构不同,ArrayList 底层是数组,LinkedList 底层是双向链表,两者的数据结构不 同也导致了操作的 API 实现有所差异,拿新增实现来说,ArrayList 会先计算并决定是否扩容, 然后把新增的数据直接赋值到数组上,而 LinkedList 仅仅只需要改变插入节点和其前后节点的指 向位置关系即可。
- ArrayList 和 LinkedList 应用场景有何不同
答:ArrayList 更适合于快速的查找匹配,不适合频繁新增删除,像工作中经常会对元素进行匹 配查询的场景比较合适,LinkedList 更适合于经常新增和删除,对查询反而很少的场景。
- ArrayList 和 LinkedList 两者有没有最大容量
答:ArrayList 有最大容量的,为 Integer 的最大值,大于这个值 JVM 是不会为数组分配内存空间 的,LinkedList 底层是双向链表,理论上可以无限大。但源码中,LinkedList 实际大小用的是 int 类型,这也说明了 LinkedList 不能超过 Integer 的最大值,不然会溢出。
- ArrayList 和 LinkedList 是如何对 null 值进行处理的
答:ArrayList 允许 null 值新增,也允许 null 值删除。删除 null 值时,是从头开始,找到第一值 是 null 的元素删除;LinkedList 新增删除时对 null 值没有特殊校验,是允许新增和删除的。
- ArrayList 和 LinedList 是线程安全的么,为什么?
答:当两者作为非共享变量时,比如说仅仅是在方法里面的局部变量时,是没有线程安全问题的, 只有当两者是共享变量时,才会有线程安全问题。主要的问题点在于多线程环境下,所有线程任 何时刻都可对数组和链表进行操作,这会导致值被覆盖,甚至混乱的情况。 如果有线程安全问题,在迭代的过程中,会频繁报 ConcurrentModificationException 的错误,意 思是在我当前循环的过程中,数组或链表的结构被其它线程修改了。
- 如何解决线程安全问题? Java 源码中推荐使用 Collections#synchronizedList 进行解决,Collections#synchronizedList 的返 回值是 List 的每个方法都加了 synchronized 锁,保证了在同一时刻,数组和链表只会被一个线 程所修改,或者采用 CopyOnWriteArrayList 并发 List 来解决,这个类我们后面会说
其它类型题目
- 你能描述下双向链表么?
答:如果和面试官面对面沟通的话,你可以去画一下,可以把 《LinkedList 源码解析》中的 LinkedList 的结构画出来,如果是电话面试,可以这么描述:双向链表中双向的意思是说前后节 点之间互相有引用,链表的节点我们称为 Node。Node 有三个属性组成:其前一个节点,本身节 点的值,其下一个节点,假设 A、B 节点相邻,A 节点的下一个节点就是 B,B 节点的上一个节 点就是 A,两者互相引用,在链表的头部节点,我们称为头节点。头节点的前一个节点是 null, 尾部称为尾节点,尾节点的后一个节点是 null,如果链表数据为空的话,头尾节点是同一个节点, 本身是 null,指向前后节点的值也是 null。
- 描述下双向链表的新增和删除
答:如果是面对面沟通,最好可以直接画图,如果是电话面试,可以这么描述: 新增:我们可以选择从链表头新增,也可以选择从链表尾新增,如果是从链表尾新增的话,直接 把当前节点追加到尾节点之后,本身节点自动变为尾节点。 删除:把删除节点的后一个节点的 prev 指向其前一个节点,把删除节点的前一个节点的 next 指 向其后一个节点,最后把删除的节点置为 null 即可
Map
引导语
Map 在面试中,占据了很大一部分的面试题目,其中以 HashMap 为主,这些面试题目有的可以
说得清楚,有的很难说清楚,如果是面对面面试的话,建议画一画。
Map 整体数据结构类问题
- 说一说 HashMap 底层数据结构
答: HashMap 底层是数组 + 链表 + 红黑树的数据结构,数组的主要作用是方便快速查找,时间
复杂度是 O(1),默认大小是 16,数组的下标索引是通过 key 的 hashcode 计算出来的,数组元素
叫做 Node,当多个 key 的 hashcode 一致,但 key 值不同时,单个 Node 就会转化成链表,链表
的查询复杂度是 O(n),当链表的长度大于等于 8 并且数组的大小超过 64 时,链表就会转化成红
黑树,红黑树的查询复杂度是 O(log(n)),简单来说,最坏的查询次数相当于红黑树的最大深度。
- HashMap、 TreeMap、 LinkedHashMap 三者有啥相同点,有啥不同点?
-
- 相同点:
-
-
- 三者在特定的情况下,都会使用红黑树;
-
-
-
- 底层的 hash 算法相同;
-
-
-
- 在迭代的过程中,如果 Map 的数据结构被改动,都会报 ConcurrentModificationException
的错误。
- 在迭代的过程中,如果 Map 的数据结构被改动,都会报 ConcurrentModificationException
-
-
- 不同点:
-
-
- HashMap 数据结构以数组为主,查询非常快, TreeMap 数据结构以红黑树为主,利用了红
黑树左小右大的特点,可以实现 key 的排序, LinkedHashMap 在 HashMap 的基础上增加了
链表的结构,实现了插入顺序访问和最少访问删除两种策略;
- HashMap 数据结构以数组为主,查询非常快, TreeMap 数据结构以红黑树为主,利用了红
-
-
-
- 由于三种 Map 底层数据结构的差别,导致了三者的使用场景的不同,TreeMap 适合需要根
据 key 进行排序的场景, LinkedHashMap 适合按照插入顺序访问,或需要删除最少访问元
素的场景,剩余场景我们使用 HashMap 即可,我们工作中大部分场景基本都在使用HashMap;
- 由于三种 Map 底层数据结构的差别,导致了三者的使用场景的不同,TreeMap 适合需要根
-
-
-
- 由于三种 map 的底层数据结构的不同,导致上层包装的 api 略有差别。
-
- 说一下 Map 的 hash 算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key 在数组中的位置公式: tab[(n - 1) & hash]
-
- 如上代码是 HashMap 的 hash 算法。
这其实是一个数学问题,源码中就是通过以上代码来计算 hash 的,首先计算出 key 的 hashcode,
因为 key 是 Object,所以会根据 key 的不同类型进行 hashcode 的计算,接着计算 h ^ (h >>> 16) ,
这么做的好处是使大多数场景下,算出来的 hash 值比较分散。
- 如上代码是 HashMap 的 hash 算法。
-
- 一般来说, hash 值算出来之后,要计算当前 key 在数组中的索引下标位置时,可以采用取模的
方式,就是索引下标位置 = hash 值 % 数组大小,这样做的好处,就是可以保证计算出来的索引
下标值可以均匀的分布在数组的各个索引位置上,但取模操作对于处理器的计算是比较慢的,数
学上有个公式,当 b 是 2 的幂次方时, a % b = a &(b-1),所以此处索引位置的计算公式我们
可以更换为: (n-1) & hash。
- 一般来说, hash 值算出来之后,要计算当前 key 在数组中的索引下标位置时,可以采用取模的
-
- 此问题可以延伸出三个小问题:
-
-
- 为什么不用 key % 数组大小,而是需要用 key 的 hash 值 % 数组大小。
答:如果 key 是数字,直接用 key % 数组大小是完全没有问题的,但我们的 key 还有可能是字符
串,是复杂对象,这时候用 字符串或复杂对象 % 数组大小是不行的,所以需要先计算出 key 的
hash 值。
- 为什么不用 key % 数组大小,而是需要用 key 的 hash 值 % 数组大小。
-
-
-
- 计算 hash 值时,为什么需要右移 16 位?
答: hash 算法是 h ^ (h >>> 16),为了使计算出的 hash 值更分散,所以选择先将 h 无符号右移
16 位,然后再于 h 异或时,就能达到 h 的高 16 位和低 16 位都能参与计算,减少了碰撞的可能
性。
- 计算 hash 值时,为什么需要右移 16 位?
-
-
-
- 为什么把取模操作换成了 & 操作?
答: key.hashCode() 算出来的 hash 值还不是数组的索引下标,为了随机的计算出索引的下表位
置,我们还会用 hash 值和数组大小进行取模,这样子计算出来的索引下标比较均匀分布。
取模操作处理器计算比较慢,处理器对 & 操作就比较擅长,换成了 & 操作,是有数学上证明的
支撑,为了提高了处理器处理的速度。
- 为什么把取模操作换成了 & 操作?
-
-
-
- 为什么提倡数组大小是 2 的幂次方?
答:因为只有大小是 2 的幂次方时,才能使 hash 值 % n(数组大小) == (n-1) & hash 公式成立。
- 为什么提倡数组大小是 2 的幂次方?
-
- 为解决 hash 冲突,大概有哪些办法。
-
- 好的 hash 算法,细问的话复述一下上题的 hash 算法;
-
- 自动扩容,当数组大小快满的时候,采取自动扩容,可以减少 hash 冲突;
-
- hash 冲突发生时,采用链表来解决;
-
- 冲突严重时,链表会自动转化成红黑树,提高遍历速度。
网上列举的一些其它办法,如开放定址法,尽量不要说,因为这些方法资料很少,实战用过的人
更少,如果你没有深入研究的话,面试官让你深入描述一下很难说清楚,反而留下不好的印象,
说 HashMap 现有的措施就足够了。
- 冲突严重时,链表会自动转化成红黑树,提高遍历速度。
HashMap 源码细节类问题
- HashMap 是如何扩容的?
-
- 答:扩容的时机:
-
-
- put 时,发现数组为空,进行初始化扩容,默认扩容大小为 16;
-
-
-
- put 成功后,发现现有数组大小大于扩容的门阀值时,进行扩容,扩容为老数组大小的 2 倍;
扩容的门阀是 threshold,每次扩容时 threshold 都会被重新计算,门阀值等于数组的大小 * 影响
因子(0.75)。
- put 成功后,发现现有数组大小大于扩容的门阀值时,进行扩容,扩容为老数组大小的 2 倍;
-
-
-
- 新数组初始化之后,需要将老数组的值拷贝到新数组上,链表和红黑树都有自己拷贝的方法。
-
- hash 冲突时怎么办?
答: hash 冲突指的是 key 值的 hashcode 计算相同,但 key 值不同的情况。
-
- 如果桶中元素原本只有一个或已经是链表了,新增元素直接追加到链表尾部;
-
- 如果桶中元素已经是链表,并且链表个数大于等于 8 时,此时有两种情况:
-
-
- 如果此时数组大小小于 64,数组再次扩容,链表不会转化成红黑树;
-
-
-
- 如果数组大小大于 64 时,链表就会转化成红黑树。
这里不仅仅判断链表个数大于等于 8, 还判断了数组大小,数组容量小于 64 没有立即转化的原
因,猜测主要是因为红黑树占用的空间比链表大很多,转化也比较耗时,所以数组容量小的情况
下冲突严重,我们可以先尝试扩容,看看能否通过扩容来解决冲突的问题。
- 如果数组大小大于 64 时,链表就会转化成红黑树。
-
- 为什么链表个数大于等于 8 时,链表要转化成红黑树了?
-
- 答:当链表个数太多了,遍历可能比较耗时,转化成红黑树,可以使遍历的时间复杂度降低,但
转化成红黑树,有空间和转化耗时的成本,我们通过泊松分布公式计算,正常情况下,链表个数
出现 8 的概念不到千万分之一,所以说正常情况下,链表都不会转化成红黑树,这样设计的目的,
是为了防止非正常情况下,比如 hash 算法出了问题时,导致链表个数轻易大于等于 8 时,仍然
能够快速遍历。
- 答:当链表个数太多了,遍历可能比较耗时,转化成红黑树,可以使遍历的时间复杂度降低,但
-
- 延伸问题:红黑树什么时候转变成链表。
答:当节点的个数小于等于 6 时,红黑树会自动转化成链表,主要还是考虑红黑树的空间成本问
题,当节点个数小于等于 6 时,遍历链表也很快,所以红黑树会重新变成链表。
- 延伸问题:红黑树什么时候转变成链表。
- HashMap 在 put 时,如果数组中已经有了这个 key,我不想把 value 覆盖怎么办?取值时,如
果得到的 value 是空时,想返回默认值怎么办?
-
- 答:如果数组有了 key,但不想覆盖 value ,可以选择 putIfAbsent 方法,这个方法有个内置变量
onlyIfAbsent,内置是 true ,就不会覆盖,我们平时使用的 put 方法,内置 onlyIfAbsent 为 false,
是允许覆盖的。
- 答:如果数组有了 key,但不想覆盖 value ,可以选择 putIfAbsent 方法,这个方法有个内置变量
-
- 取值时,如果为空,想返回默认值,可以使用 getOrDefault 方法,方法第一参数为 key,第二个
参数为你想返回的默认值,如 map.getOrDefault(“2”,“0”),当 map 中没有 key 为 2 的值时,会默
认返回 0,而不是空。
- 取值时,如果为空,想返回默认值,可以使用 getOrDefault 方法,方法第一参数为 key,第二个
- 通过以下代码进行删除,是否可行?
HashMap<String,String > map = Maps.newHashMap();
map.put("1","1");
map.put("2","2");
map.forEach((s, s2) -> map.remove("1"));
-
- 答:不行,会报错误 ConcurrentModificationException,原因如下图:

- 答:不行,会报错误 ConcurrentModificationException,原因如下图:
-
- 建议使用迭代器的方式进行删除,原理同 ArrayList 迭代器原理,我们在《List 源码会问那些面
试题》中有说到。
- 建议使用迭代器的方式进行删除,原理同 ArrayList 迭代器原理,我们在《List 源码会问那些面
- 描述一下 HashMap get、 put 的过程
答:我们在源码解析中有说,可以详细描述下源码的实现路径,说不清楚的话,可以画一画。
其它 Map 面试题
- DTO 作为 Map 的 key 时,有无需要注意的点?
-
- 答: DTO 就是一个数据载体,可以看做拥有很多属性的 Java 类,我们可以对这些属性进行 get、
set 操作。
- 答: DTO 就是一个数据载体,可以看做拥有很多属性的 Java 类,我们可以对这些属性进行 get、
-
- 看是什么类型的 Map,如果是 HashMap 的话,一定需要覆写 equals 和 hashCode 方法,因为在
get 和 put 的时候,需要通过 equals 方法进行相等的判断;如果是 TreeMap 的话, DTO 需要实
现 Comparable 接口,因为 TreeMap 会使用 Comparable 接口进行判断 key 的大小;如果是
LinkedHashMap 的话,和 HashMap 一样的。
- 看是什么类型的 Map,如果是 HashMap 的话,一定需要覆写 equals 和 hashCode 方法,因为在
- LinkedHashMap 中的 LRU 是什么意思,是如何实现的。
-
- 答: LRU ,英文全称: Least recently used,中文叫做最近最少访问,在 LinkedHashMap 中,也
叫做最少访问删除策略,我们可以通过 removeEldestEntry 方法设定一定的策略,使最少被访问
的元素,在适当的时机被删除,原理是在 put 方法执行的最后, LinkedHashMap 会去检查这种
策略,如果满足策略,就删除头节点。
- 答: LRU ,英文全称: Least recently used,中文叫做最近最少访问,在 LinkedHashMap 中,也
-
- 保证头节点就是最少访问的元素的原理是: LinkedHashMap 在 get 的时候,都会把当前访问的节
点,移动到链表的尾部,慢慢的,就会使头部的节点都是最少被访问的元素。
- 保证头节点就是最少访问的元素的原理是: LinkedHashMap 在 get 的时候,都会把当前访问的节
- 为什么推荐 TreeMap 的元素最好都实现 Comparable 接口?但 key 是 String 的时候,我们却没有额外的工作?
答:因为 TreeMap 的底层就是通过排序来比较两个 key 的大小的,所以推荐 key 实现Comparable 接口,是为了往你希望的排序顺序上发展, 而 String 本身已经实现了 Comparable接口,所以使用 String 时,我们不需要额外的工作,不仅仅是 String ,其他包装类型也都实现了 Comparable 接口,如 Long、 Double、 Short 等。
Set
- TreeSet 有用过么,平时都在什么场景下使用?
答:有木有用过如实回答就好了,我们一般都是在需要把元素进行排序的时候使用 TreeSet,使用时需要我们注意元素最好实现 Comparable 接口,这样方便底层的 TreeMap 根据 key 进行排序。
- 追问,如果我想实现根据 key 的新增顺序进行遍历怎么办?
答:要按照 key 的新增顺序进行遍历,首先想到的应该就是 LinkedHashMap,而 LinkedHashSet正好是基于 LinkedHashMap 实现的,所以我们可以选择使用 LinkedHashSet。
- 追问,如果我想对 key 进行去重,有什么好的办法么?
答:我们首先想到的是 TreeSet, TreeSet 底层使用的是 TreeMap, TreeMap 在 put 的时候,如果发现 key 是相同的,会把 value 值进行覆盖,所有不会产生重复的 key ,利用这一特性,使用TreeSet 正好可以去重。
- 说说 TreeSet 和 HashSet 两个 Set 的内部实现结构和原理?
-
- 答: HashSet 底层对 HashMap 的能力进行封装,比如说 add 方法,是直接使用 HashMap 的 put
方法,比较简单,但在初始化的时候,我看源码有一些感悟:说一下 HashSet 小结的四小点。
- 答: HashSet 底层对 HashMap 的能力进行封装,比如说 add 方法,是直接使用 HashMap 的 put
-
- TreeSet 主要是对 TreeMap 底层能力进行封装复用,我发现了两种非常有意思的复用思路,重复
TreeSet 两种复用思路。
- TreeSet 主要是对 TreeMap 底层能力进行封装复用,我发现了两种非常有意思的复用思路,重复
JDK1.7&JDK1.8
- Java 8 在 List、 Map 接口上新增了很多方法,为什么 Java 7 中这些接口的实现者不需要强制实现呢?
答:主要是因为这些新增的方法被 default 关键字修饰了, default 一旦修饰接口上的方法,我们需要在接口的方法中写默认实现,并且子类无需强制实现这些方法,所以 Java 7 接口的实现者无需感知。
- Java 8 中有新增很多实用的方法,你在平时工作中有使用过么?
答:有的,比如说 getOrDefault、 putIfAbsent、 computeIfPresent 方法等等, 具体使用细节参考
- 说说 computeIfPresent 方法的使用姿势?
答: computeIfPresent 是可以对 key 和 value 进行计算后,把计算的结果重新赋值给 key,并且如果 key 不存在时,不会报空指针,会返回 null 值。
- Java 8 集合新增了 forEach 方法,和普通的 for 循环有啥不同?
答:新增的 forEach 方法的入参是函数式的接口,比如说 Consumer 和 BiConsumer,这样子做的好处就是封装了 for 循环的代码,让使用者只需关注实现每次循环的业务逻辑,简化了重复的for 循环代码,使代码更加简洁,普通的 for 循环,每次都需要写重复的 for 循环代码, forEach把这种重复的计算逻辑吃掉了,使用起来更加方便。
- HashMap 8 和 7 有啥区别?
答: HashMap 8 和 7 的差别太大了,新增了红黑树,修改了底层数据逻辑,修改了 hash 算法,
几乎所有底层数组变动的方法都重写了一遍,可以说 Java 8 的 HashMap 几乎重新了一遍。
第三章:并发集合类
并发List与Map
引导语
并发 List 和 Map 是技术面时常问的问题,问的问题也都比较深入,有很多问题都是面试官自创的,市面上找不到,所以说通过背题的方式,这一关大部分是过不了的,只有我们真正理解了API 内部的实现,阅读过源码,才能自如应对各种类型的面试题,接着我们来看一下并发 List、Map 源码相关的面试题集。
CopyOnWriteArrayList 相关
- 和 ArrayList 相比有哪些相同点和不同点?
-
- 相同点:底层的数据结构是相同的,都是数组的数据结构,提供出来的 API 都是对数组结构进行操作,让我们更好的使用。
-
- 不同点:后者是线程安全的,在多线程环境下使用,无需加锁,可直接使用。
- CopyOnWriteArrayList 通过哪些手段实现了线程安全?
-
- 数组容器被 volatile 关键字修饰,保证了数组内存地址被任意线程修改后,都会通知到其他线程;
-
- 对数组的所有修改操作,都进行了加锁,保证了同一时刻,只能有一个线程对数组进行修改,比如我在 add 时,就无法 remove;
-
- 修改过程中对原数组进行了复制,是在新数组上进行修改的,修改过程中,不会对原数组产生任何影响。
- 在 add 方法中,对数组进行加锁后,不是已经是线程安全了么,为什么还需要对老数组进行拷贝?
-
- 答:的确,对数组进行加锁后,能够保证同一时刻,只有一个线程能对数组进行 add,在同单核CPU 下的多线程环境下肯定没有问题,但我们现在的机器都是多核 CPU,如果我们不通过复制拷贝新建数组,修改原数组容器的内存地址的话,是无法触发 volatile 可见性效果的,那么其他CPU 下的线程就无法感知数组原来已经被修改了,就会引发多核 CPU 下的线程安全问题。假设我们不复制拷贝,而是在原来数组上直接修改值,数组的内存地址就不会变,而数组被volatile 修饰时,必须当数组的内存地址变更时,才能及时的通知到其他线程,内存地址不变,仅仅是数组元素值发生变化时,是无法把数组元素值发生变动的事实,通知到其它线程的。
- 对老数组进行拷贝,会有性能损耗,我们平时使用需要注意什么?
答:主要有:
-
- 在批量操作时,尽量使用 addAll、 removeAll 方法,而不要在循环里面使用 add、 remove 方法,主要是因为 for 循环里面使用 add 、 remove 的方式,在每次操作时,都会进行一次数组的拷贝(甚至多次),非常耗性能,而 addAll、 removeAll 方法底层做了优化,整个操作只会进行一次数组拷贝,由此可见,当批量操作的数据越多时,批量方法的高性能体现的越明显。
- 为什么 CopyOnWriteArrayList 迭代过程中,数组结构变动,不会抛出ConcurrentModificationException 了
答:主要是因为 CopyOnWriteArrayList 每次操作时,都会产生新的数组,而迭代时,持有的仍然是老数组的引用,所以我们说的数组结构变动,是用新数组替换了老数组,老数组的结构并没有发生变化,所以不会抛出异常了。
- 插入的数据正好在 List 的中间,请问两种 List 分别拷贝数组几次?为什么?
-
- ArrayList 只需拷贝一次,假设插入的位置是 2,只需要把位置 2 (包含 2)后面的数据都往后移动一位即可,所以拷贝一次。
-
- CopyOnWriteArrayList 拷贝两次,因为 CopyOnWriteArrayList 多了把老数组的数据拷贝到新数组上这一步,可能有的同学会想到这种方式:先把老数组拷贝到新数组,再把 2 后面的数据往后移动一位,这的确是一种拷贝的方式,但 CopyOnWriteArrayList 底层实现更加灵活,而是:把老数组 0 到 2 的数据拷贝到新数组上,预留出新数组 2 的位置,再把老数组 3~ 最后的数据拷贝到新数组上,这种拷贝方式可以减少我们拷贝的数据,虽然是两次拷贝,但拷贝的数据却仍然是老数组的大小,设计的非常巧妙。
ConcurrentHashMap 相关
- ConcurrentHashMap 和 HashMap 的相同点和不同点
-
- 相同点:
-
-
- 都是数组 + 链表 +红黑树的数据结构,所以基本操作的思想相同;
-
-
-
- 都实现了 Map 接口,继承了 AbstractMap 抽象类,所以两者的方法大多都是相似的,可以互相切换。
-
-
- 不同点:
-
-
- ConcurrentHashMap 是线程安全的,在多线程环境下,无需加锁,可直接使用;
-
-
-
- 数据结构上, ConcurrentHashMap 多了转移节点,主要用于保证扩容时的线程安全。
-
- ConcurrentHashMap 通过哪些手段保证了线程安全。
-
- 储存 Map 数据的数组被 volatile 关键字修饰,一旦被修改,立马就能通知其他线程,因为是
数组,所以需要改变其内存值,才能真正的发挥出 volatile 的可见特性;
- 储存 Map 数据的数组被 volatile 关键字修饰,一旦被修改,立马就能通知其他线程,因为是
-
- put 时,如果计算出来的数组下标索引没有值的话,采用无限 for 循环 + CAS 算法,来保证
一定可以新增成功,又不会覆盖其他线程 put 进去的值;
- put 时,如果计算出来的数组下标索引没有值的话,采用无限 for 循环 + CAS 算法,来保证
-
- 如果 put 的节点正好在扩容,会等待扩容完成之后,再进行 put ,保证了在扩容时,老数组
的值不会发生变化;
- 如果 put 的节点正好在扩容,会等待扩容完成之后,再进行 put ,保证了在扩容时,老数组
-
- 对数组的槽点进行操作时,会先锁住槽点,保证只有当前线程才能对槽点上的链表或红黑树
进行操作;
- 对数组的槽点进行操作时,会先锁住槽点,保证只有当前线程才能对槽点上的链表或红黑树
-
- 红黑树旋转时,会锁住根节点,保证旋转时的线程安全。
- 描述一下 CAS 算法在 ConcurrentHashMap 中的应用?
-
- CAS 其实是一种乐观锁,一般有三个值,分别为:赋值对象,原值,新值,在执行的时候,
会先判断内存中的值是否和原值相等,相等的话把新值赋值给对象,否则赋值失败,整个过程都
是原子性操作,没有线程安全问题。
- CAS 其实是一种乐观锁,一般有三个值,分别为:赋值对象,原值,新值,在执行的时候,
-
- ConcurrentHashMap 的 put 方法中,有使用到 CAS ,是结合无限 for 循环一起使用的,步骤如下:
-
-
- 计算出数组索引下标,拿出下标对应的原值;
-
-
-
- CAS 覆盖当前下标的值,赋值时,如果发现内存值和 1 拿出来的原值相等,执行赋值,退出
循环,否则不赋值,转到 3;
- CAS 覆盖当前下标的值,赋值时,如果发现内存值和 1 拿出来的原值相等,执行赋值,退出
-
-
-
- 进行下一次 for 循环,重复执行 1, 2,直到成功为止。可以看到这样做的好处,第一是不会盲目的覆盖原值,第二是一定可以赋值成功。
-
- ConcurrentHashMap 是如何发现当前槽点正在扩容的。
答:ConcurrentHashMap 新增了一个节点类型,叫做转移节点,当我们发现当前槽点是转移节点时(转移节点的 hash 值是 -1),即表示 Map 正在进行扩容。
- 发现槽点正在扩容时, put 操作会怎么办?
答:无限 for 循环,或者走到扩容方法中去,帮助扩容,一直等待扩容完成之后,再执行 put 操作。
- 两种 Map 扩容时,有啥区别?
答:区别很大, HashMap 是直接在老数据上面进行扩容,多线程环境下,会有线程安全的问题,
而 ConcurrentHashMap 就不太一样,扩容过程是这样的:
-
- 从数组的队尾开始拷贝;
-
- 拷贝数组的槽点时,先把原数组槽点锁住,拷贝成功到新数组时,把原数组槽点赋值为转移节点;
-
- 从数组的尾部拷贝到头部,每拷贝成功一次,就把原数组的槽点设置成转移节点;
-
- 直到所有数组数据都拷贝到新数组时,直接把新数组整个赋值给数组容器,拷贝完成。简单来说,通过扩容时给槽点加锁,和发现槽点正在扩容就等待的策略,保证了ConcurrentHashMap 可以慢慢一个一个槽点的转移,保证了扩容时的线程安全,转移节点比较重要,平时问的人也比较多。
-
- ConcurrentHashMap 在 Java 7 和 8 中关于线程安全的做法有啥不同?
答:非常不一样,拿 put 方法为例, Java 7 的做法是:
- ConcurrentHashMap 在 Java 7 和 8 中关于线程安全的做法有啥不同?
-
-
- 把数组进行分段,找到当前 key 对应的是那一段;
-
-
-
- 将当前段锁住,然后再根据 hash 寻找对应的值,进行赋值操作。
-
-
- Java 7 的做法比较简单,缺点也很明显,就是当我们需要 put 数据时,我们会锁住改该数据对应
的某一段,这一段数据可能会有很多,比如我只想 put 一个值,锁住的却是一段数据,导致这一
段的其他数据都不能进行写入操作,大大的降低了并发性的效率。 Java 8 解决了这个问题,从锁
住某一段,修改成锁住某一个槽点,提高了并发效率。
不仅仅是 put,删除也是,仅仅是锁住当前槽点,缩小了锁的范围,增大了效率
- Java 7 的做法比较简单,缺点也很明显,就是当我们需要 put 数据时,我们会锁住改该数据对应
第四章:队列
- 说说你对队列的理解,队列和集合的区别。
-
- 答:对队列的理解:
-
-
- 首先队列本身也是个容器,底层也会有不同的数据结构,比如 LinkedBlockingQueue 是底层 是链表结构,所以可以维 持先入先出的顺序,比如 DelayQueue 底层可以是队列或堆栈,所 以可以保证先入先出,或者先入后出的顺序等等,底层的数据结构不同,也造成了操作实现 不同;
-
-
-
- 部分队列(比如 LinkedBlockingQueue )提供了暂时存储的功能,我们可以往队列里面放数 据,同时也可以从队列里面拿数据,两者可以同时进行;
-
-
-
- 队列把生产数据的一方和消费数据的一方进行解耦,生产者只管生产,消费者只管消费,两 者之间没有必然联系,队列就像生产者和消费者之间的数据通道一样,如 LinkedBlockingQueue;
-
-
-
- 队列还可以对消费者和生产者进行管理,比如队列满了,有生产者还在不停投递数据时,队 列可以使生产者阻塞住,让其不再能投递,比如队列空时,有消费者过来拿数据时,队列可 以让消费者 hodler 住,等有数据时,唤醒消费者,让消费者拿数据返回,如 ArrayBlockingQueue;
-
-
-
- 队列还提供阻塞的功能,比如我们从队列拿数据,但队列中没有数据时,线程会一直阻塞到 队列有数据可拿时才返回。
-
-
- 队列和集合的区别:
-
-
- 和集合的相同点,队列(部分例外)和集合都提供了数据存储的功能,底层的储存数据结构 是有些相似的,比如说 LinkedBlockingQueue 和 LinkedHashMap 底层都使用的是链表, ArrayBlockingQueue 和 ArrayList 底层使用的都是数组。
-
-
-
- 和集合的区别:
-
-
-
-
- 部分队列和部分集合底层的存储结构很相似的,但两者为了完成不同的事情,提供的 API 和其底层的操作实现是不同的。
-
-
-
-
-
- 队列提供了阻塞的功能,能对消费者和生产者进行简单的管理,队列空时,会阻塞消费 者,有其他线程进行 put 操作后,会唤醒阻塞的消费者,让消费者拿数据进行消费,队列满 时亦然。
-
-
-
-
-
- 解耦了生产者和消费者,队列就像是生产者和消费者之间的管道一样,生产者只管往里 面丢,消费者只管不断消费,两者之间互不关心。
-
-
- 哪些队列具有阻塞的功能,大概是如何阻塞的?
答:队列主要提供了两种阻塞功能,如下:
- LinkedBlockingQueue 链表阻塞队列和 ArrayBlockingQueue 数组阻塞队列是一类,前者容量 是 Integer 的最大值,后者数组大小固定,两个阻塞队列都可以指定容量大小,当队列满时, 如果有线程 put 数据,线程会阻塞住,直到有其他线程进行消费数据后,才会唤醒阻塞线程 继续 put,当队列空时,如果有线程 take 数据,线程会阻塞到队列不空时,继续 take。
- SynchronousQueue 同步队列,当线程 put 时,必须有对应线程把数据消费掉,put 线程才 能返回,当线程 take 时,需要有对应线程进行 put 数据时,take 才能返回,反之则阻塞, 举个例子,线程 A put 数据 A1 到队列中了,此时并没有任何的消费者,线程 A 就无法返回, 会阻塞住,直到有线程消费掉数据 A1 时,线程 A 才能返回。
- 底层是如何实现阻塞的?
答:队列本身并没有实现阻塞的功能,而是利用 Condition 的等待唤醒机制,阻塞底层实现就是 更改线程的状态为沉睡,细节我们在锁小节会说到。
- LinkedBlockingQueue 和 ArrayBlockingQueue 有啥区别。
相同点: 两者的阻塞机制大体相同,比如在队列满、空时,线程都会阻塞住。
不同点:
-
- LinkedBlockingQueue 底层是链表结构,容量默认是 Interge 的最大值, ArrayBlockingQueue 底层是数组,容量必须在初始化时指定。
-
- 两者的底层结构不同,所以 take、put、remove 的底层实现也就不同。
- 往队列里面 put 数据是线程安全的么?为什么?
答:是线程安全的,在 put 之前,队列会自动加锁,put 完成之后,锁会自动释放,保证了同一 时刻只会有一个线程能操作队列的数据,以 LinkedBlockingQueue 为例子,put 时,会加 put 锁, 并只对队尾 tail 进行操作,take 时,会加 take 锁,并只对队头 head 进行操作,remove 时,会 同时加 put 和 take 锁,所以各种操作都是线程安全的,我们工作中可以放心使用。
- take 的时候也会加锁么?既然 put 和 take 都会加锁,是不是同一时间只能运行其中一个方法。
-
- 是的,take 时也会加锁的,像 LinkedBlockingQueue 在执行 take 方法时,在拿数据的同 时,会把当前数据删除掉,就改变了链表的数据结构,所以需要加锁来保证线程安全。
-
- 这个需要看情况而言,对于 LinkedBlockingQueue 来说,队列的 put 和 take 都会加锁,但两 者的锁是不一样的,所以两者互不影响,可以同时进行的,对于 ArrayBlockingQueue 而言,put 和 take 是同一个锁,所以同一时刻只能运行一个方法。
- 工作中经常使用队列的 put、take 方法有什么危害,如何避免。
答:当队列满时,使用 put 方法,会一直阻塞到队列不满为止。 当队列空时,使用 take 方法,会一直阻塞到队列有数据为止。 两个方法都是无限(永远、没有超时时间的意思)阻塞的方法,容易使得线程全部都阻塞住,大 流量时,导致机器无线程可用,所以建议在流量大时,使用 offer 和 poll 方法来代替两者,我们 只需要设置好超时阻塞时间,这两个方法如果在超时时间外,还没有得到数据的话,就会返回默 认值(LinkedBlockingQueue 为例),这样就不会导致流量大时,所有的线程都阻塞住了。 这个也是生产事故常常发生的原因之一,尝试用 put 和 take 方法,在平时自测中根本无法发现, 对源码不熟悉的同学也不会意识到会有问题,当线上大流量打进来时,很有可能会发生故障,所 以我们平时工作中使用队列时,需要谨慎再谨慎。
- 把数据放入队列中后,有木有办法让队列过一会儿再执行?
答:可以的,DelayQueue 提供了这种机制,可以设置一段时间之后再执行,该队列有个唯一的 缺点,就是数据保存在内存中,在重启和断电的时候,数据容易丢失,所以定时的时间我们都不 会设置很久,一般都是几秒内,如果定时的时间需要设置很久的话,可以考虑采取延迟队列中间 件(这种中间件对数据会进行持久化,不怕断电的发生)进行实现。
- DelayQueue 对元素有什么要求么,我把 String 放到队列中去可以么?
答:DelayQueue 要求元素必须实现 Delayed 接口,Delayed 本身又实现了 Comparable 接口, Delayed 接口的作用是定义还剩下多久就会超时,给使用者定制超时时间的,Comparable 接口 主要用于对元素之间的超时时间进行排序的,两者结合,就可以让越快过期的元素能够排在前面。 所以把 String 放到 DelayQueue 中是不行的,编译都无法通过,DelayQueue 类在定义的时候,是 有泛型定义的,泛型类型必须是 Delayed 接口的子类才行。
- DelayQueue 如何让快过期的元素先执行的?
答:DelayQueue 中的元素都实现 Delayed 和 Comparable 接口的,其内部会使用 Comparable 的 compareTo 方法进行排序,我们可以利用这个功能,在 compareTo 方法中实现过期时间和当前 时间的差,这样越快过期的元素,计算出来的差值就会越小,就会越先被执行。
- 如何查看 SynchronousQueue 队列的大小?
答:此题是个陷进题,题目首先设定了 SynchronousQueue 是可以查看大小的,实际上 SynchronousQueue 本身是没有容量的,所以也无法查看其容量的大小,其内部的 size 方法都是 写死的返回 0。
- SynchronousQueue 底层有几种数据结构,两者有何不同?
答:底层有两种数据结构,分别是队列和堆栈。
两者不同点:
-
- 队列维护了先入先出的顺序,所以最先进去队列的元素会最先被消费,我们称为公平的,而 堆栈则是先入后出的顺序,最先进入堆栈中的数据可能会最后才会被消费,我们称为不公平 的。 2. 两者的数据结构不同,导致其 take 和 put 方法有所差别,具体的可以看 《 SynchronousQueue 源码解析 》章节。
- 假设 SynchronousQueue 底层使用的是堆栈,线程 1 执行 take 操作阻塞住了,然后有线程 2 执行 put 操作,问此时线程 2 是如何把 put 的数据传递给 take 的?
答:这是一个好问题,也是理解 SynchronousQueue 的核心问题。 首先线程 1 被阻塞住,此时堆栈头就是线程 1 了,此时线程 2 执行 put 操作,会把 put 的数据赋 值给堆栈头的 match 属性,并唤醒线程 1,线程 1 被唤醒后,拿到堆栈头中的 match 属性,就能 够拿到 put 的数据了。 严格上说并不是 put 操作直接把数据传递给了 take,而是 put 操作改变了堆栈头的数据,从而 take 可以从堆栈头上直接拿到数据,堆栈头是 take 和 put 操作之间的沟通媒介。
- 如果想使用固定大小的队列,有几种队列可以选择,有何不同?
答:可以使用 LinkedBlockingQueue 和 ArrayBlockingQueue 两种队列。 前者是链表,后者是数组,链表新增时,只要建立起新增数据和链尾数据之间的关联即可,数组 新增时,需要考虑到索引的位置(takeIndex 和 putIndex 分别记录着下次拿数据、放数据的索引 位置),如果增加到了数组最后一个位置,下次就要重头开始新增。
- ArrayBlockingQueue 可以动态扩容么?用到数组最后一个位置时怎么办?
答:不可以的,虽然 ArrayBlockingQueue 底层是数组,但不能够动态扩容的。 假设 put 操作用到了数组的最后一个位置,那么下次 put 就需要从数组 0 的位置重新开始了。 假设 take 操作用到数组的最后一个位置,那么下次 take 的时候也会从数组 0 的位置重新开始。
- ArrayBlockingQueue take 和 put 都是怎么找到索引位置的?是利用 hash 算法计算得到的么?
答:ArrayBlockingQueue 有两个属性,为 takeIndex 和 putIndex,分别标识下次 take 和 put 的 位置,每次 take 和 put 完成之后,都会往后加一,虽然底层是数组,但和 HashMap 不同,并不 是通过 hash 算法计算得到的。
第五章 线程
- 创建子线程时,子线程是得不到父线程的 ThreadLocal,有什么办法可以解决这个问题?
答:这道题主要考察线程的属性和创建过程,可以这么回答。 可以使用 InheritableThreadLocal 来代替 ThreadLocal,ThreadLocal 和 InheritableThreadLocal 都是线程的属性,所以可以做到线程之间的数据隔离,在多线程环境下我们经常使用,但在有子 线程被创建的情况下,父线程 ThreadLocal 是无法传递给子线程的,但 InheritableThreadLocal 可以,主要是因为在线程创建的过程中,会把 InheritableThreadLocal 里面的所有值传递给子线程,具体代码如下(上面说过这个 在线程初始化的时候调用):
// 当父线程的 inheritableThreadLocals 的值不为空时
// 会把 inheritableThreadLocals 里面的值全部传递给子线程
if (parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
- 线程创建有几种实现方式?
答:主要有三种,分成两大类,第一类是子线程没有返回值,第二类是子线程有返回值。 无返回值的线程有两种写法,第一种是继承 Thread,可以这么写
class MyThread extends Thread{
@Override
public void run() {
log.info(Thread.currentThread().getName());
}
}
@Test
public void extendThreadInit(){
new MyThread().start();
}
第二种是实现 Runnable 接口,并作为 Thread 构造器的入参,代码如下:
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
log.info("{} begin run",Thread.currentThread().getName());
}
});
// 开一个子线程去执行
thread.start();
这两种都会开一个子线程去执行任务,并且是没有返回值的,如果需要子线程有返回值,需要使 用 Callable 接口,但 Callable 接口是无法直接作为 Thread 构造器的入参的,必须结合 FutureTask 一起使用,可以这样写代码:
@Test
public void testThreadByCallable() throws ExecutionException, InterruptedException
{
FutureTask futureTask = new FutureTask(new Callable<String> () {
@Override
public String call() throws Exception {
Thread.sleep(3000);
String result = "我是子线程"+Thread.currentThread().getName();
log.info("子线程正在运行:{}",Thread.currentThread().getName());
return result;
}
});
new Thread(futureTask).start();
log.info("返回的结果是 {}",futureTask.get());
}
把 FutureTask 作为 Thread 的入参就可以了,FutureTask 组合了 Callable ,使我们可以使用 Callable,并且 FutureTask 实现了 Runnable 接口,使其可以作为 Thread 构造器的入参,还有 FutureTask 实现了 Future,使其对任务有一定的管理功能。
- 子线程 1 去等待子线程 2 执行完成之后才能执行,如何去实现?
答:这里考察的就是 Thread.join 方法,我们可以这么做:
@Test
public void testJoin2() throws Exception {
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
log.info("我是子线程 2,开始沉睡");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("我是子线程 2,执行完成");
}
});
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
log.info("我是子线程 1,开始运行");
try {
log.info("我是子线程 1,我在等待子线程 2");
// 这里是代码关键
thread2.join();
log.info("我是子线程 1,子线程 2 执行完成,我继续执行");
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("我是子线程 1,执行完成");
}
});
thread1.start();
thread2.start();
Thread.sleep(100000);
}
子线程 1 需要等待子线程 2,只需要子线程 1 运行的时候,调用子线程 2 的 join 方法即可,这样 线程 1 执行到 join 代码时,就会等待线程 2 执行完成之后,才会继续执行。
- 守护线程和非守护线程的区别?如果我想在项目启动的时候收集代码信息,请问是守护线程好,还是非守护线程好,为什么?
-
- 两者的主要区别是,在 JVM 退出时,JVM 是不会管守护线程的,只会管非守护线程,如果 非守护线程还有在运行的,JVM 就不会退出,如果没有非守护线程了,但还有守护线程的,JVM 直接退出。
-
- 如果需要在项目启动的时候收集代码信息,就需要看收集工作是否重要了,如果不太重要,又很 耗时,就应该选择守护线程,这样不会妨碍 JVM 的退出,如果收集工作非常重要的话,那么就 需要非守护进程,这样即使启动时发生未知异常,JVM 也会等到代码收集信息线程结束后才会退出,不会影响收集工作。
- 线程 start 和 run 之间的区别。
答:调用 Thread.start 方法会开一个新的线程,run 方法不会。
- Thread、Runnable、Callable 三者之间的区别。
答:Thread 实现了 Runnable,本身就是 Runnable,但同时负责线程创建、线程状态变更等操作。 Runnable 是无返回值任务接口,Callable 是有返回值任务接口,如果任务需要跑起来,必须需要 Thread 的支持才行,Runnable 和 Callable 只是任务的定义,具体执行还需要靠 Thread。
- 线程池 submit 有两个方法,方法一可接受 Runnable,方法二可接受 Callable,但两个方法底层的逻辑却是同一套,这是如何适配的。
答:问题考察点在于 Runnable 和 Callable 之间是如何转化的,可以这么回答。 Runnable 和 Callable 是通过 FutureTask 进行统一的,FutureTask 有个属性是 Callable,同时也 实现了 Runnable 接口,两者的统一转化是在 FutureTask 的构造器里实现的,FutureTask 的最 终目标是把 Runnable 和 Callable 都转化成 Callable,Runnable 转化成 Callable 是通过 RunnableAdapter 适配器进行实现的。 线程池的 submit 底层的逻辑只认 FutureTask,不认 Runnable 和 Callable 的差异,所以只要都转 化成 FutureTask,底层实现都会是同一套。 具体 Runnable 转化成 Callable 的代码和逻辑可以参考上一章,有非常详细的描述。
- Callable 能否丢给 Thread 去执行?
答:可以的,可以新建 Callable,并作为 FutureTask 的构造器入参,然后把 FutureTask 丢给 Thread 去执行即可。
- FutureTask 有什么作用(谈谈对 FutureTask 的理解)。
答:作用如下:
-
- 组合了 Callable,实现了 Runnable,把 Callable 和 Runnnable 串联了起来
-
- 统一了有参任务和无参任务两种定义方式,方便了使用。
-
- 实现了 Future 的所有方法,对任务有一定的管理功能,比如说拿到任务执行结果,取消任 务,打断任务等等。
- 聊聊对 FutureTask 的 get、cancel 方法的理解
get 方法主要作用是得到 Callable 异步任务执行的结果,无参 get 会一直等待任务执行完成 之后才返回,有参 get 方法可以设定固定的时间,在设定的时间内,如果任务还没有执行成功, 直接返回异常,在实际工作中,建议多多使用 get 有参方法,少用 get 无参方法,防止任务执行 过慢时,多数线程都在等待,造成线程耗尽的问题。
cancel 方法主要用来取消任务,如果任务还没有执行,是可以取消的,如果任务已经在执行过程 中了,你可以选择不取消,或者直接打断执行中的任务。 两个方法具体的执行步骤和原理见上一章节源码解析。
- Thread.yield 方法在工作中有什么用?
答:yield 方法表示当前线程放弃 cpu,重新参与到 cpu 的竞争中去,再次竞争时,自己有可能 得到 cpu 资源,也有可能得不到,这样做的好处是防止当前线程一直霸占 cpu。 我们在工作中可能会写一些 while 自旋的代码,如果我们一直 while 自旋,不采取任何手段,我 们会发现 cpu 一直被当前 while 循环占用,如果能预见 while 自旋时间很长,我们会设置一定的 判断条件,让当前线程陷入阻塞,如果能预见 while 自旋时间很短,我们通常会使用 Thread.yield 方法,使当前自旋线程让步,不一直霸占 cpu,比如这样:
boolean stop = false;
while (!stop){
// dosomething
Thread.yield();
}
- wait()和 sleep()的相同点和区别?
相同点: 两者都让线程进入到 TIMED_WAITING 状态,并且可以设置等待的时间。
不同点:
-
- wait 是 Object 类的方法,sleep 是 Thread 类的方法。
-
- sleep 不会释放锁,沉睡的时候,其它线程是无法获得锁的,但 wait 会释放锁。
- 写一个简单的死锁 demo
// 共享变量 1
private static final Object share1 = new Object();
// 共享变量 2
private static final Object share2 = new Object();
@Test
public void testDeadLock() throws InterruptedException {
// 初始化线程 1,线程 1 需要在锁定 share1 共享资源的情况下再锁定 share2
Thread thread1 = new Thread(() -> {
synchronized (share1){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (share2){
log.info("{} is run",Thread.currentThread().getName());
}
}
});
// 初始化线程 2,线程 2 需要在锁定 share2 共享资源的情况下再锁定 share1
Thread thread2 = new Thread(() -> {
synchronized (share2){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (share1){
log.info("{} is run",Thread.currentThread().getName());
}
}
});
// 当线程 1、2 启动后,都在等待对方锁定的资源,但都得不到,造成死锁
thread1.start();
thread2.start();
Thread.sleep(1000000000);
}
第六章 锁
第七章 线程池
- 说说你对线程池的理解?
答:答题思路从大到小,从全面到局部,总的可以这么说,线程池结合了锁、线程、队列等元素,
在请求量较大的环境下,可以多线程的处理请求,充分的利用了系统的资源,提高了处理请求的
速度,细节可以从以下几个方面阐述:
-
- ThreadPoolExecutor 类结构;
-
- ThreadPoolExecutor coreSize、 maxSize 等重要属性;
-
- Worker 的重要作用;
-
- submit 的整个过程。
通过以上总分的描述,应该可以说清楚对线程池的理解了,如果是面对面面试的话,可以边说边
画出线程池的整体架构图(见《ThreadPoolExecutor 源码解析》)。
- submit 的整个过程。
- ThreadPoolExecutor、 Executor、 ExecutorService、 Runnable、 Callable、 FutureTask 之间的关
系?
答:以上 6 个类可以分成两大类:一种是定义任务类,一种是执行任务类。
-
- 定义任务类: Runnable、 Callable、 FutureTask。 Runnable 是定义无返回值的任务,
Callable 是定义有返回值的任务, FutureTask 是对 Runnable 和 Callable 两种任务的统一,
并增加了对任务的管理功能;
- 定义任务类: Runnable、 Callable、 FutureTask。 Runnable 是定义无返回值的任务,
-
- 执行任务类: ThreadPoolExecutor、 Executor、 ExecutorService。 Executor 定义最基本的运
行接口, ExecutorService 是对其功能的补充, ThreadPoolExecutor 提供真正可运行的线程
池类,三个类定义了任务的运行机制。日常的做法都是先根据定义任务类定义出任务来,然后丢给执行任务类去执行。
- 执行任务类: ThreadPoolExecutor、 Executor、 ExecutorService。 Executor 定义最基本的运
- 说一说队列在线程池中起的作用?
答:作用如下:
-
- 当请求数大于 coreSize 时,可以让任务在队列中排队,让线程池中的线程慢慢的消费请求,
实际工作中,实际线程数不可能等于请求数,队列提供了一种机制让任务可排队,起一个缓
冲区的作用;
- 当请求数大于 coreSize 时,可以让任务在队列中排队,让线程池中的线程慢慢的消费请求,
-
- 当线程消费完所有的线程后,会阻塞的从队列中拿数据,通过队列阻塞的功能,使线程不消
亡, 一旦队列中有数据产生后,可立马被消费。
- 当线程消费完所有的线程后,会阻塞的从队列中拿数据,通过队列阻塞的功能,使线程不消
- 结合请求不断增加时,说一说线程池构造器参数的含义和表现?
答:线程池构造器各个参数的含义如下:
-
- coreSize 核心线程数;
-
- maxSize 最大线程数;
-
- keepAliveTime 线程空闲的最大时间;
-
- queue 有多种队列可供选择,比如:
-
-
- SynchronousQueue,为了避免任务被拒绝,要求线 程池的 maxSize 无界,缺点是当任务提交的速度超过消费的速度时,可能出现无限制的线程 增长;
-
-
-
- LinkedBlockingQueue,无界队列,未消费的任务可以在队列中等待;
-
-
-
- ArrayBlockingQueue,有界队列,可以防止资源被耗尽;
-
-
- 线程新建的 ThreadFactory 可以自定义,也可以使用默认的 DefaultThreadFactory, DefaultThreadFactory 创建线程时,优先级会被限制成 NORM_PRIORITY,默认会被设置成 非守护线程;
-
- 在 Executor 已经关闭或对最大线程和最大队列都使用饱和时,可以使用 RejectedExecutionHandler 类进行异常捕捉,有如下四种处理策略: ThreadPoolExecutor.AbortPolicy、ThreadPoolExecutor.DiscardPolicy、 ThreadPoolExecutor.CallerRunsPolicy、ThreadPoolExecutor.DiscardOldestPolicy。
-
- 当请求不断增加时,各个参数起的作用如下:
-
- 请求数 < coreSize:创建新的线程来处理任务;
-
- coreSize <= 请求数 && 能够成功入队列:任务进入到队列中等待被消费;
-
- 队列已满 && 请求数 < maxSize:创建新的线程来处理任务;
-
- 队列已满 && 请求数 >= maxSize:使用 RejectedExecutionHandler 类拒绝请求。
- coreSize 和 maxSize 可以动态设置么,有没有规则限制?
答:一般来说,coreSize 和 maxSize 在线程池初始化时就已经设定了,但我们也可以通过 setCorePoolSize、setMaximumPoolSize 方法动态的修改这两个值。 setCorePoolSize 的限制见如下源码:
// 如果新设置的值小于 coreSize,多余的线程在空闲时会被回收(不保证一定可以回收成功)
// 如果大于 coseSize,会新创建线程
public void setCorePoolSize(int corePoolSize) {
if (corePoolSize < 0)
throw new IllegalArgumentException();
int delta = corePoolSize - this.corePoolSize;
this.corePoolSize = corePoolSize;
// 活动的线程大于新设置的核心线程数
if (workerCountOf(ctl.get()) > corePoolSize)
// 尝试将可以获得锁的 worker 中断,只会循环一次
// 最后并不能保证活动的线程数一定小于核心线程数
interruptIdleWorkers();
// 设置的核心线程数大于原来的核心线程数
else if (delta > 0) {
// 并不清楚应该新增多少线程,取新增核心线程数和等待队列数据的最小值,够用就好
int k = Math.min(delta, workQueue.size());
// 新增线程直到 k,如果期间等待队列空了也不会再新增
while (k-- > 0 && addWorker(null, true)) {
if (workQueue.isEmpty())
break;
}
}
}
//setMaximumPoolSize 的限制见如下源码:
// 如果 maxSize 大于原来的值,直接设置。
// 如果 maxSize 小于原来的值,尝试干掉一些 worker
public void setMaximumPoolSize(int maximumPoolSize) {
if (maximumPoolSize <= 0 || maximumPoolSize < corePoolSize)
throw new IllegalArgumentException();
this.maximumPoolSize = maximumPoolSize;
if (workerCountOf(ctl.get()) > maximumPoolSize)
interruptIdleWorkers();
}
- 说一说对于线程空闲回收的理解,源码中如何体现的?
答:空闲线程回收的时机:如果线程超过 keepAliveTime 时间后,还从阻塞队列中拿不到任务 (这种情况我们称为线程空闲),当前线程就会被回收,如果 allowCoreThreadTimeOut 设置成 true,core thread 也会被回收,直到还剩下一个线程为止,如果 allowCoreThreadTimeOut 设置 成 false,只会回收非 core thread 的线程。 线程在任务执行完成之后,之所有没有消亡,是因为阻塞的从队列中拿任务,在 keepAliveTime 时间后都没有拿到任务的话,就会打断阻塞,线程直接返回,线程的生命周期就结束了,JVM 会 回收掉该线程对象,所以我们说的线程回收源码体现就是让线程不在队列中阻塞,直接返回了, 可以见 ThreadPoolExecutor 源码解析章节第三小节的源码解析。
- 如果我想在线程池任务执行之前和之后,做一些资源清理的工作,可以么,如何做?
答:可以的,ThreadPoolExecutor 提供了一些钩子函数,我们只需要继承 ThreadPoolExecutor 并实现这些钩子函数即可。在线程池任务执行之前实现 beforeExecute 方法,执行之后实现 afterExecute 方法。
- 线程池中的线程创建,拒绝请求可以自定义实现么?如何自定义?
答:可以自定义的,线程创建默认使用的是 DefaultThreadFactory,自定义话的只需要实现 ThreadFactory 接口即可;拒绝请求也是可以自定义的,实现 RejectedExecutionHandler 接口即 可;在 ThreadPoolExecutor 初始化时,将两个自定义类作为构造器的入参传递给 ThreadPoolExecutor 即可。
- 说说你对 Worker 的理解?
答:详见《ThreadPoolExecutor 源码解析》中 1.4 小节。
- 说一说 submit 方法执行的过程?
答:详见《ThreadPoolExecutor 源码解析》中 2 小节。
- 说一说线程执行任务之后,都在干啥?
答:线程执行任务完成之后,有两种结果:
-
- 线程会阻塞从队列中拿任务,没有任务的话无限阻塞;
-
- 线程会阻塞从队列中拿任务,没有任务的话阻塞一段时间后,线程返回,被 JVM 回收。
- keepAliveTime 设置成负数或者是 0,表示无限阻塞?
答:这种是不对的,如果 keepAliveTime 设置成负数,在线程池初始化时,就会直接报 IllegalArgumentException 的异常,而设置成 0,队列如果是 LinkedBlockingQueue 的话,执行 workQueue.poll (keepAliveTime, TimeUnit.NANOSECONDS) 方法时,如果队列中没有任务,会直 接返回 null,导致线程立马返回,不会无限阻塞。 如果想无限阻塞的话,可以把 keepAliveTime 设置的很大,把 TimeUnit 也设置的很大,接近于 无限阻塞。
- 说一说 Future.get 方法是如何拿到线程的执行结果的?
答:我们需要明确几点:
-
- submit 方法的返回结果实际上是 FutureTask,我们平时都是针对接口编程,所以使用的是 Future.get 来拿到线程的执行结果,实际上是 FutureTask.get ,其方法底层是从 FutureTask 的 outcome 属性拿值的
-
- 《ThreadPoolExecutor 源码解析》中 2 小节中详细说明了 submit 方法最终会把线程的执行 结果赋值给 outcome。 结合 1、2,当线程执行完成之后,自然就可以从 FutureTask 的 outcome 属性中拿到值。

309

被折叠的 条评论
为什么被折叠?



