2021黑马程序员Java面试宝典笔记(完整版)

哔哩哔哩链接:https://www.bilibili.com/video/BV15b4y117RJ?spm_id_from=333.337.search-card.all.click&vd_source=7c5f1f4c039688f19024d50ef51aaed1

一、基础篇

1. 二分查找

要求

  • 能够用自己语言描述二分查找算法
  • 能够手写二分查找代码
  • 能够解答一些变化后的考法

算法描述

  1. 前提:有已排序数组 A(假设已经做好)

  2. 定义左边界 L、右边界 R,确定搜索范围,循环执行二分查找(3、4两步)

  3. 获取中间索引 M = Floor((L+R) /2)

  4. 中间索引的值 A[M] 与待搜索的值 T 进行比较

    ① A[M] == T 表示找到,返回中间索引

    ② A[M] > T,中间值右侧的其它元素都大于 T,无需比较,中间索引左边去找,M - 1 设置为右边界,重新查找

    ③ A[M] < T,中间值左侧的其它元素都小于 T,无需比较,中间索引右边去找, M + 1 设置为左边界,重新查找

  5. 当 L > R 时,表示没有找到,应结束循环

更形象的描述请参考:binary_search.html

算法实现

public static int binarySearch(int[] a, int t) {int l = 0, r = a.length - 1, m;while (l <= r) {m = (l + r) / 2;if (a[m] == t) {return m;} else if (a[m] > t) {r = m - 1;} else {l = m + 1;}}return -1;
}

测试代码

public static void main(String[] args) {int[] array = {1, 5, 8, 11, 19, 22, 31, 35, 40, 45, 48, 49, 50};int target = 47;int idx = binarySearch(array, target);System.out.println(idx);
}

解决整数溢出问题

当 l 和 r 都较大时,l + r 有可能超过整数范围,造成运算错误,解决方法有两种:

int m = l + (r - l) / 2;

还有一种是:

int m = (l + r) >>> 1;

其它考法

  1. 有一个有序表为 1,5,8,11,19,22,31,35,40,45,48,49,50 当二分查找值为 48 的结点时,查找成功需要比较的次数

  2. 使用二分法在序列 1,4,6,7,15,33,39,50,64,78,75,81,89,96 中查找元素 81 时,需要经过( )次比较

  3. 在拥有128个元素的数组中二分查找一个数,需要比较的次数最多不超过多少次

对于前两个题目,记得一个简要判断口诀:奇数二分取中间,偶数二分取中间靠左。对于后一道题目,需要知道公式:

n=log2N=log10N/log102n = log_2N = log_{10}N/log_{10}2n=log2N=log10N/log102

其中 n 为查找次数,N 为元素个数

2. 冒泡排序

要求

  • 能够用自己语言描述冒泡排序算法
  • 能够手写冒泡排序代码
  • 了解一些冒泡排序的优化手段

算法描述

  1. 依次比较数组中相邻两个元素大小,若 a[j] > a[j+1],则交换两个元素,两两都比较一遍称为一轮冒泡,结果是让最大的元素排至最后
  2. 重复以上步骤,直到整个数组有序

更形象的描述请参考:bubble_sort.html

算法实现

public static void bubble(int[] a) {for (int j = 0; j < a.length - 1; j++) {// 一轮冒泡boolean swapped = false; // 是否发生了交换for (int i = 0; i < a.length - 1 - j; i++) {System.out.println("比较次数" + i);if (a[i] > a[i + 1]) {Utils.swap(a, i, i + 1);swapped = true;}}System.out.println("第" + j + "轮冒泡"+ Arrays.toString(a));if (!swapped) {break;}}
}
  • 优化点1:每经过一轮冒泡,内层循环就可以减少一次
  • 优化点2:如果某一轮冒泡没有发生交换,则表示所有数据有序,可以结束外层循环

进一步优化

public static void bubble_v2(int[] a) {int n = a.length - 1;while (true) {int last = 0; // 表示最后一次交换索引位置for (int i = 0; i < n; i++) {System.out.println("比较次数" + i);if (a[i] > a[i + 1]) {Utils.swap(a, i, i + 1);last = i;}}n = last;System.out.println("第轮冒泡"+ Arrays.toString(a));if (n == 0) {break;}}
}
  • 每轮冒泡时,最后一次交换索引可以作为下一轮冒泡的比较次数,如果这个值为零,表示整个数组有序,直接退出外层循环即可

3. 选择排序

要求

  • 能够用自己语言描述选择排序算法
  • 能够比较选择排序与冒泡排序
  • 理解非稳定排序与稳定排序

算法描述

  1. 将数组分为两个子集,排序的和未排序的,每一轮从未排序的子集中选出最小的元素,放入排序子集

  2. 重复以上步骤,直到整个数组有序

更形象的描述请参考:selection_sort.html

算法实现

public static void selection(int[] a) {for (int i = 0; i < a.length - 1; i++) {// i 代表每轮选择最小元素要交换到的目标索引int s = i; // 代表最小元素的索引for (int j = s + 1; j < a.length; j++) {if (a[s] > a[j]) { // j 元素比 s 元素还要小, 更新 ss = j;}}if (s != i) {swap(a, s, i);}System.out.println(Arrays.toString(a));}
}
  • 优化点:为减少交换次数,每一轮可以先找最小的索引,在每轮最后再交换元素

与冒泡排序比较

  1. 二者平均时间复杂度都是 O(n2)O(n^2)O(n2)

  2. 选择排序一般要快于冒泡,因为其交换次数少

  3. 但如果集合有序度高,冒泡优于选择

  4. 冒泡属于稳定排序算法,而选择属于不稳定排序

    • 稳定排序指,按对象中不同字段进行多次排序,不会打乱同值元素的顺序
    • 不稳定排序则反之

稳定排序与不稳定排序

System.out.println("=================不稳定================");
Card[] cards = getStaticCards();
System.out.println(Arrays.toString(cards));
selection(cards, Comparator.comparingInt((Card a) -> a.sharpOrder).reversed());
System.out.println(Arrays.toString(cards));
selection(cards, Comparator.comparingInt((Card a) -> a.numberOrder).reversed());
System.out.println(Arrays.toString(cards));System.out.println("=================稳定=================");
cards = getStaticCards();
System.out.println(Arrays.toString(cards));
bubble(cards, Comparator.comparingInt((Card a) -> a.sharpOrder).reversed());
System.out.println(Arrays.toString(cards));
bubble(cards, Comparator.comparingInt((Card a) -> a.numberOrder).reversed());
System.out.println(Arrays.toString(cards));

都是先按照花色排序(♠♥♣♦),再按照数字排序(AKQJ…)

  • 不稳定排序算法按数字排序时,会打乱原本同值的花色顺序

    [[♠7], [♠2], [♠4], [♠5], [♥2], [♥5]]
    [[♠7], [♠5], [♥5], [♠4], [♥2], [♠2]]
    

    原来 ♠2 在前 ♥2 在后,按数字再排后,他俩的位置变了

  • 稳定排序算法按数字排序时,会保留原本同值的花色顺序,如下所示 ♠2 与 ♥2 的相对位置不变

    [[♠7], [♠2], [♠4], [♠5], [♥2], [♥5]]
    [[♠7], [♠5], [♥5], [♠4], [♠2], [♥2]]
    

4. 插入排序

要求

  • 能够用自己语言描述插入排序算法
  • 能够比较插入排序与选择排序

算法描述

  1. 将数组分为两个区域,排序区域和未排序区域,每一轮从未排序区域中取出第一个元素,插入到排序区域(需保证顺序)

  2. 重复以上步骤,直到整个数组有序

更形象的描述请参考:insertion_sort.html

算法实现

// 修改了代码与希尔排序一致
public static void insert(int[] a) {// i 代表待插入元素的索引for (int i = 1; i < a.length; i++) {int t = a[i]; // 代表待插入的元素值int j = i;System.out.println(j);while (j >= 1) {if (t < a[j - 1]) { // j-1 是上一个元素索引,如果 > t,后移a[j] = a[j - 1];j--;} else { // 如果 j-1 已经 <= t, 则 j 就是插入位置break;}}a[j] = t;System.out.println(Arrays.toString(a) + " " + j);}
}

与选择排序比较

  1. 二者平均时间复杂度都是 O(n2)O(n^2)O(n2)

  2. 大部分情况下,插入都略优于选择

  3. 有序集合插入的时间复杂度为 O(n)O(n)O(n)

  4. 插入属于稳定排序算法,而选择属于不稳定排序

提示

插入排序通常被同学们所轻视,其实它的地位非常重要。小数据量排序,都会优先选择插入排序

5. 希尔排序

要求

  • 能够用自己语言描述希尔排序算法

算法描述

  1. 首先选取一个间隙序列,如 (n/2,n/4 … 1),n 为数组长度

  2. 每一轮将间隙相等的元素视为一组,对组内元素进行插入排序,目的有二

    ① 少量元素插入排序速度很快

    ② 让组内值较大的元素更快地移动到后方

  3. 当间隙逐渐减少,直至为 1 时,即可完成排序

更形象的描述请参考:shell_sort.html

算法实现

private static void shell(int[] a) {int n = a.length;for (int gap = n / 2; gap > 0; gap /= 2) {// i 代表待插入元素的索引for (int i = gap; i < n; i++) {int t = a[i]; // 代表待插入的元素值int j = i;while (j >= gap) {// 每次与上一个间隙为 gap 的元素进行插入排序if (t < a[j - gap]) { // j-gap 是上一个元素索引,如果 > t,后移a[j] = a[j - gap];j -= gap;} else { // 如果 j-1 已经 <= t, 则 j 就是插入位置break;}}a[j] = t;System.out.println(Arrays.toString(a) + " gap:" + gap);}}
}

参考资料

  • https://en.wikipedia.org/wiki/Shellsort

6. 快速排序

要求

  • 能够用自己语言描述快速排序算法
  • 掌握手写单边循环、双边循环代码之一
  • 能够说明快排特点
  • 了解洛穆托与霍尔两种分区方案的性能比较

算法描述

  1. 每一轮排序选择一个基准点(pivot)进行分区

    1. 让小于基准点的元素的进入一个分区,大于基准点的元素的进入另一个分区
    2. 当分区完成时,基准点元素的位置就是其最终位置
  2. 在子分区内重复以上过程,直至子分区元素个数少于等于 1,这体现的是分而治之的思想 (divide-and-conquer)
  3. 从以上描述可以看出,一个关键在于分区算法,常见的有洛穆托分区方案、双边循环分区方案、霍尔分区方案

更形象的描述请参考:quick_sort.html

单边循环快排(lomuto 洛穆托分区方案)

  1. 选择最右元素作为基准点元素

  2. j 指针负责找到比基准点小的元素,一旦找到则与 i 进行交换

  3. i 指针维护小于基准点元素的边界,也是每次交换的目标索引

  4. 最后基准点与 i 交换,i 即为分区位置

public static void quick(int[] a, int l, int h) {if (l >= h) {return;}int p = partition(a, l, h); // p 索引值quick(a, l, p - 1); // 左边分区的范围确定quick(a, p + 1, h); // 左边分区的范围确定
}private static int partition(int[] a, int l, int h) {int pv = a[h]; // 基准点元素int i = l;for (int j = l; j < h; j++) {if (a[j] < pv) {if (i != j) {swap(a, i, j);}i++;}}if (i != h) {swap(a, h, i);}System.out.println(Arrays.toString(a) + " i=" + i);// 返回值代表了基准点元素所在的正确索引,用它确定下一轮分区的边界return i;
}

双边循环快排(不完全等价于 hoare 霍尔分区方案)

  1. 选择最左元素作为基准点元素
  2. j 指针负责从右向左找比基准点小的元素,i 指针负责从左向右找比基准点大的元素,一旦找到二者交换,直至 i,j 相交
  3. 最后基准点与 i(此时 i 与 j 相等)交换,i 即为分区位置

要点

  1. 基准点在左边,并且要先 j 后 i

  2. while( i < j && a[j] > pv ) j–

  3. while ( i < j && a[i] <= pv ) i++

private static void quick(int[] a, int l, int h) {if (l >= h) {return;}int p = partition(a, l, h);quick(a, l, p - 1);quick(a, p + 1, h);
}private static int partition(int[] a, int l, int h) {int pv = a[l];int i = l;int j = h;while (i < j) {// j 从右找小的while (i < j && a[j] > pv) {j--;}// i 从左找大的while (i < j && a[i] <= pv) {i++;}swap(a, i, j);}swap(a, l, j);System.out.println(Arrays.toString(a) + " j=" + j);return j;
}

快排特点

  1. 平均时间复杂度是 O(nlog2⁡n)O(nlog_2⁡n )O(nlog2n),最坏时间复杂度 O(n2)O(n^2)O(n2)

  2. 数据量较大时,优势非常明显

  3. 属于不稳定排序

洛穆托分区方案 vs 霍尔分区方案

  • 霍尔的移动次数平均来讲比洛穆托少3倍
  • https://qastack.cn/cs/11458/quicksort-partitioning-hoare-vs-lomuto

补充代码说明

  • day01.sort.QuickSort3 演示了空穴法改进的双边快排,比较次数更少
  • day01.sort.QuickSortHoare 演示了霍尔分区的实现
  • day01.sort.LomutoVsHoare 对四种分区实现的移动次数比较

7.ArrayList、LinkedList、Vector

1.ArrayList的扩容机制

ArrayList在jdk1.7:直接创建一个初始容量为10的数组

ArrayList在jdk1.8:一开始创建一个初始容量为0的数组,当添加第一个元素时再创建一个容量为10的数组,之后每次扩容为之前的1.5倍(笼统的说法)。其实它并不是乘以1.5倍,而是使用位运算来实现的。如第二次扩容时为15,那么第三次扩容为多少呢?首先15>>1=7,之后15+7=22,所以第三次扩容为22,第四次扩容为33,以此类推。但是需要注意的是,这是相对于add方法而言的,而addAll方法却有些不一样。

如当我们使用addAll方法一次性添加超过10条元素时会怎么样呢?他会在要扩容的长度和实际添加的元素数量之间选择最大的数作为扩容的长度

代码如下:

// 通过反射获取ArrayList的长度
public static int length(ArrayList<Integer> list) {try {Field field = ArrayList.class.getDeclaredField("elementData");field.setAccessible(true);return ((Object[]) field.get(list)).length;} catch (Exception e) {e.printStackTrace();return 0;}
}@Test
public void test5(){ArrayList arrayList = new ArrayList();// 调用ArrayList的空参构造,创建的长度为0(jdk1.8)System.out.println(length(arrayList));// 0arrayList.add(1);// 当使用add添加一个元素后,第一次扩容为10System.out.println(length(arrayList));// 10ArrayList arrayList2 = new ArrayList();arrayList2.addAll(Arrays.asList(1,2,3,4,5,6,7,8,9,10,11));// 当使用addAll添加多个元素后,第一次扩容为多少?// 发现扩容的长度为11,这是因为它会在要扩容的长度和实际添加的元素数量之间选择最大的数作为扩容的长度System.out.println(length(arrayList2));// 11arrayList2.addAll(Arrays.asList(1,2));System.out.println(length(arrayList2));// 16
}

扩容规则

  1. ArrayList() 会使用长度为零的数组(jdk1.8)

  2. ArrayList(int initialCapacity) 会使用指定容量的数组

  3. public ArrayList(Collection<? extends E> c) 会使用 c 的大小作为数组容量

  4. add(Object o) 首次扩容为 10,再次扩容为上次容量的 1.5 倍(笼统的说法)

  5. addAll(Collection c) 没有元素时,扩容为 Math.max(10, 实际元素个数),有元素时为 Math.max(原容量 1.5 倍, 实际元素个数)

2.Iterator的fail-fast和fail-safe特性

  • Iterator用于遍历集合,那么,我们在遍历的时候是否运行其他人来修改我们的集合?这就牵扯到了iterator的fail-fastfail-safe这两种特性
  • ArrayList、vector 是 fail-fast 的典型代表,遍历的同时不能修改,尽快失败
  • CopyOnWriteArrayList 是 fail-safe 的典型代表,遍历的同时可以修改,原理是读写分离,但是他会牺牲一致性,即并不会遍历新增的元素

首先我们先看ArrayList,

static class Student {String name;public Student(String name) {this.name = name;}@Overridepublic String toString() {return "Student{" +"name='" + name + '\'' +'}';}
}@Test
public void test6(){ArrayList<Student> list = new ArrayList<>();list.add(new Student("A"));list.add(new Student("B"));list.add(new Student("C"));list.add(new Student("D"));for (Student student : list) {System.out.println(student);}System.out.println(list);
}

如果遍历的时候有其他线程修改了其他代码,会怎么样呢?会报并发修改异常

debug启动,之后我们模拟其他线程修改了list集合,

之后新增一个学生对象E

之后我们继续往下执行,发现报并发修改异常错误。注意:在遍历C的时候并不会报错,会在下一次遍历D的时候报错

我们继续编写代码,测试CopyOnWriteArrayList

@Test
public void test7(){CopyOnWriteArrayList<Student> list = new CopyOnWriteArrayList<>();list.add(new Student("A"));list.add(new Student("B"));list.add(new Student("C"));list.add(new Student("D"));for (Student student : list) {System.out.println(student);}System.out.println(list);
}

重复之前的步骤,发现它并不会报错,但是我们新增的元素却并没有遍历出来

ArrayList源码查看

for (Student student : list) {}为foreach循环(增强for循环),它底层也会用到我们的迭代器iterator对象,在首次循环的时候会创建一个迭代器对象。我们打断点查看运行机制,

之后调用迭代器的构造,初始化迭代器的成员变量,modCount用于记录list集合被修改了多少次,且每次调next方法都会有一个checkForComodification方法用于检查是否有其他线程更改了list集合

我们查看checkForComodification方法,modCount用于记录list集合被修改了多少次,而expectedModCount为之前记录的该list集合原先被修改的次数(即数量),如果他们不等,则会抛出并发修改异常

CopyOnWriteArrayList 源码查看

首先new了一个COWIterator迭代器

之后会把当前传入的list集合保存在snapshot数组中,然后进行遍历这个数组,所以它并不会输出我们之后对list进行新增后的数据

我们查看CopyOnWriteArrayListadd方法,发现先通过getArray方法拿到原来旧的数组,之后将此数组复制一份并使用数组长度加1,之后将新增的元素插入到复制出来的新数组的末尾,即每次进行添加元素的操作都会复制一份新的数组。

3.ArrayList、LinkedList、Vector的区别

l ArrayList和LinkedList的异同

  • 二者都线程不安全,相对线程安全的Vector,ArrayList和LinkedList执行效率更高。
  • ArrayList是实现了基于(动态)数组的数据结构,需要连续的内存。LinkedList基于(双向)链表的数据结构,无需连续内存。
  • ArrayList随机访问快(指根据下标访问),LinkedList随机访问慢(要沿着链表遍历)。
  • ArrayList尾部插入、删除性能高,因为ArrayList直接定位到尾部就可以插入。其它部分插入、删除都会移动数据,因此性能会低。LinkedList头部插入、删除性能比ArrayList高,对于尾部插入、删除ArrayList稍占优势,但也相差不大。所以,LinkedList头尾插入、删除性能都高。但是对于其他部分(特别是中间部分)的插入、删除性能低,因为要沿着链表遍历。

  • ArrayList可以利用 cpu 缓存,局部性原理。LinkedList占用内存多

  • 具体可以查看黑马程序员相关视频,点击前往

l ArrayList和Vector的区别

  • Vector和ArrayList几乎是完全相同的,唯一的区别在于Vector是同步类(synchronized),属于强同步类。因此开销就比ArrayList要大,访问要慢。

  • 正常情况下,大多数的Java程序员使用ArrayList而不是Vector,因为同步完全可以由程序员自己来控制。

  • Vector每次扩容请求其大小的2倍空间,而ArrayList是1.5倍(笼统的说法)。Vector还有一个子类Stack。

  • Vector调用无参构造的初始化容量为10,ArrayList调用无参构造的初始化容量为0(jdk1.8,jdk1.7为10)。

  • 由于LinkedList的底层是用双向链表实现的,没有初始化大小,也没有扩容的机制,就是一直在前面或者后面新增就好。

为什么ArrayList可以通过索引(下标)进行访问呢?

我们查看ArrayList源码,发现实现了RandomAccess(随机访问)接口,如果实现了RandomAccess这个接口,就会在寻找元素的时候通过下标去找,这样速度会更快。而LinkedList却并没有实现RandomAccess接口,所以只能通过迭代器使用遍历的方式去寻找元素。

8.HashMap

HashMap 初始化大小是 16 ,扩容因子默认0.75(可以指定初始化大小,和扩容因子)
扩容机制.(当前大小 和 当前容量 的比例超过了 扩容因子,就会扩容,扩容后大小为 一倍。例如:初始大小为 16 ,扩容因子 0.75 ,当容量为12的时候,比例已经是0.75 。触发扩容,扩容后的大小为 32.)

hsahtable初始容量为11,扩容因子默认0.75,hashcode的计算不需要二次hash,因为容量不是2的n次幂,其容量为质数,有比较好的hash计算性,不需要二次hash。之后就与数组长度求余计算索引下标了

面试题:负载因子值的大小,对HashMap有什么影响

l 负载因子的大小决定了HashMap的数据密度。

l 负载因子越大密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时的比较次数增多,性能会下降。

l 负载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内容空间。而且经常扩容也会影响性能,建议初始化预设大一点的空间。

l 按照其他语言的参考及研究经验,会考虑将负载因子设置为0.7~0.75,此时平均检索长度接近于常数。

1)基本数据结构

  • 1.7 数组 + 链表
  • 1.8 数组 + (链表 | 红黑树)

2)树化与退化

树化意义

  • 红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略
  • hash 表的查找,更新的时间复杂度是 O(1)O(1)O(1),而红黑树的查找,更新的时间复杂度是 O(log2⁡n)O(log_2⁡n )O(log2n),TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表
  • hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小

树化规则

  • 当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化

退化规则

  • 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
  • 情况2:remove 树节点时,若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表

3)索引计算

索引计算方法

  • 首先,计算对象的 hashCode()
  • 再进行调用 HashMap 的 hash() 方法进行二次哈希
    • 二次 hash() 是为了综合高位数据,让哈希分布更为均匀
  • 最后 & (capacity – 1) 得到索引

数组容量为何是 2 的 n 次幂

  1. 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
  2. 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap

注意

  • 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
  • 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable

4)put 与扩容

put 流程

  1. HashMap 是懒惰创建数组的,首次使用才创建数组
  2. 计算索引(桶下标)
  3. 如果桶下标还没人占用,创建 Node 占位返回
  4. 如果桶下标已经有人占用
    1. 已经是 TreeNode 走红黑树的添加或更新逻辑
    2. 是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
  5. 返回前检查容量是否超过阈值,一旦超过进行扩容

1.7 与 1.8 的区别

  1. 链表插入节点时,1.7 是头插法,1.8 是尾插法

  2. 1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容

  3. 1.8 在扩容计算 Node 索引时,会优化

扩容(加载)因子为何默认是 0.75f

  1. 在空间占用与查询时间之间取得较好的权衡
  2. 大于这个值,空间节省了,但链表就会比较长影响性能
  3. 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多

5)并发问题

