Java源码-核心笔记

语雀完整版:

https://www.yuque.com/g/mingrun/embiys/yf93au/collaborator/join?token=ZaH4itL0eqefhfD2&source=doc_collaborator# 《Java源码》

JAVA源码精讲

问题

  1. 延迟队列为基础的 定时线程池怎么用的
  1. 看下 threadLocal,的实现,并看下thread中的代码,是如何处理的
  1. 排序中的双轴快速排序算法
  1. 几种 泛型关键字的用法,T V 啥的
  1. 线程池 summit 是怎么调用 execute的
  1. 思考下 为什么线程池中 要定义一个 worker,他的作用到底是什么,怎么体现的
  1. Stream 流是如何优化的

第1章:基础

01 开篇词:为什么学习本专栏

  1. 进大厂,避免踩坑,结合场景熟练的使用 API,并对其拓展
  1. 每部分源码的分析步骤
    • 怎么用
    • 底层实现,流程图
    • 总结出设计思想,最优使用 和坑
    • 连环面试题

02 StringLong源码解析和面试题

String

  1. 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()方法,那么他一定是有返回值的,返回给你一个新的对象

  1. 字符串乱码:
    要在二进制数据 进行转换的时候 进行统一:String s2 = new String(bytes,"utf-8");
  1. 首字母大小写:name.substring(0, 1).toLowerCase() + name.substring(1);
    其中substring 底层使用 Arrays.copyOfRange(字符数组, 开始 位置, 结束位置);,而copyOfRange底层用的还是 系统的拷贝数组方法(navite修饰的)
  1. equals的实现:
    • 引用一样话返回true
    • 比较对象是String的话,就比较他们里面存的 数组,判断数组的长度 和每个字符是否相等
    • 比较对象不是String返回false
  1. 替换和删除:
    替换用 replace,删除可以把其中一个字符替换成 “”
  1. 拆分和合并:
    java提供的是 split和join方法,但有缺点,这块可以看看Guava提供的方法

Long

  1. 缓存:[-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 常用关键字理解

  1. static:
    修饰类、方法、代码块,变量, 修饰变量要注意线程安全,其中有两个加载顺序如下
    • 父类静态变量 和静态代码块比子类先加载
    • 静态变量和静态代码块比构造器 优先初始化
  1. final:
    被final修饰的类无法继承,方法无法重写,变量无法改变内存地址
  1. try-catch-finally:
    注意这一块的面试题
  1. valatil:
    注意这一块的面试题
  1. transient:
    序列化时 忽略该变量
  1. default:
    用过

04 Arrays. Collections. Objects 常用方法源码解析

  1. Arrays:给数组使用
    • 排序(sort): 要重点看看 双轴快速排序算法
    • 查找(binarySearch):要先排好序,否则会查不到,注意复习尚硅谷代码,要会写
    • 拷贝(copyOfRange)
  1. Collections:给集合使用
    • 排序 和查找与arrays中的实现一样
    • 求集合中的最大值和最小值

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

    • 判空:isNull(),nonNull,requireNoNull等这些方法

第二章:集合

05 ArrayList源码解析和设计思路

  1. 概述:

    • 默认数组大小是10,会自动扩容
    • 其中数组大小 size,没加锁,不安全
    • size、isEmpty、get、set、add 等方法时间复杂度都是 O (1)
  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
  1. 迭代器(Iterator)
    由集合实现该接口,如调用lists.iterator()
    源码解析:
    • 三个关键值
// 迭代过程中,下一个元素的位置,默认从 0 开始。
int cursor;
// 新增场景:表示上一次迭代过程中,索引的位置;删除场景:为 -1。
int lastRet = -1; 
// expectedModCount 表示迭代过程中,期望的版本号;modCount 表示数组实际的版本号。
// 这个值 上面的add 和delete方法都会加一,如果迭代时发现不一样 就会报快速异常
int expectedModCount = modCount;

    • hashNext() 方法:return cursor != size;
    • next() 方法:先检查版本号,然后记录cursor 和lastRet的值,最后返回数组中对应的元素
    • remove() 方法:这里主要就是用了 expectedModCount = modCount;,所以用这个方法可以避免快速失败

06 LinkedList源码解析

  1. 概述:


    注意 看Node源码,它是双向的链表
  1. 源码:
    • 增加删除,要会写
    • 节点查询:linkedList.get(9),底层会判断 索引在链表的前半部分还是后半部分,然后从头节点遍历或者从尾部遍历
    • 迭代(ListInterator):差不太多

07 List源码会问哪些面试题

下面

08 HashMap源码解析

其它讲解([逐行分析HashMap源码](C:/Users/Administrator/Desktop/要看的/【带你逐行分析 HashMap 源码】- 优快云.mhtml))

  1. HashMap官方说明
    • 允许Null值和Null键
    • 迭代集合所需的时间和 与HashMap的容量(桶的数量)和大小(键值对的数量)成正比
    • 有两个参数会影响Map的性能,他们是初始容量加载因子,当哈希表的条数 大于了 加载因子与容量的乘积时,就要调用rehash方法扩容,其中加载因子(0.75)是空间和时间上的一种折中
  1. 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);
    //省略 一堆比较方法。。。。
}

    • 从中可以看出存储的是链表结构,即数组中每个位置被当作一个桶,一个桶存放一个链表,就是用链地址法解决的哈希冲突,哈希值和散列桶取模运算结果相同的 都被放到一个链表里面

  1. 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;

  1. 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;

  1. 构造方法
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);
}

  1. 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;
}

  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 时,调用 key.hashCode() 并将 hashCode 的低 16 位与高 16 位异或。
        • 如果不进行高低位异或,初始容量16后28位全是0,hash值也是高位都是0,这样对于桶下标公式而言 就很容易发生冲突,所以用这种方式(需要重看
  1. 桶下标计算公式
    • hash % capacity 就是用上面计算出来的哈希值对容量取模,而如果能保证容量为2的幂,那就会用(capacity - 1) & hash
    • 以上就是要保证  capacity 为 2 的幂的原因之一,另外一个原因是在 resize() 扩容方法中可以更高效的重新计算桶下标。
  1. 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;
}

  1. 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;
}

本篇讲解

  1. 概述:HashMap由 数组+链表+红黑树组成

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

  1. 新增

    • 链表的新增:链表新增很简单,和正常添加节点一样,就是当链表的长度超过8 并且数组的大小超过64才会扩容为64,之所以用8是因为 考虑了泊松分布概览函数,链表长度能到8的概率不到千分之一(其中链表的查询速度为O(n),红黑树的查询长度为 O (log (n))  )
    • 红黑树的节点新增
      • 先判断节点在树上是不是已经存在,有下面两种手段
        • 如果节点没有实现Comparable 接口,使用 equals 进行判断;
        • Comparable 接口,使用 compareTo 进行判断。
      • 新增的节点如果在红黑树上直接返回,如果不在就判断是新增到 当前节点的左边还是右边
      • 递归运行上面两步,直到当前节点的左节点或右节点为空
      • 与当前节点建立父子关系,然后就是旋转(为了保证树的平衡 )和着色(按照红黑树的功能)
  1. 查找
    • 根据hash定位数组的索引位置,然后用eques判断当前节点是不是要寻找的,不是的话往下
    • 判断当前节点有无 next 节点,有的话判断是链表类型,还是红黑树类型。
    • 分别走链表和红黑树不同类型的查找方法

09 TreeMap和LinkedHashMap核心源码解析

TreeMap

  1. 知识储备-两种排序方式
    • 实现Comparable 接口
    • 继承Comparator
  1. 概述
    • 底层用的也是红黑树,并且因为红黑树具有排序树的特性,所以适合key需要排序的场景
    • 因为底层使用的是平衡红黑树的结构,所以 containsKey、 get、 put、 remove 等方法的时间复杂
      度都是 log(n)。
  1. 属性
//比较器,如果外部有传进来 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> {}

  1. 新增节点
    • 判断红黑树的节点是否为空,为空的话,新增的节点直接作为根节点
    • 根据红黑树左小右大的特性,进行判断,找到应该新增节点的父节点
    • 在父节点的左边或右边插入新增节点
    • 着色旋转,达到平衡,结束  。
  1. 注意
    • 查找过程中,发现 key 值已经存在,直接覆盖;
    • TreeMap 是禁止 key 是 null 值的。

LinkedHashMap

  1. 概述
    • 继承了HashMap,另外拥有两大特性如下
      • 按照插入顺序进行访问
      • 最少访问,最先删除(LRU)
  1. 按照插入顺序访问
    • 链表结构:从下面代码中可以看出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结构,内部实现的迭代器确定的)
  1. 访问最少删除策略
    • 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() 方法,以此来删除头节点
  1. 总结
    • 原本HashMap中的存储结构是不变的,LinkedHashMap其内部定义的 节点也就是Entry,继承了HashMap的Node,并且添加了before, after前后节点的引用,然后重写了 HashMap中的put方法要调用的newNode和newTreeNode,为他们加上before, after引用,以此来构建一条链表

10 Map源码会问哪些面试题

下面

11 HashSet, TreeSet源码解析

HashSet

  1. 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()

  1. 初始化
    其中入参为集合时如下:
// 对 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);
}

  1. Add方法:就直接put就行了
public boolean add(E e) {
    // 直接使用 HashMap 的 put 方法,进行一些简单的逻辑判断
    return map.put(e, PRESENT)==null;
}

TreeSet

  1. 概述
    • 底层使用的是TreeMap,对TreeMap进行复用,所以就有的key能够排序的功能,也可以按照key的排序顺序进行迭代
  1. 复用TreeMap思路一
    • add()方法,直接复用的TreeMap的
public boolean add(E e) {
    return m.put(e, PRESENT)==null;
}

  1. 复用TreeMap思路二
    • 迭代TreeSet中的元素:由TreeSet定义接口规范,让TreeMap去完成具体的实现
  1. 总结:因为对于add这种方法,逻辑较为简单,所以直接让TreeSet去实现,
    对于迭代场景,可能取第一个值,也可能取最后一个值,TreeMap底层数据结构也比较复杂,TreeSet可能不清楚它的复杂数据结构,所以不如直接交给TreeMap

12 彰显细节:看集合源码对我们实际工作的帮助和应用

  1. 注意事项
    • 线程安全


      这些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里面的一个静态内部类,就没有实现这两个方法

      • toArray 方法时,申明的数组(返回值)大小一定要大于等于 List 的大小,如果小于的话,你会得到一个空数组。

13 差异对比:集合在Java7和8有何不同和改进

  1. 所有集合都加了forEach方法
    • Iterable 接口提供的

  1. List区别
    • ArrayList,创建的时候直接就 初始化为10的大小,java8就是第一次add的时候才会创建了(底层数据结构的加载变成懒加载了都)
  1. 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
  1. Arrays 提供了一些parallel 开头的方法(多线程),比如parallelSort(),只有数据量大的时候(>8192)

14 简化工作: Guava Lists Maps实际工作运用和源码

回头看

第三章:并发集合类

01 CopyOnWriteArrayList 源码解析和设计思路

  1. 对于List的线程安全问题,除使用Collections.synchronizedList外,还可使用CopyOnWriteArrayList ,他有特征如下:
    • 线程安全,多线程下无需加锁
    • 通过锁 + 数组拷贝 + volatile 关键字保证了线程安全;
    • 每次数组操作,都会把数组拷贝一份出来,在新数组上进行操作,操作成功之后再赋值回去。
  1. 整体架构
    • 对数组操作的四步
      • 加锁;
      • 从原数组中拷贝出新数组;
      • 在新数组上进行操作,并把新数组赋值给数组容器;
      • 解锁。
    • 其中底层数组被volatile修饰
    • 类注释:
      • 所有的操作都是线程安全的,因为操作都是在新拷贝数组上进行的;
      • 数组的拷贝虽然有一定的成本,但往往比一般的替代方案效率高;
      • 迭代过程中,不会影响到原来的数组,也不会抛出 ConcurrentModificationException 异常。
  1. 新增
    • 向尾部添加一个元素
