排序算法、HashMap原理以及单例模式

文章目录

  • 1、二分查找
  • 2、冒泡排序
  • 3、选择排序
  • 4、插入排序
  • 5、希尔排序
  • 6、快速排序(面试写这个)
  • 7、ArrayList
  • 8、Iterator
  • 9、LinkedList
  • 10、HashMap
    • 10.1、基本数据结构
      • 底层数据结构,1.7和1.8有什么不同?
    • 10.2、树化与退化
      • 为何要用红黑树,为何一上来不树化,数化阈值为何是8,何时会树化,何时会退化为链表?
    • 10.3、索引计算
      • 索引如何计算,hashCode都有了,为何还要提供hash方法,数组容量为何是2的n次幂?
    • 10.4、put 与扩容
    • 10.5、并发问题
      • 多线程下会有啥问题?
      • key能否为null,作为key的对象有什么要求?
      • String对象的hashCode() 如何设计的,为啥每次乘的都是31?
  • 11、单例模式
    • 1、什么是单例模式
    • 2、单例模式的类型
    • 3、单例模式的特点
    • 4、单例模式的五种实现方式

1、二分查找

算法描述

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

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

  3. 获取中间索引 m= (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 时,表示没有找到,应结束循环

排序算法、HashMap原理以及单例模式_第1张图片

算法实现

public class BinarySearch {
    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);
    }
    // 二分查找, 找到返回元素索引,找不到返回 -1
    public static int binarySearch(int[] a, int t) {
        int l = 0;
        int r = a.length - 1;
        int 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;
    }
}

解决整数溢出问题

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

第一种:

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

推导:

m = (l + r) / 2 => l/2 + r/2 => l - l/2 + r/2 => l + (r - l)/2

第二种:位运算,(l+r)若是正数,等价于第一种

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

其它考法

  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个元素的数组中二分查找一个数,需要比较的次数最多不超过多少次

    问题转换为log_2 128 , 用 log_10 128 / log_10 2

    • 是整数,即该整数为最终结果
    • 是小数,则舍弃小数部分,整数加一为最终结果

2、冒泡排序

算法描述

  1. 依次比较数组中相邻两个元素大小,若 a[j] > a[j+1](前一个元素大于后一个元素),则交换两个元素,两两都比较一遍称为一轮冒泡,结果是让最大的元素排至最后

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

    排序算法、HashMap原理以及单例模式_第2张图片

算法实现

public static void bubble(int[] a) {
    for (int j = 0; j < a.length - 1; j++) {
        // 一轮冒泡
        boolean swapped = false; // 优化点2,是否发生了交换 
        for (int i = 0; i < a.length - 1 - j; i++) {//优化点1  
            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. 重复以上步骤,直到整个数组有序

    排序算法、HashMap原理以及单例模式_第3张图片

算法实现

private 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 元素还要小, 更新 s
                s = j;
            }
        }
        if (s != i) {
            swap(a, s, i);
        }
        System.out.println(Arrays.toString(a));
    }
}

优化点:为减少交换次数,每一轮可以先找最小的索引,在每轮最后再交换元素

与冒泡排序比较

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

  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]]
    

面试题

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

  1. 9,23,19,18,23,15
  2. 9,15,19,18,23,23
  3. 9,15,18,19,23,23

4、插入排序

算法描述

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

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

排序算法、HashMap原理以及单例模式_第4张图片

算法实现

public static void insert(int[] a) {
    // i 代表待插入元素的索引
    for (int i = 1; i < a.length; i++) {
        int t = a[i]; // 代表待插入的元素值
        int j = i-1; //代表已排序区域的元素索引
        //System.out.println(j);
        while (j >= 0) {
            if (t < a[j]) {
                a[j + 1] = a[j];
                j--;
            } else { 
                break; //退出循环,减少比较次数
            }
        }
        //将待插入的值插入
        a[j + 1] = t;
        System.out.println(Arrays.toString(a) + " " + j);
    }
}

与选择排序比较

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

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

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

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

提示

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