扩容死链(1.7 会存在)

1.7 源码如下:

void transfer(Entry[] newTable, boolean rehash) {int newCapacity = newTable.length;for (Entry<K,V> e : table) {while(null != e) {Entry<K,V> next = e.next;if (rehash) {e.hash = null == e.key ? 0 : hash(e.key);}int i = indexFor(e.hash, newCapacity);e.next = newTable[i];newTable[i] = e;e = next;}}
}
  • e 和 next 都是局部变量,用来指向当前节点和下一个节点
  • 线程1(绿色)的临时变量 e 和 next 刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程2(蓝色)完成扩容和迁移

  • 线程2 扩容完成,由于头插法,链表顺序颠倒。但线程1 的临时变量 e 和 next 还引用了这俩节点,还要再来一遍迁移

  • 第一次循环

    • 循环接着线程切换前运行,注意此时 e 指向的是节点 a,next 指向的是节点 b
    • e 头插 a 节点,注意图中画了两份 a 节点,但事实上只有一个(为了不让箭头特别乱画了两份)
    • 当循环结束是 e 会指向 next 也就是 b 节点

  • 第二次循环

    • next 指向了节点 a
    • e 头插节点 b
    • 当循环结束时,e 指向 next 也就是节点 a

  • 第三次循环

    • next 指向了 null
    • e 头插节点 a,a 的 next 指向了 b(之前 a.next 一直是 null),b 的 next 指向 a,死链已成
    • 当循环结束时,e 指向 next 也就是 null,因此第四次循环时会正常退出

数据错乱(1.7,1.8 都会存在)

  • 代码参考 day01.map.HashMapMissData,具体调试步骤参考视频

补充代码说明

  • day01.map.HashMapDistribution 演示 map 中链表长度符合泊松分布
  • day01.map.DistributionAffectedByCapacity 演示容量及 hashCode 取值对分布的影响
    • day01.map.DistributionAffectedByCapacity#hashtableGrowRule 演示了 Hashtable 的扩容规律
    • day01.sort.Utils#randomArray 如果 hashCode 足够随机,容量是否是 2 的 n 次幂影响不大
    • day01.sort.Utils#lowSameArray 如果 hashCode 低位一样的多,容量是 2 的 n 次幂会导致分布不均匀
    • day01.sort.Utils#evenArray 如果 hashCode 偶数的多,容量是 2 的 n 次幂会导致分布不均匀
    • 由此得出对于容量是 2 的 n 次幂的设计来讲,二次 hash 非常重要
  • day01.map.HashMapVsHashtable 演示了对于同样数量的单词字符串放入 HashMap 和 Hashtable 分布上的区别

6)key 的设计

key 的设计要求

  1. HashMap 的 key 可以为 null,但 Map 的其他实现则不然
  2. 作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变)
  3. key 的 hashCode 应该有良好的散列性

如果 key 可变,例如修改了 age 会导致再次查询时查询不到

public class HashMapMutableKey {public static void main(String[] args) {HashMap<Student, Object> map = new HashMap<>();Student stu = new Student("张三", 18);map.put(stu, new Object());System.out.println(map.get(stu));stu.age = 19;System.out.println(map.get(stu));}static class Student {String name;int age;public Student(String name, int age) {this.name = name;this.age = age;}public String getName() {return name;}public void setName(String name) {this.name = name;}public int getAge() {return age;}public void setAge(int age) {this.age = age;}@Overridepublic boolean equals(Object o) {if (this == o) return true;if (o == null || getClass() != o.getClass()) return false;Student student = (Student) o;return age == student.age && Objects.equals(name, student.name);}@Overridepublic int hashCode() {return Objects.hash(name, age);}}
}

String 对象的 hashCode() 设计

  • 目标是达到较为均匀的散列效果,每个字符串的 hashCode 足够独特
  • 字符串中的每个字符都可以表现为一个数字,称为 SiS_iSi,其中 i 的范围是 0 ~ n - 1
  • 散列公式为: S0∗31(n−1)+S1∗31(n−2)+…Si∗31(n−1−i)+…S(n−1)∗310S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0S031(n1)+S131(n2)+Si31(n1i)+S(n1)310
  • 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
    • 即 $32 ∗h -h $
    • 25∗h−h2^5 ∗h -h25hh
    • h≪5−hh≪5 -hh5h

9.单例模式与Volatile的学习

哔哩哔哩链接:https://www.bilibili.com/video/BV15b4y117RJ?p=56

要求

  • 掌握五种单例模式的实现方式
  • 理解为何 DCL(双检锁) 实现时要使用 volatile 修饰静态变量
  • 了解 jdk 中用到单例的场景

一、五种实现方式

1.饿汉式

类一初始化就会被创建

实现要求:

1.构造私有,所有单例的实现都要求,因为如果不是私有,则其他类有机会调用构造方法来创建实例对象,这就会导致多个实例的发生

2.提供一个静态的成员变量,成员变量类型就是单例类型,值就是用私有构造创建的唯一实例

3.静态变量一般是私有的,一般会提供一个公共的静态方法,用于返回静态成员变量

package com.singleton.test;import java.io.Serializable;// 1. 饿汉式
public class Singleton1 implements Serializable {// 要求一:构造私有private Singleton1() { System.out.println("private Singleton1()"); }// 要求二:提供一个静态的成员变量private static final Singleton1 INSTANCE = new Singleton1();// 要求三:静态变量一般是私有的,一般会提供一个公共的静态方法,用于返回静态成员变量public static Singleton1 getInstance() {return INSTANCE;}// 提供这个静态方法的意图:其他类调用这个方法时就会触发Singleton1类的加载初始化,就会导致Singleton1这个单例对象被创建,便于测试public static void otherMethod() {System.out.println("otherMethod()");}
}

编写测试代码,