// 添加元素到数组尾部
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 关键字修饰的是数组,如果我们简单的在原来数组上修改其中某几个元素的值,是
          无法触发可见性的,我们必须通过修改数组的内存地址才行,也就说要对数组进行重新赋值
          才行。
        • 在新的数组上进行拷贝,对老数组没有任何影响,只有新数组完全拷贝完成之后,外部才能
          访问到,降低了在赋值过程中,老数组数据变动的影响。
    • 向任意一个位置添加元素
// 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);

      • 先判断插入的是不是尾部,是的话走上面的逻辑
      • 如果插入的是中间,那就要把原数组一分为二 复制到新数组中,并且留个空位,插入这个元素
  1. 删除
    • 加锁
    • 判断索引的位置,根据不同的策略复制数组
    • 解锁
  1. 批量删除
    • 加锁
    • 把在给定集合中不包含的数组元素 添加到一个临时数组中,这样临时数组中的元素都是不需要删除的了,再拷贝这个临时数组就可以了
    • 解锁
    • 思想总结:他和ArraysList有异想同工之妙,但是这里如果原有100个元素,我传过来的c集合只有一个元素,那就要拷贝剩下的99个元素,很是浪费
  1. 其它方法
    • index方法:就是简单的对 数组进行遍历,然后比较里面的元素(equals方法)
    • 迭代:首先它不会出现快速异常,并且迭代过程中不会出现线程安全问题,因为迭代器先持有了原数组的引用地址,如果迭代过程中add一个元素,原数组的引用就会指向一个新的数组地址,但迭代器持有的不变,所以不会受数组结构变化的影响

02 ConcurrentHashMap 源码解析和设计思路

  1. 类注释
    • 线程安全,多个线程同时进行put和remove等操作时不会发生阻塞
    • 迭代过程中不会出现快速失败
    • 除了数组 + 链表 + 红黑树的基本结构外,新增了转移节点,是为了保证扩容时的线程安全的
      节点;
    • 提供了很多 Stream 流式方法,比如说: forEach、 search、 reduce 等等。
  1. 结构
    • 从类图看和hashMap没有半毛钱关系,虽然有很多代码相似,但因为很多地方要加锁,没法用继承

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

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

03 [*]并发 List、 Map 源码面试题

下面

04 场景集合:并发 List、 Map 的应用场景

在流引擎中的使用

第四章:队列

01 LnkedBlockingQueue源码解析

  1. 整体架构


    BlockingQueue的一些主要操作

  1. 类注释:
    基于链表、先进先出、链表大小最大为 Integer.MaxValue、可以使用 Collention和Interator的所有操作
  1. 主要构成
    • 链表存储+锁+迭代器
    • 数据节点:Node节点(存放数据),可以看出它是单向的链表
    • 成员变量:
      • 链表的容量(默认是Integer.MAX_VALUE)
      • 已有元素的大小(这里用的原子类,线程安全的)
      • 链表头尾指针
      • 用于 take和put的两把lock锁,以及对应的两个Condition
  1. 初始化
    • 构造函数-不指定容量
    • 构造函数-指定容量
    • 构造函数-指定了一个集合:然后加 putLock,把元素放入,最后解锁
  1. 阻塞新增 Put()
    • 先设置一个中断锁
    • 如果队列已满 就进入等待notFull.await()(这里用了Condition 后续需要知晓其原理)
    • 没满就加入元素,加入后还没满就尝试 唤醒其它put notFull.signal();,然后解锁,如果队列里面只有一个元素 再唤醒一个take等待线程
  1. 阻塞删除take()
    • 和put的流程差不太多  反过来了就
    • 而对于 peek操作,加锁后 用头节点指针 拿到第一个元素 返回即可

02 SynchronousQueue源码解析

  1. 概述
    • 放入一个数据,必须要等消费掉才能返回,在MQ中间件中使用的比较多
    • 抽象出了两种实现,一个是队列,一个是栈,对应了两个内部类,put和take方法 调用了这两个内部类中的transfer方法
    • SynchronousQueue的类图与 LnkedBlockingQueue是一样的,只是他其中的 isEmpty remove等方法给了默认实现
  1. 注释
    • 队列不存储数据,无法迭代,没有大小
    • 插入操作必须等待 另外一个删除线程 完成操作
    • 堆栈不公平,队列是公平的
  1. 结构细节
    • 接口Transferer:负责put or take
    • TransferStack:非公平的堆栈,默认使用这个,效率较高
    • TransferQueue:队列结构,公平的
  1. 非公平的堆栈,class SNode
    • 先入后出,所以非公平
    • 代码结构:其中 最重要的是 SNode match,有数据时可以被 take,无数据时 可以put
    • 具体实现:没看
  1. 公平的队列,class QNode
    • 具体实现:very 复杂

03 DelayQueue源码解忻

  1. 概述
    延迟队列,在延迟一定时间后再去获取资源
  1. 类注释
    对头的元素 会更早过期,过期后才能被take
  1. 类图
    和上面的一样,其中对元素的要求是 要继承Delayed
public class DelayQueue<E extends Delayed> extends AbstractQueue<E> implements BlockingQueue<E>

  1. 放数据
    上锁,然后就是新增元素的时候要进行扩容,然后还进行排序,确保过期最快的 队列放到队头
  1. 拿数据
    先判断队头是否为空,为空就等待,不为空就看过期没,没过期就等待,可设置过期时间
  1. 总结:要学会对已有的代码 尽量复用

04 ArayBlockingQueue源码解析

  1. 概述
    • 有界阻塞数组,创建后无法扩容
    • 先进先出队列(这是个循环队列
    • take和put会阻塞
  1. 数据结构
    • 一个数组,初始化时需要设置
    • 下次拿数据 和下次放数据的索引位置
    • 可重入锁和 notEmpty 和 notNull两个Condition,如下所示
// take 的队列
private final Condition notEmpty;
// put 的队列
private final Condition notFull;

  1. 初始化
    • 初始化数组大小
    • 设置锁(公平or非公平)
    • 初始化 condition
  1. 数据新增 put()
    如果队列满 就要进入无限阻塞 ,notFull.await()
    如果要插入,要判断下次是否在队尾,如果是就要从头开始插入(循环队列)
    最后会唤醒 因为队列为空导致等待的线程 notEmpty.signal();
  1. 拿数据 take()
    如果队列为空,就进入无限阻塞,notEmpty.await()
    如果数据也分是否是队尾,如果是就要从头开始拿数据
    最后会唤醒 因为队列满了导致等待的线程 notFull.signal();
  1. 删除 remove()
    要考虑好几个特殊的场景,回头再仔细看看

05 队列在源码方面的面试题

下面

06 举一反三:队列在Java 其它源码中的应用

  1. 队列与线程池
    • 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());
}

  1. 队列和锁,ReentrantLock中 自己实现了一个同步队列

07 整体设计:队列设计思想工作中使用场景

  1. 区别


    区别可以从 数据结构、入队出队方式、生产者和消费者之间的通信机制(强关联和无关联)
  1. 工作中使用的场景
    一般多用  LinkedBlockingQueue,固定数据用 ArrayBlockingQueue,对于DelayQueue 有以下场景,
    转账时调用接口超时,这时使用延时队列,等一会去对个账

08 惊叹面试官:由浅入深手写队列

看代码 demo.four.DIYQueuedemo.four.DIYQueueDemo

第5章线程

01 Thread源码解析

  1. 类注释
    • 每个线程都有优先级,优先级高的可能会先执行
    • 父线程创建的子线程,子线程和父线程的 优先级、是否是守护线程等属性是一致的
    • Java中有两种线程,用户线程和守护线程 ,守护线程需要在用户线程全退出的时候才能结束,而这个时候Jvm也停止了运行,其中守护线程创建方式为 new Thread() .setDaemon,而像垃圾回收进程就是守护线程
  1. 线程基本概念
    • 线程的状态转换

    • 优先级:要注意 这只是可能性比较大,对于优先级较高的而言
// 最低优先级
public final static int MIN_PRIORITY = 1;
// 普通优先级,也是默认的
public final static int NORM_PRIORITY = 5;
// 最大优先级
public final static int MAX_PRIORITY = 10

    • 守护线程
      默认线程都是非守护的,可以设置daemon属性 来变成守护线程,jvm退出时不考虑守护线程,一般用于一些监控系统,即使抛错也不影响主业务
    • ClassLoader
  1. 创建线程的两种方式
    • 继承Thread类
      • start() 方法流程
        • 判断是否已经初始化
        • 加入线程组
        • 调用一个 navite 方法 start0(); 来创建一个线程
    • 实现Runnable接口
      • 直接调用run方法  依然是主线程在调用,不创建线程
      • 调用start方法,才会去新建一个线程
// 简单的运行,不会新起线程,target 是 Runnable
public void run() {
    if (target != null) {
        target.run();
    }
}

  1. 线程初始化
    • 无参构造器:其中有自动命名
    • init()函数:无参和Runable为参数的构造方法 中都会调用这个方法,在这个方法中 会获取父线程,然后继承父线程的 守护属性、优先级、calssLoader(contextClassLoader)、inheritableThreadLocals 里面的值
  1. 其它操作
    • join:
    • yield(navite方法):让CPU重新选择一个线程来执行
    • sleep(navite方法):睡一会,不释放资源
    • interrupt: 线程通过Object#wait ()、Thread#join ()、Thread#sleep (long),这些方法运行后进入 WAITING 或者 TIMED_WAITING ,这时候打断这些线程就会抛出 InterruptedException异常,然后进入 TERMINATED 状态

02 Future、ExecutorService 源码解析

  1. 线程API之间的关联

  1. 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();

  1. Callable
public interface Callable<V> {
    V call() throws Exception;
}

  1. 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,这样避免了 线程池还得实现两套为他俩
  1. FutrueTask 细探究
    • 类定义:public class FutureTask implements RunnableFuture {}
    • FutureTask的属性:
      • 各种任务状态
      • 组合了 Callable,private Callable callable;
      • 当前任务线程,和调用get的线程被
    • 构造函数:
      • callable 构造函数,设置callable 和 任务状态
      • runnable 构造函数,FutureTask(Runnable runnable, V result)这里用了 适配器模式,把runable 转换成 callable,要重点看看,这样 FutureTask对外提供的就只是 功能更加丰富的 Callable接口了。
    • FutureTask 对 Future 接口方法的实现
      • V get(long timeout, TimeUnit unit)
        其中 先判断等待是否超时,超时就抛异常,其中等待方法 awaitDone() 里面有个死循环,很重要
        • 用了 yield方法 ,让给其它线程
        • Thread.interrupted(),判断是否已经被中断了,被中断的话就抛 中断异常
        • 底层阻塞使用的是 LockSupport.park  使线程进入等待 或超时等待状态
      • run(),后续再看下 到底是什么线程执行的
      • cancel(),进行打断

03 押宝线程源码面试题

下面

第6章锁

01 AbstractQueuedSynchronizer源码解析(上)

整体架构

  1. 概述  AQS中有两个队列,同步队列和条件队列,而aqs本身算是一套框架,定义了获得锁和释放锁的代码结构

  1. 类注释(重要的)
    • 提供了一个框架,定义了先进先出的同步队列,获取不到锁的进程就先进去排队
    • 有个状态字段,通过它来判断是否能获得锁
    • 子类 可以通过cas来对上面的 状态字段进行赋值,来判断什么值 可以拿到锁
    • 子类可以新建非 public 的内部类,用内部类来继承 AQS,从而实现锁的功能;
    • AQS 提供了排它模式和共享模式两种锁模式。排它模式下:只有一个线程可以获得锁,共享 模式可以让多个线程获得锁,子类 ReadWriteLock 实现了两种模式;
    • 内部类 ConditionObject 可以被用作 Condition,我们通过 new ConditionObject () 即可得到 条件队列
    • AQS 实现了锁、排队、锁队列等框架,至于如何获得锁、释放锁的代码并没有实现,比如 tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared、isHeldExclusively 这些方法, AQS 中默认抛 UnsupportedOperationException 异常,都是需要子类去实现的;
    • AQS 继承 AbstractOwnableSynchronizer 是为了方便跟踪获得锁的线程,可以帮助监控和诊 断工具识别是哪些线程持有了锁;
    • AQS 同步队列和条件队列,获取不到锁的节点在入队时是先进先出,但被唤醒时,可能并不会按照先进先出的顺序执行。
  1. 类定义
