开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

多图解释Redis的整数集合intset升级过程

发表于 2020-06-23

redis源码分析系列文章

[Redis源码系列]在Liunx安装和常见API为什么要从Redis源码分析

String底层实现——动态字符串SDS

Redis的双向链表一文全知道

面试官:说说Redis的Hash底层 我:……(来自阅文的面试题)

跳跃表确定不了解下😏

前言

大噶好,今天仍然是元气满满的一天,抛开永远写不完的需求,拒绝要求贼变态的客户,单纯的学习技术,感受技术的魅力。(哈哈哈,皮一下很开森)

前面几周我们一起看了Redis底层数据结构,如动态字符串SDS,双向链表Adlist,字典Dict,跳跃表,如果有对Redis常见的类型或底层数据结构不明白的请看上面传送门。

今天来说下set的底层实现整数集合,如果有对set不明白的,常见的API使用这篇就不讲了,看上面的传送门哈。

整数集合概念

整数集合是Redis设计的一种底层结构,是set的底层实现,当集合中只包含整数值元素,并且这个集合元素数据不多时,会使用这种结构。但是如果不满足刚才的条件,会使用其他结构,这边暂时不讲哈。

下图为整数集合的实际组成,包括三个部分,分别是编码格式encoding,包含元素数量length,保存元素的数组contents。(这边只需要简单看下,下面针对每个模块详细说明哈😝)

整数集合的实现

我们看下intset.h里面关于整数集合的定义,上代码哈:

1
2
3
4
5
6
7
8
9
10
复制代码//整数集合结构体
typedef struct intset {
uint32_t encoding; //编码格式,有如下三种格式,初始值默认为INTSET_ENC_INT16
uint32_t length; //集合元素数量
int8_t contents[]; //保存元素的数组,元素类型并不一定是ini8_t类型,柔性数组不占intset结构体大小,并且数组中的元素从小到大排列。
} intset;

#define INTSET_ENC_INT16 (sizeof(int16_t)) //16位,2个字节,表示范围-32,768~32,767
#define INTSET_ENC_INT32 (sizeof(int32_t)) //32位,4个字节,表示范围-2,147,483,648~2,147,483,647
#define INTSET_ENC_INT64 (sizeof(int64_t)) //64位,8个字节,表示范围-9,223,372,036,854,775,808~9,223,372,036,854,775,807

编码格式encoding

包括INTSET_ENC_INT16,INTSET_ENC_INT32,INTSET_ENC_INT64三种类型,其分别对应着不同的范围,具体看上面代码的注释信息。

因为插入的数据的大小是不一样的,为了尽可能的节约内存(毕竟都是钱,平时要省着点用😭),所以我们需要使用不同的类型来存储数据。

集合元素数量length

记录了保存数据contents的长度,即有多少个元素。

保存元素的数组contents

真正存储数据的地方,数组是按照从小到大有序排序的,并且不包含任何重复项(因为set是不含重复项,所以其底层实现也是不含包含项的)。

整数集合升级过程(重点,手动标星)

上面的图我们重新看下,编码格式encoding为INTSET_ENC_INT16,即每个数据占16位。长度length为4,即数组content里面有四个元素,分别是1,2,3,4。如果我们要添加一个数字位40000,很明显超过编码格式为INTSET_ENC_INT16的范围-32,768~32,767,应该是编码格式为INTSET_ENC_INT32。那么他是如何升级的呢,从INTSET_ENC_INT16升级到INTSET_ENC_INT32的呢?

1.了解旧的存储格式

首先我们看下1,2,3,4这四个元素是如何存储的。首先要知道一共有多少位,计算规则为length*编码格式的位数,即4*16=64。所以每个元素占用了16位。

2.确定新的编码格式

新的元素为40000,已经超过了INTSET_ENC_INT16的范围-32,768~32,767,所以新的编码格式为INTSET_ENC_INT32。

3.根据新的编码格式新增内存

上面已经说明了编码格式为INTSET_ENC_INT32,计算规则为length*编码格式的位数,即5*32=160。所以新增的位数为64-159。

4.根据编码格式设置对应的值

从上面知道按照新的编码格式,每个数据应该占用32位,但是旧的编码格式,每个数据占用16位。所以我们从后面开始,每次获取32位用来存储数据。

这样说太难懂了,看下图☺。

首先,那最后32位,即128-159存储40000。那么第49-127是空着的。

接着,取空着的49-127最后的32位,即96到127这32位,用来存储4。那么之前4存储的位置48-63和49-127剩下的64-95这两部分组成了一个大部分,即48-95,现在空着啦。

在接着在48-95这个大部分,再取后32位,即64-95,用来存储3。那么之前3存储位置32-47和48-95剩下的48-63这两部分组成了一个大部分,即32-63,现在空着啦。

再接着,将32-63这个大部分,再取后32位,即还是32-63,用来存储2。那么之前2存储位置16-31空着啦。

最后,将16-31和原来0-31合起来,存储1。

至此,整个升级过程结束。整体来说,分为3步,确定新的编码格式,新增需要的内存空间,从后往前调整数据。

这边有个小问题,为啥要从后往前调整数据呢?

原因是如果从前往后,数据可能会覆盖。也拿上面个例子来说,数据1在0-15位,数据2在16-31位,如果从前往后,我们知道新的编码格式INTSET_ENC_INT32要求每个元素占用32位,那么数据1应该占用0-31,这个时候数据2就被覆盖了,以后就不知道数据2啦。

但是从后往前,因为后面新增了一些内存,所以不会发生覆盖现象。

升级的优点

节约内存

整数集合既可以让集合保存三种不同类型的值,又可以确保升级操作只在有需要的时候进行,这样就节省了内存。

不支持降级

一旦对数组进行升级,编码就会一直保存升级后的状态。即使后面把40000删掉了,编码格式还是不会将会INTSET_ENC_INT16。

整数集合的源码分析

创建一个空集合 intsetnew

这个方法比较简单,是初始化整数集合的步骤,即下图部分。

主要的步骤是分配内存空间,设置默认编码格式,以及初始化数组长度length。

1
2
3
4
5
6
复制代码intset *intsetNew(void) {
intset *is = zmalloc(sizeof(intset));//分配内存空间
is->encoding = intrev32ifbe(INTSET_ENC_INT16);//设置默认编码格式INTSET_ENC_INT16
is->length = 0;//初始化length
return is;
}

添加元素并升级insetAdd流程图(重点)

添加元素并升级insetAdd源码分析

可以根据上面的流程图,对照着下面的源码分析,这边就不写啦哈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
复制代码//添加元素
//输入参数*is为原整数集合
//value为要添加的元素
//*success为是否添加成功的标志量 ,1表示成功,0表示失败
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
//确定要添加的元素的编码格式
uint8_t valenc = _intsetValueEncoding(value);

uint32_t pos;
//如果success没有初始值,则初始化为1
if (success) *success = 1;

//如果新的编码格式大于现在的编码格式,则升级并添加元素
if (valenc > intrev32ifbe(is->encoding)) {
//调用另一个方法
return intsetUpgradeAndAdd(is,value);
} else {
//如果编码格式不变,则调用查询方法
//输入参数is为原整数集合
//value为要添加的数据
//pos为位置
if (intsetSearch(is,value,&pos)) {//如果找到了,则直接返回,因为数据是不可重复的。
if (success) *success = 0;
return is;
}

//设置length
is = intsetResize(is,intrev32ifbe(is->length)+1);
if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
}
//设置数据
_intsetSet(is,pos,value);
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
}


//#define INT8_MAX 127
//#define INT16_MAX 32767
//#define INT32_MAX 2147483647
//#define INT64_MAX 9223372036854775807LL
static uint8_t _intsetValueEncoding(int64_t v) {
if (v < INT32_MIN || v > INT32_MAX)
return INTSET_ENC_INT64;
else if (v < INT16_MIN || v > INT16_MAX)
return INTSET_ENC_INT32;
else
return INTSET_ENC_INT16;
}


//根据输入参数value的编码格式,对整数集合is的编码格式升级
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
//当前集合的编码格式
uint8_t curenc = intrev32ifbe(is->encoding);
//根据对value解析获取新的编码格式
uint8_t newenc = _intsetValueEncoding(value);
//获取集合元素数量
int length = intrev32ifbe(is->length);
//如果要添加的数据小于0,则prepend为1,否则为0
int prepend = value < 0 ? 1 : 0;

//设置集合为新的编码格式,并根据编码格式重新设置内存
is->encoding = intrev32ifbe(newenc);
is = intsetResize(is,intrev32ifbe(is->length)+1);

//逐步循环,直到length小于0,挨个重新设置每个值,从后往前
while(length--)
_intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));

//如果value为负数,则放在最前面
if (prepend)
_intsetSet(is,0,value);
else//如果value为整数,设置最末尾的元素为value
_intsetSet(is,intrev32ifbe(is->length),value);
//重新设置length
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
}


//找到is集合中值为value的下标,返回1,并保存在pos中,没有找到返回0,并将pos设置为value可以插入到数组的位置
static uint8_t intsetSearch(intset *is, int64_t value, uint32_t *pos) {
int min = 0, max = intrev32ifbe(is->length)-1, mid = -1;
int64_t cur = -1;

//如果集合为空,那么位置pos为0
if (intrev32ifbe(is->length) == 0) {
if (pos) *pos = 0;
return 0;
} else {
//因为数据是有序集合,如果要添加的数据大于最后一个数字,那么直接把要添加的值放在最后即可,返回最大值下标
if (value > _intsetGet(is,intrev32ifbe(is->length)-1)) {
if (pos) *pos = intrev32ifbe(is->length);
return 0;
} else if (value < _intsetGet(is,0)) { //如果这个数据小于数组下标为0的数据,即为最小值 ,返回0
if (pos) *pos = 0;
return 0;
}
}
//有序集合采用二分法
while(max >= min) {
mid = ((unsigned int)min + (unsigned int)max) >> 1;
cur = _intsetGet(is,mid);
if (value > cur) {
min = mid+1;
} else if (value < cur) {
max = mid-1;
} else {
break;
}
}

//确定找到
if (value == cur) {
if (pos) *pos = mid;//设置参数pos,返回1,即找到位置
return 1;
} else {//如果没找到,则min和max相邻,随便设置都行,并返回0
if (pos) *pos = min;
return 0;
}
}

结语

该篇主要讲了Redis的SET数据类型的底层实现整数集合,先从整数集合是什么,,剖析了其主要组成部分,进而通过多幅过程图解释了intset是如何升级的,最后结合源码对整数集合进行描述,如创建过程,升级过程,中间穿插例子和过程图。

如果觉得写得还行,麻烦给个赞👍,您的认可才是我写作的动力!

如果觉得有说的不对的地方,欢迎评论指出。

好了,拜拜咯。

感谢大家❤

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「学习Java的小姐姐」即可加我好友,我拉你进「Java技术交流群」,大家一起共同交流和进步。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

看完这篇 HashMap,和面试官扯皮就没问题了

发表于 2020-06-23

HashMap 概述

如果你没有时间细抠本文,可以直接看 HashMap 概述,能让你对 HashMap 有个大致的了解。

HashMap 是 Map 接口的实现,HashMap 允许空的 key-value 键值对,HashMap 被认为是 Hashtable 的增强版,HashMap 是一个非线程安全的容器,如果想构造线程安全的 Map 考虑使用 ConcurrentHashMap。HashMap 是无序的,因为 HashMap 无法保证内部存储的键值对的有序性。

HashMap 的底层数据结构是数组 + 链表的集合体,数组在 HashMap 中又被称为桶(bucket)。遍历 HashMap 需要的时间损耗为 HashMap 实例桶的数量 + (key - value 映射) 的数量。因此,如果遍历元素很重要的话,不要把初始容量设置的太高或者负载因子设置的太低。

HashMap 实例有两个很重要的因素,初始容量和负载因子,初始容量指的就是 hash 表桶的数量,负载因子是一种衡量哈希表填充程度的标准,当哈希表中存在足够数量的 entry,以至于超过了负载因子和当前容量,这个哈希表会进行 rehash 操作,内部的数据结构重新 rebuilt。

注意 HashMap 不是线程安全的,如果多个线程同时影响了 HashMap ,并且至少一个线程修改了 HashMap 的结构,那么必须对 HashMap 进行同步操作。可以使用 Collections.synchronizedMap(new HashMap) 来创建一个线程安全的 Map。

HashMap 会导致除了迭代器本身的 remove 外,外部 remove 方法都可能会导致 fail-fast 机制,因此尽量要用迭代器自己的 remove 方法。如果在迭代器创建的过程中修改了 map 的结构,就会抛出 ConcurrentModificationException 异常。

下面就来聊一聊 HashMap 的细节问题。我们还是从面试题入手来分析 HashMap 。

HashMap 和 HashTable 的区别

我们上面介绍了一下 HashMap ,现在来介绍一下 HashTable

相同点

HashMap 和 HashTable 都是基于哈希表实现的,其内部每个元素都是 key-value 键值对,HashMap 和 HashTable 都实现了 Map、Cloneable、Serializable 接口。

不同点

  • 父类不同:HashMap 继承了 AbstractMap 类,而 HashTable 继承了 Dictionary 类

  • 空值不同:HashMap 允许空的 key 和 value 值,HashTable 不允许空的 key 和 value 值。HashMap 会把 Null key 当做普通的 key 对待。不允许 null key 重复。

  • 线程安全性:HashMap 不是线程安全的,如果多个外部操作同时修改 HashMap 的数据结构比如 add 或者是 delete,必须进行同步操作,仅仅对 key 或者 value 的修改不是改变数据结构的操作。可以选择构造线程安全的 Map 比如 Collections.synchronizedMap 或者是 ConcurrentHashMap。而 HashTable 本身就是线程安全的容器。
  • 性能方面:虽然 HashMap 和 HashTable 都是基于单链表的,但是 HashMap 进行 put 或者 get􏱤 操作,可以达到常数时间的性能;而 HashTable 的 put 和 get 操作都是加了 synchronized 锁的,所以效率很差。

  • 初始容量不同:HashTable 的初始长度是11,之后每次扩充容量变为之前的 2n+1(n为上一次的长度)

而 HashMap 的初始长度为16,之后每次扩充变为原来的两倍。创建时,如果给定了容量初始值,那么HashTable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。

HashMap 和 HashSet 的区别

也经常会问到 HashMap 和 HashSet 的区别

HashSet 继承于 AbstractSet 接口,实现了 Set、Cloneable,、java.io.Serializable 接口。HashSet 不允许集合中出现重复的值。HashSet 底层其实就是 HashMap,所有对 HashSet 的操作其实就是对 HashMap 的操作。所以 HashSet 也不保证集合的顺序。

HashMap 底层结构

要了解一个类,先要了解这个类的结构,先来看一下 HashMap 的结构:

最主要的三个类(接口)就是 HashMap,AbstractMap和 Map 了,HashMap 我们上面已经在概述中简单介绍了一下,下面来介绍一下 AbstractMap。

AbstractMap 类

这个抽象类是 Map 接口的骨干实现,以求最大化的减少实现类的工作量。为了实现不可修改的 map,程序员仅需要继承这个类并且提供 entrySet 方法的实现即可。它将会返回一组 map 映射的某一段。通常,返回的集合将在AbstractSet 之上实现。这个set不应该支持 add 或者 remove 方法,并且它的迭代器也不支持 remove 方法。

为了实现可修改的 map,程序员必须额外重写这个类的 put 方法(否则就会抛出UnsupportedOperationException),并且 entrySet.iterator() 返回的 iterator 必须实现 remove() 方法。

Map 接口

Map 接口定义了 key-value 键值对的标准。一个对象支持 key-value 存储。Map不能包含重复的 key,每个键最多映射一个值。这个接口代替了Dictionary 类,Dictionary是一个抽象类而不是接口。

Map 接口提供了三个集合的构造器,它允许将 map 的内容视为一组键,值集合或一组键值映射。map的顺序定义为map映射集合上的迭代器返回其元素的顺序。一些map实现,像是TreeMap类,保证了map的有序性;其他的实现,像是HashMap,则没有保证。

重要内部类和接口

Node 接口

Node节点是用来存储HashMap的一个个实例,它实现了 Map.Entry接口,我们先来看一下 Map中的内部接口 Entry 接口的定义

Map.Entry

1
2
3
4
5
6
7
8
9
10
复制代码// 一个map 的entry 链,这个Map.entrySet()方法返回一个集合的视图,包含类中的元素,
// 这个唯一的方式是从集合的视图进行迭代,获取一个map的entry链。这些Map.Entry链只在
// 迭代期间有效。
interface Entry<K,V> {
K getKey();
V getValue();
V setValue(V value);
boolean equals(Object o);
int hashCode();
}