面试题:
使用直接插入排序算法对序列18,23,19,9,23,15进行排序,第三趟排序后的结果为 9,18,19,23,23,15

  1. 18,23
  2. 18,19,23
  3. 9,18,19,23
  4. 9,18,19,23,23
  5. 9,15,18,19,19,23

插入排序有个缺陷,要是大的元素全都集中在前面,那交换的次数会很多,为了解决这个问题,引入了希尔排序。

5、希尔排序

算法描述

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

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

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

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

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

排序算法、HashMap原理以及单例模式_第5张图片

算法实现

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

6、快速排序(面试写这个)

算法描述

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

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

  3. 从以上描述可以看出,一个关键在于分区算法,常见的有洛穆托分区方案、双边循环分区方案、霍尔分区方案

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

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

  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++;
        }
    }
    //将基准点元素与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的查找,然后进行i的查找;
        
        // j 从右找小的,j与基准点元素作比较
        //在内层循环为啥还要加i
        //必须加
        while (i < j && a[j] > pv) {
            j--;
        }
        // i 从左找大的,i与基准点元素作比较
        //为什么是<=呢?
        //因为刚开始基准点元素和i元素相等,a[i] < pv不满足条件,也就不会进入循环,后面进行交换的时候就将基准点元素交换走了,肯定不对呀
        while (i < j && a[i] <= pv) {
            i++;
        }
        swap(a, i, j);
    }
    //交换基准点元素与j或者i,此时i==j
    swap(a, l, j);
    System.out.println(Arrays.toString(a) + " j=" + j);
    return j;
}

快排特点

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

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

  3. 属于不稳定排序

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

  • 霍尔的移动次数平均来讲比洛穆托少3倍

7、ArrayList

扩容规则

  1. ArrayList() 会使用长度为零的数组
  2. ArrayList(int initialCapacity) 会使用指定容量的数组
  3. public ArrayList(Collection c) 会使用 c 的大小作为数组容量
  4. add(Object o) 首次扩容为 10,再次扩容为上次容量的 1.5 倍
  5. addAll(Collection c) 下次扩容的容量跟实际的元素个数取一个较大值 作为下次扩容的容量
    • 没有元素时,扩容为 Math.max(10, 实际元素个数)
    • 有元素时为 Math.max(原容量 1.5 倍, 实际元素个数)

代码演示

我们先来看一下add(Object)方法的扩容规则

public class TestArrayList {
    public static void main(String[] args) {
        System.out.println(arrayListGrowRule(30));
    }

    private static List<Integer> arrayListGrowRule(int n) {
        List<Integer> list = new ArrayList<>();
        int init = 0;
        list.add(init);
        if (n >= 1) {
            init = 10;
            list.add(init);
        }
        for (int i = 1; i < n; i++) {
            init += (init) >> 1;
            list.add(init);
        }
        return list;
    }
}

在这里插入图片描述

这里有小伙伴就有疑问了,第三次扩容是15没问题,那第四次扩容不应该是15*1.5等于22.5,怎么会等于22呢?其实啊,查看代码我们会发现扩容1.5倍并不是乘以1.5,而是15右移一位(整数的右移1相当于除2)等于7,加上之前的15就等于22了。

接下来看一下addAll(Collection c) 方法的扩容规则

  • 集合为空时的扩容规则
  • 集合里面有一些元素时的扩容规则

先来看集合里面没有元素时的情况

private static void testAddAllGrowEmpty() {
    ArrayList<Integer> list = new ArrayList<>();
    list.addAll(List.of(1, 2, 3));
    //list.addAll(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11));
    System.out.println(length(list));//10
}

排序算法、HashMap原理以及单例模式_第6张图片

此时list的长度为10

那我们换一下,list里加11个元素呢,此时list的长度会是15吗

private static void testAddAllGrowEmpty() {
    ArrayList<Integer> list = new ArrayList<>();
    list.addAll(List.of(1, 2, 3));
    //list.addAll(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11));
    System.out.println(length(list));//11
}

排序算法、HashMap原理以及单例模式_第7张图片

list的长度其实是11,为什么呢?因为addAll扩容规则是下次扩容的容量跟实际的元素个数取一个较大值 作为下次扩容的容量