// 1. 是个抽象类,就是让子类去实现它的一些功能的
// 2. AbstractOwnableSynchronizer 的作用就是为了 方便知道当前是哪个线程获得了锁
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
}

  1. 基础属性
    • 基础属性
//同步器的状态,比如通过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;
}

    • 共享锁和排他锁
      排它锁是指 同一时刻只有一个线程能获得锁和释放锁,排他锁则可以有多个线程,并且可以设置数量
  1. Condition
    • 他的实现类 比如有ConditionObject (条件队列)
    • 主要类注释:
      • 当 lock 代替 synchronized 来加锁时,Condition 就可以用来代替 Object 中相应的监控方法了, 比如 Object#wait ()、Object#notify、Object#notifyAll 这些方法
      • Condition 提供了明确的语义和行为,这点和 Object 监控方法不同
    • 注意其在 队列中的应用
    • await() 方法
      是当前线程一直等待,直到下面中的一种情况发生
      • 有线程使用了 signal 方法,正好唤醒了条件队列中的当前线程;
      • 有线程使用了 signalAll 方法;
      • 其它线程打断了当前线程,并且当前 线程支持被打断
      • 被虚假唤醒(有一些没有用的线程也被唤醒了)
    • 其它方法
// long值表示剩余等待时间,如果小于等于 0 ,说明等待时间过了,纳秒能避免 计算时间导致误差
long awaitNanos(long nanosTimeout) throws InterruptedException;
// 虽然入参可以是任意单位的时间,但底层仍然转化成纳秒
boolean await(long time, TimeUnit unit) throws InterruptedException;
// 唤醒条件队列中的一个线程,在被唤醒前必须先获得锁
void signal();
// 唤醒条件队列中的所有线程
void signalAll();

同步器的状态

  1. state
    • state 是锁的状态,上面讲过
  1. waitStatus
    • 是节点Node的状态,上面讲过

获取锁

  1. 概述
    获取锁 最常见的案例就是 Lock.lock (),这个lock方法 会去调用acquiretryAcquire方法,
    前者aqs已经实现,他会先尝试是否能获取到锁,获取不到时 再进入同步队列中等待锁
    后者需要子类实现
  1. 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);
}

  1. acquireShared() 共享锁
    主要就是 一个节点获取锁之后,也会释放后面的节点

02 AbstractQueuedSynchronizer源码解析(下)

  1. 释放排他锁
    release(int arg)  - > release(int arg) 这个是子类实现的 ->  找到要唤醒的节点,然后使用 LockSupport.unpark(s.thread); 唤醒线程
  1. 释放共享锁
  1. 条件队列
    为什么要用 条件队列(不好理解),下面都是定义在ConditionObject中的方法(注意在队列中用过)
    • await():先是加入到 条件队列的队尾,然后阻塞到这个地方,直到被唤醒的时候,再把当前这个节点加入到同步队列中去
    • signal(): 从条件队列中的 头节点开始唤醒,并把这个 节点加入到同步队列中去
    • signalAll():则是唤醒全部

03 Reentrantlock源码解析

  1. 类结构
//类定义
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();


 

  1. 两个构造器
// 无参数构造器,相当于 ReentrantLock(false),默认是非公平的
public ReentrantLock() {
    sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

  1. Sync同步器
    • nonfairTryAcquire
      • 本方法用于尝试获得非公锁
      • 先判断同步器的状态,如果没人持有,就让当前线程持有,如果当前线程已经持有了,那就增加线程持有的数量(可重入锁),上面条件都不满足的话 就返回false,接下来就会是加入同步队列的操作
    • tryLock()
public boolean tryLock() {
    // 入参数是 1 表示尝试获得一次锁
    return sync.nonfairTryAcquire(1);
}

    • tryRelease() 方法,公平和非公平锁都在用
      • 计算出 要释放的数量 int c = getState() - releases;
      • 然后对c 进行校检,比如等于0的话就代表已经都是放了锁,不为零在设置就行了
  1. 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);
}

  1. 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;
}

  1. 捋一下
    • FairSync 和 NonfairSync 各自实现了 lock方法,和tryAcquire方法,Sync中实现了tryLock() 和reyRelese() 方法
    • lock():非公平锁是先设置一下状态值,不成功再调用aqs的acquire() 方法,公平锁则是直接acquire() 方法
    • tryLock() 方法:调用nonfairTryAcquire 尝试一下
    • tryAcquire() 方法:非公平的就是直接调用nonfairTryAcquire 去尝试,公平的会先使用 hasQueuedPredecessors 看看自己是不是在队头的下一个节点,是的话才是能获取锁的
    • tryRelease(): 这个就没有公平与不公平的区别了

04 CountDownLatch、Atomic 等其它源码解析

CountDownLatch

  1. 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);
}

  1. countDown
public void countDown() {
    sync.releaseShared(1);
}


releaseShared是aqs实现的,第一步是调用子类的 tryReleaseShared尝试释放锁,第二步是释放当前节点的后置等待节点。

Atomic

  1. 它是线程安全的,但要看你咋用,里面的value只是用 volatile修饰,直接使用get和set方法的话如下所示,都没加锁
public final int get() {
    return value;
}
public final void set(int newValue) {
    value = newValue;
}

  1. 线程安全的设置方法,可以使用 compareAndSet 或者 compareAndSet,底层使用的都是unsafe()类,最终的原子性由操作系统来保证
    public final native boolean compareAndSetInt(Object o, long offset,
                                                 int expected,
                                                 int x)

05 只求问倒:连环相扣系列锁面试题

AQS 相关面试题

  1. 说说自己对 AQS 的理解?
    答:回答这样的问题的时候,面试官主要考察的是你对 AQS 的知识有没有系统的整理,建议回 答的方向是由大到小,由全到细,由使用到原理。 如果和面试官面对面的话,可以边说边画出我们在 AQS 源码解析上中画出的整体架构图,并且 可以这么说:
    • AQS 是一个锁框架,它定义了锁的实现机制,并开放出扩展的地方,让子类去实现,比如我 们在 lock 的时候,AQS 开放出 state 字段,让子类可以根据 state 字段来决定是否能够获得 锁,对于获取不到锁的线程 AQS 会自动进行管理,无需子类锁关心,这就是 lock 时锁的内 部机制,封装的很好,又暴露出子类锁需要扩展的地方;
    • AQS 底层是由同步队列 + 条件队列联手组成,同步队列管理着获取不到锁的线程的排队和释 放,条件队列是在一定场景下,对同步队列的补充,比如获得锁的线程从空队列中拿数据, 肯定是拿不到数据的,这时候条件队列就会管理该线程,使该线程阻塞;
    • AQS 围绕两个队列,提供了四大场景,分别是:获得锁、释放锁、条件队列的阻塞,条件队列的唤醒,分别对应着 AQS 架构图中的四种颜色的线的走向。
    • 以上三点都是 AQS 全局方面的描述,接着你可以问问面试官要不要说细一点,可以的话,按照 AQS 源码解析上下两篇,把四大场景都说一下就好了。 这样说的好处是很多的:
      • 面试的主动权把握在自己手里,而且都是自己掌握的知识点;
      • 由全到细的把 AQS 全部说完,会给面试官一种你对 AQS 了如指掌的感觉,再加上全部说完 耗时会很久,面试时间又很有限,面试官就不会再问关于 AQS 一些刁钻的问题了,这样 AQS 就可以轻松过关。 当然如果你对 AQS 了解的不是很深,那么就大概回答下 AQS 的大体架构就好了,就不要说的特别细,免得给自己挖坑。
  1. 多个线程通过锁请求共享资源,获取不到锁的线程怎么办?
    答:加锁(排它锁)主要分为以下四步:
    • 尝试获得锁,获得锁了直接返回,获取不到锁的走到 2;
    • 用 Node 封装当前线程,追加到同步队列的队尾,追加到队尾时,又有两步,如 3 和 4;
    • 自旋 + CAS 保证前 一个节点的状态置为 signal;
    • 阻塞自己,使当前线程进入等待状态。 获取不到锁的线程会进行 2、3、4 步,最终会陷入等待状态,这个描述的是排它锁。
  1. 排它锁和共享锁的处理机制是一样的么?
    答:排它锁和共享锁在问题 1.2 中的 2、3、4 步骤都是一样的, 不同的是在于第一步,线程获 得排它锁的时候,仅仅把自己设置为同步队列的头节点即可,但如果是共享锁的话,还会去唤醒 自己的后续节点,一起来获得该锁。
  1. 共享锁和排它锁的区别?
    答:排它锁的意思是同一时刻,只能有一个线程可以获得锁,也只能有一个线程可以释放锁。 共享锁可以允许多个线程获得同一个锁,并且可以设置获取锁的线程数量,共享锁之所以能够做 到这些,是因为线程一旦获得共享锁,把自己设置成同步队列的头节点后,会自动的去释放头节 点后等待获取共享锁的节点,让这些等待节点也一起来获得共享锁,而排它锁就不会这么干。
  1. 排它锁和共享锁说的是加锁时的策略,那么锁释放时有排它锁和共享锁的策略么?
    答:是的,排它锁和共享锁,主要体现在加锁时,多个线程能否获得同一个锁。 但在锁释放时,是没有排它锁和共享锁的概念和策略的,概念仅仅针对锁获取。
  1. 描述下同步队列?
    答:同步队列底层的数据结构就是双向的链表,节点叫做 Node,头节点叫做 head,尾节点叫做 tail,节点和节点间的前后指向分别叫做 prev、next,如果是面对面面试的话,可以画一下 AQS  整体架构图中的同步队列。 同步队列的作用:阻塞获取不到锁的线程,并在适当时机释放这些线程。 实现的大致过程:当多个线程都来请求锁时,某一时刻有且只有一个线程能够获得锁(排它锁), 那么剩余获取不到锁的线程,都会到同步队列中去排队并阻塞自己,当有线程主动释放锁时,就 会从同步队列中头节点开始释放一个排队的线程,让线程重新去竞争锁。
  1. 描述下线程入、出同步队列的时机和过程?
    答:(排它锁为例)从 AQS 整体架构图中,可以看出同步队列入队和出队都是有两个箭头指向,所 以入队和出队的时机各有两个。 同步队列入队时机:

  • 多个线程请求锁,获取不到锁的线程需要到同步队列中排队阻塞;
  • 条件队列中的节点被唤醒,会从条件队列中转移到同步队列中来。 同步队列出队时机: 1. 锁释放时,头节点出队;
  • 获得锁的线程,进入条件队列时,会释放锁,同步队列头节点开始竞争锁。 四个时机的过程可以参考 AQS 源码解析,1 参考 acquire 方法执行过程,2 参考 signal 方法,3  参考 release 方法,4 参考 await 方法。

  1. 为什么 AQS 有了同步队列之后,还需要条件队列?
    答:的确,一般情况下,我们只需要有同步队列就好了,但在上锁后,需要操作队列的场景下, 一个同步队列就搞不定了,需要条件队列进行功能补充,比如当队列满时,执行 put 操作的线程 会进入条件队列等待,当队列空时,执行 take 操作的线程也会进入条件队列中等待,从一定程 度上来看,条件队列是对同步队列的场景功能补充。
  1. 描述一下条件队列中的元素入队和出队的时机和过程?
    答:入队时机:执行 await 方法时,当前线程会释放锁,并进入到条件队列。 出队时机:执行 signal、signalAll 方法时,节点会从条件队列中转移到同步队列中。 具体的执行过程,可以参考源码解析中 await 和 signal 方法。
  1. 描述一下条件队列中的节点转移到同步队列中去的时机和过程?
    答:时机:当有线程执行 signal、signalAll 方法时,从条件队列的头节点开始,转移到同步队列 中去。 过程主要是以下几步:
    • 找到条件队列的头节点,头节点 next 属性置为 null,从条件队列中移除了;
    • 头节点追加到同步队列的队尾;
    • 头节点状态(waitStatus)从 CONDITION 修改成 0(初始化状态);
    • 将节点的前一个节点状态置为 SIGNAL。
  1. 线程入条件队列时,为什么需要释放持有的锁?
    答:原因很简单,如果当前线程不释放锁,一旦跑去条件队里中阻塞了,后续所有的线程都无法 获得锁,正确的场景应该是:当前线程释放锁,到条件队列中去阻塞后,其他线程仍然可以获得 当前锁。