Node 节点会存储四个属性,hash值,key,value,指向下一个Node节点的引用

1
2
3
4
5
6
7
8
复制代码 // hash值
final int hash;
// 键
final K key;
// 值
V value;
// 指向下一个Node节点的Node类型
Node<K,V> next;

因为Map.Entry 是一条条entry 链连接在一起的,所以Node节点也是一条条entry链。构造一个新的HashMap实例的时候,会把这四个属性值分为传入

1
2
3
4
5
6
复制代码Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}

实现了 Map.Entry 接口所以必须实现其中的方法,所以 Node 节点中也包括上面的五个方法

KeySet 内部类

keySet 类继承于 AbstractSet 抽象类,它是由 HashMap 中的 keyset() 方法来创建 KeySet 实例的,旨在对HashMap 中的key键进行操作,看一个代码示例

图中把1, 2, 3这三个key 放在了HashMap中,然后使用 lambda 表达式循环遍历 key 值,可以看到,map.keySet() 其实是返回了一个 Set 接口,KeySet() 是在 Map 接口中进行定义的,不过是被HashMap 进行了实现操作,来看一下源码就明白了

1
2
3
4
5
6
7
8
9
10
11
12
复制代码// 返回一个set视图,这个视图中包含了map中的key。
public Set<K> keySet() {
// // keySet 指向的是 AbstractMap 中的 keyset
Set<K> ks = keySet;
if (ks == null) {
// 如果 ks 为空,就创建一个 KeySet 对象
// 并对 ks 赋值。
ks = new KeySet();
keySet = ks;
}
return ks;
}

所以 KeySet 类中都是对 Map中的 Key 进行操作的:

Values 内部类

Values 类的创建其实是和 KeySet 类很相似,不过 KeySet 旨在对 Map中的键进行操作,Values 旨在对key-value 键值对中的 value 值进行使用,看一下代码示例:

循环遍历 Map中的 values值,看一下 values() 方法最终创建的是什么:

1
2
3
4
5
6
7
8
9
复制代码public Collection<V> values() {
// values 其实是 AbstractMap 中的 values
Collection<V> vs = values;
if (vs == null) {
vs = new Values();
values = vs;
}
return vs;
}

所有的 values 其实都存储在 AbstractMap 中,而 Values 类其实也是实现了 Map 中的 Values 接口,看一下对 values 的操作都有哪些方法

其实是和 key 的操作差不多

EntrySet 内部类

上面提到了HashMap中分别有对 key、value 进行操作的,其实还有对 key-value 键值对进行操作的内部类,它就是 EntrySet,来看一下EntrySet 的创建过程:

点进去 entrySet() 会发现这个方法也是在 Map 接口中定义的,HashMap对它进行了重写

1
2
3
4
5
复制代码// 返回一个 set 视图,此视图包含了 map 中的key-value 键值对
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

如果 es 为空创建一个新的 EntrySet 实例,EntrySet 主要包括了对key-value 键值对映射的方法,如下

HashMap 1.7 的底层结构

JDK1.7 中,HashMap 采用位桶 + 链表的实现,即使用链表来处理冲突,同一 hash 值的链表都存储在一个数组中。但是当位于一个桶中的元素较多,即 hash 值相等的元素较多时,通过 key 值依次查找的效率较低。它的数据结构如下

HashMap 底层数据结构就是一个 Entry 数组,Entry 是 HashMap 的基本组成单元,每个 Entry 中包含一个 key-value 键值对。

1
复制代码transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

而每个 Entry 中包含 hash, key ,value 属性,它是 HashMap 的一个内部类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;

Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
...
}

所以,HashMap 的整体结构就像下面这样

HashMap 1.8 的底层结构

与 JDK 1.7 相比,1.8 在底层结构方面做了一些改变,当每个桶中元素大于 8 的时候,会转变为红黑树,目的就是优化查询效率,JDK 1.8 重写了 resize() 方法。

HashMap 重要属性

初始容量

HashMap 的默认初始容量是由 DEFAULT_INITIAL_CAPACITY 属性管理的。

1
复制代码static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

HashMaap 的默认初始容量是 1 << 4 = 16, << 是一个左移操作,它相当于是

最大容量

HashMap 的最大容量是

1
复制代码static final int MAXIMUM_CAPACITY = 1 << 30;

这里是不是有个疑问?int 占用四个字节,按说最大容量应该是左移 31 位,为什么 HashMap 最大容量是左移 30 位呢?因为在数值计算中,最高位也就是最左位的位 是代表着符号为,0 -> 正数,1 -> 负数,容量不可能是负数,所以 HashMap 最高位只能移位到 2 ^ 30 次幂。

默认负载因子

HashMap 的默认负载因子是

1
复制代码static final float DEFAULT_LOAD_FACTOR = 0.75f;

float 类型所以用 .f 为单位,负载因子是和扩容机制有关,这里大致提一下,后面会细说。扩容机制的原则是当 HashMap 中存储的数量 > HashMap 容量 * 负载因子时,就会把 HashMap 的容量扩大为原来的二倍。

HashMap 的第一次扩容就在 DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12 时进行。

树化阈值

HashMap 的树化阈值是

1
复制代码static final int TREEIFY_THRESHOLD = 8;

在进行添加元素时,当一个桶中存储元素的数量 > 8 时,会自动转换为红黑树(JDK1.8 特性)。

链表阈值

HashMap 的链表阈值是

1
复制代码static final int UNTREEIFY_THRESHOLD = 6;

在进行删除元素时,如果一个桶中存储元素数量 < 6 后,会自动转换为链表

扩容临界值

1
复制代码static final int MIN_TREEIFY_CAPACITY = 64;

这个值表示的是当桶数组容量小于该值时,优先进行扩容,而不是树化

节点数组

HashMap 中的节点数组就是 Entry 数组,它代表的就是 HashMap 中 数组 + 链表 数据结构中的数组。

1
复制代码transient Node<K,V>[] table;

Node 数组在第一次使用的时候进行初始化操作,在必要的时候进行 resize,resize 后数组的长度扩容为原来的二倍。

键值对数量

在 HashMap 中,使用 size 来表示 HashMap 中键值对的数量。

修改次数

在 HashMap 中,使用 modCount 来表示修改次数,主要用于做并发修改 HashMap 时的快速失败 - fail-fast 机制。

扩容阈值

在 HashMap 中,使用 threshold 表示扩容的阈值,也就是 初始容量 * 负载因子的值。

threshold 涉及到一个扩容的阈值问题,这个问题是由 tableSizeFor 源码解决的。我们先看一下它的源码再来解释

1
2
3
4
5
6
7
8
9
复制代码static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

代码中涉及一个运算符 |= ,它表示的是按位或,啥意思呢?你一定知道 a+=b 的意思是 a=a+b,那么 **同理:a |= b 就是 a = a | b **,也就是双方都转换为二进制,来进行与操作。如下图所示

我们上面采用了一个比较大的数字进行扩容,由上图可知 2^29 次方的数组经过一系列的或操作后,会算出来结果是 2^30 次方。

所以扩容后的数组长度是原来的 2 倍。

负载因子

loadFactor 表示负载因子,它表示的是 HashMap 中的密集程度。

HashMap 构造函数

在 HashMap 源码中,有四种构造函数,分别来介绍一下

  • 带有初始容量 initialCapacity 和 负载因子 loadFactor 的构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// 扩容的阈值
this.threshold = tableSizeFor(initialCapacity);
}

初始容量不能为负,所以当传递初始容量 < 0 的时候,会直接抛出 IllegalArgumentException 异常。如果传递进来的初始容量 > 最大容量时,初始容量 = 最大容量。负载因子也不能小于 0 。然后进行数组的扩容,这个扩容机制也非常重要,我们后面进行探讨

  • 只带有 initialCapacity 的构造函数
1
2
3
复制代码public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

最终也会调用到上面的构造函数,不过这个默认的负载因子就是 HashMap 的默认负载因子也就是 0.75f

  • 无参数的构造函数
1
2
3
复制代码public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}

默认的负载因子也就是 0.75f

  • 带有 map 的构造函数
1
2
3
4
复制代码public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}

带有 Map 的构造函数,会直接把外部元素批量放入 HashMap 中。

讲一讲 HashMap put 的全过程

我记得刚毕业一年去北京面试,一家公司问我 HashMap put 过程的时候,我支支吾吾答不上来,后面痛下决心好好整。以 JDK 1.8 为基准进行分析,后面也是。先贴出整段代码,后面会逐行进行分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
复制代码final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果table 为null 或者没有为 table 分配内存,就resize一次
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 指定hash值节点为空则直接插入,这个(n - 1) & hash才是表中真正的哈希
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 如果不为空
else {
Node<K,V> e; K k;
// 计算表中的这个真正的哈希值与要插入的key.hash相比
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 若不同的话,并且当前节点已经在 TreeNode 上了
else if (p instanceof TreeNode)
// 采用红黑树存储方式
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// key.hash 不同并且也不再 TreeNode 上,在链表上找到 p.next==null
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 在表尾插入
p.next = newNode(hash, key, value, null);
// 新增节点后如果节点个数到达阈值,则进入 treeifyBin() 进行再次判断
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果找到了同 hash、key 的节点,那么直接退出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 更新 p 指向下一节点
p = e;
}
}
// map中含有旧值,返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// map调整次数 + 1
++modCount;
// 键值对的数量达到阈值,需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

首先看一下 putVal 方法,这个方法是 final 的,如果你自已定义 HashMap 继承的话,是不允许你自己重写 put 方法的,然后这个方法涉及五个参数

  • hash -> put 放在桶中的位置,在 put 之前,会进行 hash 函数的计算。
  • key -> 参数的 key 值
  • value -> 参数的 value 值
  • onlyIfAbsent -> 是否改变已经存在的值,也就是是否进行 value 值的替换标志
  • evict -> 是否是刚创建 HashMap 的标志

在调用到 putVal 方法时,首先会进行 hash 函数计算应该插入的位置

1
2
3
复制代码public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

哈希函数的源码如下

1
2
3
4
复制代码static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

首先先来理解一下 hash 函数的计算规则

Hash 函数

hash 函数会根据你传递的 key 值进行计算,首先计算 key 的 hashCode 值,然后再对 hashcode 进行无符号右移操作,最后再和 hashCode 进行异或 ^ 操作。

>>>: 无符号右移操作,它指的是 无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0 ,也就是不管是正数还是负数,右移都会在空缺位补 0 。

在得到 hash 值后,就会进行 put 过程。

首先会判断 HashMap 中的 Node 数组是否为 null,如果第一次创建 HashMap 并进行第一次插入元素,首先会进行数组的 resize,也就是重新分配,这里还涉及到一个 resize() 扩容机制源码分析,我们后面会介绍。扩容完毕后,会计算出 HashMap 的存放位置,通过使用 ( n - 1 ) & hash 进行计算得出。

然后会把这个位置作为数组的下标作为存放元素的位置。如果不为空,那么计算表中的这个真正的哈希值与要插入的 key.hash 相比。如果哈希值相同,key-value 不一样,再判断是否是树的实例,如果是的话,那么就把它插入到树上。如果不是,就执行尾插法在 entry 链尾进行插入。

会根据桶中元素的数量判断是链表还是红黑树。然后判断键值对数量是否大于阈值,大于的话则进行扩容。

扩容机制

在 Java 中,数组的长度是固定的,这意味着数组只能存储固定量的数据。但在开发的过程中,很多时候我们无法知道该建多大的数组合适。好在 HashMap 是一种自动扩容的数据结构,在这种基于变长的数据结构中,扩容机制是非常重要的。

在 HashMap 中,阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。HashMap 中的扩容机制是由 resize() 方法来实现的,下面我们就来一次认识下。(贴出中文注释,便于复制)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
复制代码final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 存储old table 的大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 存储扩容阈值
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 如果old table数据已达最大,那么threshold也被设置成最大
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 左移扩大二倍,
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 扩容成原来二倍
newThr = oldThr << 1; // double threshold
}
// 如果oldThr !> 0
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 如果old table <= 0 并且 存储的阈值 <= 0
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果扩充阈值为0
if (newThr == 0) {
// 扩容阈值为 初始容量*负载因子
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 重新给负载因子赋值
threshold = newThr;
// 获取扩容后的数组
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 如果第一次进行table 初始化不会走下面的代码
// 扩容之后需要重新把节点放在新扩容的数组中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 重新映射时,需要对红黑树进行拆分
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 遍历链表,并将链表节点按原顺序进行分组
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 将分组后的链表映射到新桶中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

扩容机制源码比较长,我们耐心点进行拆分

我们以 if…else if…else 逻辑进行拆分,上面代码主要做了这几个事情

  • 判断 HashMap 中的数组的长度,也就是 (Node<K,V>[])oldTab.length() ,再判断数组的长度是否比最大的的长度也就是 2^30 次幂要大,大的话直接取最大长度,否则利用位运算 <<扩容为原来的两倍

  • 如果数组长度不大于0 ,再判断扩容阈值 threshold 是否大于 0 ,也就是看有无外部指定的扩容阈值,若有则使用,这里需要说明一下 threshold 何时是 oldThr > 0,因为 oldThr = threshold ,这里其实比较的就是 threshold,因为 HashMap 中的每个构造方法都会调用 HashMap(initCapacity,loadFactor) 这个构造方法,所以如果没有外部指定 initialCapacity,初始容量使用的就是 16,然后根据 this.threshold = tableSizeFor(initialCapacity); 求得 threshold 的值。

  • 否则,直接使用默认的初始容量和扩容阈值,走 else 的逻辑是在 table 刚刚初始化的时候。

然后会判断 newThr 是否为 0 ,笔者在刚开始研究时发现 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 一直以为这是常量做乘法,怎么会为 0 ,其实不是这部分的问题,在于上面逻辑判断中的扩容操作,可能会导致位溢出。

导致位溢出的示例:oldCap = 2^28 次幂,threshold > 2 的三次方整数次幂。在进入到 float ft = (float)newCap * loadFactor; 这个方法是 2^28 * 2^(3+n) 会直接 > 2^31 次幂,导致全部归零。

在扩容后需要把节点放在新扩容的数组中,这里也涉及到三个步骤

  • 循环桶中的每个 Node 节点,判断 Node[i] 是否为空,为空直接返回,不为空则遍历桶数组,并将键值对映射到新的桶数组中。
  • 如果不为空,再判断是否是树形结构,如果是树形结构则按照树形结构进行拆分,拆分方法在 split 方法中。
  • 如果不是树形结构,则遍历链表,并将链表节点按原顺序进行分组。

讲一讲 get 方法全过程

我们上面讲了 HashMap 中的 put 方法全过程,下面我们来看一下 get 方法的过程,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
复制代码public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

// 找到真实的元素位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 总是会check 一下第一个元素
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;

// 如果不是第一个元素,并且下一个元素不是空的
if ((e = first.next) != null) {

// 判断是否属于 TreeNode,如果是 TreeNode 实例,直接从 TreeNode.getTreeNode 取
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);

// 如果还不是 TreeNode 实例,就直接循环数组元素,直到找到指定元素位置
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

来简单介绍下吧,首先会检查 table 中的元素是否为空,然后根据 hash 算出指定 key 的位置。然后检查链表的第一个元素是否为空,如果不为空,是否匹配,如果匹配,直接返回这条记录;如果匹配,再判断下一个元素的值是否为 null,为空直接返回,如果不为空,再判断是否是 TreeNode 实例,如果是 TreeNode 实例,则直接使用 TreeNode.getTreeNode 取出元素,否则执行循环,直到下一个元素为 null 位置。

getNode 方法有一个比较重要的过程就是 (n - 1) & hash,这段代码是确定需要查找的桶的位置的,那么,为什么要 (n - 1) & hash 呢?

n 就是 HashMap 中桶的数量,这句话的意思也就是说 (n - 1) & hash 就是 (桶的容量 - 1) & hash

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码// 为什么 HashMap 的检索位置是 (table.size - 1) & hash
public static void main(String[] args) {

Map<String,Object> map = new HashMap<>();

// debug 得知 1 的 hash 值算出来是 49
map.put("1","cxuan");
// debug 得知 1 的 hash 值算出来是 50
map.put("2","cxuan");
// debug 得知 1 的 hash 值算出来是 51
map.put("3","cxuan");

}

那么每次算完之后的 (n - 1) & hash ,依次为

也就是 tab[(n - 1) & hash] 算出的具体位置。

HashMap 的遍历方式

HashMap 的遍历,也是一个使用频次特别高的操作

HashMap 遍历的基类是 HashIterator,它是一个 Hash 迭代器,它是一个 HashMap 内部的抽象类,它的构造比较简单,只有三种方法,hasNext 、 remove 和 nextNode 方法,其中 nextNode 方法是由三种迭代器实现的

这三种迭代器就就是

  • KeyIterator ,对 key 进行遍历
  • ValueIterator,对 value 进行遍历
  • EntryIterator, 对 Entry 链进行遍历

虽然说看着迭代器比较多,但其实他们的遍历顺序都是一样的,构造也非常简单,都是使用 HashIterator 中的 nextNode 方法进行遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}