我们再来看一下集合里面有元素的情况

private static void testAddAllGrowNotEmpty() {
    ArrayList<Integer> list = new ArrayList<>();
    for (int i = 0; i < 10; i++) {
        list.add(i);
    }
    list.addAll(List.of(1, 2, 3));
    //list.addAll(List.of(1, 2, 3, 4, 5, 6));
    System.out.println(length(list));//15
}

排序算法、HashMap原理以及单例模式_第8张图片

private static void testAddAllGrowNotEmpty() {
    ArrayList<Integer> list = new ArrayList<>();
    for (int i = 0; i < 10; i++) {
        list.add(i);
    }
    //list.addAll(List.of(1, 2, 3));
    list.addAll(List.of(1, 2, 3, 4, 5, 6));
    System.out.println(length(list));//16
}

排序算法、HashMap原理以及单例模式_第9张图片

补充

通过反射机制获取到当前集合的长度

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

注意,示例中用反射方式来更直观地反映 ArrayList 的扩容特征,但从 JDK 9 由于模块化的影响,对反射做了较多限制,需要在运行测试代码时添加 VM 参数 --add-opens java.base/java.util=ALL-UNNAMED 方能运行通过,后面的例子都有相同问题

8、Iterator

要求

  • 掌握什么是 Fail-Fast、什么是 Fail-Safe

Fail-Fast 与 Fail-Safe

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

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

源码分析

看下ArrayList的源码,增强for循环会调用迭代器对象

排序算法、HashMap原理以及单例模式_第10张图片

  • expectedModCount是迭代器的成员变量,记录了迭代器在刚开始迭代时的修改次数

  • modCount是List集合的成员变量,记录list 集合被修改了多少次

继续往下看有个next 方法

在这里插入图片描述

执行该方法时先执行checkForComodification(); 点进去看看

排序算法、HashMap原理以及单例模式_第11张图片

  • 它会做个判断,如果modCount != expectedModCount,会抛出并发修改异常。

接下来看看CopyOnWriteArrayList 的源码

排序算法、HashMap原理以及单例模式_第12张图片

进入COWIterator

排序算法、HashMap原理以及单例模式_第13张图片

  • 可以看到,es 记录了当前遍历的数组

再看一下add方法,点进源码

排序算法、HashMap原理以及单例模式_第14张图片

  • 可以看到添加是一个数组,遍历是另一个数组,是有两个数组的,读写分离

9、LinkedList

LinkedList

  1. 基于双向链表,无需连续内存
  2. 随机访问慢(要沿着链表遍历)
  3. 头尾插入元素删除性能高,中间插入元素的性能还不如ArrayList
  4. 占用内存多,相当于ArrayList的五倍多

ArrayList

  1. 基于数组,需要连续内存
  2. 随机访问快(指根据下标访问)
  3. 尾部插入、删除性能可以,其它部分插入、删除都会移动数据,因此性能会低
  4. 可以利用 cpu 缓存,局部性原理

CPU缓存:第一次读取数据的时候将数据存到缓存中,之后再去读取数据就不会内存中读取了,直接从缓存中读取,效率更高

局部性原理:读取数据的时候,相邻的数据有很大的几率被访问到,因此读取的时候一次性的全都读到缓存中

10、HashMap

10.1、基本数据结构

底层数据结构,1.7和1.8有什么不同?

  • 1.7 数组 + 链表 链表过长会影响性能
  • 1.8 数组 + (链表 | 红黑树)当链表中的数据过多时会转换成红黑树,反之亦然

hashmap何时会扩容呢?

  • 当元素的个数超过容量的四分之三时,HashMap会扩容

10.2、树化与退化

为何要用红黑树,为何一上来不树化,数化阈值为何是8,何时会树化,何时会退化为链表?

  1. 红黑树用来避免 DoS 攻击,防止链表过长时性能下降,树化应当是偶然情况,是保底策略
  2. hash 表的查找,更新的时间复杂度是 O(1),而红黑树的查找,更新的时间复杂度是 O(log_2⁡n ),TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表
  3. hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率很小(是 0.00000006),树化阈值选择 8 就是为了让树化几率足够小
  4. 树化规则:当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化
  5. 退化规则:
    • 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链+表
    • 情况2:remove 树节点时,若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表