AQS 子类锁面试题

  1. 你在工作中如何使用锁的,写一个看一看?
    答:这个照实说就好了,具体 demo 可以参考:demo.sixth.ConditionDemo。
  1. 如果我要自定义锁,大概的实现思路是什么样子的?
    答:现在有很多类似的问题,比如让你自定义队列,自定义锁等等,面试官其实并不是想让我们 重新造一个轮子,而是想考察一下我们对于队列、锁理解的深度,我们只需要选择自己最熟悉的 API 描述一下就好了,所以这题我们可以选择 ReentrantLock 来描述一下实现思路:

  • 新建内部类继承 AQS,并实现 AQS 的 tryAcquire 和 tryRelease 两个方法,在 tryAcquire 方 法里面实现控制能否获取锁,比如当同步器状态 state 是 0 时,即可获得锁,在 tryRelease  方法里面控制能否释放锁,比如将同步器状态递减到 0 时,即可释放锁;
  • 对外提供 lock、release 两个方法,lock 表示获得锁的方法,底层调用 AQS 的 acquire 方法, release 表示释放锁的方法,底层调用 AQS 的 release 方法。

  1. 描述 ReentrantLock 两大特性:可重入性和公平性?底层分别如何实现的?
    • 可重入性说的是线程可以对共享资源重复加锁,对应的,释放时也可以重复释放,对于 ReentrantLock 来说,在获得锁的时候,state 会加 1,重复获得锁时,不断的对 state 进行递增 即可,比如目前 state 是 4,表示线程已经对共享资源加锁了 4 次,线程每次释放共享资源的锁 时,state 就会递减 1,直到递减到 0 时,才算真正释放掉共享资源。
    • 公平性和非公平指的是同步队列中的线程得到锁的机制,如果同步队列中的线程按照阻塞的顺序 得到锁,我们称之为公平的,反之是非公平的,公平的底层实现是 ReentrantLock 的 tryAcquire  方法(调用的是 AQS 的 hasQueuedPredecessors 方法)里面实现的,要释放同步队列的节点时 (或者获得锁时),判断当前线程节点是不是同步队列的头节点的后一个节点,如果是就释放, 不是则不能释放,通过这种机制,保证同步队列中的线程得到锁时,是按照从头到尾的顺序的。
  1. 如果一个线程需要等待一组线程全部执行完之后再继续执行,有什么好的办法么?是如何实 现的?
    答:CountDownLatch 就提供了这样的机制,比如一组线程有 5 个,只需要在初始化 CountDownLatch 时,给同步器的 state 赋值为 5,主线程执行 CountDownLatch.await ,子线程 都执行 CountDownLatch.countDown 即可。
  1. Atomic 原子操作类可以保证线程安全,如果操作的对象是自定义的类的话,要如何做呢?
    答: Java 为这种情况提供了一个 API:AtomicReference,AtomicReference 类可操作的对象是个 泛型,所以支持自定义类。

06 经验总结:各种锁在工作中使用场景和细节

没啥讲的

07 从容不迫:重写锁的设计结构和细节

第7章线程池

01 ThreadPoolExecutor源码解析

  1. 整体架构(问线程池的 时候下面这个完整说出来)


    类图


    其中上面这几个类的大致介绍如下
    • Executor : 这个接口中只定义了一个方法 void execute(Runnable cammand)
    • ExecutorService: 定义了一些方法
    • AbstractExecutorService:封装了 Executor的很多通用功能,其中就有summit功能
    • ThreadPoolExecutor:拥有上面仨的 全部功能
  1. ThreadPoolExecutor  类的注释
    • 好多面试题
  1. 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); //钩子后置
        //省略。。。        
    }
}

  1. 线程池的任务提交 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();

  1. 线程执行完之后
    上述runwork中有一行代码如下
while (task != null || (task = getTask()) != null) {


其中getTask() 方法是从阻塞队列里面 继续拿worker

private Runnable getTask() {
    //省略 。。。
    Runnable r = timed ?
        workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take();
    //省略

  1. 补充: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方法

  1. execute() 总结
    • 提交过来的线程就是要先看用不用排队,不排队的话就执行,执行的话肯定不能直接用传过来的线程调用start方法,所以用worker进行包装,worker在执行的时候 还会去阻塞队列中拿任务,而且执行前执行后还提供了钩子方法
    • worker还继承了aps,能干一堆事儿

02 线程池源码面试题

下面

03 经验总结:不同场景,如何使用线程池

  1. 线程池的共用和独立
    • 一般不会公用一个线程池,比如写入和读取,一般读取的并发量较大,如果共用一个线程池可能会造成写操作堵塞·
    • 可以根据业务划分多个线程池
  1. 如何计算线程大小和队列大小
    • 如果实时性要求高,队列设置小一些,coreSize和maxSize设置大一些,如果实时性要求低队列就可以设置大一点
    • 线程池的设置更要根据实际环境

04 打动面试官:线程池流程编排中的运用实战

面试题

第1章:基础

  1. 为什么使用 Long 时,大家推荐多使用 valueOf 方法,少使用 parseLong 方法
    答:因为 Long 本身有缓存机制,缓存了 -128 到 127 范围内的 Long,valueOf(Long l)方法会从缓存中去 拿值,如果命中缓存,会减少资源的开销,parseLong 方法就没有这个机制。
  1. 如何解决 String 乱码的问题
    答:乱码的问题的根源主要是两个:字符集不支持复杂汉字、二进制进行转化时字符集不匹配, 所以在 String 乱码时我们可以这么做:

  • 所有可以指定字符集的地方强制指定字符集,比如 new String 和 getBytes 这两个地方;
  • 我们应该使用 UTF-8 这种能完整支持复杂汉字的字符集。

  1. 为什么大家都说 String 是不可变的
    答:主要是因为 String 和保存数据的 char 数组,都被 final 关键字所修饰,所以是不可变的,具 体细节描述可以参考上文。
  1. String 一些常用操作问题,如问如何分割、合并、替换、删除、截取等等问题
    答:这些都属于问 String 的基本操作题目,考察我们平时对 String 的使用熟练程度,可以参考 上文。
  1. 如何证明 static 静态变量和类无关?
    答:从三个方面就可以看出静态变量和类无关。

  • 我们不需要初始化类就可直接使用静态变量;
  • 我们在类中写个 main 方法运行,即便不写初始化类的代码,静态变量都会自动初始化;
  • 静态变量只会初始化一次,初始化完成之后,不管我再 new 多少个类出来,静态变量都不 会再初始化了。 不仅仅是静态变量,静态方法块也和类无关。

  1. 常常看见变量和方法被 static 和 final 两个关键字修饰,为什么这么做?
    答:这么做有两个目的:

  • 变量和方法于类无关,可以直接使用,使用比较方便;
  • 强调变量内存地址不可变,方法不可继承覆写,强调了方法内部的稳定性。

  1. catch 中发生了未知异常,finally 还会执行么?
    答:会的,catch 发生了异常,finally 还会执行的,并且是 finally 执行完成之后,才会抛出 catch  中的异常。 不过 catch 会吃掉 try 中抛出的异常,为了避免这种情况,在一些可以预见 catch 中会发生异常 的地方,先把 try 抛出的异常打印出来,这样从日志中就可以看到完整的异常了。
  1. volatile 关键字的作用和原理
    答:这个上文说的比较清楚,可以参考上文。
  1. 工作中有没有遇到特别好用的工具类,如何写好一个工具类
    答:有的,像 Arrays 的排序、二分查找、Collections 的不可变、线程安全集合类、Objects 的判 空相等判断等等工具类,好的工具类肯定很好用,比如说使用 static final 关键字对方法进行修饰, 工具类构造器必须是私有等等手段来写好工具类。
  1. 写一个二分查找算法的实现
    答:可以参考 Arrays 的 binarySearch 方法的源码实现。
  1. 如果我希望 ArrayList 初始化之后,不能被修改,该怎么办
    答:可以使用 Collections 的 unmodifiableList 的方法,该方法会返回一个不能被修改的内部类集 合,这些集合类只开放查询的方法,对于调用修改集合的方法会直接抛出异常。

第二章:集合

列表

基础

  1. 说说你自己对 ArrayList 的理解?
    很多面试官喜欢这样子开头,考察面试同学对 ArrayList 有没有总结经验,介于 ArrayList 内容很 多,建议先回答总体架构,再从某个细节出发作为突破口,
    比如这样: ArrayList 底层数据结构 是个数组,其 API 都做了一层对数组底层访问的封装,比如说 add 方法的过程是……(这里可以 引用我们在 ArrayList 源码解析中 add 的过程)。 一般面试官看你回答得井井有条,并且没啥漏洞的话,基本就不会深究了,这样面试的主动权就 掌握在自己手里面了,如果你回答得支支吾吾,那么面试官可能就会开启自己面试的套路了。 说说你自己对 LinkedList 的理解也是同样套路。

扩容类问题

  1. ArrayList 无参数构造器构造,现在 add 一个值进去,此时数组的大小是多少,下一次扩容 前最大可用大小是多少?
    答:此处数组的大小是 1,下一次扩容前最大可用大小是 10,因为 ArrayList 第一次扩容时,是 有默认值的,默认值是 10,在第一次 add 一个值进去时,数组的可用大小被扩容到 10 了。
  1. 如果我连续往 list 里面新增值,增加到第 11 个的时候,数组的大小是多少?
    这里的考查点就是扩容的公式,当增加到 11 的时候,此时我们希望数组的大小为 11,但实 际上数组的最大容量只有 10,不够了就需要扩容,扩容的公式是:oldCapacity + (oldCapacity>>  1),oldCapacity 表示数组现有大小,目前场景计算公式是:10 + 10 /2 = 15,然后我们发现 15  已经够用了,所以数组的大小会被扩容到 15。
  1. 数组初始化,被加入一个值后,如果我使用 addAll 方法,一下子加入 15 个值,那么最终 数组的大小是多少?
    答:第一题中我们已经计算出来数组在加入一个值后,实际大小是 1,最大可用大小是 10 ,现 在需要一下子加入 15 个值,那我们期望数组的大小值就是 16,此时数组最大可用大小只有 10, 明显不够,需要扩容,扩容后的大小是:10 + 10 /2 = 15,这时候发现扩容后的大小仍然不到 我们期望的值 16,这时候源码中有一种策略如下:

// newCapacity 本次扩容的大小,minCapacity 我们期望的数组最小大小 
// 如果扩容后的值 < 我们的期望值,我们的期望值就等于本次扩容的大小 
if (newCapacity - minCapacity < 0) 
	newCapacity = minCapacity;

所以最终数组扩容后的大小为 16。
总结:如果扩容后的值 没法满足,那就直接把新的容量设置成 期望值

  1. 现在我有一个很大的数组需要拷贝,原数组大小是 5k,请问如何快速拷贝?
    答:因为原数组比较大,如果新建新数组的时候,不指定数组大小的话,就会频繁扩容,频繁扩 容就会有大量拷贝的工作,造成拷贝的性能低下,所以回答说新建数组时,指定新数组的大小为 5k 即可。
  1. 为什么说扩容会消耗性能?
    答:扩容底层使用的是 System.arraycopy 方法,会把原数组的数据全部拷贝到新数组上,所以 性能消耗比较严重。
  1. 源码扩容过程有什么值得借鉴的地方?
    答:有两点:
    • 是扩容的思想值得学习,通过自动扩容的方式,让使用者不用关心底层数据结构的变化,封装得很好,1.5 倍的扩容速度,可以让扩容速度在前期缓慢上升,在后期增速较快,大部分 工作中要求数组的值并不是很大,所以前期增长缓慢有利于节省资源,在后期增速较快时, 也可快速扩容。
    • 扩容过程中,有数组大小溢出的意识,比如要求扩容后的数组大小,不能小于 0,不能大于 Integer 的最大值。 这两点在我们平时设计和写代码时都可以借鉴

删除类问题

  1. 有一个 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);
    }
}

  1. 还是上面的 ArrayList 数组,我们通过增强 for 循环进行删除,可以么?
    答:不可以,会报错。因为增强 for 循环过程其实调用的就是迭代器的 next () 方法,当你调用
    list#remove () 方法进行删除时, modCount 的值会 +1,而这时候迭代器中的 expectedModCount
    的值却没有变,导致在迭代器下次执行 next () 方法时, expectedModCount != modCount 就会报
    ConcurrentModificationException 的错误。
  1. 还是上面的数组,如果删除时使用 Iterator.remove () 方法可以删除么,为什么?
    答:可以的,因为 Iterator.remove () 方法在执行的过程中,会把最新的 modCount 赋值给
    expectedModCount,这样在下次循环过程中, modCount 和 expectedModCount 两者就会相等。
  1. 以上三个问题对于 LinkedList 也是同样的结果么?
    答:是的,虽然 LinkedList 底层结构是双向链表,但对于上述三个问题,结果和 ArrayList 是一
    致的。