final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
}

final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}

HashIterator 中的遍历方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
复制代码abstract class HashIterator {
Node<K,V> next; // 下一个 entry 节点
Node<K,V> current; // 当前 entry 节点
int expectedModCount; // fail-fast 的判断标识
int index; // 当前槽

HashIterator() {
expectedModCount = modCount;
Node<K,V>[] t = table;
current = next = null;
index = 0;
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
}

public final boolean hasNext() {
return next != null;
}

final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}

public final void remove() {...}
}

next 和 current 分别表示下一个 Node 节点和当前的 Node 节点,HashIterator 在初始化时会遍历所有的节点。下面我们用图来表示一下他们的遍历顺序

你会发现 nextNode() 方法的遍历方式和 HashIterator 的遍历方式一样,只不过判断条件不一样,构造 HashIterator 的时候判断条件是有没有链表,桶是否为 null,而遍历 nextNode 的判断条件变为下一个 node 节点是不是 null ,并且桶是不是为 null。

HashMap 中的移除方法

HashMap 中的移除方法也比较简单了,源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
复制代码public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}

remove 方法有很多,最终都会调用到 removeNode 方法,只不过传递的参数值不同,我们拿 remove(object) 来演示一下。

首先会通过 hash 来找到对应的 bucket,然后通过遍历链表,找到键值相等的节点,然后把对应的节点进行删除。

关于 HashMap 的面试题

HashMap 的数据结构

JDK1.7 中,HashMap 采用位桶 + 链表的实现,即使用链表来处理冲突,同一 hash 值的链表都存储在一个数组中。但是当位于一个桶中的元素较多,即 hash 值相等的元素较多时,通过 key 值依次查找的效率较低。

所以,与 JDK 1.7 相比,JDK 1.8 在底层结构方面做了一些改变,当每个桶中元素大于 8 的时候,会转变为红黑树,目的就是优化查询效率。

HashMap 的 put 过程

大致过程如下,首先会使用 hash 方法计算对象的哈希码,根据哈希码来确定在 bucket 中存放的位置,如果 bucket 中没有 Node 节点则直接进行 put,如果对应 bucket 已经有 Node 节点,会对链表长度进行分析,判断长度是否大于 8,如果链表长度小于 8 ,在 JDK1.7 前会使用头插法,在 JDK1.8 之后更改为尾插法。如果链表长度大于 8 会进行树化操作,把链表转换为红黑树,在红黑树上进行存储。

HashMap 为啥线程不安全

HashMap 不是一个线程安全的容器,不安全性体现在多线程并发对 HashMap 进行 put 操作上。如果有两个线程 A 和 B ,首先 A 希望插入一个键值对到 HashMap 中,在决定好桶的位置进行 put 时,此时 A 的时间片正好用完了,轮到 B 运行,B 运行后执行和 A 一样的操作,只不过 B 成功把键值对插入进去了。如果 A 和 B 插入的位置(桶)是一样的,那么线程 A 继续执行后就会覆盖 B 的记录,造成了数据不一致问题。

还有一点在于 HashMap 在扩容时,因 resize 方法会形成环,造成死循环,导致 CPU 飙高。

HashMap 是如何处理哈希碰撞的

HashMap 底层是使用位桶 + 链表实现的,位桶决定元素的插入位置,位桶是由 hash 方法决定的,当多个元素的 hash 计算得到相同的哈希值后,HashMap 会把多个 Node 元素都放在对应的位桶中,形成链表,这种处理哈希碰撞的方式被称为链地址法。

其他处理 hash 碰撞的方式还有 开放地址法、rehash 方法、建立一个公共溢出区这几种方法。

HashMap 是如何 get 元素的

首先会检查 table 中的元素是否为空,然后根据 hash 算出指定 key 的位置。然后检查链表的第一个元素是否为空,如果不为空,是否匹配,如果匹配,直接返回这条记录;如果匹配,再判断下一个元素的值是否为 null,为空直接返回,如果不为空,再判断是否是 TreeNode 实例,如果是 TreeNode 实例,则直接使用 TreeNode.getTreeNode 取出元素,否则执行循环,直到下一个元素为 null 位置。

HashMap 和 HashTable 有什么区别

见上

HashMap 和 HashSet 的区别

见上

HashMap 是如何扩容的

HashMap 中有两个非常重要的变量,一个是 loadFactor ,一个是 threshold ,loadFactor 表示的就是负载因子,threshold 表示的是下一次要扩容的阈值,当 threshold = loadFactor * 数组长度时,数组长度扩大位原来的两倍,来重新调整 map 的大小,并将原来的对象放入新的 bucket 数组中。

HashMap 的长度为什么是 2 的幂次方

这道题我想了几天,之前和群里小伙伴们探讨每日一题的时候,问他们为什么 length%hash == (n - 1) & hash,它们说相等的前提是 length 的长度 2 的幂次方,然后我回了一句难道 length 还能不是 2 的幂次方吗?其实是我没有搞懂因果关系,因为 HashMap 的长度是 2 的幂次方,所以使用余数来判断在桶中的下标。如果 length 的长度不是 2 的幂次方,小伙伴们可以举个例子来试试

例如长度为 9 时候,3 & (9-1) = 0,2 & (9-1) = 0 ,都在 0 上,碰撞了;

这样会增大 HashMap 碰撞的几率。

HashMap 线程安全的实现有哪些

因为 HashMap 不是一个线程安全的容器,所以并发场景下推荐使用 ConcurrentHashMap ,或者使用线程安全的 HashMap,使用 Collections 包下的线程安全的容器,比如说

1
复制代码Collections.synchronizedMap(new HashMap());

还可以使用 HashTable ,它也是线程安全的容器,基于 key-value 存储,经常用 HashMap 和 HashTable 做比较就是因为 HashTable 的数据结构和 HashMap 相同。

上面效率最高的就是 ConcurrentHashMap。

后记

文章并没有叙述太多关于红黑树的构造、包含添加、删除、树化等过程,一方面是自己能力还达不到,一方面是关于红黑树的描述太过于占据篇幅,红黑树又是很大的一部分内容,所以会考虑放在后面的红黑树进行讲解。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

漫画:Java如何实现热更新?

发表于 2020-06-23

对话-1.png

对话2.png

对话3.png

对话4.png

对话5.png

对话6.png

Arthas(阿尔萨斯)是 Alibaba 开源的一款 Java 诊断工具,使用它我们可以监控和排查 Java 程序,然而它还提供了非常实用的 Java 热更新功能。

所谓的 Java 热更新是指在不重启项目的情况下实现代码的更新与替换。使用它可以实现不停机更新 Java 程序,尤其是对那些启动非常耗时的 Java 项目来说,更是效果显著。

Arthas 的使用其实非常简单,它为我们提供了一个 Jar 包,我们只需要把这个 Jar 下载到本地,然后运行这个 Jar 包就可以正常使用它的功能了。

Arthas 功能简述

当你遇到以下类似问题而束手无策时,Arthas 可以帮助你解决(来自官方):

  1. 这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
  2. 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
  3. 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
  4. 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
  5. 是否有一个全局视角来查看系统的运行状况?
  6. 有什么办法可以监控到JVM的实时运行状态?
  7. 怎么快速定位应用的热点,生成火焰图?

Arthas 支持 JDK 6+,支持 Linux/Mac/Winodws,它采用命令行交互模式,同时提供丰富的 Tab 自动补全功能,进一步方便进行问题的定位和诊断。

Arthas 使用

Arthas 的使用步骤如下。

步骤一:下载 Arthas

首先,我们先把 Arthas 的 Jar 包下载到本地,它的下载地址是:alibaba.github.io/arthas/arth…

步骤二:启动 Arthas

我们只需要使用普通的 jar 包启动命令:java -jar arthas-boot.jar 来启动 Arthas 即可,启动成功之后的运行界面如下:

image.png

如上图所示则表示 Arthas 启动成功。

小贴士:当我们运行 java -jar arthas-boot.jar 命令时,首先需要先切换目录至该 jar 包的位置,才能正常的启动 Arthas。

步骤三:运行 Arthas

当我们启动完 Arthas 之后,根据上图的提示,我们需要选择一个要调试的 Java 进程,例如我们输入“4”来监测我自己写的一个 Java 测试程序,执行结果如下:

image.png

当出现 Arthas 的 logo 之后,表示 Arthas 正常加载了 Java 进程。

步骤四:操作 Arthas

当 Arthas 加载 Java 进程成功之后,我们就可以输入相关的命令来查看相关的信息了。

假如我们把本地环境视为生产服务器,我们此时需要查看某个运行的 Java 程序是否为最新版的。

在没有 Arthas 之前,我们通常的步骤是这样的:

  1. 找到相应的 jar 包(或者 war 包);
  2. 将 jar 包(或者 war 包)下载到本地;
  3. 找出相应的类进行解压操作;
  4. 然后将解压的 class 文件拖拽到 Java 编译器(Idea 或 Eclipse)中,查看是否为最新的代码。

但如果使用的是 Arthas,那么我们就可以直接通过反编译命令,将字节码编译为正常的 Java 代码,然后再确认是否为最新的代码即可。我们只需要执行 jad 命令即可,实现示例如下:

image.png

这样我们就可以直接来查看这个发布的程序是否为最新版本了。

不仅如此,我们还可以使用 Arthas 来监测整个程序的运行情况,如下图所示:

image.png

我们还可以用 Arthas 来查看一些 JVM 的相关信息,如下图所示:

image.png

更多 Arthas 的功能请访问:alibaba.github.io/arthas/comm…

热更新 Java 代码

对话7.png

对话8.png

假如我们原来的代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码package com.example;

import java.util.concurrent.TimeUnit;

public class App {
public static void main(String[] args) throws InterruptedException {
while (true) { // 每两秒钟打印一条信息
TimeUnit.SECONDS.sleep(3);
sayHi();
}
}

private static void sayHi() {
// 需要修改的标识
boolean flag = true;
if (flag) {
System.out.println("Hello,Java.");
} else {
System.out.println("Hello,Java中文社群.");
}
}
}

我们现在想要把 flag 变量改为 false 就可以这样来做:

  1. 使用 Arthas 的内存编译工具将新的 Java 代码编译为字节码;
  2. 使用 Arthas 的 redefine 命令实现热更新。

1.编译字节码

首先,我们需要将新的 Java 代码编译为字节码,我们可以通过 Arthas 提供的 mc 命令实现,mc 是 Memory Compiler(内存编译器)的缩写。

实现示例如下:

1
2
3
4
复制代码[arthas@3478]$ mc /Users/admin/Desktop/App.java -d /Users/admin/Desktop
Memory compiler output:
/Users/admin/Desktop/com/example/App.class
Affect(row-cnt:1) cost in 390 ms.

其中 -d 表示编译文件的存放位置。

小贴士:我们也可以使用 javac App.java 生成的字节码,它与此步骤执行的结果相同。

2.执行热更新

有了字节码文件之后,我们就可以使用 redefine 命令来实现热更新了,实现示例如下:

1
2
复制代码[arthas@51787]$ redefine /Users/admin/Desktop/com/example/App.class
redefine success, size: 1

从上述结果可以看出,热更新执行成功,此时我们去控制台查看执行结果,如下图所示:

image.png

这说明热更新执行确实成功了。

Arthas 热更新注意事项

使用热更新功能有一些条件限制,我们只能用它来修改方法内部的一些业务代码,如果我们出现了以下任意一种情况,那么热更新就会执行失败:

  1. 增加类属性(类字段);
  2. 增加或删除方法;
  3. 替换正在运行的方法。

最后一条我们需要单独说明一下,假如我们把上面的示例改为如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码package com.example;

import java.util.concurrent.TimeUnit;

public class App {
public static void main(String[] args) throws InterruptedException {
while (true) { // 每两秒钟打印一条信息
TimeUnit.SECONDS.sleep(3);
boolean flag = true;
if (flag) {
System.out.println("Hello,Java.");
} else {
System.out.println("Hello,Java中文社群.");
}
}
}
}

那么此时我们再进行热更新操作修改 flag 的值,那么就会执行失败,因为我们替换的是正在运行中的方法,而我们正常示例中的代码之所以能成功,是因为我们在 while 无线循环中调用了另一个方法,而那个方法是被间歇性使用的,因此可以替换成功。

总结

本文我们讲了 Arthas 的概念以及具体的使用流程,Arthas 其实就是一个普通的 Java 程序,我们可以使用 java -jar arthas-boot.jar 来启动它,然后再选择我们要操作的 Java 进程,这样就可以实现状态监控和其他操作。

文章的后半部分,我们介绍了 Arthas 的热更新功能,而热更新本质上只需要使用一个 redefine 命令来加载新的字节码文件就可以实现热更新了,但需要注意热更新不能替换正在运行的方法,它只能修改方法内部的业务代码,如果修改了类字段或者是更改了类方法,那么热更新就会执行失败。

PS:热更新一时爽,但在实际使用时,要充分的评估生产环境的安全性。如果真要用,一定要让相关的负责人亲自处理,毕竟“稳定”和“安全”才是生产环境奉行的第一条铁律。

关注公众号「Java中文社群」回复“干货”,获取 50 篇原创干货 Top 榜。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring Boot 2x基础教程:JdbcTempla

发表于 2020-06-23

在本系列之前的教程中,我们已经介绍了如何使用目前最常用的三种数据访问方式:

  • JdbcTemplate
  • Spring Data JPA
  • MyBatis

下面我们将分三篇来介绍在这三种数据访问方式之下,当我们需要多个数据源的时候,该如何使用的配置说明。

添加多数据源的配置

先在Spring Boot的配置文件application.properties中设置两个你要链接的数据库配置,比如这样:

1
2
3
4
5
6
7
8
9
properties复制代码spring.datasource.primary.jdbc-url=jdbc:mysql://localhost:3306/test1
spring.datasource.primary.username=root
spring.datasource.primary.password=123456
spring.datasource.primary.driver-class-name=com.mysql.cj.jdbc.Driver

spring.datasource.secondary.jdbc-url=jdbc:mysql://localhost:3306/test2
spring.datasource.secondary.username=root
spring.datasource.secondary.password=123456
spring.datasource.secondary.driver-class-name=com.mysql.cj.jdbc.Driver

说明与注意:

  1. 多数据源配置的时候,与单数据源不同点在于spring.datasource之后多设置一个数据源名称primary和secondary来区分不同的数据源配置,这个前缀将在后续初始化数据源的时候用到。
  2. 数据源连接配置2.x和1.x的配置项是有区别的:2.x使用spring.datasource.secondary.jdbc-url,而1.x版本使用spring.datasource.secondary.url。如果你在配置的时候发生了这个报错java.lang.IllegalArgumentException: jdbcUrl is required with driverClassName.,那么就是这个配置项的问题。

相关阅读:Spring Boot 1.x基础教程:多数据源配置

初始化数据源与JdbcTemplate

完成多数据源的配置信息之后,就来创建个配置类来加载这些配置信息,初始化数据源,以及初始化每个数据源要用的JdbcTemplate。你只需要在你的Spring Boot应用下添加下面的这个配置类即可完成!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码@Configuration
public class DataSourceConfiguration {

@Primary
@Bean
@ConfigurationProperties(prefix = "spring.datasource.primary")
public DataSource primaryDataSource() {
return DataSourceBuilder.create().build();
}

@Bean
@ConfigurationProperties(prefix = "spring.datasource.secondary")
public DataSource secondaryDataSource() {
return DataSourceBuilder.create().build();
}

@Bean
public JdbcTemplate primaryJdbcTemplate(@Qualifier("primaryDataSource") DataSource primaryDataSource) {
return new JdbcTemplate(primaryDataSource);
}

@Bean
public JdbcTemplate secondaryJdbcTemplate(@Qualifier("secondaryDataSource") DataSource secondaryDataSource) {
return new JdbcTemplate(secondaryDataSource);
}

}

说明与注意:

  1. 前两个Bean是数据源的创建,通过@ConfigurationProperties可以知道这两个数据源分别加载了spring.datasource.primary.*和spring.datasource.secondary.*的配置。
  2. @Primary注解指定了主数据源,就是当我们不特别指定哪个数据源的时候,就会使用这个Bean
  3. 后两个Bean是每个数据源对应的JdbcTemplate。可以看到这两个JdbcTemplate创建的时候,分别注入了primaryDataSource数据源和secondaryDataSource数据源