package com.singleton.test;public class Test {@org.junit.Testpublic void test1(){// 调用这个静态方法的意图:触发Singleton1类的加载初始化,就会导致Singleton1这个单例对象被创建Singleton1.otherMethod();System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");System.out.println(Singleton1.getInstance());System.out.println(Singleton1.getInstance());}
}

发现使用的是同一个对象,

反射破坏单例

我们使用反射破坏单例模式,

private static void reflection(Class<?> clazz) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException {Constructor<?> constructor = clazz.getDeclaredConstructor(); // 得到无参构造方法constructor.setAccessible(true); // 暴力反射,私有的构造方法也可被使用System.out.println("反射创建实例:" + constructor.newInstance()); // 创建实例
}@org.junit.Test
public void test2() throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {// 调用这个静态方法的意图:触发Singleton1类的加载初始化,就会导致Singleton1这个单例对象被创建Singleton1.otherMethod();System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");System.out.println(Singleton1.getInstance());System.out.println(Singleton1.getInstance());// 反射破坏单例reflection(Singleton1.class);
}

发现通过反射又再次调用构造方法创建了一个新的对象,

预防反射破坏单例模式:修改构造方法,如果对象已经创建则不能再次创建

再次测试,发现已经不能再创建对象

反序列化破坏单例

我们通过反序列化破坏单例模式,注意,前提是这个单例需要实现序列化接口

补充:保存在磁盘、网络传输都需要实现序列化接口

测试代码如下:

private static void serializable(Object instance) throws IOException, ClassNotFoundException {ByteArrayOutputStream bos = new ByteArrayOutputStream();ObjectOutputStream oos = new ObjectOutputStream(bos);oos.writeObject(instance); // 将对象变为一个字节流ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));// 把字节流还原成一个对象,反序列化会创建出一个新的对象,且反序列化不走构造方法System.out.println("反序列化创建实例:" + ois.readObject());
}@org.junit.Test
public void test3() throws IOException, ClassNotFoundException {// 调用这个静态方法的意图:触发Singleton1类的加载初始化,就会导致Singleton1这个单例对象被创建Singleton1.otherMethod();System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");System.out.println(Singleton1.getInstance());System.out.println(Singleton1.getInstance());// 反序列化破坏单例serializable(Singleton1.class);
}

发现反序列化会创建出一个新的对象,且反序列化不走构造方法

预防反序列化破坏单例

需要我们在单例的类中书写一个特殊的readResolve方法,方法中将单例对象返回即可。

原理:如果在反序列化过程中,如果重写了readResolve方法,就会利用这个方法的返回值作为结果返回,就不会用字节数组反序列化生成的对象了,这样就保证了单例模式。

再次测试,发现反序列化返回的还是我们原来的单例对象

Unsafe破坏单例

private static void unsafe(Class<?> clazz) throws InstantiationException {/*Unsafe是jdk内置的一个类,不能直接访问,我们通过反射拿到unsafe实例,UnsafeUtils这是一个工具类,getUnsafe拿到unsafe实例,allocateInstance可以根据类型创建这个类型的实例,且这个实例也是一个新的实例,他也不会走构造方法*/Object o = UnsafeUtils.getUnsafe().allocateInstance(clazz);System.out.println("Unsafe 创建实例:" + o);
}@org.junit.Test
public void test4() throws InstantiationException {// 调用这个静态方法的意图:触发Singleton1类的加载初始化,就会导致Singleton1这个单例对象被创建Singleton1.otherMethod();System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");System.out.println(Singleton1.getInstance());System.out.println(Singleton1.getInstance());// 反序列化破坏单例unsafe(Singleton1.class);
}

发现Unsafe会创建出一个新的对象,且Unsafe不走构造方法

目前还没有找到预防Unsafe破坏单例的方法

2.枚举饿汉式

枚举类是jdk5加入的语法,用枚举类可以很方便的控制枚举类的对象个数,枚举类最终还是会被编译器编译为class,本质还是一个类,如

enum Sex {MALE, FEMALE
}

在被编译后就变为了二进制字节码,我们将其翻译成java代码如下,

final class Sex extends Enum<Sex> {public static final Sex MALE;public static final Sex FEMALE;private Sex(String name, int ordinal) {super(name, ordinal);}static {MALE = new Sex("MALE", 0);FEMALE = new Sex("FEMALE", 1);$VALUES = values();}private static final Sex[] $VALUES;private static Sex[] $values() {return new Sex[]{MALE, FEMALE};}public static Sex[] values() {return $VALUES.clone();}public static Sex valueOf(String value) {return Enum.valueOf(Sex.class, value);}
}

final表示不能被继承,对应两个静态final成员变量,且类型为Sex,且都为public(公共的)。私有构造表示不能通过new关键字为这个枚举类创建新的对象,枚举变量的名称,序号,从0开始,逐一递增,传给父类构造,静态代码块为MALE,FEMALE进行了初始化,他们都分别创建了一个Sex对象,调用了自己的私有构造,创建了两个唯二的实例赋给MALE,FEMALE这两个变量。以后用这两个变量就是用的两个Sex对象

所以我们就可以用枚举类创建单例模式,即只声明一个变量

package com.singleton.test;// 2. 枚举饿汉式
public enum Singleton2 {// 控制枚举类只有一个唯一的实例,枚举变量都是公共的INSTANCE;// 默认枚举类的构造就是private,不写也可以private Singleton2() {System.out.println("private Singleton2()");}// 为了打印hashCode,看是否是同一个对象,不写默认打印枚举类变量的名字@Overridepublic String toString() {return getClass().getName() + "@" + Integer.toHexString(hashCode());}// 提供静态的公共方法获取静态变量,因为枚举变量都是公共的,所有不提供也能使用public static Singleton2 getInstance() {return INSTANCE;}// 为了测试是饿汉式还是懒汉式public static void otherMethod() {System.out.println("otherMethod()");}
}

之后开始测试,

@org.junit.Test
public void test5(){// 调用这个静态方法的意图:触发Singleton2类的加载初始化,就会导致Singleton2这个单例对象被创建Singleton2.otherMethod();System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");System.out.println(Singleton2.getInstance());System.out.println(Singleton2.getInstance());
}

发现使用的是同一个对象,

好处一:预防反序列化破坏单例

枚举类使用单例有两个好处,一是它不怕通过反序列化破坏单例,编写测试,

private static void serializable(Object instance) throws IOException, ClassNotFoundException {ByteArrayOutputStream bos = new ByteArrayOutputStream();ObjectOutputStream oos = new ObjectOutputStream(bos);oos.writeObject(instance); // 将对象变为一个字节流ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));// 把字节流还原成一个对象,反序列化会创建出一个新的对象,且反序列化不走构造方法System.out.println("反序列化创建实例:" + ois.readObject());
}@org.junit.Test
public void test6() throws IOException, ClassNotFoundException {// 调用这个静态方法的意图:触发Singleton2类的加载初始化,就会导致Singleton2这个单例对象被创建Singleton2.otherMethod();System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");System.out.println(Singleton2.getInstance());System.out.println(Singleton2.getInstance());// 反序列化破坏单例serializable(Singleton2.class);
}

发现我们的Singleton2枚举饿汉式并没有书写readResolve方法,反序列化创建出来的还是同一个对象

好处二:预防反射破坏单例

二是它也不怕通过反射破坏单例,编写测试,

private static void reflection(Class<?> clazz) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException {Constructor<?> constructor = clazz.getDeclaredConstructor(); // 得到无参构造方法constructor.setAccessible(true); // 暴力反射,私有的构造方法也可使用System.out.println("反射创建实例:" + constructor.newInstance()); // 创建实例
}@org.junit.Test
public void test7() throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {// 调用这个静态方法的意图:触发Singleton2类的加载初始化,就会导致Singleton2这个单例对象被创建Singleton2.otherMethod();System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");System.out.println(Singleton2.getInstance());System.out.println(Singleton2.getInstance());// 反射破坏单例reflection(Singleton2.class);
}

同时我们也没有在Singleton2枚举饿汉式中改写构造方法

发现报错,找不到一个无参的Singleton2的构造方法,

前面我们也已分析枚举的构造方法,枚举的构造是有两个参数的,一个是枚举变量的名字,一个是序号。所以我们修改代码,得到有参构造,并试图创建一个新的枚举对象

private static void reflection1(Class<?> clazz) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException {Constructor<?> constructor = clazz.getDeclaredConstructor(String.class, Integer.class); // 得到有参构造方法constructor.setAccessible(true); // 暴力反射,私有的构造方法也可被使用System.out.println("反射创建实例:" + constructor.newInstance("OTHER", 1)); // 试图创建一个新的枚举对象实例
}@org.junit.Test
public void test8() throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {// 调用这个静态方法的意图:触发Singleton2类的加载初始化,就会导致Singleton2这个单例对象被创建Singleton2.otherMethod();System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");System.out.println(Singleton2.getInstance());System.out.println(Singleton2.getInstance());// 反射破坏单例reflection1(Singleton2.class);
}

发现还是不行,不能通过反射去创建一个枚举对象

Unsafe破坏单例

private static void unsafe(Class<?> clazz) throws InstantiationException {/*Unsafe是jdk内置的一个类,不能直接访问,我们通过反射拿到unsafe实例,UnsafeUtils这是一个工具类,getUnsafe拿到unsafe实例,allocateInstance可以根据类型创建这个类型的实例,且这个实例也是一个新的实例,他也不会走构造方法*/Object o = UnsafeUtils.getUnsafe().allocateInstance(clazz);System.out.println("Unsafe 创建实例:" + o);
}@org.junit.Test
public void test9() throws InstantiationException {// 调用这个静态方法的意图:触发Singleton2类的加载初始化,就会导致Singleton2这个单例对象被创建Singleton2.otherMethod();System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");System.out.println(Singleton2.getInstance());System.out.println(Singleton2.getInstance());// Unsafe破坏单例unsafe(Singleton2.class);
}

发现创建了一个新的对象

目前还没有找到预防Unsafe破坏单例的方法

扩展:通过Unsafe创建一个新的枚举对象

package com.singleton.test;import org.springframework.objenesis.instantiator.util.UnsafeUtils;
import sun.misc.Unsafe;// 示例:通过 Unsafe 造出一个 Enum 对象
public class EnumCreator {public static void main(String[] args) throws Exception {Unsafe unsafe = UnsafeUtils.getUnsafe();long nameOffset = unsafe.objectFieldOffset(Enum.class.getDeclaredField("name"));long ordinalOffset = unsafe.objectFieldOffset(Enum.class.getDeclaredField("ordinal"));Sex o = (Sex) unsafe.allocateInstance(Sex.class);unsafe.compareAndSwapObject(o, nameOffset, null, "阴阳人");unsafe.compareAndSwapInt(o, ordinalOffset, 0, 2);System.out.println(o.name());System.out.println(o.ordinal());}
}

3.懒汉式

package com.singleton.test;import java.io.Serializable;// 3. 懒汉式单例
public class Singleton3 implements Serializable {private Singleton3() {System.out.println("private Singleton3()");}private static Singleton3 INSTANCE = null;// 调用getInstance方法时对象未创建时才进行创建public static Singleton3 getInstance() {if (INSTANCE == null) {INSTANCE = new Singleton3();}return INSTANCE;}public static void otherMethod() {System.out.println("otherMethod()");}
}

编写测试代码,

@org.junit.Testpublic void test10(){// 调用这个静态方法的意图:触发Singleton1类的加载初始化,查看构造是否被调用(区分是懒汉式还是饿汉式)Singleton3.otherMethod();System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");System.out.println(Singleton3.getInstance());System.out.println(Singleton3.getInstance());
}

发现刚开始调用otherMethod这个静态方法时我们的构造方法并未被调用,只有调用了getInstance方法时对象未创建时才进行调用构造创建对象

线程安全问题

同时如果我们的这个类如果运行在多线程环境下,则必须考虑线程安全问题。即如果我们的多个线程都执行到getInstance这个方法时,都判断INSTANCE这个对象为空,那么则会创建多个对象。

我们可以在getInstance这个方法上直接添加上synchronized进行线程安全的保护,加上synchronized的静态方法,即相当于在这个类上(Singleton3.class)加上一把锁

当某个线程执行这个方法时就会尝试获得Singleton3.class这个对象的锁,如果此时并没有被其他线程所持有,那么这个线程就会获得这把锁并执行方法中代码,同时其他线程在这个时候就需要等待,需要等这个线程执行完毕,退出这个方法并释放这把锁之后才能再次重新获取这把锁并执行这个方法内的代码

不过这种方法性能并不高,因为我们只需要在这个对象未创建时才需要同步,如果这个对象已经创建完毕后,我们就不需要同步,互斥保护了。所以,我们希望是首次创建这个对象时才提供线程安全的保护,后续这个对象创建之后就不再需要了。这就引出了我们的第四种单例的创建方式。

4.DCL(双检锁)懒汉式

是对我们第三种单例模式的优化。在加锁之前先判断,创建了对象则直接返回,没有创建对象才考虑线程之间竞争的问题

package com.singleton.test;import java.io.Serializable;// 4. 懒汉式单例 - DCL
public class Singleton4 implements Serializable {private Singleton4() {System.out.println("private Singleton4()");}private static volatile Singleton4 INSTANCE = null; // 可见性,有序性public static Singleton4 getInstance() {// 在加锁之前先判断,创建了对象则直接返回,没有创建对象才考虑线程之间竞争的问题if (INSTANCE == null) {synchronized (Singleton4.class) {// 继续判断,只有INSTANCE为null时进行创建对象if (INSTANCE == null) {INSTANCE = new Singleton4();}}}return INSTANCE;}public static void otherMethod() {System.out.println("otherMethod()");}
}

同时需要注意的是,我们的INSTANCE这个成员变量上面必须添加上volatile进行修饰

为何要加上volatile修饰符

为何要添加上volatile进行修饰,我们需要先理解INSTANCE = new Singleton4()这行代码,我们通过反编译Singleton4这个二进制字节码文件。

首先找到Singleton4的字节码文件,之后右键选择Open in Terminal

我们输入反编译指令,javap -c -v -p Singleton4.class ,之后找到getIntance方法,发现对应四个指令:

new指令表示创建一个新的对象(Singleton4),创建对象时会计算这个对象的成员变量,需要占多少内存空间,创建对象即把内存空间分配出来。

invokespecial指令表示调用方法,表示构造方法,构造方法和创建对象时两步操作,创建对象是把内存空间分配出来,而对象创建之后,里面可能存在很多成员变量,而这些成员变量的赋值是在构造方法中进行的。所以,一步是分配空间,一步是对成员变量做初始化。

putstatic指令表示给静态变量赋值,即给我们的INSTANCE赋值。

我们知道,cpu在执行过程中可能会对我们的指令进行优化(重排序),以方便更快的处理。如果我们没有对指令作出因果关系,cpu可能会调换他们的执行顺序。而我们的构造方法初始化和为静态变量赋值的操作都是初始化赋值的操作,所以cpu可能会调换他们的顺序,这在单线程下并没有问题,但是在多线程就可能会存在问题了。如下:

cpu调换了构造方法和为静态变量赋值的操作,此时只完成了对象的初始化操作和静态变量的赋值,构造方法还没有执行,即构造方法中的其他成员变量还未进行初始化,其他的线程就直接将INSTANCE对象返回了,这显然是不对的。

而加上volatile进行修饰INSTANCE之后,它会在为INSTANCE这个共享变量赋值的时候,会在这个变量的赋值语句之后添加上一层内存屏障,他的作用是防止在这语句之前的一些赋值操作(写操作)越过它跑到它的后面去。所以构造方法就不能跑到它的后面去。

补充:读操作的话就不能跑到他的前面去(详情请看后面的Volatile讲解)

所以出现以下的情况都不会有问题:

情况一:

持有的线程还未完成为静态变量的赋值操作,所以其他线程只能等待其完成,所以不会有问题

情况二:

拿到的是一个经过完整构造的对象,所以也不会有问题

所以,我们要加上volatile进行修饰INSTANCE

饿汉式会有线程安全的问题吗?

不会,因为饿汉式是将创建的对象赋值给了静态成员变量,给静态成员变量,最终会放在这个类的静态代码块中执行,而静态代码块中的线程安全,是由java虚拟机进行保证这个对象创建、代码执行的线程安全。

所以,如果我们将对象的创建放入静态代码块,那么它就是线程安全的,所以,这就引出了我们的第五种单例模式的创建。

5.内部类懒汉式

这种方法既兼备了懒汉的特性,又保证在创建线程时的线程安全,推荐使用

package com.singleton.test;import java.io.Serializable;// 5. 懒汉式单例 - 内部类
public class Singleton5 implements Serializable {private Singleton5() {System.out.println("private Singleton5()");}// 内部类可以访问外部类的私有变量、私有构造private static class Holder {// 将创建的对象赋值给内部类的静态变量,最终会在静态代码块中执行,所有是线程安全的static Singleton5 INSTANCE = new Singleton5();}// 使用内部类访问它的变量,就会触发内部类的加载初始化,即创建对象// 既兼备了懒汉的特性,又保证在创建线程时的线程安全,推荐使用public static Singleton5 getInstance() {return Holder.INSTANCE;}public static void otherMethod() {System.out.println("otherMethod()");}
}

编写测试,

@org.junit.Test
public void test11(){// 调用这个静态方法的意图:触发Singleton5类的加载初始化,查看构造是否被调用Singleton5.otherMethod();System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");System.out.println(Singleton5.getInstance());System.out.println(Singleton5.getInstance());
}

发现我们调用otherMethod静态方法时并没有触发单例对象的创建,只触发了外面这个类的加载初始化,并没有加载内部类,当调用getInstance静态方法时才会触发内部类的初始化,同时初始化的时候又能用静态代码块保证其创建对象的线程安全。

二、单例模式在JDK中的体现

JDK 中单例的体现

  • Runtime 体现了饿汉式单例
  • Console 体现了双检锁懒汉式单例
  • Collections 中的 EmptyNavigableSet 内部类懒汉式单例
  • ReverseComparator.REVERSE_ORDER 内部类懒汉式单例
  • Comparators.NaturalOrderComparator.INSTANCE 枚举饿汉式单例

Runtime类

System类

三、Volatile

哔哩哔哩链接:https://www.bilibili.com/video/BV15b4y117RJ?p=74

前面在DCL(双检锁)懒汉式中我们也已经初始了解了Volatile的作用,接下来我们将详细学习。

面试题:volatile能保证线程安全吗?不能,他能解决共享变量的可见性和有序性,但是不能解决原子性。

 `可见性:一个线程对共享变量的修改,另一个线程能看到最新的结果``有序性:一个线程内代码按编写顺序执行``原子性:一个线程内多行代码以一个整体运行,期间不能有其他线程的代码插队`

下面我们将依次对其进行分析,首先创建一个log日志对象工具类LoggerUtils,用来输出日志

package com.volatiles.test;import org.slf4j.Logger;
import org.slf4j.LoggerFactory;import java.util.HashMap;
import java.util.Map;public class LoggerUtils {public static final Logger logger1 = LoggerFactory.getLogger("A");public static final Logger logger2 = LoggerFactory.getLogger("B");public static final Logger logger3 = LoggerFactory.getLogger("C");public static final Logger logger4 = LoggerFactory.getLogger("D");public static final Logger logger5 = LoggerFactory.getLogger("E");public static final Logger logger6 = LoggerFactory.getLogger("F");public static final Logger main = LoggerFactory.getLogger("G");private static final Map<String, Logger> map = new HashMap<>();static {map.put("1", logger1);map.put("2", logger2);map.put("3", logger3);map.put("4", logger4);map.put("5", logger5);map.put("6", logger6);map.put("0", logger6);map.put("main", main);}public static Logger get() {return get(null);}public static Logger get(String prefix) {String name = Thread.currentThread().getName();if(!name.equals("main")) {int length = name.length();name = name.substring(length - 1);}return map.getOrDefault(name, logger1);}public static void main(String[] args) {logger1.debug("hello");logger2.debug("hello");logger3.debug("hello");logger4.debug("hello");logger5.warn("hello");logger6.info("hello");main.error("hello");}
}

原子性演示

以下代码大意如下:有一个共享变量balance,初始值值为5,使用volatile进行修饰,subtract方法对其进行减5的操作,add方法对其进行加5的操作,先用两个线程分别执行这两个方法,等这两个线程都运行结束之后,最后查看balance的结果

package com.volatiles.test;import java.util.concurrent.CountDownLatch;public class AddAndSubtract {static volatile int balance = 10;public static void subtract() {balance -= 5;}public static void add() {balance += 5;}public static void main(String[] args) throws InterruptedException {CountDownLatch latch = new CountDownLatch(2);new Thread(() -> {subtract();latch.countDown();}).start();new Thread(() -> {add();latch.countDown();}).start();latch.await();LoggerUtils.get().debug("{}", balance);}
}

我们运行代码,发现结果是10,但是这个代码在我们多线程下是存在问题的,结果还有两种可能,一种是5,一种是15。因为我们的balance -= 5balance += 5并不是一个原子操作。

表面的一行命令,底层可能对应着多行命令,在多线程的情况下,如果他们的执行顺序没有发生交换,那么就不会有问题。但是,一旦他们发生执行顺序发生交换,则会存在着问题。

继续反编译该类(javap -c -v -p AddAndSubtract.class),发现balance += 5已经变为了4行命令,

getstatic指令表示读取静态变量的值,iconst_5指令表示准备了一个数字5,iadd指令表示相加,之后putstatic指令将运行结果写回刚才读取的静态变量

我们知道,多个线程下,cpu执行是在这些线程之下来回切换,cpu可能会调换他们的执行顺序(未加Volatile进行修饰的变量),最终导致执行结果错误

我们修改代码,将balance -= 5balance += 5修改为多行代码的方式,并打上断点进行调试

首先执行subtract方法的int b = balance 此时b的值为10,之后切换为add方法,执行完add方法的所有代码,此时balance的值为15,之后再切换为subtract方法,此时b为10的数据已经为脏数据了,继续往下执行,结果为5,这就导致了结果的错误。

最终得出的结果为5,而正确的结果为10,所以Volatile并不能解决原子性的问题

可见性演示

以下代码大意如下:我们运行一个线程,0.1s后将stop改为true(此时并未对stop变量用volatile进行修饰),当stop为true的时候会停止while循环并输出运行了多少次

package com.volatiles.test;// 可见性例子
public class ForeverLoop {static boolean stop = false;public static void main(String[] args) {new Thread(() -> {try {Thread.sleep(100);} catch (InterruptedException e) {e.printStackTrace();}stop = true;LoggerUtils.get().debug("modify stop to true...");}).start();foo();}static void foo() {int i = 0;while (!stop) {i++;}LoggerUtils.get().debug("stopped... c:{}", i);}
}

我们运行,发现0.1s后java虚拟机并没有停止运行,且也没有输出结果,明明我们启动一个线程将stop在0.1s后设置为true了,但是他还没有停止运行,这是为什么呢?

难道是我们并没有修改stop的值为true吗?我们修改代码,书写多个线程在0.2s后输出stop的状态

我们发现stop的值为true,明明修改了stop的值,为什么他还没有停止运行呢?

这是因为stop的值初始为false,存储在物理内存当中,线程1执行while循环代码,线程2执行在0.1s后将stop修改为true,我们知道,所有的代码最终都交由cpu进行执行。

首先cpu到物理内存中读取stop的值,第一次读到的值为false,所以它继续循环读取stop的值,这个读取操作非常的快速,在0.1s内可以读取上千万次,我们知道,内存的读写效率是比较低的,每一次大约是几百纳秒。而cpu读取这么多次,发现stop的值还是false,这时java的即时编译器JIT(是java虚拟机的组成部分,主要负责代码的优化)就会发起作用了。

我们的任何一条java代码都会被翻译成java的字节码指令,但是字节码指令还不能交由cpu去执行,他还要通过解释器,解释器会将我们的java字节码指令逐行翻译成机器码,cpu才能认识并执行。但是这个将java字节码指令逐行翻译成机器码这个效率比较低,所以JIT就会对一些热点的字节码进行优化,即一些频繁调用执行的代码。

所以我们的这个while循环在超过JIT所限定的界限时就会被触发代码优化的操作。所以为了减少cpu与物理内存之间的操作,他会自动将我们的代码进行更改,即将stop的值修改为false。原来我们的字节码被JIT替换为了编译后的机器码并将其缓存起来,之后在运行这个代码,就会直接找到这个缓存的机器码交由cpu进行运行,从而减少了中间这个解释的过程,提高效率。当然,如果我们还需要之前的代码,它还可以替换为原来的代码。

所以,即使当其他线程将stop变量修改为了true,线程1也无法停止运行,因为他的代码已经被JIT替换掉了。

我们可以通过设置java虚拟机参数,-Xint,表示只用解释执行java字节码,即不用JIT了。

之后重新运行,发现已经成功输出循环次数

但是这种做法我们并不推荐,因为不使用JIT会影响整个系统的性能。所以,我们并不推荐这样做,我们直接使用volatile修饰这个变量就能解决问题,因为JIT一旦发现我们的变量使用volatile进行修饰,就不会对这个变量进行优化。

有序性演示

必须经过大量的测试才能暴露出来指令重排序这种现象,此处我们使用jcstress-core进行测试,其maven坐标

<dependency><groupId>org.openjdk.jcstress</groupId><artifactId>jcstress-core</artifactId><version>0.14</version>
</dependency>

编写测试代码,x和y都未使用volatile修饰,打包后运行,

package com.volatiles.test;import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;// 有序性例子
// 运行指令:java -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation -jar jcstress.jar -t day02.threadsafe.Reordering.Case1public class Reordering {@JCStressTest@Outcome(id = {"0, 0", "1, 1", "0, 1"}, expect = Expect.ACCEPTABLE, desc = "ACCEPTABLE")// 出现1,0的情况即表示出现了指令重排序@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "INTERESTING")@Statepublic static class Case1 {int x;int y;// @Actor表示在进行测试的过程中都会被一个线程进行执行,这里的两个表示会启动两个线程来执行这两个被@Actor修饰的方法// 赋值的操作@Actorpublic void actor1() {x = 1;y = 1;}// @Actor表示在进行测试的过程中都会被一个线程进行执行,这里的两个表示会启动两个线程来执行这两个被@Actor修饰的方法// 获取值的操作,这个结果会与我们的预期值作对比,II_Result表示收集结果,r.r1和r.r2这两个变量会与上方的@Outcome// 中的数据做对比(多组用逗号隔开),查看实际值是否与预期值相符,r.r1与每组中的第一个值做比较,r.r2与每组中的第二个值做比较,@Actorpublic void actor2(II_Result r) {r.r1 = y;r.r2 = x;}}
}