对比类问题

  1. ArrayList 和 LinkedList 有何不同?
    答:可以先从底层数据结构开始说起,然后以某一个方法为突破口深入,比如:最大的不同是两 者底层的数据结构不同,ArrayList 底层是数组,LinkedList 底层是双向链表,两者的数据结构不 同也导致了操作的 API 实现有所差异,拿新增实现来说,ArrayList 会先计算并决定是否扩容, 然后把新增的数据直接赋值到数组上,而 LinkedList 仅仅只需要改变插入节点和其前后节点的指 向位置关系即可。
  1. ArrayList 和 LinkedList 应用场景有何不同
    答:ArrayList 更适合于快速的查找匹配,不适合频繁新增删除,像工作中经常会对元素进行匹 配查询的场景比较合适,LinkedList 更适合于经常新增和删除,对查询反而很少的场景。
  1. ArrayList 和 LinkedList 两者有没有最大容量
    答:ArrayList 有最大容量的,为 Integer 的最大值,大于这个值 JVM 是不会为数组分配内存空间 的,LinkedList 底层是双向链表,理论上可以无限大。但源码中,LinkedList 实际大小用的是 int  类型,这也说明了 LinkedList 不能超过 Integer 的最大值,不然会溢出。
  1. ArrayList 和 LinkedList 是如何对 null 值进行处理的
    答:ArrayList 允许 null 值新增,也允许 null 值删除。删除 null 值时,是从头开始,找到第一值 是 null 的元素删除;LinkedList 新增删除时对 null 值没有特殊校验,是允许新增和删除的。
  1. ArrayList 和 LinedList 是线程安全的么,为什么?
    答:当两者作为非共享变量时,比如说仅仅是在方法里面的局部变量时,是没有线程安全问题的, 只有当两者是共享变量时,才会有线程安全问题。主要的问题点在于多线程环境下,所有线程任 何时刻都可对数组和链表进行操作,这会导致值被覆盖,甚至混乱的情况。 如果有线程安全问题,在迭代的过程中,会频繁报 ConcurrentModificationException 的错误,意 思是在我当前循环的过程中,数组或链表的结构被其它线程修改了。
  1. 如何解决线程安全问题? Java 源码中推荐使用 Collections#synchronizedList 进行解决,Collections#synchronizedList 的返 回值是 List 的每个方法都加了 synchronized 锁,保证了在同一时刻,数组和链表只会被一个线 程所修改,或者采用 CopyOnWriteArrayList 并发 List 来解决,这个类我们后面会说

其它类型题目

  1. 你能描述下双向链表么?
    答:如果和面试官面对面沟通的话,你可以去画一下,可以把 《LinkedList 源码解析》中的 LinkedList 的结构画出来,如果是电话面试,可以这么描述:双向链表中双向的意思是说前后节 点之间互相有引用,链表的节点我们称为 Node。Node 有三个属性组成:其前一个节点,本身节 点的值,其下一个节点,假设 A、B 节点相邻,A 节点的下一个节点就是 B,B 节点的上一个节 点就是 A,两者互相引用,在链表的头部节点,我们称为头节点。头节点的前一个节点是 null, 尾部称为尾节点,尾节点的后一个节点是 null,如果链表数据为空的话,头尾节点是同一个节点, 本身是 null,指向前后节点的值也是 null。
  1. 描述下双向链表的新增和删除
    答:如果是面对面沟通,最好可以直接画图,如果是电话面试,可以这么描述: 新增:我们可以选择从链表头新增,也可以选择从链表尾新增,如果是从链表尾新增的话,直接 把当前节点追加到尾节点之后,本身节点自动变为尾节点。 删除:把删除节点的后一个节点的 prev 指向其前一个节点,把删除节点的前一个节点的 next 指 向其后一个节点,最后把删除的节点置为 null 即可

Map

引导语
Map 在面试中,占据了很大一部分的面试题目,其中以 HashMap 为主,这些面试题目有的可以
说得清楚,有的很难说清楚,如果是面对面面试的话,建议画一画。
Map 整体数据结构类问题

  1. 说一说 HashMap 底层数据结构
    答: HashMap 底层是数组 + 链表 + 红黑树的数据结构,数组的主要作用是方便快速查找,时间
    复杂度是 O(1),默认大小是 16,数组的下标索引是通过 key 的 hashcode 计算出来的,数组元素
    叫做 Node,当多个 key 的 hashcode 一致,但 key 值不同时,单个 Node 就会转化成链表,链表
    的查询复杂度是 O(n),当链表的长度大于等于 8 并且数组的大小超过 64 时,链表就会转化成红
    黑树,红黑树的查询复杂度是 O(log(n)),简单来说,最坏的查询次数相当于红黑树的最大深度。
  1. HashMap、 TreeMap、 LinkedHashMap 三者有啥相同点,有啥不同点?
    • 相同点:
      • 三者在特定的情况下,都会使用红黑树;
      • 底层的 hash 算法相同;
      • 在迭代的过程中,如果 Map 的数据结构被改动,都会报 ConcurrentModificationException
        的错误。
    • 不同点:
      • HashMap 数据结构以数组为主,查询非常快, TreeMap 数据结构以红黑树为主,利用了红
        黑树左小右大的特点,可以实现 key 的排序, LinkedHashMap 在 HashMap 的基础上增加了
        链表的结构,实现了插入顺序访问和最少访问删除两种策略;
      • 由于三种 Map 底层数据结构的差别,导致了三者的使用场景的不同,TreeMap 适合需要根
        据 key 进行排序的场景, LinkedHashMap 适合按照插入顺序访问,或需要删除最少访问元
        素的场景,剩余场景我们使用 HashMap 即可,我们工作中大部分场景基本都在使用HashMap;
      • 由于三种 map 的底层数据结构的不同,导致上层包装的 api 略有差别。
  1. 说一下 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 值比较分散。
    • 一般来说, hash 值算出来之后,要计算当前 key 在数组中的索引下标位置时,可以采用取模的
      方式,就是索引下标位置 = hash 值 % 数组大小,这样做的好处,就是可以保证计算出来的索引
      下标值可以均匀的分布在数组的各个索引位置上,但取模操作对于处理器的计算是比较慢的,数
      学上有个公式,当 b 是 2 的幂次方时, a % b = a &(b-1),所以此处索引位置的计算公式我们
      可以更换为: (n-1) & hash。
    • 此问题可以延伸出三个小问题:
      • 为什么不用 key % 数组大小,而是需要用 key 的 hash 值 % 数组大小。
        答:如果 key 是数字,直接用 key % 数组大小是完全没有问题的,但我们的 key 还有可能是字符
        串,是复杂对象,这时候用 字符串或复杂对象 % 数组大小是不行的,所以需要先计算出 key 的
        hash 值。
      • 计算 hash 值时,为什么需要右移 16 位?
        答: hash 算法是 h ^ (h >>> 16),为了使计算出的 hash 值更分散,所以选择先将 h 无符号右移
        16 位,然后再于 h 异或时,就能达到 h 的高 16 位和低 16 位都能参与计算,减少了碰撞的可能
        性。
      • 为什么把取模操作换成了 & 操作?
        答: key.hashCode() 算出来的 hash 值还不是数组的索引下标,为了随机的计算出索引的下表位
        置,我们还会用 hash 值和数组大小进行取模,这样子计算出来的索引下标比较均匀分布。
        取模操作处理器计算比较慢,处理器对 & 操作就比较擅长,换成了 & 操作,是有数学上证明的
        支撑,为了提高了处理器处理的速度。
      • 为什么提倡数组大小是 2 的幂次方?
        答:因为只有大小是 2 的幂次方时,才能使 hash 值 % n(数组大小) == (n-1) & hash 公式成立。
  1. 为解决 hash 冲突,大概有哪些办法。
    • 好的 hash 算法,细问的话复述一下上题的 hash 算法;
    • 自动扩容,当数组大小快满的时候,采取自动扩容,可以减少 hash 冲突;
    • hash 冲突发生时,采用链表来解决;
    • 冲突严重时,链表会自动转化成红黑树,提高遍历速度。
      网上列举的一些其它办法,如开放定址法,尽量不要说,因为这些方法资料很少,实战用过的人
      更少,如果你没有深入研究的话,面试官让你深入描述一下很难说清楚,反而留下不好的印象,
      说 HashMap 现有的措施就足够了。

HashMap 源码细节类问题

  1. HashMap 是如何扩容的?
    • 答:扩容的时机:
      • put 时,发现数组为空,进行初始化扩容,默认扩容大小为 16;
      • put 成功后,发现现有数组大小大于扩容的门阀值时,进行扩容,扩容为老数组大小的 2 倍;
        扩容的门阀是 threshold,每次扩容时 threshold 都会被重新计算,门阀值等于数组的大小 * 影响
        因子(0.75)。
      • 新数组初始化之后,需要将老数组的值拷贝到新数组上,链表和红黑树都有自己拷贝的方法。
  1. hash 冲突时怎么办?
    答: hash 冲突指的是 key 值的 hashcode 计算相同,但 key 值不同的情况。
    • 如果桶中元素原本只有一个或已经是链表了,新增元素直接追加到链表尾部;
    • 如果桶中元素已经是链表,并且链表个数大于等于 8 时,此时有两种情况:
      • 如果此时数组大小小于 64,数组再次扩容,链表不会转化成红黑树;
      • 如果数组大小大于 64 时,链表就会转化成红黑树。
        这里不仅仅判断链表个数大于等于 8, 还判断了数组大小,数组容量小于 64 没有立即转化的原
        因,猜测主要是因为红黑树占用的空间比链表大很多,转化也比较耗时,所以数组容量小的情况
        下冲突严重,我们可以先尝试扩容,看看能否通过扩容来解决冲突的问题。
  1. 为什么链表个数大于等于 8 时,链表要转化成红黑树了?
    • 答:当链表个数太多了,遍历可能比较耗时,转化成红黑树,可以使遍历的时间复杂度降低,但
      转化成红黑树,有空间和转化耗时的成本,我们通过泊松分布公式计算,正常情况下,链表个数
      出现 8 的概念不到千万分之一,所以说正常情况下,链表都不会转化成红黑树,这样设计的目的,
      是为了防止非正常情况下,比如 hash 算法出了问题时,导致链表个数轻易大于等于 8 时,仍然
      能够快速遍历。
    • 延伸问题:红黑树什么时候转变成链表。
      答:当节点的个数小于等于 6 时,红黑树会自动转化成链表,主要还是考虑红黑树的空间成本问
      题,当节点个数小于等于 6 时,遍历链表也很快,所以红黑树会重新变成链表。
  1. HashMap 在 put 时,如果数组中已经有了这个 key,我不想把 value 覆盖怎么办?取值时,如
    果得到的 value 是空时,想返回默认值怎么办?
    • 答:如果数组有了 key,但不想覆盖 value ,可以选择 putIfAbsent 方法,这个方法有个内置变量
      onlyIfAbsent,内置是 true ,就不会覆盖,我们平时使用的 put 方法,内置 onlyIfAbsent 为 false,
      是允许覆盖的。
    • 取值时,如果为空,想返回默认值,可以使用 getOrDefault 方法,方法第一参数为 key,第二个
      参数为你想返回的默认值,如 map.getOrDefault(“2”,“0”),当 map 中没有 key 为 2 的值时,会默
      认返回 0,而不是空。
  1. 通过以下代码进行删除,是否可行?