测试一下

完成了上面之后,我们就可以写个测试类来尝试一下上面的多数据源配置是否正确了,比如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
java复制代码@RunWith(SpringRunner.class)
@SpringBootTest
public class Chapter37ApplicationTests {

@Autowired
protected JdbcTemplate primaryJdbcTemplate;

@Autowired
protected JdbcTemplate secondaryJdbcTemplate;

@Before
public void setUp() {
primaryJdbcTemplate.update("DELETE FROM USER ");
secondaryJdbcTemplate.update("DELETE FROM USER ");
}

@Test
public void test() throws Exception {
// 往第一个数据源中插入 2 条数据
primaryJdbcTemplate.update("insert into user(name,age) values(?, ?)", "aaa", 20);
primaryJdbcTemplate.update("insert into user(name,age) values(?, ?)", "bbb", 30);

// 往第二个数据源中插入 1 条数据,若插入的是第一个数据源,则会主键冲突报错
secondaryJdbcTemplate.update("insert into user(name,age) values(?, ?)", "ccc", 20);

// 查一下第一个数据源中是否有 2 条数据,验证插入是否成功
Assert.assertEquals("2", primaryJdbcTemplate.queryForObject("select count(1) from user", String.class));

// 查一下第一个数据源中是否有 1 条数据,验证插入是否成功
Assert.assertEquals("1", secondaryJdbcTemplate.queryForObject("select count(1) from user", String.class));
}

}

说明与注意:

  1. 可能这里你会问,有两个JdbcTemplate,为什么不用@Qualifier指定?这里顺带说个小知识点,当我们不指定的时候,会采用参数的名字来查找Bean,存在的话就注入。
  2. 这两个JdbcTemplate创建的时候,我们也没指定名字,它们是如何匹配上的?这里也是一个小知识点,当我们创建Bean的时候,默认会使用方法名称来作为Bean的名称,所以这里就对应上了。读者不妨回头看看两个名称是不是一致的?

代码示例

本文的相关例子可以查看下面仓库中的chapter3-7目录:

  • Github:github.com/dyc87112/Sp…
  • Gitee:gitee.com/didispace/S…

如果您觉得本文不错,欢迎Star支持,您的关注是我坚持的动力!

本文首发:JdbcTemplate的多数据源配置,转载请注明出处。
欢迎关注我的公众号:程序猿DD,获得独家整理的学习资源和日常干货推送。
如果您对我的其他专题内容感兴趣,直达我的个人博客:didispace.com。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

阿里腾讯后台Java社招面经(已拿offer)

发表于 2020-06-23

1、基本情况

博主17届双非一本毕业, 主要是搞Java开发的, 没有大厂经验. 2020 自己也马上快3年工作经验了. 如果再不找找机会进大厂深造一下, 后面的竞争力和个人的提升将会更难.因此在现在公司磨砺了两年之后, 开始向大厂迈进~ 这篇博客主要是想分享一下自己在面试过程中所遇到的问题,相对比较坎坷,前后经历了3个多月.希望大家也能在找工作的过程中,坚持下来!

2、面试结果

  • 阿里-蚂蚁支付宝 P6 offer
  • 腾讯-pcg 2-3 offer
  • 字节 2面 后放弃

3、面试过程

3.1、阿里-天猫超市

一面

1、静态代理,动态代理

简单描述区别, 然后可以引出 jdk动态代理和cglib 的底层实现原理(Proxy 和 InvocationHandler).

再引入 Spring AOP 在不同情况下采用的代理实现方式

最后举例项目中动态代理的使用场景(常见的 日志打印)

2、future (重点)

Future用来代表异步的结果.可以引出 ExecutorService.submit 和 ExecutorService.execute 的区别

如果有研究过一些框架的源码, 可以说一下 Future 在其中起的作用(超时控制)

3、线程池实现方式-销毁线程

这里支持需要指出 Executors 和 ThreadPoolExecutor 之间的关系

通过设置不同的入参,实现不同的线程池. 比较有意思的 SynchronousQueue 实现原理可以深入学习一下

线程池回收: 传送门

4、mysql 联合索引

先介绍一下什么是 联合索引, 索引使用场景和失效情况, 如果了解 索引下推 可以说一下

引出 联合索引 和 主键索引 有什么区别. 然后可以深入对比一下 Innodb 和 MYISAM 的区别

点睛之笔 : 自己去写几条sql 查看索引的选择规则, 你会发现并不是建立了索引就会走, 也并不是有索引下推就一定会去采用, 这就可以涉及到 mysql 一条sql 的执行过程

5、redis 集群

主要的几种集群: 主从,哨兵和redis Cluster 这几种服务端集群. 类似 Twemproxy 和 Codis这种代理实现,如果了解可以说一下.

细问: 当前公司采用哪种方案(哨兵),为什么(数据量较少,主从+哨兵能支撑业务场景),介绍哨兵的工作原理.

当时问了一个问题: 如果主挂了之后,选主结束后,怎么去通知客户端. 客户端和哨兵是什么样的关系(有无关联)

6、mysql 分库分表

先问: 目前数据库的容量大概是多少,有没有做分库分表设计.

答曰: 目前单表数据量在5000w左右, 日增长在10w以内, 暂时没有这方面的考虑(劣大于优).

再引出: 分库分表有哪些方式(垂直分库, 垂直/水平分表),讲解一下区别. 可以再说一下 分布式自增id 的实现方案,常见的比如 雪花算法, 美团-Leaf

7、项目内容

1
复制代码项目介绍,主要是挖掘你在工作中的思考以及亮点. 后面统一介绍, 因为每轮面试基本都会说一次

二面

1
复制代码二面流程比较快, 没有什么特点
  • 介绍项目和当前公司的盈利模式
  • 项目遇到最大的困难
  • 项目的方案设计等

总共20多分钟, 感觉应该没什么大问题.

结果

为啥那么快到结果呢, 就是凉了~

面试完没几天, 跟二面面试官沟通, 是通过了, 还让我准备一下后续的笔试

可能是表现稍差,对比被 干掉 或者 没hc 了

3.2 腾讯TEG

一面

HashMap 底层实现

介绍基本结构,对比 1.7和1.8的区别

建议深入阅读 1.8 resize()的源码, 还有红黑素转换的过程

HashMap 是否线程安全,如果需要使用线程安全的呢

对比 HashMap,HashTable 和 CurrentHashMap的区别和使用场景

给出一 个HashMap 要在线程安全的情况下使用, 通过加锁和 Collections.SynchronizedMap 对当前 HashMap 进行封装

介绍一下红黑树

原理: 红黑树传送门

应用场景: JDK1.8 HashMap , 对比 B+树 和 跳跃表

redis 速度快是因为什么原因

  • 内存
  • 单线程
  • 数据结构
  • io多路服复用

性能瓶颈(内存,网络io), 可以指出 为解决 网络IO 的瓶颈,在 redis 6.0 提出的 单主线程,多工作线程的设计.可以对比 Memecached 的多线程模型进行对比.

mysql 索引介绍

  • 聚集索引和非聚索引的区别(InnoDb 和 MyISAM 对比)
  • 索引选择(优化器怎么选择索引)
  • 索引失效
  • 索引下推

为什么选择b+树

介绍 b+树和b树的区别, 对比b+树在磁盘IO上面的优势(单页能存更多的索引),可以提一下mongodb 采用的是B树索引 .

可以参考: 为什么 MongoDB 索引选择B树,而 Mysql 选择B+树

聚集索引和非聚集索引

参考: 聚集索引和非聚集索引 简析与对比

当时踩了个坑, 聚集索引和聚簇索引 其实是一个东西

默认主键索引

如果没有设置主键索引, innodb 会默认添加一个隐藏列作为主键索引

为什么需要这个隐藏列, 可以参考innodb的数据存储结构

如何设计主键索引: MySQL主键设计

虚拟内存和物理内存

参考: 虚拟内存和物理内存的理解

简而言之:

1
2
复制代码物理内存有限, 虚拟内存通过磁盘映射的形式进行分配物理内存
从而解决多个进程同时运行的情况下内存不足的问题.

伪共享

伪共享原理

可以结合 volatile 和 ConcurrenthashMap.countercell 进行解答

TCP如何确保可靠传输

  • 数据包校验
  • 重排序
  • 丢弃重复数据
  • 应答机制
  • 超时重传
  • 流量控制

拥塞控制

  • 慢开始。
  • 拥塞避免。
  • 快重传。
  • 快恢复
1
2
复制代码计算机网络这部分的内容相对来说比较考验背诵理解.
需要你用自己的语言表达出来

项目设计

1
复制代码后续补充

kafka /es 有没有使用过

有没有了解最新版本的redis(支持多线程)

笔试题

笔试题的内容比较多, 有编程题,算法题 和程序运行结果的选择题等

二面

项目遇到最大的问题(OOM) - 会比较长

个人的分析步骤, 感兴趣可以参考一下. 主要也是根据理论基础进行分析, 然后一步步排查.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
复制代码1、jvm oom排查 (Java heap space)
排查过程:
1、分析oom 的原因: 主要分为内存泄漏和内存溢出
内存泄漏: 对象分配了内存, 在方法调用结束之后没有进行回收,直接进入了老年代中
内存溢出: 我们的内存容量不够,导致内存分配不足
主要从这两方面进行排查

首先排查的是内存溢出:我们机器的配置是 2核4g 的机器, 堆内存分配的是3G,按照1:2的比例进行分配
这里通过
jmap -heap 可以查看到我们的堆内存使用情况.
然后根据 jstat -gc 查看我们的gc 次数, 可以粗略的查看到我们的系统gc 情况

当时通过分析 gc.log 文件看到fgc的次数相对来说还是比较少的, 因此可以暂时排除我们内存溢出导致的oom 的可能性.

其次就是排查内存泄漏了.这里使用到了 -XX:HeapDumpOnOutOfMemoryError 命令来保存 oom 时产生的堆栈信息.
通过 MAT 工具来进行分析 内存使用情况.
当时分析看到占用比较多内存的是 java.util.map 对象比较多. 通过 MAT 工具的 leak suspects 进行分析内存泄漏可能存在的原因.
当时定位到的是我们的一个学生作业报告的接口的方法.

然后查看了一下 这个接口的调用情况,发现一天的调用量在20万次左右,平均响应时间是在400毫秒.

根据分析到的有效信息, 初步排查就是由于这个接口调用量比较多,然后导致生成比较多的一些聚合数据(主要通过map 来进行聚合), 然后由于响应时间比较长,可能会导致在ygc 的时候,根据可达性分析(gc root)判断这个对象还是存活的,然后分配到了老年代,当方法调用结束了, 就会导致这部分对象会一只存活在老年代,直到触发fgc.

如果是正常情况下, 应该会在fgc 的时候就会触发垃圾回收, 而不是发生oom. 这里是根据查看我们ygc 产生的剩余对象占用内存来进行分析的, 即如果ygc 产生了大量的存活对象,而oldgc 没有足够的内存存放这部分对象,就会导致oom.

优化过程:
1、jvm 的优化,主要有做了, 一个是增加内存,调整新生代和老年代的比例(修改成1:1),修改垃圾回收器
2、代码上面进行优化处理:
减少聚合数据对象的创建, 这个可以通过提前生成相应的报告数据
减少接口耗时

为什么要使用 redis

引入中间件都是为了解决目前存在的问题. 比如 数据库访问压力比较大, 数据存储变化频繁,数据访问频率高和数据时效性低等.

可以进一步说明,引入redis 带来的问题 和如何解决的. 比如: 引入了 redis 如何确保数据一致, redis 不可用如何保证服务可用.

改善后的吞吐量,数据库的qps

这里考验的是数据敏感性, 每次改动之后要求对系统进行测评. 判断这次修改是否对服务性能进行了提升,提升了多少, 哪里还有瓶颈等

数据库的事务, innodb 的索引实现原理

事务隔离级别 和 如何实现的.

如何实现这一块需要去了解一下 mvcc

io多路复用

select、poll 和epoll 对比

有遇到深入问 epoll 事件通知是如何实现的.

推荐: Linux IO模式及 select、poll、epoll详解

性能瓶颈,如何再优化

主要围绕这三个点进行分析:

  • cpu
  • 内存
  • io

rpc 调用过程, (为什么看dubbo源码)

rpc 调用过程这个问的挺多的, 可以参考 dubbo 的架构设计, 然后一步步跟着源码走一遍就理解了.

为什么看: 提高自己的编码能力和设计能力 (要带着问题去看源码, 不然很容易忘记)

小组内的工作职责

三面

工作内容

  • 版本开发
  • 问题处理
  • 需求分配
  • 技术评审

重构(思路,实现)

建议阅读: 《重构-改善既有代码的设计》

性能优化做了什么

jvm 调优 ,sql 优化/重建索引 和 MQ 解耦

同步和异步的区别

Linux io多路复用/aio

参考上述 面试二

linux select 通知

B+树和红黑树

HashMap 红黑树

进程间通信的方式

  • 管道
  • 匿名管道
  • 信号
  • 信号量
  • 消息队列
  • 共享内存
  • 套接字

系统性能瓶颈

主要围绕这三个点进行分析:

  • cpu
  • 内存
  • io

结果

TEG 这边的面试, 也是N 了

3.3 腾讯PCG

一面

rocketmq 如何保证消息可靠

从生产, MQ 和消费三端进行分析

消息队列技术选型

对比常见的 RabbitMQ,RockerMQ 和 Kafka 技术特点, 结合公司的实际场景抉择。

rocketmq half message

介绍 half message ,失败如何回调等

rocketmq 消费失败

如何解决消费失败的问题,和消费失败可能导致的 n+1 问题

dubbo 通信过程

rpc调用过程

dubbo 本地缓存地址

dubbo 底层源码

redis 集群模式

redis 主从同步

spring 事务传播机制

mysql 隔离级别

redis 跳跃表 层数的设置

上述可能有比较多重复的内容, 因此没有再做详细的介绍了, 大家可以自行再去学习一下~

二面

1
2
3
复制代码二面的过程有点像聊天,面试官跟 我前面别的部门(不是上面的TEG)的面试官认识,因此了解我的整体情况。
整个面试过程有点类似指导吧,指出我的不足,然后给我一些建议。
也有问一下比较常规的问题,也是上面有提到的一些内容。

三面

项目介绍

1
2
3
4
5
6
复制代码项目介绍主要从:
1、业务场景
2、性能数据
3、问题难点
4、性能瓶颈
这几个方面进行分析吧
1、业务场景

博主这边做的项目是一个教育行业的系统, 主要是描述了一下 学生在线答题的业务场景。各位可以根据自己的项目进行梳理。

2、性能数据

性能数据这一块应该是社招比较看重的问题, 基本每一轮面试都会有面试官问 性能怎么样, 需要我们平时对自己系统有一定的了解,并且清楚实际数据怎么样。 具体包括: 每天访问量,服务 qps/tps,用户量和机器数量(机器配置)等多方面的数据。

3、问题难点

这里我主要将两个地方吧, 一个是上面说到的 oom 问题定位处理 , 一个是 RocketMQ 解耦。

上面介绍了 oom, 下面简单介绍一下结合项目引入 RocketMQ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
复制代码1、为什么引入RocketMQ
通过对核心接口的压测, 发现接口 tps 相对较低,经过排查发现主流程中操作步骤相对较多。
一次写请求处理了比较多内容,导致整个请求的响应缓慢。
通过将核心的流程和辅助功能进行拆分, 通过异步的方式完成后续的工作,从而提高接口的吞吐量。

问题: 响应缓慢,吞吐量低
期望: 快速响应,提高tps
解决方式: 通过引入 RocketMQ 进行异步操作/解耦

2、为什么使用RocketMQ
技术选型: RabbitMQ,RocketMQ和Kafka
主要从:消息堆积,响应速度,底层语言和使用场景进行分析

3、如何保证消息的可靠性
从 客户端,MQ和消费端来进行保证消息可靠。
客户端: 通过事务消息来进行保证,或者失败重试(sendResult判断)
MQ : 通过RocketMQ 集群,进行保证,主要由运维负责(可能会牵扯到MQ消息保存的问题)
消费端:1、消费幂等和2、流水表的形式
这个问题需要结合到项目中的实际场景进行分析, 不能硬套

4、优化后的吞吐量
这个是比较核心的问题, 你优化完之后, 没有做性能的测试,凭什么说引入就好了
(引入中间件原本就会降低系统可靠性,提高复杂度)