发现出现了指令重排序的情况,

继续编写第二个测试,使用volatile修饰y变量,阻止指令重排序的发生,打包后运行,

package com.volatiles.test;import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;// 有序性例子
// 运行指令:java -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation -jar jcstress.jar -t day02.threadsafe.Reordering.Case2public class Reordering {@JCStressTest@Outcome(id = {"0, 0", "1, 1", "0, 1"}, expect = Expect.ACCEPTABLE, desc = "ACCEPTABLE")// Expect.FORBIDDEN 表示如果出现指令重排序,则直接报错@Outcome(id = "1, 0", expect = Expect.FORBIDDEN, desc = "FORBIDDEN")@Statepublic static class Case2 {int x;// 使用volatile修饰y变量,阻止指令重排序的发生volatile int y;@Actorpublic void actor1() {x = 1;y = 1;}@Actorpublic void actor2(II_Result r) {r.r1 = y;r.r2 = x;}}
}

发现并未报错,即没有出现指令重排序的情况,且吞吐量相对于前面不加volatile时减少了(M表示千万),这是因为使用volatile之后就不能用JITvolatile修饰的变量做优化了。

继续编写第三个测试,使用volatile修饰x变量,阻止指令重排序的发生,打包后运行,发现报错了。这是为什么呢?

volatile位置不同影响

volatile是使用内存屏障来解决指令重排序的,会为添加上volatile修饰的变量的写和读操作分别加上不同的内存屏障,对volatile的写操作会添加上一个向上的屏障,阻止上面的代码排下来,而对volatile的读取操作会添加上箭头一个向下的屏障,阻止下面的代码跑到上面去。

所以我们如果用volatile修饰x变量的话,

写操作:x=1上面的语句下不来,但是y=1是有可能到上面去的,

读操作:r.r2=x之后的语句不能越过屏障跑上去,但是r.r1=y却是可以下去的

二、并发篇

1. 线程状态

要求

  • 掌握 Java 线程六种状态
  • 掌握 Java 线程状态转换
  • 能理解五种状态与六种状态两种说法的区别

六种状态及转换

分别是

  • 新建

    • 当一个线程对象被创建,但还未调用 start 方法时处于新建状态
    • 此时未与操作系统底层线程关联
  • 可运行
    • 调用了 start 方法,就会由新建进入可运行
    • 此时与底层线程关联,由操作系统调度执行
  • 终结
    • 线程内代码已经执行完毕,由可运行进入终结
    • 此时会取消与底层线程关联
  • 阻塞
    • 当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用 cpu 时间
    • 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
  • 等待
    • 当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间
    • 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态
  • 有时限等待
    • 当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从可运行状态释放锁进入 Monitor 等待集合进行有时限等待,同样不占用 cpu 时间
    • 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁
    • 如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁
    • 还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态

其它情况(只需了解)

  • 可以用 interrupt() 方法打断等待有时限等待的线程,让它们恢复为可运行状态
  • park,unpark 等方法也可以让线程等待和唤醒

五种状态

五种状态的说法来自于操作系统层面的划分

  • 运行态:分到 cpu 时间,能真正执行线程内代码的
  • 就绪态:有资格分到 cpu 时间,但还未轮到它的
  • 阻塞态:没资格分到 cpu 时间的
    • 涵盖了 java 状态中提到的阻塞等待有时限等待
    • 多出了阻塞 I/O,指线程在调用阻塞 I/O 时,实际活由 I/O 设备完成,此时线程无事可做,只能干等
  • 新建与终结态:与 java 中同名状态类似,不再啰嗦

2. 线程池

要求

  • 掌握线程池的 7 大核心参数

七大参数

  1. corePoolSize 核心线程数目 - 池中会保留的最多线程数
  2. maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目
  3. keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
  4. unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
  5. workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
  6. threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
  7. handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
    1. 抛异常 java.util.concurrent.ThreadPoolExecutor.AbortPolicy
    2. 由调用者执行任务 java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy
    3. 丢弃任务 java.util.concurrent.ThreadPoolExecutor.DiscardPolicy
    4. 丢弃最早排队任务 java.util.concurrent.ThreadPoolExecutor.DiscardOldestPolicy

代码说明

day02.TestThreadPoolExecutor 以较为形象的方式演示了线程池的核心组成

3. wait vs sleep

要求

  • 能够说出二者区别

一个共同点,三个不同点

共同点

  • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态

不同点

  • 方法归属不同

    • sleep(long) 是 Thread 的静态方法
    • 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
  • 醒来时机不同

    • 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
    • wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  • 锁特性不同(重点)

    • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
    • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

4. lock vs synchronized

要求

  • 掌握 lock 与 synchronized 的区别
  • 理解 ReentrantLock 的公平、非公平锁
  • 理解 ReentrantLock 中的条件变量

三个层面

不同点

  • 语法层面

    • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
    • Lock 是接口,源码由 jdk 提供,用 java 语言实现
    • 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
  • 功能层面
    • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
    • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
    • Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
  • 性能层面
    • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
    • 在竞争激烈时,Lock 的实现通常会提供更好的性能

公平锁

  • 公平锁的公平体现

    • 已经处在阻塞队列中的线程(不考虑超时)始终都是公平的,先进先出
    • 公平锁是指未处于阻塞队列中的线程来争抢锁,如果队列不为空,则老实到队尾等待
    • 非公平锁是指未处于阻塞队列中的线程来争抢锁,与队列头唤醒的线程去竞争,谁抢到算谁的
  • 公平锁会降低吞吐量,一般不用

条件变量

  • ReentrantLock 中的条件变量功能类似于普通 synchronized 的 wait,notify,用在当线程获得锁后,发现条件不满足时,临时等待的链表结构
  • 与 synchronized 的等待集合不同之处在于,ReentrantLock 中的条件变量可以有多个,可以实现更精细的等待、唤醒控制

代码说明

  • day02.TestReentrantLock 用较为形象的方式演示 ReentrantLock 的内部结构

5. volatile

要求

  • 掌握线程安全要考虑的三个问题
  • 掌握 volatile 能解决哪些问题

原子性

  • 起因:多线程下,不同线程的指令发生了交错导致的共享变量的读写混乱
  • 解决:用悲观锁或乐观锁解决,volatile 并不能解决原子性

可见性

  • 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致的对共享变量所做的修改另外的线程看不到
  • 解决:用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见

有序性

  • 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致指令的实际执行顺序与编写顺序不一致
  • 解决:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
  • 注意:
    • volatile 变量写加的屏障是阻止上方其它写操作越过屏障排到 volatile 变量写之下
    • volatile 变量读加的屏障是阻止下方其它读操作越过屏障排到 volatile 变量读之上
    • volatile 读写加入的屏障只能防止同一线程内的指令重排

代码说明

  • day02.threadsafe.AddAndSubtract 演示原子性
  • day02.threadsafe.ForeverLoop 演示可见性
    • 注意:本例经实践检验是编译器优化导致的可见性问题
  • day02.threadsafe.Reordering 演示有序性
    • 需要打成 jar 包后测试
  • 请同时参考视频讲解

6. 悲观锁 vs 乐观锁

要求

  • 掌握悲观锁和乐观锁的区别

对比悲观锁与乐观锁

  • 悲观锁的代表是 synchronized 和 Lock 锁

    • 其核心思想是【线程只有占有了锁,才能去操作共享变量,每次只有一个线程占锁成功,获取锁失败的线程,都得停下来等待】
    • 线程从运行到阻塞、再从阻塞到唤醒,涉及线程上下文切换,如果频繁发生,影响性能
    • 实际上,线程在获取 synchronized 和 Lock 锁时,如果锁已被占用,都会做几次重试操作,减少阻塞的机会
  • 乐观锁的代表是 AtomicInteger,使用 cas 来保证原子性

    • 其核心思想是【无需加锁,每次只有一个线程能成功修改共享变量,其它失败的线程不需要停止,不断重试直至成功】
    • 由于线程一直运行,不需要阻塞,因此不涉及线程上下文切换
    • 它需要多核 cpu 支持,且线程数不应超过 cpu 核数

代码说明

  • day02.SyncVsCas 演示了分别使用乐观锁和悲观锁解决原子赋值
  • 请同时参考视频讲解

7. Hashtable vs ConcurrentHashMap

要求

  • 掌握 Hashtable 与 ConcurrentHashMap 的区别
  • 掌握 ConcurrentHashMap 在不同版本的实现区别

更形象的演示,见资料中的 hash-demo.jar,运行需要 jdk14 以上环境,进入 jar 包目录,执行下面命令

java -jar --add-exports java.base/jdk.internal.misc=ALL-UNNAMED hash-demo.jar

Hashtable 对比 ConcurrentHashMap

  • Hashtable 与 ConcurrentHashMap 都是线程安全的 Map 集合
  • Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时刻,只能有一个线程操作它
  • ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不同锁,那么不会冲突
  • hsahtable初始容量为11,扩容因子默认0.75,hashcode的计算不需要二次hash,因为容量不是2的n次幂,其容量为质数,有比较好的hash计算性,不需要二次hash。之后就与数组长度求余计算索引下标了
  • ConcurrentHashMap 初始容量为16(jdk1.7和1.8),扩容因子默认0.75

ConcurrentHashMap 1.7

  • 数据结构:Segment(大数组) + HashEntry(小数组) + 链表,每个 Segment 对应一把锁,如果多个线程访问不同的 Segment,则不会冲突
  • 并发度:Segment 数组大小即并发度,决定了同一时刻最多能有多少个线程并发访问。Segment 数组不能扩容,意味着并发度在 ConcurrentHashMap 创建时就固定了
  • 索引计算
    • 假设大数组长度是 2m2^m2m,key 在大数组内的索引是 key 的二次 hash 值的高 m 位
    • 假设小数组长度是 2n2^n2n,key 在小数组内的索引是 key 的二次 hash 值的低 n 位
  • 扩容:每个小数组的扩容相对独立,小数组在超过扩容因子时会触发扩容,每次扩容翻倍
  • Segment[0] 原型:首次创建其它小数组时,会以此原型为依据,数组长度,扩容因子都会以原型为准

容量16,决定了Segment 数组里面的HashEntry小数组,将容量除以并发度作为小数组的容量,如果容量小于或等于并发度,则小数组容量为2、扩容因子、并发度

ConcurrentHashMap 1.8

  • 数据结构:Node 数组 + 链表或红黑树,数组的每个头节点作为锁,如果多个线程访问的头节点不同,则不会冲突。首次生成头节点时如果发生竞争,利用 cas 而非 syncronized,进一步提升性能,插入元素采用的是尾插法
  • 并发度:Node 数组有多大,并发度就有多大,与 1.7 不同,Node 数组可以扩容
  • 扩容条件:Node 数组 3/4 时就会扩容
  • 扩容单位:以链表为单位从后向前迁移链表,迁移完成的将旧数组头节点替换为 ForwardingNode
  • 扩容时并发 get
    • 根据是否为 ForwardingNode 来决定是在新数组查找还是在旧数组查找,不会阻塞
    • 如果链表长度超过 1,则需要对节点进行复制(创建新节点),怕的是节点迁移后 next 指针改变
    • 如果链表最后几个元素扩容后索引不变,则节点无需复制
  • 扩容时并发 put
    • 如果 put 的线程与扩容线程操作的链表是同一个,put 线程会阻塞
    • 如果 put 的线程操作的链表还未迁移完成,即头节点不是 ForwardingNode,则可以并发执行
    • 如果 put 的线程操作的链表已经迁移完成,即头结点是 ForwardingNode,则可以协助扩容
  • 与 1.7 相比是懒惰初始化
  • capacity 代表预估的元素个数,capacity / factory 来计算出初始数组大小,需要贴近 2n2^n2n
  • loadFactor 只在计算初始数组大小时被使用,之后扩容固定为 3/4
  • 超过树化阈值时的扩容问题,如果容量已经是 64,直接树化,否则在原来容量基础上做 3 轮扩容

8. ThreadLocal

要求

  • 掌握 ThreadLocal 的作用与原理
  • 掌握 ThreadLocal 的内存释放时机

作用

  • ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题
  • ThreadLocal 同时实现了线程内的资源共享

原理

每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象

  • 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
  • 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
  • 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值

ThreadLocalMap 的一些特点

  • key 的 hash 值统一分配
  • 初始容量 16,扩容因子 2/3,扩容容量翻倍
  • key 索引冲突后用开放寻址法解决冲突

弱引用 key

ThreadLocalMap 中的 key 被设计为弱引用,原因如下

  • Thread 可能需要长时间运行(如线程池中的线程),如果 key 不再使用,需要在内存不足(GC)时释放其占用的内存

内存释放时机

  • 被动 GC 释放 key

    • 仅是让 key 的内存释放,关联 value 的内存并不会释放
  • 懒惰被动释放 value
    • get key 时,发现是 null key,则释放其 value 内存
    • set key 时,会使用启发式扫描,清除临近的 null key 的 value 内存,启发次数与元素个数,是否发现 null key 有关
  • 主动 remove 释放 key,value
    • 会同时释放 key,value 的内存,也会清除临近的 null key 的 value 内存
    • 推荐使用它,因为一般使用 ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收

三、虚拟机篇

堆:new出来的对象、数组

java虚拟机栈:普通的java方法中的局部变量、方法参数

本地方法栈:本地方法存放的位置,需要调用本地方法接口间接地去调由操作系统提供的一些功能

但是java虚拟机栈和本地方法栈都只是java虚拟机的一个规范,对于oracle的hostpot的具体实现来说,他们用的都是java虚拟机栈,没用本地方法栈

方法区:类的基本信息(类的名字、继承关系、类中成员变量、类上的注解),java虚拟机的规范,有永久区(jdk1.7)和元空间实现(jdk1.8)。脱离了java虚拟机内存,计算机的物理内存有多大理论上他就有多大。

jdk1.8后字符串常量池放在堆中,类文件常量池和运行时常量池放在元空间中

程序计数器:记录线程执行到第几行代码,当恢复线程运行的时候能从该位置进行运行

方法区存储类的相关信息,如类的名称、继承关系、成员变量、类上的注解等等

1. JVM 内存结构

要求

  • 掌握 JVM 内存结构划分
  • 尤其要知道方法区、永久代、元空间的关系

结合一段 java 代码的执行理解内存划分

  • 执行 javac 命令编译源代码为字节码
  • 执行 java 命令
    1. 创建 JVM,调用类加载子系统加载 class,将类的信息存入方法区
    2. 创建 main 线程,使用的内存区域是 JVM 虚拟机栈,开始执行 main 方法代码
    3. 如果遇到了未见过的类,会继续触发类加载过程,同样会存入方法区
    4. 需要创建对象,会使用内存来存储对象
    5. 不再使用的对象,会由垃圾回收器在内存不足时回收其内存
    6. 调用方法时,方法内的局部变量、方法参数所使用的是 JVM 虚拟机栈中的栈帧内存
    7. 调用方法时,先要到方法区获得到该方法的字节码指令,由解释器将字节码指令解释为机器码执行
    8. 调用方法时,会将要执行的指令行号读到程序计数器,这样当发生了线程切换,恢复时就可以从中断的位置继续
    9. 对于非 java 实现的方法调用,使用内存称为本地方法栈(见说明)
    10. 对于热点方法调用,或者频繁的循环代码,由 JIT 即时编译器将这些代码编译成机器码缓存,提高执行性能