HashMap<String,String > map = Maps.newHashMap();
map.put("1","1");
map.put("2","2");
map.forEach((s, s2) -> map.remove("1"));

    • 答:不行,会报错误 ConcurrentModificationException,原因如下图:

    • 建议使用迭代器的方式进行删除,原理同 ArrayList 迭代器原理,我们在《List 源码会问那些面
      试题》中有说到。
  1. 描述一下 HashMap get、 put 的过程
    答:我们在源码解析中有说,可以详细描述下源码的实现路径,说不清楚的话,可以画一画。

其它 Map 面试题

  1. DTO 作为 Map 的 key 时,有无需要注意的点?
    • 答: DTO 就是一个数据载体,可以看做拥有很多属性的 Java 类,我们可以对这些属性进行 get、
      set 操作。
    • 看是什么类型的 Map,如果是 HashMap 的话,一定需要覆写 equals 和 hashCode 方法,因为在
      get 和 put 的时候,需要通过 equals 方法进行相等的判断;如果是 TreeMap 的话, DTO 需要实
      现 Comparable 接口,因为 TreeMap 会使用 Comparable 接口进行判断 key 的大小;如果是
      LinkedHashMap 的话,和 HashMap 一样的。
  1. LinkedHashMap 中的 LRU 是什么意思,是如何实现的。
    • 答: LRU ,英文全称: Least recently used,中文叫做最近最少访问,在 LinkedHashMap 中,也
      叫做最少访问删除策略,我们可以通过 removeEldestEntry 方法设定一定的策略,使最少被访问
      的元素,在适当的时机被删除,原理是在 put 方法执行的最后, LinkedHashMap 会去检查这种
      策略,如果满足策略,就删除头节点。
    • 保证头节点就是最少访问的元素的原理是: LinkedHashMap 在 get 的时候,都会把当前访问的节
      点,移动到链表的尾部,慢慢的,就会使头部的节点都是最少被访问的元素。
  1. 为什么推荐 TreeMap 的元素最好都实现 Comparable 接口?但 key 是 String 的时候,我们却没有额外的工作?
    答:因为 TreeMap 的底层就是通过排序来比较两个 key 的大小的,所以推荐 key 实现Comparable 接口,是为了往你希望的排序顺序上发展, 而 String 本身已经实现了 Comparable接口,所以使用 String 时,我们不需要额外的工作,不仅仅是 String ,其他包装类型也都实现了 Comparable 接口,如 Long、 Double、 Short 等。

Set

  1. TreeSet 有用过么,平时都在什么场景下使用?
    答:有木有用过如实回答就好了,我们一般都是在需要把元素进行排序的时候使用 TreeSet,使用时需要我们注意元素最好实现 Comparable 接口,这样方便底层的 TreeMap 根据 key 进行排序。
  1. 追问,如果我想实现根据 key 的新增顺序进行遍历怎么办?
    答:要按照 key 的新增顺序进行遍历,首先想到的应该就是 LinkedHashMap,而 LinkedHashSet正好是基于 LinkedHashMap 实现的,所以我们可以选择使用 LinkedHashSet。
  1. 追问,如果我想对 key 进行去重,有什么好的办法么?
    答:我们首先想到的是 TreeSet, TreeSet 底层使用的是 TreeMap, TreeMap 在 put 的时候,如果发现 key 是相同的,会把 value 值进行覆盖,所有不会产生重复的 key ,利用这一特性,使用TreeSet 正好可以去重。
  1. 说说 TreeSet 和 HashSet 两个 Set 的内部实现结构和原理?
    • 答: HashSet 底层对 HashMap 的能力进行封装,比如说 add 方法,是直接使用 HashMap 的 put
      方法,比较简单,但在初始化的时候,我看源码有一些感悟:说一下 HashSet 小结的四小点。
    • TreeSet 主要是对 TreeMap 底层能力进行封装复用,我发现了两种非常有意思的复用思路,重复
      TreeSet 两种复用思路。

JDK1.7&JDK1.8

  1. Java 8 在 List、 Map 接口上新增了很多方法,为什么 Java 7 中这些接口的实现者不需要强制实现呢?
    答:主要是因为这些新增的方法被 default 关键字修饰了, default 一旦修饰接口上的方法,我们需要在接口的方法中写默认实现,并且子类无需强制实现这些方法,所以 Java 7 接口的实现者无需感知。
  1. Java 8 中有新增很多实用的方法,你在平时工作中有使用过么?
    答:有的,比如说 getOrDefault、 putIfAbsent、 computeIfPresent 方法等等, 具体使用细节参考
  1. 说说 computeIfPresent 方法的使用姿势?
    答: computeIfPresent 是可以对 key 和 value 进行计算后,把计算的结果重新赋值给 key,并且如果 key 不存在时,不会报空指针,会返回 null 值。
  1. Java 8 集合新增了 forEach 方法,和普通的 for 循环有啥不同?
    答:新增的 forEach 方法的入参是函数式的接口,比如说 Consumer 和 BiConsumer,这样子做的好处就是封装了 for 循环的代码,让使用者只需关注实现每次循环的业务逻辑,简化了重复的for 循环代码,使代码更加简洁,普通的 for 循环,每次都需要写重复的 for 循环代码, forEach把这种重复的计算逻辑吃掉了,使用起来更加方便。
  1. HashMap 8 和 7 有啥区别?
    答: HashMap 8 和 7 的差别太大了,新增了红黑树,修改了底层数据逻辑,修改了 hash 算法,
    几乎所有底层数组变动的方法都重写了一遍,可以说 Java 8 的 HashMap 几乎重新了一遍。

第三章:并发集合类

并发List与Map

引导语
并发 List 和 Map 是技术面时常问的问题,问的问题也都比较深入,有很多问题都是面试官自创的,市面上找不到,所以说通过背题的方式,这一关大部分是过不了的,只有我们真正理解了API 内部的实现,阅读过源码,才能自如应对各种类型的面试题,接着我们来看一下并发 List、Map 源码相关的面试题集。

CopyOnWriteArrayList 相关

  1. 和 ArrayList 相比有哪些相同点和不同点?
    • 相同点:底层的数据结构是相同的,都是数组的数据结构,提供出来的 API 都是对数组结构进行操作,让我们更好的使用。
    • 不同点:后者是线程安全的,在多线程环境下使用,无需加锁,可直接使用。
  1. CopyOnWriteArrayList 通过哪些手段实现了线程安全?
    • 数组容器被 volatile 关键字修饰,保证了数组内存地址被任意线程修改后,都会通知到其他线程;
    • 对数组的所有修改操作,都进行了加锁,保证了同一时刻,只能有一个线程对数组进行修改,比如我在 add 时,就无法 remove;
    • 修改过程中对原数组进行了复制,是在新数组上进行修改的,修改过程中,不会对原数组产生任何影响。
  1. 在 add 方法中,对数组进行加锁后,不是已经是线程安全了么,为什么还需要对老数组进行拷贝?
    • 答:的确,对数组进行加锁后,能够保证同一时刻,只有一个线程能对数组进行 add,在同单核CPU 下的多线程环境下肯定没有问题,但我们现在的机器都是多核 CPU,如果我们不通过复制拷贝新建数组,修改原数组容器的内存地址的话,是无法触发 volatile 可见性效果的,那么其他CPU 下的线程就无法感知数组原来已经被修改了,就会引发多核 CPU 下的线程安全问题。假设我们不复制拷贝,而是在原来数组上直接修改值,数组的内存地址就不会变,而数组被volatile 修饰时,必须当数组的内存地址变更时,才能及时的通知到其他线程,内存地址不变,仅仅是数组元素值发生变化时,是无法把数组元素值发生变动的事实,通知到其它线程的。
  1. 对老数组进行拷贝,会有性能损耗,我们平时使用需要注意什么?
    答:主要有:
    • 在批量操作时,尽量使用 addAll、 removeAll 方法,而不要在循环里面使用 add、 remove 方法,主要是因为 for 循环里面使用 add 、 remove 的方式,在每次操作时,都会进行一次数组的拷贝(甚至多次),非常耗性能,而 addAll、 removeAll 方法底层做了优化,整个操作只会进行一次数组拷贝,由此可见,当批量操作的数据越多时,批量方法的高性能体现的越明显。
  1. 为什么 CopyOnWriteArrayList 迭代过程中,数组结构变动,不会抛出ConcurrentModificationException 了
    答:主要是因为 CopyOnWriteArrayList 每次操作时,都会产生新的数组,而迭代时,持有的仍然是老数组的引用,所以我们说的数组结构变动,是用新数组替换了老数组,老数组的结构并没有发生变化,所以不会抛出异常了。
  1. 插入的数据正好在 List 的中间,请问两种 List 分别拷贝数组几次?为什么?
    • ArrayList 只需拷贝一次,假设插入的位置是 2,只需要把位置 2 (包含 2)后面的数据都往后移动一位即可,所以拷贝一次。
    • CopyOnWriteArrayList 拷贝两次,因为 CopyOnWriteArrayList 多了把老数组的数据拷贝到新数组上这一步,可能有的同学会想到这种方式:先把老数组拷贝到新数组,再把 2 后面的数据往后移动一位,这的确是一种拷贝的方式,但 CopyOnWriteArrayList 底层实现更加灵活,而是:把老数组 0 到 2 的数据拷贝到新数组上,预留出新数组 2 的位置,再把老数组 3~ 最后的数据拷贝到新数组上,这种拷贝方式可以减少我们拷贝的数据,虽然是两次拷贝,但拷贝的数据却仍然是老数组的大小,设计的非常巧妙。

