62-Java-面试专题(1)排序+集合+设计模式

194 阅读16分钟

62-Java-面试专题(1)__基础-- 笔记

笔记内容来源与黑马程序员教学视频


Java-面试专题(1)

笔记中涉及资源

一、二分查找

①:代码实现

1. 流程

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

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

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

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

    • ① A[M]==T表示找到,返回中间索引
    • ② A[M>T,中间值右侧的其它元素都大于T,无需比较,中间索引左边去找,M-1设置为右边界,重新查找
    • ③ A[M]<T,中间值左侧的其它元素都小于T,无需比较,中间索引右边去找,M+1设置为左边界,重新查找
  • 当L>R时,表示没有找到,应结束循环

2. 代码实现

    /**
     * 数据准备初始化一个排好序的数组
     */
    public static int[] initArray(){
        Random random = new Random();
        // 创建一个数组,长度在10-20之间
        int len = random.nextInt(10) + 10;
        int[] array = new int[len];
        // 遍历添加数据
        for (int i = 0; i < array.length; i++) {
            array[i] = random.nextInt(100);
        }
        // 排序
        Arrays.sort(array);
        return array;
    }

    /**
     * 二分查找代码实现
     */
    public static void testBinarySearch() {
        int[] array = initArray();
        System.out.println(Arrays.toString(array));
        System.out.println("请输入您要查找的数据:");
        int number = scanner.nextInt();
        
        // 初始化 开头 结尾 中间的下标
        int start = 0, end = array.length -1, middle;
        while (start <= end) {
            middle = (start + end) >>1;
            if (array[middle] == number){
                System.out.println("您要查找的数字下标为:" + middle);
                return;
            }else if (array[middle] > number){
                end = middle - 1;
            }else {
                start = middle +1;
            }
        }
        System.err.println("您要查找的数字不存在!");
    }

    public static void main(String[] args) {
        testBinarySearch();
    }

3. 测试

在这里插入图片描述 在这里插入图片描述

②:解决整数溢出(方法一)

在这里插入图片描述

③:解决整数溢出(方法二)

在这里插入图片描述

③:选择题目

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

  • 偶数二分取中间靠左

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

4
  • 3.在已经的128个数组中二分查找一个数,需要比较的次数最多不超过多少次
7
  • 2n=128或128/2/2..直到1
  • 问题转化log^2 128,如果手边有计算器,用log10 128/log10 2
    • 是整数,则该整数即为最终结果
    • 是小数,则舍去小数部分,整数加一为最终结果

④:注意事项

  • 1.目前介绍的二分查找是以jdk中Arrays.binarySearch的实现作为讲解示范,后续选择题的解答思路也是以此为准
  • 2.但实际上,二分查找有诸多变体,一旦使用变体的实现代码,则左右边界的选取会有变化,进而会影响之前选择题的答案选择

二、冒泡排序

①:初步实现

    /**
     * 数据准备初始化一个数组
     */
    public static int[] initArray(){
        Random random = new Random();
        // 创建一个数组,长度在10-20之间
        int len = random.nextInt(10) + 5;
        int[] array = new int[len];
        // 遍历添加数据
        for (int i = 0; i < array.length; i++) {
            array[i] = random.nextInt(100);
        }
        return array;
    }

    public static void main(String[] args) {
        // 调用方法获取一个无序的数组
        int[] array = initArray();
        System.out.println("排序前 :" + Arrays.toString(array));
        for (int i = 0; i < array.length -1; i++) {
            for (int j = 0; j < array.length - 1 - i; j++) {
                if (array[j] > array[j + 1]){
                    array[j + 1] = array[j + 1] + array[j];
                    array[j] = array[j + 1] - array[j];
                    array[j + 1] = array[j + 1] - array[j];
                }
            }

        }
        System.out.println("排序后 :" + Arrays.toString(array));
    }

你好

②:减少冒泡次数

在这里插入图片描述

    /**
     * 数据准备初始化一个数组
     */
    public static int[] initArray(){
        Random random = new Random();
        // 创建一个数组,长度在10-20之间
        int len = random.nextInt(10) + 5;
        int[] array = new int[len];
        // 遍历添加数据
        for (int i = 0; i < array.length; i++) {
            array[i] = random.nextInt(100);
        }
        return array;
    }

    public static void main(String[] args) {
        // 调用方法获取一个无序的数组
        int[] array = initArray();
        System.out.println("排序前 :" + Arrays.toString(array));
        for (int i = 0; i < array.length -1; i++) {
            // 是否发生交换
            boolean swapped = false;
            for (int j = 0; j < array.length - 1 - i; j++) {
                System.out.println("比较次数:" + j);
                if (array[j] > array[j + 1]){
                    array[j + 1] = array[j + 1] + array[j];
                    array[j] = array[j + 1] - array[j];
                    array[j + 1] = array[j + 1] - array[j];
                    swapped = true;
                }
            }
            if (!swapped) {
                break;
            }
            System.out.println("第" + (i+1) + "排序 :" + Arrays.toString(array));
        }
    }

在这里插入图片描述

③:进一步优化

    /**
     * 数据准备初始化一个数组
     */
    public static int[] initArray(){
        Random random = new Random();
        // 创建一个数组,长度在10-20之间
        int len = random.nextInt(10) + 5;
        int[] array = new int[len];
        // 遍历添加数据
        for (int i = 0; i < array.length; i++) {
            array[i] = random.nextInt(100);
        }
        return array;
    }

    public static void main(String[] args) {
        // 调用方法获取一个无序的数组
        int[] array = initArray();
        int n = array.length - 1;
        System.out.println("排序前 :" + Arrays.toString(array));
        for (int i = 0; i < n; i++) {
            int last = 0;
            for (int j = 0; j < n; j++) {
                System.out.println("比较次数:" + j);
                if (array[j] > array[j + 1]){
                    array[j + 1] = array[j + 1] + array[j];
                    array[j] = array[j + 1] - array[j];
                    array[j + 1] = array[j + 1] - array[j];
                    last = j;
                }
            }
            n = last;
            System.out.println("第" + (i+1) + "排序 :" + Arrays.toString(array));
        }
    }

在这里插入图片描述

④:总结

  • 文字描述 (以升序为例)

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

    • 每轮冒泡时,最后一次交换索引可以作为下一轮冒泡的比较次数,如果这个值为零,表示整个数组有序,直接退出外层循环即可

三、选择排序

①:代码实现

    /**
     * 数据准备初始化一个数组
     */
    public static int[] initArray(){
        Random random = new Random();
        // 创建一个数组,长度在10-20之间
        int len = random.nextInt(10) + 5;
        int[] array = new int[len];
        // 遍历添加数据
        for (int i = 0; i < array.length; i++) {
            array[i] = random.nextInt(100);
        }
        return array;
    }

    public static void main(String[] args) {
        int[] array = initArray();
        System.out.println("排序前 :" + Arrays.toString(array));
        for (int i = 0; i < array.length - 1; i++) {
            // 每轮最小值对应的下标
            int minIndex = i;
            for (int j = i + 1; j < array.length; j++) {
                if (array[minIndex] > array[j]){
                    minIndex = j;
                }
            }
            if (minIndex != i) {
                array[i] = array[minIndex] + array[i];
                array[minIndex] = array[i] - array[minIndex];
                array[i] = array[i] - array[minIndex];
            }
            System.out.println("第" + (i+1) + "次排序 :" + Arrays.toString(array));
        }
    }

②:总结

文字描述(以升序为例)

  • 1.将数组分为两个子集,排序的和未排序的,每一轮从未排序的子集中选出最小的元素,放入排序子集
  • 2.重复以上步骤,直到整个数组有序

优化方式

  • 1.为减少交换次数,每一轮可以先找最小的索引,在每轮最后再交换元素

与冒泡排序比较

  • 1.二者平均时间复杂度都是0(n2)
  • 2.选择排序一般要快于冒泡,因为其交换次数少
  • 3.但如果集合有序度高,冒泡优于选择
  • 4.冒泡属于稳定排序算法,而选择属于不稳定排序

四、插入排序

①:代码实现

    /**
     * 数据准备初始化一个数组
     */
    public static int[] initArray(){
        Random random = new Random();
        // 创建一个数组,长度在10-20之间
        int len = random.nextInt(10) + 5;
        int[] array = new int[len];
        // 遍历添加数据
        for (int i = 0; i < array.length; i++) {
            array[i] = random.nextInt(100);
        }
        return array;
    }

    public static void main(String[] args) {
        int[] array = initArray();
        System.out.println("排序前 :" + Arrays.toString(array));
        for (int i = 1; i < array.length; i++) {
            // 假设最小值
            int minNum = array[i];
            int j = i-1;
            while (j >= 0) {
                if (minNum < array[j]) {
                    array[j + 1] = array[j];
                }else {
                    break;
                }
                j--;
            }
            array[j + 1] = minNum;
            System.out.println("第" + (i) + "次排序 :" + Arrays.toString(array));
        }
    }

②:总结

文字描述(以升序为例)

  • 1.将数组分为两个区域,排序区域和未排序区域,每一轮从未排序区域中取出第一个元素,插入到排序区域(需保证顺序)
  • 2.重复以上步骤,直到整个数组有序

优化方式

  • 1.待插入元素进行比较时,遇到比自己小的元素,就代表找到了插入位置,无需进行后续比较
  • 2.插入时可以直接移动元素,而不是交换元素

与选择排序比较

  • 1.二者平均时间复杂度都是0(n2)
  • 2.大部分情况下,插入都略优于选择
  • 3.有序集合插入的时间复杂度为O(m)公
  • 4.插入属于稳定排序算法,而选择属于不稳定排序

③:插入和选择推到某一论排序结果

1. 使用直接插入排序算法对序列18,23,19,9,23,15进行排序,第三趟排序后的结果为()

  • A.9,18,15,23,19,23
  • B.18,23,19,9,23,15
  • C.18,19,23,9,23,15
  • D.9,18,19,23,23,15

2. 使用直接选择排序算法对序列18,23,19,9,23,15进行排序,第3趟排序后的结果为()

  • A.9,23,19,18,23,15
  • B.9,15,18,19,23,23
  • C.18,19,23,9,23,15
  • D.18,19,23,9,15,23

五、快速排序

①:文字描述

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

    • 1.让小于基准点的元素的进入一个分区,大于基准点的元素的进入另一个分区
    • 2.当分区完成时,基准点元素的位置就是其最终位置
  • 在子分区内重复以上过程,直至子分区元素个数少于等于1,这体现的是分而治之的思想(divide-and-conquer)

②:单边循环

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

  • 选择最右元素作为基准点元素
  • j指针负责找到比基准点小的元素,一旦找到则与ⅰ进行交换
  • ⅰ指针维护小于基准点元素的边界,也是每次交换的目标索引
  • 最后基准点与ⅰ交换,ⅰ即为分区位置

在这里插入图片描述

    /**
     * 数据准备初始化一个数组
     */
    public static int[] initArray(){
        Random random = new Random();
        // 创建一个数组,长度在10-20之间
        int len = random.nextInt(10) + 5;
        int[] array = new int[len];
        // 遍历添加数据
        for (int i = 0; i < array.length; i++) {
            array[i] = random.nextInt(100);
        }
        return array;
    }

    /**
     * 选择一个基准点(pivot)进行分区
     * @param array 排序数组
     * @param l 左边界
     * @param pivot 基准点
     * @return 返回值 = 分区后中间索引值
     */
    public static int  partition(int[] array, int l, int pivot){
        // 右侧基准点(值)
        int pivotNum = array[pivot];
        int i = l;
        for (int j = l; j < pivot; j++) {
            if (array[j] < pivotNum) {
                if (i != j){
                    swap(array, i, j);
                }
                i ++;
            }
        }
        if (i != pivot){
            swap(array, i, pivot);
        }
        return i;
    }

    /**
     * 交换位置
     * @param array 数组
     * @param i 位置1
     * @param j 位置2
     */
    private static void swap(int[] array, int i, int j) {
        int temp = array[j];
        array[j] = array[i];
        array[i] = temp;
    }

    /**
     * 递归排序
     * @param array 排序数组
     * @param l 左边界
     * @param pivot 基准点
     */
    public static void quick(int[] array, int l, int pivot){
        if (l >= pivot){
            return;
        }
        int index = partition(array, l, pivot);
        quick(array,l, index -1);
        quick(array,index +1,pivot);
    }

    public static void main(String[] args) {
        int[] array = initArray();
        System.out.println("排序前 :" + Arrays.toString(array));
        quick(array, 0, array.length - 1);
        System.out.println("排序后 :" + Arrays.toString(array));
    }

在这里插入图片描述

③:双边循环

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

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

1. 代码实现

    /**
     * 数据准备初始化一个数组
     */
    public static int[] initArray(){
        Random random = new Random();
        // 创建一个数组,长度在10-20之间
        int len = random.nextInt(10) + 5;
        int[] array = new int[len];
        // 遍历添加数据
        for (int i = 0; i < array.length; i++) {
            array[i] = random.nextInt(100);
        }
        return array;
    }

    /**
     * 选择一个基准点(pivot)进行分区
     * @param array 排序数组
     * @param l 左边界
     * @param r 右边界
     * @return 返回值 = 分区后中间索引值
     */
    public static int  partition(int[] array, int l, int r){
        // 左侧基准点(值)
        int pivotNum = array[l];
        int i = l;
        int j = r;
        while (i < j) {
            // j从右边找比基准点小的值
            while (i < j && array[j] > pivotNum){
                j--;
            }
            // i 从左边找比基准点大的值
            while (i < j && array[i] <= pivotNum){
                i++;
            }
            swap(array, i, j);
        }
        swap(array, i, l);
        return i;
    }

    /**
     * 交换位置
     * @param array 数组
     * @param i 位置1
     * @param j 位置2
     */
    private static void swap(int[] array, int i, int j) {
        int temp = array[j];
        array[j] = array[i];
        array[i] = temp;
    }

    /**
     * 递归排序
     * @param array 排序数组
     * @param l 左边界
     * @param pivot 基准点
     */
    public static void quick(int[] array, int l, int pivot){
        if (l >= pivot){
            return;
        }
        int index = partition(array, l, pivot);
        quick(array,l, index -1);
        quick(array,index +1,pivot);
    }


    public static void main(String[] args) {
        int[] array = initArray();
        System.out.println("排序前 :" + Arrays.toString(array));
        quick(array, 0, array.length - 1);
        System.out.println("排序后 :" + Arrays.toString(array));
    }

2. 注意事项

  • 1.基准点在左边,并且要先 j 后 i (先从右边找在从左边找)

  • 2.while ( i < j && array[ j ] > pivotNum)

// j从右边找比基准点小的值
while (i < j && array[j] > pivotNum){
    j--;
}
  • 3.while (i < j && array[i] <= pivotNum)
// i 从左边找比基准点大的值
while (i < j && array[i] <= pivotNum){
    i++;
}

④:特点

附录

六、ArrayList扩容规则

可以看看这位博主的:www.cnblogs.com/ruoli-0/p/1…

  • ArrayList()会使用长度为零的数组

    • 直接调用无参方法初始容量为0(空数组)
  • ArrayList(int initialCapacity)会使用指定容量的数组

    • 调用有参方法数组容量为传入的容量值
  • public ArrayList(Collection<?extends E>c)会使用c的大小作为数组容量

    • 传入的是一个集合使用的是集合的大小
  • add(0 bject o)首次扩容为10,再次扩容为上次容量的1.5倍

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

    • 如过集合中没有元素 扩容会在(10和实际元素个数)中选择一个大的,有元素时扩容会在(原容量1.5倍和实际元素个数)选择一个大的

ArrayList的特点:

  • 1.ArrayList的底层数据结构是数组,所以查找遍历快,增删慢。

  • 2.ArrayList可随着元素的增长而自动扩容,正常扩容的话,每次扩容到原来的1.5倍。

  • 3.ArrayList的线程是不安全的。

ArrayList的扩容:

  扩容可分为两种情况:

  第一种情况,当ArrayList的容量为0时,此时添加元素的话,需要扩容,三种构造方法创建的ArrayList在扩容时略有不同:

  • 1.无参构造,创建ArrayList后容量为0,添加第一个元素后,容量变为10,此后若需要扩容,则正常扩容。

  • 2.传容量构造,当参数为0时,创建ArrayList后容量为0,添加第一个元素后,容量为1,此时ArrayList是满的,下次添加元素时需正常扩容。

  • 3.传列表构造,当列表为空时,创建ArrayList后容量为0,添加第一个元素后,容量为1,此时ArrayList是满的,下次添加元素时需正常扩容。

  第二种情况,当ArrayList的容量大于0,并且ArrayList是满的时,此时添加元素的话,进行正常扩容,每次扩容到原来的1.5倍。

七、Iterator_FailFast_FailSafe

ArrayList是fail-fast的典型代表,遍历的同时不能修改,尽快失败

CopyOnWriteArrayList是fail-safe的典型代表,遍历的同时可以修改,原理是读写分离

①:快速失败(fail—fast)

尽可能立即暴露故障并停止整个操作。当遍历一个集合对象时,如果集合对象的结构被修改了,就会抛出ConcurrentModificationExcetion异常。

有以下情况会抛出此异常:

  • 单线程环境下,集合被创建后,在遍历它的过程中修改了结构。

注意 remove()方法会让expectModcount和modcount 相等,所以是不会抛出这个异常。

  • 多线程环境下,当一个线程在遍历这个集合,而另一个线程对这个集合的结构进行了修改。

原理:

迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。

  • modCount是ArrayList中的一个成员变量。它表示该集合实际被修改的次数。
  • expectedModCount 是 ArrayList中的一个内部类——Itr中的成员变量。expectedModCount表示这个迭代器期望该集合被修改的次数。其值是在ArrayList.iterator方法被调用的时候初始化的。只有通过迭代器对集合进行操作,该值才会改变。
  • Itr是一个Iterator的实现,使用ArrayList.iterator方法可以获取到的迭代器就是Itr类的实例。

java.util包下的集合类(例如ArrayListHashMap)都是快速失败的,\color{red}{java.util包下的集合类(例如ArrayList、HashMap)都是快速失败的,} 不能在多线程下发生并发修改(迭代过程中被修改)。\color{red}{不能在多线程下发生并发修改(迭代过程中被修改)。}

②:安全失败(fail—safe)

失败情况下不中断操作,一些系统尝试尽量避免抛出失败异常。 采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。

原理:

由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。

缺点:

  • 迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。(弱一致性)

  • 创建集合拷贝需要相应的开销,包括时间和内存。

八、LinkedList_Vs_ArrayListy

①:随机访问性能比较

ArrayList\color{#32CD32}{ArrayList}

  1. 随机访问快(指根据下标访问)

LinkedList\color{#32CD32}{LinkedList}

  1. 随机访问慢(要沿着链表遍历)

②:增删性能比较

ArrayList\color{#32CD32}{ArrayList}

  1. 尾部插入、删除性能可以,其它部分插入、删除都会移动数据,因此性能会低

LinkedList\color{#32CD32}{LinkedList}

  1. 头尾插入删除性能高, 中间位置插入元素,时间复杂度为O(N)

③:局部性原理(空间占用)

ArrayList\color{#32CD32}{ArrayList}

  1. 基于动态数组,需要连续的内存空间,对空间要求高

  2. 可以利用CPU缓存,局部性原理

LinkedList\color{#32CD32}{LinkedList}

  1. 基于双向链表,无需连续内存 内存利用率高,不会浪费内存但本身占用内存多

九、HashMap

①:HashMap JDK1.7与1.8 有何不同?

1.底层数据结构,JDK1.7JDK1.8有何不同\color{#32CD32}{1. 底层数据结构,JDK 1.7和JDK 1.8有何不同}

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

2.为什么要用红黑树\color{#32CD32}{2. 为什么要用红黑树}

  • 防止链表超长时性能下降,红黑树用来避免DOS攻击

3.为什么一上来不树化\color{#32CD32}{3. 为什么一上来不树化}

  • hash 表的查找,更新的时间复杂度是O(1)
  • 红黑树的查找,更新的时间复杂度是O(log2n\log_2n)
  • TreeNode 占用空间也比普通的Node的大,如非必要还是使用链表

4.树化阈值为何是8\color{#32CD32}{4. 树化阈值为何是8}

  • hash值如果足够随机,则在hash表内按泊松分布,在负载因子0.75的情况下,长度超过8的链表出现概率是 0.00000006, 选择8就是为了让树化几率足够小

5.何时会树化\color{#32CD32}{5. 何时会树化}

  • 树化两个条件:
    • 链表长度超过树化阈值(链表长度大于 8
    • 数组容量 >= 64

6.何时会退化为链表\color{#32CD32}{6. 何时会退化为链表}

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

②:索引如何计算

  • 计算对象的hashCode(),再进行调用HashMap的hash()方法进行二次哈希,最后&(capacity-1)得到索引

  • 二次hash()是为了综合高位数据,让哈希分布更为均匀

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

  • 但以上都是为了配合容量为2的n次幂时的优化手段,例如Hashtable的容量就不是2的n次幂,并不能说哪种设计更优,应该是设计者综合了各种因素,最终选择了使用2的门次幂作为容量

③:为何要二次hash

  • 为了提高综合高位数据 让哈希分布更为均匀 避免出现链表过长的情况

image.png

④:容量为何是2的n次幂

优点\color{#32CD32}{优点}

  • hashMap为了存取高效,要尽量较少碰撞把数据分配均匀,使得每个链表长度大致相同。关键就在于把当前数据存放到哪一个桶中(哪个节点node上),实现这个目标的算法就是取模运算。

  • 在计算机中,直接取模运算的效率不如位运算(&)

  • 当容量为2的n次方时,hash & (capacity - 1) == hash % capacity

  • 当容量为2n次方时,hash&(capacity1)==hash%capacity\color{red}{当容量为2的n次方时,hash \& (capacity - 1) == hash \% capacity}

  • 计算索引时,如果是2的n次幂可以使用位与运算代替取模,效率更高;扩容时hash&oldCap==0的元素留在原来位置,否则新位置=旧位置+oldCap(原始容量)移动操作是批量进行的

缺点\color{#32CD32}{缺点}

  • 极端情况下(如:数据都是偶数)hash分布并不是很好

image.png

image.png

⑤:HashMap put方法流程(1.7与1.8有何不同)

put方法流程\color{#32CD32}{put方法流程}

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

1.71.8不同点\color{#32CD32}{1.7与1.8不同点}

  • 链表插入节点时,1.7是头插法,1.8是尾插法
  • 1.7 是大于等于阈值且没有空位时才扩容,而1.8是大 于阈值就扩容
  • 1.8 在扩容计算Node索引时,会优化(扩容时hash&oldCap==0的元素留在原来位置,否则新位置=旧位置+oldCap(原始容量)移动操作是批量进行的

⑥:加载因子为何默认是0.75f

  • 在空间占用与查询时间之间取得较好的权衡

  • 大于这个值,空间节省了,但链表就会比较长影响性能

  • 小于这个值,冲突减少了,但扩容就会更频繁,空间占用多

⑦:HashMap多线程下有哪些问题

并发丢数据\color{#32CD32}{并发丢数据}

  1. 编写代码并发添加数据
@Test
public void testHashMapPutData() throws InterruptedException {
    HashMap<String, Object> map = new HashMap<>();

    // 分别创建两个线程向map中添加数据
    Thread t1 = new Thread(() -> {
        map.put("a", "a"); // 97 ==> 1
    }, "t1");

    Thread t2 = new Thread(() -> {
        map.put("1", "1"); // 49 ==> 1
    }, "t2");

    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(map);
}
  1. 添加断点 image.png

  2. 控制线程1的断点

image.png

  1. 制线程2的断点

image.png

  1. 在放行线程1(会发现线程1的数据覆盖了线程2的数据)

image.png

并发扩容死链(1.7\color{#32CD32}{并发扩容死链(1.7)}

image.png

image.png

image.png

key的要求\color{#32CD32}{key的要求}

  • HashMap的key可以为null,但Map的其他实现则不然
  • 作为key的对象,必须实现hashCode和equals,并且key的内容不能修改(不可变)

String对象的hashCode()如何设计的,为啥每次乘的是31\color{#32CD32}{String对象的hashCode()如何设计的,为啥每次乘的是31}

目标是达到较为均匀的散列效果,每个字符串的hashCode足够独特\color{red}{目标是达到较为均匀的散列效果,每个字符串的hashCode足够独特}

  1. 字符串中的每个字符都可以表现为一个数字,称为S,其中i的范围是0~n-1
  2. 散列公式为:S031n1+S131n2+...Si31n1i+...Sn1310S_0*31^{n-1}+S_1*31^{n-2}+... S_i*31^{n-1-i}+... S_{n-1}*31^0
  3. 31代入公式有较好的散列特性,并且31*h可以被优化为
    • 即32*h-h
    • 即25^5*h-h
    • 即 h << 5-h

十、设计模式

①:单例模式-方式1-饿汉式

1. 懒汉式代码实现

/**
 * 单例模式(饿汉式)
 */
public class Singleton1 implements Serializable {

    /**
     * 静态的构造方法
     */
    private Singleton1() {
        System.out.println("Singleton1----------->调用了无参构造方法~");
    }
    
    private static final Singleton1 INSTANCE = new Singleton1();

    public static Singleton1 getInstance() {
        return INSTANCE;
    }

    public static void orderMethod(){
        System.out.println("orderMethod");
    }
}
  • 测试

image.png

2.利用反射破解单例模式

  • 代码实现
/**
 * 反射破环单例
 * @throws NoSuchMethodException
 * @throws InvocationTargetException
 * @throws InstantiationException
 * @throws IllegalAccessException
 */
@Test
public void tsetRefllect() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
    orderMethod();

    Constructor<Singleton1> constructor = Singleton1.class.getDeclaredConstructor();
    constructor.setAccessible(true);
    System.out.println("通过反射创建实例" + constructor.newInstance());
}
  • 测试

image.png

  • 解决办法

image.png

3. 利用反序列化破解单例

注意利用反序列化破解单例,该对象必须实现Serializable接口

  • 代码实现
@Test
public void testSerializable() throws IOException, ClassNotFoundException {
    orderMethod();
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new ObjectOutputStream(bos);
    oos.writeObject(Singleton1.getInstance());

    ObjectInputStream stream = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
    System.out.println("反序列化创建实例:" + stream.readObject());
}
  • 测试

image.png

  • 解决办法重写readResolve()方法
/**
 * 重写readResolve方法后在利用反序列化创建对象就会 return INSTANCE(INSTANCE中的对象)
 * @return
 */
public Object readResolve(){
    return INSTANCE;
}

image.png

  • 再次测试成功返回的对象一致

image.png

4. Unsafe 破解单例

  • 因为使用到了Spring中工具类UnsafeUtils,所有导入Spring依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <version>2.6.12</version>
</dependency>
  • 代码实现
/**
 * Unsafe 破解单例
 * @throws InstantiationException
 */
@Test
public void testUnsafe() throws InstantiationException {
    orderMethod();

    Object o = UnsafeUtils.getUnsafe().allocateInstance(Singleton1.class);
    System.out.println("Unsafe 创建实例" + o);

}
  • 测试

image.png

②:单例模式-方式2-枚举饿汉式

1. 代码实现

public enum Singleton2 {
    /**
     * 枚举饿汉式
     */
    INSTANCE;

    private Singleton2() {
        System.out.println("Singleton2----------->调用了构造方法~");
    }

    /**
     * 重写toString打印对象的hash码,不重写默认打印枚举的名称INSTANCE
     * @return
     */
    @Override
    public String toString() {
        return getClass().getName() + "@" + Integer.toHexString(hashCode());
    }

    public static Singleton2 getInstance(){
        return INSTANCE;
    }

    public static void orderMethod(){
        System.out.println("orderMethod()");
    }

}

2. 破环枚举单例

结论:利用反射和反序列化是无法破坏枚举单例的,Unsafe 可以破坏枚举单例

  • 测试 1(枚举是否为单例)

image.png

  • 测试 2 反射(通过无参构造)能否破坏枚举单例 失败\color{red}{失败}

image.png

  • 测试 3 反射(通过构造方法)能否破坏枚举单例 失败\color{red}{失败}

image.png

  • 测试 4 反序列化破坏单例 失败\color{red}{失败}

image.png

  • 测试 5 Unsafe 破坏单例 成功\color{#32CD32}{成功}

image.png

③:单例模式-方式3-懒汉式

  • 代码实现由于synchronized🔒加在了方法上所以性能并不是很好
/**
 * 懒汉式单例
 */
public class Singleton3 {

    private Singleton3(){
        System.out.println("singleton -----------> 调用了构造方法");
    }

    private static Singleton3 INSTANCE = null;

    /**
     * 避免多线程安全问题 加了synchronized
     * @return
     */
    public static synchronized Singleton3 getInstance(){
        if (INSTANCE == null) {
            INSTANCE = new Singleton3();
        }
        return INSTANCE;
    }

    public static void orderMethod(){
        System.out.println("orderMethod()");
    }
}

④:单例模式-方式4-DCL懒汉式

DCL,即 double-checked locking(双检锁/双重校验锁)

/**
 * 懒汉式单例 - DCL(双检索)
 */
public class Singleton4 {

    private Singleton4(){
        System.out.println("singleton -----------> 调用了构造方法");
    }

    // volatile 解决共享变量可见性 有序性问题
    private static volatile Singleton4 INSTANCE = null;

    /**
     * 避免多线程安全问题 加了synchronized
     * @return
     */
    public static Singleton4 getInstance(){
        if (INSTANCE == null) {
            synchronized(Singleton4.class){
                INSTANCE = new Singleton4();
            }

        }
        return INSTANCE;
    }

    public static void orderMethod(){
        System.out.println("orderMethod()");
    }
}

⑤:单例模式-DCL懒汉式-为何加volatile

  1. 问题引入

image.png

  1. 分析问题 导致这一问题的出现就是指令重排顺利错了

image.png

  1. 解决方法 (在共享变量上加上volatile关键字)
// volatile 解决共享变量可见性 有序性问题
private static volatile Singleton4 INSTANCE = null;
  1. volatile变量的特性
  • 保证可见性,不保证原子性
    • 当写一个volatile变量时,JVM会把该线程本地内存中的变量强制刷新到主内存中去;
    • 这个写操作会导致其他线程中的volatile变量缓存无效。
  • 禁止指令重排
    • 重排序操作不会对存在数据依赖关系的操作进行重排序。
    • 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

⑥:单例模式-方式5-内部类懒汉式

  • 代码实现
/**
 * 单例模式-内部类懒汉式
 */
public class Singleton5 {
    private Singleton5() {
        System.out.println("singleton5 -----------> 调用了构造方法");
    }

    private static class Holder{
        static Singleton5 INSTANCE = new Singleton5();
    }

    public static Singleton5 getInstance() {
        return Holder.INSTANCE;
    }

    public static void orderMethod(){
        System.out.println("orderMethod()");
    }
}

⑦:单例模式-在jdk中的体现

目标\color{#32CD32}{目标}

  • 掌握单例模式常见五种实现方式
  • 了解jdk中有哪些地方体现了单例模式

实现方式\color{#32CD32}{实现方式}

  1. 饿汉式
  2. 枚举饿汉式
  3. 懒汉式
  4. 双检锁懒汉式
  5. 内部类懒汉式

单例模式jdk中的体现\color{#32CD32}{单例模式-在jdk中的体现}

  1. System类中的exit(退出虚拟机)和gc(运行垃圾收集器)方法都调用Runtime类中的方法 Runtime类就是一个单例的饿汉式
// 终止当前运行的Java虚拟机
public static void exit(int status) {
    Runtime.getRuntime().exit(status);
}
// 运行垃圾收集器
public static void gc() {
    Runtime.getRuntime().gc();
}
public class Runtime {
    private static Runtime currentRuntime = new Runtime();

    /**
        返回与当前Java应用程序关联的运行时对象。类Runtime的大多数
方法都是实例方法,必须相对于当前运行时对象进行调用。 
返回: 与当前Java应用程序相关联的Runtime对象。
     */
    public static Runtime getRuntime() {
        return currentRuntime;
    }

    /** 不要让任何人实例化这个类 */
    private Runtime() {}
}
  1. System类中Console对象 (双检索懒汉式)
private static volatile Console cons = null;
/**
    返回与当前Java虚拟机关联的唯一Console对象(如果有的话)。 
    返回: 系统控制台(如果有),否则为空。
 */
 public static Console console() {
     if (cons == null) {
         synchronized (System.class) {
             cons = sun.misc.SharedSecrets.getJavaIOAccess().console();
         }
     }
     return cons;
 }
  1. Collections(集合工具类)我们往往有一个需求获取空的集合Collections中内部类(EmptyNavigableSet)就是一个懒汉式的单例

image.png

image.png

  1. Comparators比较器中NaturalOrderComparator是一个单例-枚举-饿汉式

image.png