因此需要在优化后,进行一轮的压测(注意测试场景要保持和生产或上一次测试场景一致)和消息的消费速度(避免消费过慢导致堆积)


5、优化后的性能瓶颈在哪?
主要从: cpu,内存和IO 三方面进行分析吧, 具体系统具体分析。
4、问题难点

cpu,内存和IO 三方面进行分析吧, 具体系统具体分析。应该没有啥系统是没有瓶颈的。

hr面

工作内容

团队身份

学习规划

职业规划

个人绩效

offer

千辛万苦,终获腾讯offer,上面虽然只写了两个部门的面试内容,但是我至少面了4个部门了(2个月内),所以,没什么岁月安好,只有负重前行,才能实现梦想。

3.3 阿里蚂蚁

一面

1、匿名类,内部类静态内部类

2、HashMap 1.7和1.8区别

3、BlockingQueue 相关知识

4、线程池的创建形式,使用场景

5、多线程下实现一个计数器

6、wait 和notify

7、B+树和红黑树

8、数据库的隔离级别

9、数据库如何解决幻读

10、mysql 索引

11、redis 分布式锁

12、redis 哨兵集群

13、rpc 调用过程

14、zookeeper 是怎么服务发现的

15、zookeeper 心跳检测

总体来说,跟上面的面试过程也是大体上面相似,也没有什么难点的。因此也不做详细分析了~

二面

二面进行的也是比较快,主要是两个问题吧

项目介绍

也是跟上面的差不多内容

场景题

用户的资源权限数据库设计

三面

三面面试官问题主要是跟业务场景和架构方面的, 整体跟腾讯的三面差不多(实际上是因为忘记了问了啥, 主要也是跟项目相关的)

四面

整个流程下来大概10分钟左右,当时刚面完头条,有点突然。

项目难点

问题处理

团队角色

学习方法

hr面

hr面一共面了10分钟左右,当时面完也是慌的一批,咋那么快呢。
问的问题主要就是:

离职原因

职业规划

薪资水平

offer

最后也是成功拿到了ali 的offer ,完成自己的理想了吧! 以后便以 九灵 行走江湖了~~

4、总结与建议

楼主在面试过程中也不是一帆风顺,也是披荆斩棘走过来的,2020 不是一个安稳的时间, 每天在发生在各种各样的变化。只有坚持,把握,不放弃 方能达到自己的目标。

加油吧,少年!

最后贴一个新生的公众号 (Java 补习课),欢迎各位关注,主要会分享一下面试的内容(参考之前博主的文章),阿里的开源技术之类和阿里生活相关。 想要交流面试经验的,可以添加我的个人微信(Jayce-K)进群学习~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一对多分页的SQL到底应该怎么写?

发表于 2020-06-23

  1. 前言

MySQL一对多的数据分页是非常常见的需求,比如我们要查询商品和商品的图片信息。但是很多人会在这里遇到分页的误区,得到不正确的结果。今天就来分析并解决这个问题。

  1. 问题分析

我们先创建一个简单商品表和对应的商品图片关系表,它们之间是一对多的关系:

一对多关系

然后我分别写入了一些商品和这些商品对应的图片,通过下面的左连接查询可以看出它们之间具有明显的一对多关系:

1
2
3
4
复制代码SELECT P.PRODUCT_ID, P.PROD_NAME, PI.IMAGE_URL
FROM PRODUCT_INFO P
LEFT JOIN PRODUCT_IMAGE PI
ON P.PRODUCT_ID = PI.PRODUCT_ID

所有的一对多结果

按照传统的思维我们的分页语句会这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码    <resultMap id="ProductDTO" type="cn.felord.mybatis.entity.ProductDTO">
<id property="productId" column="product_id"/>
<result property="prodName" column="prod_name"/>
<collection property="imageUrls" ofType="string">
<result column="image_url"/>
</collection>
</resultMap>

<select id="page" resultMap="ProductDTO">
SELECT P.PRODUCT_ID, P.PROD_NAME,PI.IMAGE_URL
FROM PRODUCT_INFO P
LEFT JOIN PRODUCT_IMAGE PI
ON P.PRODUCT_ID = PI.PRODUCT_ID
LIMIT #{current},#{size}
</select>

当我按照预想传入了(0,2)想拿到前两个产品的数据,结果并不是我期望的:

1
2
3
4
复制代码2020-06-21 23:35:54.515 DEBUG 10980 --- [main] c.f.m.mappers.ProductInfoMapper.page     : ==>  Preparing: SELECT P.PRODUCT_ID, P.PROD_NAME,PI.IMAGE_URL FROM PRODUCT_INFO P LEFT JOIN PRODUCT_IMAGE PI ON P.PRODUCT_ID = PI.PRODUCT_ID limit ?,? 
2020-06-21 23:35:54.541 DEBUG 10980 --- [main] c.f.m.mappers.ProductInfoMapper.page : ==> Parameters: 0(Long), 2(Long)
2020-06-21 23:35:54.565 DEBUG 10980 --- [main] c.f.m.mappers.ProductInfoMapper.page : <== Total: 2
page = [ProductDTO{productId=1, prodName='杯子', imageUrls=[http://asset.felord.cn/cup1.png, http://asset.felord.cn/cup2.png]}]

我期望的两条数据是杯子和笔记本,但是结果却只有一条。原来当一对多映射时结果集会按照多的一侧进行输出(期望4条数据,实际上会有7条),而前两条展示的只会是杯子的数据(如上图),合并后就只有一条结果了,这样分页就对不上了。那么如何才能达到我们期望的分页效果呢?

  1. 正确的方式

正确的思路是应该先对主表进行分页,再关联从表进行查询。

抛开框架,我们的SQL应该先对产品表进行分页查询然后再左关联图片表进行查询:

1
2
3
4
5
6
复制代码SELECT P.PRODUCT_ID, P.PROD_NAME, PI.IMAGE_URL
FROM (SELECT PRODUCT_ID, PROD_NAME
FROM PRODUCT_INFO
LIMIT #{current},#{size}) P
LEFT JOIN PRODUCT_IMAGE PI
ON P.PRODUCT_ID = PI.PRODUCT_ID

这种写法的好处就是通用性强一些。但是MyBatis提供了一个相对优雅的路子,思路依然是开头所说的思路。只不过我们需要改造上面的Mybatis XML配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码<resultMap id="ProductDTO" type="cn.felord.mybatis.entity.ProductDTO">
<id property="productId" column="product_id"/>
<result property="prodName" column="prod_name"/>
<!-- 利用 collection 标签提供的 select 特性 和 column -->
<collection property="imageUrls" ofType="string" select="selectImagesByProductId" column="product_id"/>
</resultMap>
<!-- 先查询主表的分页数据 -->
<select id="page" resultMap="ProductDTO">
SELECT PRODUCT_ID, PROD_NAME
FROM PRODUCT_INFO
LIMIT #{current},#{size}
</select>
<!--根据productId 查询对应的图片-->
<select id="selectImagesByProductId" resultType="string">
SELECT IMAGE_URL
FROM PRODUCT_IMAGE
WHERE PRODUCT_ID = #{productId}
</select>
  1. 总结

大部分情况下分页是很容易的,但是一对多还是有一些小小的陷阱的。一旦我们了解了其中的机制,也并不难解决。当然如果你有更好的解决方案可以留言讨论,集思广益。多多关注:码农小胖哥,获取更多开发技巧。

关注公众号:Felordcn获取更多资讯

个人博客:https://felord.cn

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一般人不敢动系列之—基于logback的日志“规范”和“脱敏

发表于 2020-06-23

原创:小姐姐味道(微信公众号ID:xjjdog),欢迎分享,转载请保留出处。

在日常开发中,我们经常会使用logback打印日志,还会包含一些敏感内容。比如手机号、卡号、邮箱等,这对数据安全而言是有风险的。

但是如果让业务去处理这些问题,则需要在每个打印日志的地方,进行重复的脱敏操作,不仅繁琐影响代码风格,还会有遗漏情况。

这个时候,我们就需要考虑一个相对统一的解决方案,通过增强logback,在日志message落盘之前,统一进行检测、脱敏。

一、需求来源

我们通常的日志处理,面临的通用诉求:

1)超长日志message截取: 程序打印的日志message可能非常大,比如超过1M,这种message极大的影响系统的性能,而且通常数据价值比较低。我们应该对这种message进行截取或者直接抛弃。

2)日志格式统一: 通常情况下,生产环境的业务日志通过会按需采集、分析、存储,那么日志格式的统一对下游数据处理是非常必要的。

为了避免错误配置了日志格式,我们应该将日志格式规范,默认进行集成且限制修改。

日志格式中,通常包含一些用于数据分拣的系统信息(例如,项目名、部署集群名、IP、云平台、rack等),也包含一些运行时的MDC动态参数值,最终格式要求是一致的。

3)脱敏: 日志中存在特定规则的字符串时,比如手机号,需要对其进行脱敏处理。

二、设计核心思想

我们可以基于PatternLayoutEncoder来实现日志格式的限定,不再使用默认的pattern参数指定格式,而是固定字段格式 + 自定义字段,最终拼接成格式规范。

其中,局部可控字段,可以是系统变量、也可以MDC字段列表;固定格式部分,通常是message的头部,包含时间、IP、项目名等等。

基于logback提供的MessageConverter特性,在message打印之前,允许对“参数格式化之后的message”(formattedMessage)进行转换,最终logger打印的实际内容是converter返回的整形后的结果。

那么,我们就可以基于此特性,在convert方法中执行“超长message截取”、“内容脱敏”两个主要操作。

三、设计编码

设计理念

CommonPatternLayoutEncoder:
父类为PatternLayoutEncoder,用于定义日志格式,包括固定字段部分、自定义字段部分,将系统属性、MDC属性等,进行拼接。

同时,基于logback的option特性,将动态参数传递给MessageConverter,最终拼接成一个字符串,作为pattern属性。同时converter所需要的配置参数,比如消息最大长度、正则表达式、替换策略,都需要通过Encoder声明。

ComplexMessageConverter:

message转换,只会操作logger.info(String message,Throwable ex)传递的message部分。其中,throwable栈信息不会被操作(其实也无法修改)。

Converter可以获取Encoder传递的option参数列表,并初始化相关的处理类;内部实现基于正则表达式来匹配敏感信息。

DataSetPatternLayoutEncoder(可选):

主要用于限定数据集类的日志格式,它本身不能对敏感信息进行过滤;数据格式主要为了便于数据分析。

主要代码

下面是CommonPatternLayoutEncoder.java的主要代码,详细参见注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
复制代码package ch.qos.logback.classic.encoder;  

import ch.qos.logback.classic.PolicyEnum;
import ch.qos.logback.classic.Utils;

import java.text.MessageFormat;

import static ch.qos.logback.classic.Utils.DOMAIN_DELIMITER;
import static ch.qos.logback.classic.Utils.FIELD_DELIMITER;

/**
* 适用于基于File的Appender
* <p>
* 限定我司日志规范,增加有关敏感信息的过滤。
* 可以通过regex指定需要匹配和过滤的表达式,对于符合表达式的字符串,则采用policy进行处理。
* 1)replace:替换,将字符串替换为facade,比如:18611001100 > 186****1100
* 2) drop:抛弃整条日志
* 3)erase:擦除字符串,全部替换成等长度的"****",18611001100 > ***********
* <p>
* depth:正则匹配深度,默认为12,即匹配成功次数达到此值以后终止匹配,主要考虑是性能。如果一个超长的日志,我们不应该全部替换,否则可能引入性能问题。
* maxLength:单条message的最大长度(不计算throwable),超长则截取,并在message尾部追加终止符。
* <p>
* 考虑到扩展性,用户仍然可以直接配置pattern,此时regex、policy、depth等option则不生效。但是maxLength会一致生效。
* 格式样例:
* %d{yyyy-MM-dd/HH:mm:ss.SSS}|IP_OR_HOSTNAME|REQUEST_ID|REQUEST_SEQ|^_^|
* SYS_K1:%property{SYS_K1}|SYS_K2:%property{SYS_K2}|MDC_K1:%X{MDC_K1:--}|MDC_K2:%X{MDC_K2:--}|^_^|
* [%t] %-5level %logger{50} %line - %m{o1,o2,o3,o4}%n
* 格式中domain1是必选,而且限定无法扩展
* domain2根据配置文件指定的system properties和mdcKeys动态拼接,K-V结构,便于解析;可以为空。
* domain3是常规message部分,其中%m携带options,此后Converter可以获取这些参数。
**/
public class CommonPatternLayoutEncoder extends PatternLayoutEncoder {


protected static final String PATTERN_D1 = "%d'{'yyyy-MM-dd/HH:mm:ss.SSS'}'|{0}|%X'{'requestId:--'}'|%X'{'requestSeq:--'}'";
protected static final String PATTERN_D2_S1 = "{0}:%property'{'{1}'}'";
protected static final String PATTERN_D2_S2 = "{0}:%X'{'{1}:--'}'";
protected static final String PATTERN_D3_S1 = "[%t] %-5level %logger{50} %line - ";
//0:message最大长度(超出则截取),1:正则表达式,2:policy,3:查找深度(超过深度后停止正则匹配)
protected static final String PATTERN_D3_S2 = "%m'{'{0},{1},{2},{3}'}'%n";

protected String mdcKeys;//来自MDC的key,多个key用逗号分隔。

protected String regex = "-";//匹配的正则表达式,如果此值为null或者"-",那么policy、deep参数都将无效

protected int maxLength = 2048;//单条消息的最大长度,主要是message

protected String policy = "replace";//如果匹配成功,字符串的策略。

protected int depth = 128;

protected boolean useDefaultRegex = true;

protected static final String DEFAULT_REGEX = "'((?<\\d)1[3-9]\\d{9}(?!\\d))'";//手机号,11位数字,并且前后位不再是数字。
//系统参数,如果未指定,则使用default;
protected String systemProperties;

protected static final String DEFAULT_SYSTEM_PROPERTIES = "project,profiles,cloudPlatform,clusterName";

@Override
public void start() {
if (getPattern() == null) {
StringBuilder sb = new StringBuilder();
String d1 = MessageFormat.format(PATTERN_D1, Utils.getHostName());
sb.append(d1);
sb.append(FIELD_DELIMITER)
.append(DOMAIN_DELIMITER)
.append(FIELD_DELIMITER);
//拼装系统参数,如果当前数据视图不存在,则先set一个默认值
if (systemProperties == null || systemProperties.isEmpty()) {
systemProperties = DEFAULT_SYSTEM_PROPERTIES;
}
//系统参数
String[] properties = systemProperties.split(",");
for (String property : properties) {
String value = Utils.getSystemProperty(property);
if (value == null) {
System.setProperty(property, "-");//初始化
}
sb.append(MessageFormat.format(PATTERN_D2_S1, property, property))
.append(FIELD_DELIMITER);
}

//拼接MDC参数
if (mdcKeys != null) {
String[] keys = mdcKeys.split(",");
for (String key : keys) {
sb.append(MessageFormat.format(PATTERN_D2_S2, key, key));
sb.append(FIELD_DELIMITER);
}
sb.append(DOMAIN_DELIMITER)
.append(FIELD_DELIMITER);
}
sb.append(PATTERN_D3_S1);

if (PolicyEnum.codeOf(policy) == null) {
policy = "-";
}

if (maxLength < 0 || maxLength > 10240) {
maxLength = 2048;
}

//如果设定了自定义regex,则优先生效;否则使用默认
if (!regex.equalsIgnoreCase("-")) {
useDefaultRegex = false;
}
if (useDefaultRegex) {
regex = DEFAULT_REGEX;
}

sb.append(MessageFormat.format(PATTERN_D3_S2, String.valueOf(maxLength), regex, policy, String.valueOf(depth)));
setPattern(sb.toString());
}
super.start();
}

public String getMdcKeys() {
return mdcKeys;
}

public void setMdcKeys(String mdcKeys) {
this.mdcKeys = mdcKeys;
}

public String getRegex() {
return regex;
}

public void setRegex(String regex) {
this.regex = regex;
}

public int getMaxLength() {
return maxLength;
}

public void setMaxLength(int maxLength) {
this.maxLength = maxLength;
}

public String getPolicy() {
return policy;
}

public void setPolicy(String policy) {
this.policy = policy;
}

public int getDepth() {
return depth;
}

public void setDepth(int depth) {
this.depth = depth;
}

public Boolean getUseDefaultRegex() {
return useDefaultRegex;
}

public boolean isUseDefaultRegex() {
return useDefaultRegex;
}

public void setUseDefaultRegex(boolean useDefaultRegex) {
this.useDefaultRegex = useDefaultRegex;
}

@Override
public String getPattern() {
return super.getPattern();
}

@Override
public void setPattern(String pattern) {
super.setPattern(pattern);
}

public String getSystemProperties() {
return systemProperties;
}

public void setSystemProperties(String systemProperties) {
this.systemProperties = systemProperties;
}
}

代码介绍

下面简单介绍一下上面的代码。

MDC参数声明格式为:%X{key},如果上下文中key不存在,则打印””;我们通过使用:-来声明其默认值。比如,%X{key:--}表示,如果key不存在则将打印“-”。

根据logback的规定,option参数列表需要声明在某个字段中,并配合<conversionRule>才能生效,以本文为例,我们主要对message进行整形。所以option参数声明在%m上,其格式为:%m{o1,o2...},多个option之间以,分割。o1,o2的字面值,可以在Converter中获取。简单来说,你需要将参数传递给Converter时,这些参数必须以option方式声明在某个字段上,否则没法做。

特别注意,如果option参数中包含{、}时,必须将option参数使用''包括。比如%m{2048,'\\d{11}','replace','128'},为了便于理解,建议所有的option参数都使用''逐个包含。

此外,如果你对日志格式中,还需要使用系统参数(System Property),可以使用%property{key}来声明。比如,

1
复制代码MessageFormat.format("展示一下'{'{0}'}'格式化的效果。","hello")

输出>>

1
复制代码展示一下{hello}格式化效果。

还有一些比较重要的参数。

useDefaultRegex

是否使用默认表达式,即手机号数字(连续11位数字,且后位不再跟进数字)。

regex

我们也允许用户自定义表达式。此时需要将useDefaultRegex设定为false才能生效。

maxLength

默认值为2048,即message的最大长度超过此值后将会被截取,可配置。

policy

对于regex匹配成功的字符串,如何处理。(处理规则,参见下文ComplexMessageConverter)

A)drop 直接抛弃,将message重置为一个“终止符号”。比如:

1
复制代码我的手机号为18611001100

将会被整形为:

1
复制代码><

B)replace 替换,将敏感信息除去前三、后四位字符之外的其他字符用“*”替换,也是默认策略。比如:

1
复制代码我的手机号为18611001100

将会被整形为

1
复制代码我的手机号为186****1100

C)erase:参数,将匹配成功的字符串,全部替换为等长度的“*”,比如:

1
复制代码我的手机号为18611001100

将会被整形为:

1
复制代码我的手机号为***********

depth

匹配深度,即message中,最多匹配成功的次数,超过之后将会终止匹配,主要考虑性能,默认值为128。假如message中有200个手机号,那么匹配和替换到128个之后,将会终止操作,剩余的手机号将不会再替换。

mdcKeys

指定pattern拼接时,需要植入的mdc参数列表,比如mdcKeys=”name,address”,那么在pattern中将会包含:

1
复制代码name:%X{name:--}|address:%X{address:--}

其实大家主要关注的是option部分,Encoder的主要作用就是拼接一个pattern大概样例:

1
2
复制代码%d{yyyy-MM-dd/HH:mm:ss.SSS}|IP_OR_HOSTNAME|REQUEST_ID|REQUEST_SEQ|^_^|  
SYS_K1:%property{SYS_K1}|SYS_K2:%property{SYS_K2}|MDC_K1:%X{MDC_K1:--}|MDC_K
1
2
复制代码%X{MDC_K2:--}|^_^|  
[%t] %-5level %logger{50} %line - %m{2048,'(\\d{11})','replace',128}

格式中,domain1是必选,而且限定无法扩展 。

domain2根据配置文件指定的system properties和mdcKeys动态拼接,K-V结构,便于解析;可以为空。

domain3是常规message部分,其中%m携带options,此后Converter可以获取这些参数。

日志格式转换器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
复制代码package ch.qos.logback.classic.pattern;  

import ch.qos.logback.classic.PolicyEnum;
import ch.qos.logback.classic.spi.ILoggingEvent;

import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* <p>
* 日志格式转换器,会为每个appender创建一个实例,所以在配置层面需要考虑兼容。
* 主要目的是,根据配置的regex来匹配message,对于匹配成功的字符串进行替换操作,并返回修正后的message。
**/
public class ComplexMessageConverter extends MessageConverter {

protected String regex = "-";
protected int depth = 0;
protected String policy = "-";
protected int maxLength = 2048;
private ReplaceMatcher replaceMatcher = null;

@Override
public void start() {
List<String> options = getOptionList();
//如果存在参数选项,则提取
if (options != null && options.size() == 4) {
maxLength = Integer.valueOf(options.get(0));
regex = options.get(1);
policy = options.get(2);
depth = Integer.valueOf(options.get(3));

if ((regex != null && !regex.equals("-"))
&& (PolicyEnum.codeOf(policy) != null)
&& depth > 0) {
replaceMatcher = new ReplaceMatcher();
}
}
super.start();
}

@Override
public String convert(ILoggingEvent event) {
String source = event.getFormattedMessage();
if (source == null || source.isEmpty()) {
return source;
}
//复杂处理的原因:尽量少的字符串转换、空间重建、字符移动。共享一个builder
if (source.length() > maxLength || replaceMatcher != null) {
StringBuilder sb = null;
//如果超长截取
if (source.length() > maxLength) {
sb = new StringBuilder(maxLength + 6);
sb.append(source.substring(0, maxLength))
.append("❮❮❮");//后面增加三个终止符
}
//如果启动了matcher
if (replaceMatcher != null) {
//如果没有超过maxLength
if (sb == null) {
sb = new StringBuilder(source);
}
return replaceMatcher.execute(sb, policy);
}

return sb.toString();
}

return source;
}

class ReplaceMatcher {
Pattern pattern;

ReplaceMatcher() {
pattern = Pattern.compile(regex);
}

String execute(StringBuilder source, String policy) {

Matcher matcher = pattern.matcher(source);

int i = 0;
while (matcher.find() && (i < depth)) {
i++;
int start = matcher.start();
int end = matcher.end();
if (start < 0 || end < 0) {
break;
}
String group = matcher.group();
switch (policy) {
case "drop":
return "❯❮";//只要匹配,立即返回
case "replace":
source.replace(start, end, facade(group, true));
break;
case "erase":
default:
source.replace(start, end, facade(group, false));
break;

}
}
return source.toString();
}

}

/**
* 混淆,但是不能改变字符串的长度
*
* @param source
* @param included
* @return
*/
public static String facade(String source, boolean included) {
int length = source.length();
StringBuilder sb = new StringBuilder();
//长度超过11的,保留前三、后四,中间全部*替换
//低于11位或者included=false,全部*替换
if (length >= 11) {
if (included) {
sb.append(source.substring(0, 3));
} else {
sb.append("***");
}
sb.append(repeat('*', length - 7));
if (included) {
sb.append(source.substring(length - 4));
} else {
sb.append(repeat('*', 4));
}
} else {
sb.append(repeat('*', length));
}

return sb.toString();
}

private static String repeat(char t, int times) {
char[] r = new char[times];
for (int i = 0; i < times; i++) {
r[i] = t;
}
return new String(r);
}
}

这个类,主要是从CommonPatternLayoutEncoder声明的options(即regix、maxLength、policy、depth)初始化一个Matcher,针对message进行匹配和替换。正则比较消耗CPU。我门还要避免在message处理过程中,新建太多的字符串,否则会大量消耗内存;在处理时,尽可能确保主message只有一个,replace时不改变message的长度,可以避免因为重建String导致一些空间浪费。

之所以Converter能够发挥作用,离不开<conversionRule>,参看下文的配置样例。不过还需要注意,每个Appender都会根据<conversionRule>创建一个Converter实例,所以Converter设计时注意代码兼容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
复制代码<?xml version="1.0" encoding="UTF-8"?>  
<configuration>

...

<conversionRule conversionWord="m" converterClass="ch.qos.logback.classic.pattern.ComplexMessageConverter"/>

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<file>你的日志文件名</file>
<Append>true</Append>
<prudent>false</prudent>
<encoder class="ch.qos.logback.classic.encoder.CommonPatternLayoutEncoder">
<useDefaultRegex>true</useDefaultRegex>
<policy>replace</policy>
<maxLength>2048</maxLength>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<FileNamePattern>你的日志名.%d{yyyy-MM-dd}.%i</FileNamePattern>
<maxFileSize>64MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>6GB</totalSizeCap>
</rollingPolicy>
</appender>

<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.ConsolePatternLayoutEncoder"/>
</appender>

...
</configuration>

注意<conversionRule>节点中的conversionWord='m',其中m就是对应pattern中的%m,可以从%m获取options列表。

因为CommonPatternLayoutEncoder中已经限定了pattern的格式,所以我们在logback.xml中也不需要再显示的声明pattern参数。基于此,可以限定业务日志的格式保持统一。当然,如果有特殊情况需要自定义,仍然可以使用<pattern>来声明以覆盖默认格式。

作者简介:小姐姐味道 (xjjdog),一个不允许程序员走弯路的公众号。聚焦基础架构和Linux。十年架构,日百亿流量,与你探讨高并发世界,给你不一样的味道。我的个人微信xjjdog0,欢迎添加好友,​进一步交流。​

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Redis Stream(一) 理论知识和疑问探究

发表于 2020-06-23

Redis Stream 和Kafka类似, 基本上是一个Append Only的日志数据结构. 他主要有两个特色: 1. BLOCK API 2. Consumer Group

Redis Stream 具体的使用在官网: Intro to Redis Stream说的很清楚了, 对于英文不好的可以参见Redis中文站的相关翻译, 这里主要探讨几个在看官网和实际使用中产生的疑问. (假定读者已经对于基本使用有一定的了解).

一 : Entry IDs

组成

使用XADD命令创建一条记录

1
2
复制代码>XADD mystream * sensor-id 1234 temperature 19.8
1592836847842-0

这里返回的是Entry ID, 用于唯一标识一条记录. 它由两部分组成:

1
复制代码<millisecondsTime>-<sequenceNumber>

milliseconds time 部分为改Redis 节点根据当前时间戳产生的唯一ID, 实际可能出现:

  1. 时钟回拨, 当前时间比之前的要小
  2. 该毫秒存在多条记录

对于第二种情况, sequence number 代表这个时间点的第多少个记录. 默认从0开始, 没有sequenceNumber的时候也默认为0. 第一种情况, 文档上说, 如果当前时间小于之前时间, 那么去之前的时间递增, 而不是使用现在时间. 从而保证有序.

数据有序

image-20200622225736465

写入的数据必须要比当前的最大id要大.

image-20200622225946223

比较id大小, 也是先比较时间戳, 然后是sequence number.

数据消费

Redis Stream支持三种消费方式:

  1. 多个消费者可以同时看到新消息的到来
  2. 通过时序的方式来看这段时间内产生的数据.
  3. 多个节点共同消费同一份数据, 一个消息只能被一个节点消费.

这三种分别对应XREAD XRANGE, XREVRANGE, XREADGROUP三类命令. 这里简单概述下:

和Pub/Sub不同的是, Redis Stream保存消息历史. 即时客户端不在线, 客户端也可以拉取到所需要的数据. XREAD可以读取可以读取或者监听某个entry id之后的数据. 其中读取支持COUNT参数, 持续监听支持BLOCK的方式.

1
2
3
4
5
6
7
8
9
10
11
12
复制代码127.0.0.1:6379> XREAD BLOCK 0 STREAMS mystream $
1) 1) "mystream"
2) 1) 1) "1592840681758-0"
2) 1) "message"
2) "abc"
(22.72s)
127.0.0.1:6379> XREAD BLOCK 0 STREAMS mystream $
1) 1) "mystream"
2) 1) 1) "1592840705832-0"
2) 1) "message"
2) "abc"
(3.56s)

至于某个范围的数据就是 XRANGE, XREVRANGE, 其中支持COUNT, - + 分别指代最小和最大的Entry ID, XREVRANGE和XRANGE一致.

在XRANGE的start 和end中, 如果不指定sequenceNumber, start默认为0, end 默认为最大值

Consumer Group

It is very important to understand that Redis consumer groups have nothing to do from the point of view of the implementation with Kafka (TM) consumer groups, but they are only similar from the point of view of the concept they implement, so I decided to do not change terminology compared to the software product that initially popularized such idea.

基本概念和流程:

  1. 每条消息会被不同的Consumer消费, 不存在一条消息发往多个消费者的情况.
  2. 同一个Consumer Group的Consumer, 根据名字进行区分(大小写敏感). Consumer Group 保存所有的状态
  3. 每一个消费者组都有first ID never consumed. 当消费者请求数据, 只能获取到没有被发送的数据.(XREADGROUP >)
  4. 使用消费者组消费, 需要使用ACK命令, 确认这条消息被正确消费, 可以从Consumer Group中排除了.(XACK)
  5. 当消息被发往消费者, 还没有被ACK标记时, 这条消息会维护进PENDING列表. 但是每个消费者只能看见发向自己的消息.(PEL和XREADGROUP)

XREADGROUP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码127.0.0.1:6379> xreadgroup group mygroup alice count 1 STREAMS mystream >
1) 1) "mystream"
2) 1) 1) "1592842069466-0"
2) 1) "key"
2) "value1"
127.0.0.1:6379> xreadgroup group mygroup alice count 1 STREAMS mystream 0
1) 1) "mystream"
2) 1) 1) "1592842069466-0"
2) 1) "key"
2) "value1"
127.0.0.1:6379> xack mystream mygroup 1592842069466-0
(integer) 1
127.0.0.1:6379> xreadgroup group mygroup alice count 1 STREAMS mystream 0
1) 1) "mystream"
2) (empty array)
  1. 如果ID为>, 只会接受没有发送给其他消费者的数据, 并且会更新last ID
  2. 如果ID为其他有效的数字ID时, 我们会获取 history of pending messages. 被发往消费者, 但是执行ACK的数据

XPENDING和XCLAIM

某些消费者可能永久无法恢复, 但是有一些数据还是在PEL中. 这个时候我们可以通过XCLAIM把它发到别的消费者的PEL中. 具体语法见文档.

Consumer Group整体处理流程

那么目前一个完整的Consumer Group处理流程是什么样子的呢?

  1. 初始化系统时, 检测Stream和Group是否存在, 如果不存在通过XGROUP CREATE xxx $ MKSTREAM的方式进行创建.
  2. 项目启动时, 检测本Consumer中处在PEL中的消息, 并且进行处理. 保证消息会被ACK.(重试应该在代码中实现)
  3. 主流程: 设置ID为>的方式, 监听新数据的到来, 处理然后ACK.
  4. BackUp: 定时扫描PEL(不指定Consumer), 将超出某个处理时间范围的数据进行重新处理(通过XREAD), 或者通过XCLAIM的方式指定给别的消费者(这样需要将第二步做成定时任务).

后续任务

  1. Redis分布式系统中id生成和保证有序
  2. Redis Stream为什么要设计成时间序列呢?
  3. 测试时钟回拨和相关ID生成

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

大厂面试官必问的Mysql锁机制

发表于 2020-06-22

前几天有粉丝和我聊到他找工作面试大厂时被问的问题,因为现在疫情期间,找工作也特别难找。他说面试的题目也比较难,都偏向于一两年的工作经验的面试题。

他说在一面的时候被问到Mysql的面试题,索引那块自己都回答比较满意,但是问到Mysql的锁机制就比较懵了。

因为平时没有关注Mysql的锁机制,当被问到高并发场景下锁机制是怎么保证数据的一致性的和事务隔离性的。

他把他面试的过程分享给了我,Mysql高并发锁机制的问题,几乎面大厂都有被问到,Mysql怎么在高并发下控制并发访问的?

我细想了一下,Mysql的锁机制确实非常重要,所以在这里做一个全面的总结整理,便于以后的查阅,也分享给各位读者大大们。

Mysql的锁机制还是有点难理解的,所以这篇文章采用图文结合的方式讲解难点,帮助大家理解,讲解的主要内容如下图的脑图所示,基本涵盖了Mysql锁机制的所有知识点。

本文脑图


锁种类


Mysql中锁的分类按照不同类型的划分可以分成不同的锁,按照「锁的粒度」划分可以分成:「表锁、页锁、行锁」;按照「使用的方式」划分可以分为:「共享锁」和「排它锁」;按照思想的划分:「乐观锁」和「悲观锁」。

下面我们对着这几种划分的锁进行详细的解说和介绍,在了解设计者设计锁的概念的同时,也能深入的理解设计者的设计思想。

「表锁」是粒度最大的锁,开销小,加锁快,不会出现死锁,但是由于粒度太大,因此造成锁的冲突几率大,并发性能低。

Mysql中「MyISAM储存引擎就支持表锁」,MyISAM的表锁模式有两种:「表共享读锁」和「表独占写锁」。