说明

  • 加粗字体代表了 JVM 虚拟机组件
  • 对于 Oracle 的 Hotspot 虚拟机实现,不区分虚拟机栈和本地方法栈

会发生内存溢出的区域

  • 不会出现内存溢出的区域 – 程序计数器
  • 出现 OutOfMemoryError 的情况
    • 堆内存耗尽 – 对象越来越多,又一直在使用,不能被垃圾回收
    • 方法区内存耗尽 – 加载的类越来越多,很多框架都会在运行期间动态产生新的类
    • 虚拟机栈累积 – 每个线程最多会占用 1 M 内存,线程个数越来越多,而又长时间运行不销毁时
  • 出现 StackOverflowError 的区域
    • JVM 虚拟机栈,原因有方法递归调用未正确结束、反序列化 json 时循环引用

方法区、永久代、元空间

  • 方法区是 JVM 规范中定义的一块内存区域,用来存储类元数据、方法字节码、即时编译器需要的信息等
  • 永久代是 Hotspot 虚拟机对 JVM 规范的实现(1.8 之前)
  • 元空间是 Hotspot 虚拟机对 JVM 规范的另一种实现(1.8 以后),使用本地内存作为这些信息的存储空间

从这张图学到三点

  • 当第一次用到某个类是,由类加载器将 class 文件的类元信息读入,并存储于元空间
  • X,Y 的类元信息是存储于元空间中,无法直接访问
  • 可以用 X.class,Y.class 间接访问类元信息,它们俩属于 java 对象,我们的代码中可以使用

从这张图可以学到

  • 堆内存中:当一个类加载器对象,这个类加载器对象加载的所有类对象,这些类对象对应的所有实例对象都没人引用时,GC 时就会对它们占用的对内存进行释放
  • 元空间中:内存释放以类加载器为单位,当堆中类加载器内存释放时,对应的元空间中的类元信息也会释放

Java中的常量池(字符串常量池、class常量池和运行时常量池)

在Java的内存分配中,总共3种常量池:

1.字符串常量池(String Constant Pool):

1.1:字符串常量池在Java内存区域的哪个位置?

  • 在JDK6.0及之前版本,字符串常量池是放在Perm Gen区(也就是方法区)中;

  • 在JDK7.0版本,字符串常量池被移到了堆中了。至于为什么移到堆内,大概是由于方法区的内存空间太小了。

1.2:字符串常量池是什么?

  • 在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个Hash表,默认值大小长度是1009;这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable上。

  • 在JDK6.0中,StringTable的长度是固定的,长度就是1009,因此如果放入String Pool中的String非常多,就会造成hash冲突,导致链表过长,当调用String#intern()时会需要到链表上一个一个找,从而导致性能大幅度下降;

  • 在JDK7.0中,StringTable的长度可以通过参数指定:

-XX:StringTableSize=66666

1.3:字符串常量池里放的是什么?

  • 在JDK6.0及之前版本中,String Pool里放的都是字符串常量;

  • 在JDK7.0中,由于String#intern()发生了改变,因此String Pool中也可以存放放于堆内的字符串对象的引用。关于String在内存中的存储和String#intern()方法的说明,可以参考我的另外一篇博客:

  • 需要说明的是:字符串常量池中的字符串只存在一份!

如:

String s1 = "hello,world!";
String s2 = "hello,world!";

即执行完第一行代码后,常量池中已存在 “hello,world!”,那么 s2不会在常量池中申请新的空间,而是直接把已存在的字符串内存地址返回给s2。(这里具体的字符串如何分配就不细说了,可以看我的另一篇博客)

2.class常量池(Class Constant Pool):

2.1:class常量池简介:

  • 我们写的每一个Java类被编译后,就会形成一份class文件;class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References);

  • 每个class文件都有一个class常量池。

2.2:什么是字面量和符号引用:

  • 字面量包括:1.文本字符串 2.八种基本类型的值 3.被声明为final的常量等;

  • 符号引用包括:1.类和方法的全限定名 2.字段的名称和描述符 3.方法的名称和描述符。

3.运行时常量池(Runtime Constant Pool):

  • 运行时常量池存在于内存中,也就是class常量池被加载到内存之后的版本,不同之处是:它的字面量可以动态的添加(String#intern()),符号引用可以被解析为直接引用
  • JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

2. JVM 内存参数

要求

  • 熟悉常见的 JVM 参数,尤其和大小相关的

堆内存,按大小设置

解释:

  • -Xms 最小堆内存(包括新生代和老年代)
  • -Xmx 最大对内存(包括新生代和老年代)
  • 通常建议将 -Xms 与 -Xmx 设置为大小相等,即不需要保留内存,不需要从小到大增长,这样性能较好
  • -XX:NewSize 与 -XX:MaxNewSize 设置新生代的最小与最大值,但一般不建议设置,由 JVM 自己控制
  • -Xmn 设置新生代大小,相当于同时设置了 -XX:NewSize 与 -XX:MaxNewSize 并且取值相等
  • 保留是指,一开始不会占用那么多内存,随着使用内存越来越多,会逐步使用这部分保留内存。下同

堆内存,按比例设置

解释:

  • -XX:NewRatio=2:1 表示老年代占两份,新生代占一份
  • -XX:SurvivorRatio=4:1 表示新生代分成六份,伊甸园占四份,from 和 to 各占一份

元空间内存设置

解释:

  • class space 存储类的基本信息,最大值受 -XX:CompressedClassSpaceSize 控制
  • non-class space 存储除类的基本信息以外的其它信息(如方法字节码、注解等)
  • class space 和 non-class space 总大小受 -XX:MaxMetaspaceSize 控制

注意:

  • 这里 -XX:CompressedClassSpaceSize 这段空间还与是否开启了指针压缩有关,这里暂不深入展开,可以简单认为指针压缩默认开启

代码缓存内存设置

解释:

  • 如果 -XX:ReservedCodeCacheSize < 240m,所有优化机器代码不加区分存在一起
  • 否则,分成三个区域(图中笔误 mthod 拼写错误,少一个 e)
    • non-nmethods - JVM 自己用的代码
    • profiled nmethods - 部分优化的机器码
    • non-profiled nmethods - 完全优化的机器码

线程内存设置

官方参考文档

  • https://docs.oracle.com/en/java/javase/11/tools/java.html#GUID-3B1CE181-CD30-4178-9602-230B800D4FAE

3. JVM 垃圾回收

要求

  • 掌握垃圾回收算法
  • 掌握分代回收思想
  • 理解三色标记及漏标处理
  • 了解常见垃圾回收器

三种垃圾回收算法

标记清除法

解释:

  1. 找到 GC Root 对象,即那些一定不会被回收的对象,如正执行方法内局部变量引用的对象、静态变量引用的对象
  2. 标记阶段:沿着 GC Root 对象的引用链找,直接或间接引用到的对象加上标记
  3. 清除阶段:释放未加标记的对象占用的内存

要点:

  • 标记速度与存活对象线性关系
  • 清除速度与内存大小线性关系
  • 缺点是会产生内存碎片

标记整理法

解释:

  1. 前面的标记阶段、清理阶段与标记清除法类似
  2. 多了一步整理的动作,将存活对象向一端移动,可以避免内存碎片产生

特点:

  • 标记速度与存活对象线性关系

  • 清除与整理速度与内存大小成线性关系

  • 缺点是性能上较慢

标记复制法

解释:

  1. 将整个内存分成两个大小相等的区域,from 和 to,其中 to 总是处于空闲,from 存储新创建的对象
  2. 标记阶段与前面的算法类似
  3. 在找出存活对象后,会将它们从 from 复制到 to 区域,复制的过程中自然完成了碎片整理
  4. 复制完成后,交换 from 和 to 的位置即可

特点:

  • 标记与复制速度与存活对象成线性关系
  • 缺点是会占用成倍的空间

GC 与分代回收算法

GC 的目的在于实现无用对象内存自动释放,减少内存碎片、加快分配速度

GC 要点:

  • 回收区域是堆内存,不包括虚拟机栈
  • 判断无用对象,使用可达性分析算法三色标记法标记存活对象,回收未标记对象
  • GC 具体的实现称为垃圾回收器
  • GC 大都采用了分代回收思想
    • 理论依据是大部分对象朝生夕灭,用完立刻就可以回收,另有少部分对象会长时间存活,每次很难回收
    • 根据这两类对象的特性将回收区域分为新生代老年代,新生代采用标记复制法、老年代一般采用标记整理法
  • 根据 GC 的规模可以分成 Minor GCMixed GCFull GC

分代回收

  1. 伊甸园 eden,最初对象都分配到这里,与幸存区 survivor(分成 from 和 to)合称新生代,

  1. 当伊甸园内存不足,标记伊甸园与 from(现阶段没有)的存活对象

  1. 将存活对象采用复制算法复制到 to 中,复制完毕后,伊甸园和 from 内存都得到释放

  1. 将 from 和 to 交换位置

  1. 经过一段时间后伊甸园的内存又出现不足

  1. 标记伊甸园与 from(现阶段没有)的存活对象

  1. 将存活对象采用复制算法复制到 to 中

  1. 复制完毕后,伊甸园和 from 内存都得到释放

  1. 将 from 和 to 交换位置

  1. 老年代 old,当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会导致提前晋升)

GC 规模

  • Minor GC 发生在新生代的垃圾回收,暂停时间短

  • Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1 收集器特有

  • Full GC 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免

三色标记

即用三种颜色记录对象的标记状态

  • 黑色 – 已标记
  • 灰色 – 标记中
  • 白色 – 还未标记
  1. 起始的三个对象还未处理完成,用灰色表示

image-20210831215016566

  1. 该对象的引用已经处理完成,用黑色表示,黑色引用的对象变为灰色

image-20210831215033510

  1. 依次类推

image-20210831215105280

  1. 沿着引用链都标记了一遍

image-20210831215146276

  1. 最后为标记的白色对象,即为垃圾

image-20210831215158311

并发漏标问题

比较先进的垃圾回收器都支持并发标记,即在标记过程中,用户线程仍然能工作。但这样带来一个新的问题,如果用户线程修改了对象引用,那么就存在漏标问题。例如:

  1. 如图所示标记工作尚未完成

image-20210831215846876

  1. 用户线程同时在工作,断开了第一层 3、4 两个对象之间的引用,这时对于正在处理 3 号对象的垃圾回收线程来讲,它会将 4 号对象当做是白色垃圾

image-20210831215904073

  1. 但如果其他用户线程又建立了 2、4 两个对象的引用,这时因为 2 号对象是黑色已处理对象了,因此垃圾回收线程不会察觉到这个引用关系的变化,从而产生了漏标

image-20210831215919493

  1. 如果用户线程让黑色对象引用了一个新增对象,一样会存在漏标问题

image-20210831220004062

因此对于并发标记而言,必须解决漏标问题,也就是要记录标记过程中的变化。有两种解决方法:

  1. Incremental Update 增量更新法,CMS 垃圾回收器采用

    • 思路是拦截每次赋值动作,只要赋值发生,被赋值的对象就会被记录下来,在重新标记阶段再确认一遍
  2. Snapshot At The Beginning,SATB 原始快照法,G1 垃圾回收器采用
    • 思路也是拦截每次赋值动作,不过记录的对象不同,也需要在重新标记阶段对这些对象二次处理
    • 新加对象会被记录
    • 被删除引用关系的对象也被记录

垃圾回收器 - Parallel GC

  • eden 内存不足发生 Minor GC,采用标记复制算法,需要暂停用户线程

  • old 内存不足发生 Full GC,采用标记整理算法,需要暂停用户线程

  • 注重吞吐量

垃圾回收器 - ConcurrentMarkSweep GC

  • 它是工作在 old 老年代,支持并发标记的一款回收器,采用并发清除算法

    • 并发标记时不需暂停用户线程
    • 重新标记时仍需暂停用户线程
  • 如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC

  • 注重响应时间

垃圾回收器 - G1 GC

  • 响应时间与吞吐量兼顾
  • 划分成多个区域,每个区域都可以充当 eden,survivor,old, humongous,其中 humongous 专为大对象准备
  • 分成三个阶段:新生代回收、并发标记、混合收集
  • 如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC

G1 回收阶段 - 新生代回收

  1. 初始时,所有区域都处于空闲状态

image-20210831222639754

  1. 创建了一些对象,挑出一些空闲区域作为伊甸园区存储这些对象

image-20210831222653802

  1. 当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程

image-20210831222705814

  1. 复制完成,将之前的伊甸园内存释放

image-20210831222724999

  1. 随着时间流逝,伊甸园的内存又有不足

image-20210831222737928

  1. 将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老对象晋升至老年代

image-20210831222752787

  1. 释放伊甸园以及之前幸存区的内存

image-20210831222803281

G1 回收阶段 - 并发标记与混合收集

  1. 当老年代占用内存超过阈值后,触发并发标记,这时无需暂停用户线程

image-20210831222813959

  1. 并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程。这些都完成后就知道了老年代有哪些存活对象,随后进入混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是 Gabage First 名称的由来)。

image-20210831222828104

  1. 混合收集阶段中,参与复制的有 eden、survivor、old,下图显示了伊甸园和幸存区的存活对象复制

image-20210831222841096

  1. 下图显示了老年代和幸存区晋升的存活对象的复制

image-20210831222859760

  1. 复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集

image-20210831222919182

4. 内存溢出

要求

  • 能够说出几种典型的导致内存溢出的情况

典型情况

  • 误用线程池导致的内存溢出

    • 参考 day03.TestOomThreadPool
  • 查询数据量太大导致的内存溢出
    • 参考 day03.TestOomTooManyObject
  • 动态生成类导致的内存溢出
    • 参考 day03.TestOomTooManyClass

5. 类加载

要求

  • 掌握类加载阶段
  • 掌握类加载器
  • 理解双亲委派机制

类加载过程的三个阶段

  1. 加载

    1. 将类的字节码载入方法区,并创建类.class 对象

    2. 如果此类的父类没有加载,先加载父类

    3. 加载是懒惰执行

  2. 链接

    1. 验证 – 验证类是否符合 Class 规范,合法性、安全性检查
    2. 准备 – 为 static 变量分配空间,设置默认值
    3. 解析 – 将常量池的符号引用解析为直接引用
  3. 初始化

    1. 静态代码块、static 修饰的变量赋值、static final 修饰的引用类型变量赋值,会被合并成一个 <cinit> 方法,在初始化时被调用
    2. static final 修饰的基本类型变量赋值,在链接阶段就已完成
    3. 初始化是懒惰执行

验证手段

  • 使用 jps 查看进程号
  • 使用 jhsdb 调试,执行命令 jhsdb.exe hsdb 打开它的图形界面
    • Class Browser 可以查看当前 jvm 中加载了哪些类
    • 控制台的 universe 命令查看堆内存范围
    • 控制台的 g1regiondetails 命令查看 region 详情
    • scanoops 起始地址 结束地址 对象类型 可以根据类型查找某个区间内的对象地址
    • 控制台的 inspect 地址 指令能够查看这个地址对应的对象详情
  • 使用 javap 命令可以查看 class 字节码

代码说明

  • day03.loader.TestLazy - 验证类的加载是懒惰的,用到时才触发类加载
  • day03.loader.TestFinal - 验证使用 final 修饰的变量不会触发类加载

jdk 8 的类加载器

名称 加载哪的类 说明
Bootstrap ClassLoader JAVA_HOME/jre/lib 无法直接访问
Extension ClassLoader JAVA_HOME/jre/lib/ext 上级为 Bootstrap,显示为 null
Application ClassLoader classpath 上级为 Extension
自定义类加载器 自定义 上级为 Application

双亲委派机制

所谓的双亲委派,就是指优先委派上级类加载器进行加载,如果上级类加载器

  • 能找到这个类,由上级加载,加载后该类也对下级加载器可见
  • 找不到这个类,则下级类加载器才有资格执行加载

双亲委派的目的有两点

  1. 让上级类加载器中的类对下级共享(反之不行),即能让你的类能依赖到 jdk 提供的核心类

  2. 让类的加载有优先次序,保证核心类优先加载

对双亲委派的误解

下面面试题的回答是错误的

错在哪了?

  • 自己编写类加载器就能加载一个假冒的 java.lang.System 吗? 答案是不行。

  • 假设你自己的类加载器用双亲委派,那么优先由启动类加载器加载真正的 java.lang.System,自然不会加载假冒的

  • 假设你自己的类加载器不用双亲委派,那么你的类加载器加载假冒的 java.lang.System 时,它需要先加载父类 java.lang.Object,而你没有用委派,找不到 java.lang.Object 所以加载会失败

  • 以上也仅仅是假设。事实上操作你就会发现,自定义类加载器加载以 java. 打头的类时,会抛安全异常,在 jdk9 以上版本这些特殊包名都与模块进行了绑定,更连编译都过不了

代码说明

  • day03.loader.TestJdk9ClassLoader - 演示类加载器与模块的绑定关系

6. 四种引用

要求

  • 掌握四种引用

强引用

  1. 普通变量赋值即为强引用,如 A a = new A();

  2. 通过 GC Root 的引用链,如果强引用不到该对象,该对象才能被回收

image-20210901111903574

软引用(SoftReference)

  1. 例如:SoftReference a = new SoftReference(new A());

  2. 如果仅有软引用该对象时,首次垃圾回收不会回收该对象,如果内存仍不足,再次回收时才会释放对象

  3. 软引用自身需要配合引用队列来释放

  4. 典型例子是反射数据

image-20210901111957328

弱引用(WeakReference)

  1. 例如:WeakReference a = new WeakReference(new A());

  2. 如果仅有弱引用引用该对象时,只要发生垃圾回收,就会释放该对象

  3. 弱引用自身需要配合引用队列来释放

  4. 典型例子是 ThreadLocalMap 中的 Entry 对象

