IceOfSummerの博客
还是自己搭的博客靠谱
后面一辈子的博客都在这了!
Netty面试题
1. 为什么要用Netty
Netty是一个基于Java NIO封装的高性能网络通信框架。它主要有以下优势:
- Netty提供了比NIO更简单的API
- 很容易地实现Reactor模型
- Netty在NIO的基础上做出了很多优化
- 内存池
- 零拷贝
- Netty内置了多种通信协议
用官方的总结就是:Netty 成功地找到了一种在不妥协可维护性和性能的情况下实现易于开发,性能,稳定性和灵活性的方法。
2. Netty零拷贝
Netty零拷贝主要在五个方面:
- Netty默认情况下使用直接内存,避免了从JVM堆内存拷贝到直接内存这一次拷贝,而是直接从直接使用直接内存进行Socket读写
- Netty的文件传输调用了
FileRegion
包装的transferTo
方法,可以直接将文件从缓冲区发送到目标Channel - Netty提供了
CompositeByteBuf
类,可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了多个ByteBuf的拷贝。 - 通过
ByteBuf.wrap
方法,可以将byte[]数组、ByteBuffer包装成一个ByteBuf,从而避免了拷贝 ByteBuf
支持slice操作,可以将ByteBuf分解为多个共享同一存储区域的ByteBuf,避免了内存的拷贝
3. Netty内存管理
为了减少频繁向操作系统申请内存的情况,Netty会一次性申请一块较大的内存(由ChunkSize决定,默认为16M),这块内存被称为PoolChunk
。
而在一个Chunk
下,又分为了一个一个页,叫做Page
,默认为8K,即默认情况下一个Chunk有2048个页。
超详细图文详解神秘的 Netty 高性能内存管理 - 知乎 (zhihu.com)
3.1 PoolChunk如何管理Page
PoolChunk通过一个完全二叉树来管理Page,这颗二叉树的深度为12(2^11 = 2048)。
PoolChunk会维护一个memeoryMap
数组,这个数组对应着每个节点,它的值代表这个节点之下的第几层还存在未分配的节点。
- 比如说第9层的
memeoryMap
值为9,代表这个节点下面的子节点都未被分配 - 若第9层的
memeoryMap
为10,代表它本身不可被分配,但第10层有子节点可以被分配 - 若第9层的
memeoryMap
为12(树的高度),代表当前节点下的所有子节点都不可分配
那么我们怎么分配呢?
比如我们要15KB的空间,这里会先向上取8的整数,也就是16K,也就是2^1 * 8,拿到指数1,通过depth - 1 = 12 - 1得到11,那么我们只需要去找memeoryMap
为11的节点即可。在分配后,父节点的memeoryMap
等于两个子节点的最小值。
3.2 Page的管理
一个Page有8K,一般我们的应用程序是用不了这么多的,因此每个Page下会再次分隔。但这次分隔并不是以完全二叉树的形式,因为太占空间了,而是将这8K划分为等长的n份,一般会由PoolSubpage
管理,一般分为两类:
- tiny:用于分配小于512字节的内存,一般大小为16B,32B,…,496B,每次增长为16的倍数,共32个。
- small:用于分配大于等于512字节的内存,一般大小为512B、1K、2K,4K。
对于每个块,会有一个bitMap去判断是否使用,可以理解为Java中的BitSet
3.3 Chunk的管理
每个PoolChunk通过PoolArena
类来管理,这些Chunk被封装在PoolChunkList
类中,这是一个双向链表。
PoolArena
有6个PoolChunkList
:
- qInit:存储内存利用率 0-25% 的 chunk
- q000:存储内存利用率 1-50% 的 chunk
- q025:存储内存利用率 25-75% 的 chunk
- q050:存储内存利用率 50-100% 的 chunk
- q075:存储内存利用率 75-100%的 chunk
- q100:存储内存利用率 100%的 chunk
PoolArena
分配内存的顺序是:q050、q025、q000、qInit、q075
这样分配的好处是可以提高内存的利用率,以及减少链表的遍历次数。
3.4 PoolThreadCache
PoolThreadCache利用了ThreadLocal,每次线程在申请内存时都会优先从这里面获取。
- 在释放已分配的内存块时,不放回到 Chunk 中,而是缓存到 ThreadCache 中
- 在分配内存块时,优先从 ThreadCache 获取。若无法获取到,再从 Chunk 中分配
- 通过这样的方式,既能提高分配效率,又尽可能的避免多线程的同步和竞争
4. 直接内存回收原理
每个ByteBuf都实现了一个ReferenceCounted
接口,netty也是直接采用了引用计数法来进行内存回收。
5. 怎么判断ByteBuffer是否处于写模式或读模式
ByteBuffer
有三个重要参数:position
、limit
、capacity
,而平常我们说的读模式或写模式只是用来方便我们理解的东西,真正在ByteBuffer的实现里并不存在什么读模式和写模式,也就是说你在”读模式下”仍然可以写。
例如下面的代码:
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
byte[] hello = "hello".getBytes(StandardCharsets.UTF_8);
System.out.println(Arrays.toString(hello));
// "write mode"
byteBuffer.put(hello);
// "read mode"
byteBuffer.flip();
// write again
byteBuffer.put("h".getBytes(StandardCharsets.UTF_8));
while (byteBuffer.hasRemaining()) {
System.out.print(byteBuffer.get() + " ");
}
在”读模式”下去写的时候,并不会报错,由于切换到了”读模式”,此时position = 0,limit = 写模式的offset
,因此在写的时候,会从索引0处开始写,写完后,position
变为1,我们再读的话也就只能从索引1读到4了。
如果硬要判断是不是”读模式”或”写模式”,可以根据position
和limit
的值进行判断:
- 如
limit = capacity
,表示当前可能为写模式
MySql面试
1. B树和B+树之间的区别
B树有些博客上会写成B-树,部分博客甚至读成了B减树,其实这个减号只是一个连接符,没有任何意义
B-Tree Visualization (usfca.edu)
B+ Tree Visualization (usfca.edu)
B树和B+树的区别:
- B+树只会在叶子节点存储数据,而B树每个节点上都会有数据
- B+树每个叶子节点之间有一个指针乡相连
2. 高度为3的B+树能存多少条数据
MySQL系列(4)— InnoDB数据页结构 - 掘金 (juejin.cn)
在InnoDB中,索引默认使用的数据结构为B+树,而B+树里的每个节点都是一个页,默认的页大小为16KB
。
3. 简单说一下InnoDB事务实现原理
事务ACID:
名称 | 别名 | 说明 |
---|---|---|
Atomicity | 原子性 | 原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生 |
Consistency | 一致性 | 事务前后数据的完整性必须保持一致 |
Isolation | 隔离性 | 事务的隔离性是多个用户并发访问数据库时,数据库为每一个用户开启的事务,不能被其他事务的操作数据所干扰,多个并发事务之间要相互隔离 |
Durability | 持久性 | 持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响 |
一文了解InnoDB事务实现原理 - 知乎 (zhihu.com)
上面那个比较深入,下面这个比较好理解一些:
图解InnoDB事务实现原理|Redo Log&Undo Log - 掘金 (juejin.cn)
4.MySql的三大日志是哪些
MySQL三大日志(binlog,redolog,undolog)详解 - 掘金 (juejin.cn)
聊聊MVCC和Next-key Locks - 掘金 (juejin.cn)
5. MySql当前读和快照度
MySQL 的可重复读到底是怎么实现的?图解 ReadView 机制 - 知乎 (zhihu.com)
这里其实有个问题,如果我只是单独的一条查询语句,没有开启事务,那么怎么去快照读呢?
这个我自己查了一下,众所周知,MySql里有一个autocommit属性,对于单条SQL,这个值一定是true,那么是不是说明我们每条SQL都会被认作是一个事务呢?
然后我在官方文档里查了一下:
MySQL :: MySQL 5.7 Reference Manual :: 14.7.2.2 autocommit, Commit, and Rollback
MySQL :: MySQL 8.0 Reference Manual :: 15.7.2.2 autocommit, Commit, and Rollback
不管是8.0还是5.7,都是这样写的,那么就可以说的通了。每次执行单条SQL都会拿到一个事务id,然后再去进行快照读。
那么什么是当前读呢,使用下面的sql语句就是当前读:
# 加共享锁
SELECT ... LOCK IN SHARE MODE
# 加排它锁
SELECT ... FROM UPDATE
这两条语句的原理就是给对应的行加上共享锁(读锁)或排它锁(写锁),当有事务进行增删改时也会加排它锁,对于共享锁,允许多个事务持有(即允许多读),对于排它锁,则只允许一个事务持有(即只能一个人写,且除了自己其它人都不能读)。
在排它锁和共享锁下读的的数据就是当前读,这份数据永远是最新的(此时若有其它事务想要修改相关的行,都会被阻塞)。其它状况则就是快照读了,通过MVCC创建ReadView进行数据的读取。
6. MySql的MVCC
MVCC(Multiversion Concurrency Control)多版本并发控制。
首先在在MVCC下,每个表都会多出几个隐藏的列,分别为隐藏主键(row_id)、事务id(trx_id)、回滚指针(roll_pointer)。
MVCC还有两个重要的组成:undo log(回滚日志)、ReadView。
更详细的就不说了,因为上面的链接里面都有,主要是下面这四个关系:
(1)当【版本链中记录的 trx_id 等于当前事务id(trx_id = creator_trx_id)】时,说明版本链中的这个版本是当前事务修改的,所以该快照记录对当前事务可见。
(2)当【版本链中记录的 trx_id 小于活跃事务的最小id(trx_id < min_trx_id)】时,说明版本链中的这条记录已经提交了,所以该快照记录对当前事务可见。
(3)当【版本链中记录的 trx_id 大于下一个要分配的事务id(trx_id > max_trx_id)】时,该快照记录对当前事务不可见。
(4)当【版本链中记录的 trx_id 大于等于最小活跃事务id】且【版本链中记录的trx_id小于下一个要分配的事务id】(min_trx_id<= trx_id < max_trx_id)时,如果版本链中记录的 trx_id 在活跃事务id列表 m_ids 中,说明生成 ReadView 时,修改记录的事务还没提交,所以该快照记录对当前事务不可见;否则该快照记录对当前事务可见。
6.1 RepeatableRead是怎么实现的
我们都知道,RepeatableRead相比ReadCommited能够避免不可重复读的问题(实际也能够避免幻读,是通过加间隙锁实现的)。
首先我们来看ReadCommitted,使用mysql执行如下指令(假如我们叫它事务A)
set session transaction isolation level read committed;
begin;
update test set xid = 2 where id = 1;
# 等一会再提交
commit;
然后再开一个mysql执行如下指令(假如我们叫它事务B):
set session transaction isolation level read committed;
begin;
select * from test where id = 1;
# 提交上面那个指令后再执行下面这条
select * from test where id = 1;
这里就不放图了,大家都知道第二次读取会不一样。
这回我们再将隔离级别设置为RepeatableRead,并同样执行上面的指令。
这次执行后,发现两次查询的结果都是一样的,而且在事务A执行更新后且没有提交时,B再去读,并没有发生阻塞,因为在修改数据的时候会加排它锁,在读的时候要么是当前读要么是快照读,如果是当前读,那么读操作会堵塞,说明在B这里是快照读,是创建了ReadView的,通过ReadView有效地避免了不可重复读。
我们再用同样的方式去验证ReadCommited级别的读,发现同样是快照读,那么凭什么RepeatableRead不会读到新值,而ReadCommited会呢?
这里我画了一个流程图方便理解:
图画的可能不太好,不过应该能看懂
网上大部分人讲的都是以ReadCommited级别为例子的,即m_ids里的事务提交后可读,但其实在RepeatableRead隔离级别下是读不了的,只能走undo_log进行回滚。
这里可能有点错误,在ReadCommited下可以读已经提交的事务,所以如果trx_id大于等于mid_id,只需要判断对应的事务是否已经提交(或者trx_id指向自己)就能读
7. RepeatableRead真的不能避免幻读吗?
美团三面:一直追问我, MySQL 幻读被彻底解决了吗?_肥肥技术宅的博客-CSDN博客
8. 为什么bin_log不能用作崩溃后的恢复
redis面试
1. Redis字典
深入理解Redis 数据结构—字典 - 知乎 (zhihu.com)
可以这样理解:Redis的字典就是java7的HashMap,即哈希表+链表
Redis字典使用的哈希表结构如下:redis/dict.h at 2.6 · redis/redis (github.com)
typedef struct dictht {
// table 数组
dictEntry **table;
// 哈希表的大小
unsigned long size;
// 等于size-1,用于计算索引值, 这里说明size肯定是2的幂
unsigned long sizemask;
// 已有的键值对数量
unsigned long used;
} dictht;
dictEntry
就是哈希节点了:redis/dict.h at 2.6 · redis/redis (github.com)
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
Redis中的字典则由dict
组成:redis/dict.h at 2.6 · redis/redis (github.com)
typedef struct dict {
// 类型特定的函数,提供增删改查等功能
dictType *type;
// 私有函数
void *privdata;
// 哈希表, 这里的二维是后面用来扩容的
dictht ht[2];
// rehash 索引,记录了当前扩容的进度
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 用来记录当前运行的安全迭代器数,当不为0的时候表示有安全迭代器正在执行,这时候就会暂停rehash操作
int iterators; /* number of iterators currently running */
} dict;
2. Redis扩容与缩容
我们用ht[0].used/ht[0].size
表示负载因子
2.1 扩容
如果没有fork子进程在执行RDB或者AOF的持久化,一旦满足负载因子大于等于1,此时触发扩容;
如果有fork子进程在执行RDB或者AOF的持久化时,则需要满足负载因子大于5,此时触发扩容。
Redis在扩容时使用的是渐进式哈希,即每次值移动一部分的数据到新的哈希表中。
在字典dict
中,dict.ht[0]
代表旧的哈希表,dict.ht[1]
代表新的哈希表,每次扩容时会将容量乘2,同时dict.rehashidx
代表rehash的进度,表示dict.ht[0]
中,小于该索引的值都已经被移动到dict.ht[1]
中了,此时需要在dict.ht[1]
中进行相关的增删改查操作,反之则在dict.ht[0]
中进行。
在扩容期间,每次进行增删改查都会将dict.rehashidx
加一,并进行相关的rehash操作。
在扩容完毕后,将dict.ht[0]
指向dict.ht[1]
,并删除旧的哈希表。
2.2 缩容
当负载因子小于0.1时,Redis就会对哈希表进行收缩操作。
相关操作和扩容一样,在dict.ht[1]
处创建新的哈希表,之后再渐进式rehash。
2.3 其它问题
假如在rehash扩容的时候,我们一直插入,会不会导致再次扩容呢?
假设此时哈希表容量为n
,元素数量为n
,在扩容哈希表容量后变为2n
,而对于Redis来说,完成rehash需要2n - n = n
次操作,所以我们最多进行n
次插入,插入完后元素数量也变为2n
,再次触发扩容。
对于负载因子为5的时候,假设此时哈希表容量为n
,元素数量为5n + 1
,扩容后哈希表容量为2n
,同样我们可以插入n
个元素,此时元素数量变为6n + 1
,负载因子为(6n + 1) / 2n
约等于3,此时不会触发扩容。
3. 字典遍历
3.1 全遍历
使用如下指令就会执行全遍历,返回所有的key:
keys *
优点:
- 返回的key不会重复
缺点:
- 在遍历完前会阻塞服务器
迭代器结构:
typedef struct dictIterator {
dict *d; //迭代的字典
int index; //当前迭代到Hash表中哪个索引值
int table, safe; //table用于表示当前正在迭代的Hash表,即ht[0]与ht[1],safe用于表示当前创建的是否为安全迭代器
dictEntry *entry, *nextEntry;//当前节点,下一个节点
/* unsafe iterator fingerprint for misuse detection. */
long long fingerprint;//字典的指纹,当字典未发生改变时,该值不变,发生改变时则值也随着改变
} dictIterator;
3.3.1 安全迭代器和非安全迭代器
Redis源码学习——安全迭代器和非安全迭代器(一)_damanchen的博客-CSDN博客
Redis源码学习——安全迭代器和非安全迭代器(二)_damanchen的博客-CSDN博客
跋山涉水 —— 深入 Redis 字典遍历 - 知乎 (zhihu.com)
安全迭代器:
- 迭代的时候不能rehash,可以进行过期键的删除
非安全迭代器:
- 迭代的时候可以rehash,但是不能进行删除等操作(字典只读),通过
fingerprint
字段来判断字典是否发生变动
3.2 间接遍历
使用scan
命令可以间接遍历,这个命令每次会返回一个下一个需要遍历的索引值:
redis 127.0.0.1:6379> scan 0
1) "17"
2) 1) "key:12"
2) "key:8"
3) "key:4"
4) "key:14"
5) "key:16"
6) "key:17"
7) "key:15"
8) "key:10"
9) "key:3"
10) "key:7"
11) "key:1"
redis 127.0.0.1:6379> scan 17
1) "0"
2) 1) "key:5"
2) "key:18"
3) "key:0"
4) "key:2"
5) "key:19"
6) "key:13"
7) "key:6"
8) "key:9"
9) "key:11"
优点:
- 一次只返回部分内容,响应较快,不会较长时间阻塞服务器
缺点:
- 可能会返回重复的值
redis scan 命令底层原理(为什么会重复扫描?)_redis scan命令原理_柏油的博客-CSDN博客
这里第一次看可能会有这个疑问,我们打个比方:
遍历顺序:00 -> 10 -> 01 -> 11
若正好遍历到10时扩容完毕了,则新顺序为:
000 -> 100 -> 010 -> 110 -> 001 -> 101 -> 011 -> 111
此时我们在第三个位置,即010那里。
这时候可能就有疑问了:100那里不就遍历不到了吗?这不是丢数据了吗?
但这样其实是想多了,我们来看000和100,假如哈希表长度为4时,这两个索引下的元素会落到哪个哈希表下?
很明显,这两个位置的元素都会落到00这个索引的下面,因为哈希表长度为4时,索引位置的取法就是和0x11
做与操作,而000和100低两位相同,所以它们俩在之前就在00处,以链表的形式组合在了一起,当遍历到10时,100也肯定被遍历了。
总结一下就是scan
命令会在哈希表缩容的时候造成数据重复,在rehash的期间也会造成重复。
在rehash期间调用scan
,Redis会先扫小表,假如最终索引为v,然后会接着在大表中从v开始扫。
4. 五大基本数据类型
在看数据类型前,我们再回顾一下entry的结构:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
有没有发现一个问题:这个v代表值,那么这个值是个什么东西??
这里其实是C语言的union,可以让多个变量使用同一个内存空间:C/C++ union 使用教程 (常见操作与缺陷) - 知乎 (zhihu.com)
你可以这样理解:这里的v即有三种类型,即void*
、uint64_t
(64位无符号整数)、int64_t
(64位有符号整数)。
对应void*
你可以理解为Java中的Object
类型,用它做参数的话就可以传入任意对象,更详细的信息可以看这篇博客:void(指针)的类型转换-专讲_void转换_NeverLate_gogogo的博客-CSDN博客
一般情况下void*
都是指向redisObject
:redis/README.md at cb1717865804fdb0561e728d2f3a0a1138099d9d · redis/redis (github.com)
struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
};
type
:没啥好说的,每种数据结构的标识符encoding
:编码以string来说,就有三种:
int
,embstr
,raw
:127.0.0.1:6379> SET counter 1 OK 127.0.0.1:6379> OBJECT ENCODING counter "int" 127.0.0.1:6379> SET name "Tom" OK 127.0.0.1:6379> OBJECT ENCODING name "embstr" 127.0.0.1:6379> SETBIT bits 1 1 (integer) 0 127.0.0.1:6379> OBJECT ENCODING bits "raw"
lru
:给Redis做内存淘汰用refcount
:引用计数,这个值为0的时候这个对象会被清除ptr
:指向对象的实际表示,可能有多个指向同一个对象,一般还要配合encoding判断
4.1 String
Redis 的字符串是动态字符串,是可以修改的字符串,可以勉强理解为Java里的StringBuilder
。
当字符串需要扩容时,有如下两种情况:
- 当字符串长度小于 1M 时,扩容都是加倍现有的空间
- 超过 1M,扩容时一次只会多扩 1M 的空间
字符串最大长度为512MB。
数据结构:redis/sds.h at cb1717865804fdb0561e728d2f3a0a1138099d9d · redis/redis (github.com)
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
typedef char *sds;
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
我们可以发现,字符串结构体基本由len
(已使用的长度)、alloc
(最大长度/分配的长度)、flags
(标志信息)、buf
(字符串内容)组成。
字符串拼接:redis/sds.c at cb1717865804fdb0561e728d2f3a0a1138099d9d · redis/redis · GitHub
sds sdscatlen(sds s, const void *t, size_t len) {
size_t curlen = sdslen(s);
s = sdsMakeRoomFor(s,len);
// 内存不足
if (s == NULL) return NULL;
memcpy(s+curlen, t, len);
sdssetlen(s, curlen+len);
s[curlen+len] = '\0';
return s;
}
要懂redis,首先得看懂sds(全网最细节的sds讲解) - 知乎 (zhihu.com)
4.2 Hash
Hash类型有两种实现方式:
- ziplist 编码的哈希对象使用压缩列表作为底层实现
- hashtable 编码的哈希对象使用字典作为底层实现
Redis之Hash数据结构底层原理_redis hash原理_不要迷恋发哥的博客-CSDN博客
4.2.1 ziplist
ziplist
的运作方式类似于一个队列,当有一对键值时,先将值入队,再将键入队。
这种设计完全不符合哈希表的设计,所以只会在数据量较少时使用。
当发生如下情况时,ziplist
会被转换为真正的哈希表(字典):
- 当hash中的数据项的数目超过512的时候,也就是ziplist数据项超过1024的时候
- 当hash中插入的任意一个value的长度超过了64的时候
area |<---- ziplist header ---->|<----------- entries ------------->|<-end->|
size 4 bytes 4 bytes 2 bytes ? ? ? ? 1 byte
+---------+--------+-------+--------+--------+--------+--------+-------+
component | zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend |
+---------+--------+-------+--------+--------+--------+--------+-------+
^ ^ ^
address | | |
ZIPLIST_ENTRY_HEAD | ZIPLIST_ENTRY_END
|
ZIPLIST_ENTRY_TAIL
字段 | 类型 | 说明 |
---|---|---|
zlbytes | uint32_t | 整个ziplist占用的内存字节数。 |
zltail | uint32_t | 到达ziplist表尾节点的偏移量。 |
zllen | uint16_t | ziplist中节点的数量。 |
entryX | ? | ziplist的各个节点。 |
zlend | uint8_t | 常量0b111111,用于标记ziplist末尾。 |
每个entry
的结构是这样的:
+-----------------------+----------+---------+
| previous_entry_length | encoding | content |
+-----------------------+----------+---------+
previous_entry_length
:前面一个节点的长度(字节)。若前面一个节点的长度小于254字节,则该属性占1个字节的宽度,反正则是占5个字节的宽度,第一个字节是常量0xFE
(254)
encoding
:记录content的类型
content
:保存节点的值
我们可以发现ziplist
不能从头开始遍历,因为每个节点的长度都是不一样的,在遍历的时候需要根据zltail的值从尾部开始向前遍历。
4.2.2 hashtable
hashtable就和Redis最外层的字典是差不多的了。
4.3 List
对于List同样也有两种编码:
- ziplist:压缩列表
- linkedlist:双向链表
满足如下条件时,压缩列表会被转换为双向链表:
- 试图往列表新添加一个字符串值,且这个字符串的长度超过 server.list_max_ziplist_value (默认值为 64 )
- ziplist 包含的节点超过 server.list_max_ziplist_entries (默认值为 512 )
Redis列表list 底层原理 - 知乎 (zhihu.com)
4.4 Set
Set拥有两种编码:
- intset:使用数组维护set,数组是有序的
- hashtable:直接使用哈希表维护set
满足如下条件时intset将会被转换成hashtable:
- 保存了非整型的值
- 元素数量超过了512个
4.4.1 intset
数据结构:
typedef struct intset {
// 这个编码用来决定contents的大小
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
redis/intset.h at 971b177fa338fe06cb67a930c6e54467d29ec44f · redis/redis (github.com)
面试官:说说Redis中Set数据类型的底层实现 - 掘金 (juejin.cn)
4.5 zset(Sorted Set)
zset有两种实现:
- zipList:压缩列表
- skipList:跳表
满足如下条件时zipList会转换成skipList:
- 节点数量大于等于128(server.zset_max_ziplist_entries)
- 节点的长度大于等64(server.zset_max_ziplist_value)
Redis 跳跃表skiplist(深入理解,面试再也不用怕)_redis skiplist_妖四灵.Shuen的博客-CSDN博客
- header:指向跳跃表的表头节点
- tail:指向跳跃表的表尾节点
- level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)
- length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)
节点数据结构:
typedef struct zskiplistNode {
// 当前保存的值
sds ele;
// 分值,用于排序
double score;
// 前一个节点
struct zskiplistNode *backward;
// 当前层节点
struct zskiplistLevel {
struct zskiplistNode *forward;
// 跳表的跨度
unsigned long span;
} level[];
} zskiplistNode;
redis/server.h at 971b177fa338fe06cb67a930c6e54467d29ec44f · redis/redis (github.com)
// 跳表
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
// 跳表的长度
unsigned long length;
// 最高层数
int level;
} zskiplist;
// zset数据结构
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
5. Redis持久化方式
5.1 RDB
RDB即Redis Database,它会将Redis某一时刻的数据以文件的形式全量备份到磁盘。
Redis提供了两个指令来生成RDB,一个是SAVE,这个命令会阻塞主线程,直到RDB生成完毕。
另外一个则是BGSAVE,这时会fork一个子进程去专门负责写入RDB。
在读取数据时用到了写时复制(COW)技术,fork创建出的子进程,与父进程共享内存空间。也就是说,如果子进程不对内存空间进行写入操作的话,内存空间中的数据并不会复制给子进程,这样创建子进程的速度就很快了!
LInux fork的写时复制(copy on write)_fork写时复制_富士康质检员张全蛋的博客-CSDN博客
5.2 AOF
AOF即Append Only File,它会记录在redis服务器上执行过的命令来实现持久化的目的。
Redis详解(七)—— AOF 持久化 - YSOcean - 博客园 (cnblogs.com)
Redis默认每秒执行一次AOF的写入。
5.2.1 AOF重写
为了防止AOF文件过大,当AOF的文件大小超过设定的阈值后,Redis就会启动AOF的文件压缩。
压缩前:
sadd animals "cat"
sadd animals "dog"
sadd animals "cat"
sadd animals "lion"
压缩后:
sadd animals "cat" "dog" "lion"
6. 内存
6.1 内存过期策略
Redis对于过期的key有两种删除策略:
- 定期删除
- 惰性删除
6.1.1 定期删除
Redis会将每个设置了过期时间的key放到一个独立的字典中,以后会定期按照某种算法来遍历里面的key。
默认每秒进行10次扫描,每次会随机选取一些key,然后删除其中过期的key,若某次删除的数量超过了选取的1/4
,则会重复这一步骤。
6.1.2 惰性删除
在客户端访问某个key时,若这个key过期,则会将其删除。
6.2 内存淘汰策略
当没有可以被删除的key,且Redis内存不足时,此时会根据内存淘汰策略删除一些没有过期的key。
noeviction:当内存使用超过配置的时候会返回错误,不会驱逐任何键
allkeys-lru:加入键的时候,如果过限,首先通过LRU算法驱逐最久没有使用的键
volatile-lru:加入键的时候如果过限,首先从设置了过期时间的键集合中驱逐最久没有使用的键
allkeys-random:加入键的时候如果过限,从所有key随机删除
volatile-random:加入键的时候如果过限,从过期键的集合中随机驱逐
volatile-ttl:从配置了过期时间的键中驱逐马上就要过期的键
volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键
allkeys-lfu:从所有键中驱逐使用频率最少的键
关于LRU和LFU,在redisObject里会保存相关的参数:
struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
};
redis/README.md at cb1717865804fdb0561e728d2f3a0a1138099d9d · redis/redis (github.com)
- 当使用LRU算法时,
lru
整个值都用来表示相关访问时间。 - 当使用LFU算法时,低8位表示访问频率,高16位表示上一次访问时间
6.2.1 LRU
LRU即Least Recently Used-最近最少使用算法。
LRU会维护一个双向链表,对于新加入的数据或者最近被访问的数据,它们会被存/移动到链表头部,当内存不足的时候删除链表尾部的数据。
优点:
- 好实现
缺点:
- 冷数据可能会把热数据顶掉
6.2.2 LFU
LFU即Least Frequently Used-最不经常使用。其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。
Redis的LFU和上面的LFU有一些不一样,一般的LFU有如下缺点:
- 新增的缓存容易被删除
而Redis在此基础上会将每个数据的访问次数设置为5
,每次访问会根据其上次访问时间扣除一定的访问次数,然后再根据生成的一个随机数,决定是否对访问次数字段加一:
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
// 访问次数越多,加一的概率越小
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;
return counter;
}
其中server.lfu_log_factor
默认为10。
因为访问计数器的长度为8位,最大为255,如果每次访问都加一,很可能会导致溢出。
16 | LFU算法和其他算法相比有优势吗? (geekbang.org)
7. Redis集群
一文搞懂 Redis 的三种集群方案 - 腾讯云开发者社区-腾讯云 (tencent.com)
Redis支持三种集群方案:
- 主从复制
- 哨兵模式
- Cluster模式
7.1 主从复制
主从复制主要由一个主数据库与一个或多个从数据库实例组成。
客户端可对主数据库进行读写操作,对从数据库进行读操作,主数据库写入的数据会实时自动同步给从数据库。
具体工作机制为:
- slave启动后,向master发送SYNC命令,master接收到SYNC命令后通过bgsave保存快照,并使用缓冲区记录保存快照这段时间内执行的写命令
- master将保存的快照文件发送给slave,并继续记录执行的写命令
- slave接收到快照文件后,加载快照文件,载入数据
- master快照发送完后开始向slave发送缓冲区的写命令,slave接收命令并执行,完成复制初始化
- 此后master每次执行一个写命令都会同步发送给slave,保持master与slave之间数据的一致性
优点:
- master能自动将数据同步到slave,可以进行读写分离,分担master的读压力
- master、slave之间的同步是以非阻塞的方式进行的,同步期间,客户端仍然可以提交查询或更新请求
缺点:
难以支持在线扩容,Redis的容量受限于单机配置
master宕机,如果宕机前数据没有同步完,则切换IP后会存在数据不一致的问题
不具备自动容错与恢复功能,master或slave的宕机都可能导致客户端请求失败,需要等待机器重启或手动切换客户端IP才能恢复
7.2 哨兵模式
哨兵模式基于主从复制模式,只是引入了哨兵来监控与自动处理故障。
哨兵顾名思义,就是来为Redis集群站哨的,一旦发现问题能做出相应的应对处理。其功能包括:
- 监控master、slave是否正常运行
- 当master出现故障时,能自动将一个slave转换为master(大哥挂了,选一个小弟上位)
- 多个哨兵可以监控同一个Redis,哨兵之间也会自动监控
Redis哨兵(Sentinel)模式 - 简书 (jianshu.com)
优点:
- 哨兵模式基于主从复制模式,所以主从复制模式有的优点,哨兵模式也有
- 哨兵模式下,master挂掉可以自动进行切换,系统可用性更高
缺点:
- 同样也继承了主从模式难以在线扩容的缺点,Redis的容量受限于单机配置
- 需要额外的资源来启动sentinel进程,实现相对复杂一点,同时slave节点作为备份节点不提供服务
7.3 Cluster模式
Cluster采用无中心结构,它的特点如下:
- 所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽
- 节点的fail是通过集群中超过半数的节点检测失效时才生效
- 客户端与redis节点直连,不需要中间代理层.客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可
Cluster模式的具体工作机制:
- 在Redis的每个节点上,都有一个插槽(slot),取值范围为0-16383
- 当我们存取key的时候,Redis会根据CRC16的算法得出一个结果,然后把结果对16384求余数,这样每个key都会对应一个编号在0-16383之间的哈希槽,通过这个值,去找到对应的插槽所对应的节点,然后直接自动跳转到这个对应的节点上进行存取操作
- 为了保证高可用,Cluster模式也引入主从复制模式,一个主节点对应一个或者多个从节点,当主节点宕机的时候,就会启用从节点
- 当其它主节点ping一个主节点A时,如果半数以上的主节点与A通信超时,那么认为主节点A宕机了。如果主节点A和它的从节点都宕机了,那么该集群就无法再提供服务了
Cluster模式集群节点最小配置6个节点(3主3从,因为需要半数以上),其中主节点提供读写操作,从节点作为备用节点,不提供请求,只作为故障转移使用。
8. 分布式锁
springboot源码
1. 基础
通过AnnotationConfigApplicationContext
可以创建一个Spring容器:
public class MySpringApplication {
public static void main(String[] args) {
AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
UserService userService = (UserService) applicationContext.getBean("userService");
userService.test();
}
}
@ComponentScan("pers.xds.springboot")
public class AppConfig {
}
1.1 生命周期
可以在AbstractAutowireCapableBeanFactory#doCreateBean
中看到完整的bean生成流程。
大致分为如下过程:
1.对Bean进行实例化
2.依赖注入
3.如果Bean实现了BeanNameAware
接口,Spring将调用setBeanName()
,设置 Bean
的 id(xml文件中bean标签的id)
4.如果Bean实现了BeanFactoryAware
接口,Spring将调用setBeanFactory()
5.如果Bean实现了ApplicationContextAware
接口,Spring容器将调用setApplicationContext()
6.如果存在BeanPostProcessor
,Spring将调用它们的postProcessBeforeInitialization
(预初始化)方法,在Bean初始化前对其进行处理
7.如果Bean实现了InitializingBean
接口,Spring将调用它的afterPropertiesSet
方法,然后调用xml定义的 init-method方法(初始化),两个方法作用类似,都是在初始化 bean 的时候执行
8.如果存在BeanPostProcessor
,Spring将调用它们的postProcessAfterInitialization
(后初始化)方法,在Bean初始化后对其进行处理
9.Bean初始化完成,供应用使用,直到应用被销毁
10.如果Bean实现了DisposableBean
接口,Spring将调用它的destory
方法,然后调用在xml中定义的 destory-method
方法,这两个方法作用类似,都是在Bean实例销毁前执行。
1.2 BeanFactory和FactoryBean的区别
BeanFactory
:管理Bean的容器,Spring中生成的Bean都是由这个接口的实现来管理的。
FactoryBean
:让开发者以编程的方式来创建一个bean,一般用于创建比较复杂的bean。
1.3 Bean注入容器有哪些方式
1、使用@Configuration
与@Bean
注解
2、使用@Controller
、@Service
、@Repository
、@Component
注解标注该类,然后启用@ComponentScan
自动扫描
3、使用@Import
方法,使用@Import注解把bean导入到当前容器中。
1.4 Bean的作用域
1、singleton:单例,Spring中的bean默认都是单例的。
2、prototype:每次请求都会创建一个新的bean实例。
3、request:每一次HTTP请求都会产生一个新的bean,该bean仅在当前HTTP request内有效。
4、session:每一次HTTP请求都会产生一个新的bean,该bean仅在当前HTTP session内有效。
5、application:限定一个Bean的作用域为ServletContext
的生命周期。该作用域仅适用于web的Spring WebApplicationContext环境。
1.5 自动装配的方式
@Autowired
注解会优先根据类型来注入,当有多个bean时,会尝试根据变量名来注入(byname),如果没有找到就抛出异常。
可以通过@Qualifier
来指定要注入的bean的名称。
@Resource
注解会优先byname,找不到再byType。
1.6 @Bean和@Component的区别
@Bean
只能作用于方法上,表示这个方法会返回一个Bean,一般需要配合@Configuration
使用。
@Component
只能作用于类型上,表示这个类会作为组件类,并告诉Spring要为这个类创建bean。
1.6.1 @Bean必须在@Configuration里使用吗?
Spring: @Bean can still work without @Configuration - Stack Overflow
@Bean
在@Configuration
表示的类里使用时,Spring会为其自动创建一个动态代理对象,在同一个配置类中可以直接调用方法来获取Bean:
@Configuration
public class ExampleConfiguration {
@Bean
public Datasource datasource() {
BasicDatasource datasource = new BasicDatasource();
// ...
return datasource;
}
public PlatformTransactionManager transactionManager() {
// 注意这里是直接调用了方法,每次调用都会返回同一个bean,并不会多次创建
return new DataSourceTransactionManager(datasource());
}
}
而在非@Configuration
下定义的@Bean
会以Lite Mode运作,在该模式下调用其它@Bean
方法时,则是普通的方法调用(没有代理对象去拦截调用)。
1.7 Spring怎么解决循环依赖问题
对于构造器注入的循环依赖:Spring处理不了,直接抛出BeanCurrentlylnCreationException
异常。
非单例循环依赖:无法处理。
单例模式下属性注入的循环依赖会通过三级缓存处理循环依赖:
singletonObjects
:完成了初始化的单例对象map,bean name –> bean instance
earlySingletonObjects
:完成实例化未初始化的单例对象map,bean name –> bean instance
singletonFactories
: 单例对象工厂map,bean name –> ObjectFactory,存放 bean 工厂对象
具体的执行逻辑可以在DefaultSingletonBeanRegistry
中看到,这里贴出核心方法:
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null && this.isSingletonCurrentlyInCreation(beanName)) {
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null && allowEarlyReference) {
synchronized(this.singletonObjects) {
singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null) {
ObjectFactory<?> singletonFactory = (ObjectFactory)this.singletonFactories.get(beanName);
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject();
this.earlySingletonObjects.put(beanName, singletonObject);
this.singletonFactories.remove(beanName);
}
}
}
}
}
}
return singletonObject;
}
1.8 Spring的单例Bean是否有线程安全问题
当多个用户同时请求一个服务时,容器会给每一个请求分配一个线程,这时多个线程会并发执行该请求对应的业务逻辑,如果业务逻辑有对单例状态的修改(体现为此单例的成员属性),则必须考虑线程安全问题。
若每个线程中对全局变量、静态变量只有读操作,而无写操作,那么不会有线程安全问题;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则就可能影响线程安全。
无状态bean和有状态bean
- 有实例变量的bean,可以保存数据,是非线程安全的。
- 没有实例变量的对象。不能保存数据,是线程安全的。
在Spring中无状态的Bean适合用单例模式,这样可以共享实例提高性能。有状态的Bean在多线程环境下不安全,一般用Prototype模式或者使用ThreadLocal解决线程安全问题。
1.9 Spring容器的启动过程
阿里面试真题:Spring容器启动流程_spring启动流程面试题_敖 丙的博客-CSDN博客
2. AOP
常见的动态代理有两种:
JDK动态代理:基于Java反射机制实现,必须要实现了接口的业务类才生成代理对象。
CGLIB动态代理:基于ASM机制实现,通过生成业务类的子类作为代理类。
JDK Proxy的优势:
最小化依赖关系、代码实现简单、简化开发和维护、JDK原生支持,比CGLIB更加可靠,随JDK版本平滑升级。而字节码类库通常需要进行更新以保证在新版Java上能够使用。
CGLIB的优势:
无需实现接口,达到代理类无侵入,只操作关心的类,而不必为其他相关类增加工作量。高性能。
Java动态代理之一CGLIB详解 - 知乎 (zhihu.com)
3. Spring事务
可能是最漂亮的Spring事务管理详解 - 掘金 (juejin.cn)
4. MyBatis工作原理
一文搞懂Mybatis架构与工作原理 - 掘金 (juejin.cn)
加载映射文件(通过动态代理和xml为接口生成对应的代理类)
构造会话工程(SqlSessionFactory)
创建会话对象(SqlSession)
try (SqlSession session = sqlSessionFactory.openSession()) { BlogMapper mapper = session.getMapper(BlogMapper.class); Blog blog = mapper.selectBlog(101); }
Executor执行器
Mybatis会通过Executor去执行SQL语句。一般这里面会有缓存的实现。
MappedStatement对象
对映射信息的封装,它存储了一个SQL对应的所有信息。Mybatis 通过解析 XML 和 mapper 接口上的注解,生成 sql 对应的 MappedStatement 实例
输入参数映射
输出参数映射
将数据库输出转换为 Map
,List
或自定义的类型
计网常用知识总结
1. 网际协议IP
1.1 IPv4
- 版本:IPv4为4,IPv6即为6
- 首部长度:单位为4字节,因此头部最长为15个4字节,即60字节
- 区分服务(旧版叫服务类型):QoS分类和标记 - 知乎 (zhihu.com)
- 总长度:总长度包括首部和数据的长度。
- 标识:用于路由器分片重组。同一个分片的标志值相同,不同的分片的标识值不同。
- 标志:占3位,但只两位可以使用
- 最低位(可以理解为最右边):记为MF(More Fragment)。MF = 1标识后面还有分片。
- 中间位:记为DF(Don’t Fragment),意为不能分片,只有当DF = 0时才允许分片。
- 片偏移:较长的分组在分片后,某片在原分组中的相对位置,单位是8字节。
- 例如有一个数据有3800字节,被分为了1400字节、1400字节、1000字节(忽略了首部),则它们的片偏移分别为:
0/8 = 0
,1400 / 8 = 175
,2800 / 8 = 350
- 例如有一个数据有3800字节,被分为了1400字节、1400字节、1000字节(忽略了首部),则它们的片偏移分别为:
- 生存时间:一般称为TTL(Time To Live),标明数据报在网络中的寿命。最初的设计是以秒作为TTL的单位,每经过一个路由器就减掉响应的时间,到零时则会被丢弃。然而随着技术的进步,路由器处理数据报所需的时间不断在缩短,一般都远远小于1秒,后来就把TTL字段的功能改为“跳数限制”,即每经过一个路由器,就将该值减一,到零时就删除掉,最大值为255
- 协议:见下表
协议名 | ICMP | IGMP | IP | TCP | EGP | IGP | UDP | IPv6 | ESP | ICMP-IPv6 |
---|---|---|---|---|---|---|---|---|---|---|
字段值 | 1 | 2 | 4 | 6 | 8 | 9 | 17 | 41 | 50 | 58 |
- 首部校验和:这个字段只检验数据报的首部,但不包括数据部分。具体运算方式是把首部按16字节划分,然后按照反码算术相加得到。
- 源地址:发送IP数据报主机的IP地址
- 目的地址:占32位。接收IP数据报主机的IP地址
- 选项:由于选项很难背用到,而且也会增加路由器的开销,因此在IPv6中,IP数据报首部长度被改为固定值了
1.2 IPv6
版本:IPv6肯定就是6了
通信量类(Traffic Class):这是为了区分不同的IPv6数据报的类别或优先级,和IPv4的区分服务字段的作用相似。
流标号(Flow Label):IPv6的一个新的机制是支持资源预分配,并且允许路由器把每个数据报与一个给定的资源分配相联系。
有效荷载长度:它指明IPv6数据报除基本首部以为的字节数(所有扩展首部都算在有效荷载之内)。
下一个首部:它相当于IPv4的协议字段或可选字段。
跳数限制:用来防止数据报在网络中无期限的存在。
2. TCP
2.1 三次握手
深入浅出TCP三次握手 (多图详解) - 知乎 (zhihu.com)
主要是要记住客户端和服务端的状态:
- 客户端:CLOSED -> SYN-SENT -> ESTABLISHED
- 服务端:CLOSED -> SYN-RCVD -> ESTABLISHED
之后在每次通信时,下次消息的seq
为对方上次消息的ack
,下次消息的ack
为对方上次消息的seq + 总数据长度
(同一个seq
可能会有多个消息)
在握手时,虽然数据长度为0,在理解时需要将其”当做”长度为1。
2.2 四次挥手
TCP四次挥手详解_oOoOoOooOO的博客-CSDN博客
客户端和服务端的状态:
- 客户端(主动关闭的那一方):ESTABLISHED -> FIN_WAIT -> TIME_WAIT(等待2ms) -> CLOSE
- 服务端(响应关闭的那一方):ESTABLISHED -> CLOSE_WAIT -> LAST_ACK -> CLOSE
这里在主动关闭的那一方需要等待2MSL,原因如下:
首先占用该端口,因为IP报文在网络中的生存时间是有限的,让旧的报文全部在网络中被丢弃
若主动关闭的那一方的ACK没有被服务端收到,此时服务端再次返回一个FIN报文,会被客户端错误的以为是一个错误报文而导致状态重置(发送RST报文)
TCP的TIME_WAIT状态 - 知乎 (zhihu.com)
2.3 TCP报文格式
其中可能比较难记的就是8个标志位的前两个了,其实只需要记住全称就不容易忘了:
- CWR:Congestion Window Reduce
- ECE:ECN Echo,全称Explicit Congestin Notification Echo
里面还有个校验和,这里需要知道是怎么算的。在计算机网络第八版P218中介绍了UDP的检验和计算:
- 首先在数据报前添加一个伪首部
- 将校验和的位置置零
- 以16位2个字节为单位,如果由于数据的长度导致无法满足该条件,则在数据部分后补零即可。之后将所有数据相加,如果溢出,则添加到低位(二进制反码运算求和)
- 将结果取反添加到校验和的位置
在验证时同样使用上面的方法,但不用把校验位置零,若计算结果最终为全1,则表示数据无误
TCP/UDP伪头部详解_tcp 伪头部_M、k的博客-CSDN博客
需要注意的是IP报文的校验不包含数据。
3. 路由器和交换机
交换机是工作在数据链路层的,它根据每台设备的MAC地址来转发数据。
而路由器是工作在网络层的,它负责IP报文的转发。