文章目录
- 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、单例模式的特点
- 3、单例模式的五种实现方式
1、二分查找
算法描述
-
前提:有已排序数组 a(假设已经做好)
-
定义左边界 l、右边界 r,确定搜索范围,循环执行二分查找(3、4两步)
-
获取中间索引 m= (l+r) /2
-
中间索引的值 a[m] 与待搜索的值 T 进行比较
① a[m] == T 表示找到,返回中间索引
② a[m] > T,中间值右侧的其它元素都大于 T,无需比较,中间索引左边去找,m- 1 设置为右边界,重新查找
③ a[m] < T,中间值左侧的其它元素都小于 T,无需比较,中间索引右边去找, m+ 1 设置为左边界,重新查找
-
当
l > r
时,表示没有找到,应结束循环
算法实现
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,5,8,11,19,22,
31
,35,40,45
,48
,49
,50 当二分查找值为 48 的结点时,查找成功需要比较的次数 4奇数二分取中间
-
使用二分法在序列 1,4,6,7,15,33,
39
,50,64,78,75
,81
,89
,96 中查找元素 81 时,需要经过 4 次比较偶数二分取中间靠左
-
在拥有128个元素的数组中二分查找一个数,需要比较的次数最多不超过多少次
问题转换为log_2 128 , 用
log_10 128 / log_10 2
- 是整数,即该整数为最终结果
- 是小数,则舍弃小数部分,整数加一为最终结果
2、冒泡排序
算法描述
-
依次比较数组中相邻两个元素大小,若
a[j] > a[j+1]
(前一个元素大于后一个元素),则交换两个元素,两两都比较一遍称为一轮冒泡,结果是让最大的元素排至最后 -
重复以上步骤,直到整个数组有序
算法实现
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、选择排序
算法描述
-
将数组分为两个子集,排序的和未排序的,每一轮从未排序的子集中选出最小的元素,放入排序子集
-
重复以上步骤,直到整个数组有序
算法实现
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));
}
}
优化点:为减少交换次数,每一轮可以先找最小的索引,在每轮最后再交换元素
与冒泡排序比较
-
二者平均时间复杂度都是 O(n^2)
-
选择排序一般要快于冒泡,因为其交换次数少
-
但如果集合有序度高,冒泡优于选择
-
冒泡属于稳定排序算法,而选择属于不稳定排序
- 稳定排序指,按对象中不同字段进行多次排序,不会打乱同值元素的顺序
- 不稳定排序则反之
稳定排序与不稳定排序
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
- 9,23,19,18,23,15
- 9,15,19,18,23,23
- 9,15,18,19,23,23
4、插入排序
算法描述
-
将数组分为两个区域,排序区域和未排序区域,每一轮从未排序区域中取出第一个元素,插入到排序区域(需保证顺序)
-
重复以上步骤,直到整个数组有序
算法实现
// 修改了代码与希尔排序一致
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);
}
}
与选择排序比较
-
二者平均时间复杂度都是 O(n^2)
-
大部分情况下,插入都略优于选择
-
有序集合
插入的时间复杂度为 O(n) -
插入属于稳定排序算法,而选择属于不稳定排序
提示
插入排序通常被轻视,其实它的地位非常重要。小数据量排序,都会优先选择插入排序
面试题:
使用直接插入排序算法对序列18,23,19,9,23,15进行排序,第三趟排序后的结果为 9,18,19,23,23,15
- 18,23
- 18,19,23
- 9,18,19,23
- 9,18,19,23,23
- 9,15,18,19,19,23
插入排序有个缺陷,要是大的元素全都集中在前面,那交换的次数会很多,为了解决这个问题,引入了希尔排序。
5、希尔排序
算法描述
-
首先选取一个间隙序列,如 (n/2,n/4 … 1),n 为数组长度
-
每一轮
将间隙相等的元素视为一组
,对组内元素进行插入排序,目的有二① 少量元素插入排序速度很快
② 让组内值较大的元素更快地移动到后方
-
当间隙逐渐减少,直至为 1 时,即可完成排序
算法实现
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、快速排序
算法描述
-
每一轮排序选择一个基准点(pivot)进行分区
- 让小于基准点的元素的进入一个分区,大于基准点的元素的进入另一个分区
- 当分区完成时,基准点元素的位置就是其最终位置
-
在子分区内重复以上过程,直至子分区元素个数少于等于 1,这体现的是分而治之的思想 (divide-and-conquer)
-
从以上描述可以看出,一个关键在于分区算法,常见的有洛穆托分区方案、双边循环分区方案、霍尔分区方案
单边循环快排(lomuto 洛穆托分区方案)
-
选择最右元素作为基准点元素s
-
j 指针负责找到比基准点小的元素,一旦找到则与 i 进行交换
-
i 指针维护小于基准点元素的边界,也是每次交换的目标索引
-
最后基准点与 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 霍尔分区方案)
- 选择最左元素作为基准点元素
- j 指针负责从右向左找比基准点小的元素,i 指针负责从左向右找比基准点大的元素,一旦找到二者交换,直至 i,j 相交
- 最后基准点与 i(此时 i 与 j 相等)交换,i 即为分区位置
要点
-
基准点在左边,并且要先 j 后 i
-
while(
i < j
&& a[j] > pv ) j - - -
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<j的条件呢?
//必须加
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;
}
快排特点
-
平均时间复杂度是 O(nlog_2n ),最坏时间复杂度 O(n^2)
-
数据量较大时,优势非常明显
-
属于不稳定排序
洛穆托分区方案 vs 霍尔分区方案
- 霍尔的移动次数平均来讲比洛穆托少3倍
7、ArrayList
扩容规则
- ArrayList() 会使用
长度为零
的数组 - ArrayList(int initialCapacity) 会使用
指定容量
的数组 - public ArrayList(Collection<? extends E> c) 会使用
c 的大小
作为数组容量 add(Object o) 首次扩容为 10,再次扩容为上次容量的 1.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
}
此时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
}
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
}
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
}
补充
通过反射机制获取到当前集合的长度
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循环会调用迭代器对象
-
expectedModCount是迭代器的成员变量,记录了迭代器在刚开始迭代时的修改次数
-
modCount是List集合的成员变量,记录list 集合被修改了多少次
继续往下看有个next 方法
执行该方法时先执行checkForComodification(); 点进去看看
- 它会做个判断,如果modCount != expectedModCount,会抛出并发修改异常。
接下来看看CopyOnWriteArrayList 的源码
进入COWIterator
- 可以看到,es 记录了当前遍历的数组
再看一下add方法,点进源码
- 可以看到添加是一个数组,遍历是另一个数组,是有两个数组的,读写分离
9、LinkedList
LinkedList
- 基于
双向链表
,无需连续内存 - 随机访问慢(要沿着链表遍历)
头尾插入元素删除性能高
,中间插入元素的性能还不如ArrayList- 占用内存多,相当于ArrayList的五倍多
ArrayList
- 基于
数组
,需要连续内存 随机访问快
(指根据下标访问)尾部插入、删除性能可以
,其它部分插入、删除都会移动数据,因此性能会低- 可以利用 cpu 缓存,局部性原理
CPU缓存:第一次读取数据的时候将数据存到缓存中,之后再去读取数据就不会内存中读取了,直接从缓存中读取,效率更高
局部性原理:读取数据的时候,相邻的数据有很大的几率被访问到,因此读取的时候一次性的全都读到缓存中
10、HashMap
10.1、基本数据结构
底层数据结构,1.7和1.8有什么不同?
- 1.7 数组 + 链表 链表过长会影响性能
- 1.8 数组 + (链表 | 红黑树)当链表中的数据过多时会转换成红黑树,反之亦然
hashmap何时会扩容呢?
- 当元素的个数超过容量的四分之三时,HashMap会扩容
10.2、树化与退化
为何要用红黑树,为何一上来不树化,数化阈值为何是8,何时会树化,何时会退化为链表?
- 红黑树用来避 免 DoS 攻击,防止链表过长时性能下降,树化应当是偶然情况,是保底策略
- hash 表的查找,更新的时间复杂度是 O(1),而红黑树的查找,更新的时间复杂度是 O(log_2n ),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 ,也会退化为链表
10.3、索引计算
索引如何计算,hashCode都有了,为何还要提供hash方法,数组容量为何是2的n次幂?
-
首先,计算对象的 hashCode(),再调用 HashMap 的 hash() 方法进行二次哈希,最后二次哈希值跟数组容量
模运算
,就得到桶下标,也就是索引等价于
二次哈希值 & (capacity – 1)
得到索引;等价运算前提,除数必须是2的n次幂
-
二次 hash() 是为了综合高位数据,让哈希分布更为均匀,不会造成链表过长的情况
-
计算索引时,如果是2的n次幂可以使用位与运算代替取模,效率更高;扩容时重新计算索引效率更高:二次 hash值 & 原始容量 == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap(批量移动)。
-
但前三点都是为了配合容量为2的n次幂时的优化手段,例如HashTable的容量就不是2的n次幂,设计者综合考虑了各种因素,最终选择了2的n次幂作为容量。
注意:
- 二次 hash 是为了配合
容量是 2 的 n 次幂
这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash 容量是 2 的 n 次幂
这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable
10.4、put 与扩容
put 流程
- HashMap 是懒惰创建数组的,首次使用才创建数组
- 计算索引(桶下标)
- 如果桶下标还没人占用,创建 Node 占位返回
- 如果桶下标已经有人占用
- 已经是 TreeNode 走红黑树的添加或更新逻辑
- 是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
- 返回前检查容量是否超过阈值,一旦超过进行扩容
- 扩容时,先把元素加到旧的数组里,然后扩容,再把就数组中的数据迁移到新数组
1.7 与 1.8 的区别
-
链表插入节点时,1.7 是头插法,1.8 是尾插法
-
1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容
-
1.8 在扩容计算 Node 索引时,会优化
扩容(加载)因子为何默认是 0.75f
- 在空间占用与查询时间之间取得较好的权衡
- 大于这个值,空间节省了,但链表就会比较长影响性能
- 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多
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(蓝色)完成扩容和迁移
- 线程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 都会存在)
key能否为null,作为key的对象有什么要求?
- HashMap 的 key 可以为 null,但 Map 的其他实现则不可以
- 作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变)
- 重写hashcode是为了key在整个hashmap中有良好的分布性,提高查询性能
- 重写equals是为了防止万一两个key计算出来的hashcode一样,通过equals比较看看是否是相同的对象
- 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、单例模式的特点
- 单例类只能有一个实例
- 单例类必须自己创建自己的唯一实例
- 单例类必须给所有其他对象提供这个实例
3、单例模式的五种实现方式
饿汉式
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 枚举饿汉式单例