10.3、索引计算

索引如何计算,hashCode都有了,为何还要提供hash方法,数组容量为何是2的n次幂?

  1. 首先,计算对象的 hashCode(),再调用 HashMap 的 hash() 方法进行二次哈希,最后二次哈希值跟数组容量模运算,就得到桶下标,也就是索引

    等价于二次哈希值 & (capacity – 1) 得到索引;等价运算前提,除数必须是2的n次幂

  2. 二次 hash() 是为了综合高位数据,让哈希分布更为均匀,不会造成链表过长的情况

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

  4. 但前三点都是为了配合容量为2的n次幂时的优化手段,例如HashTable的容量就不是2的n次幂,设计者综合考虑了各种因素,最终选择了2的n次幂作为容量。

注意:

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

10.4、put 与扩容

put 流程

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

1.7 与 1.8 的区别

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

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

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

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

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

10.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(蓝色)完成扩容和迁移

排序算法、HashMap原理以及单例模式_第15张图片

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

排序算法、HashMap原理以及单例模式_第16张图片

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

排序算法、HashMap原理以及单例模式_第17张图片

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

排序算法、HashMap原理以及单例模式_第18张图片

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

排序算法、HashMap原理以及单例模式_第19张图片

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

key能否为null,作为key的对象有什么要求?

  1. HashMap 的 key 可以为 null,但 Map 的其他实现则不可以
  2. 作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变)
    • 重写hashcode是为了key在整个hashmap中有良好的分布性,提高查询性能
    • 重写equals是为了防止万一两个key计算出来的hashcode一样,通过equals比较看看是否是相同的对象
  3. key 的 hashCode 应该有良好的散列性

String对象的hashCode() 如何设计的,为啥每次乘的都是31?

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

11、单例模式

1、什么是单例模式

确保一个类只有一个实例,并提供该实例的全局访问点。

单例模式可以让程序仅在内存中创建一个对象,让所有需要调用的地方都调用这个单例对象,防止了频繁地创建对象使得内存飙升。

2、单例模式的类型

  • 懒汉式:在使用对象时才去创建该单例对象
  • 饿汉式:在类加载时已经创建好该单例对象

3、单例模式的特点

  1. 单例类只能有一个实例
  2. 单例类必须自己创建自己的唯一实例
  3. 单例类必须给所有其他对象提供这个实例

4、单例模式的五种实现方式

饿汉式

class Singleton1 implements Serializable {
    //无参构造私有,对象只能在本类被创建
    private Singleton1() {
        //防止反射破坏单例
        if (INSTANCE != null) {
            throw new RuntimeException("单例对象不能重复创建");
        }
        System.out.println("Singleton1()");
    }
    //静态成员变量
    //静态变量的赋值是在静态代码块中执行的,由jvm保证其线程安全
    private static final Singleton1 INSTANCE = new Singleton1();

    //公共的静态方法,供外部调用
    public static Singleton1 getInstance() {
        return INSTANCE;
    }
    
	//防止反序列化破坏单例
    public Object readResolve() {
        return INSTANCE;
    }
    
    //其他方法
    public static void otherMethod() {
        System.out.println("otherMethod()");
    }
}

public class AppTest {
    public static void main(String[] args) {
        Singleton1.otherMethod();
        System.out.println("--------------------");
        System.out.println(Singleton1.getInstance());
        System.out.println(Singleton1.getInstance());
    }
    /**
     * Singleton1()
     * otherMethod()
     * --------------------
     * com.hh.demo.designpattern.e.Singleton1@135fbaa4
     * com.hh.demo.designpattern.e.Singleton1@135fbaa4
     */
}
  • 构造方法抛出异常是防止反射破坏单例
  • readResolve() 是防止反序列化破坏单例

枚举饿汉式(重要)

package com.hh.demo.designpattern.e;

enum Singleton2 {

    INSTANCE;
    
    //公共的静态方法
    public static Singleton2 getInstance() {
        return INSTANCE;
    }