ConcurrentHashMap 相关

  1. ConcurrentHashMap 和 HashMap 的相同点和不同点
    • 相同点:
      • 都是数组 + 链表 +红黑树的数据结构,所以基本操作的思想相同;
      • 都实现了 Map 接口,继承了 AbstractMap 抽象类,所以两者的方法大多都是相似的,可以互相切换。
    • 不同点:
      • ConcurrentHashMap 是线程安全的,在多线程环境下,无需加锁,可直接使用;
      • 数据结构上, ConcurrentHashMap 多了转移节点,主要用于保证扩容时的线程安全。
  1. ConcurrentHashMap 通过哪些手段保证了线程安全。
    • 储存 Map 数据的数组被 volatile 关键字修饰,一旦被修改,立马就能通知其他线程,因为是
      数组,所以需要改变其内存值,才能真正的发挥出 volatile 的可见特性;
    • put 时,如果计算出来的数组下标索引没有值的话,采用无限 for 循环 + CAS 算法,来保证
      一定可以新增成功,又不会覆盖其他线程 put 进去的值;
    • 如果 put 的节点正好在扩容,会等待扩容完成之后,再进行 put ,保证了在扩容时,老数组
      的值不会发生变化;
    • 对数组的槽点进行操作时,会先锁住槽点,保证只有当前线程才能对槽点上的链表或红黑树
      进行操作;
    • 红黑树旋转时,会锁住根节点,保证旋转时的线程安全。
  1. 描述一下 CAS 算法在 ConcurrentHashMap 中的应用?
    • CAS 其实是一种乐观锁,一般有三个值,分别为:赋值对象,原值,新值,在执行的时候,
      会先判断内存中的值是否和原值相等,相等的话把新值赋值给对象,否则赋值失败,整个过程都
      是原子性操作,没有线程安全问题。
    • ConcurrentHashMap 的 put 方法中,有使用到 CAS ,是结合无限 for 循环一起使用的,步骤如下:
      • 计算出数组索引下标,拿出下标对应的原值;
      • CAS 覆盖当前下标的值,赋值时,如果发现内存值和 1 拿出来的原值相等,执行赋值,退出
        循环,否则不赋值,转到 3;
      • 进行下一次 for 循环,重复执行 1, 2,直到成功为止。可以看到这样做的好处,第一是不会盲目的覆盖原值,第二是一定可以赋值成功。
  1. ConcurrentHashMap 是如何发现当前槽点正在扩容的。
    答:ConcurrentHashMap 新增了一个节点类型,叫做转移节点,当我们发现当前槽点是转移节点时(转移节点的 hash 值是 -1),即表示 Map 正在进行扩容。
  1. 发现槽点正在扩容时, put 操作会怎么办?
    答:无限 for 循环,或者走到扩容方法中去,帮助扩容,一直等待扩容完成之后,再执行 put 操作。
  1. 两种 Map 扩容时,有啥区别?
    答:区别很大, HashMap 是直接在老数据上面进行扩容,多线程环境下,会有线程安全的问题,
    而 ConcurrentHashMap 就不太一样,扩容过程是这样的:
    • 从数组的队尾开始拷贝;
    • 拷贝数组的槽点时,先把原数组槽点锁住,拷贝成功到新数组时,把原数组槽点赋值为转移节点;
    • 从数组的尾部拷贝到头部,每拷贝成功一次,就把原数组的槽点设置成转移节点;
    • 直到所有数组数据都拷贝到新数组时,直接把新数组整个赋值给数组容器,拷贝完成。简单来说,通过扩容时给槽点加锁,和发现槽点正在扩容就等待的策略,保证了ConcurrentHashMap 可以慢慢一个一个槽点的转移,保证了扩容时的线程安全,转移节点比较重要,平时问的人也比较多。
    • ConcurrentHashMap 在 Java 7 和 8 中关于线程安全的做法有啥不同?
      答:非常不一样,拿 put 方法为例, Java 7 的做法是:
      • 把数组进行分段,找到当前 key 对应的是那一段;
      • 将当前段锁住,然后再根据 hash 寻找对应的值,进行赋值操作。
    • Java 7 的做法比较简单,缺点也很明显,就是当我们需要 put 数据时,我们会锁住改该数据对应
      的某一段,这一段数据可能会有很多,比如我只想 put 一个值,锁住的却是一段数据,导致这一
      段的其他数据都不能进行写入操作,大大的降低了并发性的效率。 Java 8 解决了这个问题,从锁
      住某一段,修改成锁住某一个槽点,提高了并发效率。
      不仅仅是 put,删除也是,仅仅是锁住当前槽点,缩小了锁的范围,增大了效率

第四章:队列

  1. 说说你对队列的理解,队列和集合的区别。
    • 答:对队列的理解:
      • 首先队列本身也是个容器,底层也会有不同的数据结构,比如 LinkedBlockingQueue 是底层 是链表结构,所以可以维 持先入先出的顺序,比如 DelayQueue 底层可以是队列或堆栈,所 以可以保证先入先出,或者先入后出的顺序等等,底层的数据结构不同,也造成了操作实现 不同;
      • 部分队列(比如 LinkedBlockingQueue )提供了暂时存储的功能,我们可以往队列里面放数 据,同时也可以从队列里面拿数据,两者可以同时进行;
      • 队列把生产数据的一方和消费数据的一方进行解耦,生产者只管生产,消费者只管消费,两 者之间没有必然联系,队列就像生产者和消费者之间的数据通道一样,如 LinkedBlockingQueue;
      • 队列还可以对消费者和生产者进行管理,比如队列满了,有生产者还在不停投递数据时,队 列可以使生产者阻塞住,让其不再能投递,比如队列空时,有消费者过来拿数据时,队列可 以让消费者 hodler 住,等有数据时,唤醒消费者,让消费者拿数据返回,如 ArrayBlockingQueue;
      • 队列还提供阻塞的功能,比如我们从队列拿数据,但队列中没有数据时,线程会一直阻塞到 队列有数据可拿时才返回。
    • 队列和集合的区别:
      • 和集合的相同点,队列(部分例外)和集合都提供了数据存储的功能,底层的储存数据结构 是有些相似的,比如说 LinkedBlockingQueue 和 LinkedHashMap 底层都使用的是链表, ArrayBlockingQueue 和 ArrayList 底层使用的都是数组。
      • 和集合的区别:
        • 部分队列和部分集合底层的存储结构很相似的,但两者为了完成不同的事情,提供的 API 和其底层的操作实现是不同的。
        • 队列提供了阻塞的功能,能对消费者和生产者进行简单的管理,队列空时,会阻塞消费 者,有其他线程进行 put 操作后,会唤醒阻塞的消费者,让消费者拿数据进行消费,队列满 时亦然。
        • 解耦了生产者和消费者,队列就像是生产者和消费者之间的管道一样,生产者只管往里 面丢,消费者只管不断消费,两者之间互不关心。
  1. 哪些队列具有阻塞的功能,大概是如何阻塞的?
    答:队列主要提供了两种阻塞功能,如下:

  • LinkedBlockingQueue 链表阻塞队列和 ArrayBlockingQueue 数组阻塞队列是一类,前者容量 是 Integer 的最大值,后者数组大小固定,两个阻塞队列都可以指定容量大小,当队列满时, 如果有线程 put 数据,线程会阻塞住,直到有其他线程进行消费数据后,才会唤醒阻塞线程 继续 put,当队列空时,如果有线程 take 数据,线程会阻塞到队列不空时,继续 take。
  • SynchronousQueue 同步队列,当线程 put 时,必须有对应线程把数据消费掉,put 线程才 能返回,当线程 take 时,需要有对应线程进行 put 数据时,take 才能返回,反之则阻塞, 举个例子,线程 A put 数据 A1 到队列中了,此时并没有任何的消费者,线程 A 就无法返回, 会阻塞住,直到有线程消费掉数据 A1 时,线程 A 才能返回。

  1. 底层是如何实现阻塞的?
    答:队列本身并没有实现阻塞的功能,而是利用 Condition 的等待唤醒机制,阻塞底层实现就是 更改线程的状态为沉睡,细节我们在锁小节会说到。
  1. LinkedBlockingQueue 和 ArrayBlockingQueue 有啥区别。
    相同点: 两者的阻塞机制大体相同,比如在队列满、空时,线程都会阻塞住。
    不同点:
    • LinkedBlockingQueue 底层是链表结构,容量默认是 Interge 的最大值, ArrayBlockingQueue 底层是数组,容量必须在初始化时指定。
    • 两者的底层结构不同,所以 take、put、remove 的底层实现也就不同。
  1. 往队列里面 put 数据是线程安全的么?为什么?
    答:是线程安全的,在 put 之前,队列会自动加锁,put 完成之后,锁会自动释放,保证了同一 时刻只会有一个线程能操作队列的数据,以 LinkedBlockingQueue 为例子,put 时,会加 put 锁, 并只对队尾 tail 进行操作,take 时,会加 take 锁,并只对队头 head 进行操作,remove 时,会 同时加 put 和 take 锁,所以各种操作都是线程安全的,我们工作中可以放心使用。
  1. take 的时候也会加锁么?既然 put 和 take 都会加锁,是不是同一时间只能运行其中一个方法。
    • 是的,take 时也会加锁的,像 LinkedBlockingQueue 在执行 take 方法时,在拿数据的同 时,会把当前数据删除掉,就改变了链表的数据结构,所以需要加锁来保证线程安全。
    • 这个需要看情况而言,对于 LinkedBlockingQueue 来说,队列的 put 和 take 都会加锁,但两 者的锁是不一样的,所以两者互不影响,可以同时进行的,对于 ArrayBlockingQueue 而言,put  和 take 是同一个锁,所以同一时刻只能运行一个方法。
  1. 工作中经常使用队列的 put、take 方法有什么危害,如何避免。
    答:当队列满时,使用 put 方法,会一直阻塞到队列不满为止。 当队列空时,使用 take 方法,会一直阻塞到队列有数据为止。 两个方法都是无限(永远、没有超时时间的意思)阻塞的方法,容易使得线程全部都阻塞住,大 流量时,导致机器无线程可用,所以建议在流量大时,使用 offer 和 poll 方法来代替两者,我们 只需要设置好超时阻塞时间,这两个方法如果在超时时间外,还没有得到数据的话,就会返回默 认值(LinkedBlockingQueue 为例),这样就不会导致流量大时,所有的线程都阻塞住了。 这个也是生产事故常常发生的原因之一,尝试用 put 和 take 方法,在平时自测中根本无法发现, 对源码不熟悉的同学也不会意识到会有问题,当线上大流量打进来时,很有可能会发生故障,所 以我们平时工作中使用队列时,需要谨慎再谨慎。
  1. 把数据放入队列中后,有木有办法让队列过一会儿再执行?
    答:可以的,DelayQueue 提供了这种机制,可以设置一段时间之后再执行,该队列有个唯一的 缺点,就是数据保存在内存中,在重启和断电的时候,数据容易丢失,所以定时的时间我们都不 会设置很久,一般都是几秒内,如果定时的时间需要设置很久的话,可以考虑采取延迟队列中间 件(这种中间件对数据会进行持久化,不怕断电的发生)进行实现。
  1. DelayQueue 对元素有什么要求么,我把 String 放到队列中去可以么?
    答:DelayQueue 要求元素必须实现 Delayed 接口,Delayed 本身又实现了 Comparable 接口, Delayed 接口的作用是定义还剩下多久就会超时,给使用者定制超时时间的,Comparable 接口 主要用于对元素之间的超时时间进行排序的,两者结合,就可以让越快过期的元素能够排在前面。 所以把 String 放到 DelayQueue 中是不行的,编译都无法通过,DelayQueue 类在定义的时候,是 有泛型定义的,泛型类型必须是 Delayed 接口的子类才行。
  1. DelayQueue 如何让快过期的元素先执行的?
    答:DelayQueue 中的元素都实现 Delayed 和 Comparable 接口的,其内部会使用 Comparable 的 compareTo 方法进行排序,我们可以利用这个功能,在 compareTo 方法中实现过期时间和当前 时间的差,这样越快过期的元素,计算出来的差值就会越小,就会越先被执行。
  1. 如何查看 SynchronousQueue 队列的大小?
    答:此题是个陷进题,题目首先设定了 SynchronousQueue 是可以查看大小的,实际上 SynchronousQueue 本身是没有容量的,所以也无法查看其容量的大小,其内部的 size 方法都是 写死的返回 0。
  1. SynchronousQueue 底层有几种数据结构,两者有何不同?
    答:底层有两种数据结构,分别是队列和堆栈。
    两者不同点:
    • 队列维护了先入先出的顺序,所以最先进去队列的元素会最先被消费,我们称为公平的,而 堆栈则是先入后出的顺序,最先进入堆栈中的数据可能会最后才会被消费,我们称为不公平 的。 2. 两者的数据结构不同,导致其 take 和 put 方法有所差别,具体的可以看 《 SynchronousQueue 源码解析 》章节。
  1. 假设 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 操作之间的沟通媒介。
  1. 如果想使用固定大小的队列,有几种队列可以选择,有何不同?
    答:可以使用 LinkedBlockingQueue 和 ArrayBlockingQueue 两种队列。 前者是链表,后者是数组,链表新增时,只要建立起新增数据和链尾数据之间的关联即可,数组 新增时,需要考虑到索引的位置(takeIndex 和 putIndex 分别记录着下次拿数据、放数据的索引 位置),如果增加到了数组最后一个位置,下次就要重头开始新增。
  1. ArrayBlockingQueue 可以动态扩容么?用到数组最后一个位置时怎么办?
    答:不可以的,虽然 ArrayBlockingQueue 底层是数组,但不能够动态扩容的。 假设 put 操作用到了数组的最后一个位置,那么下次 put 就需要从数组 0 的位置重新开始了。 假设 take 操作用到数组的最后一个位置,那么下次 take 的时候也会从数组 0 的位置重新开始。
  1. ArrayBlockingQueue take 和 put 都是怎么找到索引位置的?是利用 hash 算法计算得到的么?
    答:ArrayBlockingQueue 有两个属性,为 takeIndex 和 putIndex,分别标识下次 take 和 put 的 位置,每次 take 和 put 完成之后,都会往后加一,虽然底层是数组,但和 HashMap 不同,并不 是通过 hash 算法计算得到的。