image-20210901112107707

虚引用(PhantomReference)

  1. 例如: PhantomReference a = new PhantomReference(new A(), referenceQueue);

  2. 必须配合引用队列一起使用,当虚引用所引用的对象被回收时,由 Reference Handler 线程将虚引用对象入队,这样就可以知道哪些对象被回收,从而对它们关联的资源做进一步处理

  3. 典型例子是 Cleaner 释放 DirectByteBuffer 关联的直接内存

image-20210901112157901

代码说明

  • day03.reference.TestPhantomReference - 演示虚引用的基本用法
  • day03.reference.TestWeakReference - 模拟 ThreadLocalMap, 采用引用队列释放 entry 内存

7. finalize

要求

  • 掌握 finalize 的工作原理与缺点

finalize

  • 它是 Object 中的一个方法,如果子类重写它,垃圾回收时此方法会被调用,可以在其中进行资源释放和清理工作
  • 将资源释放和清理放在 finalize 方法中非常不好,非常影响性能,严重时甚至会引起 OOM,从 Java9 开始就被标注为 @Deprecated,不建议被使用了

finalize 原理

  1. 对 finalize 方法进行处理的核心逻辑位于 java.lang.ref.Finalizer 类中,它包含了名为 unfinalized 的静态变量(双向链表结构),Finalizer 也可被视为另一种引用对象(地位与软、弱、虚相当,只是不对外,无法直接使用)
  2. 当重写了 finalize 方法的对象,在构造方法调用之时,JVM 都会将其包装成一个 Finalizer 对象,并加入 unfinalized 链表中

  1. Finalizer 类中还有另一个重要的静态变量,即 ReferenceQueue 引用队列,刚开始它是空的。当狗对象可以被当作垃圾回收时,就会把这些狗对象对应的 Finalizer 对象加入此引用队列
  2. 但此时 Dog 对象还没法被立刻回收,因为 unfinalized -> Finalizer 这一引用链还在引用它嘛,为的是【先别着急回收啊,等我调完 finalize 方法,再回收】
  3. FinalizerThread 线程会从 ReferenceQueue 中逐一取出每个 Finalizer 对象,把它们从链表断开并真正调用 finallize 方法

  1. 由于整个 Finalizer 对象已经从 unfinalized 链表中断开,这样没谁能引用到它和狗对象,所以下次 gc 时就被回收了

finalize 缺点

  • 无法保证资源释放:FinalizerThread 是守护线程,代码很有可能没来得及执行完,线程就结束了
  • 无法判断是否发生错误:执行 finalize 方法时,会吞掉任意异常(Throwable)
  • 内存释放不及时:重写了 finalize 方法的对象在第一次被 gc 时,并不能及时释放它占用的内存,因为要等着 FinalizerThread 调用完 finalize,把它从 unfinalized 队列移除后,第二次 gc 时才能真正释放内存
  • 有的文章提到【Finalizer 线程会和我们的主线程进行竞争,不过由于它的优先级较低,获取到的CPU时间较少,因此它永远也赶不上主线程的步伐】这个显然是错误的,FinalizerThread 的优先级较普通线程更高,原因应该是 finalize 串行执行慢等原因综合导致

代码说明

  • day03.reference.TestFinalize - finalize 的测试代码

四、框架篇

1. Spring refresh 流程

要求

  • 掌握 refresh 的 12 个步骤

Spring refresh 概述

refresh 是 AbstractApplicationContext 中的一个方法,负责初始化 ApplicationContext 容器,容器必须调用 refresh 才能正常工作。它的内部主要会调用 12 个方法,我们把它们称为 refresh 的 12 个步骤:

  1. prepareRefresh

  2. obtainFreshBeanFactory

  3. prepareBeanFactory

  4. postProcessBeanFactory

  5. invokeBeanFactoryPostProcessors

  6. registerBeanPostProcessors

  7. initMessageSource

  8. initApplicationEventMulticaster

  9. onRefresh

  10. registerListeners

  11. finishBeanFactoryInitialization

  12. finishRefresh

功能分类

  • 1 为准备环境

  • 2 3 4 5 6 为准备 BeanFactory

  • 7 8 9 10 12 为准备 ApplicationContext

  • 11 为初始化 BeanFactory 中非延迟单例 bean

1. prepareRefresh

  • 这一步创建和准备了 Environment 对象,它作为 ApplicationContext 的一个成员变量

  • Environment 对象的作用之一是为后续 @Value,值注入时提供键值

  • Environment 分成三个主要部分

    • systemProperties - 保存 java 环境键值
    • systemEnvironment - 保存系统环境键值
    • 自定义 PropertySource - 保存自定义键值,例如来自于 *.properties 文件的键值

2. obtainFreshBeanFactory

  • 这一步获取(或创建) BeanFactory,它也是作为 ApplicationContext 的一个成员变量
  • BeanFactory 的作用是负责 bean 的创建、依赖注入和初始化,bean 的各项特征由 BeanDefinition 定义
    • BeanDefinition 作为 bean 的设计蓝图,规定了 bean 的特征,如单例多例、依赖关系、初始销毁方法等
    • BeanDefinition 的来源有多种多样,可以是通过 xml 获得、配置类获得、组件扫描获得,也可以是编程添加
  • 所有的 BeanDefinition 会存入 BeanFactory 中的 beanDefinitionMap 集合

3. prepareBeanFactory

  • 这一步会进一步完善 BeanFactory,为它的各项成员变量赋值
  • beanExpressionResolver 用来解析 SpEL,常见实现为 StandardBeanExpressionResolver
  • propertyEditorRegistrars 会注册类型转换器
    • 它在这里使用了 ResourceEditorRegistrar 实现类
    • 并应用 ApplicationContext 提供的 Environment 完成 ${ } 解析
  • registerResolvableDependency 来注册 beanFactory 以及 ApplicationContext,让它们也能用于依赖注入
  • beanPostProcessors 是 bean 后处理器集合,会工作在 bean 的生命周期各个阶段,此处会添加两个:
    • ApplicationContextAwareProcessor 用来解析 Aware 接口
    • ApplicationListenerDetector 用来识别容器中 ApplicationListener 类型的 bean

4. postProcessBeanFactory

  • 这一步是空实现,留给子类扩展。

    • 一般 Web 环境的 ApplicationContext 都要利用它注册新的 Scope,完善 Web 下的 BeanFactory
  • 这里体现的是模板方法设计模式

5. invokeBeanFactoryPostProcessors

  • 这一步会调用 beanFactory 后处理器
  • beanFactory 后处理器,充当 beanFactory 的扩展点,可以用来补充或修改 BeanDefinition
  • 常见的 beanFactory 后处理器有
    • ConfigurationClassPostProcessor – 解析 @Configuration、@Bean、@Import、@PropertySource 等
    • PropertySourcesPlaceHolderConfigurer – 替换 BeanDefinition 中的 ${ }
    • MapperScannerConfigurer – 补充 Mapper 接口对应的 BeanDefinition

6. registerBeanPostProcessors

  • 这一步是继续从 beanFactory 中找出 bean 后处理器,添加至 beanPostProcessors 集合中
  • bean 后处理器,充当 bean 的扩展点,可以工作在 bean 的实例化、依赖注入、初始化阶段,常见的有:
    • AutowiredAnnotationBeanPostProcessor 功能有:解析 @Autowired,@Value 注解
    • CommonAnnotationBeanPostProcessor 功能有:解析 @Resource,@PostConstruct,@PreDestroy
    • AnnotationAwareAspectJAutoProxyCreator 功能有:为符合切点的目标 bean 自动创建代理

7. initMessageSource

  • 这一步是为 ApplicationContext 添加 messageSource 成员,实现国际化功能
  • 去 beanFactory 内找名为 messageSource 的 bean,如果没有,则提供空的 MessageSource 实现

8. initApplicationContextEventMulticaster

  • 这一步为 ApplicationContext 添加事件广播器成员,即 applicationContextEventMulticaster
  • 它的作用是发布事件给监听器
  • 去 beanFactory 找名为 applicationEventMulticaster 的 bean 作为事件广播器,若没有,会创建默认的事件广播器
  • 之后就可以调用 ApplicationContext.publishEvent(事件对象) 来发布事件

9. onRefresh

  • 这一步是空实现,留给子类扩展

    • SpringBoot 中的子类在这里准备了 WebServer,即内嵌 web 容器
  • 体现的是模板方法设计模式

10. registerListeners

  • 这一步会从多种途径找到事件监听器,并添加至 applicationEventMulticaster
  • 事件监听器顾名思义,用来接收事件广播器发布的事件,有如下来源
    • 事先编程添加的
    • 来自容器中的 bean
    • 来自于 @EventListener 的解析
  • 要实现事件监听器,只需要实现 ApplicationListener 接口,重写其中 onApplicationEvent(E e) 方法即可

11. finishBeanFactoryInitialization

  • 这一步会将 beanFactory 的成员补充完毕,并初始化所有非延迟单例 bean
  • conversionService 也是一套转换机制,作为对 PropertyEditor 的补充
  • embeddedValueResolvers 即内嵌值解析器,用来解析 @Value 中的 ${ },借用的是 Environment 的功能
  • singletonObjects 即单例池,缓存所有单例对象
    • 对象的创建都分三个阶段,每一阶段都有不同的 bean 后处理器参与进来,扩展功能

12. finishRefresh

  • 这一步会为 ApplicationContext 添加 lifecycleProcessor 成员,用来控制容器内需要生命周期管理的 bean
  • 如果容器中有名称为 lifecycleProcessor 的 bean 就用它,否则创建默认的生命周期管理器
  • 准备好生命周期管理器,就可以实现
    • 调用 context 的 start,即可触发所有实现 LifeCycle 接口 bean 的 start
    • 调用 context 的 stop,即可触发所有实现 LifeCycle 接口 bean 的 stop
  • 发布 ContextRefreshed 事件,整个 refresh 执行完成

2. Spring bean 生命周期

要求

  • 掌握 Spring bean 的生命周期

bean 生命周期 概述

bean 的生命周期从调用 beanFactory 的 getBean 开始,到这个 bean 被销毁,可以总结为以下七个阶段:

  1. 处理名称,检查缓存
  2. 处理父子容器
  3. 处理 dependsOn
  4. 选择 scope 策略
  5. 创建 bean
  6. 类型转换处理
  7. 销毁 bean

注意

  • 划分的阶段和名称并不重要,重要的是理解整个过程中做了哪些事情

1. 处理名称,检查缓存

  • 这一步会处理别名,将别名解析为实际名称
  • 对 FactoryBean 也会特殊处理,如果以 & 开头表示要获取 FactoryBean 本身,否则表示要获取其产品
  • 这里针对单例对象会检查一级、二级、三级缓存
    • singletonFactories 三级缓存,存放单例工厂对象
    • earlySingletonObjects 二级缓存,存放单例工厂的产品对象
      • 如果发生循环依赖,产品是代理;无循环依赖,产品是原始对象
    • singletonObjects 一级缓存,存放单例成品对象

2. 处理父子容器

  • 如果当前容器根据名字找不到这个 bean,此时若父容器存在,则执行父容器的 getBean 流程
  • 父子容器的 bean 名称可以重复

3. 处理 dependsOn

  • 如果当前 bean 有通过 dependsOn 指定了非显式依赖的 bean,这一步会提前创建这些 dependsOn 的 bean
  • 所谓非显式依赖,就是指两个 bean 之间不存在直接依赖关系,但需要控制它们的创建先后顺序

4. 选择 scope 策略

  • 对于 singleton scope,首先到单例池去获取 bean,如果有则直接返回,没有再进入创建流程
  • 对于 prototype scope,每次都会进入创建流程
  • 对于自定义 scope,例如 request,首先到 request 域获取 bean,如果有则直接返回,没有再进入创建流程

5.1 创建 bean - 创建 bean 实例

要点 总结
有自定义 TargetSource 的情况 由 AnnotationAwareAspectJAutoProxyCreator 创建代理返回
Supplier 方式创建 bean 实例 为 Spring 5.0 新增功能,方便编程方式创建 bean 实例
FactoryMethod 方式 创建 bean 实例 ① 分成静态工厂与实例工厂;② 工厂方法若有参数,需要对工厂方法参数进行解析,利用 resolveDependency;③ 如果有多个工厂方法候选者,还要进一步按权重筛选
AutowiredAnnotationBeanPostProcessor ① 优先选择带 @Autowired 注解的构造;② 若有唯一的带参构造,也会入选
mbd.getPreferredConstructors 选择所有公共构造,这些构造之间按权重筛选
采用默认构造 如果上面的后处理器和 BeanDefiniation 都没找到构造,采用默认构造,即使是私有的

5.2 创建 bean - 依赖注入