    @Override
    public String toString() {
        return getClass().getName() + "@" + Integer.toHexString(hashCode());
    }
	//其他方法
    public static void otherMethod() {
        System.out.println("otherMethod()");
    }
}
public class AppTest {
    public static void main(String[] args) {
        Singleton2.otherMethod();
        System.out.println("--------------------");
        System.out.println(Singleton2.getInstance());
        System.out.println(Singleton2.getInstance());
    }
    /**
     * Singleton2()
     * otherMethod()
     * -------------------------
     * com.hh.demo.designpattern.e.Singleton2@135fbaa4
     * com.hh.demo.designpattern.e.Singleton2@135fbaa4
     *
     * 进程已结束,退出代码0
     */
}

  • 枚举饿汉式能天然防止反射、反序列化破坏单例

懒汉式

package com.hh.demo.designpattern.e;

class Singleton3 implements Serializable {
    //构造方法私有
    private Singleton3() {
        System.out.println("Singleton3()");
    }
    //私有的静态变量
    private static Singleton3 INSTANCE = null;

	//公共的静态方法,加锁保证线程安全
    public static synchronized Singleton3 getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton3();
        }
        return INSTANCE;
    }
	//其他方法
    public static void otherMethod() {
        System.out.println("otherMethod()");
    }
}
public class AppTest {
    public static void main(String[] args) {
        Singleton3.otherMethod();
        System.out.println("--------------------");
        System.out.println(Singleton3.getInstance());
        System.out.println(Singleton3.getInstance());
    }
    /**
     * otherMethod()
     * --------------------
     * Singleton3()
     * com.hh.demo.designpattern.e.Singleton3@135fbaa4
     * com.hh.demo.designpattern.e.Singleton3@135fbaa4
     *
     * 进程已结束,退出代码0
     */
}

上面代码每次去获取对象都需要先获取锁,并发性能非常差;

其实只有首次创建单例对象时才需要同步,但该代码实际上每次调用都会同步;

接下来要做的就是优化性能,如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例

因此有了下面的双检锁改进

双检锁懒汉式

package com.hh.demo.designpattern.e;

class Singleton4 implements Serializable {
    //无参构造私有
    private Singleton4() {
        System.out.println("private Singleton4()");
    }
	//volatile保证了变量的可见性,有序性,禁止指令重排
    private static volatile Singleton4 INSTANCE = null; 

    public static Singleton4 getInstance() {
        if (INSTANCE == null) {// 线程A和线程B同时看到 INSTANCE = null,如果不为null,则直接返回 INSTANCE
            synchronized (Singleton4.class) {//线程A或线程B获得该锁进行初始化
                if (INSTANCE == null) {// 其中一个线程进入该分支,另外一个线程则不会进入该分支
                    INSTANCE = new Singleton4();
                }
            }
        }
        return INSTANCE;
    }
	//其他方法
    public static void otherMethod() {
        System.out.println("otherMethod()");
    }
}
public class AppTest {
    public static void main(String[] args) {
        Singleton4.otherMethod();
        System.out.println("--------------------");
        System.out.println(Singleton4.getInstance());
        System.out.println(Singleton4.getInstance());
    }
    /**
     * otherMethod()
     * --------------------
     * private Singleton4()
     * com.hh.demo.designpattern.e.Singleton4@135fbaa4
     * com.hh.demo.designpattern.e.Singleton4@135fbaa4
     *
     * 进程已结束,退出代码0
     */
}

为何必须加 volatile:

  • INSTANCE = new Singleton4() 不是原子的,分成 3 步:创建对象、调用构造、给静态变量赋值,其中后两步可能被指令重排序优化,变成先赋值、再调用构造
  • 如果线程1 先执行了赋值,线程2 执行到第一个 INSTANCE == null 时发现 INSTANCE 已经不为 null,此时就会返回一个未完全构造的对象

内部类懒汉式(重要)

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()");
    }
}
  • 避免了双检锁的缺点
  • 既实现了线程安全,又避免了同步带来的性能影响。

JDK 中单例的体现

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

你可能感兴趣的:(算法,数据结构,java)