第五章 线程

  1. 创建子线程时,子线程是得不到父线程的 ThreadLocal,有什么办法可以解决这个问题?
    答:这道题主要考察线程的属性和创建过程,可以这么回答。 可以使用 InheritableThreadLocal 来代替 ThreadLocal,ThreadLocal 和 InheritableThreadLocal  都是线程的属性,所以可以做到线程之间的数据隔离,在多线程环境下我们经常使用,但在有子 线程被创建的情况下,父线程 ThreadLocal 是无法传递给子线程的,但 InheritableThreadLocal  可以,主要是因为在线程创建的过程中,会把 InheritableThreadLocal 里面的所有值传递给子线程,具体代码如下(上面说过这个 在线程初始化的时候调用):
// 当父线程的 inheritableThreadLocals 的值不为空时
// 会把 inheritableThreadLocals 里面的值全部传递给子线程
if (parent.inheritableThreadLocals != null)
    this.inheritableThreadLocals =
    ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);

  1. 线程创建有几种实现方式?
    答:主要有三种,分成两大类,第一类是子线程没有返回值,第二类是子线程有返回值。 无返回值的线程有两种写法,第一种是继承 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. 子线程 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 执行完成之后,才会继续执行。

  1. 守护线程和非守护线程的区别?如果我想在项目启动的时候收集代码信息,请问是守护线程好,还是非守护线程好,为什么?
    • 两者的主要区别是,在 JVM 退出时,JVM 是不会管守护线程的,只会管非守护线程,如果 非守护线程还有在运行的,JVM 就不会退出,如果没有非守护线程了,但还有守护线程的,JVM  直接退出。
    • 如果需要在项目启动的时候收集代码信息,就需要看收集工作是否重要了,如果不太重要,又很 耗时,就应该选择守护线程,这样不会妨碍 JVM 的退出,如果收集工作非常重要的话,那么就 需要非守护进程,这样即使启动时发生未知异常,JVM 也会等到代码收集信息线程结束后才会退出,不会影响收集工作。
  1. 线程 start 和 run 之间的区别。
    答:调用 Thread.start 方法会开一个新的线程,run 方法不会。
  1. Thread、Runnable、Callable 三者之间的区别。
    答:Thread 实现了 Runnable,本身就是 Runnable,但同时负责线程创建、线程状态变更等操作。 Runnable 是无返回值任务接口,Callable 是有返回值任务接口,如果任务需要跑起来,必须需要 Thread 的支持才行,Runnable 和 Callable 只是任务的定义,具体执行还需要靠 Thread。
  1. 线程池 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 的代码和逻辑可以参考上一章,有非常详细的描述。
  1. Callable 能否丢给 Thread 去执行?
    答:可以的,可以新建 Callable,并作为 FutureTask 的构造器入参,然后把 FutureTask 丢给 Thread 去执行即可。
  1. FutureTask 有什么作用(谈谈对 FutureTask 的理解)。
    答:作用如下:
    • 组合了 Callable,实现了 Runnable,把 Callable 和 Runnnable 串联了起来
    • 统一了有参任务和无参任务两种定义方式,方便了使用。
    • 实现了 Future 的所有方法,对任务有一定的管理功能,比如说拿到任务执行结果,取消任 务,打断任务等等。
  1. 聊聊对 FutureTask 的 get、cancel 方法的理解
    get 方法主要作用是得到 Callable 异步任务执行的结果,无参 get 会一直等待任务执行完成 之后才返回,有参 get 方法可以设定固定的时间,在设定的时间内,如果任务还没有执行成功, 直接返回异常,在实际工作中,建议多多使用 get 有参方法,少用 get 无参方法,防止任务执行 过慢时,多数线程都在等待,造成线程耗尽的问题。
    cancel 方法主要用来取消任务,如果任务还没有执行,是可以取消的,如果任务已经在执行过程 中了,你可以选择不取消,或者直接打断执行中的任务。 两个方法具体的执行步骤和原理见上一章节源码解析。
  1. Thread.yield 方法在工作中有什么用?
    答:yield 方法表示当前线程放弃 cpu,重新参与到 cpu 的竞争中去,再次竞争时,自己有可能 得到 cpu 资源,也有可能得不到,这样做的好处是防止当前线程一直霸占 cpu。 我们在工作中可能会写一些 while 自旋的代码,如果我们一直 while 自旋,不采取任何手段,我 们会发现 cpu 一直被当前 while 循环占用,如果能预见 while 自旋时间很长,我们会设置一定的 判断条件,让当前线程陷入阻塞,如果能预见 while 自旋时间很短,我们通常会使用 Thread.yield 方法,使当前自旋线程让步,不一直霸占 cpu,比如这样:
boolean stop = false;
while (!stop){
    // dosomething
    Thread.yield();
}

  1. wait()和 sleep()的相同点和区别?
    相同点: 两者都让线程进入到 TIMED_WAITING 状态,并且可以设置等待的时间。
    不同点:
    • wait 是 Object 类的方法,sleep 是 Thread 类的方法。
    • sleep 不会释放锁,沉睡的时候,其它线程是无法获得锁的,但 wait 会释放锁。
  1. 写一个简单的死锁 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);
}

第六章 锁

第七章 线程池

  1. 说说你对线程池的理解?
    答:答题思路从大到小,从全面到局部,总的可以这么说,线程池结合了锁、线程、队列等元素,
    在请求量较大的环境下,可以多线程的处理请求,充分的利用了系统的资源,提高了处理请求的
    速度,细节可以从以下几个方面阐述:
    • ThreadPoolExecutor 类结构;
    • ThreadPoolExecutor coreSize、 maxSize 等重要属性;
    • Worker 的重要作用;
    • submit 的整个过程。
      通过以上总分的描述,应该可以说清楚对线程池的理解了,如果是面对面面试的话,可以边说边
      画出线程池的整体架构图(见《ThreadPoolExecutor 源码解析》)。
  1. ThreadPoolExecutor、 Executor、 ExecutorService、 Runnable、 Callable、 FutureTask 之间的关
    系?
    答:以上 6 个类可以分成两大类:一种是定义任务类,一种是执行任务类。
    • 定义任务类: Runnable、 Callable、 FutureTask。 Runnable 是定义无返回值的任务,
      Callable 是定义有返回值的任务, FutureTask 是对 Runnable 和 Callable 两种任务的统一,
      并增加了对任务的管理功能;
    • 执行任务类: ThreadPoolExecutor、 Executor、 ExecutorService。 Executor 定义最基本的运
      行接口, ExecutorService 是对其功能的补充, ThreadPoolExecutor 提供真正可运行的线程
      池类,三个类定义了任务的运行机制。日常的做法都是先根据定义任务类定义出任务来,然后丢给执行任务类去执行。
  1. 说一说队列在线程池中起的作用?
    答:作用如下:
    • 当请求数大于 coreSize 时,可以让任务在队列中排队,让线程池中的线程慢慢的消费请求,
      实际工作中,实际线程数不可能等于请求数,队列提供了一种机制让任务可排队,起一个缓
      冲区的作用;
    • 当线程消费完所有的线程后,会阻塞的从队列中拿数据,通过队列阻塞的功能,使线程不消
      亡, 一旦队列中有数据产生后,可立马被消费。
  1. 结合请求不断增加时,说一说线程池构造器参数的含义和表现?
    答:线程池构造器各个参数的含义如下:
    • 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 类拒绝请求。
  1. 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();
}

  1. 说一说对于线程空闲回收的理解,源码中如何体现的?
    答:空闲线程回收的时机:如果线程超过 keepAliveTime 时间后,还从阻塞队列中拿不到任务 (这种情况我们称为线程空闲),当前线程就会被回收,如果 allowCoreThreadTimeOut 设置成 true,core thread 也会被回收,直到还剩下一个线程为止,如果 allowCoreThreadTimeOut 设置 成 false,只会回收非 core thread 的线程。 线程在任务执行完成之后,之所有没有消亡,是因为阻塞的从队列中拿任务,在 keepAliveTime  时间后都没有拿到任务的话,就会打断阻塞,线程直接返回,线程的生命周期就结束了,JVM 会 回收掉该线程对象,所以我们说的线程回收源码体现就是让线程不在队列中阻塞,直接返回了, 可以见 ThreadPoolExecutor 源码解析章节第三小节的源码解析。
  1. 如果我想在线程池任务执行之前和之后,做一些资源清理的工作,可以么,如何做?
    答:可以的,ThreadPoolExecutor 提供了一些钩子函数,我们只需要继承 ThreadPoolExecutor  并实现这些钩子函数即可。在线程池任务执行之前实现 beforeExecute 方法,执行之后实现 afterExecute 方法。
  1. 线程池中的线程创建,拒绝请求可以自定义实现么?如何自定义?
    答:可以自定义的,线程创建默认使用的是 DefaultThreadFactory,自定义话的只需要实现 ThreadFactory 接口即可;拒绝请求也是可以自定义的,实现 RejectedExecutionHandler 接口即 可;在 ThreadPoolExecutor 初始化时,将两个自定义类作为构造器的入参传递给 ThreadPoolExecutor 即可。
  1. 说说你对 Worker 的理解?
    答:详见《ThreadPoolExecutor 源码解析》中 1.4 小节。
  1. 说一说 submit 方法执行的过程?
    答:详见《ThreadPoolExecutor 源码解析》中 2 小节。
  1. 说一说线程执行任务之后,都在干啥?
    答:线程执行任务完成之后,有两种结果:
    • 线程会阻塞从队列中拿任务,没有任务的话无限阻塞;
    • 线程会阻塞从队列中拿任务,没有任务的话阻塞一段时间后,线程返回,被 JVM 回收。
  1. keepAliveTime 设置成负数或者是 0,表示无限阻塞?
    答:这种是不对的,如果 keepAliveTime 设置成负数,在线程池初始化时,就会直接报 IllegalArgumentException 的异常,而设置成 0,队列如果是 LinkedBlockingQueue 的话,执行 workQueue.poll (keepAliveTime, TimeUnit.NANOSECONDS) 方法时,如果队列中没有任务,会直 接返回 null,导致线程立马返回,不会无限阻塞。 如果想无限阻塞的话,可以把 keepAliveTime 设置的很大,把 TimeUnit 也设置的很大,接近于 无限阻塞。
  1. 说一说 Future.get 方法是如何拿到线程的执行结果的?
    答:我们需要明确几点:
    • submit 方法的返回结果实际上是 FutureTask,我们平时都是针对接口编程,所以使用的是 Future.get 来拿到线程的执行结果,实际上是 FutureTask.get ,其方法底层是从 FutureTask  的 outcome 属性拿值的
    • 《ThreadPoolExecutor 源码解析》中 2 小节中详细说明了 submit 方法最终会把线程的执行 结果赋值给 outcome。 结合 1、2,当线程执行完成之后,自然就可以从 FutureTask 的 outcome 属性中拿到值。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值