当一个线程获取到MyISAM表的读锁的时候,会阻塞其他用户对该表的写操作,但是不会阻塞其它用户对该用户的读操作。

相反的,当一个线程获取到MyISAM表的写锁的时候,就会阻塞其它用户的读写操作对其它的线程具有排它性。

「页锁」的粒度是介于行锁和表锁之间的一种锁,因为页锁是在BDB中支持的一种锁机制,也很少没人提及和使用,所以这里制作概述,不做详解。

「行锁」是粒度最小的锁机制,行锁的加锁开销性能大,加锁慢,并且会出现死锁,但是行锁的锁冲突的几率低,并发性能高。

行锁是InnoDB默认的支持的锁机制,MyISAM不支持行锁,这个也是InnoDB和MyISAM的区别之一。

行锁在使用的方式上可以划分为:「共享读锁(S锁)「和」排它写锁(X锁)」。

当一个事务对Mysql中的一条数据行加上了S锁,当前事务不能修改该行数据只能执行度操作,其他事务只能对该行数据加S锁不能加X锁。

若是一个事务对一行数据加了X锁,该事物能够对该行数据执行读和写操作,其它事务不能对该行数据加任何的锁,既不能读也不能写。

「悲观锁和乐观锁是在很多框架都存在的一种思想,不要狭义地认为它们是某一种框架的锁机制」。

数据库管理系统中为了控制并发,保证在多个事务执行时的数据一致性以及事务的隔离性,使用悲观锁和乐观锁来解决并发场景下的问题。

Mysql中「悲观锁的实现是基于Mysql自身的锁机制实现,而乐观锁需要程序员自己去实现的锁机制」,最常见的乐观锁实现就锁机制是「使用版本号实现」。

乐观锁设计思想的在CAS的运用也是比较经典,之前我写过一篇关于CAS的文章,大家感兴趣的可以参考这一篇[]。

从上面的介绍中说了每一种锁的概念,但是很难说哪一种锁就是最好的,锁没有最好的,只有哪种业务场景最适合哪种锁,具体业务具体分析。

下面我们就具体基于Mysql的存储引擎详细的分析每一种锁在存储引擎中的运用和实现。

MyISAM

MyISAM中默认支持的表级锁有两种:「共享读锁」和「独占写锁」。表级锁在MyISAM和InnoDB的存储引擎中都支持,但是InnoDB默认支持的是行锁。

Mysql中平时读写操作都是隐式的进行加锁和解锁操作,Mysql已经自动帮我们实现加锁和解锁操作了,若是想要测试锁机制,我们就要显示的自己控制锁机制。

Mysql中可以通过以下sql来显示的在事务中显式的进行加锁和解锁操作:

1
2
3
4
5
6
复制代码// 显式的添加表级读锁
LOCK TABLE 表名 READ
// 显示的添加表级写锁
LOCK TABLE 表名 WRITE
// 显式的解锁(当一个事务commit的时候也会自动解锁)
unlock tables;

下面我们就来测试一下MyISAM中的表级锁机制,首先创建一个测试表employee ,这里要指定存储引擎为MyISAM,并插入两条测试数据:

1
2
3
4
5
6
7
8
复制代码CREATE TABLE IF NOT EXISTS employee (
id INT PRIMARY KEY auto_increment,
name VARCHAR(40),
money INT
)ENGINE MyISAM

INSERT INTO employee(name, money) VALUES('黎杜', 1000);
INSERT INTO employee(name, money) VALUES('非科班的科班', 2000);

查看一下,表结果如下图所示:

MyISAM表级写锁

(1)与此同时再开启一个session窗口,然后在第一个窗口执行下面的sql,在session1中给表添加写锁:

1
复制代码LOCK TABLE employee WRITE

(2)可以在session2中进行查询或者插入、更新该表数据,可以发现都会处于等待状态,也就是session1锁住了整个表,导致session2只能等待:


(3)在session1中进行查询、插入、更新数据,都可以执行成功:


「总结:」 从上面的测试结果显示「当一个线程获取到表级写锁后,只能由该线程对表进行读写操作,别的线程必须等待该线程释放锁以后才能操作」。

MyISAM表级共享读锁

(1)接下来测试一下表级共享读锁,同样还是利用上面的测试数据,第一步还是在session1给表加读锁。


(2)然后在session1中尝试进行插入、更新数据,发现都会报错,只能查询数据。


(3)最后在session2中尝试进行插入、更新数据,程序都会进入等待状态,只能查询数据,直到session1解锁表session2才能插入、更新数据。


「总结:」 从上面的测试结果显示「当一个线程获取到表级读锁后,该线程只能读取数据不能修改数据,其它线程也只能加读锁,不能加写锁」。

MyISAM表级锁竞争情况

MyISAM存储引擎中,可以通过查询变量来查看并发场景锁的争夺情况,具体执行下面的sql语句:

1
复制代码show status like 'table%';


主要是查看table_locks_waited和table_locks_immediate的值的大小分析锁的竞争情况。

Table_locks_immediate:表示能够立即获得表级锁的锁请求次数;Table_locks_waited表示不能立即获取表级锁而需要等待的锁请求次数分析,「值越大竞争就越严重」。

并发插入

通过上面的操作演示,详细的说明了表级共享锁和表级写锁的特点。但是在平时的执行sql的时候,这些「解锁和释放锁都是Mysql底层隐式的执行的」。

上面的演示只是为了证明显式的执行事务的过程共享锁和表级写锁的加锁和解锁的特点,实际并不会这么做的。

在我们平时执行select语句的时候就会隐式的加读锁,执行增、删、改的操作时就会隐式的执行加写锁。

MyISAM存储引擎中,虽然读写操作是串行化的,但是它也支持并发插入,这个需要设置内部变量concurrent_insert的值。

它的值有三个值0、1、2。可以通过以下的sql查看concurrent_insert的默认值为「AUTO(或者1)」。


concurrent_insert的值为NEVER (or 0)表示不支持比并发插入;值为AUTO(或者1)表示在MyISAM表中没有被删除的行,运行另一个线程从表尾插入数据;值为ALWAYS (or 2)表示不管是否有删除的行,都允许在表尾插入数据。

锁调度

MyISAM存储引擎中,「假如同时一个读请求,一个写请求过来的话,它会优先处理写请求」,因为MyISAM存储引擎中认为写请求比都请求重要。

这样就会导致,「假如大量的读写请求过来,就会导致读请求长时间的等待,或者”线程饿死”,因此MyISAM不适合运用于大量读写操作的场景」,这样会导致长时间读取不到用户数据,用户体验感极差。

当然可以通过设置low-priority-updates参数,设置请求链接的优先级,使得Mysql优先处理读请求。

InnoDB

InnoDB和MyISAM不同的是,InnoDB支持「行锁」和「事务」,行级锁的概念前面以及说了,这里就不再赘述,事务的四大特性的概述以及实现的原理可以参考这一篇[]。

InnoDB中除了有「表锁」和「行级锁」的概念,还有Gap Lock(间隙锁)、Next-key Lock锁,「间隙锁主要用于范围查询的时候,锁住查询的范围,并且间隙锁也是解决幻读的方案」。

InnoDB中的行级锁是「对索引加的锁,在不通过索引查询数据的时候,InnoDB就会使用表锁」。

「但是通过索引查询的时候是否使用索引,还要看Mysql的执行计划」,Mysql的优化器会判断是一条sql执行的最佳策略。

若是Mysql觉得执行索引查询还不如全表扫描速度快,那么Mysql就会使用全表扫描来查询,这是即使sql语句中使用了索引,最后还是执行为全表扫描,加的是表锁。

若是对于Mysql的sql执行原理不熟悉的可以参考这一篇文章[]。最后是否执行了索引查询可以通过explain来查看,我相信这个大家都是耳熟能详的命令了。

InnoDB行锁和表锁

InnoDB的行锁也是分为行级「共享读锁(S锁)「和」排它写锁(X锁)」,原理特点和MyISAM的表级锁两种模式是一样的。

若想显式的给表加行级读锁和写锁,可以执行下面的sql语句:

1
2
3
4
复制代码// 给查询sql显示添加读锁
select ... lock in share mode;
// 给查询sql显示添加写锁
select ... for update;

(1)下面我们直接进入锁机制的测试阶段,还是创建一个测试表,并插入两条数据:

1
2
3
4
5
6
7
8
9
10
复制代码// 先把原来的MyISAM表给删除了
DROP TABLE IF EXISTS employee;
CREATE TABLE IF NOT EXISTS employee (
id INT PRIMARY KEY auto_increment,
name VARCHAR(40),
money INT
)ENGINE INNODB;
// 插入测试数据
INSERT INTO employee(name, money) VALUES('黎杜', 1000);
INSERT INTO employee(name, money) VALUES('非科班的科班', 2000);

(2)创建的表中可以看出对表中的字段只有id添加了主键索引,接着就是在session1窗口执行begin开启事务,并执行下面的sql语句:

1
2
复制代码// 使用非索引字段查询,并显式的添加写锁
select * from employee where name='黎杜' for update;

(3)然后在session2中执行update语句,上面查询的式id=1的数据行,下面update的是id=2的数据行,会发现程序也会进入等待状态:

1
复制代码update employee set name='ldc' where id =2;

可见若是「使用非索引查询,直接就是使用的表级锁」,锁住了整个表。


(4)若是session1使用的是id来查询,如下图所示:


(5)那么session2是可以成功update其它数据行的,但是这里我建议使用数据量大的表进行测试,因为前面我说过了「是否执行索引还得看Mysql的执行计划,对于一些小表的操作,可能就直接使用全表扫描」。


(6)还有一种情况就是:假如我们给name字段也加上了普通索引,那么通过普通索引来查询数据,并且查询到多行数据,拿它是锁这多行数据还是锁整个表呢?

下面我们来测试一下,首先给「name字段添加普通索引」,如下图所示:


(6)并插入一条新的数据name值与id=2的值相同,并显式的加锁,如下若是:


(7)当update其它数据行name值不是ldc的也会进入等待状态,并且通过explain来查看是否name=’ldc’有执行索引,可以看到sql语句是有执行索引条件的。


结论:从上面的测试锁机制的演示可以得出以下几个结论:

  1. 执行非索引条件查询执行的是表锁。
  2. 执行索引查询是否是加行锁,还得看Mysql的执行计划,可以通过explain关键字来查看。
  3. 用普通键索引的查询,遇到索引值相同的,也会对其他的操作数据行的产生影响。

InnoDB间隙锁

当我们使用范围条件查询而不是等值条件查询的时候,InnoDB就会给符合条件的范围索引加锁,在条件范围内并不存的记录就叫做”间隙(GAP)”

大家大概都知道在事务的四大隔离级别中,不可重复读会产生幻读的现象,只能通过提高隔离级别到串行化来解决幻读现象。

但是Mysql中的不可重复是已经解决了幻读问题,它通过引入间隙锁的实现来解决幻读,通过给符合条件的间隙加锁,防止再次查询的时候出现新数据产生幻读的问题。

例如我们执行下面的sql语句,就会对id大于100的记录加锁,在id>100的记录中肯定是有不存在的间隙:

1
复制代码Select * from  employee where id> 100 for update;

(1)接着来测试间隙锁,新增一个字段num,并将num添加为普通索引、修改之前的数据使得num之间的值存在间隙,操作如下sql所示:

1
2
3
4
5
复制代码alter table employee add num int not null default 0;
update employee set num = 1 where id = 1;
update employee set num = 1 where id = 2;
update employee set num = 3 where id = 3;
insert into employee values(4,'kris',4000,5);


(2)接着在session1的窗口开启事务,并执行下面操作:


(3)同时打开窗口session2,并执行新增语句:

1
2
3
4
复制代码insert into employee values(5,'ceshi',5000,2);  // 程序出现等待
insert into employee values(5,'ceshi',5000,4); // 程序出现等待
insert into employee values(5,'ceshi',5000,6); // 新增成功
insert into employee values(6,'ceshi',5000,0); // 新增成功

「从上面的测试结果显示在区间(1,3]U[3,5)之间加了锁,是不能够新增数据行,这就是新增num=2和num=4失败的原因,但是在这个区间以外的数据行是没有加锁的,可以新增数据行」。

根据索引的有序性,而普通索引是可以出现重复值,那么当我们第一个sesson查询的时候只出现一条数据num=3,为了解决第二次查询的时候出现幻读,也就是出现两条或者更多num=3这样查询条件的数据。

Mysql在满足where条件的情况下,给(1,3]U[3,5)区间加上了锁不允许插入num=3的数据行,这样就解决了幻读。

这里抛出几种情况接着来测试间隙锁。主键索引(唯一索引)是否会加上间隙所呢?范围查询是否会加上间隙锁?使用不存在的检索条件是否会加上间隙锁?

先来说说:「主键索引(唯一索引)是否会加上间隙所呢?」

因为主键索引具有唯一性,不允许出现重复,那么当进行等值查询的时候id=3,只能有且只有一条数据,是不可能再出现id=3的第二条数据。

因此它只要锁定这条数据(锁定索引),在下次查询当前读的时候不会被删除、或者更新id=3的数据行,也就保证了数据的一致性,所以主键索引由于他的唯一性的原因,是不需要加间隙锁的。

再来说说第二个问题:「范围查询是否会加上间隙锁?」

直接在session1中执行下面的sql语句,并在session2中在这个num>=3的查询条件内和外新增数据:

1
2
3
4
复制代码select * from employee where num>=3 for update;
insert into employee values(6,'ceshi',5000,2); // 程序出现等待
insert into employee values(7,'ceshi',5000,4); // 程序出现等待
insert into employee values(8,'ceshi',5000,1); // 新增数据成功

我们来分析以下原理:单查询num>=3的时候,在现有的employee表中满足条件的数据行,如下所示:

id num
3 3
4 5
5 6

那么在设计者的角度出发,我为了解决幻读的现象:在num>=3的条件下是必须加上间隙锁的。

而在小于num=3中,下一条数据行就是num=1了,为了防止在(1,3]的范围中加入了num=3的数据行,所以也给这个间隙加上了锁,这就是添加num=2数据行出现等待的原因。

最后来说一说:「使用不存在的检索条件是否会加上间隙锁?」

假如是查询num>=8的数据行呢?因为employee表并不存在中num=8的数据行,num最大num=6,所以为了解决幻读(6,8]与num>=8也会加上锁。

说到这里我相信很多人已经对间隙锁有了清晰和深入的认识,可以说是精通了,又可以和面试官互扯了。

假如你是第一次接触Mysql的锁机制,第一次肯定是懵的,建议多认真的看几遍,跟着案例敲一下自己深刻的去体会,慢慢的就懂了。

死锁

死锁在InnoDB中才会出现死锁,MyISAM是不会出现死锁,因为MyISAM支持的是表锁,一次性获取了所有得锁,其它的线程只能排队等候。

而InnoDB默认支持行锁,获取锁是分步的,并不是一次性获取所有得锁,因此在锁竞争的时候就会出现死锁的情况。

虽然InnoDB会出现死锁,但是并不影响InnoDB最受欢成为迎的存储引擎,MyISAM可以理解为串行化操作,读写有序,因此支持的并发性能低下。

死锁案例一

举一个例子,现在数据库表employee中六条数据,如下所示:


其中name=ldc的有两条数据,并且name字段为普通索引,分别是id=2和id=3的数据行,现在假设有两个事务分别执行下面的两条sql语句:

1
2
3
4
复制代码// session1执行
update employee set num = 2 where name ='ldc';
// session2执行
select * from employee where id = 2 or id =3;

其中session1执行的sql获取的数据行是两条数据,假设先获取到第一个id=2的数据行,然后cpu的时间分配给了另一个事务,另一个事务执行查询操作获取了第二行数据也就是id=3的数据行。

当事务2继续执行的时候获取到id=3的数据行,锁定了id=3的数据行,此时cpu又将时间分配给了第一个事务,第一个事务执行准备获取第二行数据的锁,发现已经被其他事务获取了,它就处于等待的状态。

当cpu把时间有分配给了第二个事务,第二个事务准备获取第一行数据的锁发现已经被第一个事务获取了锁,这样就行了死锁,两个事务彼此之间相互等待。

死锁案例二

第二种死锁情况就是当一个事务开始并且update一条id=1的数据行时,成功获取到写锁,此时另一个事务执行也update另一条id=2的数据行时,也成功获取到写锁(id为主键)。

此时cpu将时间分配给了事务一,事务一接着也是update id=2的数据行,因为事务二已经获取到id=2数据行的锁,所以事务已处于等待状态。

