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 源码】- CSDN.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 属性中拿到值。
java源码包实例源码JAVA开发源码50个合集: Ajax框架 ZK.rar Java图书馆管理系统源程序.rar Java图片倒影效果实例源码.rar Java图片翻折,将图像压扁.rar Java坦克大战网络对战版源代码.rar Java声音播放程序源代码.rar JAVA实现CLDC与MIDP底层编程的代码.rar Java实现HTTP连接与浏览,Java源码下载.rar Java实现的FTP连接与数据浏览程序.rar Java实现的放大镜效果附有源文件.rar Java实现的点对点短消息发送协议(smpp)开发包源码.rar Java实现的视频播放程序源码.rar Java实现移动的遮照效果.rar JAVA实现超级玛丽.zip Java实现跟踪鼠标运行坐标的源码.rar Java手机与计算机互发彩信源码.rar Java手机游戏大富翁源代码+注释.rar Java手机短信项目源码.rar Java扫雷源码.rar Java生成自定义控件源代码.rar Java调色板面板源代码.rar Java跳棋(基于SWT).rar Java通讯录手机版源码.rar Java鼠标拖拽功能.rar 乐趣大型购物系统.rar 可实现网上对战和人机对战.rar 基于BS结构的Java可视化工作流定制软件.rar 基于J2ME的Java游戏梦幻炸弹人源程序.rar 基于JAVA的ICQ系统.rar 基于Java的mp3播放器源代码.rar 基于Java的小型人事管理系统,带数据库.rar 基于JAVA的日程提醒簿.rar 基于Java的邮件服务器源程序.rar 基于MVC的Java资源管理器 v2.0.rar 基于smpp协议的Java点对点短信发送源码包.rar 季风进销存管理系统(JSP版).rar 客户管理系统 Alfresco Content Management.rar 家庭多媒体播放器.rar 局域网广播系统java源码.rar 开源Winzip压缩工具Java源码.rar 很不错的Java计算器.rar 很强的Java加密解密算法源码.rar 泡泡堂战车游戏JAVA源码.rar 简单模拟的J2ME潜艇大战源代码.rar 简单的注册与登录功能.rar 类似QQ的聊天软件JAVA源码(附设计文档).rar 进程通信.rar 连接postsql数据库的java代码.rar 附加数据库.rar 雷电游戏JAVA版源程序.rar
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值