要点 总结
AutowiredAnnotationBeanPostProcessor 识别 @Autowired 及 @Value 标注的成员,封装为 InjectionMetadata 进行依赖注入
CommonAnnotationBeanPostProcessor 识别 @Resource 标注的成员,封装为 InjectionMetadata 进行依赖注入
resolveDependency 用来查找要装配的值,可以识别:① Optional;② ObjectFactory 及 ObjectProvider;③ @Lazy 注解;④ @Value 注解(${ }, #{ }, 类型转换);⑤ 集合类型(Collection,Map,数组等);⑥ 泛型和 @Qualifier(用来区分类型歧义);⑦ primary 及名字匹配(用来区分类型歧义)
AUTOWIRE_BY_NAME 根据成员名字找 bean 对象,修改 mbd 的 propertyValues,不会考虑简单类型的成员
AUTOWIRE_BY_TYPE 根据成员类型执行 resolveDependency 找到依赖注入的值,修改 mbd 的 propertyValues
applyPropertyValues 根据 mbd 的 propertyValues 进行依赖注入(即xml中 `<property name ref

5.3 创建 bean - 初始化

要点 总结
内置 Aware 接口的装配 包括 BeanNameAware,BeanFactoryAware 等
扩展 Aware 接口的装配 由 ApplicationContextAwareProcessor 解析,执行时机在 postProcessBeforeInitialization
@PostConstruct 由 CommonAnnotationBeanPostProcessor 解析,执行时机在 postProcessBeforeInitialization
InitializingBean 通过接口回调执行初始化
initMethod 根据 BeanDefinition 得到的初始化方法执行初始化,即 <bean init-method> 或 @Bean(initMethod)
创建 aop 代理 由 AnnotationAwareAspectJAutoProxyCreator 创建,执行时机在 postProcessAfterInitialization

5.4 创建 bean - 注册可销毁 bean

在这一步判断并登记可销毁 bean

  • 判断依据

    • 如果实现了 DisposableBean 或 AutoCloseable 接口,则为可销毁 bean
    • 如果自定义了 destroyMethod,则为可销毁 bean
    • 如果采用 @Bean 没有指定 destroyMethod,则采用自动推断方式获取销毁方法名(close,shutdown)
    • 如果有 @PreDestroy 标注的方法
  • 存储位置
    • singleton scope 的可销毁 bean 会存储于 beanFactory 的成员当中
    • 自定义 scope 的可销毁 bean 会存储于对应的域对象当中
    • prototype scope 不会存储,需要自己找到此对象销毁
  • 存储时都会封装为 DisposableBeanAdapter 类型对销毁方法的调用进行适配

6. 类型转换处理

  • 如果 getBean 的 requiredType 参数与实际得到的对象类型不同,会尝试进行类型转换

7. 销毁 bean

  • 销毁时机

    • singleton bean 的销毁在 ApplicationContext.close 时,此时会找到所有 DisposableBean 的名字,逐一销毁
    • 自定义 scope bean 的销毁在作用域对象生命周期结束时
    • prototype bean 的销毁可以通过自己手动调用 AutowireCapableBeanFactory.destroyBean 方法执行销毁
  • 同一 bean 中不同形式销毁方法的调用次序
    • 优先后处理器销毁,即 @PreDestroy
    • 其次 DisposableBean 接口销毁
    • 最后 destroyMethod 销毁(包括自定义名称,推断名称,AutoCloseable 接口 多选一)

3. Spring bean 循环依赖

要求

  • 掌握单例 set 方式循环依赖的原理
  • 掌握其它循环依赖的解决方法

循环依赖的产生

  • 首先要明白,bean 的创建要遵循一定的步骤,必须是创建、注入、初始化三步,这些顺序不能乱

image-20210903085238916

  • set 方法(包括成员变量)的循环依赖如图所示

    • 可以在【a 创建】和【a set 注入 b】之间加入 b 的整个流程来解决

    • 【b set 注入 a】 时可以成功,因为之前 a 的实例已经创建完毕

    • a 的顺序,及 b 的顺序都能得到保障

image-20210903085454603

  • 构造方法的循环依赖如图所示,显然无法用前面的方法解决

image-20210903085906315

构造循环依赖的解决

  • 思路1

    • a 注入 b 的代理对象,这样能够保证 a 的流程走通
    • 后续需要用到 b 的真实对象时,可以通过代理间接访问

image-20210903091627659

  • 思路2

    • a 注入 b 的工厂对象,让 b 的实例创建被推迟,这样能够保证 a 的流程先走通
    • 后续需要用到 b 的真实对象时,再通过 ObjectFactory 工厂间接访问

image-20210903091743366

  • 示例1:用 @Lazy 为构造方法参数生成代理
public class App60_1 {static class A {private static final Logger log = LoggerFactory.getLogger("A");private B b;public A(@Lazy B b) {log.debug("A(B b) {}", b.getClass());this.b = b;}@PostConstructpublic void init() {log.debug("init()");}}static class B {private static final Logger log = LoggerFactory.getLogger("B");private A a;public B(A a) {log.debug("B({})", a);this.a = a;}@PostConstructpublic void init() {log.debug("init()");}}public static void main(String[] args) {GenericApplicationContext context = new GenericApplicationContext();context.registerBean("a", A.class);context.registerBean("b", B.class);AnnotationConfigUtils.registerAnnotationConfigProcessors(context.getDefaultListableBeanFactory());context.refresh();System.out.println();}
}
  • 示例2:用 ObjectProvider 延迟依赖对象的创建
public class App60_2 {static class A {private static final Logger log = LoggerFactory.getLogger("A");private ObjectProvider<B> b;public A(ObjectProvider<B> b) {log.debug("A({})", b);this.b = b;}@PostConstructpublic void init() {log.debug("init()");}}static class B {private static final Logger log = LoggerFactory.getLogger("B");private A a;public B(A a) {log.debug("B({})", a);this.a = a;}@PostConstructpublic void init() {log.debug("init()");}}public static void main(String[] args) {GenericApplicationContext context = new GenericApplicationContext();context.registerBean("a", A.class);context.registerBean("b", B.class);AnnotationConfigUtils.registerAnnotationConfigProcessors(context.getDefaultListableBeanFactory());context.refresh();System.out.println(context.getBean(A.class).b.getObject());System.out.println(context.getBean(B.class));}
}
  • 示例3:用 @Scope 产生代理
public class App60_3 {public static void main(String[] args) {GenericApplicationContext context = new GenericApplicationContext();ClassPathBeanDefinitionScanner scanner = new ClassPathBeanDefinitionScanner(context.getDefaultListableBeanFactory());scanner.scan("com.itheima.app60.sub");context.refresh();System.out.println();}
}
@Component
class A {private static final Logger log = LoggerFactory.getLogger("A");private B b;public A(B b) {log.debug("A(B b) {}", b.getClass());this.b = b;}@PostConstructpublic void init() {log.debug("init()");}
}
@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)
@Component
class B {private static final Logger log = LoggerFactory.getLogger("B");private A a;public B(A a) {log.debug("B({})", a);this.a = a;}@PostConstructpublic void init() {log.debug("init()");}
}
  • 示例4:用 Provider 接口解决,原理上与 ObjectProvider 一样,Provider 接口是独立的 jar 包,需要加入依赖
<dependency><groupId>javax.inject</groupId><artifactId>javax.inject</artifactId><version>1</version>
</dependency>
public class App60_4 {static class A {private static final Logger log = LoggerFactory.getLogger("A");private Provider<B> b;public A(Provider<B> b) {log.debug("A({}})", b);this.b = b;}@PostConstructpublic void init() {log.debug("init()");}}static class B {private static final Logger log = LoggerFactory.getLogger("B");private A a;public B(A a) {log.debug("B({}})", a);this.a = a;}@PostConstructpublic void init() {log.debug("init()");}}public static void main(String[] args) {GenericApplicationContext context = new GenericApplicationContext();context.registerBean("a", A.class);context.registerBean("b", B.class);AnnotationConfigUtils.registerAnnotationConfigProcessors(context.getDefaultListableBeanFactory());context.refresh();System.out.println(context.getBean(A.class).b.get());System.out.println(context.getBean(B.class));}
}

解决 set 循环依赖的原理

一级缓存

image-20210903100752165

作用是保证单例对象仅被创建一次

  • 第一次走 getBean("a") 流程后,最后会将成品 a 放入 singletonObjects 一级缓存
  • 后续再走 getBean("a") 流程时,先从一级缓存中找,这时已经有成品 a,就无需再次创建

一级缓存与循环依赖

image-20210903100914140

一级缓存无法解决循环依赖问题,分析如下

  • 无论是获取 bean a 还是获取 bean b,走的方法都是同一个 getBean 方法,假设先走 getBean("a")
  • 当 a 的实例对象创建,接下来执行 a.setB() 时,需要走 getBean("b") 流程,红色箭头 1
  • 当 b 的实例对象创建,接下来执行 b.setA() 时,又回到了 getBean("a") 的流程,红色箭头 2
  • 但此时 singletonObjects 一级缓存内没有成品的 a,陷入了死循环

二级缓存

image-20210903101849924

解决思路如下:

  • 再增加一个 singletonFactories 缓存
  • 在依赖注入前,即 a.setB() 以及 b.setA() 将 a 及 b 的半成品对象(未完成依赖注入和初始化)放入此缓存
  • 执行依赖注入时,先看看 singletonFactories 缓存中是否有半成品的对象,如果有拿来注入,顺利走完流程

对于上面的图

  • a = new A() 执行之后就会把这个半成品的 a 放入 singletonFactories 缓存,即 factories.put(a)
  • 接下来执行 a.setB(),走入 getBean("b") 流程,红色箭头 3
  • 这回再执行到 b.setA() 时,需要一个 a 对象,有没有呢?有!
  • factories.get() 在 singletonFactories 缓存中就可以找到,红色箭头 4 和 5
  • b 的流程能够顺利走完,将 b 成品放入 singletonObject 一级缓存,返回到 a 的依赖注入流程,红色箭头 6

二级缓存与创建代理

image-20210903103030877

二级缓存无法正确处理循环依赖并且包含有代理创建的场景,分析如下

  • spring 默认要求,在 a.init 完成之后才能创建代理 pa = proxy(a)
  • 由于 a 的代理创建时机靠后,在执行 factories.put(a) 向 singletonFactories 中放入的还是原始对象
  • 接下来箭头 3、4、5 这几步 b 对象拿到和注入的都是原始对象

三级缓存

简单分析的话,只需要将代理的创建时机放在依赖注入之前即可,但 spring 仍然希望代理的创建时机在 init 之后,只有出现循环依赖时,才会将代理的创建时机提前。所以解决思路稍显复杂:

  • 图中 factories.put(fa) 放入的既不是原始对象,也不是代理对象而是工厂对象 fa
  • 当检查出发生循环依赖时,fa 的产品就是代理 pa,没有发生循环依赖,fa 的产品是原始对象 a
  • 假设出现了循环依赖,拿到了 singletonFactories 中的工厂对象,通过在依赖注入前获得了 pa,红色箭头 5
  • 这回 b.setA() 注入的就是代理对象,保证了正确性,红色箭头 7
  • 还需要把 pa 存入新加的 earlySingletonObjects 缓存,红色箭头 6
  • a.init 完成后,无需二次创建代理,从哪儿找到 pa 呢?earlySingletonObjects 已经缓存,蓝色箭头 9

当成品对象产生,放入 singletonObject 后,singletonFactories 和 earlySingletonObjects 就中的对象就没有用处,清除即可

4. Spring 事务失效

要求

  • 掌握事务失效的八种场景

1. 抛出检查异常导致事务不能正确回滚

@Service
public class Service1 {@Autowiredprivate AccountMapper accountMapper;@Transactionalpublic void transfer(int from, int to, int amount) throws FileNotFoundException {int fromBalance = accountMapper.findBalanceBy(from);if (fromBalance - amount >= 0) {accountMapper.update(from, -1 * amount);new FileInputStream("aaa");accountMapper.update(to, amount);}}
}
  • 原因:Spring 默认只会回滚非检查异常

  • 解法:配置 rollbackFor 属性

    • @Transactional(rollbackFor = Exception.class)

2. 业务方法内自己 try-catch 异常导致事务不能正确回滚

@Service
public class Service2 {@Autowiredprivate AccountMapper accountMapper;@Transactional(rollbackFor = Exception.class)public void transfer(int from, int to, int amount)  {try {int fromBalance = accountMapper.findBalanceBy(from);if (fromBalance - amount >= 0) {accountMapper.update(from, -1 * amount);new FileInputStream("aaa");accountMapper.update(to, amount);}} catch (FileNotFoundException e) {e.printStackTrace();}}
}
  • 原因:事务通知只有捉到了目标抛出的异常,才能进行后续的回滚处理,如果目标自己处理掉异常,事务通知无法知悉

  • 解法1:异常原样抛出

    • 在 catch 块添加 throw new RuntimeException(e);
  • 解法2:手动设置 TransactionStatus.setRollbackOnly()

    • 在 catch 块添加 TransactionInterceptor.currentTransactionStatus().setRollbackOnly();

3. aop 切面顺序导致导致事务不能正确回滚

@Service
public class Service3 {@Autowiredprivate AccountMapper accountMapper;@Transactional(rollbackFor = Exception.class)public void transfer(int from, int to, int amount) throws FileNotFoundException {int fromBalance = accountMapper.findBalanceBy(from);if (fromBalance - amount >= 0) {accountMapper.update(from, -1 * amount);new FileInputStream("aaa");accountMapper.update(to, amount);}}
}
@Aspect
public class MyAspect {@Around("execution(* transfer(..))")public Object around(ProceedingJoinPoint pjp) throws Throwable {LoggerUtils.get().debug("log:{}", pjp.getTarget());try {return pjp.proceed();} catch (Throwable e) {e.printStackTrace();return null;}}
}
  • 原因:事务切面优先级最低,但如果自定义的切面优先级和他一样,则还是自定义切面在内层,这时若自定义切面没有正确抛出异常…

  • 解法1、2:同情况2 中的解法:1、2

  • 解法3:调整切面顺序,在 MyAspect 上添加 @Order(Ordered.LOWEST_PRECEDENCE - 1) (不推荐)

4. 非 public 方法导致的事务失效

@Service
public class Service4 {@Autowiredprivate AccountMapper accountMapper;@Transactionalvoid transfer(int from, int to, int amount) throws FileNotFoundException {int fromBalance = accountMapper.findBalanceBy(from);if (fromBalance - amount >= 0) {accountMapper.update(from, -1 * amount);accountMapper.update(to, amount);}}
}
  • 原因:Spring 为方法创建代理、添加事务通知、前提条件都是该方法是 public 的

  • 解法1:改为 public 方法

  • 解法2:添加 bean 配置如下(不推荐)

@Bean
public TransactionAttributeSource transactionAttributeSource() {return new AnnotationTransactionAttributeSource(false);
}

5. 父子容器导致的事务失效

package day04.tx.app.service;// ...@Service
public class Service5 {@Autowiredprivate AccountMapper accountMapper;@Transactional(rollbackFor = Exception.class)public void transfer(int from, int to, int amount) throws FileNotFoundException {int fromBalance = accountMapper.findBalanceBy(from);if (fromBalance - amount >= 0) {accountMapper.update(from, -1 * amount);accountMapper.update(to, amount);}}
}

控制器类

package day04.tx.app.controller;// ...@Controller
public class AccountController {@Autowiredpublic Service5 service;public void transfer(int from, int to, int amount) throws FileNotFoundException {service.transfer(from, to, amount);}
}

App 配置类

@Configuration
@ComponentScan("day04.tx.app.service")
@EnableTransactionManagement
// ...
public class AppConfig {// ... 有事务相关配置
}

Web 配置类

@Configuration
@ComponentScan("day04.tx.app")
// ...
public class WebConfig {// ... 无事务配置
}

现在配置了父子容器,WebConfig 对应子容器,AppConfig 对应父容器,发现事务依然失效

  • 原因:子容器扫描范围过大,把未加事务配置的 service 扫描进来

  • 解法1:各扫描各的,不要图简便

  • 解法2:不要用父子容器,所有 bean 放在同一容器

6. 调用本类方法导致传播行为失效

@Service
public class Service6 {@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)public void foo() throws FileNotFoundException {LoggerUtils.get().debug("foo");bar();}@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)public void bar() throws FileNotFoundException {LoggerUtils.get().debug("bar");}
}
  • 原因:本类方法调用不经过代理,因此无法增强

  • 解法1:依赖注入自己(代理)来调用

  • 解法2:通过 AopContext 拿到代理对象,来调用

  • 解法3:通过 CTW,LTW 实现功能增强

解法1

@Service
public class Service6 {@Autowiredprivate Service6 proxy; // 本质上是一种循环依赖@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)public void foo() throws FileNotFoundException {LoggerUtils.get().debug("foo");System.out.println(proxy.getClass());proxy.bar();}@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)public void bar() throws FileNotFoundException {LoggerUtils.get().debug("bar");}
}

解法2,还需要在 AppConfig 上添加 @EnableAspectJAutoProxy(exposeProxy = true)

@Service
public class Service6 {@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)public void foo() throws FileNotFoundException {LoggerUtils.get().debug("foo");((Service6) AopContext.currentProxy()).bar();}@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)public void bar() throws FileNotFoundException {LoggerUtils.get().debug("bar");}
}

7. @Transactional 没有保证原子行为

@Service
public class Service7 {private static final Logger logger = LoggerFactory.getLogger(Service7.class);@Autowiredprivate AccountMapper accountMapper;@Transactional(rollbackFor = Exception.class)public void transfer(int from, int to, int amount) {int fromBalance = accountMapper.findBalanceBy(from);logger.debug("更新前查询余额为: {}", fromBalance);if (fromBalance - amount >= 0) {accountMapper.update(from, -1 * amount);accountMapper.update(to, amount);}}public int findBalance(int accountNo) {return accountMapper.findBalanceBy(accountNo);}
}

上面的代码实际上是有 bug 的,假设 from 余额为 1000,两个线程都来转账 1000,可能会出现扣减为负数的情况

  • 原因:事务的原子性仅涵盖 insert、update、delete、select … for update 语句,select 方法并不阻塞

image-20210903120436365

  • 如上图所示,红色线程和蓝色线程的查询都发生在扣减之前,都以为自己有足够的余额做扣减

8. @Transactional 方法导致的 synchronized 失效

针对上面的问题,能否在方法上加 synchronized 锁来解决呢?

@Service
public class Service7 {private static final Logger logger = LoggerFactory.getLogger(Service7.class);@Autowiredprivate AccountMapper accountMapper;@Transactional(rollbackFor = Exception.class)public synchronized void transfer(int from, int to, int amount) {int fromBalance = accountMapper.findBalanceBy(from);logger.debug("更新前查询余额为: {}", fromBalance);if (fromBalance - amount >= 0) {accountMapper.update(from, -1 * amount);accountMapper.update(to, amount);}}public int findBalance(int accountNo) {return accountMapper.findBalanceBy(accountNo);}
}

答案是不行,原因如下:

  • synchronized 保证的仅是目标方法的原子性,环绕目标方法的还有 commit 等操作,它们并未处于 sync 块内
  • 可以参考下图发现,蓝色线程的查询只要在红色线程提交之前执行,那么依然会查询到有 1000 足够余额来转账

  • 解法1:synchronized 范围应扩大至代理方法调用

  • 解法2:使用 select … for update 替换 select

5. Spring MVC 执行流程

要求

  • 掌握 Spring MVC 的执行流程
  • 了解 Spring MVC 的重要组件的作用

概要

我把整个流程分成三个阶段

  • 准备阶段
  • 匹配阶段
  • 执行阶段

准备阶段

  1. 在 Web 容器第一次用到 DispatcherServlet 的时候,会创建其对象并执行 init 方法

  2. init 方法内会创建 Spring Web 容器,并调用容器 refresh 方法

  3. refresh 过程中会创建并初始化 SpringMVC 中的重要组件, 例如 MultipartResolver,HandlerMapping,HandlerAdapter,HandlerExceptionResolver、ViewResolver 等

  4. 容器初始化后,会将上一步初始化好的重要组件,赋值给 DispatcherServlet 的成员变量,留待后用

image-20210903140657163

匹配阶段

  1. 用户发送的请求统一到达前端控制器 DispatcherServlet

  2. DispatcherServlet 遍历所有 HandlerMapping ,找到与路径匹配的处理器

    ① HandlerMapping 有多个,每个 HandlerMapping 会返回不同的处理器对象,谁先匹配,返回谁的处理器。其中能识别 @RequestMapping 的优先级最高

    ② 对应 @RequestMapping 的处理器是 HandlerMethod,它包含了控制器对象和控制器方法信息

    ③ 其中路径与处理器的映射关系在 HandlerMapping 初始化时就会建立好

image-20210903141017502

  1. 将 HandlerMethod 连同匹配到的拦截器,生成调用链对象 HandlerExecutionChain 返回

image-20210903141124911

  1. 遍历HandlerAdapter 处理器适配器,找到能处理 HandlerMethod 的适配器对象,开始调用

image-20210903141204799

调用阶段

  1. 执行拦截器 preHandle

image-20210903141445870

  1. 由 HandlerAdapter 调用 HandlerMethod

    ① 调用前处理不同类型的参数

    ② 调用后处理不同类型的返回值

image-20210903141658199

  1. 第 2 步没有异常

    ① 返回 ModelAndView

    ② 执行拦截器 postHandle 方法

    ③ 解析视图,得到 View 对象,进行视图渲染

image-20210903141749830

  1. 第 2 步有异常,进入 HandlerExceptionResolver 异常处理流程

image-20210903141844185

  1. 最后都会执行拦截器的 afterCompletion 方法

  2. 如果控制器方法标注了 @ResponseBody 注解,则在第 2 步,就会生成 json 结果,并标记 ModelAndView 已处理,这样就不会执行第 3 步的视图渲染

6. Spring 注解

要求

  • 掌握 Spring 常见注解

提示

  • 注解的详细列表请参考:面试题-spring-注解.xmind
  • 下面列出了视频中重点提及的注解,考虑到大部分注解同学们已经比较熟悉了,仅对个别的作简要说明

事务注解

  • @EnableTransactionManagement,会额外加载 4 个 bean

    • BeanFactoryTransactionAttributeSourceAdvisor 事务切面类
    • TransactionAttributeSource 用来解析事务属性
    • TransactionInterceptor 事务拦截器
    • TransactionalEventListenerFactory 事务监听器工厂
  • @Transactional

核心

  • @Order

切面

  • @EnableAspectJAutoProxy

    • 会加载 AnnotationAwareAspectJAutoProxyCreator,它是一个 bean 后处理器,用来创建代理
    • 如果没有配置 @EnableAspectJAutoProxy,又需要用到代理(如事务)则会使用 InfrastructureAdvisorAutoProxyCreator 这个 bean 后处理器

组件扫描与配置类

  • @Component

  • @Controller

  • @Service

  • @Repository

  • @ComponentScan

  • @Conditional

  • @Configuration

    • 配置类其实相当于一个工厂, 标注 @Bean 注解的方法相当于工厂方法
    • @Bean 不支持方法重载, 如果有多个重载方法, 仅有一个能入选为工厂方法
    • @Configuration 默认会为标注的类生成代理, 其目的是保证 @Bean 方法相互调用时, 仍然能保证其单例特性
    • @Configuration 中如果含有 BeanFactory 后处理器, 则实例工厂方法会导致 MyConfig 提前创建, 造成其依赖注入失败,解决方法是改用静态工厂方法或直接为 @Bean 的方法参数依赖注入, 针对 Mapper 扫描可以改用注解方式
  • @Bean

  • @Import

    • 四种用法

      ① 引入单个 bean

      ② 引入一个配置类

      ③ 通过 Selector 引入多个类

      ④ 通过 beanDefinition 注册器

    • 解析规则

      • 同一配置类中, @Import 先解析 @Bean 后解析
      • 同名定义, 默认后面解析的会覆盖前面解析的
      • 不允许覆盖的情况下, 如何能够让 MyConfig(主配置类) 的配置优先? (虽然覆盖方式能解决)
      • 采用 DeferredImportSelector,因为它最后工作, 可以简单认为先解析 @Bean, 再 Import
  • @Lazy

    • 加在类上,表示此类延迟实例化、初始化
    • 加在方法参数上,此参数会以代理方式注入
  • @PropertySource

依赖注入

  • @Autowired
  • @Qualifier
  • @Value

mvc mapping

  • @RequestMapping,可以派生多个注解如 @GetMapping 等

mvc rest

  • @RequestBody
  • @ResponseBody,组合 @Controller => @RestController
  • @ResponseStatus

mvc 统一处理

  • @ControllerAdvice,组合 @ResponseBody => @RestControllerAdvice
  • @ExceptionHandler

mvc 参数

  • @PathVariable

mvc ajax

  • @CrossOrigin

boot auto

  • @SpringBootApplication
  • @EnableAutoConfiguration
  • @SpringBootConfiguration

boot condition

  • @ConditionalOnClass,classpath 下存在某个 class 时,条件才成立
  • @ConditionalOnMissingBean,beanFactory 内不存在某个 bean 时,条件才成立
  • @ConditionalOnProperty,配置文件中存在某个 property(键、值)时,条件才成立

boot properties

  • @ConfigurationProperties,会将当前 bean 的属性与配置文件中的键值进行绑定
  • @EnableConfigurationProperties,会添加两个较为重要的 bean
    • ConfigurationPropertiesBindingPostProcessor,bean 后处理器,在 bean 初始化前调用下面的 binder
    • ConfigurationPropertiesBinder,真正执行绑定操作

7. SpringBoot 自动配置原理

要求

  • 掌握 SpringBoot 自动配置原理

自动配置原理

@SpringBootConfiguration 是一个组合注解,由 @ComponentScan、@EnableAutoConfiguration 和 @SpringBootConfiguration 组成

  1. @SpringBootConfiguration 与普通 @Configuration 相比,唯一区别是前者要求整个 app 中只出现一次

  2. @ComponentScan

    • excludeFilters - 用来在组件扫描时进行排除,也会排除自动配置类
  3. @EnableAutoConfiguration 也是一个组合注解,由下面注解组成

    • @AutoConfigurationPackage – 用来记住扫描的起始包
    • @Import(AutoConfigurationImportSelector.class) 用来加载 META-INF/spring.factories 中的自动配置类

为什么不使用 @Import 直接引入自动配置类

有两个原因:

  1. 让主配置类和自动配置类变成了强耦合,主配置类不应该知道有哪些从属配置
  2. 直接用 @Import(自动配置类.class),引入的配置解析优先级较高,自动配置类的解析应该在主配置没提供时作为默认配置

因此,采用了 @Import(AutoConfigurationImportSelector.class)

  • AutoConfigurationImportSelector.class 去读取 META-INF/spring.factories 中的自动配置类,实现了弱耦合。
  • 另外 AutoConfigurationImportSelector.class 实现了 DeferredImportSelector 接口,让自动配置的解析晚于主配置的解析

8. Spring 中的设计模式

要求

  • 掌握 Spring 中常见的设计模式

1. Spring 中的 Singleton

请大家区分 singleton pattern 与 Spring 中的 singleton bean

  • 根据单例模式的目的 Ensure a class only has one instance, and provide a global point of access to it
  • 显然 Spring 中的 singleton bean 并非实现了单例模式,singleton bean 只能保证每个容器内,相同 id 的 bean 单实例
  • 当然 Spring 中也用到了单例模式,例如
    • org.springframework.transaction.TransactionDefinition#withDefaults
    • org.springframework.aop.TruePointcut#INSTANCE
    • org.springframework.aop.interceptor.ExposeInvocationInterceptor#ADVISOR
    • org.springframework.core.annotation.AnnotationAwareOrderComparator#INSTANCE
    • org.springframework.core.OrderComparator#INSTANCE

2. Spring 中的 Builder

定义 Separate the construction of a complex object from its representation so that the same construction process can create different representations

它的主要亮点有三处:

  1. 较为灵活的构建产品对象

  2. 在不执行最后 build 方法前,产品对象都不可用

  3. 构建过程采用链式调用,看起来比较爽

Spring 中体现 Builder 模式的地方:

  • org.springframework.beans.factory.support.BeanDefinitionBuilder

  • org.springframework.web.util.UriComponentsBuilder

  • org.springframework.http.ResponseEntity.HeadersBuilder

  • org.springframework.http.ResponseEntity.BodyBuilder

3. Spring 中的 Factory Method

定义 Define an interface for creating an object, but let subclasses decide which class to instantiate. Factory Method lets a class defer instantiation to subclasses

根据上面的定义,Spring 中的 ApplicationContext 与 BeanFactory 中的 getBean 都可以视为工厂方法,它隐藏了 bean (产品)的创建过程和具体实现

Spring 中其它工厂:

  • org.springframework.beans.factory.FactoryBean

  • @Bean 标注的静态方法及实例方法

  • ObjectFactory 及 ObjectProvider

前两种工厂主要封装第三方的 bean 的创建过程,后两种工厂可以推迟 bean 创建,解决循环依赖及单例注入多例等问题

4. Spring 中的 Adapter

定义 Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn’t otherwise because of incompatible interfaces

典型的实现有两处:

  • org.springframework.web.servlet.HandlerAdapter – 因为控制器实现有各种各样,比如有

    • 大家熟悉的 @RequestMapping 标注的控制器实现
    • 传统的基于 Controller 接口(不是 @Controller注解啊)的实现
    • 较新的基于 RouterFunction 接口的实现
    • 它们的处理方法都不一样,为了统一调用,必须适配为 HandlerAdapter 接口
  • org.springframework.beans.factory.support.DisposableBeanAdapter – 因为销毁方法多种多样,因此都要适配为 DisposableBean 来统一调用销毁方法

5. Spring 中的 Composite

定义 Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly

典型实现有:

  • org.springframework.web.method.support.HandlerMethodArgumentResolverComposite
  • org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite
  • org.springframework.web.servlet.handler.HandlerExceptionResolverComposite
  • org.springframework.web.servlet.view.ViewResolverComposite

composite 对象的作用是,将分散的调用集中起来,统一调用入口,它的特征是,与具体干活的实现实现同一个接口,当调用 composite 对象的接口方法时,其实是委托具体干活的实现来完成

6. Spring 中的 Decorator

定义 Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality

典型实现:

  • org.springframework.web.util.ContentCachingRequestWrapper

7. Spring 中的 Proxy

定义 Provide a surrogate or placeholder for another object to control access to it

装饰器模式注重的是功能增强,避免子类继承方式进行功能扩展,而代理模式更注重控制目标的访问

典型实现:

  • org.springframework.aop.framework.JdkDynamicAopProxy
  • org.springframework.aop.framework.ObjenesisCglibAopProxy

8. Spring 中的 Chain of Responsibility

定义 Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handles it

典型实现:

  • org.springframework.web.servlet.HandlerInterceptor

9. Spring 中的 Observer

定义 Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically

典型实现:

  • org.springframework.context.ApplicationListener
  • org.springframework.context.event.ApplicationEventMulticaster
  • org.springframework.context.ApplicationEvent

10. Spring 中的 Strategy

定义 Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it

典型实现:

  • org.springframework.beans.factory.support.InstantiationStrategy
  • org.springframework.core.annotation.MergedAnnotations.SearchStrategy
  • org.springframework.boot.autoconfigure.condition.SearchStrategy

11. Spring 中的 Template Method

定义 Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure

典型实现:

  • 大部分以 Template 命名的类,如 JdbcTemplate,TransactionTemplate

    • org.springframework.transaction.TransactionDefinition#withDefaults
    • org.springframework.aop.TruePointcut#INSTANCE
    • org.springframework.aop.interceptor.ExposeInvocationInterceptor#ADVISOR
    • org.springframework.core.annotation.AnnotationAwareOrderComparator#INSTANCE
    • org.springframework.core.OrderComparator#INSTANCE

2. Spring 中的 Builder

定义 Separate the construction of a complex object from its representation so that the same construction process can create different representations

它的主要亮点有三处:

  1. 较为灵活的构建产品对象

  2. 在不执行最后 build 方法前,产品对象都不可用

  3. 构建过程采用链式调用,看起来比较爽

Spring 中体现 Builder 模式的地方:

  • org.springframework.beans.factory.support.BeanDefinitionBuilder

  • org.springframework.web.util.UriComponentsBuilder

  • org.springframework.http.ResponseEntity.HeadersBuilder

  • org.springframework.http.ResponseEntity.BodyBuilder

3. Spring 中的 Factory Method

定义 Define an interface for creating an object, but let subclasses decide which class to instantiate. Factory Method lets a class defer instantiation to subclasses

根据上面的定义,Spring 中的 ApplicationContext 与 BeanFactory 中的 getBean 都可以视为工厂方法,它隐藏了 bean (产品)的创建过程和具体实现

Spring 中其它工厂:

  • org.springframework.beans.factory.FactoryBean

  • @Bean 标注的静态方法及实例方法

  • ObjectFactory 及 ObjectProvider

前两种工厂主要封装第三方的 bean 的创建过程,后两种工厂可以推迟 bean 创建,解决循环依赖及单例注入多例等问题

4. Spring 中的 Adapter

定义 Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn’t otherwise because of incompatible interfaces

典型的实现有两处:

  • org.springframework.web.servlet.HandlerAdapter – 因为控制器实现有各种各样,比如有

    • 大家熟悉的 @RequestMapping 标注的控制器实现
    • 传统的基于 Controller 接口(不是 @Controller注解啊)的实现
    • 较新的基于 RouterFunction 接口的实现
    • 它们的处理方法都不一样,为了统一调用,必须适配为 HandlerAdapter 接口
  • org.springframework.beans.factory.support.DisposableBeanAdapter – 因为销毁方法多种多样,因此都要适配为 DisposableBean 来统一调用销毁方法

5. Spring 中的 Composite

定义 Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly

典型实现有:

  • org.springframework.web.method.support.HandlerMethodArgumentResolverComposite
  • org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite
  • org.springframework.web.servlet.handler.HandlerExceptionResolverComposite
  • org.springframework.web.servlet.view.ViewResolverComposite

composite 对象的作用是,将分散的调用集中起来,统一调用入口,它的特征是,与具体干活的实现实现同一个接口,当调用 composite 对象的接口方法时,其实是委托具体干活的实现来完成

6. Spring 中的 Decorator

定义 Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality

典型实现:

  • org.springframework.web.util.ContentCachingRequestWrapper

7. Spring 中的 Proxy

定义 Provide a surrogate or placeholder for another object to control access to it

装饰器模式注重的是功能增强,避免子类继承方式进行功能扩展,而代理模式更注重控制目标的访问

典型实现:

  • org.springframework.aop.framework.JdkDynamicAopProxy
  • org.springframework.aop.framework.ObjenesisCglibAopProxy

8. Spring 中的 Chain of Responsibility

定义 Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handles it

典型实现:

  • org.springframework.web.servlet.HandlerInterceptor

9. Spring 中的 Observer

定义 Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically

典型实现:

  • org.springframework.context.ApplicationListener
  • org.springframework.context.event.ApplicationEventMulticaster
  • org.springframework.context.ApplicationEvent

10. Spring 中的 Strategy

定义 Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it

典型实现:

  • org.springframework.beans.factory.support.InstantiationStrategy
  • org.springframework.core.annotation.MergedAnnotations.SearchStrategy
  • org.springframework.boot.autoconfigure.condition.SearchStrategy

11. Spring 中的 Template Method

定义 Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure

典型实现:

  • 大部分以 Template 命名的类,如 JdbcTemplate,TransactionTemplate
  • 很多以 Abstract 命名的类,如 AbstractApplicationContext

2021黑马程序员Java面试宝典笔记(完整版)相关推荐

  1. 黑马程序员Java教程学习笔记(五)

    学习视频:https://www.bilibili.com/video/BV1Cv411372m 如侵权,请私信联系本人删除 文章目录 黑马程序员Java教程学习笔记(五) 日期时间:Date.Sim ...

  2. 黑马程序员Java教程学习笔记(三)

    学习视频:https://www.bilibili.com/video/BV1Cv411372m 如侵权,请私信联系本人删除 文章目录 黑马程序员Java教程学习笔记(三) 面向对象:设计对象.注意事 ...

  3. python3入门与进阶笔记_16_变量进阶 — 黑马程序员《Python入门教程完整版》笔记...

    变量进阶(理解) - 黑马程序员<Python入门教程完整版>笔记 目标变量的引用 可变和不可变类型 局部变量和全局变量 01. 变量的引用变量 和 数据 都是保存在 内存 中的 在 Py ...

  4. python设计游戏的背景_04_游戏背景 — 黑马程序员《Python入门教程完整版》笔记...

    游戏背景 黑马程序员<Python入门教程完整版>笔记 - 黑马程序员<Python入门教程完整版>笔记 目标背景交替滚动的思路确定 显示游戏背景 01. 背景交替滚动的思路确 ...

  5. java面试宝典超长完整版

    一. Java基础部分......................................................................................... ...

  6. 黑马程序员 JAVA基础学习笔记

    ------Java培训.Android培训.iOS培训..Net培训.期待与您交流! ------- ------小弟在因特网的小窝,祝愿所有的朋友身体健康------- 面向对象: 就是更加透明, ...

  7. java面试宝典2019_Java面试宝典2019完整版.doc

    Java面试宝典2019完整版.doc PAGE \* MERGEFORMAT2 Java面试宝典2019版 TOC \o "1-3" \h \z \u HYPERLINK \l ...

  8. 2023年黑马程序员Java学习路线图

    2023年Java学科免费学习资源放送40+套课程,超过600小时的内容! 在过去的一年里,为了帮助更多人线上充电,学会更多技能,黑马程序员举办了 150+ 场免费直播公开课,新增精品视频教程 80+ ...

  9. 黑马程序员_java自学学习笔记(八)----网络编程

    黑马程序员_java自学学习笔记(八)----网络编程 android培训. java培训.期待与您交流! 网络编程对于很多的初学者来说,都是很向往的一种编程技能,但是很多的初学者却因为很长一段时间无 ...

  10. 黑马程序员 JAVA WEB 第三节 MYSQL 约束

    这是阿锃总结的第三节黑马程序员JAVA WEB视频的MYSQL约束部分的笔记.希望可以帮助跟我一样正在学习Java web的同学们.我们一起进步.   b_d 若果有同学也想学习黑马程序员Java w ...

最新文章

  1. 高阶函数-lambda表达式
  2. arach Linux设置静态,大神面对win7系统archlinux静态网络配置错误造成无法上网的还原办法...
  3. linux安装java tar.gz_Linux(CentOS)安装java运行环境JDK1.8(.tar.gz)
  4. flink的TimeCharacteristic(转载)
  5. python三级联动菜单_VUE+element三级联动或树形菜单获取最后一项,并加入到表格中...
  6. 黑猿大叔-译文 | TensorFlow实现Batch Normalization
  7. python使用BytesIO或StringIO读写文件
  8. 【进阶修炼】——改善C#程序质量(9)
  9. TurboMail邮件服务器推动邮件领域的进一步发展
  10. python字符串转整数_Python连接字符串和整数
  11. php svn更改密码,svn 开启修改备注
  12. python中函数的返回值
  13. linux 光盘本地yum源,小凡带你搭建本地的光盘yum源
  14. 大批驱动管理软件(免安装版)
  15. 【Windows网络编程】完成端口IOCP原理及案例
  16. 什么是短信接口API
  17. 【高等数学】九种二次曲面及其方程
  18. win10锁屏壁纸提取保存
  19. android源生获取经纬度,Android获取GPS经纬度
  20. 水晶报表打印出错,未能加载文件或程序集“CrystalDecisions.CrystalReports.Engine, Version=10.5.3700.0

热门文章

  1. java 设置jframe大小_Java Swing JFrame设置大小
  2. sap代加工流程图_委外加工_SAP的两种典型委外处理方法
  3. 计算机音乐简谱网红歌曲,2020抖音网红歌曲40首 2020抖音最新歌曲100首BGM
  4. 【总结】最近写代码的思考与总结
  5. EPSG是什么?WKT是什么?SRID是什么?EPSG、WKT、SRID概念
  6. 税务会计实务【18】
  7. 笔记 GWAS 操作流程2-1:缺失质控
  8. 【3D建模】2020最好用的3款3D建模软件!新手入门必备建模软件!
  9. 资产配置那些事-标准普尔家庭资产象限图
  10. 服务机器人分类和发展趋势分析