事务二有获取到了时间,像执行update id=1的数据行,但是此时id=1的锁被事务一获取到了,事务二也处于等待的状态,因此形成了死锁。

session1 session2
begin;update t set name=’测试’ where id=1; begin
update t set name=’测试’ where id=2;
update t set name=’测试’ where id=2;
等待….. update t set name=’测试’ where id=1;
等待….. 等待……

死锁的解决方案

首先要解决死锁问题,在程序的设计上,当发现程序有高并发的访问某一个表时,尽量对该表的执行操作串行化,或者锁升级,一次性获取所有的锁资源。

然后也可以设置参数innodb_lock_wait_timeout,超时时间,并且将参数innodb_deadlock_detect 打开,当发现死锁的时候,自动回滚其中的某一个事务。

总结

上面详细的介绍了MyISAM和InnoDB两种存储引擎的锁机制的实现,并进行了测试。

MyISAM的表锁分为两种模式:「共享读锁」和「排它写锁」。获取的读锁的线程对该数据行只能读,不能修改,其它线程也只能对该数据行加读锁。

获取到写锁的线程对该数据行既能读也能写,对其他线程对该数据行的读写具有排它性。

MyISAM中默认写优先于去操作,因此MyISAM一般不适合运用于大量读写操作的程序中。

InnoDB的行锁虽然会出现死锁的可能,但是InnoDB的支持的并发性能比MyISAM好,行锁的粒度最小,一定的方法和措施可以解决死锁的发生,极大的发挥InnoDB的性能。

InnoDB中引入了间隙锁的概念来决解出现幻读的问题,也引入事务的特性,通过事务的四种隔离级别,来降低锁冲突,提高并发性能。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

SpringMVC源码分析四、从Java内省机制到BeanW

发表于 2020-06-22

Java内省机制

描述

1
2
3
4
5
6
7
8
复制代码在这里笔者先以我自己的理解来说下什么是Java的内省机制, Java内省机制是对反射的一种封装, 是Java提供给开发
者对一个对象属性的查看和操作、方法的操作以及对象的描述等, 或许这样说比较抽象, 之后我们会举一些例子来说明

当然, 这个机制在对于属性操作的时候是有一定限制的, 这个我们留个悬念, 文章之后会特别对这一块说明

Java的内省机制, 不可避免的会涉及到几个类: Introspector、PropertyDescriptor、PropertyEditor以及
BeanInfo, 接下来我们详细描述下这几个类, 并用一些例子来演示, 这样大家就能够更加清晰的理解Java的内省机制
了

PropertyDescriptor属性描述器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
复制代码先以一个例子来入门吧:

public class User {
private String name;

private String aName;

getter / setter / toString
}

public static void main (String[] args) throws Exception {
User user = new User();
System.out.println( user );
PropertyDescriptor propertyDescriptor = new PropertyDescriptor( "name", User.class );
Method readMethod = propertyDescriptor.getReadMethod();
System.out.println( readMethod.invoke( user ) );
Method writeMethod = propertyDescriptor.getWriteMethod();
writeMethod.invoke( user, "hello" );
System.out.println( user );
}

输出结果:
User{name='null', aName='null'}
null
User{name='hello', aName='null'}

分析:
可以看到, 我们先创建了一个对象, 然后输出的结果中里面的属性都是null, 这个就不用解释了吧..........

然后我们创建了一个PropertyDescriptor属性描述器, 传入了属性名称和所在的类, 这时候大家应该就可能会
想到, 这个PropertyDescriptor中应该是有两个属性, 可能分别叫targetClass, propertyName这样的, 从而
保存了这两个传入的值, 好的, 接着往下看......

getReadMethod获取读方法, 什么是读方法呢?就是getXXXX, 那我们描述的属性是name, 获取的自然就是getName
了, 返回值是一个Method对象, 通过反射调用, 传入user对象, 就能获取到该对象中的name属性

getWriteMethod获取写方法, 什么是写方法呢?就是setXXXX, 那我们描述的属性是name, 获取的自然就是setName
了, 返回值是一个Method对象, 通过反射调用, 传入user对象和期望设置的值, 再次输出user对象的时候, 会发
现name已经被设置好值了

上面几步操作经过描述, 大家应该有点感觉了是吧.....没错, PropertyDescriptor属性描述器, 就是对属性反
射的一种封装, 方便我们直接操作属性, 当我们利用构造方法new的时候, 内部就会帮我们找出这个属性的get和
set方法, 分别作为这个属性的读方法和写方法, 我们通过PropertyDescriptor对属性的操作, 其实就是利用反
射对其get和set方法的操作而已, 但是其内部实现还是有点意思的, 利用软引用和弱引用来保存方法、以及Class
对象的引用, 这个软引用和弱引用, 笔者之后也会把之前写的关于这个Java四大引用类型的文章也上传到掘金,
大家要是之前没了解过四大引用类型的话, 可以理解为这是为了防止内存泄露的一种操作就好了, 或者直接理解为
就是一个直接引用就可以了........

接下来再来看一个例子, 我们重用之前的User类:

public static void main (String[] args) throws Exception{
PropertyDescriptor propertyDescriptor = new PropertyDescriptor( "aName", User.class );
Method readMethod = propertyDescriptor.getReadMethod();
Method writeMethod = propertyDescriptor.getWriteMethod();
}

分析:
可以看到, 此时我们要创建的属性描述器是User这个类中的aName属性, 这个aName属性的get和set方法是这样的
public String getaName() {
return aName;
}

public void setaName(String aName) {
this.aName = aName;
}

这个是idea自动生成的, 或者编辑器自动生成的get/set就是这样的, 但是我们会发现, 当执行上述的main方法
的时候, 竟然报错了, 报错信息: java.beans.IntrospectionException: Method not found: isAName

因为Java内省机制是有一定的规范的, 查找一个属性的get/set方法, 需要将该属性的第一个字母变成大写, 然后
前面拼接上get/set前缀, 由于我们编辑器生成的get/set方法在属性前两个字母一大一小的情况下, 不会改变其
set和get方法的前两个字母, 所以导致了报错, 这一点需要注意

PropertyEditor属性编辑器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
复制代码PropertyEditor也是Java提供的一种对属性的扩展了, 用于类型的转换, 比如我们设置属性的时候, 期望将String类
型转为其他类型后再设置到对象中, 就需要利用到这个属性编辑器, Java中PropertyEditor接口有一个直接子类
PropertyEditorSupport, 该类基本是这样定义的(伪代码):
public class PropertyEditorSupport implements PropertyEditor {
private Object value;

public Object getValue() {
return value;
}

public void setValue(Object value) {
this.value = value;
}

public void setAsText(String text) throws java.lang.IllegalArgumentException {
if (value instanceof String) {
setValue(text);
return;
}
throw new java.lang.IllegalArgumentException(text);
}

public String getAsText() {
return (this.value != null)
? this.value.toString()
: null;
}
}


分析:
上面这个代码是PropertyEditorSupport的一小部分, 可以看到其实就是一个简单的类而已, 里面有一个Object
类型的属性value, 提供了get/set方法, 与此同时, 提供了setAsText和getAsText方法, 通常我们需要继承这
个类来完成扩展, 比如说这个value是一个Date类型, 我们期望设置的时候提供的是字符串, 那么就要继承这个类
并重写其setAsText方法, 如下:

public class DatePropertyEditor extends PropertyEditorSupport {
@Override
public String getAsText() {
Date value = (Date) getValue();
DateFormat dateFormat = new SimpleDateFormat( "yyyy-MM-dd HH:mm:ss" );
return dateFormat.format( value );
}

@Override
public void setAsText(String text) throws IllegalArgumentException {
DateFormat dateFormat = new SimpleDateFormat( "yyyy-MM-dd HH:mm:ss" );
try {
Date date = dateFormat.parse( text );
setValue( date );
} catch (ParseException e) {}
}
}

我们先来写个测试类玩玩吧, 这时候先不去分析PropertyEditorSupport中的其他功能, 先以最简单的开始:

public static void main(String[] args) throws Exception{
DatePropertyEditor datePropertyEditor = new DatePropertyEditor();
datePropertyEditor.setAsText( "2020-03-06 15:33:33" );
Date value = (Date) datePropertyEditor.getValue();
System.out.println( value );
}

创建了一个自定义的日期属性编辑器, 结合上面该类的代码, 当调用setAsText的时候, 传入了一个字符串日期, 那么
就会被解析成一个Date类型, 最后保存到value中, 从而getValue返回的类型就是Date类型, 这个应该很容易理解,
那么到这里, 我们算是入门了, 简单的体会了下其功能, 该类中还有一个source属性和listeners属性, 这个我们就
简单介绍下, source, 通常是我们需要操作的对象, listeners就是监听器, 在setValue调用时, 除了直接赋值
this.value = value外, 还会触发所有的监听器, 调用监听器的方法, 监听器方法中会传入一个事件对象, 事件对象
中保存了该source, 也就是说, PropertyEditorSupport中有一个Object类型的source属性, 同时有一个监听器对象
集合, 这个source属性可以在监听器对象方法被调用的时候获取到(存在于事件中, 调用监听器方法会放入一个事件对
象, 构造该事件对象会传入source), 由于暂时没有用到这两个, 所以先不进行扩展, 没有应用场景, 扩展也没多大意
义

BeanInfo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
复制代码BeanInfo是一个接口, 有一个子类GenericBeanInfo, 通常情况下创建的是GenericBeanInfo, 其是Introspector
中的一个包访问权下的类, 我们先来简单看看其结构吧:

class GenericBeanInfo extends SimpleBeanInfo {
private BeanDescriptor beanDescriptor;
private PropertyDescriptor[] properties;
private MethodDescriptor[] methods;
}

分析:
只列举出了几个简单的属性, 但是够用了, BeanDescriptor就是持有类Class对象的引用而已,
PropertyDescriptor中就是这个类的所有属性描述器, MethodDescriptor自然就所有的方法描述器了, 跟属性
描述器是类似的, 都是为了方便反射调用的, 那么BeanInfo的作用就出来了, 就是对一个类所有的属性、方法等
反射操作封装后的集合体, 那么它如何得到呢?这就用到了Introspector这个类了, 如下:

public static void main(String[] args) throws Exception {
BeanInfo beanInfo = Introspector.getBeanInfo( Customer.class );
PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors();
MethodDescriptor[] methodDescriptors = beanInfo.getMethodDescriptors();
BeanDescriptor beanDescriptor = beanInfo.getBeanDescriptor();
}

那么到此为止, 我们要讲解的内省机制的关系就出来了, 通过Introspector获取一个类的BeanInfo, 通过
BeanInfo能够获取属性描述器、方法描述器、类Class对象, 利用获取到的属性描述器, 我们能够往一个该类实例
中放入数据

CachedIntrospectionResults

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
复制代码--------------看到下面代码别慌......请直接看着下面我的文字分析来看代码---------------
public class CachedIntrospectionResults {
static final ConcurrentMap<Class<?>, CachedIntrospectionResults> strongClassCache =
new ConcurrentHashMap<>(64);

static final ConcurrentMap<Class<?>, CachedIntrospectionResults> softClassCache =
new ConcurrentReferenceHashMap<>(64);

private final BeanInfo beanInfo;

private final Map<String, PropertyDescriptor> propertyDescriptorCache;

static CachedIntrospectionResults forClass(Class<?> beanClass) throws BeansException {
CachedIntrospectionResults results = strongClassCache.get(beanClass);
if (results != null) {
return results;
}
results = softClassCache.get(beanClass);
if (results != null) {
return results;
}

results = new CachedIntrospectionResults(beanClass);
ConcurrentMap<Class<?>, CachedIntrospectionResults> classCacheToUse;

if (ClassUtils.isCacheSafe(beanClass, CachedIntrospectionResults.class.getClassLoader()) ||
isClassLoaderAccepted(beanClass.getClassLoader())) {
classCacheToUse = strongClassCache;
}
else {
classCacheToUse = softClassCache;
}

CachedIntrospectionResults existing = classCacheToUse.putIfAbsent(beanClass, results);
return (existing != null ? existing : results);
}
}

分析:
CachedIntrospectionResults这个类是Spring提供的对类的内省机制使用的工具类, 不同于Introspector之处
在于, 该类提供类内省机制时的数据缓存, 即内省获得的PropertyDescriptor这些数据进行了缓存

首先我们来看看最上面的两个静态方法, 全局变量保存了两个Map, key是class对象, value是
CachedIntrospectionResults对象, 大家应该可以想到, 这应该是类似于利用Map实现的单例吧, 提供了缓存的
功能, 之后可以通过static方法直接访问

再来看看其两个属性, 一个是BeanInfo, 一个是propertyDescriptorCache, 前者就不用笔者描述了吧, 就是
内省机制中对一个类的功能的封装, 前面已经专门对这个类进行说明了, 后者是属性名到属性描述器的映射Map,
这个应该也不用详细解释了

CachedIntrospectionResults类实例, 封装了一个类通过内省机制获得的BeanInfo和属性描述器映射, 全局
的static变量中保存了所有要操作的类的CachedIntrospectionResults类实例缓存, 采用强引用和软引用是为
了防止内存泄露用的

再来看看forClass, 表示根据Class对象获取该类的CachedIntrospectionResults类实例, 可以看到, 先从强
引用缓存中获取, 没拿到则从软引用中获取, 这里大家不熟悉四大引用类型的话, 可以直接认为是从Map中根据
Class对象获取对应的CachedIntrospectionResults类实例, 如果没有获取到, 则创建一个并放到Map中去

小小的总结:
CachedIntrospectionResults类对象通过全局变量Map提供了对内省机制获得的BeanInfo信息的缓存, 从而可以
方便我们通过static方法获取对应类的内省信息

BeanWrapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
复制代码public class BeanWrapperImpl extends AbstractNestablePropertyAccessor implements BeanWrapper {
@Nullable
private CachedIntrospectionResults cachedIntrospectionResults;
}

分析:
可以看到, BeanWrapper实例中内置了一个CachedIntrospectionResults, 之前分析DispathcherSerlvet的
初始化流程的时候, 小小的说明了下BeanWrapper的作用, 但是没有分析其怎么实现属性的设置的, 这个时候我
们就深入分析下

那既然内部存储了一个CachedIntrospectionResults实例, 大家应该很容易的想到, 内部就是通过该实例来获取
对应的属性描述器, 然后获取读方法和写方法来设置属性的吗?确实如此, 接下来我们看看setPropertyValue这个
方法吧, 有很多个重载方法, 我们以直接通过属性名和属性值来设置的这个方法为例子

public void setPropertyValue(String propertyName, @Nullable Object value) {
AbstractNestablePropertyAccessor nestedPa=getPropertyAccessorForPropertyPath(propertyName);

PropertyTokenHolder tokens = getPropertyNameTokens(getFinalPath(nestedPa, propertyName));
nestedPa.setPropertyValue(tokens, new PropertyValue(propertyName, value));
}

简单解析下, AbstractNestablePropertyAccessor提供了嵌套属性设置的功能, 比如一个实体类中还有另一个实体
类, 这种情况下也是能设置成功的, 不用管这些代码什么意思, 下面跟着代码走, 可以看到一个豁然开朗的东西...
nestedPa.setPropertyValue中调用了processLocalProperty(tokens, pv), processLocalProperty中获取了一
个PropertyHandler, 就是通过这个PropertyHandler来完成属性的设置的, 接下来我们看看这个PropertyHandler
是什么

protected BeanPropertyHandler getLocalPropertyHandler(String propertyName) {
PropertyDescriptor pd
= getCachedIntrospectionResults().getPropertyDescriptor(propertyName);
if (pd != null) {
return new BeanPropertyHandler(pd);
}
return null;
}

是不是觉得豁然开朗, 原来BeanWrapper中, 最终就是通过PropertyDescriptor来完成属性的设置的!!!!

总结

1
2
3
4
5
6
7
复制代码我们从Java内省机制进行出发, 引出了PropertyDescriptor、PropertyEditor(类型转换用)、Introspector、
BeanInfo这四个Java内省机制中的核心类, 同时也捋清楚了内省机制原来就是对反射的封装, 进而引出了
CachedIntrospectionResults类, 该类是Spring对内省机制中获得的数据的缓存, 进而引出了BeanWrapper的实现
原理, 里面内置了一个CachedIntrospectionResults对象, 对属性的操作最终就会变成该对象中的
PropertyDescriptor的操作, 需要说明的是, CachedIntrospectionResults还能提供嵌套的属性设置, 这个需要
注意, 其实Spring对Java的内省机制的封装还有很多很多可以说的, 或许在不久的将来, 笔者会专门写一个系列来描
述Spring对Java内省机制的封装, 大家可以期待期待哈.....

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…801802803…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%